# 创建超级块

如果说普通按键专注于“触发一个操作”，那么**超级块（Widget）**则为你提供了无限的视觉与交互可能——它可以是实时跳动的数字时钟、绚丽的媒体音乐控制器、硬件温度仪表盘，或是支持直接点击计数的交互卡片。

超级块采用了优雅的**双层解耦架构**：
- **前端展示层（HTML / CSS / JS）**：负责在桌面端与移动设备屏幕上渲染华丽的 UI，监听用户触控与点击；
- **后台逻辑层（Node.js）**：负责保存核心数据、处理复杂运算，并通过状态通道将最新结果广播回页面。

在本指南中，我们将以经典的“多实例计数器”为例：即使将该计数器同时放置到画布上的两个不同位置，它们各自也能保持完全独立的计数与配置。

---

## 1. 声明超级块与实例状态（manifest.json）

首先在清单中注册超级块的入口页面、默认网格尺寸以及它所依赖的数据来源：



```json
{
  "apiVersion": 1,
  "id": "example.counter",
  "name": "独立计数超级块",
  "version": "1.0.0",
  "description": "独立计数超级块教学示例",
  "hostPlatforms": [
    "windows",
    "macos",
    "linux"
  ],
  "main": "index.js",
  "permissions": [],
  "contributes": {
    "stateSources": [
      {
        "id": "counter.value",
        "title": "当前计数",
        "scope": "widget-instance",
        "visibility": "screen",
        "widgetId": "counter",
        "readAccess": {
          "workflows": true,
          "widgets": true
        },
        "writeAccess": "owner-plugin",
        "lifetime": "placement",
        "schema": {
          "type": "object",
          "properties": {
            "count": {
              "type": "integer",
              "minimum": 0,
              "description": "本实例在本次插件运行中的点击次数",
              "descriptionKey": "stateSources.counter.value.schema.properties.count.description"
            }
          },
          "required": [
            "count"
          ],
          "additionalProperties": false
        },
        "titleKey": "stateSources.counter.value.title"
      }
    ],
    "widgets": [
      {
        "id": "counter",
        "title": "计数器",
        "page": "pages/counter/index.html",
        "configPage": "settings.html",
        "defaultSize": {
          "cols": 1,
          "rows": 1
        },
        "allowedSizes": [
          {
            "cols": 1,
            "rows": 1
          },
          {
            "cols": 2,
            "rows": 1
          }
        ],
        "stateSource": "counter.value",
        "instanceActions": [
          {
            "id": "increment",
            "title": "增加一次",
            "argsSchema": {
              "type": "object",
              "properties": {},
              "required": [],
              "additionalProperties": false
            },
            "titleKey": "widgets.counter.instanceActions.increment.title"
          }
        ],
        "settingsSchema": {
          "properties": {
            "label": {
              "type": "string",
              "title": "标签",
              "default": "点击计数",
              "titleKey": "widgets.counter.settingsSchema.properties.label.title"
            },
            "step": {
              "type": "integer",
              "title": "步进",
              "minimum": 1,
              "maximum": 10,
              "default": 1,
              "titleKey": "widgets.counter.settingsSchema.properties.step.title"
            }
          }
        },
        "titleKey": "widgets.counter.title"
      }
    ]
  },
  "i18n": {
    "defaultLocale": "zh-CN",
    "resources": {
      "zh-CN": "locales/zh-CN.json",
      "en-US": "locales/en-US.json"
    }
  },
  "nameKey": "plugin.name",
  "descriptionKey": "plugin.description"
}
```



**核心配置解读：**
- **`widgets`**：指定超级块的唯一标识、显示名称、主页面路径（`page`）以及在控制台默认占用的网格大小（如 `1×1` 或 `2×2`）。
- **`stateSource`**：绑定当前超级块所监听的数据源。请注意，这里的数据源作用域必须声明为 `widget-instance`（实例级），这样不同放置位置的计数器才能互不干扰，各自拥有独立的数据状态。
- **`instanceActions`**：声明前端页面可以向后台提交的交互动作。这里声明了 `increment` 操作，用于响应用户的点击自增。

---

## 2. 编写前端展示页面（index.html）

超级块的主页面是标准的 Web 页面。我们只需引入飞块官方提供的轻量级页面 SDK，即可轻松订阅数据流：



```html
<!doctype html>
<html lang="zh-CN"><head><script src="/api/plugin-sdk/i18n-v1.js"></script>
  <meta charset="utf-8" /><meta
    name="viewport"
    content="width=device-width,initial-scale=1"
  />
  <title data-i18n='widgets.counter.title'>计数器</title
  ><style>
    * { box-sizing: border-box; }
    html, body { width: 100%; height: 100%; overflow: hidden; }
    body {
      margin: 0;
      background: #17212b;
      color: #eaf5fa;
      font-family: system-ui;
    }
    button {
      width: 100%;
      height: 100%;
      display: grid;
      grid-template-rows: 18px minmax(0, 1fr);
      padding: 10px;
      gap: 4px;
      text-align: center;
      border: 0;
      background: transparent;
      color: inherit;
      cursor: pointer;
    }
    strong {
      display: block;
      min-width: 0;
      align-self: center;
      overflow: hidden;
      text-overflow: ellipsis;
      font-size: clamp(24px, min(15vw, calc(100vh - 52px)), 80px);
      line-height: 1;
    }
    span {
      min-width: 0;
      font-size: 12px;
      line-height: 18px;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
    }
  </style>
  <button id="counter">
    <span data-i18n='messages.pages.counter.index.click-count' id="label">点击计数</span><strong id="count">—</strong>
  </button>
  <script src="/api/plugin-sdk/widget-page-v1.js"></script>
  <script>var tr = (message, values = []) => window.FlyonDeckI18n?.t(message, values) ?? message.replace(/\{(\d+)\}/g, (token, key) => values[key] === undefined ? token : String(values[key]));

    const sdk = window.FlyonDeckWidget;
    const unsubscribe = sdk.onState((state, context) => {
      document.querySelector("#label").textContent =
        context.settings.label || tr("messages.pages.counter.index.click-count");
      document.querySelector("#count").textContent =
        state?.count ?? "—";
    });
    document.querySelector("#counter").addEventListener("click", () => {
      if (sdk.getContext().mode === "live") sdk.invokeAction("increment");
    });
    window.addEventListener("pagehide", unsubscribe, { once: true });
  </script>
</html>
```



**前端交互逻辑解析：**
1. **监听状态更新（`onState`）**：每当后台广播新数据或用户修改了组件设置时，`onState(state, context)` 会被即时调用。
   - `state.count`：读取后台同步过来的当前计数值；
   - `context.settings`：读取用户在编辑器中为当前实例设置的自定义标签文本。
   - **初次加载兜底**：后台数据尚未到达时，示例显示 `—`，不能把空状态当成真实 `0`。需要保证首屏为当前业务值时，使用[首屏主动读取模式](./data-services.md#超级块首屏读取)。
2. **派发用户操作（`invokeAction`）**：当用户在屏幕上点击按钮时，调用 `FlyonDeckWidget.invokeAction('increment')` 将操作委派给后台。
   - > ⚠️ **单向数据流原则**：`invokeAction` 仅负责提交指令，前端不需要也不应该手动去直接修改界面数字。界面的改变由后台收到操作、更新数据并通过下一次状态广播统一驱动，确保多端视图始终绝对一致。

---

## 3. 在后台处理点击交互（index.js）

在插件后台逻辑中，我们需要针对特定的实例更新其数据：



```js
const { t: tr } = require(process.env.FLYONDECK_SDK_PATH || "@flyondeck/plugin-sdk");
"use strict";
const { definePlugin } = require(process.env.FLYONDECK_SDK_PATH);

const counts = new Map();
const key = (i) => JSON.stringify([i.deviceId, i.screenId, i.blockId]);
let timer;
let stopped = false;
let inFlight;
async function reconcile(context) {
  const instances = await context.widgets.instances("counter");
  if (stopped) return;
  const active = new Set(instances.map(key));
  for (const id of counts.keys()) if (!active.has(id)) counts.delete(id);
  for (const instance of instances) {
    if (stopped) return;
    const id = key(instance);
    if (!counts.has(id)) {
      counts.set(id, 0);
      await context.states.publish(
        "counter.value",
        { count: 0 },
        {
          deviceId: instance.deviceId,
          screenId: instance.screenId,
          blockId: instance.blockId,
        },
      );
    }
  }
}
definePlugin({
  async onStart(context) {
    stopped = false;
    await reconcile(context);
    timer = setInterval(() => {
      if (inFlight || stopped) return;
      inFlight = reconcile(context)
        .catch((error) => console.warn(tr("messages.index.counter-instance-sync-failed"), error.message))
        .finally(() => {
          inFlight = undefined;
        });
    }, 1000);
  },
  async onStop() {
    stopped = true;
    clearInterval(timer);
    await inFlight;
    counts.clear();
  },
  widgetActions: {
    counter: {
      async increment(instance, context) {
        const placed = (await context.widgets.instances("counter")).find(
          (i) => key(i) === key(instance),
        );
        if (!placed) throw new Error(tr("messages.index.counter-removed-add-it-again"));
        const id = key(instance);
        const count = (counts.get(id) || 0) + (placed.settings.step || 1);
        counts.set(id, count);
        await context.states.publish(
          "counter.value",
          { count },
          {
            deviceId: instance.deviceId,
            screenId: instance.screenId,
            blockId: instance.blockId,
          },
        );
      },
    },
  },
});
```



**后台核心机制：**
- **精准定位实例**：后台通过三元组 `(deviceId, screenId, blockId)` 唯一锁定当前触发操作的具体组件实例。自增后，仅向该实例定向发布最新数值，绝不会意外影响放置在其它屏幕或位置的组件。
- **动态生命周期同步**：通过调用 [context.widgets.instances()](../reference/widgets-api.md) 查询当前所有已放置的实例，在插件启动时完成初始化，并在用户移除组件时自动清理无效的内存状态。

示例每秒检查实例增删，并非每秒重新发布所有实例状态。实际插件若对相同快照去重，需同时提供 `requestData()` 首屏读取路径，避免页面晚于最后一次发布创建时一直等待下一次业务操作；详见[超级块首屏读取](./data-services.md#超级块首屏读取)。

---

## 4. 提供自定义配置面板

用户往往希望为每个超级块个性化设置不同的标题、步长或主题色。

在清单的 `settingsSchema` 中声明配置结构后，飞块能自动为你生成精美的设置表单；而如果你需要更具特色的个性化表单，也可以像示例一样通过 `configPage` 提供专属的设置页面：



```html
<!doctype html>
<html lang="zh-CN"><head><script src="/api/plugin-sdk/i18n-v1.js"></script>
  <meta charset="utf-8" /><title data-i18n='messages.pages.counter.settings.counter-settings'>计数配置</title>
  <style>
    body {
      font: 14px system-ui;
      color: #eaf5fa;
      background: #17212b;
      padding: 16px;
    }
    label {
      display: block;
      margin: 12px 0;
    }
    input {
      padding: 8px;
      background: #223342;
      color: inherit;
      border: 1px solid #4a6474;
      border-radius: 6px;
    }
  </style>
  <label><span data-i18n='widgets.counter.settingsSchema.properties.label.title'>标签 </span><input id="label" maxlength="256" /></label
  ><label><span data-i18n='widgets.counter.settingsSchema.properties.step.title'>步进 </span><input id="step" type="number" min="1" max="10" /></label>
  <script src="/api/plugin-sdk/widget-config-page-v1.js"></script>
  <script>var tr = (message, values = []) => window.FlyonDeckI18n?.t(message, values) ?? message.replace(/\{(\d+)\}/g, (token, key) => values[key] === undefined ? token : String(values[key]));

    const sdk = window.FlyonDeckWidgetConfig;
    const unsubscribe = sdk.onSettings((settings) => {
      document.querySelector("#label").value = settings.label || tr("messages.pages.counter.index.click-count");
      document.querySelector("#step").value = settings.step || 1;
    });
    document
      .querySelector("#label")
      .addEventListener("change", (e) => sdk.update({ label: e.target.value }));
    document.querySelector("#step").addEventListener("change", (e) => {
      const step = Number(e.target.value);
      if (Number.isInteger(step) && step >= 1 && step <= 10)
        sdk.update({ step });
    });
    window.addEventListener("pagehide", unsubscribe, { once: true });
  </script>
</html>
```



- **`onSettings()`**：配置页面加载时触发，读取用户当前正在编辑的配置草稿。
- **`update()`**：当用户在自定义表单中修改输入时，调用该方法向宿主提交变更。最终在用户点击飞块编辑器的“保存”按钮后，配置会正式持久化到该实例上。

> 📚 **深入参考**：字段定义见 [WidgetSettings 参考](../reference/settings.md)，页面 SDK 方法见 [FlyonDeckWidgetConfig 参考](../reference/config-page.md)。

---

## 5. 最佳实践指南

为了让你的超级块在各种屏幕、分辨率和宿主环境中都呈现出工业级水准的视觉与交互体验，请仔细阅读以下设计规范：

### 🎯 最佳实践一：桌面端与移动端数据同步

> **核心原则：**  
> **优先让桌面编辑器中的超级块与移动端展示同一份真实数据。只有在因设备专属能力限制确实无法取得数据时，才展示明确标注“示例”的内容。**

在飞块中，宿主向桌面目录卡片和已放置组件的布局预览传入 `context.mode === 'preview'`，而向实际设备页面传入 `live`。请特别注意：**`mode` 仅用于描述宿主的展示环境，绝不能用来粗暴判定“是否存在真实数据”！**

桌面端已放置的组件实例完全具备接收后台状态的能力，也完全可以通过 `requestData()` 正常拉取数据。请遵循以下标准流程：

1. **优先渲染真实状态**：无论处于桌面预览还是真机环境，只要收到后台广播的 state，均走统一的渲染逻辑。检查状态时请使用严格的 `null` / `undefined` 判断，切勿因判断条件不当把数值 `0`、布尔值 `false` 或空字符串误判为“无数据”。
2. **优雅处理过渡态**：当首份数据还在网络传输或计算中时，请展示轻量加载动画或骨架屏；若已有实例需要主动拉取，可适时调用 `requestData`。如果请求失败，请展示友好的错误提示或带时间戳的上次历史数据，而不是闪烁白屏。
3. **审慎使用“示例”兜底**：仅当组件尚未放置在画布上（如在插件市场或组件选择目录中，确实缺少实例 ID 和配置）时，才展示演示用的模拟数据，并**务必醒目标注“示例”字样**。当真实数据到达后，应平滑切回。
4. **展示与交互分离**：在桌面布局编辑模式下，即便处于“禁止点击触发业务”的预览阶段，也可以保持实时数据的刷新展示。切勿仅仅因为当前处于编辑预览状态，就把鲜活的真实数据粗暴替换成虚假的示例数据。

---

### 🎨 最佳实践二：外轮廓圆角与几何裁剪

> **核心原则：**  
> **让宿主负责裁剪超级块的外轮廓，插件页面只需负责铺满背景。**

桌面端组件卡片、画布网格和移动设备都有宿主统一规划的高精细度圆角容器。由于超级块运行在隔离的 `iframe` 沙箱中，**无法**通过 `border-radius: inherit` 或父级 CSS 变量继承宿主圆角。切勿通过 CSS 继承或按屏幕短边比例（例如 `20vmin`）盲猜外层圆角！

#### 1. 基础页面骨架（避免双重边缘）
在 `html`、`body` 或铺满界面的根按钮上，**切勿设置额外的圆角、边距、外阴影或溢出裁剪**。否则一旦与宿主外轮廓发生微小偏差，就会露底穿帮，形成丑陋的双层边框或缺角。

推荐的基础样式骨架如下：

```html
<style>
  * { box-sizing: border-box; }
  html, body {
    margin: 0;
    width: 100%;
    height: 100%;
    overflow: hidden;
    background: #14161d; /* 与插件主色调一致的纯色背景 */
  }
  .widget-root {
    display: block;
    width: 100%;
    height: 100%;
    margin: 0;
    padding: 0;
    border: 0;
    border-radius: 0; /* 外轮廓圆角完全交给宿主容器裁剪 */
    background: #14161d;
    color: #f1f2f6;
  }
</style>
<button class="widget-root" aria-label="计时器">10:00</button>
```

#### 2. 内缩环形与 SVG 几何对齐
如果你的超级块内部需要绘制一个与外边框严格平行的进度环或卡片边框，请使用页面 SDK 提供的宿主几何参数动态计算：

页面 SDK 的 `context.surface` 提供了宿主当前的精确几何信息：
- `insetX` / `insetY`：宿主边界内缩距离；
- `cornerRadiusX` / `cornerRadiusY`：宿主外轮廓圆角半径。
（数值均为 iframe 内部未变换的标准 CSS 像素，详见 [WidgetSurfaceGeometry 参考](../reference/page-sdk.md#widgetsurfacegeometry)）。

假设内缩环形到宿主边界的距离为 $d$，则：
- 内轮廓起始坐标为：$(insetX + d,\; insetY + d)$
- 内轮廓水平与垂直半径为：$\max(0,\; cornerRadiusX - d)$ 与 $\max(0,\; cornerRadiusY - d)$

这样计算出的内外轮廓圆心将完全重合，呈现出完美的同心圆角过渡。

**SVG 动态适配代码样例：**

```javascript
FlyonDeckWidget.onState((state, context) => {
  const surface = context.surface;
  if (!surface) return; // 首次有效快照到达前做好防空保护

  const width = document.documentElement.clientWidth;
  const height = document.documentElement.clientHeight;
  const d = 8; // 描边中心线距宿主裁剪边界的期望距离

  const rect = document.querySelector("#progress-track");
  rect.setAttribute("x", surface.insetX + d);
  rect.setAttribute("y", surface.insetY + d);
  rect.setAttribute("width", Math.max(0, width - 2 * (surface.insetX + d)));
  rect.setAttribute("height", Math.max(0, height - 2 * (surface.insetY + d)));
  rect.setAttribute("rx", Math.max(0, surface.cornerRadiusX - d));
  rect.setAttribute("ry", Math.max(0, surface.cornerRadiusY - d));
});
```

#### 3. 跨场景渲染检查清单
在发布前，请务必在以下真实场景中验证你的超级块 UI：
- [ ] **多形态适配**：分别在组件选择目录、桌面端画布预览及真机实时屏幕中测试；
- [ ] **多规格与缩放**：检查 `1×1` 标准块、横向长条块以及缩放场景下的排版是否发生溢出；
- [ ] **高对比度环境**：在浅色与深色两种宿主主题下，确认背景与文字对比度均清晰可读；
- [ ] **各状态全覆盖**：验证空数据加载态、正常运行态、警告提醒态及断线重连态的视觉表现。


### 最佳实践三：响应式尺寸与内容预算

使用 iframe 实际内容区的宽高计算有限的字号与间距比例，并先为头部、主体和操作区分配空间。空间不足时按优先级收起内容。完整公式、当前网格与页面尺寸对照、可运行布局骨架和验证方法见[超级块响应式布局最佳实践](./responsive-widgets.md)。
