diff --git a/apps/web/src/app/styles/console.css b/apps/web/src/app/styles/console.css index 0feef15..da7371c 100644 --- a/apps/web/src/app/styles/console.css +++ b/apps/web/src/app/styles/console.css @@ -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); } diff --git a/apps/web/src/features/gov-task/internal/panel.tsx b/apps/web/src/features/gov-task/internal/panel.tsx index f0030fd..22d77b0 100644 --- a/apps/web/src/features/gov-task/internal/panel.tsx +++ b/apps/web/src/features/gov-task/internal/panel.tsx @@ -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> = { - /* 极简四参(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> = { { 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 ( + + onChange(e.target.value)} /> + + + ); + } + return ( + + ); +} + /** * 启动抽屉 —— 复用全局 `g-drawer` 骨架(与系统区模型抽屉同一副); * 挂 body portal:详情页容器带入场动画 transform,fixed 抽屉不 portal 会被钉进卡片盒子里(2026-07-30 实测); @@ -446,6 +500,7 @@ function LaunchDrawer({ name, spec, onCancel, onLaunch }: { name: string; spec: ))} )} + {p.type === 'combo' && setVal(p.key, v)} />} {p.type === 'list' && ( {(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(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 && setAsk(null)} />} {launch && setLaunch(false)} onLaunch={onLaunch} />} - {blocked && ( - - 无法禁用:该活动还有 {a.instances} 个实例 —— 这条提示将来由接口返回(有实例时接口拒绝禁用),本期为前端占位演示。 - - )} {/* 统计带 + 检索条同一行(2026-08-26 用户定:检索放右侧)—— diff --git a/docs/API文档-活动模块.md b/docs/API文档-活动模块.md new file mode 100644 index 0000000..9ae0d51 --- /dev/null +++ b/docs/API文档-活动模块.md @@ -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": "", "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,前端 `