E04 修到零被拒线,harness 补七刀:跨进程租约与广播桥、原子写、引用墙、对话区按序号排

活动侧(E04 2.1.0 → 2.1.9,I01 1.2.2):
- 覆盖闭合成为机检:基座新增 distinct 读数,「计划盖住的客户数 = 候选行数」钉进
  S3 收步 / S4 哨兵 / 放行 L2;人加的候选客户不再被整单无视
- 「关键人待识别」占位句式全链退役:查无联系人先发数据录入卡收人;契约新增 refersTo,
  计划/回执的 contact_code 只许引用真实存在的联系人(安装期核引用目标)
- 台账更新确认摆全落库面:客户档案/联系人档案两张落库预览(touched: 数据源,
  正式现值 + 草稿叠加),可补改、可加行(自增主键由基座续号);姓名升必填
- 更新明细:update_key 位置键 + 「同一处只留一条」L2;依据写人话不带机器键;
  标题旁不摆单号;判据与文案不夹字段名;行标识去掉单标识那一段
- 回执骨架 = 计划行数钉进 S3 收步与 S4 哨兵;重做分支不许删计划凑数
- 阶段刻度对齐文档 7 步;起单样例预填满

基座侧:
- 两个后端连同一个库:task-claim 跨进程租约(迁移 0030)+ Postgres LISTEN/NOTIFY
  广播桥;对话区按任务内记忆序号排,不按墙上时钟;并卡「刚说过话」按记忆序列判紧邻
- 多行 write 整批先验后写(dryRun),被拒 = 一行都没落
- 决策卡意见与本单草稿字面比对,归属以观察进轨迹(note-anchors)
- 括号栈补「补闭括号」两形(少一个 } / 行对象没关直接跟下一行)
- 件上加行:空着的自增主键直进续号,不从别的行借号;未落下的行点名回给前端
- 人答复落库那一刻就把 runtime 翻 running(task.wake),两块同帧变脸
- 试跑单可落真库(land_master,迁移 0031,默认开);数据域按活动分组;
  JSON 列不再是 [object Object];按钮键感 + 在办进度条 + 防抖
- 报价链 A1–A7 从系统卸载(只卸不删源码),种子只装 E04/I01

契约变更(api-report 已刷新):FieldDeclaration.distinct / refersTo,
objectDeclaration.file,landMaster,unkeyed,assetFiles,ledgerByActivityRoute。
1700 测 / typecheck / lint / depcruise / api:check 绿。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEJX8Z7CHejvwmDQyPQuBX
This commit is contained in:
xcs
2026-08-25 01:23:30 +08:00
co-authored by Claude Fable 5
parent 92dd729c36
commit f3397a60ef
91 changed files with 3919 additions and 350 deletions
@@ -25,6 +25,7 @@ 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';
import { Btn } from '@web-shared/Btn.tsx';
type Tab = 'open' | 'comms' | 'done' | 'wf' | 'log';
@@ -189,16 +190,30 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
* 实测:人把三条料全改成 A 类,底下的去向还写着「去选型」。
* 落完重取任务投影,整份件跟着一起变。**工单不办结,放行门的位置没动。**
*/
/**
* **有行没落下就说出来**(2026-08-24 用户:「件上新增的那个客户,推到下一步就不见了」)。
* 主键那几格空着就凑不出钥匙、存不住;从前基座悄悄跳过、接口照回 ok ——
* 人看到「保存成功」,东西却不在。现在把没落下的行红字点名,他补上钥匙再存一次。
*/
const [saveWarn, setSaveWarn] = useState<string | undefined>(undefined);
const saveTable = useMutation({
mutationFn: (save: ViewSave) =>
call(saveTableRoute, {
call<{ ok: boolean; rows: number; unkeyed?: Array<{ hint: string }> }>(saveTableRoute, {
params: { taskId: detail.card.id },
body: {
tables: save.tables as Record<string, Array<Record<string, string>>>,
drops: save.drops as Record<string, Array<Record<string, string>>>,
},
}),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['desk', 'task', detail.card.id] }),
onSuccess: (out) => {
setSaveWarn(
out.unkeyed === undefined || out.unkeyed.length === 0
? undefined
: `有 ${out.unkeyed.length} 行没能存下 —— ${out.unkeyed.map((u) => u.hint).join(';')}`,
);
void queryClient.invalidateQueries({ queryKey: ['desk', 'task', detail.card.id] });
},
});
/*
@@ -221,6 +236,14 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
onError: (error: unknown) => setFileError(error instanceof Error ? error.message : '这份资料打不开'),
});
/*
* 正在取的是哪一份 —— 先摘成两个普通值再用。
* 直接在 JSX 里写 `openAssetFile.isPending && openAssetFile.variables.key === …` 不行:
* 上面那一层三元已经按 `isPending` 分过岔,进到 else 分支里它被收窄成「不在办」,
* `variables` 整个不存在(TS 判 never)—— 同一个判断在同一棵树里只能问一次。
*/
const fetchingFileKey = openAssetFile.isPending ? openAssetFile.variables.key : undefined;
const markRead = useMutation({
mutationFn: (input: { commId: string; read: boolean }) =>
call(markCommReadRoute, { params: { commId: input.commId }, body: { read: input.read } }),
@@ -490,7 +513,14 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
vpClassName="wf-body"
inset={-6}
>
{openFile !== undefined ? (
{openAssetFile.isPending ? (
/*
* **取件的那一段要说出来** —— 资料名是件上的一个链接,点它没有按钮可以变样;
* 那份文件又是服务端**现造**出来的字节(造 + 传,慢的有一两秒)。
* 从前这一段屏上一动不动,人只好再点一次 —— 于是同一份资料取两遍。
*/
<EmptyState ico="file-text" tone="brand" title="正在取这一份资料…" desc="文件是台账那一行现造出来的,造好就摆在这儿。" />
) : openFile !== undefined ? (
<FileViewer doc={openFile} href={fileHref} />
) : fileError !== undefined ? (
/* 那一份取不回来:**如实说是哪一句**(服务端原话),别把人留在原地猜 */
@@ -528,16 +558,15 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
不做「打开页签即已读」:那是替人宣布他看过了,而他可能只是路过。
反向也留着:看了一半想留个记号,标回未读。
*/}
<button
type="button"
<Btn
className={'jc-comm-mark' + (m.unread ? '' : ' done')}
disabled={markRead.isPending}
busy={markRead.isPending && markRead.variables.commId === m.id}
aria-label={m.unread ? `把「${m.subject}」标为已读` : `把「${m.subject}」标回未读`}
onClick={() => markRead.mutate({ commId: m.id, read: m.unread })}
>
<Ico name={m.unread ? 'check' : 'rotate-ccw'} s={11} />
{m.unread ? '标为已读' : '标回未读'}
</button>
</Btn>
</header>
<div className="jc-comm-meta">
<span className="jc-mono">{m.channel}</span>
@@ -641,10 +670,10 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
{detail.assetFiles.every((f) => f.archived) ? null : <i>在办中 · 放行时存档</i>}
</div>
{detail.assetFiles.map((f) => (
<button
<Btn
key={`${f.address}/${f.key}`}
type="button"
className={'jc-dv jc-dv-row s-ok m-file f-' + f.format}
busy={fetchingFileKey === f.key}
onClick={() => {
setFileError(undefined);
openAssetFile.mutate({ address: f.address, key: f.key });
@@ -664,7 +693,7 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
<span className={'jc-dv-fmt f-' + f.format}>{f.format}</span>
<Ico name="chevron-right" s={15} />
</span>
</button>
</Btn>
))}
{doneList.length > 0 && <div className="jc-dvs-sub">定稿报告<em>{doneList.length} 份</em></div>}
</>
@@ -780,6 +809,17 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
* 判据换成直接问 `detail.viewHtml` 本身,`cur` 只在没有件、要退回卡壳
* 呈现,或收边传值(`onField`)时才用得上。
*/
<>
{/*
**有行没落下,红字摆在件的上方** —— 而不是让人以为存好了。
位置有意)在件之上:他刚按完保存,眼睛就在这一带。
*/}
{saveWarn !== undefined && (
<div className="jc-savewarn" role="alert">
<Ico name="info" s={14} />
<span>{saveWarn}</span>
</div>
)}
<ViewContainer
html={detail.viewHtml}
title={`${detail.card.activity} · 汇报报告`}
@@ -806,6 +846,7 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
if (cur !== undefined) collectEdits(save.tables, cur, onField);
}}
/>
</>
) : cur !== undefined ? (
/* 活动没推件的(还没到推件那一拍、或包里就没有模版)才退回工单报告:那时工作区总不能是白的 */
<article className="jc-mst-doc">