/** * feature `gov-task` —— 任务管理:在途监控、控制事件(暂停/叫停/终止)、恢复、改派、任务日志查看 * * 自 `~/coding/JCAI_PRD/governance/_components/Governance.tsx` 平移: * DOM 结构、类名(`g-*` + 色调 `t-*`)与文案原样保留,mock 常量换成服务端投影(README 平移规则 1/2/4)。 * 区首页只负责导航,治理动作一律落在下钻详情页的**就地确认带**(影响预览三必答)。 */ import { useState } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { CONTROL_SEMANTIC_LABEL, TASK_RUNTIME_STATE_LABEL, TERMINAL_STATE_LABEL, WORKFLOW_STATE_LABEL, controlTaskRoute, reassignTaskRoute, resumeTaskRoute, } from '@contracts'; import type { ControlSemantic } from '@contracts'; import { Ico } from '@web-shared/Ico.tsx'; import { call } from '@web-shared/typed-fetch.ts'; import { Ava, Card, Chip, Clear, Entry, Facts, NList, NRow, Note, PageHead, Sec } from '@web-shared/gov/primitives.tsx'; import type { ActSpec } from '@web-shared/gov/primitives.tsx'; import { RUNTIME_ST, TERMINAL_ST, WF_ST } from '@web-shared/gov/tone.ts'; import type { Nav } from '@web-shared/gov/gov-route.ts'; import { FlowFigure } from '@web-shared/gov/flow/FlowFigure.tsx'; import { atFull, atMinute } from '@web-shared/at.ts'; import { Btn } from '@web-shared/Btn.tsx'; import { useGovStaff, useGovTaskLog, useGovTasks, useGovWorkflow } from '@web-shared/gov/gov-api.ts'; export type HomeProps = { tab: string; nav: Nav }; export function TasksHome({ tab, nav }: HomeProps) { return ; } /** * 在途工作流看板 —— **工作流是流程包的运行时**,一级看板归「运营中心 · 工作流」区; * 任务区(活动的运行时)不再摆它。 */ export function WorkflowBoard({ nav }: { nav: Nav }) { const q = useGovTasks(); const tasks = q.data?.tasks ?? []; const workflows = q.data?.workflows ?? []; /** 工作流不设负责人:要看「这条流现在落在谁身上」,读它当前节点的任务负责人 */ const holderOf = (workflowId: string) => { const live = tasks.find((t) => t.workflowId === workflowId && t.terminal === undefined); if (live) return { name: live.ownerName, label: '当前任务负责人' }; const stuck = tasks.find((t) => t.workflowId === workflowId && t.terminal !== undefined && t.terminal !== 'released'); return stuck ? { name: stuck.ownerName, label: '最后经手 · 待处置' } : undefined; }; const lanes = [ { k: 'running', tone: 'brand' }, { k: 'waiting', tone: 'warn' }, { k: 'abnormal', tone: 'danger' }, { k: 'completed', tone: 'ok' }, ] as const; return (
{lanes.map((ln) => { const items = workflows.filter((w) => w.state === ln.k); return (
{WORKFLOW_STATE_LABEL[ln.k]} {items.length}
{items.map((w) => { const mine = tasks.filter((t) => t.workflowId === w.id); const done = mine.filter((t) => t.terminal === 'released').length; const total = Math.max(mine.length, 1); const holder = holderOf(w.id); const cur = mine.find((t) => t.terminal === undefined)?.title ?? '—'; return ( ); })} {items.length === 0 &&
—
}
); })}
); } /** * 运营中心 · 活动区一级页(L1)—— 2026-08-25 重构模版(静态数据占位,接口未接): * 页签二分「已发布 / 审核中」(与「创建过审核才展示」的规则对应); * 活动以卡片呈现 —— 头 = 名称 + 标识 + 状态丸,契约带 = 输入 → 输出,底板 = 实例/负责人/时间; * 尾部幽灵卡 = 创建活动入口(创建功能本期不做,纯占位)。 * L2(活动详情 + 实例清单)、L3(实例详情)随后接。 */ type DemoActivity = { id: string; name: string; biz: string; instances: number; owner: string; createdAt: string; /** 运营态:激活中 / 已禁用(2026-08-25 用户定)—— L2 里可改;发布/审核是设计部分,暂缓 */ status: 'active' | 'disabled'; /** 当前版本号 —— 活动无编辑/删除,演进只能靠发新版本(2026-08-25 用户定) */ version?: string; /** 语义图标与业务色相 —— 色即业务,一卡一色(2026-08-25 视觉增强) */ ico: string; hue: 'blue' | 'teal' | 'amber' | 'violet'; /** 这个活动绑定的 LLM —— 每个活动一个专属 agent,负责把流程跑通(2026-08-25 用户定:L2 要展示) */ llm: string; }; /** 占位静态数据 —— 接口设计定稿后整体换掉(2026-08-25 用户定:先出页面模版看效果) */ const DEMO_ACTIVITIES: readonly DemoActivity[] = [ { id: 'quote-intake', name: '询价接收与结构化', biz: '报价业务', instances: 12, owner: '柴浩', createdAt: '08-12', status: 'active', version: '1.3.0', ico: 'inbox', hue: 'blue', llm: 'K3 · quote-intake' }, { id: 'quote-pricing', name: '报价定价测算', biz: '报价业务', instances: 8, owner: '丁子乔', createdAt: '08-12', status: 'active', version: '1.2.0', ico: 'calculator', hue: 'blue', llm: 'K3 · quote-pricing' }, { id: 'crm-coverage', name: '关键人触达计划', biz: 'CRM 业务', instances: 5, owner: '冯兵', createdAt: '08-15', status: 'active', version: '1.0.0', ico: 'target', hue: 'teal', llm: 'K3 · crm-coverage' }, { id: 'competitor-watch', name: '竞品价格监测', biz: '报价业务', instances: 2, owner: '柴浩', createdAt: '08-24', status: 'active', version: '1.1.0', ico: 'eye', hue: 'blue', llm: 'K3 · competitor-watch' }, { id: 'market-weekly', name: '市场信号周报生成', biz: '市场业务', instances: 3, owner: '林佩萱', createdAt: '08-18', status: 'disabled', version: '2.1.0', ico: 'trending-up', hue: 'amber', llm: 'K3 · market-weekly' }, { id: 'supplier-verify', name: '供应商资质核验', biz: '采购业务', instances: 1, owner: '丁子乔', createdAt: '08-25', status: 'disabled', version: '1.0.0', ico: 'shield-check', hue: 'violet', llm: 'K3 · supplier-verify' }, ]; function RuntimeHome({ tab, nav }: HomeProps) { const items = DEMO_ACTIVITIES.filter((a) => a.status === tab); return (
{items.map((a) => (
{/* 整卡热区 → L2 活动详情(2026-08-25 接) */}
))} {/* 幽灵卡 = 创建入口 —— 2026-08-25 用户定:设计功能暂缓,先做运营部分, 创建活动的幽灵卡先注释掉;恢复时把这段 JSX 解开即可(样式 .g-fcard.ghost 保留着)
创建活动
*/}
); } /* ==================== 活动详情(L2,2026-08-25 重构模版) ==================== */ /** 实例状态五态(2026-08-25 用户定)—— 进行中蓝 / 待执行琥珀 / 已完成绿 / 驳回红 / 失败灰 */ type InstanceState = '进行中' | '待执行' | '已完成' | '驳回' | '失败'; const INSTANCE_ST: Readonly> = { 进行中: 'brand', 待执行: 'warn', 已完成: 'ok', 驳回: 'danger', 失败: 'mute', }; type DemoInstance = { id: string; input: string; state: InstanceState; owner: string; startedAt: string; output: string; }; /** 占位实例清单 —— 每个活动先共用同一份样子,接口定稿后按活动取(2026-08-25 模版) */ const demoInstances = (owner: string): readonly DemoInstance[] => [ { id: 'INS-2417', input: 'Itelma 车身控制器询价 · 3 项物料', state: '进行中', owner, startedAt: '08-25 10:12', output: '—' }, { id: 'INS-2416', input: '伏尔加重卡灯组询价 · 12 项物料', state: '待执行', owner, startedAt: '08-25 09:40', output: '—' }, { id: 'INS-2415', input: 'GAZ 底盘线束询价 · 7 项物料', state: '已完成', owner, startedAt: '08-24 17:03', output: 'quote-20260824-07.json' }, { id: 'INS-2414', input: 'KAMAZ 传感器询价 · 5 项物料', state: '已完成', owner, startedAt: '08-24 14:26', output: 'quote-20260824-03.json' }, { id: 'INS-2413', input: 'UAZ 控制模块询价 · 4 项物料', state: '驳回', owner, startedAt: '08-23 16:51', output: '—' }, { id: 'INS-2412', input: 'Lada 车机询价 · 9 项物料', state: '失败', owner, startedAt: '08-23 11:05', output: '—' }, { id: 'INS-2411', input: 'Itelma 座椅电机询价 · 6 项物料', state: '已完成', owner, startedAt: '08-22 18:22', output: 'quote-20260822-11.json' }, { id: 'INS-2410', input: 'GAZ 门锁执行器询价 · 2 项物料', state: '已完成', owner, startedAt: '08-22 15:47', output: 'quote-20260822-06.json' }, { id: 'INS-2409', input: 'KAMAZ 变速箱询价 · 8 项物料', state: '驳回', owner, startedAt: '08-22 10:19', output: '—' }, { id: 'INS-2408', input: '伏尔加仪表集群询价 · 5 项物料', state: '已完成', owner, startedAt: '08-21 16:33', output: 'quote-20260821-09.json' }, { id: 'INS-2407', input: 'UAZ 分动箱询价 · 3 项物料', state: '失败', owner, startedAt: '08-21 13:58', output: '—' }, { id: 'INS-2406', input: 'Lada 悬挂件询价 · 11 项物料', state: '已完成', owner, startedAt: '08-21 09:24', output: 'quote-20260821-02.json' }, { id: 'INS-2405', input: 'Itelma 雨刮电机询价 · 1 项物料', state: '已完成', owner, startedAt: '08-20 17:41', output: 'quote-20260820-08.json' }, ]; /** 实例清单单页上限(2026-08-25 用户定:6 → 8,6 张时页面与卡内留白太多) */ const INSTANCE_PAGE_SIZE = 8; /* ── 历史活动检索(2026-08-26 用户定:列表就要能搜)────────────────────── 条件下拉四选:实例(号,模糊)/ 负责人(人员下拉)/ 状态(五态)/ 启动时间(日期段); 条件一换,右边的控件跟着换 —— 一个条件一个趁手的组件 */ type InstanceFilterKey = 'id' | 'owner' | 'state' | 'time'; const INSTANCE_FILTER_LABEL: Readonly> = { id: '实例', owner: '负责人', state: '状态', time: '启动时间', }; /** 占位数据的启动时间是 `MM-DD HH:mm`(2026 年),转成可比较的 Date;接口定稿后随真数据格式一起换 */ function instanceTime(at: string): number { const m = /^(\d{2})-(\d{2}) (\d{2}):(\d{2})$/.exec(at); if (m === null) return 0; return new Date(2026, Number(m[1]) - 1, Number(m[2]), Number(m[3]), Number(m[4])).getTime(); } /** 日期段 [from, to](`YYYY-MM-DD`,含两端整日)—— 空端不卡那一边 */ function inTimeRange(at: string, from: string, to: string): boolean { const t = instanceTime(at); if (from !== '' && t < new Date(`${from}T00:00:00`).getTime()) return false; if (to !== '' && t > new Date(`${to}T23:59:59`).getTime()) return false; return true; } /** 轻量二次确认的规格(2026-08-26 用户定:只问一句 + 确认/取消 —— 影响三必答(AskBand)是治理台重度动作的规格,禁用活动/终止实例是轻动作,不摆) */ type PopAskSpec = { title: string; okLabel: string; danger?: boolean; onOk: () => void }; /** 气泡式二次确认 —— 锚在页头右下的小泡泡:一句话问清,确认/取消两钮;点气泡外的透明罩即取消 */ function PopAsk({ spec, onCancel }: { spec: PopAskSpec; onCancel: () => void }) { return ( <>

{spec.title}

); } /** * L2 · 活动详情 —— 两个问题一屏答:「这个活动是什么」(定义,只读) * 与「它跑得怎么样」(小统计 + 实例清单,点行进 L3)。 * * 头部动作三个(2026-08-25 用户定): * · 激活/禁用 —— 本期做交互,二次确认;**有实例时无法禁用**(规则来自接口,本期前端占位演示); * · 启动活动(即创建实例)/ 版本迭代 —— 只留入口,功能后做(disabled)。 * * 沙箱区:加载服务器返回的活动视图 HTML —— iframe + sandbox 隔离; * v1 静态占位页,src 与内容高度通讯(postMessage)待接口一起定。 */ 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); const [page, setPage] = useState(0); /* 检索条件(2026-08-26 用户定):条件下拉 + 随条件换控件;值一变回第一页 */ const [fKey, setFKey] = useState('id'); const [fText, setFText] = useState(''); const [fOwner, setFOwner] = useState(''); const [fState, setFState] = useState(''); const [fFrom, setFFrom] = useState(''); const [fTo, setFTo] = useState(''); /* 轻量二次确认(2026-08-26 用户定):一句话 + 确认/取消的气泡,不走 AskBand 三必答 */ const [ask, setAsk] = useState(null); if (a === undefined) return 活动不存在 —— 它可能已被调整,返回活动清单看看。; const st = status ?? a.status; const instances = rows ?? demoInstances(a.owner); const countOf = (s: InstanceState): number => instances.filter((i) => i.state === s).length; /** 终止实例(2026-08-25 用户定:进行中的实例要有「终止活动」钮)—— 终止后归到哪个状态接口语义未定,前端先落「失败」占位 */ const stopInstance = (rid: string): void => setRows((cur) => (cur ?? demoInstances(a.owner)).map((r) => (r.id === rid ? { ...r, state: '失败' as InstanceState } : r))); /* 检索:负责人下拉的候选从现有实例里收(真接口落地后换人员接口) */ const owners = [...new Set(instances.map((i) => i.owner))]; /** 当前条件是否真的带了值 —— 没带值不过滤,等于没搜 */ const filtering = (fKey === 'id' && fText.trim() !== '') || (fKey === 'owner' && fOwner !== '') || (fKey === 'state' && fState !== '') || (fKey === 'time' && (fFrom !== '' || fTo !== '')); const filtered = !filtering ? instances : instances.filter((r) => { switch (fKey) { case 'id': return r.id.toLowerCase().includes(fText.trim().toLowerCase()); case 'owner': return r.owner === fOwner; case 'state': return r.state === fState; case 'time': return inTimeRange(r.startedAt, fFrom, fTo); } }); const clearFilter = () => { setFText(''); setFOwner(''); setFState(''); setFFrom(''); setFTo(''); setPage(0); }; /* 分页(2026-08-25 用户定:单页最多 8 条)—— 检索后按筛出的集合翻页 */ const pageCount = Math.max(1, Math.ceil(filtered.length / INSTANCE_PAGE_SIZE)); const cur = Math.min(page, pageCount - 1); const pageRows = filtered.slice(cur * INSTANCE_PAGE_SIZE, (cur + 1) * INSTANCE_PAGE_SIZE); return (
{st === 'active' ? '激活中' : '已禁用'} {a.version !== undefined && v{a.version}} {/* 绑定的 LLM 上头版(2026-08-25 用户定):每个活动一个专属 agent */} {a.llm} {a.owner} 创建于 {a.createdAt} } desc="活动定义只读 —— 输入与输出在创建时定死,演进走版本迭代;验收在工作台,这里只看状态。" acts={[ st === 'active' ? { label: '禁用活动', ico: 'power', danger: true, onClick: () => setAsk({ title: '确认禁用这个活动?', okLabel: '确认禁用', danger: true, onOk: () => { /* 规则(2026-08-25 用户定):有实例时接口会提示无法禁用 —— 本期前端先演出这个拦截 */ if (a.instances > 0) { setBlocked(true); } else { setStatus('disabled'); setBlocked(false); } }, }), } : { label: '激活活动', ico: 'zap', primary: true, onClick: () => setAsk({ title: '确认激活这个活动?', okLabel: '确认激活', onOk: () => { setStatus('active'); setBlocked(false); }, }), }, { label: '启动活动', ico: 'plus', disabled: true }, /* 版本迭代入口先隐藏(2026-08-25 用户定):功能设计回头讨论,要做时把这行加回 acts { label: '版本迭代', ico: 'git-branch', disabled: true }, */ ]} /> {ask !== null && setAsk(null)} />}
{blocked && ( 无法禁用:该活动还有 {a.instances} 个实例 —— 这条提示将来由接口返回(有实例时接口拒绝禁用),本期为前端占位演示。 )} {/* 统计带 + 检索条同一行(2026-08-26 用户定:检索放右侧)—— 左:总数 + 五态灰字合计(状态色只进 6px 小圆点);右:条件下拉 + 随条件换控件 */}
总数 {instances.length} {(Object.keys(INSTANCE_ST) as InstanceState[]).map((s) => ( {s} {countOf(s)} ))}
{fKey === 'id' && ( { setFText(e.target.value); setPage(0); }} /> )} {fKey === 'owner' && ( )} {fKey === 'state' && ( )} {fKey === 'time' && ( { setFFrom(e.target.value); setPage(0); }} /> – { setFTo(e.target.value); setPage(0); }} /> )} {filtering && ( <> 筛出 {filtered.length} / 共 {instances.length} 个 )}
{/* 列表式清单(2026-08-26 用户定:卡片撤了改列表)—— 行 = 一次实例,列齐好扫;状态色只进 6px 圆点(色盲可读:状态词在点旁边); 行内操作常驻(查看详情是主入口,不藏) */}
{/* 列序(2026-08-26 用户定):实例 / 负责人 / 输入 / 输出 / 启动时间 / 状态 / 操作(操作栏也有标题) */}
实例 负责人 输入 输出 启动时间 状态 操作
{pageRows.map((r) => (
{r.id} {r.owner} {r.input} {r.output} {r.startedAt} {/* key 带状态:状态翻转(终止)时弹出一次高亮 —— 状态变化要看得见 */} {r.state} {r.state === '进行中' && ( )} {/* L3 实例详情(2026-08-26 接):路由 id 带活动与实例两段,详情页按活动找回上下文 */}
))} {/* 检索无结果:如实摆一句话,不留一张只剩表头的空表 */} {pageRows.length === 0 && (
没有匹配的实例 —— 换个条件,或清空重看全部。
)}
{/* 分页(2026-08-25 用户定:单页最多 8 条)—— 页少不上页码器,翻页两枚就够;计数跟着筛出的集合走 */}
第 {cur + 1} / {pageCount} 页 · {filtering ? `筛出 ${filtered.length} / 共 ${instances.length} 个` : `共 ${filtered.length} 个`}
{/* * 沙箱(2026-08-25 用户定):加载服务器返回的活动视图 HTML。 * 隔离:iframe + sandbox="allow-scripts" —— 不透明源,禁同源/表单/弹窗,视图只能渲染与跑自己的脚本; * v1 静态占位页(/sandbox-demo/activity-view.html),src 以后来自接口(活动定义里的视图地址); * 待议:内容高度 postMessage 自适应(现在固定高 480)、加载失败态(接口 404/超时)。 */}
{/* 仿浏览器 chrome 条:让人一眼认出「这是嵌进来的外部视图」—— 全页唯一一处隐喻,不多用 */}
{a.id} · 活动视图(沙箱隔离)
{!frameLoaded && (
视图载入中…
)}