资料文件真产出来了;E04/I01 补到验收线,顺手填了几个「墙拦住却不给路」的坑

**资料文件全链**(用户:审核资料这一步要真产出 word/excel/md/html,前端能渲、点资料名能开)
接手时这一步一个文件都产不出来 —— S3 剧本白纸黑字写着「file_ref 本 MVP 阶段可留空」。
文件是台账那一行的投影,不另存一份:
· 声明 `file:{titleColumn,bodyColumn,formatColumn}`,它同时是授权面(没声明就点不开)
· 现造真字节 —— docx/xlsx 是自己打的 OOXML 包(zip-write:store+CRC32,零依赖)
· 读回来渲 —— 解的就是造出来那份字节,渲得出来才证明文件是真的
· 件上 `data-file-of` → 工作区二级界面(Word 纸面/Excel 网格/渲好的 md/沙箱 html)+ 下载原件
放行落地前把每一份冻进 `sys.task_asset_file`(迁移 0029):归档件点开的必须是签字那一刻
那一份,而资料落在主数据上、试跑单不落正式表 —— 不存这一份,收工后一个都点不开。
工作成果里「产出的资料」自成一摞。Markdown 富文本渲染顺带销账(仍零依赖)。

**Harness**
· 画面按「这一拍办完之后」在办的那一步挑 —— 从前拿拍首的局面决定拍尾的事:
  收掉 S4、发出 S5 的卡,而屏上还摆着 S4 的只读件,人无处可填(墙要配路第四次现形)
· 验收「重做」真能退回重做,且打回后台账没动就不许再提交(软硬两段,不锁死单子)
· 立监听的授权面是清单声明;发卡的授权面是调人预算,不是计划项
· 读工具同参重调有兜底;空转写身后还有行动就不废串;finish 的 doneWhen 按写完后的草稿算

**界面**
· 翻件与翻页各归各位(从前二选一,多卡多页时页翻不动);没得翻写「—」不写 0/0
· 工作成果详情能翻份;表宽超出能横滑(E04/I01 抄版式时把 overflow-x 抄丢了)
· 查看面灰底滚过一屏变白 —— 两条独立的路都治了(沙箱内不同色 / 弹性子项不长个)
· 测试台「取不到」不再当成「还活着」

**活动**
E04 关系覆盖、I01 品牌内容各自补到验收线:判据全读基座数、判据指向的东西摆上卡面、
样例满载且名字 ≤14 字;提交前要裁的判据搬进说明书(模型会压拍,压过之后只有说明书还在)。

验收:e2e i01 4/4、e04 3/3 全部 released,资料在办中渲得出、放行后存了档点得开;
门禁 typecheck/lint/depcruise/1628 测全绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEJX8Z7CHejvwmDQyPQuBX
This commit is contained in:
xcs
2026-08-24 10:26:45 +08:00
co-authored by Claude Opus 5
parent 70e83d0b09
commit 0aa2fc11b0
92 changed files with 13984 additions and 370 deletions
+24 -39
View File
@@ -27,7 +27,6 @@ import { Ico } from '@web-shared/Ico.tsx';
import { call } from '@web-shared/typed-fetch.ts';
import { useChatWidth } from '@web-shared/useChatWidth.ts';
import { EmptyState } from '@web-shared/desk/module-kit.tsx';
import { SURFACE_META, canSeeSurface } from '@web-shared/gov/surfaces.ts';
import { queryKeys } from './query.ts';
type ModuleKey = 'tasks' | 'approvals' | 'messages' | 'minutes' | 'report';
@@ -153,49 +152,33 @@ export function AppShell() {
);
})}
<div className="jc-rail-foot">
{/*
另两面的入口 —— 按授权亮灯:没有治理授权的人根本看不见,不给点了才说没权。
两枚并排、只留章不留名:轨底还得放身份钮,竖排三件会把左轨撑出滚动条(实测)。
*/}
{(canSeeSurface(session.data, 'design') || canSeeSurface(session.data, 'ops')) && (
<div className="jc-nav-user" style={{ flexDirection: 'row', gap: 6, paddingTop: 0 }}>
{canSeeSurface(session.data, 'design') && (
<a
className="jc-nav-ava"
href={SURFACE_META.design.path}
title="设计中心 —— 定义企业怎么运转"
style={{ width: 26, height: 26, fontSize: 11, background: 'linear-gradient(140deg,#4954e6,#232b6e)' }}
>
设
</a>
)}
{canSeeSurface(session.data, 'ops') && (
<a
className="jc-nav-ava"
href={SURFACE_META.ops.path}
title="运营中心 —— 看企业今天跑得怎样"
style={{ width: 26, height: 26, fontSize: 11, background: 'linear-gradient(140deg,#12b886,#0b7a5c)' }}
>
运
</a>
)}
</div>
)}
{/* 轨底身份即登出入口 —— 会话在服务端,点它是真作废,不只是清本地 */}
<button
type="button"
className="jc-nav-user"
title={`${me}(本工作台所有者)· 点击登出`}
disabled={logout.isPending}
onClick={() => logout.mutate()}
>
{/* 轨底只报身份,不再兼职登出入口 —— 退出登录挪到顶栏,免得点头像误触登出 */}
<div className="jc-nav-user" title={`${me}(本工作台所有者)`}>
<span className="jc-nav-ava">{me.slice(0, 1)}</span>
<span className="jc-nav-name">{me}</span>
</button>
</div>
</div>
</nav>
{module === 'tasks' ? (
<div className="app-main">
<header className="jc-topbar">
<span className="jc-topbar-title">{NAV_MODULES.find((m) => m.key === module)?.label ?? ''}</span>
<div className="jc-topbar-user">
<span className="jc-topbar-name">{me}</span>
<button
type="button"
className="jc-topbar-logout"
disabled={logout.isPending}
onClick={() => logout.mutate()}
>
<Ico name="log-out" s={14} />
退出登录
</button>
</div>
</header>
<div className="app-main-body">
{module === 'tasks' ? (
<div className={'workspace' + (collapsed ? ' jc-side-collapsed' : '')} style={split.style}>
{collapsed && (
<button
@@ -320,6 +303,8 @@ export function AppShell() {
) : (
<ReportDesk />
)}
</div>
</div>
</div>
</div>
);
+69 -3
View File
@@ -476,7 +476,7 @@
.proto-root .jc-sup:focus { outline: 0; border-color: var(--brand); box-shadow: 0 0 0 3px rgba(51,112,255,.12); }
.proto-root .jc-sup:disabled { background: #f3f4f6; color: #5c626b; }
/* ===== 左侧一体化导航轨(无顶栏):品牌 → 四大模块 → 轨底动作 + 登录者身份 ===== */
/* ===== 左侧一体化导航轨:品牌 → 四大模块 → 轨底身份(登出已挪去顶栏,见下)===== */
.proto-root .app-nav { padding: 14px 10px 12px; }
.proto-root .jc-rail-brand { text-align: center; font-size: 10px; font-weight: 800; letter-spacing: .14em; color: var(--ink-3); margin: -4px 0 6px; }
.proto-root .app-nav button { min-height: 58px; transition: background var(--t-fast), color var(--t-fast), border-color var(--t-fast); }
@@ -489,6 +489,18 @@
.proto-root .jc-nav-ava { width: 32px; height: 32px; border-radius: 999px; display: grid; place-items: center; flex: 0 0 auto; line-height: 1; color: #fff; font-size: 13px; font-weight: 800; background: linear-gradient(140deg, #2bc7d4, #0e9fd8); box-shadow: 0 2px 6px rgba(14, 159, 216, .35); }
.proto-root .jc-nav-name { font-size: 10px; font-weight: 600; color: var(--ink-3); }
/* ===== 顶栏:当前模块名 + 身份 + 退出登录 —— 与左轨互不重叠,登出只有这一处入口 ===== */
.proto-root .app-main { min-width: 0; min-height: 0; display: flex; flex-direction: column; }
.proto-root .app-main-body { flex: 1 1 auto; min-width: 0; min-height: 0; display: flex; flex-direction: column; }
.proto-root .app-main-body > * { flex: 1 1 auto; min-width: 0; min-height: 0; }
.proto-root .jc-topbar { flex: 0 0 auto; height: 56px; display: flex; align-items: center; justify-content: space-between; padding: 0 20px; background: #fff; border-bottom: 1px solid var(--line); }
.proto-root .jc-topbar-title { font-size: 14px; font-weight: 700; color: var(--ink); }
.proto-root .jc-topbar-user { display: flex; align-items: center; gap: 12px; }
.proto-root .jc-topbar-name { font-size: 13px; color: var(--ink-3); }
.proto-root .jc-topbar-logout { display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 12px; border: 1px solid var(--line); border-radius: 8px; background: #fff; color: var(--ink-2); font-size: 13px; font-weight: 600; cursor: pointer; transition: background var(--t-fast), color var(--t-fast), border-color var(--t-fast); }
.proto-root .jc-topbar-logout:hover { background: var(--danger-soft); color: var(--danger); border-color: #f3c9c6; }
.proto-root .jc-topbar-logout:disabled { opacity: .6; cursor: default; }
/* 工作清单头:渐变图标点缀 + 两级筛选 */
.proto-root .jc-wl-head { display: flex; align-items: center; justify-content: flex-start; gap: 10px; }
.proto-root .jc-wl-head > div { flex: 1; min-width: 0; text-align: left; }
@@ -909,16 +921,50 @@
.proto-root .fileview-name svg { color: var(--ink-3); flex: none; }
.proto-root .fileview-name strong { font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.proto-root .fileview-kind { margin-left: auto; flex: none; font-size: 11px; font-weight: 700; padding: 3px 9px; border-radius: 999px; }
/* 下载原件 —— 屏上渲的是读回来的样子,这条给的是真字节(.docx 用 Word 打得开) */
.proto-root .fileview-dl { display: inline-flex; align-items: center; gap: 5px; flex: none; height: 28px; padding: 0 11px;
border: 1px solid var(--line); border-radius: 8px; background: #fff; color: var(--ink-2);
font-size: 12px; font-weight: 600; text-decoration: none; transition: all var(--t-fast); }
.proto-root .fileview-dl:hover { border-color: var(--brand-soft); background: var(--brand-wash); color: var(--brand); }
.proto-root .fileview-dl svg { flex: none; }
.proto-root .fileview-kind.k-markdown { color: var(--brand-deep); background: var(--brand-wash); }
.proto-root .fileview-kind.k-excel { color: var(--ok); background: var(--ok-soft); }
.proto-root .fileview-kind.k-word { color: var(--st-auto); background: var(--st-auto-soft); }
.proto-root .fileview-kind.k-pdf { color: var(--danger); background: var(--danger-soft); }
.proto-root .fileview-body { min-height: 0; padding: 26px 22px; background: radial-gradient(120% 80% at 50% 0%, #eef1f6 0%, #e8ecf1 100%); }
/*
* **书桌要跟着纸一样长**(2026-08-23 用户第二次报「灰底滚动后变白」,附截图)。
*
* 这一格是 `.fileview`(`height:100%`)的弹性子项,而弹性子项默认 `flex: 0 1 auto`:
* 它**不长个**,高度被容器(= 看得见的那一屏)封住。里面那张纸比一屏长时,
* 纸溢出这个盒子继续往下排,而**背景只画在盒子里** —— 于是滚到一屏之外,
* 纸两侧的灰书桌没了,底下是面板自己的白。上一轮只治了走沙箱的 markdown / html
* (那两档的底色画在 iframe 里),word / excel 这两档是父文档直接渲的,漏在外面。
*
* `flex: 1 0 auto`:不许被压(`shrink:0` → 盒子至少和内容一样高,背景铺到底),
* 内容比一屏短时又能长满(`grow:1` → 不会在下方留一条白边)。
*/
.proto-root .fileview-body { flex: 1 0 auto; min-height: 0; padding: 26px 22px; background: radial-gradient(120% 80% at 50% 0%, #eef1f6 0%, #e8ecf1 100%); }
/* 文档纸张(word:居中纸张) */
.proto-root .doc-paper { max-width: 760px; margin: 0 auto; background: #fff; border: 1px solid var(--line-soft); border-radius: 8px; box-shadow: 0 16px 44px rgba(31, 35, 41, .14), 0 2px 6px rgba(31, 35, 41, .06); padding: 44px 52px; }
/* markdown:铺满中间区全渲染,无窄栏/灰边/纸张感 */
.proto-root .fileview.k-markdown .fileview-body { background: #fff; padding: 0; }
/*
* **Markdown 与 HTML 走沙箱之后,外层这一格要跟里面同色**(2026-08-23 用户:
* 「渲染页面向下翻的时候背景的灰色在下半截会变成白色」)。
*
* 这条从前是 `background:#fff; padding:0` —— 那时 markdown 是一整块 `<pre>` 纸面铺满,
* 白是对的。现在里面是一份**自带灰底 + 居中白纸**的文档(与下载到的 .html 同一份),
* iframe 高度按内容算:内容比这一屏矮时,底下那截露出来的就是外层这块白 ——
* 人往下翻,灰底在半路变成白色。同色(与文档自己的 #f4f5f7 一致)之后接缝就没了。
*
* 也不再给内边距:那份文档自己排的版(纸面居中、两侧留白),外层再套一层边距
* 等于两套版面语言叠着。excel / word / pdf 那三种仍走上面的渐变书桌 —— 它们没有自己的底。
*/
.proto-root .fileview.k-markdown .fileview-body,
.proto-root .fileview.k-html .fileview-body { background: #f4f5f7; padding: 0; }
/* 内容比这一屏矮时,让沙箱把这一格铺满 —— 灰底一路到底,不在中途断开 */
.proto-root .fileview.k-markdown .jc-view-frame,
.proto-root .fileview.k-html .jc-view-frame { min-height: 100%; }
.proto-root .doc-paper.doc-full { max-width: none; margin: 0; min-height: 100%; border: 0; border-radius: 0; box-shadow: none; padding: 28px 40px; }
.proto-root .word-paper h1 { font-size: 22px; font-weight: 800; color: var(--ink); margin: 0 0 14px; text-align: center; }
.proto-root .word-paper h2 { font-size: 16px; font-weight: 700; color: var(--ink); margin: 20px 0 8px; }
@@ -2132,6 +2178,24 @@
.proto-root .jc-dv { padding: 12px 14px; background: #fff; border: 1px solid var(--cardline); border-left: 3px solid var(--line); border-radius: 11px; box-shadow: var(--card-shadow); }
.proto-root .jc-dv.s-ok { border-left-color: var(--ok); }
.proto-root .jc-dv.s-live { border-left-color: var(--brand); }
/* ── 产出的资料(与定稿报告分两摞,2026-08-23)────────────────────────
小标题只是分组条,不是卡片:它要看着比行「轻」,不然一摞里出现三种同重量的块 */
.proto-root .jc-dvs-sub { display: flex; align-items: baseline; gap: 8px; margin: 4px 0 -2px; padding: 0 2px;
font-size: 11.5px; font-weight: 800; letter-spacing: .04em; color: var(--ink-3); }
.proto-root .jc-dvs-sub em { font-style: normal; font-weight: 700; color: var(--ink-4); }
.proto-root .jc-dvs-sub i { margin-left: auto; font-style: normal; font-weight: 600; color: var(--ink-4); }
/* 资料行按格式上色(与件上的格式徽同一套颜色) */
.proto-root .jc-dv.m-file { border-left-color: var(--ink-4); }
.proto-root .jc-dv.m-file.f-docx { border-left-color: #2b5fd9; }
.proto-root .jc-dv.m-file.f-xlsx { border-left-color: #1f7a45; }
.proto-root .jc-dv.m-file.f-html { border-left-color: #b25e15; }
.proto-root .jc-dv-fmt { flex: none; margin-left: auto; padding: 3px 9px; border-radius: 999px;
font-size: 11px; font-weight: 800; letter-spacing: .04em; text-transform: uppercase;
background: var(--soft); color: var(--ink-3); }
.proto-root .jc-dv-fmt.f-docx { background: #eaf1ff; color: #2b5fd9; }
.proto-root .jc-dv-fmt.f-xlsx { background: #e9f7ee; color: #1f7a45; }
.proto-root .jc-dv-fmt.f-html { background: #fdf0e6; color: #b25e15; }
.proto-root .jc-dv.s-bad { border-left-color: var(--danger); }
.proto-root .jc-dv-h { display: flex; align-items: flex-start; gap: 10px; }
.proto-root .jc-dv-ico { flex: none; width: 30px; height: 30px; border-radius: 8px; display: grid; place-items: center; color: var(--ink-4); background: var(--soft); }
@@ -2842,6 +2906,8 @@
.proto-root .jc-tnav-btn:active:not(:disabled) { transform: scale(.95); }
.proto-root .jc-tnav-btn:disabled { opacity: .35; cursor: default; }
.proto-root .jc-tnav-n { min-width: 34px; text-align: center; font-size: 11px; font-weight: 800; color: var(--ink-2); }
/* 两组翻页同时在场时(多张卡各自多页)各带一个字,单独在场时不摆 —— 一个数不必解释自己 */
.proto-root .jc-tnav-mk { font-style: normal; font-weight: 700; color: var(--ink-3); margin-right: 3px; }
.proto-root .jc-tnav-btn.back { width: auto; padding: 0 14px 0 10px; font-size: 12.5px; font-weight: 700; line-height: 1; white-space: nowrap; }
.proto-root .jc-tnav-btn svg { flex: none; display: block; }
/* 窄栏:页签藏图标省宽,保持一排 */
@@ -0,0 +1,45 @@
/**
* 工作区右上角的翻页 —— 2026-08-23 用户三连指出的那几处。
*/
import { describe, expect, it } from 'vitest';
import { pagerFace, pagerText } from '../internal/pager.ts';
describe('pagerText —— 没有可翻的东西就别摆数字', () => {
it('**一件都没有:写「—」,不写 0/0**(0 分之 0 不是一个数)', () => {
expect(pagerText(0, 0)).toBe('—');
});
it('只有一件:照旧 1/1(点位条要定得住,不能忽有忽无)', () => {
expect(pagerText(0, 1)).toBe('1/1');
});
it('多件:报当下这一件', () => {
expect(pagerText(1, 3)).toBe('2/3');
});
it('页码越界(件数变少了而页码还停在后面):夹到最后一件,不报一个不存在的号', () => {
expect(pagerText(5, 2)).toBe('2/2');
});
});
describe('pagerFace —— 翻件与翻页各归各位', () => {
it('一件一页:两组都不出,只留占位', () => {
expect(pagerFace({ items: 1, pages: 1 })).toEqual({ items: false, pages: false, solo: true });
});
it('多件单页:只出翻件', () => {
expect(pagerFace({ items: 3, pages: 1 })).toEqual({ items: true, pages: false, solo: false });
});
it('单件多页:只出翻页(A2 一颗待选型芯片一页)', () => {
expect(pagerFace({ items: 1, pages: 4 })).toEqual({ items: false, pages: true, solo: false });
});
it('**多件各自多页:两组同时出** —— 从前二选一,页就翻不动了', () => {
expect(pagerFace({ items: 2, pages: 3 })).toEqual({ items: true, pages: true, solo: false });
});
it('一件都没有:两组都不出,占位那一格由 pagerText 写「—」', () => {
expect(pagerFace({ items: 0, pages: 1 })).toEqual({ items: false, pages: false, solo: true });
});
});
@@ -21,7 +21,7 @@ const KIND: Readonly<Record<FileDoc['kind'], { label: string; icon: string }>> =
* 二级界面**长在工作区内部**:顶部四个页签始终不变,
* 「返回」由页签条右槽给出(见 ViewRegion)—— 所以这里的标题栏不带返回钮,只报文件本身。
*/
export function FileViewer({ doc }: { doc: FileDoc }) {
export function FileViewer({ doc, href }: { doc: FileDoc; href?: string | undefined }) {
const k = KIND[doc.kind];
return (
<div className={'fileview k-' + doc.kind}>
@@ -31,15 +31,33 @@ export function FileViewer({ doc }: { doc: FileDoc }) {
<strong>{doc.name}</strong>
</div>
<span className={'fileview-kind k-' + doc.kind}>{k.label}</span>
{/*
**拿得走才算文件**(2026-08-23):屏上这一份是读回来渲的,而这条链接给的是
原字节 —— .docx 用 Word 打得开、.xlsx 用 Excel 打得开。没有下载口的话,
「产出了 Word」这句话就只能靠界面自称。取不到 href 时不摆这个钮(不给死链)。
*/}
{href !== undefined && (
<a className="fileview-dl" href={href} download={doc.name}>
<Ico name="download" s={13} /> 下载原件
</a>
)}
</div>
<div className="fileview-body">
{doc.kind === 'html' && <ViewContainer html={doc.html} title={doc.name} />}
{doc.kind === 'markdown' && (
// 富文本渲染(GFM 表格 / 内联 SVG)尚未接入 —— 先以原文纸面呈现,不假装渲染过(见 debt)
<article className="doc-paper doc-full">
<pre className="md">{doc.md}</pre>
</article>
)}
{doc.kind === 'markdown' &&
/*
* **渲好的那一份由基座给**(`html`,2026-08-23):它与下载到的 .html 是
* 同一段函数出的,两处看着是同一份文件;渲染走沙箱,与别的 HTML 件同一条路
* (前端拼 DOM 就得 `dangerouslySetInnerHTML`,那是把模型写的字注进父文档)。
* `html` 缺席仍退回原文纸面 —— **不假装渲染过**。
*/
(doc.html === undefined ? (
<article className="doc-paper doc-full">
<pre className="md">{doc.md}</pre>
</article>
) : (
<ViewContainer html={doc.html} title={doc.name} />
))}
{doc.kind === 'excel' && <ExcelView doc={doc} />}
{doc.kind === 'word' && <WordView doc={doc} />}
{doc.kind === 'pdf' && <iframe className="pdf-frame" src={doc.src} title={doc.name} />}
@@ -12,7 +12,7 @@
*/
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { markCommReadRoute, saveTableRoute } from '@contracts';
import { markCommReadRoute, openAssetFileRoute, 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';
@@ -20,6 +20,7 @@ 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 { pagerFace, pagerText } from './pager.ts';
import type { ViewEdits, ViewSave } from '@ui';
import { GuideCard, WorkOrderReport } from './work-order-report.tsx';
import { FileViewer } from './FileViewer.tsx';
@@ -78,6 +79,59 @@ function collectEdits(edits: ViewEdits, order: WorkOrder, onField: (k: string, v
}
}
/**
* 右上角那对箭头 —— **翻件**与**翻页**共用这一个形状,靠 `mark` 区分。
* 两组同时出现时(多张卡各自多页)才摆那个字,单独出现时保持原样不占地方。
*/
/** 资料格式各用各的图标 —— 一眼看得出点开的是张表还是一份文档 */
const ICO_OF_FORMAT: Readonly<Record<string, string>> = {
xlsx: 'file-spreadsheet',
docx: 'file-type',
html: 'file-text',
md: 'file-text',
};
function Pager({
mark,
pos,
total,
onGo,
what,
}: {
mark?: string;
pos: number;
total: number;
onGo: (next: number) => void;
what: string;
}) {
return (
<span className="jc-tnav-pg" aria-label={what}>
<button
type="button"
className="jc-tnav-btn"
disabled={pos <= 0}
onClick={() => onGo(pos - 1)}
aria-label={`上一${what}`}
>
<Ico name="chevron-left" s={15} />
</button>
<b className="jc-tnav-n jc-mono">
{mark === undefined ? null : <i className="jc-tnav-mk">{mark}</i>}
{pagerText(pos, total)}
</b>
<button
type="button"
className="jc-tnav-btn"
disabled={pos >= total - 1}
onClick={() => onGo(pos + 1)}
aria-label={`下一${what}`}
>
<Ico name="chevron-right" s={15} />
</button>
</span>
);
}
export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: ViewRegionProps) {
const [view, setView] = useState<Tab>('open');
const [page, setPage] = useState(0);
@@ -90,12 +144,37 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
const [docPages, setDocPages] = useState(1);
/** 工作成果:列表里点开的那份定稿报告 */
const [doneSel, setDoneSel] = useState<string | null>(null);
/**
* **定稿报告的件内翻页**(2026-08-21 用户:「工作成果里面点进一个工作成果也需要
* 这个按钮」)——待处理那份件的翻页只接在 `cur`/`docPage` 上,工作成果点进详情后
* 走的是另一套局面(`doneItem`),从前那对 `[data-page]` 页从没接过 `page`/`onPages`,
* 于是定稿报告里带多页的件(如一颗芯片一页的选型件)在成果详情里会**整份铺开**,
* 待处理那一屏能翻的页,归档后反而翻不了。与 `docPage`/`docPages` 分开一份状态,
* 免得两个局面共用一个页码,来回切换互相冲页。
*/
const [doneDocPage, setDoneDocPage] = useState(0);
const [doneDocPages, setDoneDocPages] = useState(1);
const [openFile, setOpenFile] = useState<FileDoc | undefined>(undefined);
/** 那一份取不回来时的原话 —— 摆在件上方,不弹窗、不静默 */
const [fileError, setFileError] = useState<string | undefined>(undefined);
/** 当下这一份的**原件**地址(`raw=1`)—— 屏上渲的是读回来的样子,下载给的是真字节 */
const [fileHref, setFileHref] = useState<string | undefined>(undefined);
const orders = detail.workOrders;
const openList = orders.filter((o) => o.done === undefined);
/* 未读条数 —— 页签那枚红气泡读的就是它;标记按钮按一下这个数才动 */
const unreadN = detail.comms.filter((m) => m.unread).length;
/**
* **`awaiting-event` 是「本回合挂起」的统称**,`decide`/`entry`/`listen` 三条路
* 都落这一个值 —— 而 decide/entry 发的是**对话区的卡**(chat_card),不进
* `work_order`,`openList` 因此照旧空。这一屏原样套用「不用盯着,轮到你时
* 工单会带着上下文出现在这里」就成了空话:那张卡已经在等,而且**永远不会**
* 出现在这一页(它压根不是工单)。界面不撒谎 —— 这里改成读一眼 `detail.chat`
* 里有没有未回的决策卡 / 录入卡,是就换一句指向对话区的实话。
*/
const pendingChatCard = detail.chat.some(
(m) => (m.k === 'decision' && m.chosen === undefined) || (m.k === 'entry' && m.done === undefined),
);
/*
* 标记已读 / 未读 —— 乐观更新交给失效:标完服务端广播 task 触点,
@@ -122,12 +201,41 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['desk', 'task', detail.card.id] }),
});
/*
* **点开一份资料文件** —— 件上点了资料名,这里去把那一份取回来。
*
* 为什么要跑一趟服务端而不在前端拼:那份文件是**真造出来的字节**
* (docx / xlsx 是 ZIP 包着 XML),前端拿到的是它读回来的样子 ——
* 屏上渲的与下载到的是同一份东西。取不到就如实弹一句,不摆一个空壳。
*/
const openAssetFile = useMutation({
mutationFn: (ref: { address: string; key: string }) => {
const qs = `address=${encodeURIComponent(ref.address)}&key=${encodeURIComponent(ref.key)}`;
setFileHref(`/api/desk/tasks/${detail.card.id}/file?${qs}&raw=1`);
return call<{ file: FileDoc; fileName: string; format: string }>(openAssetFileRoute, {
params: { taskId: detail.card.id },
query: { address: ref.address, key: ref.key },
});
},
onSuccess: (out) => setOpenFile(out.file),
onError: (error: unknown) => setFileError(error instanceof Error ? error.message : '这份资料打不开'),
});
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);
/*
* **工作成果的「一行一份」不分是工单还是决策卡答出来的**——`decide` 是唯一能循环的
* 中间验收机制(如 E04 的经营计划审批),答完不产生工单行,报告快照走的是
* `detail.decidedDeliverables` 这条单独的口(见后端注释:混进 `workOrders`
* 会连累 `signed` 那处「`mode==='acceptance'` 恒是终局放行卡」的假设)。
* 只在这一屏(工作成果列表)合并显示,`orders`/`openList`/`signed` 一律不动。
*/
const doneList = [...orders.filter((o) => o.done !== undefined), ...detail.decidedDeliverables].sort((a, b) =>
a.at < b.at ? -1 : a.at > b.at ? 1 : 0,
);
/*
* **字签下了,但这一单还没收尾** —— 签完到收尾之间那一小段(合并 / 发口 / 落终态)。
* 认的是「有一张办结的验收单 + 还没有终态」:两者同时成立只有这一种局面。
@@ -138,6 +246,10 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
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);
/** 成果详情里当下摆的是第几份 —— 翻「份」用它 */
const donePos = Math.max(0, doneList.findIndex((o) => o.id === doneSel));
/** 这一屏该出哪几组翻页(件 / 页 / 都没有) —— 判断抽在 `pager.ts`,有测 */
const face = pagerFace({ items: openList.length, pages: docPages });
/*
* 对话区点击 → 跳到工单所在页签与页位(渲染期调整 state,seen 保证幂等 —— PRD 同款)。
@@ -150,25 +262,37 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
setOpenFile(undefined);
setView(locate.tab);
setPage(0);
setDocPage(0);
setDoneSel(null);
}
const hit = orders.find((o) => o.id === locate.orderId);
// 决策卡答出来的成果没有工单行(见 doneList 那条注释),定位时一并找
const hit = orders.find((o) => o.id === locate.orderId) ?? detail.decidedDeliverables.find((o) => o.id === locate.orderId);
if (hit !== undefined) {
setOpenFile(undefined);
if (hit.done !== undefined) {
setView('done');
setDoneSel(hit.id);
setDoneDocPage(0);
} else {
setView('open');
setPage(Math.max(0, openList.findIndex((o) => o.id === locate.orderId)));
setDocPage(0);
}
}
}
/** 换件 —— 页码跟着归零(上一件翻到第 3 页,下一件可能只有一页) */
const goItem = (next: number) => {
setPage(next);
setDocPage(0);
};
const go = (to: Tab) => {
setView(to);
setPage(0);
setDocPage(0);
setDoneSel(null);
setDoneDocPage(0);
};
const terminalLabel = detail.card.terminal === undefined ? undefined : TERMINAL_STATE_LABEL[detail.card.terminal];
@@ -278,64 +402,84 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
顶部五个按钮**始终不变** —— 二级界面长在工作区内部,不是另一页。
*/}
{openFile !== undefined ? (
<button type="button" className="jc-tnav-btn jc-tnav-back" onClick={() => setOpenFile(undefined)}>
<button
type="button"
className="jc-tnav-btn jc-tnav-back"
onClick={() => {
setOpenFile(undefined);
setFileHref(undefined);
setFileError(undefined);
}}
>
<Ico name="arrow-left" s={14} /> 返回
</button>
) : view === 'done' && doneItem !== undefined ? (
<button type="button" className="jc-tnav-btn back" onClick={() => setDoneSel(null)}>
<Ico name="chevron-left" s={14} /> 返回列表
</button>
/*
* 成果详情右上角摆两组翻页 + 返回列表:
* · **翻页**(2026-08-21):同一份带多页的件归了档,翻页的能力不该不见了;
* · **翻份**(2026-08-23 用户:「工作成果点进一个工作成果也需要这个东西」)——
* 从前点进详情只有「返回列表」:想看下一份得先退出去再点一次,
* 而一单常有三五份定稿(执行单快照 / 验收单快照 / 决策卡快照),对着看是常事。
* 换份时页码归零 —— 上一份翻到第 3 页,下一份可能只有一页。
* 各自只在多于一个时现身;都只有一个就照旧只留「返回列表」。
*/
<>
{doneList.length > 1 && (
<Pager
{...(doneDocPages > 1 ? { mark: '份' } : {})}
pos={donePos}
total={doneList.length}
what="份成果"
onGo={(next) => {
const to = doneList[Math.max(0, Math.min(next, doneList.length - 1))];
if (to !== undefined) setDoneSel(to.id);
setDoneDocPage(0);
}}
/>
)}
{doneDocPages > 1 && (
<Pager
{...(doneList.length > 1 ? { mark: '页' } : {})}
pos={doneDocPage}
total={doneDocPages}
what="页"
onGo={setDoneDocPage}
/>
)}
<button type="button" className="jc-tnav-btn back" onClick={() => setDoneSel(null)}>
<Ico name="chevron-left" s={14} /> 返回列表
</button>
</>
) : view === 'open' ? (
/* 单卡多页时这对箭头翻**件里的页**(一颗芯片一页);多卡时照旧翻件 */
docPages > 1 && openList.length <= 1 ? (
<span className="jc-tnav-pg" aria-label="翻页">
<button
type="button"
className="jc-tnav-btn"
disabled={docPage === 0}
onClick={() => setDocPage(docPage - 1)}
aria-label="上一页"
>
<Ico name="chevron-left" s={15} />
</button>
<b className="jc-tnav-n jc-mono">
{docPage + 1}/{docPages}
</b>
<button
type="button"
className="jc-tnav-btn"
disabled={docPage >= docPages - 1}
onClick={() => setDocPage(docPage + 1)}
aria-label="下一页"
>
<Ico name="chevron-right" s={15} />
</button>
</span>
) : (
<span className="jc-tnav-pg" aria-label="翻页">
<button
type="button"
className="jc-tnav-btn"
disabled={pos === 0}
onClick={() => setPage(pos - 1)}
aria-label="上一件"
>
<Ico name="chevron-left" s={15} />
</button>
<b className="jc-tnav-n jc-mono">
{openList.length === 0 ? 0 : pos + 1}/{openList.length}
</b>
<button
type="button"
className="jc-tnav-btn"
disabled={pos >= openList.length - 1}
onClick={() => setPage(pos + 1)}
aria-label="下一件"
>
<Ico name="chevron-right" s={15} />
</button>
</span>
)
/*
* **翻件与翻页各归各位**(2026-08-23 用户:「一次叫人不一定只有一个 html
* 模板所以需要翻页,我看现在这个 0/0 显示的不正常」)。从前两者二选一
* (`docPages > 1 && openList.length <= 1`):多张卡各自多页时**页就翻不动了**,
* 人只看得见每张卡的第一页。现在同时出,两组各带一个字区分。
* 一件都没有时(还在跑 / 在等对话区那张卡 / 已收尾)也不再摆「0/0」——
* 那不是一个数,是「这儿没有可翻的东西」,写「—」。
*/
<>
{face.items && (
<Pager
{...(face.pages ? { mark: '件' } : {})}
pos={pos}
total={openList.length}
what="件"
onGo={goItem}
/>
)}
{face.pages && (
<Pager
{...(face.items ? { mark: '页' } : {})}
pos={docPage}
total={docPages}
what="页"
onGo={setDocPage}
/>
)}
{face.solo && <Pager pos={pos} total={openList.length} what="件" onGo={goItem} />}
</>
) : null}
</div>
@@ -347,7 +491,10 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
inset={-6}
>
{openFile !== undefined ? (
<FileViewer doc={openFile} />
<FileViewer doc={openFile} href={fileHref} />
) : fileError !== undefined ? (
/* 那一份取不回来:**如实说是哪一句**(服务端原话),别把人留在原地猜 */
<EmptyState ico="file-text" tone="muted" title="这份资料打不开" desc={fileError} />
) : view === 'comms' ? (
/*
* 沟通记录 = 一单往来的**原件**。进来的那一封是开案的那份料 ——
@@ -455,6 +602,12 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
<ViewContainer
html={doneItem.finalHtml ?? detail.viewHtml ?? ''}
title={`${detail.card.activity} · 定稿报告`}
page={doneDocPage}
onPages={setDoneDocPages}
onOpenFile={(ref) => {
setFileError(undefined);
openAssetFile.mutate(ref);
}}
/>
) : (
<article className="jc-mst-doc">
@@ -469,9 +622,53 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
/>
</article>
)
) : doneList.length > 0 ? (
) : doneList.length > 0 || detail.assetFiles.length > 0 ? (
/* 一行一份成果报告,点进去看整篇(PRD:jc-dvs 列表) */
<div className="jc-dvs">
{/*
* **产出的资料另起一摞**(2026-08-23 用户:「工作成果里也需要有产出的附件,
* 点击后可以预览」)。它与定稿报告不是一类东西:报告是「叫了人办的那件事
* 办完长什么样」,资料是**办出来的东西本身**(白皮书 / 对照表 / 落地页)——
* 混在一摞里,「这一单交出了几份成果」这个数就说不清了。
* 点开走的是件上点资料名的同一条路(同一个地址 + 主键)。
*/}
{detail.assetFiles.length > 0 && (
<>
<div className="jc-dvs-sub">
产出的资料
<em>{detail.assetFiles.length} 份</em>
{/* 没存档的是在办中的那几份 —— 如实说,别让人以为已经归了档 */}
{detail.assetFiles.every((f) => f.archived) ? null : <i>在办中 · 放行时存档</i>}
</div>
{detail.assetFiles.map((f) => (
<button
key={`${f.address}/${f.key}`}
type="button"
className={'jc-dv jc-dv-row s-ok m-file f-' + f.format}
onClick={() => {
setFileError(undefined);
openAssetFile.mutate({ address: f.address, key: f.key });
}}
>
<span className="jc-dv-h">
<span className="jc-dv-ico">
<Ico name={ICO_OF_FORMAT[f.format] ?? 'file-text'} s={16} />
</span>
<span className="jc-dv-t">
<em>
{f.name}
{f.at === undefined ? '' : ` · 存档 ${atMinute(f.at)}`}
</em>
<b>{f.title}</b>
</span>
<span className={'jc-dv-fmt f-' + f.format}>{f.format}</span>
<Ico name="chevron-right" s={15} />
</span>
</button>
))}
{doneList.length > 0 && <div className="jc-dvs-sub">定稿报告<em>{doneList.length} 份</em></div>}
</>
)}
{doneList.map((o) => {
const res = o.done?.result;
const bad = res !== undefined && isRejection(res);
@@ -487,7 +684,10 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
className={
'jc-dv jc-dv-row ' + (bad ? 's-bad' : 's-ok') + (o.mode === 'acceptance' ? ' m-acc' : ' m-exe')
}
onClick={() => setDoneSel(o.id)}
onClick={() => {
setDoneSel(o.id);
setDoneDocPage(0);
}}
>
<span className="jc-dv-h">
<span className="jc-dv-ico">
@@ -518,7 +718,47 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
hint="先去「待处理」把手上的事办完"
/>
)
) : cur !== undefined ? (
) : detail.card.terminal !== undefined ? (
<EmptyState
ico="circle-check"
tone="ok"
title="这单已收尾"
desc="所有工单都定稿归档了。要回看哪一份,去「工作成果」一行一份点开整篇。"
/>
) : signed ? (
/*
* **签完到收尾之间那一小段**(2026-08-02 用户:「点完验收按钮后,
* 状态变化时交互上会闪一下中间态,视觉效果不好」)。
*
* 那一闪不是渲染毛病,是**真的中间态**:字签下去了,合并、发口、收尾还在跑。
* 从前这一段落在通用四态里 —— 待办清零 → 「都办完了」闪一下 → 再跳「已收尾」。
* 「都办完了」这句话在那一刻是对的,可它说的是**另一件事**(你手上没活了),
* 于是读起来像跳了一帧。
*
* 现在这一段有自己的话:**字已签下,正在落地**。它不是为了盖住中间态 ——
* 中间态本来就该说出来,只是要说对。
*/
<EmptyState
ico="hourglass"
tone="brand"
title="字已签下,正在落地"
desc="成果正在并进正式表、按去向发往下游。落完这一屏会换成收尾结果 —— 不用刷新。"
hint="通常几秒钟"
/>
) : detail.card.runtime === 'running' ? (
/*
* **AI 正在算这一拍,不是「已经有一份件摆着」**(2026-08-21 用户:
* 「AI 持续推进中时中间的页面不变成持续推进的页面了,而是卡在 html
* 模版页面」)——这是上面 `detail.viewHtml` 判据改动带出的回归:
* `viewHtml` 是**上一次**推的那一帧,活步收完之后引擎不主动清它
* (「一帧不推,让最后摆上去的那张留在档案里」),于是它在 AI 正埋头
* 算下一拍的这几秒里仍然「有货」——viewHtml 判据排在 running 前面,
* 空待处理就被这份**陈旧**的件抢先占住,推进中动画再也显示不出来。
* **正在跑的这一拍,永远比上一拍摆下的旧件优先**:running 判据挪到
* viewHtml 前面,陈旧的件只在「AI 这会儿不在算」时才配拿来顶上。
*/
<WorkingScene doneN={doneList.length} />
) : detail.viewHtml !== undefined ? (
/*
* **工作区只放那份件本身**(2026-08-01 用户定)。
*
@@ -531,79 +771,75 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
* **判据与作业指导书跟着「要你办的这件事」走,落在对话区的卡上**;
* 提交那一下也在那儿 —— 编辑与提交分在两个区,缓冲由壳层持有(values)。
*
* 活动没推件的(还没到推件那一拍、或包里就没有模版)才退回工单报告:
* 那时工作区总不能是白的。
* **这一格判据从前是 `cur !== undefined`(挂在开着的工单上)**(2026-08-21
* 用户送来实测截图查出来的坑):`decide` 循环(如 E04 S4「经营计划审批」)
* 不开工单,活动照旧按 `view:'coverage-plan'` 推了一屏「E04-验收工单-01-
* 关系经营计划.html」——`detail.viewHtml` 明明有货,却因为外层先问
* 「有没有开着的工单」而永远问不到它,工作区落空态,人只能去对话区
* 翻找那份件在说什么。**件推没推,与工单开没开是两件事**——
* 判据换成直接问 `detail.viewHtml` 本身,`cur` 只在没有件、要退回卡壳
* 呈现,或收边传值(`onField`)时才用得上。
*/
detail.viewHtml !== undefined ? (
<ViewContainer
html={detail.viewHtml}
title={`${detail.card.activity} · 汇报报告`}
page={docPage}
onPages={setDocPages}
editable
onEdits={(save) => {
/*
* **落库按地址走** —— 件自己说了它是哪张表(`data-edit="域.对象"`),
* 原样送上去即可。从前借道当下那张卡的 `blocks[i]` 收列名,于是
* 卡上没有那一块时(熔断卡就没有)**保存一格都送不出去**,
* 而屏上照旧有闸、有编辑、有保存 —— 界面给了个落不了地的口子。
*
* 缓冲那一份仍按卡的形状收(`table:N`):卡上那张表由提交那一下带走,
* 与件上这一张是两条路,卡没有对应的块时它自然收不到东西,不影响落库。
*/
saveTable.mutate(save);
collectEdits(save.tables, cur, onField);
}}
<ViewContainer
html={detail.viewHtml}
title={`${detail.card.activity} · 汇报报告`}
page={docPage}
onPages={setDocPages}
onOpenFile={(ref) => {
setFileError(undefined);
openAssetFile.mutate(ref);
}}
editable
onEdits={(save) => {
/*
* **落库按地址走** —— 件自己说了它是哪张表(`data-edit="域.对象"`),
* 原样送上去即可。从前借道当下那张卡的 `blocks[i]` 收列名,于是
* 卡上没有那一块时(熔断卡就没有)**保存一格都送不出去**,
* 而屏上照旧有闸、有编辑、有保存 —— 界面给了个落不了地的口子。
*
* 缓冲那一份仍按卡的形状收(`table:N`):卡上那张表由提交那一下带走,
* 与件上这一张是两条路,卡没有对应的块时它自然收不到东西,不影响落库。
* `decide` 循环没有开着的工单(`cur` 恒 undefined)时这条缓冲天然不适用,
* 落库(`saveTable.mutate`)本身不靠它,原样跳过即可。
*/
saveTable.mutate(save);
if (cur !== undefined) collectEdits(save.tables, cur, onField);
}}
/>
) : cur !== undefined ? (
/* 活动没推件的(还没到推件那一拍、或包里就没有模版)才退回工单报告:那时工作区总不能是白的 */
<article className="jc-mst-doc">
<WorkOrderReport
order={cur}
detail={detail}
typeLine={cur.mode === 'acceptance' ? '验收工单 · 终局唯放行' : '执行工单 · 作业指导书 + 表单'}
blocks={cur.blocks !== undefined && cur.blocks.length > 0 ? cur.blocks : detail.view}
values={values}
onField={onField}
onReply={onReplyOrder}
/>
) : (
<article className="jc-mst-doc">
<WorkOrderReport
order={cur}
detail={detail}
typeLine={cur.mode === 'acceptance' ? '验收工单 · 终局唯放行' : '执行工单 · 作业指导书 + 表单'}
blocks={cur.blocks !== undefined && cur.blocks.length > 0 ? cur.blocks : detail.view}
values={values}
onField={onField}
onReply={onReplyOrder}
/>
{cur.mode === 'execution' && <GuideCard order={cur} />}
</article>
)
{cur.mode === 'execution' && <GuideCard order={cur} />}
</article>
) : (
/*
* 空待处理**四态分明** —— 状态如实,不许一句「没有待办」把四种局面糊成一种:
* 推进中 = AI 在自己转(动画场景);等外部 = 环停着听回音;
* 都办完了 = 你的活清零、单还在走;已收尾 = 一切定稿。
* 推进中 = AI 在自己转(动画场景,running 已在上面拦下);
* 等外部 = 环停着听回音;都办完了 = 你的活清零、单还在走;已收尾 = 一切定稿
* (terminal/signed 也已在上面拦下)。这里只剩 awaiting-event 的两条岔与
* 兜底两态。
*
* 「等外部」这一态下还有一条岔:decide/entry 挂起时 `runtime` 与真正等
* 外部回音是同一个值(`awaiting-event`),但那张卡在**对话区**等你,
* 不是在等世界回信 —— `pendingChatCard` 把这条岔认出来单独说话。
*/
detail.card.terminal !== undefined ? (
<EmptyState
ico="circle-check"
tone="ok"
title="这单已收尾"
desc="所有工单都定稿归档了。要回看哪一份,去「工作成果」一行一份点开整篇。"
/>
) : signed ? (
/*
* **签完到收尾之间那一小段**(2026-08-02 用户:「点完验收按钮后,
* 状态变化时交互上会闪一下中间态,视觉效果不好」)。
*
* 那一闪不是渲染毛病,是**真的中间态**:字签下去了,合并、发口、收尾还在跑。
* 从前这一段落在通用四态里 —— 待办清零 → 「都办完了」闪一下 → 再跳「已收尾」。
* 「都办完了」这句话在那一刻是对的,可它说的是**另一件事**(你手上没活了),
* 于是读起来像跳了一帧。
*
* 现在这一段有自己的话:**字已签下,正在落地**。它不是为了盖住中间态 ——
* 中间态本来就该说出来,只是要说对。
*/
detail.card.runtime === 'awaiting-event' && pendingChatCard ? (
<EmptyState
ico="hourglass"
tone="brand"
title="字已签下,正在落地"
desc="成果正在并进正式表、按去向发往下游。落完这一屏会换成收尾结果 —— 不用刷新。"
hint="通常几秒钟"
title="有一张卡在等你"
desc="这一步要你在对话区做个决定或补一笔档案 —— 待处理这一页只装执行 / 验收工单,那张卡走的是另一条路,去右边对话区看。"
hint="回完那张卡,AI 接着往下推进"
/>
) : detail.card.runtime === 'running' ? (
<WorkingScene doneN={doneList.length} />
) : detail.card.runtime === 'awaiting-event' ? (
<EmptyState
ico="hourglass"
@@ -0,0 +1,30 @@
/**
* 工作区右上角那对箭头**此刻在翻什么** —— 纯函数,好让「界面不撒谎」这条有测可钉。
*
* 这一屏有两种「翻」,语义不同,不该抢同一个位置(2026-08-23 用户三连指出):
* · **翻件**:待处理里有好几张工单,一张一件;
* · **翻页**:一张工单的件里有好几页(`[data-page]`,如 A2 一颗待选型芯片一页)——
* 「一次叫人不一定只有一个 HTML」说的就是它。
* 从前两者二选一(`docPages > 1 && openList.length <= 1`):**多张卡各自多页时,
* 页就翻不动了** —— 人只能看见每张卡的第一页。现在两组各归各位,同时出。
*
* 还有个数字的事:一件都没有时从前照旧摆一个「0/0」。**0 分之 0 不是一个数**,
* 它是「这儿没有可翻的东西」——那就别拿数字冒充,写「—」。
*/
/** 两组翻页此刻各出不出 —— 都不出时给一个占位(点位条要定得住,不能忽有忽无) */
export function pagerFace(input: { items: number; pages: number }): {
readonly items: boolean;
readonly pages: boolean;
readonly solo: boolean;
} {
const items = input.items > 1;
const pages = input.pages > 1;
return { items, pages, solo: !items && !pages };
}
/** 页码文案 —— 没有可翻的东西不摆「0/0」 */
export function pagerText(pos: number, total: number): string {
if (total <= 0) return '—';
return `${Math.min(pos, total - 1) + 1}/${total}`;
}
@@ -0,0 +1,61 @@
/**
* **取不到不是活着**(2026-08-23,用户:「推进完成了,但测试台还在运行中,没有停下来」)。
*
* 实测现场:面板钉着一个**已经不存在的任务**(别处 reset-runs 把它清了),屏上同时说着
* 两句相反的话 —— 上面「AWAITING-EVENT · 自动刷新 2s」灯还亮着、钮还是「停止仿真」,
* 下面一条橙带「取数失败 —— 任务不存在:fda365fc…」。病根在两处:
* ① `q.data` 在取数失败后保留**上一次成功**的那份,而那份的 `terminal` 是 undefined,
* 于是 `live` 恒真:灯常亮、2s 一直对着一个死号打;
* ② `s === undefined ? 'live'` 这个兜底本身就在撒谎 —— 还没取到 ≠ 正在跑。
*
* 这几条钉的就是「界面不撒谎」:服务端说这一单没了,面板就得当场松手,
* 别再举着上一次的状态说它在跑。
*/
import { describe, expect, it } from 'vitest';
import { observeFace } from '../internal/observe-face.ts';
describe('observeFace —— 面板此刻该怎么说', () => {
it('在跑:灯活、会呼吸、报 runtime、还在自动刷新', () => {
const face = observeFace({ data: { runtime: 'running' } });
expect(face).toMatchObject({ lamp: 'live', breathe: true, word: 'running', gone: false, poll: true });
});
it('等人:灯是 wait,照旧算活着(它在等,不是没了)', () => {
const face = observeFace({ data: { runtime: 'awaiting-human' } });
expect(face.lamp).toBe('wait');
expect(face.gone).toBe(false);
expect(face.poll).toBe(true);
});
it('跑完:终态词上位,灯灭、不呼吸、不再轮询', () => {
const face = observeFace({ data: { runtime: 'terminal', terminal: 'released' } });
expect(face).toMatchObject({ lamp: 'done', breathe: false, gone: false, poll: false });
expect(face.word).toBe('放行完成');
});
/* ↓ 这三条是这次修的正主 */
it('**任务不存在(404):当场认没了** —— 不再说在跑、不再呼吸、停止轮询', () => {
const face = observeFace({ data: { runtime: 'awaiting-event' }, error: { status: 404 } });
expect(face.gone).toBe(true);
expect(face.breathe).toBe(false);
expect(face.poll).toBe(false);
expect(face.lamp).toBe('bad');
expect(face.word).toBe('这一单已不在了');
});
it('别的取数失败(500/断网):不当「没了」,但也不许说在跑', () => {
const face = observeFace({ data: { runtime: 'running' }, error: { status: 500 } });
expect(face.gone).toBe(false);
expect(face.breathe).toBe(false);
expect(face.word).toBe('取数失败');
// 断网是暂时的 —— 照旧轮询,回来了自己接上
expect(face.poll).toBe(true);
});
it('还没取到:说「取数中」,不说在跑(灯不亮呼吸)', () => {
const face = observeFace({});
expect(face.word).toBe('取数中');
expect(face.breathe).toBe(false);
expect(face.gone).toBe(false);
});
});
@@ -14,7 +14,7 @@
import { useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { TERMINAL_STATE_LABEL, testActivityStateRoute, testRunActivityRoute, testStopRoute, uploadFileRoute } from '@contracts';
import { testActivityStateRoute, testRunActivityRoute, testStopRoute, uploadFileRoute } from '@contracts';
import type { UploadedFile } from '@contracts';
import { Ico } from '@web-shared/Ico.tsx';
import { call } from '@web-shared/typed-fetch.ts';
@@ -22,6 +22,7 @@ import { useGovActivities, useGovStaff } from '@web-shared/gov/gov-api.ts';
import { QueryGate } from '@web-shared/gov/primitives.tsx';
import type { Nav } from '@web-shared/gov/gov-route.ts';
import { DraftGroups } from './drafts.tsx';
import { observeFace } from './observe-face.ts';
type Parsed<S> = S extends { parse: (data: unknown) => infer O } ? O : never;
export type ActivityState = Parsed<typeof testActivityStateRoute.output>;
@@ -89,17 +90,61 @@ export function ActivityRun({
});
if (taskId !== undefined) {
return <ActivityObserve taskId={taskId} strip={strip} stopping={stop.isPending} onStop={() => stop.mutate(taskId)} />;
return (
<ActivityObserve
taskId={taskId}
strip={strip}
stopping={stop.isPending}
onStop={() => stop.mutate(taskId)}
/* 服务端说这一单没了:不调销毁接口(没有可销毁的),只让上层松手、重取「当下挂着哪一单」 */
onGone={onStopped}
/>
);
}
const fields = orderFields(picked?.contract.inputFields ?? [], picked?.contract.requiredInputs ?? []);
// 必填缺一即不可起 —— 门口这道校验与后端同一口径(契约的 required)
const missing = (picked?.contract.inputFields ?? [])
.filter((f) => f.required && (data[f.name] ?? '').trim() === '')
.map((f) => f.label ?? f.name);
const allFields = orderFields(picked?.contract.inputFields ?? [], picked?.contract.requiredInputs ?? []);
// 藏起来的格子不算数 —— 没露面的必填(如未选「事件驱动」时的「事件类型」)不该拦起单
const fields = allFields.filter((f) => isVisible(f, data));
const missing = fields.filter((f) => f.required && (data[f.name] ?? '').trim() === '').map((f) => f.label ?? f.name);
const fireable = picked !== undefined && missing.length === 0;
const ai = staff.find((s) => s.name === 'AI');
/*
* 改一格顺手清掉因此藏起来的那几格 —— 触发方式从「事件驱动」切回「月度例行」,
* 「事件类型」那格就该跟着消失;它已经填的值若留在 state 里,人切回来又看得见,
* 也会被 `fire()` 悄悄当成这一单的入参一起交上去,而界面上早就看不见它了。
*
* 改的若是一格枚举选择(如「触发方式」「事件类型」),选完顺手看这组枚举组合
* 唯不唯一对得上一条样例 —— 唯一就把那条样例其余格子的内容套进来当默认值
* (「月度例行」/「事件驱动+某事件类型」各自的默认客户与说明,不用另外去点样例按钮)。
* 不唯一(比如刚选了「事件驱动」,「事件类型」还没选)就不动,留给人接着选。
*/
const setField = (name: string, value: string): void => {
setData((prev) => {
const clear = (obj: Record<string, string>): void => {
for (const f of allFields) if (!isVisible(f, obj)) delete obj[f.name];
};
let next: Record<string, string> = { ...prev, [name]: value };
clear(next);
const changed = allFields.find((f) => f.name === name);
if (picked !== undefined && changed?.options !== undefined) {
const idx = matchingSample(picked, next, allFields);
/*
* **整单换成这条样例,不是把它的字段叠上去** —— 样例的 `input` 本就是稀疏的
* (「月度例行」样例只有 `trigger` 一个键),叠加式的 `{...next, ...sampleValues}`
* 加得上样例给的值,却清不掉样例没提的值:从「重大商机」切回「月度例行」,
* 客户与说明会照抄上一条样例的内容原样卡在框里不消失。样例匹配到的这一刻,
* 它就该是这一单入参唯一的真相,照 sampleValues 整份重写。
*/
if (idx !== undefined) {
next = sampleValues(picked, idx);
clear(next); // 套模版也可能改变可见性,按同一条规矩再清一遍
}
}
return next;
});
};
/*
* 起单前**按契约声明归型**:表单格子里全是文本,而契约说得清哪个字段是数组、哪个是对象
* (`attachments: string[]`)。不归型就把 `["BOM.xlsx"]` 这串字面量当字符串送进去,
@@ -108,8 +153,11 @@ export function ActivityRun({
*/
const fire = (card: ActivityCard): void => {
const typeOf = new Map(card.contract.inputFields.map((f) => [f.name, f.type]));
const visibleNames = new Set(fields.map((f) => f.name));
const shaped: Record<string, unknown> = {};
for (const [key, raw] of Object.entries(data)) {
// 藏起来的格子不该跟着交上去 —— 与 `setField` 的清理是同一条规矩,这里再兜一道底
if (!visibleNames.has(key)) continue;
if (raw.trim() === '') continue;
const type = typeOf.get(key) ?? 'string';
if (!type.endsWith('[]') && type !== 'object') {
@@ -197,23 +245,36 @@ export function ActivityRun({
<FilePicker
label={f.label ?? f.name}
value={data[f.name] ?? ''}
onChange={(next) => setData((prev) => ({ ...prev, [f.name]: next }))}
onChange={(next) => setField(f.name, next)}
onError={setError}
/>
) : f.options !== undefined && f.options.length > 0 ? (
<select
className="tb-pp-i"
value={data[f.name] ?? ''}
onChange={(e) => setField(f.name, e.target.value)}
>
<option value="">{f.required ? '请选择…' : '(不填)'}</option>
{f.options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
) : isLong(f) ? (
<textarea
className="tb-pp-i"
rows={5}
value={data[f.name] ?? ''}
placeholder={f.type}
onChange={(e) => setData((prev) => ({ ...prev, [f.name]: e.target.value }))}
placeholder={f.example ?? f.type}
onChange={(e) => setField(f.name, e.target.value)}
/>
) : (
<input
className="tb-pp-i"
value={data[f.name] ?? ''}
placeholder={f.type}
onChange={(e) => setData((prev) => ({ ...prev, [f.name]: e.target.value }))}
placeholder={f.example ?? f.type}
onChange={(e) => setField(f.name, e.target.value)}
/>
)}
</label>
@@ -301,8 +362,36 @@ type InputField = {
label?: string | undefined;
/** `file` = 这一格收真文件(契约里的 `format`)—— 界面给文件选择器 */
format?: string | undefined;
/** 声明了枚举(契约里的 `enum`)—— 界面给下拉选,不给自由文本框猜合法值 */
options?: { value: string; label: string }[] | undefined;
/** 示例值(契约里的 `examples` 第一项)—— 占位提示用,比裸类型名能抄 */
example?: string | undefined;
/** 声明了 → 只在另一格(`showWhen.field`)当下等于 `showWhen.equals` 时才出现(级联下拉) */
showWhen?: { field: string; equals: string } | undefined;
};
/** 这一格该不该露面 —— 没声明 `showWhen` 恒可见,声明了就看那另一格当下的值 */
function isVisible(f: InputField, data: Readonly<Record<string, string>>): boolean {
return f.showWhen === undefined || (data[f.showWhen.field] ?? '') === f.showWhen.equals;
}
/**
* 当前选中的这组枚举字段(有 `options` 且可见的那几格,如「触发方式」「事件类型」)
* 如果唯一对应清单里的一条样例,回它的下标 —— 正是这条样例把其余格子的默认内容定下来。
* 不唯一(比如选了「事件驱动」但「事件类型」还没选,四条候选样例都对不上)就回
* `undefined`,不瞎填一条。这是通用能力:任何"选项 A 决定用哪套默认数据"的活动
* 都靠它自动套模版,不用专门写一次性逻辑。
*/
function matchingSample(card: ActivityCard, data: Readonly<Record<string, string>>, allFields: readonly InputField[]): number | undefined {
const enumFields = allFields.filter((f) => f.options !== undefined && f.options.length > 0 && isVisible(f, data));
if (enumFields.length === 0) return undefined;
const hit = card.contract.samples
.map((s, i) => ({ s, i }))
.filter(({ s }) => enumFields.every((f) => String((s.input as Record<string, unknown>)[f.name] ?? '') === (data[f.name] ?? '')))
.map(({ i }) => i);
return hit.length === 1 ? hit[0] : undefined;
}
/** 长文本:正文、对象、数组 —— 一行输入框装不下的东西 */
/* ==================== 真文件那一格 ==================== */
@@ -484,14 +573,6 @@ function Row({ label, values, empty }: { label: string; values: readonly string[
/* ==================== 观测态 ==================== */
const RUNTIME_LAMP: Readonly<Record<string, 'live' | 'wait' | 'done' | 'bad'>> = {
running: 'live',
'awaiting-human': 'wait',
'awaiting-event': 'wait',
'landing-blocked': 'bad',
terminal: 'done',
};
/**
* 单活动试跑的观测面 —— 一屏看穿这一单。
*
@@ -504,11 +585,14 @@ function ActivityObserve({
strip,
stopping,
onStop,
onGone,
}: {
taskId: string;
strip: (input: { lamp: 'idle' | 'live' | 'wait' | 'done' | 'bad'; breathe: boolean; word: string; detail?: ReactNode; right?: ReactNode }) => ReactNode;
stopping: boolean;
onStop: () => void;
/** 服务端说这一单没了 —— 面板松手(别再钉着一个死号轮询) */
onGone: () => void;
}): ReactNode {
const q = useQuery({
queryKey: ['gov', 'testbed', 'activity', taskId],
@@ -519,17 +603,33 @@ function ActivityObserve({
* 轮询留着是为了推送漏掉的那种情形(断线、别的进程推进的单)。
* 到终态就停:已经不动的东西没必要一直问。
*/
refetchInterval: (query) => (query.state.data?.terminal === undefined ? 2000 : false),
/*
* **取不到不是活着**(2026-08-23):从前这里只看 `data.terminal`,而取数失败时
* `data` 保留的是上一次成功的那份、`terminal` 恒 undefined —— 于是对着一个
* 已经不存在的任务 2s 一直打,屏上还举着「AWAITING-EVENT · 自动刷新」。
* 现在由 `observeFace` 一处判断该不该继续问(404 与终态都不必再问)。
*/
refetchInterval: (query) =>
observeFace({ data: query.state.data, error: query.state.error as { status?: number } | null ?? undefined }).poll
? 2000
: false,
// 任务没了就别重试 —— 重试只是把同一句「不存在」再问三遍
retry: (count, error) => (error as { status?: number } | undefined)?.status !== 404 && count < 2,
});
const s = q.data;
const live = s !== undefined && s.terminal === undefined;
const face = observeFace({ data: q.data, ...(q.error === null ? {} : { error: q.error as { status?: number } }) });
const live = face.breathe;
/* 服务端说没了:松手一次(回装配台或切到当下那一单)—— 不在渲染里直接改父状态 */
useEffect(() => {
if (face.gone) onGone();
}, [face.gone, onGone]);
return (
<>
{strip({
lamp: s === undefined ? 'live' : (RUNTIME_LAMP[s.runtime] ?? 'live'),
breathe: live,
word: s?.terminal === undefined ? (s?.runtime ?? '取数中') : (TERMINAL_STATE_LABEL[s.terminal] ?? s.terminal),
lamp: face.lamp,
breathe: face.breathe,
word: face.word,
detail: (
<>
{s?.title ?? taskId.slice(0, 8)}
@@ -0,0 +1,64 @@
/**
* 观测面此刻**该怎么说** —— 纯函数,好让「界面不撒谎」这条有测可钉。
*
* 起因(2026-08-23 用户:「推进完成了,但测试台还在运行中,没有停下来」):
* 面板钉着一个已经不存在的任务(别处把运行数据清了),屏上同时说两句相反的话 ——
* 上面「AWAITING-EVENT · 自动刷新 2s」灯亮着、钮写「停止仿真」,
* 下面一条橙带「取数失败 —— 任务不存在」。两处都错在同一个念头:
* **把「取不到」当成了「还活着」**。
* · `q.data` 取数失败后保留上一次成功的那份,那份 `terminal` 是 undefined → 恒判在跑;
* · `s === undefined ? 'live'` 的兜底 —— 还没取到 ≠ 正在跑。
*
* 现在分三种局面说话:**没了**(服务端 404,当场松手、停轮询)、
* **取数失败**(断网/500,不说在跑,但照旧轮询等它回来)、**取到了**(照实说)。
*/
import { TERMINAL_STATE_LABEL } from '@contracts';
/** 灯:活 / 等 / 完 / 坏 —— 与 `RUNTIME_LAMP` 同一套词 */
export type Lamp = 'idle' | 'live' | 'wait' | 'done' | 'bad';
const RUNTIME_LAMP: Readonly<Record<string, Lamp>> = {
running: 'live',
'awaiting-human': 'wait',
'awaiting-event': 'wait',
'landing-blocked': 'bad',
terminal: 'done',
};
export type ObserveFace = {
readonly lamp: Lamp;
/** 呼吸灯 —— 只有真在跑才亮;取不到、没了、跑完了都不亮 */
readonly breathe: boolean;
readonly word: string;
/** 服务端说这一单不存在了 —— 面板据此松手(回装配台或切到当下那一单) */
readonly gone: boolean;
/** 还要不要 2s 轮询 —— 终态与「没了」都不必再问 */
readonly poll: boolean;
};
export function observeFace(input: {
data?: { runtime: string; terminal?: string | undefined } | undefined;
error?: { status?: number | undefined } | undefined;
}): ObserveFace {
// ① 服务端明说没有这一单 —— 不是一次普通取数失败,是这一单已经不在了
if (input.error?.status === 404) {
return { lamp: 'bad', breathe: false, word: '这一单已不在了', gone: true, poll: false };
}
// ② 取数失败(断网 / 500):不知道它现在怎么样,那就别替它说「在跑」;轮询留着等它回来
if (input.error !== undefined) {
return { lamp: 'bad', breathe: false, word: '取数失败', gone: false, poll: true };
}
// ③ 还没取到:说「取数中」,灯不呼吸 —— 没取到不等于在跑
if (input.data === undefined) {
return { lamp: 'idle', breathe: false, word: '取数中', gone: false, poll: true };
}
const { runtime, terminal } = input.data;
const live = terminal === undefined;
return {
lamp: RUNTIME_LAMP[runtime] ?? 'live',
breathe: live,
word: live ? runtime : ((TERMINAL_STATE_LABEL as Readonly<Record<string, string>>)[terminal] ?? terminal),
gone: false,
poll: live,
};
}
+11 -1
View File
@@ -18,6 +18,8 @@ export class ApiCallError extends Error {
type CallOptions = {
readonly params?: Readonly<Record<string, string>>;
/** 查询串 —— 路由契约里声明了 `query` 的走这里(值 undefined 的键不发) */
readonly query?: Readonly<Record<string, string | undefined>>;
readonly body?: unknown;
};
@@ -26,8 +28,16 @@ function resolvePath(path: string, params: Readonly<Record<string, string>> = {}
return path.replace(/:([A-Za-z_][\w]*)/g, (_, key: string) => encodeURIComponent(params[key] ?? ''));
}
/** `?a=1&b=2` —— 没有查询参数时连问号都不加 */
function queryString(query: Readonly<Record<string, string | undefined>> = {}): string {
const usp = new URLSearchParams();
for (const [k, v] of Object.entries(query)) if (v !== undefined) usp.set(k, v);
const s = usp.toString();
return s === '' ? '' : `?${s}`;
}
export async function call<T>(route: RouteContract, options: CallOptions = {}): Promise<T> {
const response = await fetch(resolvePath(route.path, options.params), {
const response = await fetch(resolvePath(route.path, options.params) + queryString(options.query), {
method: route.method,
headers: { 'content-type': 'application/json' },
credentials: 'same-origin',