1. 完整示例
插件化手册
  • FH2 插件开发手册
    • 产品简介
    • 典型场景
    • 发版说明
    • 快速开始
      • 快速开始总览
      • 第一个插件
      • 后端插件
      • 全栈闭环
    • AI辅助开发
      • AI 开发 Skill
      • 安装与复验
      • 第一个工程
      • 真实需求示例:现场问题上报
      • 改造已有工程
      • 调试与交付
    • 开发指南
      • 开发指南
      • 基础概念
        • 基础概念总览
        • 什么是插件开发
        • 平台架构
        • 前端宿主、扩展点与能力
        • 后端运行时、入口与权限
        • 开发流程
        • 安全与隔离
        • 支持范围与问题反馈
      • 开发指南
        • 开发指南总览
        • 前端插件
          • 前端插件深度指南
          • 前端工程结构
          • 插件清单(fh2-plugin.json)
          • 入口与生命周期
          • 界面与状态
          • 多宿主插件
          • 跨宿主通信服务
          • HTTP、WebSocket 与事件
          • 存储、Blob 与定时器
          • 工具链与本地调试页面
        • 后端插件
          • 后端开发深度指南
          • 后端插件的结构
          • 后端源清单 fh2-plugin.json
          • HTTP 接口处理函数
          • 消息订阅与事件处理函数
          • ctx 与能力注入
          • 数据存储与缓存
          • 流程编排
          • 触发方式
          • 连接外部系统
          • 流程编排练习(DAG)
        • 前后端协作
          • 前后端协作总览
          • 前后端共享契约
          • 端到端幂等
          • 事件、通知与状态
          • 统一错误模型
          • 链路标识与诊断日志
        • 完整练习
          • 环境准备
          • 第一个前端插件
          • 第一个后端插件
          • 第一个全栈插件
      • 能力总览
        • 能力地图
        • 能力可用性矩阵
        • 前端能力
          • 平台通用能力
            • 平台通用能力总览
            • 基础运行时:HTTP、事件、WebSocket 与存储
            • 导航、位置与通知
            • 弹窗、音频与剪贴板
            • 地图能力
            • 项目设备能力
            • WPMZ 与航线预览
            • 受控对象上传
          • 项目宿主
            • 项目宿主总览
            • 项目上下文
            • 项目侧边栏入口
            • 项目插件路由
            • 项目工作区
          • 消息宿主
            • 消息宿主总览
            • 消息告警弹层
            • 消息导航与通知
          • 计划库宿主
            • 计划库宿主总览
            • 计划库表单上下文
            • 计划库表单扩展
            • 校验、提交与 `biz_ext`
          • 驾驶舱
            • 驾驶舱插件能力总览
            • 驾驶舱扩展点
            • 上下文与遥测
            • 相机、检测与 AI
            • 场景几何与直播指针
            • 飞控、云台与任务
            • 驾驶舱平台事件
        • 后端能力
          • 后端能力地图
          • 后端能力总览
          • 配置、受管出网与集成连接
          • 事件与流程
          • 飞行任务与违停巡逻
          • 数据存储
          • 媒体与前端通知
          • 资源租约
          • 作业、目标轨迹与视频处理
      • 调试与测试
        • 调试与测试总览
        • 快速入门排错
        • 本地开发
        • Web IDE:打开工程与保存改动
        • 工作流编辑器:节点、接线与交付
        • 知识与 MCP:先判断需要哪一层事实
        • 前端本地调试
        • 后端运行时与流程调试
        • 模拟数据与测试数据
        • 集成测试与 E2E
        • 日志与链路追踪
        • 常见问题排查
      • 完整示例
        • 示例与案例总览
        • 项目侧边栏与工作区
        • 消息告警卡片
        • 计划表单任务清单
        • 驾驶舱动作与左侧面板
        • 最小后端插件
        • 最小全栈插件
        • 多宿主插件实战
        • 事故快处
        • 交通抓拍
        • 违停巡逻完整案例
      • 构建发布与安全
        • 构建、发布与安全
        • 质量门禁
        • 行业包构建与打包
        • 能力版本发布
        • 安装、升级与回滚
        • 安全基线
      • API参考
        • API 参考总览
        • 前端
          • 前端 API 参考
          • 前端插件清单结构
          • 前端 SDK API
          • 前端扩展点目录
          • 前端能力目录
          • 前端平台事件目录
          • 跨宿主通信服务 API
          • 工具链命令行参考(`fh2-plugin`)
        • 后端
          • 后端 API 参考
          • 后端插件源清单
          • 后端 HTTP 接口参考
          • 后端事件消费参考
          • 后端脚本上下文(`ctx`)
          • 后端数据存储
          • 流程编排语言
          • 流程触发器参考
          • 后端能力目录
          • 后端 HTTP API
        • 共享契约
          • 共享契约参考
          • 类型与契约目录
          • 错误模型
          • 版本兼容性
      • 附录
        • 错误码
        • 术语表
        • 常见问题
  1. 完整示例

最小全栈插件

这个示例分为 可运行示例(界面/HTTP)+ 集成示例(媒体事件):驾驶舱面板上报"现场问题",通过后端接口存储并流转状态(new → acknowledged → resolved);媒体事件部分讲解如何把照片挂到问题上,需要目标环境验证。本文适用 Toolchain / SDK 1.0.0。
最短路径:先按“怎么在本地跑界面/HTTP”跑通;再看“关键代码讲解”改自己的字段;媒体事件和正式运行按“支持条件与边界”另验。
fh2-plugin-sdk 是插件开发 SDK;模块路径示意,实际导入保留生成工程配置。本页不表示同名 npm 包已发布。已有文件保留真实导入语句,仅替换业务函数与类型;新文件参照生成工程中相应 API 的真实导入路径,不添加模块别名。测试中的 fh2-plugin-toolchain 同样是工具链逻辑模块名,保留生成测试的真实导入。

这个示例做什么#

前端(驾驶舱左侧面板):列出问题、上报新问题、签收、完成。
后端(4 个 HTTP 接口):用平台数据存储读写 issue 实体,状态推进用条件更新防并发;本地使用内存存储,无需数据库。
媒体事件集成示例:消费 media.photo_created,意图是把照片挂到最新一条"已签收"的问题上;正式消息接入、输入投影、排序和数据库迁移在目标环境另验。
本地支持界面、HTTP、数字错误信封和去重/状态推进;照片、列表容量与 HTTP 状态差异的边界见下表及媒体集成示例。
范围支持条件与边界
界面/HTTP工具链与模板已就绪,保持 dev 运行;重置为空数据后从单问题流程开始,调用由 ctx.apiCall 自动处理鉴权。数据仅在当前进程内,重置、重启或实体结构变化会丢失本轮写入
列表全状态查询按创建时间倒序,最多返回 20 条;本地模拟器支持该对象式排序,但本示例未提供加载后续历史的分页界面
媒体集成示例本地事件包装与正式消费输入不同,不能完成照片挂载验收;正式输入投影、排序和消息路由需目标环境验证
正式运行后端包安装、SQL 迁移、网关鉴权和事件配置均是部署前置条件;本地调试不替代这些验收

关键代码讲解#

工程结构#

用能力工程模板创建。注意两级同名目录:外层是工作区,内层才是能力工程;前后端是不同的插件 ID:
exp_issue_demo/                           # 工作区根,以下命令均在这里运行
├── package.json
├── packages.json
└── exp_issue_demo/                       # 能力工程目录
    ├── capabilities.json
    ├── frontend/exp_issue_demo_frontend/
    │   ├── fh2-plugin.json
    │   ├── public/icons/issue-board.svg
    │   └── src/
    │       ├── plugin.ts
    │       ├── shared/backend.ts
    │       └── views/issue-panel/{contract.ts,index.ts,IssuePanel.vue}
    ├── frontend/exp_issue_demo_issues/    # 生成的另一示例,本页保留不改
    ├── backend/exp_issue_demo_backend/
    │   ├── fh2-plugin.json
    │   ├── schema.sql
    │   ├── handlers/issue/{create.ts,list.ts,acknowledge.ts,resolve.ts}
    │   ├── events/photo-created.ts
    │   ├── fixtures/photo-created.body.json # 正式消息体示意,不用于本地模拟器成功验收
    │   ├── types/                       # generate 生成,勿手改
    │   └── tsconfig.json
    └── mock/
        ├── fixtures/default.json
        └── events/photo-created.json    # 独立本地注入信封,当前消费仍受阻

后端源清单:声明权限和事件订阅#

下文文件路径都以工作区根为起点。exp_issue_demo/backend/exp_issue_demo_backend/fh2-plugin.json:
{
  "pluginId": "exp_issue_demo_backend",
  "name": "现场问题示例后端",
  "version": "0.1.0",
  "allowlist": ["data:read", "data:write"],
  "flows": [],
  "connections": [],
  "scripts": [],
  "events": [
    {
      "type": "media.photo_created",
      "consumer": [ { "handler": "events/photo-created.ts", "entry": "onPhotoCreated" } ]
    }
  ]
}
allowlist 是权限声明:申报了 data:read / data:write,脚本里才有全局对象 store 可用。
接口不用在清单里登记,放 handlers/ 目录就会被按路径发现。
存储不写清单:数据表由 schema.sql 单点定义,工具链从它派生存储实体。
配套的建表脚本(title 上的唯一索引是 createIfAbsent 去重的前提):
SQL 物理列使用 created_at / updated_at;正式 JS store 返回的公开时间字段是 createdAt / updatedAt,筛选和排序也使用这两个公开名。下面的 orderBy 必须写 createdAt,直接使用 SQL 列名会违反公开字段契约并返回 UNKNOWN_FIELD。本地模拟器同样返回公开平台列并支持对象式 orderBy;原始状态快照仍保留内部存储行,不要把快照字段当作脚本接口。目标环境的真实存储仍需复验。创建和更新时间由平台维护,不写进创建/更新的业务记录;建表语句保留物理列名。
不要把原始快照里的物理列名复制进业务查询。store.query 的返回、筛选及对象式排序均使用公开字段;工具升级后按环境准备步骤重装工程依赖,旧进程不会自动采用修复。

后端接口#

// exp_issue_demo/backend/exp_issue_demo_backend/handlers/issue/create.ts
import type { HandlerCtx } from 'fh2-plugin-sdk/backend'

interface RecordInput { readonly [key: string]: unknown }

export function create (input: RecordInput, _ctx: HandlerCtx) {
  const title = String(input.title || '').trim()
  if (!title) {
    throw { code: 'INVALID_ARGUMENT', message: 'title 不能为空' }
  }
  const severity = ['low', 'medium', 'high'].indexOf(String(input.severity)) >= 0 ? String(input.severity) : 'medium'

  // 按标题去重:重复提交不会产生第二条记录,返回已有记录
  // created_at 由平台自动维护,不要自己写(它是保留字段)
  const saved = store.createIfAbsent('issue', {
    title: title,
    description: String(input.description || ''),
    severity: severity,
    status: 'new',
    evidence_file_id: ''
  }, ['title'])

  if (!saved.ok) {
    throw saved.error
  }
  return { id: saved.data.record.id, created: saved.data.created }
}
// exp_issue_demo/backend/exp_issue_demo_backend/handlers/issue/list.ts
import type { HandlerCtx } from 'fh2-plugin-sdk/backend'

interface RecordInput { readonly [key: string]: unknown }

export function list (input: RecordInput, _ctx: HandlerCtx) {
  // 不传状态时保留全部状态,使已签收记录仍可在界面点完成
  const status = String(input.status || '').trim()
  const result = store.query('issue', status ? { status: { eq: status } } : {},
    { orderBy: [{ field: 'createdAt', dir: 'desc' }], limit: 20 })
  if (!result.ok) {
    throw result.error
  }
  return { list: result.data.list, total: result.data.total }
}
列表约定支持边界
全状态、limit: 20不传状态时包含已完成历史,使已签收的同一问题可继续完成;最多返回 20 条,不自动加载后续历史
本地历史数据本地支持对象式 orderBy;最新 20 条记录可见,更早历史仍需分页。创建时间相同的记录用 ID 确定顺序,不能依赖接口完成先后
单问题验收先重置为空数据,再验证上报、签收、完成;正式业务按需求实现分页并在目标环境复验排序,不改用字符串排序
// exp_issue_demo/backend/exp_issue_demo_backend/handlers/issue/acknowledge.ts
import type { HandlerCtx } from 'fh2-plugin-sdk/backend'

interface RecordInput { readonly [key: string]: unknown }

export function acknowledge (input: RecordInput, _ctx: HandlerCtx) {
  const id = String(input.id || '').trim()
  if (!id) {
    throw { code: 'INVALID_ARGUMENT', message: 'id 不能为空' }
  }

  // 条件更新:只有还是 new 才允许签收,affected === 1 才算成功
  const updated = store.update('issue',
    { id: { eq: id }, status: { eq: 'new' } },
    { status: 'acknowledged' })
  if (!updated.ok) {
    throw updated.error
  }
  if (updated.data.affected === 0) {
    throw { code: 'NOT_FOUND', message: '问题不存在或已被签收' }
  }
  return { id: id, status: 'acknowledged' }
}
// exp_issue_demo/backend/exp_issue_demo_backend/handlers/issue/resolve.ts
import type { HandlerCtx } from 'fh2-plugin-sdk/backend'

interface RecordInput { readonly [key: string]: unknown }

export function resolve (input: RecordInput, _ctx: HandlerCtx) {
  const id = String(input.id || '').trim()
  if (!id) {
    throw { code: 'INVALID_ARGUMENT', message: 'id 不能为空' }
  }
  const updated = store.update('issue',
    { id: { eq: id }, status: { eq: 'acknowledged' } },
    { status: 'resolved' })
  if (!updated.ok) {
    throw updated.error
  }
  if (updated.data.affected === 0) {
    throw { code: 'NOT_FOUND', message: '问题不存在或当前状态不可完成' }
  }
  return { id: id, status: 'resolved' }
}

后端事件消费#

这部分是媒体事件集成示例,不是本地成功验收路径。以下 file_id 位于正式消息体的消费输入上;上线前还需核对目标后端运行时的消费声明、输入投影与事件路由,见事件消费。
// exp_issue_demo/backend/exp_issue_demo_backend/events/photo-created.ts
import type { EventCtx } from 'fh2-plugin-sdk/backend'

interface RecordInput { readonly [key: string]: unknown }

export function onPhotoCreated (input: RecordInput, _ctx: EventCtx) {
  const fileId = String(input.file_id || '').trim()
  if (!fileId) {
    // 契约错误:消息进死信,不重投
    throw { code: 'INVALID_ARGUMENT', message: 'file_id 不能为空' }
  }

  // 找最新一条已签收的问题
  const found = store.query('issue', { status: { eq: 'acknowledged' } },
    { orderBy: [{ field: 'createdAt', dir: 'desc' }], limit: 1 })
  if (!found.ok) {
    throw found.error
  }
  const issue = found.data.list[0]
  if (issue === undefined) {
    // 没有进行中的问题:消息按正常处理,不挂载
    return { attached: false }
  }

  // 更新条件里带旧值(evidence_file_id 为空):消息重复投递时第二次 affected=0,不会重复覆盖
  const updated = store.update('issue',
    { id: { eq: issue.id }, evidence_file_id: { eq: '' } },
    { evidence_file_id: fileId })
  if (!updated.ok) {
    throw updated.error
  }
  if (updated.data.affected === 0) {
    // 已经绑过照片(重复投递),幂等命中
    return { attached: false, issue_id: issue.id }
  }
  return { attached: true, issue_id: issue.id }
}

前端:统一调用封装#

// exp_issue_demo/frontend/exp_issue_demo_frontend/src/shared/backend.ts
import { isPluginError } from 'fh2-plugin-sdk'
import type { JsonValue, PluginContext } from 'fh2-plugin-sdk'

// 注意:这里填后端清单里的 pluginId,不是前端的 id
const BACKEND_PLUGIN_ID = 'exp_issue_demo_backend'

export async function invoke<T> (
  ctx: Pick<PluginContext, 'apiCall'>,
  handler: string,
  input: Record<string, JsonValue>,
  idempotencyKey?: string,
): Promise<T> {
  try {
    const res = await ctx.apiCall<{ code: number; message: string; data: JsonValue }>('/plugin/invoke', {
      method: 'POST',
      body: { pluginId: BACKEND_PLUGIN_ID, handler, input },
      ...(idempotencyKey ? { idempotencyKey } : {}),
    })
    // 只有网关返回 code === 0 才会走到这里
    return res.data as T
  } catch (error) {
    if (isPluginError(error) && error.code === 'PLUGIN_GATEWAY_ERROR') {
      // 本地与目标网关的非零数字码在 detail.gatewayCode;网络或格式错误可能没有该字段
      const detail = (error.detail ?? {}) as { gatewayCode?: number; gatewayMessage?: string }
      throw new Error(`[网关 ${detail.gatewayCode ?? '?'}] ${detail.gatewayMessage ?? error.message}`)
    }
    throw error
  }
}

前端:面板状态与操作#

// exp_issue_demo/frontend/exp_issue_demo_frontend/src/plugin.ts
import { createViewState, definePlugin } from 'fh2-plugin-sdk'
import { issuePanelView } from './views/issue-panel/contract'
import { invoke } from './shared/backend'

export interface IssuePanelState {
  readonly list: Array<{ id: string; title: string; severity: string; status: string }>;
  readonly busy: boolean;
  readonly error: string;
}

export default definePlugin({
  async activate (ctx) {
    if (ctx.host.id !== 'cockpit')
      return

    const state = createViewState<IssuePanelState>({ list: [], busy: false, error: '' })

    async function loadRecords () {
      const data = await invoke<{ list: IssuePanelState['list'] }>(ctx, 'issue.list', {})
      state.update(current => ({ ...current, list: data.list }))
    }

    async function runAction (action: () => Promise<void>): Promise<void> {
      state.update(current => ({ ...current, busy: true, error: '' }))
      try {
        await action()
      } catch (error) {
        state.update(current => ({ ...current, error: error instanceof Error ? error.message : '请求失败' }))
      } finally {
        state.update(current => ({ ...current, busy: false }))
      }
    }

    await issuePanelView.handle(ctx, {
      state,
      methods: {
        refresh: () => runAction(loadRecords),
        createIssue: (input: { title: string; description: string; severity: string }) => runAction(async () => {
          const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(JSON.stringify([input.title, input.description])))
          const key = Array.from(new Uint8Array(hash), byte => byte.toString(16).padStart(2, '0')).join('')
          await invoke(ctx, 'issue.create', input, `issue-create:${key}`)
          await loadRecords()
        }),
        acknowledge: (input: { id: string }) => runAction(async () => {
          await invoke(ctx, 'issue.acknowledge', { id: input.id }, `issue-ack:${input.id}`)
          await loadRecords()
        }),
        resolve: (input: { id: string }) => runAction(async () => {
          await invoke(ctx, 'issue.resolve', { id: input.id }, `issue-resolve:${input.id}`)
          await loadRecords()
        }),
      },
    })

    await ctx.extensions.contribute('issue-panel', {
      visible: true,
      label: '现场问题',
      icon: 'issue-board', // 图标必须在插件清单 contributes.icons 里声明,否则渲染为空
      render: () => issuePanelView.get(ctx),
    })
  },
})
相同标题、描述按固定数组顺序生成相同 SHA-256 摘要,重试得到相同请求键,无随机值。摘要是 64 个十六进制 ASCII 字符,issue-create:<摘要> 固定为 77 字符,满足 apiCall 的 1–256 字符限额,不因中文 URL 编码膨胀而超限。业务输入不截断;按标题去重仍由后端唯一索引决定。此实现依赖浏览器 TextEncoder / crypto.subtle,需本地回环地址或 HTTPS 安全上下文。摘要不等同于完整的生产重试/请求键设计,更严格的约束见端到端幂等。

前端请求失败单测#

新建 exp_issue_demo/frontend/exp_issue_demo_frontend/tests/requests.test.ts。刷新、签收和完成失败时都要显示错误并退出忙碌状态,不能让 Vue 点击事件收到未处理拒绝:
import { describe, expect, it, vi } from 'vitest'
import type { PluginContext } from 'fh2-plugin-sdk'
import plugin from '../src/plugin'

const captured = vi.hoisted(() => ({ session: undefined as any }))
vi.mock('../src/views/issue-panel/contract', () => ({ issuePanelView: {
  handle: async (_ctx: unknown, session: unknown) => {
    captured.session = session
  },
  get: () => ({}),
} }))

describe('请求失败反馈', () => {
  it('刷新、签收和完成失败均写入状态并退出忙碌,不向 Vue 事件抛未处理拒绝', async () => {
    const apiCall = vi.fn().mockRejectedValue(new Error('请求失败'))
    await plugin.activate({ host: { id: 'cockpit' }, apiCall, extensions: { contribute: vi.fn().mockResolvedValue({}) } } as unknown as PluginContext)
    await expect(captured.session.methods.refresh()).resolves.toBeUndefined()
    expect(captured.session.state.get()).toMatchObject({ busy: false, error: '请求失败' })
    await expect(captured.session.methods.acknowledge({ id: 'test' })).resolves.toBeUndefined()
    await expect(captured.session.methods.resolve({ id: 'test' })).resolves.toBeUndefined()
    expect(captured.session.state.get()).toMatchObject({ busy: false, error: '请求失败' })
  })
})

前端:完整清单与图标#

替换 exp_issue_demo/frontend/exp_issue_demo_frontend/fh2-plugin.json。issue-panel 必须同时有界面和扩展声明,HTTP 必须申报 platform:http。这里保留生成的 hello 源码及对应声明;本页新入口不调用 hello 激活,不会展示它的按钮。
{
  "id": "exp_issue_demo_frontend",
  "name": "现场问题示例",
  "version": "0.1.0",
  "entry": "./plugin.js",
  "permissions": ["platform:http"],
  "extensions": [
    { "id": "hello-action", "point": "cockpit.ui.toolbar-actions" },
    { "id": "hello-panel", "point": "cockpit.ui.left-panel" },
    { "id": "issue-panel", "point": "cockpit.ui.left-panel" }
  ],
  "views": {
    "hello-panel": { "entry": "./views/hello-panel.js" },
    "issue-panel": { "entry": "./views/issue-panel.js" }
  },
  "contributes": {
    "icons": [{ "id": "issue-board", "src": "./icons/issue-board.svg" }]
  }
}
新建 exp_issue_demo/frontend/exp_issue_demo_frontend/public/icons/issue-board.svg,不要仅声明一个不存在的图标路径:
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="5" y="3" width="14" height="18" rx="2"/><path d="M9 8h6M9 12h6M9 16h4"/></svg>

前端:界面三件套#

这个界面只显示接口返回的最多 20 条,不会自动加载后续历史。以下界面示例以重置后的空数据、单问题流程为验收前提,不保证在任意数量历史记录下新问题都可见。
以下三个文件放在 exp_issue_demo/frontend/exp_issue_demo_frontend/src/views/issue-panel/,不是另一个 _issues 插件目录。状态类型由本页 src/plugin.ts 导出,契约只做类型引用,不把入口运行逻辑引入 Vue。
contract.ts:
import { definePluginView } from 'fh2-plugin-sdk/view'
import type { PluginViewClient } from 'fh2-plugin-sdk/view'
import type { IssuePanelState } from '../../plugin'

interface IssuePanelContract {
  state: IssuePanelState;
  methods: {
    refresh: () => Promise<void>;
    createIssue: (input: { title: string; description: string; severity: string }) => Promise<void>;
    acknowledge: (input: { id: string }) => Promise<void>;
    resolve: (input: { id: string }) => Promise<void>;
  };
}

export const issuePanelView = definePluginView<IssuePanelContract>('issue-panel')
export type IssuePanelClient = PluginViewClient<IssuePanelContract>
index.ts:
import { createApp } from 'vue'
import { defineView } from 'fh2-plugin-sdk/view'
import { issuePanelView } from './contract'
import IssuePanel from './IssuePanel.vue'

export default defineView({
  mount (ctx) {
    const client = issuePanelView.client(ctx.port)
    const app = createApp(IssuePanel, { client })
    app.mount(ctx.mountElement)
    return {
      dispose () {
        app.unmount()
        client.dispose()
      },
    }
  },
})
IssuePanel.vue:
<template>
  <section class="issue-panel" data-plugin-view="issue-panel">
    <h2>现场问题</h2>
    <form @submit.prevent="submit">
      <input v-model="title" aria-label="问题标题" placeholder="问题标题">
      <input v-model="description" aria-label="问题描述" placeholder="问题描述">
      <select v-model="severity" aria-label="严重程度">
        <option value="low">
          低
        </option>
        <option value="medium">
          中
        </option>
        <option value="high">
          高
        </option>
      </select>
      <button type="submit" :disabled="state.busy">
        上报问题
      </button>
      <button type="button" @click="client.refresh()">
        刷新列表
      </button>
    </form>
    <p v-if="state.error" class="error" role="alert">
      {{ state.error }}
    </p>
    <p v-if="state.list.length === 0" class="empty">
      暂无现场问题
    </p>
    <ul>
      <li v-for="issue in state.list" :key="issue.id" :data-issue-id="issue.id" :data-issue-status="issue.status">
        <strong>{{ issue.title }}</strong>
        <small>{{ statusLabel[issue.status] ?? issue.status }}</small>
        <button v-if="issue.status === 'new'" type="button" @click="client.acknowledge({ id: issue.id })">
          签收
        </button>
        <button v-if="issue.status === 'acknowledged'" type="button" @click="client.resolve({ id: issue.id })">
          完成
        </button>
      </li>
    </ul>
  </section>
</template>

<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import type { IssuePanelState } from '../../plugin'
import type { IssuePanelClient } from './contract'

const props = defineProps<{ client: IssuePanelClient }>()
const client = props.client
const state = ref<IssuePanelState>({ list: [], busy: false, error: '' })
const title = ref('')
const description = ref('')
const severity = ref('medium')
const statusLabel: Record<string, string> = { new: '待签收', acknowledged: '处理中', resolved: '已完成' }
const subscription = client.state.subscribe((next) => {
  state.value = next
})
onUnmounted(() => subscription.dispose())
onMounted(() => {
  void client.refresh()
})

function submit () {
  void client.createIssue({ title: title.value.trim(), description: description.value.trim(), severity: severity.value })
}
</script>

<style scoped>
.issue-panel { box-sizing: border-box; width: 100%; padding: 16px; color: #fff; background: #1f1f1f; font: 13px/1.5 "PingFang SC", sans-serif; }
h2 { margin: 0 0 12px; font-size: 16px; }
form { display: flex; flex-wrap: wrap; gap: 8px; }
input, select { box-sizing: border-box; min-width: 0; width: 100%; padding: 6px 8px; color: #fff; background: #2c2c2c; border: 1px solid #555; border-radius: 4px; }
button { padding: 6px 10px; color: #fff; background: #0075ff; border: 0; border-radius: 4px; cursor: pointer; }
button:disabled { opacity: .5; cursor: default; }
ul { padding: 0; list-style: none; }
li { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px 0; border-bottom: 1px solid #444; }
li strong { overflow-wrap: anywhere; }
small, .empty { color: #aaa; }
.error { color: #ff8a92; }
</style>

前端:更新模板契约测试#

模板的 tests/contract.test.ts 针对原 hello/左下角 issue 示例,不能继续断言本页左侧面板使用对话框权限与旧图标。保留生成测试的真实导入语句,按本页清单更新断言,不修改工具链测试:
import { readdirSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { loadManifest } from 'fh2-plugin-toolchain'

const manifest = loadManifest(resolve('fh2-plugin.json'))

describe('现场问题插件契约', () => {
  it('清单界面与源目录一致', () => {
    const directories = readdirSync(resolve('src/views'), { withFileTypes: true })
      .filter(entry => entry.isDirectory())
      .map(entry => entry.name)
      .sort()
    expect(Object.keys(manifest.views ?? {}).sort()).toEqual(directories)
  })

  it('声明左侧面板、HTTP 权限和实际图标', () => {
    expect(manifest.extensions).toContainEqual({ id: 'issue-panel', point: 'cockpit.ui.left-panel' })
    expect(manifest.views).toHaveProperty('issue-panel.entry', './views/issue-panel.js')
    expect(manifest.permissions).toContain('platform:http')
    expect(manifest.contributes?.icons).toContainEqual({ id: 'issue-board', src: './icons/issue-board.svg' })
    expect(manifest).not.toHaveProperty('manifestVersion')
  })
})

媒体事件集成示例:两种样例与阻塞#

正式消息体示意:exp_issue_demo/backend/exp_issue_demo_backend/fixtures/photo-created.body.json。它不是本地注入成功样例,实际消息主题、消费声明和 file_id 输入投影以目标环境为准:
{
  "event_id": "photo-event-001",
  "file_id": "photo-demo-001"
}
当前本地模拟器的注入接口使用独立信封,保存为 exp_issue_demo/mock/events/photo-created.json:
{
  "id": "photo-event-001",
  "type": "media.photo_created",
  "data": { "file_id": "photo-demo-001" }
}
媒体验证项本地模拟器边界目标环境要求
消费输入消费函数收到完整 { id, type, occurredAt, data } 包装,正式代码读取 input.file_id 时会报 file_id 不能为空;仅改 JSON 为 data 信封不能完成验收从消息体投影消费输入,保留 input.file_id,核对消费声明与路由,不添加 input.data 或双形状兼容分支
最近一条本地支持对象式 orderBy;按 createdAt 倒序选一条,消息输入仍须另外接入保留插件 SDK 的对象式排序及 createdAt 字段,验证最近优先,不用本地模拟器的字符串排序替代
重复投递受消息输入边界限制,不属于本地可运行示例验收验证空证据条件更新和重复投递不覆盖;确认照片后再完成问题

怎么在本地跑界面/HTTP#

在确认的业务父目录运行创建向导:选择「0=完整 Capability」,工作区目录输入 exp_issue_demo,成员 ID、展示名和包管理器按默认值确认,并选择立即安装。命令只要求终端能直接运行 fh2-plugin;未注册全局 CLI 时,把它替换为开发者工具目录中 packages/fh2-plugin-toolchain/bin/fh2-plugin.mjs 的实际路径:
在该工作区根完成以下操作,路径都带内层 exp_issue_demo/ 前缀:
后端 exp_issue_demo/backend/exp_issue_demo_backend/:替换清单、schema.sql,新建四个 handlers/issue/*.ts 和 events/photo-created.ts。新文件沿用生成接口处理函数、事件处理函数的真实插件 SDK 导入路径,再填本页类型与函数体。移除模板 handlers/example/、events/example-created.ts、adapters/example-script.ts、shared/{records,store,time,validation}.ts 和 workflows/example-flow.json;保留 tsconfig.json、工具链生成的 types/,不要只改清单留下旧接口。
前端 exp_issue_demo/frontend/exp_issue_demo_frontend/:替换清单,保留 src/plugin.ts、tests/contract.test.ts 的真实导入路径并更新业务代码;新建 src/shared/backend.ts、issue-panel 三件套和 SVG,插件 SDK 导入参照生成工程,业务模块使用本页相对路径。保留生成的 src/hosts/cockpit/、src/views/hello-panel/ 及清单中的 hello 声明,让未使用模板模块仍能通过类型检查;本页入口不激活 hello。
保留整个 exp_issue_demo/frontend/exp_issue_demo_issues/ 插件,不把本页界面写到它里面;保留工作区依赖/构建配置、packages.json、能力声明。另一个 _issues 插件是原示例,本页测试定位顶部左侧的"现场问题"入口。
在能力工程的 exp_issue_demo/mock/ 中移除旧 events/example-created.json,按上节新建独立照片事件信封。默认测试数据替换为下方内容;正式消息体示意另存 backend/.../fixtures/,不要混入本地模拟器成功验收。
exp_issue_demo/mock/fixtures/default.json:
{ "entities": { "issue": [] } }
以上只精确替换示例业务文件,不删除工作区或修改插件 SDK、插件运行时和工具链配置。完成后从工作区根执行:
build / pack 只生成产物,不监听端口。dev 为本地能力工程启动插件运行时和本地 HTTP 模拟器;下面的界面及回环辅助接口验证都需保持该服务运行。

界面/HTTP 验收#

开始前先重置为空数据:使用本页末尾的 /plugin-api/mock/backend/reset 命令,在面板点"刷新列表",确认"暂无现场问题",再从一个问题的流程开始。列表最多 20 条且包含已完成记录;列表按创建时间倒序;最新 20 条之外的历史没有分页入口。此处可运行示例验收不代表不限历史的业务界面已可用。
1.
打开 dev 打印的本地调试页面地址,从默认团队页的设备卡打开设备小窗,再进入虚拟驾驶舱新页签。
2.
点击驾驶舱顶部左侧的"现场问题"面板入口。visible: true 表示入口可见,不保证自动展开界面;不要误点另一个 _issues 插件的 AI 前工具栏入口。
3.
输入问题标题、描述和严重程度,点"上报问题",列表显示"待签收";重复同标题,仍为一条。
4.
点"签收",同一条保留在列表,显示"处理中",可以继续点"完成"变为"已完成"。这是本地界面/HTTP 验收,没有照片也应能完成。
5.
空标题上报时,本地 HTTP 400 / 数字 40006,面板显示 [网关 40006] INVALID_ARGUMENT: title 不能为空,而不是白屏。ctx.apiCall 抛出的错误是 PLUGIN_GATEWAY_ERROR,数字码在 detail.gatewayCode,不是普通失败返回值。
保持同一 dev 进程、未重置且实体结构不变时,刷新页面后点「刷新列表」仍可读回本轮问题;重置或重启后本轮数据会丢失,这不是持久化验收。本地数字分类不改变 HTTP 差异:NOT_FOUND 是 HTTP 404 / 20001,未分类 BRIDGE_FAILED 是 HTTP 400 / 50301,不能只按 HTTP 400 判断业务分类。目标网关与真实存储另验,见后端调试。

接入接口服务后的 curl 诊断#

首次验证使用上方界面的 ctx.apiCall 受管通道,不需要手动获取凭据。以下 curl 仅是接入已启用调试服务或部署环境之后的诊断示意:目标服务需已加载本页后端,PLUGIN_API_ORIGIN 由实际接口服务接入配置提供,PLUGIN_TOKEN 由插件网关按目标鉴权流程提供。仅完成 build 不会使接口可用。
示例按同源服务的标准 /plugin-api/plugin/invoke 路径组织;服务源地址不含路径,非本机使用 HTTPS。实际 API 基础路径、服务选择和鉴权配置以目标服务接入说明为准;直接后端服务的 Bearer 访问凭据与插件令牌不可互换。没有独立诊断凭据时,使用上方界面的受管调用,或按接入说明完成授权后再运行 curl。

媒体事件验收(当前本地受阻)#

目标验证顺序应为:新建 → 签收 → 投递照片 → 确认证据 → 完成。不要先完成再投照片;消费函数只查询 acknowledged,记录已 resolved 时不会挂载。目标环境仍需安装后端包、执行 schema.sql 并完成正式事件配置与输入投影。
保持本机 dev 运行时,下面命令从工作区根读取能力工程级信封,仅用于诊断上表的本地输入边界,不是通过照片验收的步骤。这里的无需令牌的回环辅助接口不用于部署环境:
当前预期是 HTTP 400 / 数字 40006,文案保留 INVALID_ARGUMENT: file_id 不能为空,不是照片已写入。数字错误信封不修复消费输入形状。查看快照可检查界面/HTTP 状态;不要在本地勾选 evidence_file_id 成功验收:
{
  "code": 40006,
  "message": "INVALID_ARGUMENT: file_id 不能为空",
  "data": null
}
需要回到初始数据时重置:

常见问题#

现象原因与处理
本地 HTTP 404 / code: 40006,文案含 PLUGIN_NOT_FOUND 或 HANDLER_NOT_FOUNDpluginId 写成了前端的 id,或接口名不匹配;body.pluginId 必须是后端清单的 pluginId
脚本里 store is not defined清单 allowlist 没申报 data:read / data:write;缺申报的对象根本不会注入
创建报契约错误要求幂等键脚本用了 ctx.stableId;调用方必须带 Idempotency-Key
重复上报出现两条记录唯一键设计或 createIfAbsent 用法问题;核对 uniqueBy 字段
本地事件报 file_id 不能为空当前本地模拟器传包装信封,正式消费函数读消息体字段;仅改 JSON 不能解决,不把模拟器专用的 input.data 写进生产代码
目标事件没挂上照片核对正式输入投影含 file_id,且记录仍是 acknowledged;先投照片再完成
本地照片选到旧记录核对筛选与 createdAt 对象式排序,确认实际运行工具已更新;真实媒体输入仍在目标环境验收,不用字符串兼容分支
本地通过,目标环境未通过按HTTP 真实环境预览使用完整项目页 URL,配置 PAC、同源登录、手填本次资源根并完成宿主校验/权限同意/重载;只替换前端,后端包、SQL 与事件配置仍须在目标就绪
改了模拟测试数据,状态没变注入事件或重置后再看;内存存储不会自动重读修改

延伸阅读#

第一个全栈插件:更短的前后端入门。
前后端共享契约:调用入口与信封详解。
数据存储:store 六个方法的完整签名。
事件消费:事件订阅与至少一次投递。
修改于 2026-10-22 03:40:00
上一页
最小后端插件
下一页
多宿主插件实战
Built with