@international-iot-association/plugin-template 1.0.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +62 -0
- package/manifest.json +17 -0
- package/package.json +74 -0
- package/runtime/main.ts +30 -0
- package/schemas/result.schema.json +17 -0
- package/shared/api.ts +12 -0
- package/smoke.mjs +12 -0
- package/test/runtime.test.mjs +68 -0
- package/tsconfig.json +14 -0
- package/tsup.runtime.config.ts +21 -0
- package/ui/App.tsx +116 -0
- package/ui/components/operator-controls.tsx +66 -0
- package/ui/index.css +10 -0
- package/ui/index.html +23 -0
- package/ui/main.tsx +20 -0
- package/ui/state.ts +14 -0
- package/vite.config.ts +3 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 The Loom Plugin SDK Authors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# __PLUGIN_ID__
|
|
2
|
+
|
|
3
|
+
由 `@international-iot-association/plugin-cli new` 从 plugin-starter 生成的产测工位插件工程。
|
|
4
|
+
|
|
5
|
+
**这是一个 unsigned 开发包,不是「官方 ZIP」**——「官方 ZIP」专指本仓 `plugins/*` 经五方对账与签名后的产物;仓外产出的包不管走什么路径打包,都只能叫 unsigned 开发包,产线放行与否取决于 MES 后台是否将其标记为 released。
|
|
6
|
+
|
|
7
|
+
## 环境要求
|
|
8
|
+
|
|
9
|
+
- Node.js >= 20.19
|
|
10
|
+
- pnpm(由 `package.json` 的 `packageManager` 字段锁版本,勿用 npm/yarn 安装)
|
|
11
|
+
|
|
12
|
+
## 大陆网络:npm 镜像配置
|
|
13
|
+
|
|
14
|
+
clone 走 GitHub、装依赖走 npmjs 在大陆网络下容易同时卡住,先配置镜像:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm config set registry https://registry.npmmirror.com
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## 快速开始
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pnpm install
|
|
24
|
+
pnpm run dev
|
|
25
|
+
pnpm run build && pnpm run pack
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## 目录形态
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
manifest.json 插件清单:id / version / permissions / dependencies
|
|
32
|
+
package.json 依赖声明 + loom 字段(见下)
|
|
33
|
+
runtime/ 产测流程主逻辑(独立 Node 子进程,禁止 import electron)
|
|
34
|
+
ui/ 操作员界面(React,沙箱 iframe,无网络)
|
|
35
|
+
shared/ runtime 与 ui 共用的类型/契约
|
|
36
|
+
test/ 单元测试(不能是空壳)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## loom 字段
|
|
40
|
+
|
|
41
|
+
`package.json` 里的 `loom` 字段供外壳工作台读取启动命令,避免猜测:
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"loom": {
|
|
46
|
+
"ui": { "dev": "...", "port": 5174 },
|
|
47
|
+
"runtime": { "build": "..." }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## 插件间依赖不是 npm 依赖
|
|
53
|
+
|
|
54
|
+
`manifest.json` 的 `dependencies` 字段引用的是别的插件(基座能力,如登录态、全局配置),由 Shell 底座(bundled-attested)或 MES 后台提供——**不要** `npm install` 它们,那些包名不在任何 npm registry 上。
|
|
55
|
+
|
|
56
|
+
## 反馈
|
|
57
|
+
|
|
58
|
+
发现问题请开 issue,不要提 PR——本仓不接受针对模板/SDK 的外部 PR。
|
|
59
|
+
|
|
60
|
+
## 局限
|
|
61
|
+
|
|
62
|
+
本 README 只验证过「照着做流程能跑通」,**未做真人可读性验收**(没有找一个没碰过本仓的人照文档从零走一遍);如果卡住,请在 issue 里写清卡在哪一步。
|
package/manifest.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "__PLUGIN_ID__",
|
|
3
|
+
"name": "__PLUGIN_NAME__",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"description": "由模板生成的插件。",
|
|
6
|
+
"stationTypes": [
|
|
7
|
+
"DUT"
|
|
8
|
+
],
|
|
9
|
+
"models": [],
|
|
10
|
+
"agentApi": "3.0.0",
|
|
11
|
+
"uiEntry": "ui/index.html",
|
|
12
|
+
"runtimeEntry": "runtime/main.mjs",
|
|
13
|
+
"permissions": [],
|
|
14
|
+
"resultSchema": "schemas/result.schema.json",
|
|
15
|
+
"steps": [],
|
|
16
|
+
"checksum": ""
|
|
17
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@international-iot-association/plugin-template",
|
|
3
|
+
"version": "1.0.0-rc.1",
|
|
4
|
+
"description": "插件模板(React + Siemens iX(深色)+ Tailwind 布局 + rct-state + API-Bridge)。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"build:runtime": "tsup --config tsup.runtime.config.ts",
|
|
8
|
+
"build:ui": "vite build",
|
|
9
|
+
"build": "pnpm run build:runtime && pnpm run build:ui",
|
|
10
|
+
"dev": "vite --host 127.0.0.1 --port 5174",
|
|
11
|
+
"typecheck": "tsc --noEmit",
|
|
12
|
+
"test": "pnpm run build:runtime && node --test test/*.test.mjs",
|
|
13
|
+
"clean": "rimraf ui-dist runtime-dist .turbo",
|
|
14
|
+
"pack": "plugin-cli pack . --out dist",
|
|
15
|
+
"preinstall": "npx only-allow pnpm"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@international-iot-association/api-bridge": "3.0.0-rc.1",
|
|
19
|
+
"@international-iot-association/plugin-contracts": "3.0.0-rc.1",
|
|
20
|
+
"@international-iot-association/plugin-sdk": "3.0.0-rc.1",
|
|
21
|
+
"@international-iot-association/rct-state": "3.0.0-rc.1",
|
|
22
|
+
"@international-iot-association/ui": "1.0.0-rc.1",
|
|
23
|
+
"@siemens/ix": "~5.1.1",
|
|
24
|
+
"@siemens/ix-icons": "~3.4.0",
|
|
25
|
+
"@siemens/ix-react": "~5.1.1",
|
|
26
|
+
"react": "^19.2.1",
|
|
27
|
+
"react-dom": "^19.2.1"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@international-iot-association/tsconfig": "1.0.0-rc.1",
|
|
31
|
+
"@tailwindcss/vite": "^4.3.1",
|
|
32
|
+
"@types/react": "^19.2.7",
|
|
33
|
+
"@types/react-dom": "^19.2.3",
|
|
34
|
+
"@vitejs/plugin-react": "^5.1.1",
|
|
35
|
+
"tailwindcss": "^4.3.1",
|
|
36
|
+
"tsup": "^8.4.0",
|
|
37
|
+
"typescript": "^5.9.3",
|
|
38
|
+
"vite": "^7.2.6",
|
|
39
|
+
"vite-plugin-singlefile": "^2.2.0",
|
|
40
|
+
"rimraf": "^6.0.1",
|
|
41
|
+
"@international-iot-association/plugin-vite-config": "1.0.0-rc.1",
|
|
42
|
+
"@types/node": "^22.19.1",
|
|
43
|
+
"@international-iot-association/plugin-cli": "1.0.0-rc.1"
|
|
44
|
+
},
|
|
45
|
+
"pnpm": {
|
|
46
|
+
"overrides": {
|
|
47
|
+
"react": "19.2.7",
|
|
48
|
+
"react-dom": "19.2.7"
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"loom": {
|
|
52
|
+
"ui": {
|
|
53
|
+
"dev": "vite --host 127.0.0.1 --port 5174",
|
|
54
|
+
"port": 5174
|
|
55
|
+
},
|
|
56
|
+
"runtime": {
|
|
57
|
+
"build": "tsup --config tsup.runtime.config.ts"
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"engines": {
|
|
61
|
+
"node": ">=20.19"
|
|
62
|
+
},
|
|
63
|
+
"packageManager": "pnpm@10.14.0",
|
|
64
|
+
"x-internal-version": "1.0.0",
|
|
65
|
+
"x-source-revision": "410a2f3b85ef03b678f04f22ea0a47718f5ddae1",
|
|
66
|
+
"repository": {
|
|
67
|
+
"type": "git",
|
|
68
|
+
"url": "git+https://github.com/International-IoT-Association/Loom-Plugin-Template.git"
|
|
69
|
+
},
|
|
70
|
+
"publishConfig": {
|
|
71
|
+
"access": "public",
|
|
72
|
+
"registry": "https://registry.npmjs.org/"
|
|
73
|
+
}
|
|
74
|
+
}
|
package/runtime/main.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// __PLUGIN_NAME__ —— 插件运行时入口。
|
|
2
|
+
//
|
|
3
|
+
// 能力边界(重要):运行在进程外的插件宿主中(纯 Node 子进程),只能使用受控的 `ctx`。
|
|
4
|
+
// 绝不要 import serialport / fs / child_process / net。真实设备交互优先使用
|
|
5
|
+
// createStationRuntime() 提供的 station.step / step.devices,底层仍由 Agent Device Broker 执行。
|
|
6
|
+
|
|
7
|
+
import { createStationRuntime, definePluginRuntime } from "@international-iot-association/plugin-sdk/runtime";
|
|
8
|
+
import type { TemplateApi } from "../shared/api";
|
|
9
|
+
|
|
10
|
+
export const { activate } = definePluginRuntime<TemplateApi>((server, ctx) => {
|
|
11
|
+
const station = createStationRuntime(server, ctx);
|
|
12
|
+
|
|
13
|
+
ctx.logger.info("[__PLUGIN_ID__] runtime activated");
|
|
14
|
+
|
|
15
|
+
server.example.run.onRequest(async (input) => {
|
|
16
|
+
return station.step("example", async (step) => {
|
|
17
|
+
// —— 在这里写你的业务逻辑 ——
|
|
18
|
+
// 例如:const reply = await step.devices.dut.ask({ cmd: "ping" });
|
|
19
|
+
step.say("run", `note=${input?.note ?? ""}`);
|
|
20
|
+
station.sendEvent(server.example.progress, "progress", { pct: 100 });
|
|
21
|
+
return step.pass({ note: input?.note ?? null });
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
deactivate() {
|
|
27
|
+
ctx.logger.info("[__PLUGIN_ID__] runtime deactivated");
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"title": "StationPluginStepResult",
|
|
4
|
+
"description": "受控的插件测试步骤结果结构。runtime 必须返回符合此结构的对象。",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["ok", "stepKey", "startedAt", "finishedAt"],
|
|
7
|
+
"properties": {
|
|
8
|
+
"ok": { "type": "boolean" },
|
|
9
|
+
"stepKey": { "type": "string" },
|
|
10
|
+
"measurements": { "type": "object", "additionalProperties": true },
|
|
11
|
+
"raw": { "type": "string" },
|
|
12
|
+
"error": { "type": "string" },
|
|
13
|
+
"startedAt": { "type": "string", "format": "date-time" },
|
|
14
|
+
"finishedAt": { "type": "string", "format": "date-time" }
|
|
15
|
+
},
|
|
16
|
+
"additionalProperties": false
|
|
17
|
+
}
|
package/shared/api.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// 本插件的类型化 API-Bridge 接口。UI(usePluginClient<TemplateApi>)与 runtime
|
|
2
|
+
// (definePluginRuntime)双方共同 import 它。把你自己的方法/事件加在这里。
|
|
3
|
+
import type { ApiRoot, EventListener, FuncHandler, StepResult } from "@international-iot-association/plugin-sdk";
|
|
4
|
+
|
|
5
|
+
export interface TemplateApi extends ApiRoot {
|
|
6
|
+
example: {
|
|
7
|
+
/** UI -> runtime:执行一次示例步骤。 */
|
|
8
|
+
run: FuncHandler<[{ note?: string }], StepResult>;
|
|
9
|
+
/** runtime -> UI:进度事件示例。 */
|
|
10
|
+
progress: EventListener<"progress", { pct: number }>;
|
|
11
|
+
};
|
|
12
|
+
}
|
package/smoke.mjs
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// __PLUGIN_NAME__ —— 无头冒烟场景(由 tools/verify.mjs 经 tools/smoke-harness.mjs 执行)。
|
|
2
|
+
// 要求:activate → 至少一次 API 往返 → 至少一条 ctx.report.result。
|
|
3
|
+
// 写你自己的业务后,把这里改成能覆盖主流程的真实场景。
|
|
4
|
+
export default async function smoke(toolkit) {
|
|
5
|
+
const { ctx, client, results } = toolkit.createHarness();
|
|
6
|
+
await toolkit.activate(ctx);
|
|
7
|
+
|
|
8
|
+
const response = await client.example.run.call({ note: "smoke" });
|
|
9
|
+
toolkit.assert.equal(response.result.ok, true, "example.run 应 PASS");
|
|
10
|
+
toolkit.assert.equal(response.result.measurements.note, "smoke");
|
|
11
|
+
toolkit.assert.equal(results.length, 1, "应产生一条 report.result");
|
|
12
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// __PLUGIN_NAME__ —— runtime 单元测试(node --test)。
|
|
2
|
+
// 脚手架自带:新插件生成后 `pnpm --filter __PLUGIN_ID__ test` 必须直接全绿。
|
|
3
|
+
// 写你自己的步骤逻辑时,请同步把用例改成真实断言(不要留空壳测试)。
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import { createAPIClient } from "@international-iot-association/api-bridge";
|
|
7
|
+
|
|
8
|
+
/** 最小 stub ctx:形状对齐外壳 plugin-host/runner.ts 注入的 PluginRuntimeContext。 */
|
|
9
|
+
function makeStubCtx() {
|
|
10
|
+
const reports = [];
|
|
11
|
+
const stepEvents = [];
|
|
12
|
+
let runtimeOnMessage = null;
|
|
13
|
+
|
|
14
|
+
const ctx = {
|
|
15
|
+
logger: { info() {}, warn() {}, error() {}, debug() {} },
|
|
16
|
+
channels: {
|
|
17
|
+
async writeReadMock(logicalName, payload) {
|
|
18
|
+
return { logicalName, request: payload, response: `MOCK<${logicalName}>:${payload}:OK`, latencyMs: 1 };
|
|
19
|
+
},
|
|
20
|
+
async writeRead() {
|
|
21
|
+
throw new Error("no device configured");
|
|
22
|
+
},
|
|
23
|
+
async control() {
|
|
24
|
+
throw new Error("no device configured");
|
|
25
|
+
},
|
|
26
|
+
subscribe() {},
|
|
27
|
+
},
|
|
28
|
+
report: {
|
|
29
|
+
stepEvent: (e) => stepEvents.push(e),
|
|
30
|
+
result: (r) => reports.push(r),
|
|
31
|
+
},
|
|
32
|
+
ui: {
|
|
33
|
+
postMessage: (env) => {
|
|
34
|
+
if (env.type === "response") clientMessageHandler(env);
|
|
35
|
+
else if (env.type === "event") serverEventHandler(env);
|
|
36
|
+
},
|
|
37
|
+
onMessage: (h) => {
|
|
38
|
+
runtimeOnMessage = h;
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
bus: { postMessage() {}, onMessage() {} },
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const { client, clientMessageHandler, serverEventHandler } = createAPIClient({
|
|
45
|
+
requestServerFunc: async (req) => {
|
|
46
|
+
runtimeOnMessage?.(req);
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
return { ctx, client, reports, stepEvents };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
test("example.run:回传 PASS、携带输入 note、上报 result 并推送 progress 事件", async () => {
|
|
54
|
+
const { ctx, client, reports } = makeStubCtx();
|
|
55
|
+
const { activate } = await import("../runtime-dist/main.mjs");
|
|
56
|
+
await activate(ctx);
|
|
57
|
+
|
|
58
|
+
const progressEvents = [];
|
|
59
|
+
client.example.progress.on("progress", (data) => progressEvents.push(data));
|
|
60
|
+
|
|
61
|
+
const response = await client.example.run.call({ note: "hello" });
|
|
62
|
+
assert.equal(response.result.ok, true);
|
|
63
|
+
assert.equal(response.result.stepKey, "example");
|
|
64
|
+
assert.equal(response.result.measurements.note, "hello");
|
|
65
|
+
assert.equal(reports.length, 1);
|
|
66
|
+
assert.equal(reports[0].ok, true);
|
|
67
|
+
assert.deepEqual(progressEvents, [{ pct: 100 }]);
|
|
68
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { defineConfig } from "tsup";
|
|
2
|
+
|
|
3
|
+
// 将 runtime/main.ts 构建为一个自包含的 ESM 模块,外壳可以从一个没有 node_modules 的
|
|
4
|
+
// 安装目录中动态 import 它。
|
|
5
|
+
//
|
|
6
|
+
// 因此我们把 SDK + api-bridge 及其依赖(rxjs、nanoid)一并打包进来。我们从不 import
|
|
7
|
+
// electron/serialport/fs,所以它们绝不会进入产物——runtime 始终是一个纯 node 的能力沙箱客户端。
|
|
8
|
+
export default defineConfig({
|
|
9
|
+
entry: { main: "runtime/main.ts" },
|
|
10
|
+
outDir: "runtime-dist",
|
|
11
|
+
format: ["esm"],
|
|
12
|
+
platform: "node",
|
|
13
|
+
target: "node18",
|
|
14
|
+
bundle: true,
|
|
15
|
+
noExternal: [/^@international-iot-association\//, "rxjs", "nanoid"],
|
|
16
|
+
outExtension: () => ({ js: ".mjs" }),
|
|
17
|
+
clean: true,
|
|
18
|
+
dts: false,
|
|
19
|
+
sourcemap: false,
|
|
20
|
+
minify: false,
|
|
21
|
+
});
|
package/ui/App.tsx
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { usePluginClient, useDisplayScale } from "@international-iot-association/plugin-sdk/ui";
|
|
3
|
+
import { IxChip, IxTypography } from "@siemens/ix-react";
|
|
4
|
+
import type { TemplateApi } from "../shared/api";
|
|
5
|
+
import { ui$ } from "./state";
|
|
6
|
+
import { OperatorButton, OperatorToggleButton } from "./components/operator-controls";
|
|
7
|
+
|
|
8
|
+
// 状态 → 文案/语义色集中映射;禁止在 JSX 里散落颜色判断或硬编码 hex(约定 §8.3/§11.5)。
|
|
9
|
+
const STATUS_WORD = { idle: "待机", running: "运行中", done: "完成" } as const;
|
|
10
|
+
const STATUS_CHIP = { idle: "neutral", running: "info", done: "success" } as const;
|
|
11
|
+
|
|
12
|
+
export function App(): React.JSX.Element {
|
|
13
|
+
// 类型化的 API-Bridge 客户端:直接调用插件 runtime,卸载时自动清理监听。
|
|
14
|
+
const client = usePluginClient<TemplateApi>();
|
|
15
|
+
// 显示缩放:根字号 = Shell 校准基准 ×(普通 1.0 / 超大 1.5),组件内只写 rem(约定 §7)。
|
|
16
|
+
const scale = useDisplayScale();
|
|
17
|
+
|
|
18
|
+
// rct-state:值变化时自动重渲染。
|
|
19
|
+
const status = ui$.status.use();
|
|
20
|
+
const count = ui$.count.use();
|
|
21
|
+
const lastError = ui$.lastError.use();
|
|
22
|
+
|
|
23
|
+
// runtime -> UI 的事件示例。
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
const off = client.example.progress.on("progress", (d) => {
|
|
26
|
+
ui$.status.set(d.pct >= 100 ? "done" : "running");
|
|
27
|
+
});
|
|
28
|
+
return () => off();
|
|
29
|
+
}, [client]);
|
|
30
|
+
|
|
31
|
+
const handleRunExample = async (): Promise<void> => {
|
|
32
|
+
// UI 层防重复(busy + 状态检查);runtime 侧仍需幂等校验(约定 §13.2)。
|
|
33
|
+
if (ui$.status.peek() === "running") return;
|
|
34
|
+
ui$.batch(() => {
|
|
35
|
+
ui$.status.set("running");
|
|
36
|
+
ui$.lastError.set("");
|
|
37
|
+
ui$.count.set(ui$.count.peek() + 1);
|
|
38
|
+
});
|
|
39
|
+
try {
|
|
40
|
+
// invoke:resolve StepResult 本体;失败以 ApiBridgeError reject(约定 §18.1)。
|
|
41
|
+
const result = await client.example.run.invoke({ note: `run #${ui$.count.peek()}` });
|
|
42
|
+
ui$.batch(() => {
|
|
43
|
+
ui$.status.set(result.ok ? "done" : "idle");
|
|
44
|
+
ui$.lastError.set(result.ok ? "" : (result.error ?? "示例步骤失败"));
|
|
45
|
+
});
|
|
46
|
+
} catch (cause) {
|
|
47
|
+
// 失败必须显式呈现;真实插件请给出影响、下一步操作与故障代码(约定 §15)。
|
|
48
|
+
ui$.batch(() => {
|
|
49
|
+
ui$.status.set("idle");
|
|
50
|
+
ui$.lastError.set(cause instanceof Error ? cause.message : String(cause));
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// 布局约定(§9):全宽填满显示区(禁止 max-w-* 限宽),统一页面内边距 1.25rem。
|
|
56
|
+
return (
|
|
57
|
+
<div className="flex min-h-full w-full flex-col gap-[1rem] p-[1.25rem]">
|
|
58
|
+
<header className="flex flex-wrap items-center gap-[1rem]">
|
|
59
|
+
<div className="min-w-0 flex-1">
|
|
60
|
+
<IxTypography format="h3">__PLUGIN_NAME__</IxTypography>
|
|
61
|
+
<IxTypography format="body-sm" textColor="soft">
|
|
62
|
+
__PLUGIN_ID__ · 校准 {scale.basePercent}% ×{" "}
|
|
63
|
+
{scale.mode === "xl" ? "超大 ×1.5" : "普通 ×1.0"}
|
|
64
|
+
</IxTypography>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
{/* 普通/超大显示模式:面向产线的插件必须提供(约定 §7.1)。 */}
|
|
68
|
+
<div className="flex items-center gap-[0.5rem]" role="group" aria-label="显示尺寸">
|
|
69
|
+
<OperatorToggleButton
|
|
70
|
+
pressed={scale.mode === "normal"}
|
|
71
|
+
onPressedChange={() => scale.setMode("normal")}
|
|
72
|
+
>
|
|
73
|
+
普通
|
|
74
|
+
</OperatorToggleButton>
|
|
75
|
+
<OperatorToggleButton
|
|
76
|
+
pressed={scale.mode === "xl"}
|
|
77
|
+
onPressedChange={() => scale.setMode("xl")}
|
|
78
|
+
>
|
|
79
|
+
超大
|
|
80
|
+
</OperatorToggleButton>
|
|
81
|
+
</div>
|
|
82
|
+
</header>
|
|
83
|
+
|
|
84
|
+
<section className="flex flex-col gap-[1rem] rounded-lg border border-border bg-card p-[1.25rem]">
|
|
85
|
+
<IxTypography format="body" textColor="soft">
|
|
86
|
+
这是插件模板:UI 用 rct-state 管理状态、经 API-Bridge 的 invoke 调用 runtime。
|
|
87
|
+
界面按 docs/ui-conventions.md 实现(iX 组件、深色唯一、rem 尺寸、全宽布局)。
|
|
88
|
+
把业务逻辑写在 runtime/main.ts 与本文件即可。
|
|
89
|
+
</IxTypography>
|
|
90
|
+
|
|
91
|
+
<div className="flex flex-wrap items-center gap-[1rem]">
|
|
92
|
+
<OperatorButton
|
|
93
|
+
physicalSize="primary"
|
|
94
|
+
busy={status === "running"}
|
|
95
|
+
onClick={() => void handleRunExample()}
|
|
96
|
+
>
|
|
97
|
+
{status === "running" ? "正在运行示例步骤…" : "运行示例步骤"}
|
|
98
|
+
</OperatorButton>
|
|
99
|
+
{/* 状态同时有文字 + 语义色,且经 aria-live 播报(约定 §11.5/§20)。 */}
|
|
100
|
+
<span className="flex items-center gap-[0.5rem]" role="status" aria-live="polite">
|
|
101
|
+
<IxChip variant={STATUS_CHIP[status]}>{STATUS_WORD[status]}</IxChip>
|
|
102
|
+
<IxTypography format="body-sm" textColor="soft">
|
|
103
|
+
已运行 {count} 次
|
|
104
|
+
</IxTypography>
|
|
105
|
+
</span>
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
{lastError !== "" ? (
|
|
109
|
+
<IxTypography format="body-sm" textColor="alarm">
|
|
110
|
+
上次运行失败:{lastError}
|
|
111
|
+
</IxTypography>
|
|
112
|
+
) : null}
|
|
113
|
+
</section>
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { ComponentProps } from "react";
|
|
2
|
+
import { IxButton, IxToggleButton } from "@siemens/ix-react";
|
|
3
|
+
|
|
4
|
+
// 产线触摸尺寸包装(docs/ui-conventions.md §7.2/§7.3):
|
|
5
|
+
// iX 5.x 按钮宿主固定 height: 2rem、内核 height: 100%,低于产线普通模式 2.75rem 触摸基线。
|
|
6
|
+
// 在宿主元素上覆盖高度即可整体拉伸。所有操作员可触控件统一经这里,不要在页面里散落覆盖。
|
|
7
|
+
|
|
8
|
+
const TOUCH_STANDARD = "2.75rem"; // 普通触摸目标(校准后约 11.6mm)
|
|
9
|
+
const TOUCH_PRIMARY = "3rem"; // 高频主操作(校准后约 12.7mm)
|
|
10
|
+
|
|
11
|
+
type OperatorButtonProps = ComponentProps<typeof IxButton> & {
|
|
12
|
+
/** primary = 高频主操作(3rem 高);standard = 普通触摸目标(2.75rem 高)。 */
|
|
13
|
+
physicalSize?: "standard" | "primary";
|
|
14
|
+
/** 异步进行中:loading + 禁点。仅 UI 层防重复不够,runtime 仍需幂等(约定 §13.2)。 */
|
|
15
|
+
busy?: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function OperatorButton({
|
|
19
|
+
physicalSize = "standard",
|
|
20
|
+
busy = false,
|
|
21
|
+
disabled,
|
|
22
|
+
loading,
|
|
23
|
+
style,
|
|
24
|
+
children,
|
|
25
|
+
...props
|
|
26
|
+
}: OperatorButtonProps): React.JSX.Element {
|
|
27
|
+
return (
|
|
28
|
+
<IxButton
|
|
29
|
+
{...props}
|
|
30
|
+
disabled={disabled || busy}
|
|
31
|
+
loading={loading || busy}
|
|
32
|
+
aria-busy={busy || undefined}
|
|
33
|
+
style={{
|
|
34
|
+
blockSize: physicalSize === "primary" ? TOUCH_PRIMARY : TOUCH_STANDARD,
|
|
35
|
+
minInlineSize: TOUCH_STANDARD,
|
|
36
|
+
fontSize: "1rem",
|
|
37
|
+
...style,
|
|
38
|
+
}}
|
|
39
|
+
>
|
|
40
|
+
{children}
|
|
41
|
+
</IxButton>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type OperatorToggleButtonProps = ComponentProps<typeof IxToggleButton>;
|
|
46
|
+
|
|
47
|
+
/** 触摸尺寸达标的 iX ToggleButton(用于"普通/超大"显示模式等分段选择)。 */
|
|
48
|
+
export function OperatorToggleButton({
|
|
49
|
+
style,
|
|
50
|
+
children,
|
|
51
|
+
...props
|
|
52
|
+
}: OperatorToggleButtonProps): React.JSX.Element {
|
|
53
|
+
return (
|
|
54
|
+
<IxToggleButton
|
|
55
|
+
{...props}
|
|
56
|
+
style={{
|
|
57
|
+
blockSize: TOUCH_STANDARD,
|
|
58
|
+
minInlineSize: "4.5rem",
|
|
59
|
+
fontSize: "1rem",
|
|
60
|
+
...style,
|
|
61
|
+
}}
|
|
62
|
+
>
|
|
63
|
+
{children}
|
|
64
|
+
</IxToggleButton>
|
|
65
|
+
);
|
|
66
|
+
}
|
package/ui/index.css
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
@import "@international-iot-association/ui/styles.css";
|
|
3
|
+
|
|
4
|
+
/* 扫描本插件自身的 UI 源码以及下面这行 @import 引入的共享 UI 组件库源码,以便 Tailwind
|
|
5
|
+
生成所有用到的工具类(在 monorepo 内相对路径是确定的,不受 pnpm 提升策略影响)。
|
|
6
|
+
注意:这里刻意不写死具体包名——gen-starter.mjs 生成 plugin-starter/ 时只做结构化
|
|
7
|
+
改写(import specifier / JSON 字段 / CSS 精确子串),不做全文替换,写死包名的说明性
|
|
8
|
+
文字不会被自动改写,会在投影里变成过时/错误的提及。 */
|
|
9
|
+
@source "./";
|
|
10
|
+
@source "../node_modules/@international-iot-association/ui/dist";
|
package/ui/index.html
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<!-- iX 5.x 主题经属性选择器挂载:[data-ix-theme=classic][data-ix-color-schema=dark]。
|
|
3
|
+
深色是唯一默认(docs/ui-conventions.md §8),不要在业务组件中切换主题。 -->
|
|
4
|
+
<html lang="zh" class="dark" data-ix-theme="classic" data-ix-color-schema="dark">
|
|
5
|
+
<head>
|
|
6
|
+
<meta charset="utf-8" />
|
|
7
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
8
|
+
<!--
|
|
9
|
+
运行在沙箱、opaque-origin 的 iframe 中,无网络、无 Node。UI 是单个自包含文件
|
|
10
|
+
(JS + CSS 由 viteSingleFile 内联),因此 'unsafe-inline' 是务实的 CSP 选择。
|
|
11
|
+
不允许任何远程来源;font-src/img-src 的 data: 用于内联的 iX 字体与图形资产。
|
|
12
|
+
-->
|
|
13
|
+
<meta
|
|
14
|
+
http-equiv="Content-Security-Policy"
|
|
15
|
+
content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'none'"
|
|
16
|
+
/>
|
|
17
|
+
<title>__PLUGIN_NAME__</title>
|
|
18
|
+
</head>
|
|
19
|
+
<body>
|
|
20
|
+
<div id="root"></div>
|
|
21
|
+
<script type="module" src="./main.tsx"></script>
|
|
22
|
+
</body>
|
|
23
|
+
</html>
|
package/ui/main.tsx
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { StrictMode } from "react";
|
|
2
|
+
import { createRoot } from "react-dom/client";
|
|
3
|
+
import { applyInitialDisplayScale } from "@international-iot-association/plugin-sdk/ui";
|
|
4
|
+
import { App } from "./App";
|
|
5
|
+
// Siemens iX 核心样式 + 深色主题(深色是唯一默认,见 docs/ui-conventions.md §8)。
|
|
6
|
+
// 单文件构建会把 iX 样式内嵌的字体/图形资产全部内联(CSP 无网络,见 vite.config.ts)。
|
|
7
|
+
import "@siemens/ix/dist/siemens-ix/siemens-ix.css";
|
|
8
|
+
import "@siemens/ix/dist/siemens-ix/theme/classic-dark.css";
|
|
9
|
+
import "./index.css";
|
|
10
|
+
|
|
11
|
+
// 渲染前应用外壳注入的校准基准(?displayScale=),消除首帧错误字号。
|
|
12
|
+
applyInitialDisplayScale();
|
|
13
|
+
|
|
14
|
+
const el = document.getElementById("root");
|
|
15
|
+
if (!el) throw new Error("root element missing");
|
|
16
|
+
createRoot(el).render(
|
|
17
|
+
<StrictMode>
|
|
18
|
+
<App />
|
|
19
|
+
</StrictMode>,
|
|
20
|
+
);
|
package/ui/state.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { observable } from "@international-iot-association/rct-state";
|
|
2
|
+
|
|
3
|
+
// 插件 UI 的响应式状态(rct-state)。
|
|
4
|
+
// 读取:const x = ui$.x.use() —— 值变化时组件自动重渲染
|
|
5
|
+
// 更新:ui$.x.set(v) —— 同步写入并通知订阅者
|
|
6
|
+
// 快照:ui$.x.peek() / ui$.peek()
|
|
7
|
+
// 批量:ui$.batch(() => { ... }) —— 多次 set 只通知一次
|
|
8
|
+
// 主状态用互斥的字面量联合表达;真实插件请扩展为判别联合/状态机
|
|
9
|
+
// (禁止用多个独立布尔拼装,见 docs/ui-conventions.md §10)。
|
|
10
|
+
export const ui$ = observable({
|
|
11
|
+
status: "idle" as "idle" | "running" | "done",
|
|
12
|
+
count: 0,
|
|
13
|
+
lastError: "",
|
|
14
|
+
});
|
package/vite.config.ts
ADDED