用 SDK 做定制应用

第 5 课 · 共 6 课 约 8 分钟

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

本课目标

读完这一课,你将能够

  • 说出 Nexus 应用的三块:界面 SDK、语义 SDK、清单,各管什么
  • 读懂并写出一个读工单、能登记缺陷的完整小应用
  • 走通检查、部署、发布,并说出实时订阅的成本

三块拼起来

前面讲了设计和按钮。这一节把它们装成一个真能部署的应用:示例工厂的工单看板,带「登记缺陷」按钮。它由三块组成:

UI SDK

界面 SDK

样式表 ui.css 加 ui 模块,纯 HTML 和原生 JS,没有构建步骤。React 项目的界面用 AIDC UI(shadcn 加 @aidc 注册表)。

SEMANTIC SDK

语义 SDK

semantic.ontology() 读、聚合、执行 Action;live() 订阅变化。在应用里自动以访客的身份、用应用所在公司的命名空间调用。

MANIFEST

清单

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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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 的应用没有这层缓存,同样的效果要自己做:提交成功后重读一次,就是那个「成功之后」的事件。

  1. 检查

    aidc app check work-order-board:清单、包、SDK 登记。

  2. 部署到 test

    aidc app deploy work-order-board,在 /developer/{company}/apps/work-order-board 试用。

  3. 发布到 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 看你公司有什么。

小测

选一个答案,马上看解析。

Q1app.js 里 import 了 data,清单 sdk 却只写了 ["semantic", "auth", "ui"]。部署会怎样?

Q2看板放在车间大屏上一直开着 live(),一个月下来会怎样?

Q3清单 semantic.types 只登记了 workOrder,代码却沿关系读 inspections。会怎样?

延伸阅读