- contracts 补建 activity-ops(schema + 11 条路由);L1/L2/L3 全真接口对接(清单/详情/实例/启动抽屉/沙箱) - 启动抽屉:参数表服务端下发、幂等键防重、400 逐格报错;combo「新增」提为候选第一项 - 终止/禁用/激活轻气泡反馈闭环:在途留场转忙碌、成功自闭合并刷新、失败原因留气泡可重试 - L3 实例详情 7 秒轮询(待执行/进行中),终态自动停 - 员工模块四页改版:目录行+首字圆块、权限三槽点位、组织树/空态、成员详情 hero+角色开关;卡内容区内边距补齐
33 KiB
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 都是同一形状:
{ "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.1 |
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 摆「暂不支持的控件」 |
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 或新增的原样字符串(两种都合法);选填没填可以不传或传 '' |
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[] |
是 | 时间线 |
注意:详情里没有 input/output 那两句摘要字符串(它们是清单行的字段),input/output 在详情里是上面的结构体。
2. L1 / L2 活动
2.1 活动清单 —— GET /api/gov/activities(listOpsActivitiesRoute)
请求
| 位置 | 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| query | status |
'active' | 'disabled' |
否 | L1 两个页签各取一份;不带 = 全量。别的值 400 |
响应 200
| 字段 | 类型 | 说明 |
|---|---|---|
activities |
ActivitySummary[] |
量小不分页;顺序 = 活动库顺序 |
{
"activities": [
{
"id": "part-substitution-assessment",
"name": "ACT-WORK-05 · 差异评估",
"biz": "需求分析与方案匹配",
"status": "active",
"version": "1.5.1",
"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:活动不存在。
{
"id": "part-substitution-assessment",
"name": "ACT-WORK-05 · 差异评估",
"biz": "需求分析与方案匹配",
"status": "active",
"version": "1.5.1",
"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 时压根别请求)。
高度协议:页面自己会向父页 postMessage({ type: 'jcai:view-height', height: <number> }, '*')(load、尺寸变化、每秒兜底)。父页:
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:活动不存在。
{
"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:活动不存在。
{
"activityId": "part-substitution-assessment",
"version": "1.5.1",
"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": "list",
"required": true,
"desc": "被评估物料,一次可传多个、逐个评估 —— 至少 1 个;不在库的启动后经数据工单创建。",
"placeholder": "XL2596S-5.0"
}
]
}
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-order': <数字>定表单次序(清单存在 jsonb 里,书写顺序到不了这里);format:'file'的字段本期不上表单;select 没候选降成 text。
5. 启动活动(创建实例)—— POST /api/gov/activities/:activityId/instances(launchOpsInstanceRoute)
请求:路径参数 activityId;请求体 LaunchInput(§1.7)。
{
"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", "LM2596S-ADJ"]
}
}
响应 200:InstanceDetail(§1.12,与 §6 同形)——新建的实例,初始 state='pending'(推进器领到即转 running,靠 SSE 失效重取)。同一 idempotencyKey 重放也回 200,给的是第一次创建的那一单,不起第二个。
错误
| 状态 | 何时 | detail |
|---|---|---|
| 400 | 请求体不合 schema | zod issues |
| 400 | 启动参数缺必填 / list 没传数组 / 标量传了数组 / 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:实例不存在或不属于这个活动。
{
"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 示例:
{ "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,同名导出)
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):
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 的是路由对象),无需改动 |