Feat/activity l3 detail #2

Merged
ShanQin merged 2 commits from feat/activity-l3-detail into mvp-foundation 2026-08-27 11:16:29 +08:00
4 changed files with 413 additions and 29 deletions
Showing only changes of commit 723d379dbd - Show all commits
+5
View File
@@ -750,6 +750,11 @@
.g-lplist-add { justify-self: start; display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 12px; border: 1px dashed var(--cline); border-radius: 8px; background: transparent; color: var(--ink-3); font: inherit; font-size: 12px; font-weight: 600; cursor: pointer; transition: color var(--t-fast), border-color var(--t-fast); }
.g-lplist-add:hover { color: var(--brand); border-color: var(--brand); }
.g-launch-hint { margin: 13px 0 0; font-size: 11.5px; line-height: 1.6; color: var(--ink-4); }
/* combo 选择+新增(2026-08-27 用户定):新增态 = 输入框 + 右侧「返回选择」链 */
.g-launch .g-combo { display: flex; align-items: center; gap: 7px; }
.g-launch .g-combo .g-input { flex: 1; min-width: 0; }
.g-launch .g-combo-back { flex: none; padding: 4px 6px; border: 0; border-radius: 6px; background: transparent; color: var(--brand); font: inherit; font-size: 12px; font-weight: 600; cursor: pointer; }
.g-launch .g-combo-back:hover { text-decoration: underline; }
.g-fcard .mbtn { flex: none; width: 26px; height: 26px; display: grid; place-items: center; border: 1px solid transparent; border-radius: 7px; background: transparent; color: var(--ink-4); cursor: pointer; }
.g-fcard .mbtn:hover, .g-fcard .mbtn.on { border-color: var(--cline); background: #f3f6fa; color: var(--ink); }
.g-fcard .mbtn.bad:hover { border-color: #e6c4c4; background: #fdf2f2; color: var(--danger); }
@@ -317,6 +317,8 @@ function PopAsk({ spec, onCancel }: { spec: PopAskSpec; onCancel: () => void })
/* ── 启动活动 · 参数抽屉(2026-08-27 用户定)────────────────────────────
点「启动活动」弹抽屉填启动参数:**参数表由服务端按活动下发**(参数名/中文名/控件种类/必填/描述),
每个活动要的参数不一样 —— 前端只按规格渲染,不写死字段;
控件口径(2026-08-27 用户定):大部分是自由输入;客户/料号这类「台账里可能有」的用 combo
(选择+新增:先选,候选没有就新增自填)—— **填了库里没有的值不拦启动**,存在性检查在启动后做,缺口派数据工单;
接口未落地前用 LAUNCH_PARAMS 占位,提交先在清单头落一条「待执行」看全链路效果 */
/** 启动参数的一项规格 —— 将来就是接口返回的参数表行 */
@@ -325,24 +327,25 @@ type LaunchParam = {
key: string;
/** 表单上的中文名 */
label: string;
/** 控件种类:text 单行 / textarea 多行 / select 单选 / list 字符串数组(一次可传多个) */
type: 'text' | 'textarea' | 'select' | 'list';
/** 控件种类:text 单行 / textarea 多行 / select 单选(只能选) / combo 选择+新增(候选没有就自己填) / list 字符串数组(一次可传多个) */
type: 'text' | 'textarea' | 'select' | 'combo' | 'list';
required?: boolean;
/** 参数描述(服务端给),挂在控件下当辅助说明 */
desc?: string;
placeholder?: string;
/** select 的候选项 */
/** select / combo 的候选项 */
options?: readonly string[];
};
/** 占位参数表 —— 一活动一份,接口定稿后按活动取(2026-08-27 用户定:参数由服务器给,每个活动不一样) */
const LAUNCH_PARAMS: Readonly<Record<string, readonly LaunchParam[]>> = {
/* 极简四参(2026-08-26《一键启动入参说明》口径:客户/需求/原PN/候选PN数组,其余启动后进工单补) */
/* 极简四参(2026-08-26《一键启动入参说明》口径:客户/需求/原PN/候选PN数组,其余启动后进工单补);
客户与料号用 combo(2026-08-27 用户定):台账里有就先选,没有就新增自填 —— 不拦启动,缺口派数据工单 */
'quote-intake': [
{ key: 'customer_id', label: '客户', type: 'select', required: true, desc: '决定通知对象与默认负责人线。', options: ['Itelma', '伏尔加', 'GAZ', 'KAMAZ', 'UAZ', 'Lada'] },
{ key: 'customer_id', label: '客户', type: 'combo', required: true, desc: '台账里有就直接选;是新客户就自己填,启动后派数据工单补档案。', options: ['Itelma', '伏尔加', 'GAZ', 'KAMAZ', 'UAZ', 'Lada'] },
{ key: 'request_description', label: '客户需求', type: 'textarea', required: true, desc: '保留原始需求语义,据此新建需求主记录(V1)。', placeholder: '如:车身控制器询价 · 3 项物料,期望 9 月交期' },
{ key: 'original_internal_pn', label: '原内部料号', type: 'select', required: true, desc: '差异评估基准,引用物料台账。', options: ['PN-880101', 'PN-880205', 'PN-880310'] },
{ key: 'candidate_internal_pn', label: '候选内部料号', type: 'list', required: true, desc: '被评估物料,一次可传多个、逐个评估 —— 至少 1 个。', placeholder: '如 PN-880412' },
{ key: 'original_internal_pn', label: '原内部料号', type: 'combo', required: true, desc: '差异评估基准;库里没有该 PN 也可启动,启动后派数据工单补参数。', options: ['PN-880101', 'PN-880205', 'PN-880310'], placeholder: '如 PN-880501' },
{ key: 'candidate_internal_pn', label: '候选内部料号', type: 'list', required: true, desc: '被评估物料,一次可传多个、逐个评估;库里没有的 PN 照样填,至少 1 个。', placeholder: '如 PN-880412' },
],
'quote-pricing': [
{ key: 'quote_id', label: '询价单号', type: 'text', required: true, desc: '针对哪一张询价单做测算。', placeholder: '如 INS-2415' },
@@ -350,7 +353,7 @@ const LAUNCH_PARAMS: Readonly<Record<string, readonly LaunchParam[]>> = {
{ key: 'target_margin', label: '目标毛利率', type: 'text', desc: '选填,留空按客户线默认。', placeholder: '如 18%' },
],
'crm-coverage': [
{ key: 'customer_id', label: '客户', type: 'select', required: true, desc: '为哪家客户排触达计划。', options: ['Itelma', '伏尔加', 'GAZ', 'KAMAZ', 'UAZ', 'Lada'] },
{ key: 'customer_id', label: '客户', type: 'combo', required: true, desc: '为哪家客户排触达计划;新客户直接填,启动后补档案。', options: ['Itelma', '伏尔加', 'GAZ', 'KAMAZ', 'UAZ', 'Lada'] },
{ key: 'cadence', label: '触达节奏', type: 'select', required: true, desc: '关键人触达的排期密度。', options: ['每周', '双周', '每月'] },
{ key: 'note', label: '补充说明', type: 'textarea', desc: '选填。' },
],
@@ -388,6 +391,57 @@ function paramFilled(p: LaunchParam, v: string | string[]): boolean {
return p.type === 'list' ? (v as string[]).some((s) => s.trim() !== '') : (v as string).trim() !== '';
}
/** combo 里「新增」这一项的哨兵值 —— 不会与真实候选撞名 */
const COMBO_CREATE = '__create__';
/**
* 选择+新增(2026-08-27 用户定):先摆选择框,候选末项是「新增 …」;
* 选了它就换成输入框自己填(库里没有的值也收 —— 不拦启动,缺口由服务端派数据工单),
* 旁边留「返回选择」换回去。
*/
function ComboInput({ p, value, onChange }: { p: LaunchParam; value: string; onChange: (v: string) => void }) {
const [custom, setCustom] = useState(false);
if (custom) {
return (
<span className="g-combo">
<input className="g-input" value={value} placeholder={p.placeholder ?? `输入新的${p.label}`} onChange={(e) => onChange(e.target.value)} />
<button
type="button"
className="g-combo-back"
onClick={() => {
setCustom(false);
onChange('');
}}
>
返回选择
</button>
</span>
);
}
return (
<select
className="g-select"
value={value}
onChange={(e) => {
if (e.target.value === COMBO_CREATE) {
setCustom(true);
onChange('');
} else {
onChange(e.target.value);
}
}}
>
<option value="">请选择…</option>
{(p.options ?? []).map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
<option value={COMBO_CREATE}>+ 新增{p.label}…</option>
</select>
);
}
/**
* 启动抽屉 —— 复用全局 `g-drawer` 骨架(与系统区模型抽屉同一副);
* 挂 body portal:详情页容器带入场动画 transform,fixed 抽屉不 portal 会被钉进卡片盒子里(2026-07-30 实测);
@@ -446,6 +500,7 @@ function LaunchDrawer({ name, spec, onCancel, onLaunch }: { name: string; spec:
))}
</select>
)}
{p.type === 'combo' && <ComboInput p={p} value={values[p.key] as string} onChange={(v) => setVal(p.key, v)} />}
{p.type === 'list' && (
<span className="g-lplist">
{(values[p.key] as string[]).map((v, i, arr) => (
@@ -499,7 +554,8 @@ function LaunchDrawer({ name, spec, onCancel, onLaunch }: { name: string; spec:
* 与「它跑得怎么样」(小统计 + 实例清单,点行进 L3)。
*
* 头部动作两个(2026-08-27 更新):
* · 激活/禁用 —— 本期做交互,二次确认;**有实例时无法禁用**(规则来自接口,本期前端占位演示);
* · 激活/禁用 —— 本期做交互,二次确认;**任何时刻都可禁用**(2026-08-27 用户重定:
* 禁用只拦新启动,不影响已完成与进行中的实例,废除「有实例不能禁用」);
* · 启动活动(即创建实例)—— 弹参数抽屉(LaunchDrawer),参数表按活动下发;已禁用不能启动。
*
* 沙箱区:加载服务器返回的活动视图 HTML —— iframe + sandbox 隔离;
@@ -508,7 +564,6 @@ function LaunchDrawer({ name, spec, onCancel, onLaunch }: { name: string; spec:
export function ActvPage({ id, nav }: { id: string; nav: Nav }) {
const a = DEMO_ACTIVITIES.find((x) => x.id === id);
const [status, setStatus] = useState<'active' | 'disabled' | undefined>(undefined);
const [blocked, setBlocked] = useState(false);
const [frameLoaded, setFrameLoaded] = useState(false);
/* 实例清单入组件状态:操作列的「终止活动」要就地改行(本期前端演示,接口对接后走 mutation) */
const [rows, setRows] = useState<readonly DemoInstance[] | undefined>(undefined);
@@ -618,15 +673,8 @@ export function ActvPage({ id, nav }: { id: string; nav: Nav }) {
title: '确认禁用这个活动?',
okLabel: '确认禁用',
danger: true,
onOk: () => {
/* 规则(2026-08-25 用户定):有实例时接口会提示无法禁用 —— 本期前端先演出这个拦截 */
if (a.instances > 0) {
setBlocked(true);
} else {
setStatus('disabled');
setBlocked(false);
}
},
/* 任何时刻都可禁用(2026-08-27 用户重定):只拦新启动,存量实例不受影响 */
onOk: () => setStatus('disabled'),
}),
}
: {
@@ -637,10 +685,7 @@ export function ActvPage({ id, nav }: { id: string; nav: Nav }) {
setAsk({
title: '确认激活这个活动?',
okLabel: '确认激活',
onOk: () => {
setStatus('active');
setBlocked(false);
},
onOk: () => setStatus('active'),
}),
},
/* 启动活动(2026-08-27 接):弹参数抽屉填参启动;已禁用不能启动 —— 先激活,再启动 */
@@ -653,11 +698,6 @@ export function ActvPage({ id, nav }: { id: string; nav: Nav }) {
{ask !== null && <PopAsk spec={ask} onCancel={() => setAsk(null)} />}
{launch && <LaunchDrawer name={a.name} spec={launchSpec} onCancel={() => setLaunch(false)} onLaunch={onLaunch} />}
</div>
{blocked && (
<Note tone="warn">
无法禁用:该活动还有 {a.instances} 个实例 —— 这条提示将来由接口返回(有实例时接口拒绝禁用),本期为前端占位演示。
</Note>
)}
<Sec ico="repeat" title="历史活动" extra={`共 ${instances.length} 个 · 查看详情进 L3`}>
{/* 统计带 + 检索条同一行(2026-08-26 用户定:检索放右侧)——
+337
View File
@@ -0,0 +1,337 @@
# API 文档 —— 活动模块(L1 清单 / L2 活动详情 / L3 实例详情)
> 本文是活动模块前端(运营中心 · 活动区,2026-08-25 重构模版)与服务端之间的接口约定,
> 给后端编写接口、前端取数用。鉴权 / 票据 / 错误体 / 端归属通则:`docs/API文档-鉴权与人员.md` §0,本文不重复。
> 需求出处:`docs/活动模块重构需求-2026-08-25.md`;启动参数口径:`ACT-WORK-05-一键启动入参说明.md`(极简四参)。
> 契约落地后唯一真相在 `packages/contracts/src/api/*.ts`(zod schema,前后端共用);两者不一致时以契约为准并回来改本文。
## 0. 通则(本文特有)
| 项 | 值 |
|---|---|
| 前缀 | `/api/gov/activities/*`;**全部 `access: 'ops'`** —— 活动区落在运营中心,与工作流/任务看板同端 |
| 时间 | 一律 ISO 8601 UTC 字符串(`2026-08-27T02:25:11.000Z`);**展示格式化是前端的事**(`YYYY-MM-DD HH:mm:ss` 本地时区) |
| 人员 | 凡出人,都是 `{ "id": "<staffId>", "name": "柴浩" }` 两段 —— 展示用 name,筛选/提交用 id;人员候选名单用既有 `GET /api/gov/staff`,本文不另做 |
| 广播 | 写路由(启动 / 禁用 / 激活 / 终止)成功后广播 `{kind:'system', id:'gov'}` —— 前端按既有套路失效 `['gov',*]` 重取 |
| 实例状态 | 五态,英文码:`pending`(待执行)/ `running`(进行中)/ `completed`(已完成)/ `rejected`(驳回)/ `failed`(失败);**没有第六态** —— 手动终止归入 `failed`,用 `reason.kind='terminated'` 区分「自己跑挂」(见 §6) |
| 禁用语义 | **任何时刻都可禁用**(2026-08-27 用户重定,废除「有实例不能禁用」):禁用只拦「新启动」—— 已完成的实例是历史留痕,进行中的实例**继续跑完不受影响**;激活即恢复启动能力 |
状态码怎么读(通则之外本文特有的):
| 状态 | code | 何时 |
|---|---|---|
| 400 | `validation-failed` | 入参不合 schema(`detail` 是 zod issues);启动参数缺必填 / 类型不对(`detail` 指明是哪个 `key`) |
| 404 | `not-found` | 活动 / 实例不存在;实例不属于这个活动 |
| 412 | `precondition-failed` | 启动:活动已禁用;终止:实例不在 `running` |
## 1. L1 活动清单
### `GET /api/gov/activities?status=active|disabled` —— access: `ops`
L1 两个页签(激活中 / 已禁用)各取一份;不带 `status` = 全量。量小,**不分页**。
```json
{
"activities": [
{
"id": "quote-intake",
"name": "询价接收与结构化",
"biz": "报价业务",
"status": "active",
"version": "1.3.0",
"owner": { "id": "8f3c…", "name": "柴浩" },
"llm": "K3 · quote-intake",
"instanceCount": 12,
"createdAt": "2026-08-12T01:00:00.000Z",
"ico": "inbox",
"hue": "blue"
}
]
}
```
- `ico` / `hue`:展示元数据(语义图标名 / 业务色相 `blue|teal|amber|violet`),创建活动时可配;**缺省时前端兜底**(`inbox` / `blue`),后端可以暂时不存。
- `llm`:这个活动绑定的专属 agent 的人读标签;要链到 LLM 实例时再加 `llmId`,本期没有。
## 2. L2 活动定义
### `GET /api/gov/activities/:activityId` —— access: `ops`
```json
{
"id": "quote-intake",
"name": "询价接收与结构化",
"biz": "报价业务",
"status": "active",
"version": "1.3.0",
"owner": { "id": "8f3c…", "name": "柴浩" },
"llm": "K3 · quote-intake",
"createdAt": "2026-08-12T01:00:00.000Z",
"ico": "inbox",
"hue": "blue",
"desc": "活动定义只读 —— 输入与输出在创建时定死,演进走版本迭代;验收在工作台,这里只看状态。",
"view": { "url": "/api/gov/activities/quote-intake/view" }
}
```
- `desc`:定义说明,创建时填,只读。
- `view`:沙箱要渲染的活动视图;**为 `null` 时前端藏起整个沙箱区块**(不再摆空 iframe)。
### `GET /api/gov/activities/:activityId/view` —— access: `ops`(支持 `?ticket=`)
→ `content-type: text/html`,服务器托管的活动视图 HTML,前端 `<iframe sandbox="allow-scripts">` 直接渲染。
iframe 带不了 Authorization 头,走票据(`GET /api/auth/ticket`,ADR-012 §6)—— 前端每次装载前取一张拼 `?ticket=`。
**内容高度用 postMessage 通讯自适应(视图脚本向父页报高),协议随第一版视图实现一起定。**404:该活动没配视图。
## 3. L2 实例清单(历史活动)
### `GET /api/gov/activities/:activityId/instances` —— access: `ops`
查询参数(可组合,AND;都不带 = 全部):
| 参数 | 形 | 说明 |
|---|---|---|
| `id` | 字符串 | 实例号**模糊**搜,如 `INS-24` |
| `ownerId` | staffId | 精确到人 |
| `state` | 五态之一 | 精确 |
| `startedFrom` / `startedTo` | `YYYY-MM-DD` | 启动日期段,**含两端整日**;可只给一端 |
| `page` | 整数,从 1 | 默认 1 |
| `size` | 整数 | 默认 8(与前端单页一致),上限 50 |
```json
{
"stats": { "total": 13, "byState": { "pending": 1, "running": 1, "completed": 7, "rejected": 2, "failed": 2 } },
"total": 13,
"page": 1,
"size": 8,
"items": [
{
"id": "INS-2417",
"state": "running",
"owner": { "id": "8f3c…", "name": "柴浩" },
"input": "Itelma 车身控制器询价 · 3 项物料",
"output": null,
"startedAt": "2026-08-25T02:12:00.000Z"
},
{
"id": "INS-2415",
"state": "completed",
"owner": { "id": "8f3c…", "name": "柴浩" },
"input": "GAZ 底盘线束询价 · 7 项物料",
"output": "quote-20260824-07.json",
"startedAt": "2026-08-24T09:03:00.000Z"
}
]
}
```
- **`stats` 是活动整体的五态统计,不随检索条件动**(页头统计带摆的是全局数);`total` 是**筛后**条数,分页器按它走。
- `input` / `output` 都是**一句话摘要**;`output` 为 `null` 前端摆 `-`。排序:启动时间倒序(新的在前)。
## 4. 启动参数表
### `GET /api/gov/activities/:activityId/launch-params` —— access: `ops`
点「启动活动」开抽屉时取。**一活动一份,前端只按此表渲染表单,不写死字段。**
```json
{
"activityId": "quote-intake",
"version": "1.3.0",
"params": [
{
"key": "customer_id",
"label": "客户",
"type": "combo",
"required": true,
"desc": "台账里有就直接选;是新客户就自己填,启动后派数据工单补档案。",
"options": [
{ "value": "cust-itelma", "label": "Itelma" },
{ "value": "cust-gaz", "label": "GAZ" }
]
},
{
"key": "request_description",
"label": "客户需求",
"type": "textarea",
"required": true,
"desc": "保留原始需求语义,据此新建需求主记录(V1)。",
"placeholder": "如:车身控制器询价 · 3 项物料,期望 9 月交期"
},
{
"key": "original_internal_pn",
"label": "原内部料号",
"type": "combo",
"required": true,
"desc": "差异评估基准;库里没有该 PN 也可启动,启动后派数据工单补参数。",
"placeholder": "如 PN-880501",
"options": [
{ "value": "PN-880101", "label": "PN-880101" }
]
},
{
"key": "candidate_internal_pn",
"label": "候选内部料号",
"type": "list",
"required": true,
"desc": "被评估物料,一次可传多个、逐个评估 —— 至少 1 个。",
"placeholder": "如 PN-880412"
}
]
}
```
| 字段 | 说明 |
|---|---|
| `key` | 提交时回传的字段名(接口契约,英文) |
| `label` | 表单上的中文名 |
| `type` | 控件种类:`text` 单行 / `textarea` 多行 / `select` 单选(只能选候选) / **`combo` 选择+新增**(候选末项是「新增」,选了换输入框自己填;见下方「combo 语义」) / `list` 字符串数组(一次可传多个,逐行自由填);**新增种类前要前后端一起升级**,前端遇到不认识的 type 摆「暂不支持的控件」 |
| `required` | 必填;前端在没填完时禁提交,**服务端仍要再验一道**(400) |
| `desc` | 参数描述,挂在控件下当辅助说明 |
| `placeholder` | 选填,text / textarea / combo(新增态) / list 用 |
| `options` | `select` / `combo` 必填;**两段式 `{ value, label }`** —— value 是提交的标识(如客户 ID),label 是显示名;标识即显示名时两段相同 |
| `default` | 选填;预填值(`list` 给字符串数组,其余给字符串)—— 上游触发预填场景用 |
**combo 语义(2026-08-27 用户定)**:启动表单大部分是自由输入;客户 / 料号这类「台账里可能有」的参数才用 combo —— 先选,候选里没有就新增自填。**提交候选之外的值是合法的,不拦启动**:服务端按 value 在台账里查,查不到照样启动,启动后派**数据工单**到工作台补这条档案(客户进客户台账、PN 补参数,确认即入库,活动从断点续跑)。combo 提交的值统一回字符串:选中候选回它的 `value`,新增回用户填的原样字符串 —— 服务端不靠类型区分新旧,靠台账存在性检查。
候选量大到不宜内联时(如全量物料台账选 PN),后续加 `optionsSource` 引用检索接口,**本期一律内联 `options`**。
## 5. 启动活动(创建实例)
### `POST /api/gov/activities/:activityId/instances` —— access: `ops`
```json
{
"idempotencyKey": "01J9…",
"params": {
"customer_id": "cust-itelma",
"request_description": "车身控制器询价 · 3 项物料,期望 9 月交期",
"original_internal_pn": "PN-880101",
"candidate_internal_pn": ["PN-880412", "PN-880530"]
}
}
```
→ 200 一条实例详情(同 §6)。**同一 `idempotencyKey` 重放返回第一次创建的实例,不起第二个**(前端每开一次抽屉生成一枚;重复点提交、网络重试都靠它挡住)。
`params` 的键值形状由 §4 参数表决定:`list` 回字符串数组,其余回字符串;`select` 回 `options` 里某个 `value`;**`combo` 回候选的 `value` 或新增的原样字符串,两种都合法**。
412:活动已禁用(先激活再启动)。400:缺必填 / 类型不符 / select 的值不在 options 里(**combo 不适用这条** —— 候选之外的值是合法新增)。
正常落地初始状态 `pending`(排入队列,调度器领到即转 `running`);**存在性检查(客户 / 料号在不在台账)在启动后做,查不到不拦启动** —— 按入参说明口径,缺口派数据工单到工作台逐项补(确认即入库,活动从断点续跑);响应里不单独回这件事,工单出现在工作台那一侧(工单接口不在本文范围)。
## 6. L3 实例详情
### `GET /api/gov/activities/:activityId/instances/:instanceId` —— access: `ops`
```json
{
"id": "INS-2417",
"activityId": "quote-intake",
"state": "running",
"owner": { "id": "8f3c…", "name": "柴浩" },
"llm": "K3 · quote-intake",
"startedAt": "2026-08-25T02:12:00.000Z",
"endedAt": null,
"durationMs": null,
"reason": null,
"input": {
"content": "Itelma 车身控制器询价 · 3 项物料",
"source": { "kind": "manual", "label": "手动启动 · 柴浩" },
"submittedAt": "2026-08-25T02:12:00.000Z",
"files": [
{ "index": 0, "name": "RFQ-2026-081.eml", "contentType": "message/rfc822", "size": 48210 }
]
},
"output": {
"files": [
{ "index": 0, "name": "quote-20260825-07.json", "contentType": "application/json", "size": 6102 }
]
},
"steps": [
{ "seq": 1, "name": "输入接收", "by": "agent", "byName": "K3 · quote-intake", "at": "2026-08-25T02:12:00.000Z", "durationMs": 60000, "res": "done", "note": "询价单已进入,要素识别完成,实例正式起步。" },
{ "seq": 2, "name": "要素结构化", "by": "agent", "byName": "K3 · quote-intake", "at": "2026-08-25T02:15:00.000Z", "durationMs": 360000, "res": "done", "note": "抽取客户 / 物料 / 交期等字段,落入结构化模型。" },
{ "seq": 3, "name": "定价测算", "by": "agent", "byName": "K3 · quote-intake", "at": "2026-08-25T02:21:00.000Z", "durationMs": null, "res": "current", "note": "按成本模型逐项给出建议价与毛利测算。" },
{ "seq": 4, "name": "负责人验收", "by": "human", "byName": "柴浩", "at": null, "durationMs": null, "res": "pending", "note": "确认测算结果 —— 规划类产出必须由人验收担责。" },
{ "seq": 5, "name": "输出归档", "by": "agent", "byName": "K3 · quote-intake", "at": null, "durationMs": null, "res": "pending", "note": "生成报价文件并归档,产出回写台账。" }
]
}
```
- `endedAt` / `durationMs`:未结束(null)。`durationMs` 毫秒,前端格式化(`27m`)。
- **`reason`**:终态的原因说明,`null` = 不需要(进行中 / 待执行 / 正常完成)。
`{ "kind": "rejected", "text": "验收驳回:测算毛利低于阈值,退回重新测算 —— 负责人 柴浩。" }`
`{ "kind": "failed", "text": "要素结构化失败:询价单附件缺失,已通知发起人补齐后重试。" }`
`{ "kind": "terminated", "text": "已于 08-27 10:41 由 柴浩 手动终止。" }` —— **手动终止的实例状态归 `failed`,靠 `reason.kind='terminated'` 与「自己跑挂」区分**(过程面当前步记「已终止」,不摆失败原因)。
- `input.source.kind`:`manual`(运营端手动启动)/ `upstream`(上游活动触发)/ `mail`(邮件触发)…… 枚举随触发渠道增加;`label` 是人读形式,前端直接摆。
- `steps[].res`:`done` / `fail` / `current` / `pending`;`fail` 那行的性质由 `reason.kind` 定(驳回 / 失败 / 已终止)。
- `input.files` / `output.files`:只有元数据;**下载走 §7**。没有产出时 `output.files` 是空数组(前端摆「暂无产出」)。
## 7. 实例文件下载
### `GET /api/gov/activities/:activityId/instances/:instanceId/files/:io/:index` —— access: `ops`(支持 `?ticket=`)
`:io` ∈ `input` | `output`,`:index` 对应 §6 文件行的 `index`。
→ `{ "name": "…", "contentType": "…", "dataBase64": "…" }`(与 `GET /api/gov/upload/:id` 同形);可带 `?ticket=` 用 `<a href download>`。
**本期可以只回元数据、这条路先 404** —— 前端下载钮当前就是「随接口开放」的置灰态。
## 8. 禁用 / 激活 / 终止
### `POST /api/gov/activities/:activityId/disable` —— access: `ops`
→ 200 活动视图(§2 同形)。**任何时刻都可禁用,没有前置条件**(2026-08-27 用户重定):
禁用后这个活动不再允许启动(§5 返回 412);**已完成的实例是历史留痕,进行中的实例继续跑完,都不受影响**。
已禁用再禁用 = 幂等 200。
### `POST /api/gov/activities/:activityId/activate` —— access: `ops`
→ 200 活动视图。已激活再激活 = 幂等 200。
### `POST /api/gov/activities/:activityId/instances/:instanceId/terminate` —— access: `ops`
body 空(终止理由本期不填,`reason.text` 由服务端生成)。
→ 200 实例详情(§6 同形,`state='failed'` + `reason.kind='terminated'`)。412:实例不在 `running`(已终态的不能再终止)。
## 9. Schema 附录(落地时进 `packages/contracts`,同名)
```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; 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; 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 }[] /* select / combo 必填 */; default?: string | string[] };
type LaunchParams = { activityId: string; version: string; params: LaunchParam[] };
type LaunchInput = { idempotencyKey: 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 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 = InstanceSummary & { activityId: string; llm: string; endedAt: string | null; durationMs: number | null;
reason: InstanceReason | null;
input: { content: string; source: { kind: string; label: string }; submittedAt: string; files: InstanceFile[] };
output: { files: InstanceFile[] };
steps: InstanceStep[] };
```
## 10. 前端对接要点(现状 → 接口落地)
| 现状(占位) | 接口落地后 |
|---|---|
| `DEMO_ACTIVITIES` 静态数组 | §1;L1 页签切换即换 `status` 重取 |
| `demoInstances()` 每活动共用一份 | §3;检索四条件与分页全部上移到查询参数,前端删掉本地过滤/分页 |
| 统计带由前端从全量数组数 | §3 `stats`(服务端给,分页下才数得准) |
| `LAUNCH_PARAMS` 占位参数表 | §4;`options` 从纯字符串升级为 `{value,label}` 两段(渲染器小改);控件种类已含 `combo`(选择+新增,前端已实现) |
| `onLaunch` 在清单头落假行 | §5;抽屉每开一次生成 `idempotencyKey`,提交走 mutation,成功失效 `['gov']` |
| 禁用/激活/终止就地改 state | §8;mutation 成功后由服务端重投影(广播 → 失效 → 重取) |
| `demoSteps` / `terminatedSteps` 五步占位 | §6 `steps`;终止归态以 `reason.kind='terminated'` 为准,前端不再落「失败」占位 |
| 沙箱 `src="/sandbox-demo/activity-view.html"` 固定 480 高 | §2 `view.url` + 票据;`view` 为 null 藏起沙箱区;高度 postMessage 随第一版视图定 |
| 输出下载钮置灰 | §7(本期可继续 404,钮保持置灰) |
+3 -1
View File
@@ -30,7 +30,7 @@
- **创建实例**:先把入口留出来,功能待做;
- **运营概览小统计**:需要;
- **验收动作在工作台**,活动模块不管验收 —— L2 只展示状态;
- **激活/禁用都需要二次确认**;禁用会访问接口,**有实例时接口会提示无法禁用**(本期只做前端,暂不接接口,前端先把这个规则的样子做出来);
- **激活/禁用都需要二次确认**;禁用会访问接口,~~有实例时接口会提示无法禁用~~(**2026-08-27 用户重定:任何时刻都可禁用**,禁用只拦新启动,不影响已完成与进行中的实例 —— 见下方记录);
- **L2 需要一个沙箱**:用来加载服务器返回的 HTML 页面(活动视图),先做一个版本看效果再改。
### 2026-08-25 · 版本号与版本迭代入口(用户口述)
@@ -93,6 +93,8 @@ L1(模块首页 · 活动清单)需要一个**标签页**切换,因为活
- 2026-08-26 · **实例不可删除、不可编辑(用户定,推翻 L3 原规划)**:L3 的编辑/删除入口撤掉,唯一动作是进行中的「终止活动」(二次确认);六功能里实例的「编辑/删除」两项就此划掉,待确认问题里的两条(编辑指什么 / 删除是否放开)同时关闭。
- 2026-08-26 · **二次确认的规格(用户定)**:活动模块的确认走**轻气泡**(锚在触发钮下方的小泡泡,只问一句 + 确认/取消,点罩即取消)—— 不摆影响三必答;三必答(AskBand)是治理台重度动作的规格,别处的确认带暂不动。
- 2026-08-27 · **「启动活动」按钮接功能(用户定)**:L2 头部「启动活动」不再占位 —— 点击弹**参数抽屉**(复用全局 `g-drawer` 右抽屉骨架),填启动参数后启动。**参数表由服务端按活动下发**(参数名 / 中文名 / 控件种类 / 必填 / 描述),每个活动要的参数不一样,前端只按规格渲染不写死字段;控件种类已支持单行 / 多行 / 单选 / 字符串数组(可传多个,如候选 PN)。接口未落地前用 `LAUNCH_PARAMS` 占位表(询价接收与结构化 = 入参说明的极简四参),提交先在历史活动清单头落一条「待执行」演示全链路,接口对接后换启动 mutation。**已禁用的活动不能启动**(钮置灰,先激活再启动)。
- 2026-08-27 · **禁用规则重定(用户定,废除 08-25 的「有实例不能禁用」)**:**任何时刻都可以禁用活动** —— 禁用后只拦「新启动」(启动接口返回 412);已完成的实例是历史留痕、进行中的实例继续跑完,都不受影响;激活即恢复。前端占位的拦截逻辑(有实例弹「无法禁用」提示条)已随此删除;接口口径见 `docs/API文档-活动模块.md` §0/§8。
- 2026-08-27 · **启动参数的控件口径(用户定)**:启动表单**大部分是自由输入框**;客户 / 料号这类「台账里可能有」的参数用 **combo(选择+新增)** —— 先摆选择框,候选末项「新增」选了换输入框自己填。**填了库里没有的值不拦启动**:启动后服务端做存在性检查,查不到就派**数据工单**到工作台补档案(客户进客户台账、PN 补参数,确认即入库,活动从断点续跑)。前端已加 `combo` 控件类型;接口口径见 `docs/API文档-活动模块.md` §4「combo 语义」/§5。
## 三、功能表(整理稿)