This repository has been archived on 2026-09-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
BPOS-TMP-REPO/docs/API文档-活动模块.md
T
xcsandClaude Opus 5 c1137fb09a fix(ops): 实例时间线第 9 步的结果跟第 8 步走 —— 已放行的单不再永远停在 pending
放行那一步(T_release,人工激活)**没有自己的收步事件** —— 人签字后 `submit` 直接把整单落终态,
计划项本身从没被 finish 过。于是一单已 `released` 的详情里第 9 步永远是 `pending`,
而它前面拟落账那一步早就 `done`:同一张单上两句话互相打脸。
`followRes` 纯函数在服务端交出详情之前把第 9 步的 `res` 与 `at` 跟上第 8 步(2026-08-28 用户定,`at` 也跟);
**用时不编** —— 那一步没有自己的起止痕;两步有一步不在(别的活动步数不同)就什么都不动。
《API文档-活动模块》§ 时间线补一行口径。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
2026-08-28 10:50:16 +08:00

623 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# API 文档 —— 活动模块(L1 清单 / L2 活动详情 / L3 实例详情)
> 本文是活动模块前端(运营中心 · 活动区,2026-08-25 重构模版)与服务端之间的接口约定,给前端取数、后端维护用。
> 鉴权 / 票据 / 错误体 / 端归属的**通则**在 `docs/API文档-鉴权与人员.md` §0,本文 §0.1 只摘调用时必须知道的几条。
> 需求出处:`docs/活动模块重构需求-2026-08-25.md`;启动参数口径:`ACT-WORK-05-一键启动入参说明.md`(极简四参)。
> **契约已落地(2026-08-27)**:`packages/contracts/src/activity-ops.ts`(数据结构 zod)+ `packages/contracts/src/api/activity-ops.ts`
> (11 条路由,花名册 `ACTIVITY_OPS_ROUTES`);前端一律 `import { xxxRoute } from '@contracts'` 经 `shared/typed-fetch` 的 `call(route, { params, query, body })` 调用,
> 类型直接用 `@contracts` 导出的(§1 每张表都标了导出名)。两者不一致时以契约为准并回来改本文。
> 设计端的活动库两条路由已改名让位:`GET/POST /api/gov/activities` → `/api/gov/activity-packages`(前端 import 路由对象,不用改)。
## 0. 通则
### 0.1 调用约定
| 项 | 值 |
|---|---|
| 前缀 | `/api/gov/activities/*`;**11 条全部 `access: 'ops'`** —— 登录人的 `ends` 里要有 `ops`,否则 403 |
| 请求头 | `Authorization: Bearer <Keycloak access_token>`;写请求 `content-type: application/json` |
| 票据 | 只有两条路带不了请求头(`<iframe src>` / `<a href download>`):§2.3 视图、§7 文件。GET 时改带 `?ticket=<票据>`(取自 `GET /api/auth/ticket`,**60 秒有效,用的那一刻再取**) |
| 响应 | 除 §2.3(`text/html`)与 §7 `?raw=1`(文件字节)外,全部 `application/json` |
| 时间 | 一律 ISO 8601 UTC 字符串(`2026-08-27T02:25:11.000Z`);**展示格式化是前端的事**(`YYYY-MM-DD HH:mm:ss` 本地时区,用 `shared/at.ts`) |
| 人员 | 凡出人,都是 `StaffRef = { id, name }` —— 展示用 `name`,筛选 / 提交用 `id`(= 名册 staffId);候选名单用既有 `GET /api/gov/staff` |
| 实时刷新 | 写路由(启动 / 禁用 / 激活 / 终止)成功后服务端广播 `{kind:'system', id:'gov'}`,前端既有 SSE 订阅会失效 `['gov', …]` 缓存 —— 查询键放在 `['gov', 'activities', …]` 下就自动重取 |
| 分页 | 只有 §3 实例清单分页(`page` 从 1 起,`size` 默认 8、上限 50);其余不分页 |
### 0.2 错误体
所有非 2xx 都是同一形状:
```json
{ "code": "validation-failed", "message": "启动参数不合要求:candidateInternalPns 必填,至少一项", "detail": [ { "key": "candidateInternalPns", "message": "必填,至少一项" } ] }
```
| 状态 | `code` | 何时 | `detail` |
|---|---|---|---|
| 400 | `validation-failed` | 查询参数 / 路径参数 / 请求体不合 schema;启动参数缺必填 / 类型不对 / select 值不在候选 / 不认识的参数名 / 责任人不在职 | schema 不合 → zod issues 数组;启动参数问题 → `[{ key, message }]`,`key` 是哪一格(责任人那一条 `key='ownerId'`) |
| 401 | `unauthenticated` | 没带 token / 过期 / 票据坏了 | — |
| 403 | `forbidden` | 登录人进不了运营中心 | `{ need, ends }` |
| 404 | `not-found` | 活动不存在;实例不存在或不属于这个活动;活动没配视图;文件序号不存在 | — |
| 409 | `conflict` | 启动:同一 `idempotencyKey` 的实例**正在创建**(60 秒内再来);或这枚键已用在别的活动上 | — |
| 412 | `precondition-failed` | 启动:活动已禁用;终止:实例已终态 | — |
| 500 | `internal` | 服务端出错 | — |
### 0.3 状态口径
| 项 | 值 |
|---|---|
| 活动状态 `ActivityStatus` | `active`(激活中)/ `disabled`(已禁用)。中文在 `@contracts` 的 `ACTIVITY_STATUS_LABEL` |
| 实例状态 `InstanceState` | 五态英文码:`pending`(待执行)/ `running`(进行中)/ `completed`(已完成)/ `rejected`(驳回)/ `failed`(失败);中文在 `INSTANCE_STATE_LABEL`。**没有第六态** —— 手动终止归 `failed`,靠 `reason.kind='terminated'` 区分「自己跑挂」。口径:`pending` = 建了单、推进器还没领到;`running` = 在途(含等人回传 / 监听中);`completed` = 放行完成;`rejected` = 验收被负责人打回、模型还没重新提交的那一段(重新提交后回 `running`);`failed` = 跑挂(受阻 / 停滞熔断 / 预算耗尽)与手动终止 |
| 禁用语义 | **任何时刻都可禁用**:只拦「新启动」(§5 回 412);已完成的实例是历史留痕,进行中的**继续跑完**;激活即恢复 |
| 活动档案 | 负责人 / 说明 / 图标 / 色相来自运营侧档案表,不在活动包里;**没建档时 `owner` 是 `null`、`desc` 是空串、`ico`/`hue` 缺席**,前端摆「—」并兜底图标色 |
## 1. 数据结构(`@contracts` 导出名在括号里)
字段列「必有」= 响应里一定带这个键(可能是 `null` 的会单独写);「可缺」= 键可能不出现。
### 1.1 `StaffRef`(`staffRefSchema`)
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | string | 名册 staffId |
| `name` | string | 姓名 |
### 1.2 `ActivitySummary`(`activitySummarySchema`)—— L1 卡片
| 字段 | 类型 | 必有 | 说明 |
|---|---|---|---|
| `id` | string | 是 | 活动 id,如 `part-substitution-assessment`;后面所有路径里的 `:activityId` |
| `name` | string | 是 | 活动名 |
| `biz` | string | 是 | 业务分类(卡片副题) |
| `status` | `ActivityStatus` | 是 | `active` / `disabled` |
| `version` | string | 是 | 当前生效的包版本,如 `1.5.7` |
| `owner` | `StaffRef` \| **null** | 是 | 活动负责人;没建档 = `null`,摆「—」 |
| `llm` | string | 是 | 绑定的模型(逻辑名,如 `deepseek-v4-flash`) |
| `instanceCount` | number | 是 | 该活动的实例总数(含测试台试跑单) |
| `createdAt` | string(ISO) | 是 | 首个版本装入的时刻 |
| `ico` | string | 可缺 | 语义图标名;缺省前端兜底 `inbox` |
| `hue` | `'blue' \| 'teal' \| 'amber' \| 'violet'` | 可缺 | 业务色相;缺省前端兜底 `blue` |
### 1.3 `ActivityDetail`(`activityDetailSchema`)—— L2 头部
`ActivitySummary` **去掉 `instanceCount`**,加:
| 字段 | 类型 | 必有 | 说明 |
|---|---|---|---|
| `desc` | string | 是 | 定义说明;没写就是 `''` |
| `view` | `{ url: string }` \| **null** | 是 | 沙箱要渲染的活动视图地址(相对路径,见 §2.3);`null` = 藏起整个沙箱区块 |
### 1.4 `InstanceSummary`(`instanceSummarySchema`)—— L2 表格一行
| 字段 | 类型 | 必有 | 说明 |
|---|---|---|---|
| `id` | string | 是 | 实例的**寻址键**(任务 id,uuid):L3 路由、终止、文件都用它 |
| `no` | string | 是 | 人读实例号 `INS-<序号>`(表格上摆它、模糊搜也认它) |
| `state` | `InstanceState` | 是 | 五态 |
| `owner` | `StaffRef` | 是 | 责任人 |
| `input` | string | 是 | 输入一句话摘要(≤80 字,可为 `''`) |
| `output` | string \| **null** | 是 | 产出一句话摘要;只有 `completed` 才有值(文件名,或「已发口:<口名>」);`null` 摆 `-` |
| `startedAt` | string(ISO) | 是 | 启动时刻 |
### 1.5 `InstanceStats` / `InstanceList`(`instanceStatsSchema` / `instanceListSchema`)
| 字段 | 类型 | 说明 |
|---|---|---|
| `stats.total` | number | 该活动实例总数(**不随检索条件动**,页头统计带用) |
| `stats.byState` | `Record<InstanceState, number>` | 五个键**都在**(没有的是 0) |
| `total` | number | **筛后**条数,分页器按它 |
| `page` | number | 当前页(回显请求值,默认 1) |
| `size` | number | 单页条数(回显请求值,默认 8) |
| `items` | `InstanceSummary[]` | 当前页,启动时间倒序 |
### 1.6 `LaunchParam` / `LaunchParams`(`launchParamSchema` / `launchParamsSchema`)—— 启动表单规格
`LaunchParams`:
| 字段 | 类型 | 说明 |
|---|---|---|
| `activityId` | string | |
| `version` | string | 参数表对应的包版本 |
| `params` | `LaunchParam[]` | **按这个顺序渲染表单** |
`LaunchParam`:
| 字段 | 类型 | 必有 | 说明 |
|---|---|---|---|
| `key` | string | 是 | 提交时 `params` 里的键名(英文) |
| `label` | string | 是 | 表单上的中文名 |
| `type` | `'text' \| 'textarea' \| 'select' \| 'combo' \| 'list'` | 是 | 控件种类:单行 / 多行 / 单选(只能选候选)/ **选择+新增**(候选末项「新增」,选了换输入框自填)/ 字符串数组(逐行自由填,可加多项)。**遇到不认识的 type 摆「暂不支持的控件」**。前端只按 `type` 渲染 —— 活动契约里是数组的字段也可能摆成 `combo`/`select`(只选一颗,提交单个字符串,后端包成数组),前端不必知道 |
| `required` | boolean | 可缺 | 缺席 = 选填。前端没填完禁提交;服务端仍再验一道(400) |
| `desc` | string | 可缺 | 控件下的辅助说明 |
| `placeholder` | string | 可缺 | text / textarea / combo(新增态)/ list 用 |
| `options` | `{ value: string; label: string }[]` | 可缺 | `select` / `combo` 的候选,两段式:`value` 是提交值,`label` 是显示名。**combo 可能没有这个键**(候选读不到),那时就是自由输入 |
| `default` | string \| string[] | 可缺 | 预填值:`list` 给字符串数组,其余给字符串 |
### 1.7 `LaunchInput`(`launchInputSchema`)—— 启动请求体
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `idempotencyKey` | string(1–128) | 是 | 幂等键:**每开一次抽屉生成一枚**(`crypto.randomUUID()`);重复点提交、网络重试都带同一枚 |
| `ownerId` | string | 是 | 责任人 staffId(固定字段,不在参数表里);默认当前登录人 `me.staff.id`,允许改;只列 `GET /api/gov/staff` 里 `state='active'` 的 |
| `params` | `Record<string, string \| string[]>` | 是 | 键 = `LaunchParam.key`;`list` 传 `string[]`,其余传 `string`;`select` 传某个 `options[].value`;`combo` 传候选的 `value` **或**新增的原样字符串(两种都合法);选填没填可以不传或传 `''`。服务端宽收:活动契约里是数组的字段(含 `list`)传 `string` 或 `string[]` 都行,统一归成数组交活动 |
### 1.8 `InstanceFile`(`instanceFileSchema`)
| 字段 | 类型 | 说明 |
|---|---|---|
| `index` | number | 在同一侧(input / output)文件里的序号,从 0 起;§7 下载用 |
| `name` | string | 文件名(含扩展名) |
| `contentType` | string | MIME |
| `size` | number | 字节数 |
### 1.9 `InstanceReason`(`instanceReasonSchema`)
| 字段 | 类型 | 说明 |
|---|---|---|
| `kind` | `'rejected' \| 'failed' \| 'terminated'` | 驳回 / 自己跑挂 / 手动终止 |
| `text` | string | 人话原因,直接摆。驳回:「验收驳回:<负责人写的意见> —— 负责人 <名>」(没写意见时「验收驳回:负责人未写明具体意见,已退回重做」);失败:引擎原话(预算耗尽已翻成「预算耗尽:步数 90/90、活跃时长 120/120 分钟」);终止:「由 <操作人> 手动终止」 |
### 1.10 `InstanceSource`(`instanceSourceSchema`)
| 字段 | 类型 | 说明 |
|---|---|---|
| `kind` | string | `manual`(运营端手动启动)/ `upstream`(上游工作流节点)/ `mail` 等通道名(来件触发)/ `testbed`(测试台试跑)/ `system`(其余)。**开放枚举**,不认识的按 `label` 摆 |
| `label` | string | 人读形式,直接摆:`手动启动 · 柴浩` / `上游 · <工作流主题>` / `邮件 · <发件方>` / `测试台试跑` / `系统起单` |
### 1.11 `InstanceStep`(`instanceStepSchema`)—— L3 时间线一行
| 字段 | 类型 | 说明 |
|---|---|---|
| `seq` | number | 从 1 起,按活动案例模型的步骤序 |
| `name` | string | 步骤名(活动定义的计划项名) |
| `by` | `'agent' \| 'human'` | 这一步谁办:AI / 人 |
| `byName` | string | `agent` → 模型名(同 `llm`);`human` → 责任人姓名 |
| `at` | string(ISO) \| null | 开始时刻;没到这一步 = `null` |
| `durationMs` | number \| null | 用时(毫秒);未完成 = `null`。格式化如 `6m` |
| `res` | `'done' \| 'fail' \| 'current' \| 'pending'` | 已完成 / 出事(性质看 `reason.kind`:驳回 · 失败 · 已终止)/ 正在办 / 未到 |
| `note` | string | 这一步交出什么的一句话;可为 `''` |
### 1.12 `InstanceDetail`(`instanceDetailSchema`)—— L3 整页
| 字段 | 类型 | 必有 | 说明 |
|---|---|---|---|
| `id` | string | 是 | 任务 id |
| `no` | string | 是 | 人读实例号 |
| `activityId` | string | 是 | |
| `state` | `InstanceState` | 是 | |
| `owner` | `StaffRef` | 是 | 责任人 |
| `llm` | string | 是 | 模型名 |
| `startedAt` | string(ISO) | 是 | |
| `endedAt` | string(ISO) \| **null** | 是 | 未结束 = `null`;终止时 = 终止时刻 |
| `durationMs` | number \| **null** | 是 | `endedAt − startedAt`;未结束 = `null` |
| `reason` | `InstanceReason` \| **null** | 是 | `null` = 不需要(待执行 / 进行中 / 正常完成) |
| `input.content` | string | 是 | 输入一句话摘要(= 清单上的 `input`) |
| `input.source` | `InstanceSource` | 是 | 来源 |
| `input.submittedAt` | string(ISO) | 是 | 提交时刻(= `startedAt`) |
| `input.files` | `InstanceFile[]` | 是 | 输入文件元数据(可为空数组) |
| `output.files` | `InstanceFile[]` | 是 | 产出文件元数据;没产出是 `[]`,摆「暂无产出」 |
| `steps` | `InstanceStep[]` | 是 | 时间线 |
| | | | **第 9 步的 `res` 与 `at` 跟第 8 步走**(服务端交出前改好,2026-08-28 定):放行那一步没有自己的收步事件,不这么做已放行的单第 9 步永远 `pending`;用时不编 |
注意:详情里**没有** `input`/`output` 那两句摘要字符串(它们是清单行的字段),`input`/`output` 在详情里是上面的结构体。
## 2. L1 / L2 活动
### 2.1 活动清单 —— `GET /api/gov/activities`(`listOpsActivitiesRoute`)
**请求**
| 位置 | 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| query | `status` | `'active' \| 'disabled'` | 否 | L1 两个页签各取一份;不带 = 全量。别的值 400 |
**响应 200**
| 字段 | 类型 | 说明 |
|---|---|---|
| `activities` | `ActivitySummary[]` | 量小不分页;顺序 = 活动库顺序 |
```json
{
"activities": [
{
"id": "part-substitution-assessment",
"name": "ACT-WORK-05 · 差异评估",
"biz": "需求分析与方案匹配",
"status": "active",
"version": "1.5.7",
"owner": null,
"llm": "deepseek-v4-flash",
"instanceCount": 12,
"createdAt": "2026-08-26T01:00:00.000Z",
"ico": "git-compare",
"hue": "teal"
}
]
}
```
### 2.2 活动详情 —— `GET /api/gov/activities/:activityId`(`getOpsActivityRoute`)
**请求**:路径参数 `activityId`(string,必填)。
**响应 200**:`ActivityDetail`。**404**:活动不存在。
```json
{
"id": "part-substitution-assessment",
"name": "ACT-WORK-05 · 差异评估",
"biz": "需求分析与方案匹配",
"status": "active",
"version": "1.5.7",
"owner": { "id": "8f3c…", "name": "柴浩" },
"llm": "deepseek-v4-flash",
"createdAt": "2026-08-26T01:00:00.000Z",
"ico": "git-compare",
"hue": "teal",
"desc": "对候选料号逐项比较参数、判四态、定推荐等级,审核后落 P-11 替代关系并回写 P-01。",
"view": { "url": "/api/gov/activities/part-substitution-assessment/view" }
}
```
### 2.3 活动视图(沙箱)—— `GET /api/gov/activities/:activityId/view`(`opsActivityViewRoute`)
**请求**
| 位置 | 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| path | `activityId` | string | 是 | |
| query | `ticket` | string | 是(iframe 场景) | `<iframe src>` 带不了请求头:**装载那一刻** `call(authTicketRoute)` 取一张拼上,不要渲染时预取(60 秒过期) |
**响应 200**:`content-type: text/html`(`cache-control: no-store`),整份 HTML,`<iframe sandbox="allow-scripts" src="...">` 直接渲染。
**404**:活动不存在 / 该活动没配视图(`detail.view === null` 时压根别请求)。
**沙箱里摆什么**(2026-08-27 定):活动包里名为 `activity-ledger` 的视图 —— **关键台账数据看板**,
摆本活动**最近一次已放行实例**的落账快照(页头:实例号 / 开案与放行时刻 / 负责人 / 案号 / 关联需求 / 评估基准与候选;
正文:P-01 一条 · P-08 逐颗料 + 参数瓷贴 · P-11 主记录 + 差异明细 · P-10 · P-06 本单挂的 + 同料号历史)。
行由服务端按活动清单 `viewTemplates[].data` 的取数声明从**正式表**圈出(在办中的单不摆),渲染完再注入报高脚本。
三种局面页面自己说清、都回 200:有落账(`v-ready`)/ 本活动还没有已放行实例(`v-none`,只留页头与横幅)/
最近那一单已放行但案号在正式表里查无(`v-missing`,多半是起单时关了「落真库」)。前端不需要为这三态做任何事。
**高度协议**:页面自己会向父页 `postMessage({ type: 'jcai:view-height', height: <number> }, '*')`(load、尺寸变化、每秒兜底)。父页:
```ts
window.addEventListener('message', (e) => {
if (e.source !== iframe.contentWindow) return; // 沙箱帧 origin 是 'null',按 source 认,不按 origin
if (e.data?.type !== ACTIVITY_VIEW_HEIGHT_MESSAGE) return; // 常量在 @contracts,= 'jcai:view-height'
setHeight(Number(e.data.height));
});
```
## 3. L2 实例清单 —— `GET /api/gov/activities/:activityId/instances`(`listOpsInstancesRoute`)
**请求**
| 位置 | 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| path | `activityId` | string | 是 | |
| query | `id` | string | 否 | 实例号**模糊**搜,如 `INS-24`:对 `no` 与 `id` 的子串,不分大小写 |
| query | `ownerId` | string(staffId) | 否 | 精确到人 |
| query | `state` | `InstanceState` | 否 | 精确;别的值 400 |
| query | `startedFrom` | `YYYY-MM-DD` | 否 | 启动日期段起点,**含整日**;格式不对 400 |
| query | `startedTo` | `YYYY-MM-DD` | 否 | 终点,**含整日**;可只给一端 |
| query | `page` | number ≥ 1 | 否 | 默认 1 |
| query | `size` | number 1–50 | 否 | 默认 8(与前端单页一致);>50 → 400 |
条件之间 AND;都不带 = 全部。
**响应 200**:`InstanceList`。**404**:活动不存在。
```json
{
"stats": { "total": 13, "byState": { "pending": 1, "running": 1, "completed": 7, "rejected": 2, "failed": 2 } },
"total": 13,
"page": 1,
"size": 8,
"items": [
{
"id": "0c9d3a1e-…",
"no": "INS-2417",
"state": "running",
"owner": { "id": "8f3c…", "name": "柴浩" },
"input": "工业电源项目在用 LM2596S-5.0(TI,12V→5V),原厂 PCN 转 NRND…",
"output": null,
"startedAt": "2026-08-25T02:12:00.000Z"
},
{
"id": "7b21e6c0-…",
"no": "INS-2415",
"state": "completed",
"owner": { "id": "8f3c…", "name": "柴浩" },
"input": "…",
"output": "已发口:assessment-released",
"startedAt": "2026-08-24T09:03:00.000Z"
}
]
}
```
- 页头统计带摆 `stats`(全局),分页器按 `total`(筛后)走;筛中提示「筛出 `total` / 共 `stats.total`」。
- 负责人下拉的候选用 `GET /api/gov/staff`(在职的),提交 `ownerId`。
- 前端**不再**本地过滤 / 分页 / 数统计,全部上移到这里。
## 4. 启动参数表 —— `GET /api/gov/activities/:activityId/launch-params`(`opsLaunchParamsRoute`)
点「启动活动」开抽屉时取。**一活动一份,前端只按 `params` 渲染表单,不写死字段。**
**请求**:路径参数 `activityId`。**响应 200**:`LaunchParams`。**404**:活动不存在。
```json
{
"activityId": "part-substitution-assessment",
"version": "1.5.7",
"params": [
{
"key": "customerCode",
"label": "客户",
"type": "combo",
"required": true,
"desc": "台账里有就直接选;是新客户就自己填客户编码,启动后派数据录入卡建档。",
"placeholder": "CU-0142",
"options": [
{ "value": "CU-0142", "label": "Itelma" },
{ "value": "CU-2039", "label": "青岛新星电子有限公司" }
]
},
{
"key": "customerName",
"label": "客户名称(仅客户不在库时填)",
"type": "text",
"desc": "新客户先把名字摆上,启动后建档时让人核。",
"placeholder": "深圳恒星电源有限公司"
},
{
"key": "requestDescription",
"label": "客户需求",
"type": "textarea",
"required": true,
"desc": "保留原始需求语义,据此新建 P-01 需求主记录(版本 V1)。",
"placeholder": "工业电源项目在用 LM2596S-5.0(TI,12V→5V),原厂 PCN 转 NRND,客户要求验证替代料"
},
{
"key": "originalInternalPn",
"label": "原内部料号",
"type": "combo",
"required": true,
"desc": "差异评估基准;库里没有该 PN 也可启动,启动后派数据工单补参数。",
"placeholder": "LM2596S-5.0",
"options": [{ "value": "LM2596S-5.0", "label": "LM2596S-5.0" }]
},
{
"key": "candidateInternalPns",
"label": "候选内部料号",
"type": "combo",
"required": true,
"desc": "被评估物料;库里有就直接选,没有就自己填料号,启动后经数据工单创建。",
"placeholder": "XL2596S-5.0",
"options": [{ "value": "LM2596S-5.0", "label": "LM2596S-5.0" }, { "value": "LM2596S-ADJ", "label": "LM2596S-ADJ" }]
}
]
}
```
W05 的候选内部料号(2026-08-27 用户定)与原内部料号**同款下拉、只选一颗**:清单里它仍是数组字段,前端按 `type='combo'` 渲染、提交单个字符串,后端包成一元数组交给活动 —— 活动契约不变。
**combo 语义(2026-08-27 用户定)**:启动表单大部分是自由输入;客户 / 料号这类「台账里可能有」的参数才用 combo —— 先选,候选里没有就新增自填。**提交候选之外的值是合法的,不拦启动**:服务端按值在台账里查,查不到照样启动,启动后派**数据工单**到工作台补这条档案(客户进客户台账、PN 补参数,确认即入库,活动从断点续跑)。combo 提交的值统一是字符串:选中候选传它的 `value`,新增传用户填的原样字符串。
候选量大到不宜内联时(如全量物料台账选 PN),后续加 `optionsSource` 引用检索接口;**本期一律内联 `options`**(封顶 500 行)。
> 活动清单侧怎么声明(前端不用管,给写活动包的人看):`properties.<key>` 上 `title`→label、`description`→desc、`examples[0]`→placeholder、`default`、`required[]`;`enum`+`enumNames`→select;`'x-control'` 定标量控件(`text|textarea|select|combo`);`'x-options': { ledger:'域.对象', value:'列', label:'列' }` 让候选从正式台账现读;`type:'array'` 且元素是字符串→list,**数组字段标 `x-control: combo|select` 就摆成单选**(提交单个字符串,后端包成一元数组);**`'x-order': <数字>` 定表单次序**(清单存在 jsonb 里,书写顺序到不了这里);`format:'file'` 的字段本期不上表单;select 没候选降成 text。
## 5. 启动活动(创建实例)—— `POST /api/gov/activities/:activityId/instances`(`launchOpsInstanceRoute`)
**请求**:路径参数 `activityId`;请求体 `LaunchInput`(§1.7)。
```json
{
"idempotencyKey": "b5a2d6f0-3e1c-4a7b-9d2e-8f1c0a4b7e93",
"ownerId": "8f3c1a2b-6c1e-4b3a-9f0e-2d5c7a1b9e44",
"params": {
"customerCode": "CU-0142",
"requestDescription": "工业电源项目在用 LM2596S-5.0,原厂 PCN 转 NRND,客户要求验证替代料",
"originalInternalPn": "LM2596S-5.0",
"candidateInternalPns": "XL2596S-5.0"
}
}
```
`candidateInternalPns` 在活动契约里是数组:表单单选传 `"XL2596S-5.0"`,服务端包成 `["XL2596S-5.0"]`;传 `["XL2596S-5.0", "LM2596S-ADJ"]` 也照收。
**响应 200**:`InstanceDetail`(§1.12,与 §6 同形)——新建的实例,初始 `state='pending'`(推进器领到即转 `running`,靠 SSE 失效重取)。**同一 `idempotencyKey` 重放也回 200,给的是第一次创建的那一单,不起第二个。**
**错误**
| 状态 | 何时 | `detail` |
|---|---|---|
| 400 | 请求体不合 schema | zod issues |
| 400 | 启动参数缺必填 / 标量格传了数组 / `select` 值不在候选 / 不认识的参数名 | `[{ key, message }]`,逐格点名 —— 前端把 `message` 摆到对应控件下 |
| 400 | 责任人不在名册 / 已停用 | `[{ key: 'ownerId', message }]` |
| 404 | 活动不存在 | — |
| 409 | 同一 `idempotencyKey` 的实例正在创建(60 秒内);或这枚键已用在别的活动上 | — |
| 412 | 活动已禁用(先激活再启动) | — |
成功后:关抽屉、失效 `['gov']`、按返回的 `id` 跳 L3(路由 id = `${activityId}:${id}`)。存在性检查(客户 / 料号在不在台账)在启动后由活动做,查不到不拦启动 —— 响应里不单独回这件事,工单出现在工作台那一侧。
## 6. L3 实例详情 —— `GET /api/gov/activities/:activityId/instances/:instanceId`(`getOpsInstanceRoute`)
**请求**:路径参数 `activityId`、`instanceId`(= 清单行的 `id`,任务 uuid,**不是** `no`)。
**响应 200**:`InstanceDetail`(§1.12)。**404**:实例不存在或不属于这个活动。
```json
{
"id": "0c9d3a1e-…",
"no": "INS-2417",
"activityId": "part-substitution-assessment",
"state": "running",
"owner": { "id": "8f3c…", "name": "柴浩" },
"llm": "deepseek-v4-flash",
"startedAt": "2026-08-25T02:12:00.000Z",
"endedAt": null,
"durationMs": null,
"reason": null,
"input": {
"content": "工业电源项目在用 LM2596S-5.0(TI,12V→5V),原厂 PCN 转 NRND…",
"source": { "kind": "manual", "label": "手动启动 · 柴浩" },
"submittedAt": "2026-08-25T02:12:00.000Z",
"files": [
{ "index": 0, "name": "XL2596S-5.0.pdf", "contentType": "application/pdf", "size": 48210 }
]
},
"output": {
"files": [
{ "index": 0, "name": "差异评估报告.docx", "contentType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "size": 6102 }
]
},
"steps": [
{ "seq": 1, "name": "建立评估上下文:绑定四项、新建需求、存在性核对", "by": "agent", "byName": "deepseek-v4-flash", "at": "2026-08-25T02:12:00.000Z", "durationMs": 60000, "res": "done", "note": "铸案号、建需求 V1、锁规则版本、逐个料号查库、写存在性核对表" },
{ "seq": 2, "name": "数据工单:补建芯片主数据(P-08)", "by": "human", "byName": "柴浩", "at": "2026-08-25T02:13:00.000Z", "durationMs": null, "res": "current", "note": "" },
{ "seq": 3, "name": "评估输入与资料补齐确认", "by": "human", "byName": "柴浩", "at": null, "durationMs": null, "res": "pending", "note": "" }
]
}
```
三种终局的 `reason` 示例:
```json
{ "kind": "rejected", "text": "验收驳回:测算毛利低于阈值,退回重新测算 —— 负责人 柴浩" }
{ "kind": "failed", "text": "预算耗尽:步数 90/90、活跃时长 120/120 分钟" }
{ "kind": "terminated", "text": "由 柴浩 手动终止" }
```
- 头部:状态丸(`state`)+ 责任人 + 启动 / 结束 · 耗时(`startedAt` / `endedAt` / `durationMs`)+ 模型(`llm`)。
- 驳回 / 失败 / 已终止出原因块(`reason.text`);`reason === null` 不摆。
- 时间线按 `steps` 渲染;`res='fail'` 那一行的字眼按 `reason.kind` 选(驳回 / 失败 / 已终止)。
- 右栏输入(`input.content` / `input.source.label` / `input.submittedAt` / `input.files`)、输出(`output.files`;空数组摆「暂无产出」);文件行的下载走 §7。
## 7. 实例文件下载 —— `GET /api/gov/activities/:activityId/instances/:instanceId/files/:io/:index`(`opsInstanceFileRoute`)
**请求**
| 位置 | 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| path | `activityId` | string | 是 | |
| path | `instanceId` | string | 是 | 任务 id |
| path | `io` | `'input' \| 'output'` | 是 | 哪一侧;别的值 400 |
| path | `index` | number ≥ 0 | 是 | 对应 §6 文件行的 `index`;不是整数 → 400 |
| query | `raw` | `'1'` | 否 | 带 `raw=1` 直出文件字节(`content-type` = 文件 MIME,`content-disposition: attachment; filename*=UTF-8''…`);不带回 JSON |
| query | `ticket` | string | `<a href download>` 场景必填 | 点击那一刻取票据拼上(`shared` 里的 `downloadWithTicket(url)`) |
**响应 200**(不带 `raw`):
| 字段 | 类型 | 说明 |
|---|---|---|
| `name` | string | 文件名 |
| `contentType` | string | MIME |
| `dataBase64` | string | 文件字节的 base64(与 `GET /api/gov/upload/:id` 同形) |
**404**:实例不存在 / 该序号没有文件。
下载钮的写法:`downloadWithTicket(`/api/gov/activities/${activityId}/instances/${id}/files/${io}/${index}?raw=1`)`。
## 8. 禁用 / 激活 / 终止
### 8.1 禁用 —— `POST /api/gov/activities/:activityId/disable`(`disableOpsActivityRoute`)
**请求**:路径参数 `activityId`;**body 空**。
**响应 200**:`ActivityDetail`(`status='disabled'`)。**404**:活动不存在。已禁用再禁用 = 幂等 200。
任何时刻都可禁用,没有前置条件:禁用后 §5 回 412;已完成的实例是历史留痕,进行中的继续跑完。
### 8.2 激活 —— `POST /api/gov/activities/:activityId/activate`(`activateOpsActivityRoute`)
**请求**:路径参数 `activityId`;**body 空**。
**响应 200**:`ActivityDetail`(`status='active'`)。**404**:活动不存在。已激活再激活 = 幂等 200。
### 8.3 终止实例 —— `POST /api/gov/activities/:activityId/instances/:instanceId/terminate`(`terminateOpsInstanceRoute`)
**请求**:路径参数 `activityId`、`instanceId`;**body 空**(终止理由本期不填,服务端生成「由 <操作人> 手动终止」)。
**响应 200**:`InstanceDetail`,`state='failed'`、`reason.kind='terminated'`、`endedAt` = 终止时刻。
**404**:实例不存在。**412**:实例已终态(已完成 / 已失败 / 已终止的不能再终止;`pending` 与 `running` 都可以)。
前端:二次确认(轻气泡)后调用;成功后由服务端重投影(广播 → 失效 → 重取),不要就地改 state。
## 9. 类型附录(`packages/contracts/src/activity-ops.ts`,同名导出)
```ts
type ActivityStatus = 'active' | 'disabled';
type InstanceState = 'pending' | 'running' | 'completed' | 'rejected' | 'failed';
type StaffRef = { id: string; name: string };
type ActivitySummary = { id: string; name: string; biz: string; status: ActivityStatus; version: string;
owner: StaffRef | null; llm: string; instanceCount: number; createdAt: string; ico?: string; hue?: 'blue' | 'teal' | 'amber' | 'violet' };
type ActivityDetail = Omit<ActivitySummary, 'instanceCount'> & { desc: string; view: { url: string } | null };
type InstanceSummary = { id: string; no: string; state: InstanceState; owner: StaffRef; input: string; output: string | null; startedAt: string };
type InstanceStats = { total: number; byState: Record<InstanceState, number> };
type InstanceList = { stats: InstanceStats; total: number; page: number; size: number; items: InstanceSummary[] };
type LaunchParamType = 'text' | 'textarea' | 'select' | 'combo' | 'list';
type LaunchParam = { key: string; label: string; type: LaunchParamType; required?: boolean; desc?: string;
placeholder?: string; options?: { value: string; label: string }[]; default?: string | string[] };
type LaunchParams = { activityId: string; version: string; params: LaunchParam[] };
type LaunchInput = { idempotencyKey: string; ownerId: string; params: Record<string, string | string[]> };
type InstanceFile = { index: number; name: string; contentType: string; size: number };
type InstanceReason = { kind: 'rejected' | 'failed' | 'terminated'; text: string };
type InstanceSource = { kind: string; label: string };
type InstanceStep = { seq: number; name: string; by: 'agent' | 'human'; byName: string;
at: string | null; durationMs: number | null; res: 'done' | 'fail' | 'current' | 'pending'; note: string };
type InstanceDetail = Omit<InstanceSummary, 'input' | 'output'> & { activityId: string; llm: string; endedAt: string | null; durationMs: number | null;
reason: InstanceReason | null;
input: { content: string; source: InstanceSource; submittedAt: string; files: InstanceFile[] };
output: { files: InstanceFile[] };
steps: InstanceStep[] };
```
路由常量(`packages/contracts/src/api/activity-ops.ts`):
| 常量 | 方法 · 路径 |
|---|---|
| `listOpsActivitiesRoute` | GET `/api/gov/activities` |
| `getOpsActivityRoute` | GET `/api/gov/activities/:activityId` |
| `opsActivityViewRoute` | GET `/api/gov/activities/:activityId/view` |
| `listOpsInstancesRoute` | GET `/api/gov/activities/:activityId/instances` |
| `opsLaunchParamsRoute` | GET `/api/gov/activities/:activityId/launch-params` |
| `launchOpsInstanceRoute` | POST `/api/gov/activities/:activityId/instances` |
| `getOpsInstanceRoute` | GET `/api/gov/activities/:activityId/instances/:instanceId` |
| `opsInstanceFileRoute` | GET `/api/gov/activities/:activityId/instances/:instanceId/files/:io/:index` |
| `disableOpsActivityRoute` | POST `/api/gov/activities/:activityId/disable` |
| `activateOpsActivityRoute` | POST `/api/gov/activities/:activityId/activate` |
| `terminateOpsInstanceRoute` | POST `/api/gov/activities/:activityId/instances/:instanceId/terminate` |
其它导出:`INSTANCE_STATES` / `INSTANCE_STATE_LABEL`(五态中文)、`ACTIVITY_STATUSES` / `ACTIVITY_STATUS_LABEL`、`ACTIVITY_HUES`、`LAUNCH_PARAM_TYPES`、`ACTIVITY_VIEW_HEIGHT_MESSAGE`、`ACTIVITY_OPS_ROUTES`。
调用示例(前端唯一通道 `shared/typed-fetch`):
```ts
import { call } from '@web-shared/typed-fetch.ts';
import { launchOpsInstanceRoute, listOpsInstancesRoute } from '@contracts';
import type { InstanceDetail, InstanceList } from '@contracts';
const list = await call<InstanceList>(listOpsInstancesRoute, { params: { activityId }, query: { state: 'running', page: '2', size: '8' } });
const created = await call<InstanceDetail>(launchOpsInstanceRoute, { params: { activityId }, body: { idempotencyKey, ownerId, params: values } });
// 失败抛 ApiCallError { status, code, message, detail }
```
## 10. 前端对接要点(现状 → 接口落地)
接口已落地,前端仍是占位数据(对接另起一轮,2026-08-27 用户定)。届时照这张表换:
| 现状(占位) | 接口落地后 |
|---|---|
| `DEMO_ACTIVITIES` 静态数组 | §2.1;L1 页签切换即换 `status` 重取;`owner` 为 null 摆「—」,`ico`/`hue` 缺省兜底 |
| `demoInstances()` 每活动共用一份 | §3;检索四条件与分页全部上移到查询参数,前端删掉本地过滤/分页;表格摆 `no`,寻址用 `id` |
| 统计带由前端从全量数组数 | §3 `stats`(服务端给,分页下才数得准) |
| 中文状态字面量当键 | 五态英文码 + `INSTANCE_STATE_LABEL`,前端一张 `Record<InstanceState, {tone, ico}>` 映射 |
| `LAUNCH_PARAMS` 占位参数表 | §4;`options` 从纯字符串升级为 `{value,label}` 两段(渲染器小改);控件种类已含 `combo` |
| `onLaunch` 在清单头落假行 | §5;抽屉每开一次生成 `idempotencyKey`(`crypto.randomUUID()`),提交走 mutation,400 的 `detail[].key` 对回控件,成功失效 `['gov']` 并跳 L3 |
| 责任人固定字段 | §5 `ownerId`;抽屉加「固定参数」段(名册下拉取 `GET /api/gov/staff` 在职的人,默认当前登录人 `me.staff.id`) |
| 禁用/激活/终止就地改 state | §8;mutation 成功后由服务端重投影(广播 → 失效 → 重取);L2 行内「终止活动」也要二次确认 |
| `demoSteps` / `terminatedSteps` 五步占位 | §6 `steps`;终止归态以 `reason.kind='terminated'` 为准,前端不再落「失败」占位 |
| 沙箱 `src="/sandbox-demo/activity-view.html"` 固定 480 高 | §2.3 `view.url` + 票据(装载那一刻取);`view` 为 null 藏起沙箱区;高度听 `jcai:view-height` |
| 输出下载钮置灰 | §7 已落地:`downloadWithTicket(url + '?raw=1')`(从 `desk-task/internal/FileViewer.tsx` 抽到 shared) |
| 设计中心活动库 / 测试台的 `useGovActivities` | 路径改名对它们透明(import 的是路由对象),无需改动 |