用 SDK 做定制应用
界面 SDK、语义 SDK 与清单;一个完整的工单看板;实时的成本与发布。

本课目标
读完这一课,你将能够
- 说出 Nexus 应用的三块:界面 SDK、语义 SDK、清单,各管什么
- 读懂并写出一个读工单、能登记缺陷的完整小应用
- 走通检查、部署、发布,并说出实时订阅的成本
三块拼起来
前面讲了设计和按钮。这一节把它们装成一个真能部署的应用:示例工厂的工单看板,带「登记缺陷」按钮。它由三块组成:
界面 SDK
样式表 ui.css 加 ui 模块,纯 HTML 和原生 JS,没有构建步骤。React 项目的界面用 AIDC UI(shadcn 加 @aidc 注册表)。
语义 SDK
semantic.ontology() 读、聚合、执行 Action;live() 订阅变化。在应用里自动以访客的身份、用应用所在公司的命名空间调用。
清单
aidc.app.json 登记用了哪些 SDK、读哪些类型、执行哪些 Action、谁能打开。应用只看得到登记过的。
Workshop 已能用声明式组件做看板,也能用 Custom widget 嵌入 Nexus 应用。需要定制交互时,可以写 Nexus 应用。本课保留 aidc.app.json 示例;init 默认生成 plugin.json,所以要加 --format aidc。起步不用从空白开始:aidc app init work-order-board --template semantic --format aidc 生成一个读一个对象类型、带一个 Action 按钮的骨架,清单里的 sdk 与 semantic 已经登记好。下面的代码是把它换成示例工厂之后的样子。
完整示例:工单看板
先是清单。sdk 必须如实登记:部署时平台会静态分析代码里的 import,用了没登记的直接拒收。semantic.types 里要有 inspection,因为缺陷要挂在检验上,Action 的对象参数和沿关系读取都需要能读到它。
{
"manifestVersion": 1,
"slug": "work-order-board",
"version": "1.0.0",
"title": "工单看板",
"summary": "产线上还没做完的工单,一眼看进度;发现问题,点一下登记缺陷。",
"examples": ["列出这周到期、还没做完的工单", "给工单 WO-2409-017 登记一个缺陷"],
"owner": { "department": "制造部", "team": "产线小助手" },
"category": "data",
"entry": "index.html",
"sdk": ["semantic", "auth", "ui"],
"semantic": { "types": ["workOrder", "inspection"], "actions": ["flag-defect"], "write": [] },
"auth": { "access": "company", "publicLink": "members" }
}
页面只有三样:顶栏(实时开关、刷新)、一张表、一个登记缺陷的对话框。类和结构照界面 SDK 的样例,脚本引用的是相对路径。
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>工单看板</title>
<link rel="stylesheet" href="/developer/sdk/v1/ui.css" />
</head>
<body>
<header class="aidc-appbar">
<span class="aidc-appbar__title">工单看板</span>
<div class="aidc-appbar__actions">
<label class="aidc-label"><input type="checkbox" role="switch" class="aidc-switch" id="liveSwitch" /> 实时</label>
<button type="button" class="aidc-btn aidc-btn--outline aidc-btn--sm" id="refresh">刷新</button>
</div>
</header>
<main class="aidc-page aidc-stack">
<div class="aidc-table-wrap"><table class="aidc-table" id="board"></table></div>
</main>
<dialog class="aidc-dialog" id="defectDialog">
<form class="aidc-stack" id="defectForm">
<div class="aidc-dialog__header">
<h2 class="aidc-dialog__title">登记缺陷</h2>
<p class="aidc-dialog__description" id="defectFor"></p>
</div>
<div class="aidc-field">
<label class="aidc-label" for="code">缺陷代码</label>
<input class="aidc-input" id="code" required />
</div>
<div class="aidc-field">
<label class="aidc-label" for="severity">严重程度</label>
<select class="aidc-select" id="severity">
<option>minor</option><option>major</option><option>critical</option>
</select>
</div>
<div class="aidc-field">
<label class="aidc-label" for="description">描述</label>
<textarea class="aidc-textarea" id="description" rows="3"></textarea>
<p class="aidc-field__error" id="formError" hidden></p>
</div>
<div class="aidc-dialog__footer">
<button type="button" class="aidc-btn aidc-btn--outline" id="cancel">取消</button>
<button type="submit" class="aidc-btn" id="submit">登记</button>
</div>
</form>
</dialog>
<script type="module" src="app.js"></script>
</body>
</html>
脚本分四段:读、实时、打开对话框、提交。flag-defect 的四个参数是检验、代码、严重程度、描述;检验由程序沿「检验」关系找到这张工单最近的一次。
import { auth, semantic, ui } from "/developer/sdk/v1/aidc.js";
const client = semantic.ontology(); // 在应用里自动以访客身份、应用所在公司的命名空间调用
const $ = (id) => document.getElementById(id);
const esc = (v) => String(v ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
const OPEN = ["planned", "released", "running", "paused"];
const me = await auth.me();
const canFlag = auth.can(me, { action: "flag-defect" }); // 只决定按钮显不显示,真正的拦截在服务端
let target = null; // 正在登记缺陷的工单号
let subscription = null; // 实时订阅:开着才占计算分钟
// 读:打开时读一次,点「刷新」再读
async function load() {
const page = await client.objects("workOrder").where({ status: { $in: OPEN } })
.fetchPage({ $orderBy: { dueAt: "asc" }, $pageSize: 50 });
render(page.data);
}
function render(rows) {
const head = "<thead><tr><th>工单</th><th>产品</th><th>状态</th><th class=\"aidc-num\">良品 / 计划</th><th>到期</th><th></th></tr></thead>";
const body = rows.map((p) => `<tr>
<td>${esc(p.workOrderNo)}</td><td>${esc(p.sku)}</td>
<td><span class="aidc-badge aidc-badge--secondary">${esc(p.status)}</span></td>
<td class="aidc-num">${esc(p.goodQty ?? 0)} / ${esc(p.plannedQty)}</td>
<td>${esc(String(p.dueAt ?? "").slice(0, 10))}</td>
<td>${canFlag ? `<button type="button" class="aidc-btn aidc-btn--outline aidc-btn--sm" data-no="${esc(p.workOrderNo)}">登记缺陷</button>` : ""}</td>
</tr>`).join("");
$("board").innerHTML = `${head}<tbody>${body}</tbody>`;
}
// 实时:开关打开才订阅(先全量,之后数据一变就回调)。live() 用数据 SDK 的条件写法:in,不带 $
$("liveSwitch").addEventListener("change", (ev) => {
if (ev.target.checked) {
subscription = semantic.objects("workOrder").where({ status: { in: OPEN } }).sort("dueAt")
.live({ onUpdate: (objects) => render(objects.map((o) => o.props)) });
} else {
subscription?.close();
subscription = null;
}
});
$("refresh").addEventListener("click", load);
// 事件:点「登记缺陷」打开对话框
$("board").addEventListener("click", (ev) => {
const no = ev.target.closest("[data-no]")?.dataset.no;
if (!no) return;
target = no;
$("defectFor").textContent = `工单 ${no}`;
$("formError").hidden = true;
$("defectDialog").showModal();
});
$("cancel").addEventListener("click", () => $("defectDialog").close());
function firstProblem(v) {
const bad = Object.entries(v.parameters).find(([, p]) => p.result === "INVALID");
if (bad) return `${bad[0]}:${bad[1].message ?? "不合格"}`;
return v.submissionCriteria.find((c) => c.result === "INVALID")?.configuredFailureMessage ?? "校验没有通过";
}
// 写:先只校验,再提交
$("defectForm").addEventListener("submit", async (ev) => {
ev.preventDefault();
$("submit").disabled = true;
$("formError").hidden = true;
try {
// 缺陷挂在检验上:沿「检验」关系找这张工单最近的一次
const latest = await client.objects("workOrder").where({ workOrderNo: target }).pivotTo("inspections")
.fetchPage({ $orderBy: { inspectedAt: "desc" }, $pageSize: 1 });
if (!latest.data.length) throw new Error("这张工单还没有检验记录,先检验,再登记缺陷。");
const params = { inspection: latest.data[0].__primaryKey, code: $("code").value.trim(), severity: $("severity").value, description: $("description").value.trim() };
const action = client.action("flag-defect");
const check = await action.applyAction(params, { $validateOnly: true }); // 什么都不写
if (check.validation.result !== "VALID") throw new Error(firstProblem(check.validation));
await action.applyAction(params); // 一个事务
$("defectDialog").close();
ui.toast("已登记缺陷", { variant: "success", description: `${target} · ${params.code}` });
} catch (err) {
$("formError").textContent = err.message; // 提交条件的失败信息原样显示
$("formError").hidden = false;
} finally {
$("submit").disabled = false;
}
});
load();
有三处值得看:读用 semantic.ontology(),写只走 applyAction,先 $validateOnly 再提交;按钮由 auth.can 决定显不显示,真正的拦截仍在服务端;提交条件的失败信息直接放进表单的错误提示。
实时、成本与发布
live() 先取全量,之后数据一变就回调,同步、Action、别人的修改都算。底层是断线按序号续传的连接,页面隐藏超过 1 分钟会自动断开,切回来续上。代价是:连接挂着的时间按分钟计入应用的计算分钟,缺省每月 2000。
算一笔账:5 位班组长每天开着看板 8 小时、一个月 22 个工作日,是 5 × 480 × 22 = 52,800 分钟,远超缺省上限。所以示例里实时是一个开关,默认关:打开时读一次,要看新数据点「刷新」,只有值班大屏才打开实时,并有意识地调高上限。不要用 setInterval 轮询,aidc app check 会告警。
官方的 React 方案提供带类型的 hooks 和共享缓存:多个组件读同一个对象只请求一次,Action 提交后自动同步受影响的对象。AIDC 的应用没有这层缓存,同样的效果要自己做:提交成功后重读一次,就是那个「成功之后」的事件。
- 检查
aidc app check work-order-board:清单、包、SDK 登记。 - 部署到 test
aidc app deploy work-order-board,在/developer/{company}/apps/work-order-board试用。 - 发布到 Nexus
aidc app publish work-order-board:测过的版本才能发,需要 developer 账号。
几条规矩:内容变了就要升 version,否则部署被拒(409 version_conflict);版本包最多 200 个文件、解码后不超过 3 MB;包里不能带 SDK 的副本;脚本与样式只能来自本站和清单 cdn 声明的域名。
要点
- Nexus 应用 = 界面 SDK(长相)加语义 SDK(读写)加清单(登记与权限)。
- 应用以访客的身份调用,只读得到
semantic.types登记的类型,只执行semantic.actions登记的 Action。 - 读用
semantic.ontology(),写只走applyAction:先$validateOnly再提交。 live()挂着的连接计入计算分钟(缺省每月 2000),要用才开。- 发布是检查、部署到 test、发布到 Nexus;改了内容就升版本号,包最多 200 个文件、3 MB。
练一练
做出你自己的看板
先在本机开发;部署与发布需要 developer 账号。
用 aidc app init … --template semantic --format aidc 生成骨架,把类型与 Action 换成 workOrder 与 flag-defect;先用 aidc semantic describe 看你公司有什么。
给看板加一列「缺陷数」:用 withProperties 沿检验、缺陷两跳汇总(派生属性最多 3 跳)。提交后要手动刷新一次,为什么?
3 位计划员每天开 6 小时、22 个工作日,live() 会用多少计算分钟?改成「点刷新才读」后呢?
小测
选一个答案,马上看解析。
Q1app.js 里 import 了 data,清单 sdk 却只写了 ["semantic", "auth", "ui"]。部署会怎样?
部署时平台静态分析 import。旧 SDK 名 data 已归入 semantic;题设已登记 semantic,这一项不算缺登记。其他检查仍须通过。
Q2看板放在车间大屏上一直开着 live(),一个月下来会怎样?
实时连接按时长计入计算分钟,用完之后票据请求一律 429。
Q3清单 semantic.types 只登记了 workOrder,代码却沿关系读 inspections。会怎样?
应用能读的类型由清单登记决定,关系的另一端也一样。