/** * 标题区 + 工作区 —— 自原型 `_components/ViewRegion.tsx` 平移(2026-07-26 定稿)。 * * 「视图区**承担全部结构化交互**」:表单读写 + 过程成果的图形展示,全在这里完成; * 决策不在这里 —— 它是对话区的选择题(AM 07)。 * * 五页签:待处理 / 沟通记录 / 工作成果 / 任务详情 / 工作日志。 * DOM 与类名逐字照搬原型 —— 皮肤是原型的原样,自造类名等于没样式。 * * **空态即常态**:文案从用人的角度写「你手上现在没有什么、将会有什么」, * 不写「这块还没做」—— 后者是开发视角,用户不关心,也看不懂。 */ import { useState } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { markCommReadRoute, saveTableRoute } from '@contracts'; import { call } from '@web-shared/typed-fetch.ts'; import type { FileDoc, TaskDetail, WorkOrder, WorkOrderReply } from '@contracts'; import { TERMINAL_STATE_LABEL, isRejection } from '@contracts'; import { Ico } from '@web-shared/Ico.tsx'; import ScrollArea from '@web-shared/ScrollArea.tsx'; import { EmptyState } from '@web-shared/desk/module-kit.tsx'; import { ViewBlocks, ViewContainer } from '@ui'; import type { ViewEdits, ViewSave } from '@ui'; import { GuideCard, WorkOrderReport } from './work-order-report.tsx'; import { FileViewer } from './FileViewer.tsx'; import { atFull, atMinute } from '@web-shared/at.ts'; type Tab = 'open' | 'comms' | 'done' | 'wf' | 'log'; export type ViewRegionProps = { readonly detail: TaskDetail; /** 工单回传 —— 由壳层接路由,视图区只管把人填的东西交出去 */ onReplyOrder: (reply: WorkOrderReply) => void; /** * 填报值由**壳层持有** —— 不是这一份的局部状态。 * * PRD 的口径:验收工单「AI 已填好、可编辑」,而**裁决在对话区的验收单上**。 * 编辑与裁决是同一件事的两半,却分在两个区里做 —— 缓冲只活在工作区里, * 人在报告上改的值就到不了裁决那一刻:验收回传的 `values` 恒空,修订永远落不下来。 */ readonly values: Readonly>; onField: (key: string, value: string) => void; /** * 对话区点了「打开工单」→ 视图区跳转定位(PRD:locate)。 * `seq` 每点一次递增 —— 同一张卡点两次也要再跳一次(人就是想回到它)。 * 待办的跳「待处理」并翻到那一页;办结的跳「工作成果」并打开那份定稿报告。 */ /* * `orderId` 跳工单;`tab` 直接跳页签 —— 起单那句系统开场白的「查看邮件」用后者 * (它指的不是某张工单,是那封来件本身)。 */ readonly locate?: { seq: number; orderId?: string; tab?: Tab } | null; }; /** * 件上改出来的行 → 回传载荷 —— **按台账地址对上工单声明的那张表**。 * * 件里那几张可改的表各挂着 `data-edit="域.对象"`;工单的 `blocks[i]` 声明了 * `ledger`(写哪张表)与 `fields`(列在哪个位置)。这里按地址找到那一块, * 再**照它的 `fields` 顺序**摆格子 —— 于是回传仍是老样子 `[列名, ...行]`, * 服务端那道写回(`applyBoundTableEdits`)一个字都不用改。 * * 按地址找而不是按位置找:件与卡是两份东西,位置对齐迟早会错开一位, * 而错开一位的后果是把年用量写进目标价那一列 —— 那种错没人看得出来。 */ function collectEdits(edits: ViewEdits, order: WorkOrder, onField: (k: string, v: string) => void): void { const blocks = order.blocks ?? []; for (const [address, rows] of Object.entries(edits)) { const at = blocks.findIndex((b) => b.t === 'table' && b.ledger === address); const block = blocks[at]; if (at < 0 || block === undefined || block.t !== 'table') continue; const fields = block.fields ?? []; if (fields.length === 0) continue; const table = [block.cols, ...rows.map((row) => fields.map((f) => row[f] ?? ''))]; onField(`table:${at}`, JSON.stringify(table)); } } export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: ViewRegionProps) { const [view, setView] = useState('open'); const [page, setPage] = useState(0); /* * **件内翻页**(2026-08-02 用户:「一个执行单可以派多个 HTML 待执行报告, * 合理利用右上角的翻页和待处理的数字气泡」):件里带 `data-page` 的可见段 * 各算一页(A2 = 一颗待选型芯片一页)。单卡多页时,右上角那对箭头翻的是页。 */ const [docPage, setDocPage] = useState(0); const [docPages, setDocPages] = useState(1); /** 工作成果:列表里点开的那份定稿报告 */ const [doneSel, setDoneSel] = useState(null); const [openFile, setOpenFile] = useState(undefined); const orders = detail.workOrders; const openList = orders.filter((o) => o.done === undefined); /* 未读条数 —— 页签那枚红气泡读的就是它;标记按钮按一下这个数才动 */ const unreadN = detail.comms.filter((m) => m.unread).length; /* * 标记已读 / 未读 —— 乐观更新交给失效:标完服务端广播 task 触点, * 这一屏跟着重取。不在前端手写缓存(订阅层统一做,组件不自建过程状态)。 */ const queryClient = useQueryClient(); /** * 件上按下「保存」→ **落草稿层 + 让服务端按新数据重渲那一帧**。 * * 不落库,人改的东西只活在浏览器里,刷新一次就没了;不重渲,由数据算出来的东西 * (类徽 A/B/C、分布条、结论横幅那条去向)会停在上一次渲染那一刻 —— * 实测:人把三条料全改成 A 类,底下的去向还写着「去选型」。 * 落完重取任务投影,整份件跟着一起变。**工单不办结,放行门的位置没动。** */ const saveTable = useMutation({ mutationFn: (save: ViewSave) => call(saveTableRoute, { params: { taskId: detail.card.id }, body: { tables: save.tables as Record>>, drops: save.drops as Record>>, }, }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['desk', 'task', detail.card.id] }), }); const markRead = useMutation({ mutationFn: (input: { commId: string; read: boolean }) => call(markCommReadRoute, { params: { commId: input.commId }, body: { read: input.read } }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['desk'] }), }); const doneList = orders.filter((o) => o.done !== undefined); /* * **字签下了,但这一单还没收尾** —— 签完到收尾之间那一小段(合并 / 发口 / 落终态)。 * 认的是「有一张办结的验收单 + 还没有终态」:两者同时成立只有这一种局面。 */ const signed = detail.card.terminal === undefined && orders.some((o) => o.mode === 'acceptance' && o.done !== undefined && !isRejection(o.done.result)); const pos = Math.min(page, Math.max(0, openList.length - 1)); const cur = openList[pos]; const doneItem = doneSel === null ? undefined : doneList.find((o) => o.id === doneSel); /* * 对话区点击 → 跳到工单所在页签与页位(渲染期调整 state,seen 保证幂等 —— PRD 同款)。 * 办结的跳成果详情;待办的跳待处理并翻到那一页。 */ const [seenLoc, setSeenLoc] = useState(0); if (locate != null && locate.seq !== seenLoc) { setSeenLoc(locate.seq); if (locate.tab !== undefined) { setOpenFile(undefined); setView(locate.tab); setPage(0); setDoneSel(null); } const hit = orders.find((o) => o.id === locate.orderId); if (hit !== undefined) { setOpenFile(undefined); if (hit.done !== undefined) { setView('done'); setDoneSel(hit.id); } else { setView('open'); setPage(Math.max(0, openList.findIndex((o) => o.id === locate.orderId))); } } } const go = (to: Tab) => { setView(to); setPage(0); setDoneSel(null); }; const terminalLabel = detail.card.terminal === undefined ? undefined : TERMINAL_STATE_LABEL[detail.card.terminal]; return (
{/* ① 标题区 —— **不滚**,两行定高:主题 · 工作流 · 状态 · 谁做 */}
{detail.card.activity} {detail.card.workflowSubject} {terminalLabel === undefined ? ( <> AI 推进中 ) : ( <> {terminalLabel} )} {detail.owner.name.charAt(0)} {detail.owner.name} · {detail.owner.role}

{detail.card.title}

{detail.card.position}/{detail.card.total}
{/* ② 工作区 —— 页签与点位条**不滚**,只有正文滚 */}
{/* 翻页**恒显示**:只有一件也出 1/1,两侧箭头灰显。 按件数隐藏会让点位条忽有忽无,右侧跟着跳 —— 页签条要定得住。 */} {/* 右槽两态:成果进二级界面时是「返回」,否则待处理位上是翻页。 顶部五个按钮**始终不变** —— 二级界面长在工作区内部,不是另一页。 */} {openFile !== undefined ? ( ) : view === 'done' && doneItem !== undefined ? ( ) : view === 'open' ? ( /* 单卡多页时这对箭头翻**件里的页**(一颗芯片一页);多卡时照旧翻件 */ docPages > 1 && openList.length <= 1 ? ( {docPage + 1}/{docPages} ) : ( {openList.length === 0 ? 0 : pos + 1}/{openList.length} ) ) : null}
{/* key:换件 / 换页签即重挂载滚动区,回到正文顶部 */} {openFile !== undefined ? ( ) : view === 'comms' ? ( /* * 沟通记录 = 一单往来的**原件**。进来的那一封是开案的那份料 —— * 人一点开这单就该看见「这单从哪来的」,不必等模型跑几拍再说。 * * 附件那一行照抄基座读出来的说明:读出多少字 / 为什么读不出。 * **摆一个「已上传 ✓」是撒谎** —— 活动读不出内容的件,人得当场知道。 */ detail.comms.length === 0 ? ( ) : (
{detail.comms.map((m) => (
{m.direction === 'in' ? '收到' : '发出'} {m.subject} {/* 未读点:一眼扫得出哪几封还没看 —— 与右边那个按钮说的是同一件事 */} {m.unread && 未读} {atMinute(m.at)} {/* 标记按钮 —— **气泡计数只跟着这一下走**。 不做「打开页签即已读」:那是替人宣布他看过了,而他可能只是路过。 反向也留着:看了一半想留个记号,标回未读。 */}
{m.channel} {m.direction === 'in' ? '来自' : '发往'} {m.party === '' ? '未署名' : m.party} {m.note !== '' && {m.note}}
{m.body !== '' &&
{m.body}
} {m.attachments.length > 0 && (
{m.attachments.map((a) => ( {a.name} {a.size !== undefined && {(a.size / 1024).toFixed(0)} KB} {a.note !== undefined && {a.note}} ))}
)}
))}
) ) : view === 'wf' ? ( ) : view === 'log' ? ( detail.log.length > 0 ? (
{detail.log.map((line) => ( /* * **三色一档一意**(2026-08-02 用户定):负责人绿 · 智能体蓝 · 系统灰。 * 从前按归侧上色,而归侧有五种 —— 那是**度量**的分法 * (自主率要把「等外面」与「机器干的」分开);人扫这一屏只问 * 「这一步是我办的、AI 办的,还是流程自己走到的」。 */ ))}
) : ( ) ) : view === 'done' ? ( doneItem !== undefined ? ( /* * **定稿归档 = 办结那一刻的件快照,1:1 静态重放**(2026-08-02 用户: * 「HTML UI、数据、内容、布局都要 1:1 地从待处理状态转换为静态报告 * 放到工作成果」—— 反复强调过)。 * * 从前两条路都不对:验收单渲**当下**的任务视图(随后续推进还会变), * 执行单干脆退回 WorkOrderReport 卡壳(题头/编号带/blocks)—— * 没有一样是人办结时看到的那份。现在办结那一刻基座把渲好的件 * 存进工单(`finalHtml`),这里原样静态渲它;老单没有快照的, * 退最近一帧任务视图,再没有才退卡壳(历史兼容)。 */ doneItem.finalHtml !== undefined || detail.viewHtml !== undefined ? ( ) : (
0 ? doneItem.blocks : detail.view} values={{}} onField={() => undefined} onReply={() => undefined} />
) ) : doneList.length > 0 ? ( /* 一行一份成果报告,点进去看整篇(PRD:jc-dvs 列表) */
{doneList.map((o) => { const res = o.done?.result; const bad = res !== undefined && isRejection(res); return ( ); })}
) : ( ) ) : cur !== undefined ? ( /* * **工作区只放那份件本身**(2026-08-01 用户定)。 * * 从前这里是 `WorkOrderReport`:它自带题头、编号带、依据带、判据表、 * 章节头,再把活动推的那一屏**包在自己里面** —— 于是人看到的是 * 「一份报告里嵌着另一份报告」,两套版面语言叠在一起。用户原话: * 「你用你自己的报告格式嵌套了一层我要的报告格式」。 * * 现在分家:件归工作区(整篇铺开、可就地改), * **判据与作业指导书跟着「要你办的这件事」走,落在对话区的卡上**; * 提交那一下也在那儿 —— 编辑与提交分在两个区,缓冲由壳层持有(values)。 * * 活动没推件的(还没到推件那一拍、或包里就没有模版)才退回工单报告: * 那时工作区总不能是白的。 */ detail.viewHtml !== undefined ? ( { /* * **落库按地址走** —— 件自己说了它是哪张表(`data-edit="域.对象"`), * 原样送上去即可。从前借道当下那张卡的 `blocks[i]` 收列名,于是 * 卡上没有那一块时(熔断卡就没有)**保存一格都送不出去**, * 而屏上照旧有闸、有编辑、有保存 —— 界面给了个落不了地的口子。 * * 缓冲那一份仍按卡的形状收(`table:N`):卡上那张表由提交那一下带走, * 与件上这一张是两条路,卡没有对应的块时它自然收不到东西,不影响落库。 */ saveTable.mutate(save); collectEdits(save.tables, cur, onField); }} /> ) : (
0 ? cur.blocks : detail.view} values={values} onField={onField} onReply={onReplyOrder} /> {cur.mode === 'execution' && }
) ) : ( /* * 空待处理**四态分明** —— 状态如实,不许一句「没有待办」把四种局面糊成一种: * 推进中 = AI 在自己转(动画场景);等外部 = 环停着听回音; * 都办完了 = 你的活清零、单还在走;已收尾 = 一切定稿。 */ detail.card.terminal !== undefined ? ( ) : signed ? ( /* * **签完到收尾之间那一小段**(2026-08-02 用户:「点完验收按钮后, * 状态变化时交互上会闪一下中间态,视觉效果不好」)。 * * 那一闪不是渲染毛病,是**真的中间态**:字签下去了,合并、发口、收尾还在跑。 * 从前这一段落在通用四态里 —— 待办清零 → 「都办完了」闪一下 → 再跳「已收尾」。 * 「都办完了」这句话在那一刻是对的,可它说的是**另一件事**(你手上没活了), * 于是读起来像跳了一帧。 * * 现在这一段有自己的话:**字已签下,正在落地**。它不是为了盖住中间态 —— * 中间态本来就该说出来,只是要说对。 */ ) : detail.card.runtime === 'running' ? ( ) : detail.card.runtime === 'awaiting-event' ? ( ) : doneList.length > 0 ? ( ) : ( ) )}
); } /* ═════════════════ AI 推进中 · 空工作区的工作场景 ═════════════════ */ /** * AI 正在办这单、还没轮到人 —— 空待处理不是「没有东西」,是**有人在干活**。 * 涟漪说「环在转」,微光工作线说「报告正在一行行写出来」; * 都是纯 CSS、只动 transform/opacity,prefers-reduced-motion 下静止但状态仍可读。 */ /** * 日志一行 —— **折叠时一行,展开才看细节**(2026-08-03 用户:「细节展开不够好」)。 * * 这一屏的用法是**扫**:一眼看完这单走过哪几步、谁办的。所以密度不能破 —— * 细节收进抽屉,想深究哪一条点哪一条。**没有细节的条目不给展开钮**: * 摆一个点开是空的抽屉,比不给还糟(点一次才知道白点)。 * * 展开不做动画高度:日志动辄几十条,一条一个过渡就是一屏都在抖。 */ function LogRow({ line }: { line: TaskDetail['log'][number] }) { const [open, setOpen] = useState(false); const has = line.detail.length > 0; return (
{atMinute(line.at)} {/* 章摆的是**动作**(四字,与对话区同词),不是归侧 —— 归侧仍在 `t-${side}` 的配色里,度量也照旧靠它 */} {line.action} {has ? ( ) : ( {line.title} )} {line.summary !== undefined && {line.summary}} {/* * **展开就是把话说完,不摆二级条目**(2026-08-03 用户定)。 * * 从前展开出来是一摞「键:值」—— 那是把留痕的字段名搬到人面前, * 人还得自己在脑子里把它们连成一句话。日志是给人读的: * 该说的直接写成正文,一段说完;真有两三样要说的,一样一段。 */} {open && (
{line.detail.map((text, i) => (

{text}

))}
)}
); } function WorkingScene({ doneN }: { doneN: number }) { return (
AI 正在推进这单

看材料、写台账、调工具都由它来;需要你执行或验收的,工单会带着完整上下文出现在这里。 {doneN > 0 ? `此前你办结的 ${doneN} 件都在「工作成果」里。` : ''}

实时盯着 · 有进展这里立即更新
); } /* ═════════════════ 任务详情:KPI 带 + 流程链 + 上下文档案 ═════════════════ */ function WSec({ ico, label, aside }: { ico: string; label: string; aside?: React.ReactNode }) { return (
{label} {aside}
); } function WfDetail({ detail, openN, doneN }: { detail: TaskDetail; openN: number; doneN: number }) { return (
当前 · {detail.card.position}/{detail.card.total} {detail.card.activity} } />
{detail.chain.map((n, k) => (
{n.state === 'current' && ( 你在这 )}
{k + 1} {n.state === 'done' ? ( ) : n.kind === 'approval' ? ( ) : null}
{n.label} {n.ownerName.charAt(0)} {n.ownerName} · {n.ownerRole}
))} {detail.chain.length === 0 &&

这单不在流程上(直接起单)

}
过程视图 活动推送
上下文 {detail.card.id}
本单目标

{detail.goal}

所属工作流

{detail.card.workflowSubject} 序位 {detail.card.position}/{detail.card.total}

运行状态

已派 {openN + doneN} 件 · 办完 {doneN} · 待办 {openN} AI 边推进边派工,件数还会增加

编号

{detail.card.id} 起于 {atFull(detail.card.startedAt)}

); } /** KPI 指标带:四层卡解剖(维度徽 → 指标名 → 大数 → 限额条);限额是基座在环外强制的 */ function KpiBand({ budget }: { budget: TaskDetail['budget'] }) { const meter = (p: string) => { const [a = '0', z = '0'] = p.split('/').map((x) => x.trim()); const r = Math.max(0, Math.min(1, Number(a) / (Number(z) || 1))); return { a, z, r, tone: r >= 0.85 ? 'hot' : r >= 0.6 ? 'warm' : 'cool' }; }; const KPI = [ { dim: '步数', icon: 'activity', ...meter(budget.steps) }, { dim: 'Tokens', icon: 'cpu', ...meter(budget.tokens) }, { dim: '持续时长', icon: 'clock', ...meter(budget.activeMinutes) }, { dim: '调人次数', icon: 'users', ...meter(budget.humanCalls) }, ]; return (
{KPI.map((k) => (
当前活动 {k.dim} {k.a} / {k.z}
))}
); } /* ═════════════════ 工单报告页 ═════════════════ */