人手改的那一手真正落得下去;界面不撒谎那几处补齐

手改是**兜 AI 的错**的那一手 —— 这一轮把它从「摆在那儿但落不了地」做成真的:

件上就地改
· 保存**按地址收**,不看当下那张卡:载荷从前借道工单的 `blocks[i]` 取列名,
  于是熔断卡(没有 blocks)在屏上时,人补的那一格一个字都送不出去 ——
  而他要补的恰恰是把这一单卡住的那个必填格。授权面改由活动清单的台账声明把关。
· **能增删行**:错不止「这一格填错了」,还有「这一行不该在」与「漏了一行」。
  删挂记号不当场抹掉(看得见、可后悔),且**删要说出来**(`drops`)——
  「没回传就是删了」会把一次不完整的回传变成一次删表。
· **必填没填按不下提交**:与放行门 L1 同一个数同一套算法;闸上摆出差在哪一行哪一格。
· 只写真改了的那几行 —— 改一格却留「修订 4 行」的痕,那句话是假的。

判据与门
· 裁量判据带谓词时**谓词说了算**:「选填缺口已补齐」基座数得出来,
  人填好的目标价它照样判「客户未提供」的毛病由此消掉。
· 验收卡判据**读时现算** —— 人在成果件上补一格,判据跟着变。
· 放行门认「**办完了没有**」不认「有人写过」:从前写个 `false` 就把门推开,
  于是一单没打标就进了放行门。三个数全由基座现数。
· 拒回把「活没干完」说成「数或行对不上」的毛病:判据点名的表一行都没写时直说那一步没办。
  **指向两扇打不开的门,比不指还糟。**

界面不撒谎
· 日志只有三色:负责人绿 · 智能体蓝 · 系统灰(归侧五种是度量的分法,不是给人看的)。
· 验收收口答两件事:落了什么(按**交付物**点名,一份成果的几张表并成一行)、
  往哪儿去(转出哪一口、带过去哪些字段);一个口都没发就直说到此为止。
· 签完到收尾之间那一段有自己的话「字已签下,正在落地」—— 中间态本来就该说出来,只是要说对。
· 消息不再被内部事件刷屏:只有从通道进来的件才谈得上「无人认领」。
· 建客户公司与联系人**一次确认落两行**;联系人编码自增不问人;卡面摆表的人读名。

A1 三案例端到端全 released:全 A 类直通 12 拍 / 客户点名跳一条 10 拍 / 新客户缺必填 14 拍。
五道门禁全绿(971 passed)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JsTDjoHovERXyy6Wmw1ALp
This commit is contained in:
2026-08-02 13:41:44 +08:00
co-authored by Claude Opus 5
parent 94f1c7a4ef
commit 259192f736
228 changed files with 27850 additions and 2847 deletions
@@ -13,11 +13,37 @@ import { replyChatCardRoute, replyWorkOrderRoute, sayRoute } from '@contracts';
import { call } from '@web-shared/typed-fetch.ts';
import { Ico } from '@web-shared/Ico.tsx';
import { EmptyState } from '@web-shared/desk/module-kit.tsx';
import { ChatStream } from './chat-bubbles.tsx';
import { ChatStream, WorkingStrip } from './chat-bubbles.tsx';
export function TaskChat({ detail, onLocate }: { detail: TaskDetail; onLocate: (orderId: string) => void }) {
export function TaskChat({
detail,
onLocate,
onOpenComms,
orderValues,
onField,
}: {
detail: TaskDetail;
onLocate: (orderId: string) => void;
/** 起单开场白上的「查看邮件」—— 跳到沟通记录那一屏 */
onOpenComms: () => void;
/**
* 人在工作区报告上改过的填报值 —— **裁决时一并带走**。
*
* 验收工单「AI 已填好、可编辑」而裁决在这儿:两半分在两个区里做,
* 不把缓冲带过来,回传的 `values` 就恒空 —— 人改了报告,落地时用的还是 AI 的原值。
*/
orderValues?: Readonly<Record<string, string>>;
/** 卡上填的值进同一个缓冲 —— 执行单的表单如今就长在对话区的卡上 */
onField: (key: string, value: string) => void;
}) {
const queryClient = useQueryClient();
const taskId = detail.card.id;
/*
* AI 正在办这单 = Loop 在自己转(runtime running)。
* 等人(awaiting-human)、等外部(awaiting-event)、已收尾都不算 ——
* 那时亮「工作中」就是撒谎:环没在转,是在等。
*/
const working = detail.card.terminal === undefined && detail.card.runtime === 'running';
/** 回传后一律重取任务投影 —— 卡与工单的状态由服务端说,前端不自行推演 */
const refresh = () => {
@@ -50,12 +76,29 @@ export function TaskChat({ detail, onLocate }: { detail: TaskDetail; onLocate: (
<span className="jc-tch-desc">盯着这单推进</span>
</header>
<div className="jc-tch-scroll">
<EmptyState
ico="messages-square"
tone="brand"
title="还没有消息"
desc="AI 有结论、要派工、或者要你拿个主意,都会在这里说。你也可以随时交代两句。"
/>
{/*
* **起手就是头像 + 气泡,不是空占位**。
*
* 从前这里在 working 时同时摆两样:一张「AI 已经开工」的空态插画,底下再挂一条办事条。
* 于是这一单的开头是「一幅画 + 一条」,而第一句真消息到达时那幅画忽然消失、
* 版式整个跳一下 —— 人看到的是界面在换布景,不是 AI 在办事。
*
* 现在:一开工就渲染与真消息**同形**的气泡(头像 + 署名行 + 内容),
* 内容是办事条。第一句话到了,它接在同一条流里,位置不动、形状不变。
* 真正的空态只留给「还没开工也没消息」那一种。
*/}
{working ? (
<div className="jc-chat-flow" style={{ padding: '10px 14px 0' }}>
<WorkingStrip />
</div>
) : (
<EmptyState
ico="messages-square"
tone="brand"
title="还没有消息"
desc="AI 有结论、要派工、或者要你拿个主意,都会在这里说。你也可以随时交代两句。"
/>
)}
</div>
<div className="copilot-composer session-composer">
<div className="composer-row">
@@ -83,13 +126,17 @@ export function TaskChat({ detail, onLocate }: { detail: TaskDetail; onLocate: (
return (
<ChatStream
{...(orderValues === undefined ? {} : { orderValues })}
chat={detail.chat}
orders={detail.workOrders}
thinking={say.isPending}
working={working}
onSay={(text) => say.mutate(text)}
onReplyCard={(reply) => replyCard.mutate(reply)}
onReplyOrder={(reply) => replyOrder.mutate(reply)}
onLocate={onLocate}
onOpenComms={onOpenComms}
onField={onField}
/>
);
}
@@ -128,7 +128,16 @@ function TaskCardView({
onPick: () => void;
}) {
const done = task.terminal !== undefined;
const pct = done ? 100 : Math.round((task.position / task.total) * 100);
/*
* 进度条读**活动自己的阶段**,不读工作流序位。
*
* 序位答的是「这单在整条流的第几个活动」——A1 在七节点流里恒 1/7,
* 从起单到收口一动不动,画成进度条就是一条永远 14% 的线;
* 单活动试跑更荒唐:没有工作流,序位恒 1/1,起单那一刻就满格。
* 活动没声明阶段就**不画** —— 编一个假的比不画更坏。
*/
const phase = task.phase;
const pct = done ? 100 : phase === undefined ? undefined : Math.round((phase.index / phase.total) * 100);
return (
<button
className={'task-card jc-tc jc-tc3' + (selected ? ' selected' : '')}
@@ -155,13 +164,22 @@ function TaskCardView({
<span className="jc-tc2-sub jc-clamp2">{task.workflowSubject}</span>
<span className="jc-tc3-meta">
<code className="jc-mono">{task.id.slice(0, 8)}</code>
<b className="jc-mono" title="它在工作流上的序位">
{task.position}/{task.total}
</b>
</span>
<span className="jc-tc-bar">
<span style={{ width: `${pct}%` }} />
{/* 阶段名 + x/N:有阶段就报阶段(人问的是「办到哪了」),没有就退回工作流序位 */}
{phase === undefined ? (
<b className="jc-mono" title="它在工作流上的序位">
{task.position}/{task.total}
</b>
) : (
<b className="jc-mono" title={`阶段:${phase.name}`}>
{phase.name} {phase.index}/{phase.total}
</b>
)}
</span>
{pct !== undefined && (
<span className="jc-tc-bar">
<span style={{ width: `${pct}%` }} />
</span>
)}
</button>
);
}
@@ -11,14 +11,19 @@
* 不写「这块还没做」—— 后者是开发视角,用户不关心,也看不懂。
*/
import { useState } from 'react';
import type { FileDoc, TaskDetail, WorkOrderReply } from '@contracts';
import { TERMINAL_STATE_LABEL } from '@contracts';
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 } from '@ui';
import { WorkOrderReport } from './work-order-report.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';
@@ -27,24 +32,136 @@ export type ViewRegionProps = {
readonly detail: TaskDetail;
/** 工单回传 —— 由壳层接路由,视图区只管把人填的东西交出去 */
onReplyOrder: (reply: WorkOrderReply) => void;
/**
* 填报值由**壳层持有** —— 不是这一份的局部状态。
*
* PRD 的口径:验收工单「AI 已填好、可编辑」,而**裁决在对话区的验收单上**。
* 编辑与裁决是同一件事的两半,却分在两个区里做 —— 缓冲只活在工作区里,
* 人在报告上改的值就到不了裁决那一刻:验收回传的 `values` 恒空,修订永远落不下来。
*/
readonly values: Readonly<Record<string, string>>;
onField: (key: string, value: string) => void;
/**
* 对话区点了「打开工单」→ 视图区跳转定位(PRD:locate)。
* `seq` 每点一次递增 —— 同一张卡点两次也要再跳一次(人就是想回到它)。
* 待办的跳「待处理」并翻到那一页;办结的跳「工作成果」并打开那份定稿报告。
*/
/*
* `orderId` 跳工单;`tab` 直接跳页签 —— 起单那句系统开场白的「查看邮件」用后者
* (它指的不是某张工单,是那封来件本身)。
*/
readonly locate?: { seq: number; orderId?: string; tab?: Tab } | null;
};
export function ViewRegion({ detail, onReplyOrder }: ViewRegionProps) {
/**
* 件上改出来的行 → 回传载荷 —— **按台账地址对上工单声明的那张表**。
*
* 件里那几张可改的表各挂着 `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<Tab>('open');
const [page, setPage] = useState(0);
/** 表单值是**过程状态**,只活在这一次填写里:回传成功后画面由服务端重投影 */
const [values, setValues] = useState<Record<string, string>>({});
/** 工作成果:列表里点开的那份定稿报告 */
const [doneSel, setDoneSel] = useState<string | null>(null);
const [openFile, setOpenFile] = useState<FileDoc | undefined>(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<string, Array<Record<string, string>>>,
drops: save.drops as Record<string, Array<Record<string, string>>>,
},
}),
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];
@@ -114,6 +231,8 @@ export function ViewRegion({ detail, onReplyOrder }: ViewRegionProps) {
onClick={() => go('comms')}
>
<Ico name="mail" s={14} /> 沟通记录
{/* 未读气泡:红的,数字是**未读条数**,不是总条数 —— 它要回答「有没有我还没看的」 */}
{unreadN > 0 && <em className="jc-unread">{unreadN}</em>}
</button>
<button
type="button"
@@ -155,6 +274,10 @@ export function ViewRegion({ detail, onReplyOrder }: ViewRegionProps) {
<button type="button" className="jc-tnav-btn jc-tnav-back" onClick={() => setOpenFile(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>
) : view === 'open' ? (
<span className="jc-tnav-pg" aria-label="翻页">
<button
@@ -184,7 +307,7 @@ export function ViewRegion({ detail, onReplyOrder }: ViewRegionProps) {
{/* key:换件 / 换页签即重挂载滚动区,回到正文顶部 */}
<ScrollArea
key={`${view}:${cur?.id ?? '-'}`}
key={`${view}:${view === 'done' ? (doneSel ?? 'list') : (cur?.id ?? '-')}`}
className="jc-sa--fill"
vpClassName="wf-body"
inset={-6}
@@ -193,26 +316,88 @@ export function ViewRegion({ detail, onReplyOrder }: ViewRegionProps) {
<FileViewer doc={openFile} />
) : view === 'comms' ? (
/*
* 「还没接」和「暂无数据」是两回事,占位不能混:
* 邮件与通道消息的数据在 `channel` / `event` 里确实有,但按单归档的投影还没做 ——
* 所以在这儿等下去也不会自己冒出内容来。说「都会归到这里」就是把没接说成了空,
* 人会一直等,或者以为坏了。如实说没接通,再说接通之后是什么样。
* 沟通记录 = 一单往来的**原件**。进来的那一封是开案的那份料 ——
* 人一点开这单就该看见「这单从哪来的」,不必等模型跑几拍再说。
*
* 附件那一行照抄基座读出来的说明:读出多少字 / 为什么读不出。
* **摆一个「已上传 ✓」是撒谎** —— 活动读不出内容的件,人得当场知道。
*/
<EmptyState
ico="mail"
tone="muted"
title="往来记录还没接通"
desc="跟客户、供应商往来的邮件和消息,目前还没有按单归档到这一屏。接通后连原件一起存:谈过什么、什么时候谈的,回头都查得到。"
/>
detail.comms.length === 0 ? (
<EmptyState
ico="mail"
tone="muted"
title="这一单还没有往来记录"
desc="跟客户、供应商往来的邮件和消息会按单归到这里,连原件一起存:谈过什么、什么时候谈的,回头都查得到。"
/>
) : (
<div className="jc-comms">
{detail.comms.map((m) => (
<article key={m.id} className={'jc-comm t-' + m.direction + (m.unread ? ' unread' : '')}>
<header className="jc-comm-h">
<span className={'jc-comm-dir t-' + m.direction}>
<Ico name={m.direction === 'in' ? 'corner-down-left' : 'corner-up-right'} s={12} />
{m.direction === 'in' ? '收到' : '发出'}
</span>
<b className="jc-comm-sub">{m.subject}</b>
{/* 未读点:一眼扫得出哪几封还没看 —— 与右边那个按钮说的是同一件事 */}
{m.unread && <span className="jc-comm-new">未读</span>}
<span className="jc-comm-at jc-mono">{atMinute(m.at)}</span>
{/*
标记按钮 —— **气泡计数只跟着这一下走**。
不做「打开页签即已读」:那是替人宣布他看过了,而他可能只是路过。
反向也留着:看了一半想留个记号,标回未读。
*/}
<button
type="button"
className={'jc-comm-mark' + (m.unread ? '' : ' done')}
disabled={markRead.isPending}
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>
</header>
<div className="jc-comm-meta">
<span className="jc-mono">{m.channel}</span>
<span>{m.direction === 'in' ? '来自' : '发往'}</span>
<b>{m.party === '' ? '未署名' : m.party}</b>
{m.note !== '' && <em>{m.note}</em>}
</div>
{m.body !== '' && <pre className="jc-comm-body">{m.body}</pre>}
{m.attachments.length > 0 && (
<div className="jc-comm-att">
{m.attachments.map((a) => (
<span key={a.name} className={'jc-att' + (a.readable === true ? '' : ' blind')}>
<Ico name={a.readable === true ? 'file-text' : 'alert-triangle'} s={12} />
<b>{a.name}</b>
{a.size !== undefined && <i className="jc-mono">{(a.size / 1024).toFixed(0)} KB</i>}
{a.note !== undefined && <em>{a.note}</em>}
</span>
))}
</div>
)}
</article>
))}
</div>
)
) : view === 'wf' ? (
<WfDetail detail={detail} openN={openList.length} doneN={orders.length - openList.length} />
) : view === 'log' ? (
detail.log.length > 0 ? (
<div className="jc-log">
{detail.log.map((line) => (
<div key={line.id} className={'jc-log-row t-' + line.side}>
<span className="jc-log-at jc-mono">{line.at.slice(5, 16).replace('T', ' ')}</span>
<span className="jc-log-side">{line.sideLabel}</span>
/*
* **三色一档一意**(2026-08-02 用户定):负责人绿 · 智能体蓝 · 系统灰。
* 从前按归侧上色,而归侧有五种 —— 那是**度量**的分法
* (自主率要把「等外面」与「机器干的」分开);人扫这一屏只问
* 「这一步是我办的、AI 办的,还是流程自己走到的」。
*/
<div key={line.id} className={'jc-log-row t-' + line.who}>
<span className="jc-log-at jc-mono">{atMinute(line.at)}</span>
{/* 章摆的是**动作**(四字,与对话区同词),不是归侧 ——
归侧仍在 `t-${side}` 的配色里,度量也照旧靠它 */}
<span className="jc-log-side">{line.action}</span>
<span className="jc-log-t">
<b>{line.title}</b>
{line.summary !== undefined && <em>{line.summary}</em>}
@@ -229,35 +414,185 @@ export function ViewRegion({ detail, onReplyOrder }: ViewRegionProps) {
/>
)
) : view === 'done' ? (
<EmptyState
ico="archive"
tone="brand"
title="还没有成果"
desc="这一单产出的报告与文件会归档在这里,一行一份、点开整篇看。"
/>
doneItem !== undefined ? (
/*
* 定稿归档 = **同一份件,不加编辑态**(§9.3 第 2 条:在办与归档同一张模版)。
* 与待处理那一屏一样,这里也只放件本身 —— 判据留在对话区那张办结卡上,
* 归档之后「凭什么算过」比当时更常被问起,所以它跟着卡走,不跟着件走。
*/
doneItem.mode === 'acceptance' && detail.viewHtml !== undefined ? (
<ViewContainer html={detail.viewHtml} title={`${detail.card.activity} · 定稿报告`} />
) : (
<article className="jc-mst-doc">
<WorkOrderReport
order={doneItem}
detail={detail}
typeLine={doneItem.mode === 'acceptance' ? '验收工单 · 定稿归档' : '执行工单 · 定稿归档'}
blocks={doneItem.blocks !== undefined && doneItem.blocks.length > 0 ? doneItem.blocks : detail.view}
values={{}}
onField={() => undefined}
onReply={() => undefined}
/>
</article>
)
) : doneList.length > 0 ? (
/* 一行一份成果报告,点进去看整篇(PRD:jc-dvs 列表) */
<div className="jc-dvs">
{doneList.map((o) => {
const res = o.done?.result;
const bad = res !== undefined && isRejection(res);
return (
<button
key={o.id}
type="button"
/*
* **执行与验收两种颜色**(2026-08-02 用户定):一摞成果里
* 「我办过的那张单」与「我签过字的那一关」是两类东西 ——
* 同色排下来,人得逐行读标题才分得出。退回的仍压过颜色走警示色。
*/
className={
'jc-dv jc-dv-row ' + (bad ? 's-bad' : 's-ok') + (o.mode === 'acceptance' ? ' m-acc' : ' m-exe')
}
onClick={() => setDoneSel(o.id)}
>
<span className="jc-dv-h">
<span className="jc-dv-ico">
<Ico name={o.mode === 'acceptance' ? 'shield-check' : 'file-text'} s={16} />
</span>
<span className="jc-dv-t">
<em>
派发 {atMinute(o.at)}
{o.done !== undefined ? ` · 办结 ${atMinute(o.done.at)}` : ''}
</em>
<b>{o.title}</b>
</span>
<span className="jc-dv-st">
{bad ? '已退回' : o.mode === 'acceptance' ? '验收通过' : '已办结'}
</span>
<Ico name="chevron-right" s={15} />
</span>
</button>
);
})}
</div>
) : (
<EmptyState
ico="archive"
tone="brand"
title="还没有成果报告"
desc="办结一件工单,它就会定稿成一份可回看的报告,归档在这里 —— 一行一份、点开整篇看。"
hint="先去「待处理」把手上的事办完"
/>
)
) : cur !== undefined ? (
/*
有工单就把工单摆出来 —— 角标说有 1 件、正文说「手上没有待办」,那是占位在说谎。
办这张单要用的上下文(活动推的那一屏、依据、表单)由 WorkOrderReport 一并铺开。
*/
<WorkOrderReport
order={cur}
detail={detail}
typeLine={cur.mode === 'acceptance' ? '验收工单 · 终局唯放行' : '执行工单 · 作业指导书 + 表单'}
blocks={cur.mode === 'execution' ? (cur.blocks ?? detail.view) : detail.view}
{...(detail.viewHtml === undefined ? {} : { html: detail.viewHtml, srcTitle: detail.card.activity })}
values={values}
onField={(k, v) => setValues((old) => ({ ...old, [k]: v }))}
onReply={onReplyOrder}
/>
* **工作区只放那份件本身**(2026-08-01 用户定)。
*
* 从前这里是 `WorkOrderReport`:它自带题头、编号带、依据带、判据表、
* 章节头,再把活动推的那一屏**包在自己里面** —— 于是人看到的是
* 「一份报告里嵌着另一份报告」,两套版面语言叠在一起。用户原话:
* 「你用你自己的报告格式嵌套了一层我要的报告格式」。
*
* 现在分家:件归工作区(整篇铺开、可就地改),
* **判据与作业指导书跟着「要你办的这件事」走,落在对话区的卡上**;
* 提交那一下也在那儿 —— 编辑与提交分在两个区,缓冲由壳层持有(values)。
*
* 活动没推件的(还没到推件那一拍、或包里就没有模版)才退回工单报告:
* 那时工作区总不能是白的。
*/
detail.viewHtml !== undefined ? (
<ViewContainer
html={detail.viewHtml}
title={`${detail.card.activity} · 汇报报告`}
editable
onEdits={(save) => {
/*
* **落库按地址走** —— 件自己说了它是哪张表(`data-edit="域.对象"`),
* 原样送上去即可。从前借道当下那张卡的 `blocks[i]` 收列名,于是
* 卡上没有那一块时(熔断卡就没有)**保存一格都送不出去**,
* 而屏上照旧有闸、有编辑、有保存 —— 界面给了个落不了地的口子。
*
* 缓冲那一份仍按卡的形状收(`table:N`):卡上那张表由提交那一下带走,
* 与件上这一张是两条路,卡没有对应的块时它自然收不到东西,不影响落库。
*/
saveTable.mutate(save);
collectEdits(save.tables, cur, onField);
}}
/>
) : (
<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>
)
) : (
<EmptyState
ico="list-checks"
tone="brand"
title="手上没有待办"
desc="需要你执行或验收的事会送到这里,带着办这件事要用的全部上下文 —— 不用你去别处找。"
hint="AI 正在往下推进,轮到你时会第一时间出现"
/>
/*
* 空待处理**四态分明** —— 状态如实,不许一句「没有待办」把四种局面糊成一种:
* 推进中 = AI 在自己转(动画场景);等外部 = 环停着听回音;
* 都办完了 = 你的活清零、单还在走;已收尾 = 一切定稿。
*/
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' ? (
<WorkingScene doneN={doneList.length} />
) : detail.card.runtime === 'awaiting-event' ? (
<EmptyState
ico="hourglass"
tone="brand"
title="AI 在等一个外部回音"
desc="等的可能是一封回信、一批数据、或一个时点。回音一到它就接着推进;需要你出手时,工单会带着完整上下文出现在这里。"
hint="不用盯着 —— 轮到你时左侧列表会亮角标"
/>
) : doneList.length > 0 ? (
<EmptyState
ico="circle-check"
tone="ok"
title="都办完了"
desc="手上的工单清零。AI 接着往下推进,需要你执行或验收的,会第一时间送到这里。"
hint="工作成果里可以回看每一份定稿报告"
/>
) : (
<EmptyState
ico="list-checks"
tone="brand"
title="手上没有待办"
desc="需要你执行或验收的事会送到这里,带着办这件事要用的全部上下文 —— 不用你去别处找。"
hint="轮到你时会第一时间出现"
/>
)
)}
</ScrollArea>
</section>
@@ -265,6 +600,41 @@ export function ViewRegion({ detail, onReplyOrder }: ViewRegionProps) {
);
}
/* ═════════════════ AI 推进中 · 空工作区的工作场景 ═════════════════ */
/**
* AI 正在办这单、还没轮到人 —— 空待处理不是「没有东西」,是**有人在干活**。
* 涟漪说「环在转」,微光工作线说「报告正在一行行写出来」;
* 都是纯 CSS、只动 transform/opacity,prefers-reduced-motion 下静止但状态仍可读。
*/
function WorkingScene({ doneN }: { doneN: number }) {
return (
<div className="jc-working" aria-live="polite">
<span className="jc-working-art" aria-hidden>
<i className="rp" />
<i className="rp rp2" />
<span className="jc-working-ico">
<Ico name="bot" s={26} />
</span>
</span>
<b>AI 正在推进这单</b>
<p>
看材料、写台账、调工具都由它来;需要你执行或验收的,工单会带着完整上下文出现在这里。
{doneN > 0 ? `此前你办结的 ${doneN} 件都在「工作成果」里。` : ''}
</p>
<span className="jc-working-lines" aria-hidden>
<i />
<i />
<i />
</span>
<span className="jc-working-hint">
<i className="jc-pulse-dot" />
实时盯着 · 有进展这里立即更新
</span>
</div>
);
}
/* ═════════════════ 任务详情:KPI 带 + 流程链 + 上下文档案 ═════════════════ */
function WSec({ ico, label, aside }: { ico: string; label: string; aside?: React.ReactNode }) {
@@ -386,7 +756,7 @@ function WfDetail({ detail, openN, doneN }: { detail: TaskDetail; openN: number;
<em>编号</em>
<p className="jc-mono">
{detail.card.id}
<span className="jc-mono">起于 {detail.card.startedAt.slice(0, 16).replace('T', ' ')}</span>
<span className="jc-mono">起于 {atFull(detail.card.startedAt)}</span>
</p>
</div>
</div>
@@ -7,46 +7,110 @@
* 「对话流是**一切回环输入的唯一入口**」(OS 4.1);输入侧不参与控制权转交(AM 07)。
* AI 的内容只有两种形态、不允许中间态:
* · 纯文本气泡 —— 结论 / 说明 / 回执
* · 完整表单卡 —— 工单卡 / 验收单 / 决策单 / 补全单 / 评价单
* · 完整表单卡 —— 工单卡 / 验收单 / 决策单 / 补全单 / 录入单 / 评价单
* 六张卡共用一套外壳 `jc-fc`(头 = 图标 + 类型名 + 计数,身 = `jc-fc-b`)——
* 谁自造一套骨架,谁在对话流里就显得是外来的。
* 一条消息 = 署名行(AI · 时间 · 类型章)+ 一或两个**纯**块。
* ⛔ 补全 / 决策卡吐出期间 composer 锁定 —— 先办卡,再说话。
*
* DOM 与类名逐字照搬原型:皮肤是原型的原样,自造类名等于没样式。
*/
import { useEffect, useRef, useState } from 'react';
import type { AiTag, ChatCardReply, ChatMessage, WorkOrder, WorkOrderReply } from '@contracts';
import type { FormField, AiTag, ChatCardReply, ChatMessage, WorkOrder, WorkOrderReply } from '@contracts';
import { EVENT_TYPE_LABEL, isRejection } from '@contracts';
import { Ico } from '@web-shared/Ico.tsx';
import { ChatAvatar } from '@web-shared/ChatAvatar.tsx';
import { atClock } from '@web-shared/at.ts';
/** 类型章:结论 = auto,指导 = exe,判据 = acc(原型 TAG_UI 口径) */
/**
* 类型章:结论 = auto,指导 = exe,判据 = acc(原型 TAG_UI 口径)。
*
* **同一个签,带不带工单说的是两件事**:
* 带工单(`ref`)的那条是「我派了一张卡给你」;不带的是 AI 在**交代进展**。
* 只按签取字的话,一句「三条已归一,缺 1 个交期」会被标成「派了工单」——
* 它一张卡也没派。界面不撒谎:没有卡就不写卡的字(实测踩过,满屏「派了工单」)。
*/
const TAG_UI: Readonly<Record<AiTag, { label: string; cls: string }>> = {
conclusion: { label: '阶段结论', cls: 'auto' },
guidance: { label: '派了工单', cls: 'exe' },
guidance: { label: '派出工单', cls: 'exe' },
criterion: { label: '请你验收', cls: 'acc' },
};
/**
* 交代进展的章 —— 同一批签,不带工单时的说法。
*
* **一律四个字**(2026-07-31 用户定):这一列的每一格只说一件事,
* 字数一致那一列才扫得动。从前这里是「进展」「判据」两个字,
* 跟旁边的「阶段结论」一长一短,像两套词表混着用 —— 事实上它们就是一套。
* 工作日志用的也是这一套词(见 `task-log.ts` 的 `actionOf`)。
*/
const SAY_UI: Readonly<Record<AiTag, { label: string; cls: string }>> = {
conclusion: { label: '阶段结论', cls: 'auto' },
guidance: { label: '交代进展', cls: 'auto' },
criterion: { label: '验收判据', cls: 'acc' },
};
/**
* 系统气泡的章 —— **对话区说人话,契约说类型**。
*
* `EVENT_TYPE_LABEL` 是事件分类学(data / time / human / control),治理面按它分区没错;
* 可直接摆进对话流就成了「数据事件」——「一封邮件触发了这条流程」被标成「数据事件」,
* 人看不出这跟自己有什么关系。归侧词表(`SIDE_ST`)里这一类早就叫**触发事件**了,
* 工作日志用的就是它;两屏说同一件事,得用同一个词。
*/
const SYS_TAG: Readonly<Record<string, string>> = {
data: '触发事件',
time: '时间到点',
// 系统气泡里 `human` 只有一个来源:人办结了执行工单(chat-projection 显式打的),
// 裸「事件到达(human)」那条早在投影里就被滤掉了 —— 所以这里说得死一点
human: '员工办结',
control: '控制事件',
};
/** ISO → HH:MM(署名行只要时分) */
const hm = (iso: string) => iso.slice(11, 16);
const hm = (iso: string): string => atClock(iso);
export type TaskChatProps = {
readonly chat: readonly ChatMessage[];
readonly orders: readonly WorkOrder[];
/** AI 正在回话 —— 请求在途即亮「输入中」 */
readonly thinking: boolean;
/**
* AI 正在办这单(Loop 在自己转)—— 与「在打字」是两种状态:
* 打字是**在回你的话**(三点气泡);办事是**环在推进**(均衡条常驻在场)。
* 状态来自任务投影的 runtime,不是前端猜的。
*/
readonly working?: boolean;
onSay(text: string): void;
onReplyCard(reply: ChatCardReply): void;
onReplyOrder(reply: WorkOrderReply): void;
/** 点卡上的「打开工单」→ 视图区定位到那张工单 */
onLocate(orderId: string): void;
onOpenComms(): void;
/** 人在工作区报告上改过的填报值 —— 验收裁决时一并带走(见 TaskChat 的注释) */
readonly orderValues?: Readonly<Record<string, string>>;
/** 卡上填的值也进同一个缓冲 —— 执行单的表单如今长在对话区的卡上 */
onField(key: string, value: string): void;
};
export function ChatStream({ chat, orders, thinking, onSay, onReplyCard, onReplyOrder, onLocate }: TaskChatProps) {
export function ChatStream({
chat,
orders,
thinking,
working = false,
onSay,
onReplyCard,
onReplyOrder,
onLocate,
onOpenComms,
orderValues,
onField,
}: TaskChatProps) {
const [text, setText] = useState('');
const boxRef = useRef<HTMLDivElement>(null);
useEffect(() => {
boxRef.current?.scrollTo({ top: 1e9, behavior: 'smooth' });
}, [chat.length, thinking]);
}, [chat.length, thinking, working]);
// ⛔ 补全 / 决策卡未办结期间不能对话 —— 锁的是**卡**,不是工单
const blocked = chat.some(
@@ -80,9 +144,12 @@ export function ChatStream({ chat, orders, thinking, onSay, onReplyCard, onReply
onReplyCard={onReplyCard}
onReplyOrder={onReplyOrder}
onLocate={onLocate}
onOpenComms={onOpenComms}
onField={onField}
{...(orderValues === undefined ? {} : { orderValues })}
/>
))}
{thinking && (
{thinking ? (
<article className="jc-ag">
<ChatAvatar who="ai" />
<div className="jc-ag-col">
@@ -94,7 +161,9 @@ export function ChatStream({ chat, orders, thinking, onSay, onReplyCard, onReply
</span>
</div>
</article>
)}
) : working ? (
<WorkingStrip />
) : null}
</div>
<div className="copilot-composer session-composer">
@@ -126,9 +195,51 @@ export function ChatStream({ chat, orders, thinking, onSay, onReplyCard, onReply
);
}
/**
* AI 办事条 —— 消息流末尾的**常驻在场**:环在自己转(写台账 / 调工具 / 判局面)。
* 均衡条不是打字点:打字说「正在回你」,均衡条说「正在办事」。
* 头像加呼吸环:第二信号,很轻 —— 只有留意的人看得见。
*
* 导出是给**起手那一刻**用的(`TaskChat` 的空流分支):一单刚起、还没有任何消息时,
* 摆的就该是这一条,而不是一张空态插画 —— 两者形状不同,第一句话到了版式会跳。
*/
export function WorkingStrip() {
return (
<article className="jc-ag" aria-live="polite">
<span className="jc-ava-live" style={{ display: 'inline-flex', borderRadius: 999 }}>
<ChatAvatar who="ai" />
</span>
<div className="jc-ag-col">
<div className="jc-msg-meta">AI · 正在办这单</div>
<span className="jc-aiwork">
<span className="jc-aiwork-eq" aria-hidden>
<i />
<i />
<i />
<i />
</span>
<b className="jc-aiwork-txt">持续推进中</b>
<em className="jc-aiwork-sub">有新进展会随时在这里说</em>
</span>
</div>
</article>
);
}
/* ── Agent 侧包装:头像 + (署名行 → 内容流) ── */
function Ag({ meta, tag, children }: { meta: string; tag?: AiTag | undefined; children: React.ReactNode }) {
const t = tag === undefined ? undefined : TAG_UI[tag];
function Ag({
meta,
tag,
hasOrder = false,
children,
}: {
meta: string;
tag?: AiTag | undefined;
/** 这条消息带不带工单 —— 带才用卡的说法(见 TAG_UI 的注释) */
hasOrder?: boolean;
children: React.ReactNode;
}) {
const t = tag === undefined ? undefined : (hasOrder ? TAG_UI : SAY_UI)[tag];
return (
<article className="jc-ag">
<ChatAvatar who="ai" />
@@ -149,12 +260,19 @@ function Msg({
onReplyCard,
onReplyOrder,
onLocate,
onOpenComms,
orderValues,
onField,
}: {
m: ChatMessage;
orders: readonly WorkOrder[];
onReplyCard: (reply: ChatCardReply) => void;
onReplyOrder: (reply: WorkOrderReply) => void;
onLocate: (orderId: string) => void;
onOpenComms: () => void;
/** 人在工作区报告上改过的值 —— 验收裁决时一并带走 */
orderValues?: Readonly<Record<string, string>> | undefined;
onField: (key: string, value: string) => void;
}) {
// 人的消息:右侧品牌气泡(纯文本)
if (m.k === 'human') {
@@ -181,10 +299,20 @@ function Msg({
<div className="jc-ag-col">
<div className="jc-msg-meta">
系统 · {hm(m.at)}
<b className="jc-mtag m-sys">{EVENT_TYPE_LABEL[m.type]}事件</b>
<b className="jc-mtag m-sys">{SYS_TAG[m.type] ?? `${EVENT_TYPE_LABEL[m.type]}事件`}</b>
</div>
<div className="jc-ag-b sys">
<p className="jc-content">{m.text}</p>
{/*
起单开场白带的那一下 —— 原件在沟通记录里。
**点不点都不挡路**:这条消息之后 AI 照常开跑,不等人按。
*/}
{m.openComms === true && (
<button type="button" className="jc-sys-act" onClick={onOpenComms}>
<Ico name="mail" s={12} />
查看邮件
</button>
)}
</div>
</div>
</article>
@@ -196,12 +324,26 @@ function Msg({
const order = m.ref === undefined ? undefined : orders.find((o) => o.id === m.ref);
const settled = order?.done !== undefined;
return (
<Ag meta={`AI · ${hm(m.at)}`} tag={m.tag}>
<Ag meta={`AI · ${hm(m.at)}`} tag={m.tag} hasOrder={order !== undefined}>
<div className={'jc-ag-b' + (order === undefined ? '' : ' card')}>
<p className="jc-content">{m.text}</p>
{order !== undefined &&
(order.mode === 'acceptance' && m.tag === 'criterion' && !settled ? (
<AcceptForm order={order} onReplyOrder={onReplyOrder} onLocate={onLocate} />
<AcceptForm
order={order}
onReplyOrder={onReplyOrder}
onLocate={onLocate}
{...(orderValues === undefined ? {} : { orderValues })}
/>
) : order.mode === 'execution' && !settled ? (
/* 没办结的执行单 = 一张能就地办的卡:指导书 + 依据 + 表单 + 提交 */
<ExecForm
order={order}
onReplyOrder={onReplyOrder}
onLocate={onLocate}
onField={onField}
{...(orderValues === undefined ? {} : { orderValues })}
/>
) : (
<WoCard order={order} onLocate={onLocate} />
))}
@@ -291,6 +433,36 @@ function Msg({
);
}
/*
* 数据录入卡 —— **确认那一下就是写入**(第五类人类工具)。
* 与补全卡长得像,分量完全不同:补全卡收的是「答案」,AI 拿去再写;
* 这一张收的是「这一行数据」,按下确认基座当场落进台账草稿层,AI 不经手。
* 所以卡面上要**明写落到哪张表** —— 人得知道自己这一下写的是什么。
*/
if (m.k === 'entry') {
const written = m.done !== undefined;
return (
<Ag meta={`AI · ${hm(m.at)}`}>
<div className="jc-ag-b card">
<p className="jc-content">{m.ask}</p>
{/* 外壳与补全/决策/评价同一套 `jc-fc` —— 卡族要长得像一家人,
各自的身份写在头上(图标 + 类型名),不靠各造一套框来区分 */}
<div className="jc-fc t-entry">
<div className="jc-fc-h">
<Ico name="table" s={13} />
<b>数据录入</b>
<em className={written ? 'ok' : ''}>{written ? '已写入' : `${m.fields.length} 项`}</em>
</div>
<div className="jc-fc-b">
{m.basis !== undefined && m.basis.length > 0 && <Kv items={m.basis} />}
<EntryCard card={m} onDone={(values) => onReplyCard({ k: 'entry', cardId: m.id, values })} />
</div>
</div>
</div>
</Ag>
);
}
// 决策:问题文字在上,选择卡嵌在同一气泡里
const chosenLabel =
m.chosen === undefined
@@ -382,7 +554,7 @@ function Kv({ items }: { items: ReadonlyArray<{ k: string; v: string }> }) {
);
}
/* 工单卡:题头 + 单体 + 打开动作 —— 派单 / 已裁决的验收都用它 */
/* 工单卡:题头 + 单体 + 打开动作 —— 已办结的、以及没有作业指导书的都用它 */
function WoCard({ order, onLocate }: { order: WorkOrder; onLocate: (id: string) => void }) {
const result = order.done?.result;
const bad = result !== undefined && isRejection(result);
@@ -398,10 +570,290 @@ function WoCard({ order, onLocate }: { order: WorkOrder; onLocate: (id: string)
</div>
<div className="jc-fc-b">
<p className="jc-fc-q strong">{order.title}</p>
{/*
* **签完之后换一屏**(2026-08-02 用户:「完成验收后反而要把落地结果展示出来」)。
*
* 放行卡上摆的是签字前要核的东西(判据);签完之后人关心的是另一件事 ——
* 这一签下去,世界上发生了什么:落了几行、进了哪几张表、转去哪儿了。
* 那一摞对勾说的还是签之前的话,回看时它答不了这个问题。
* (落地事实取不到的老单仍退回判据表:宁可摆旧的,也不摆空的。)
*/}
{order.mode === 'acceptance' &&
(order.landing !== undefined ? (
<Landing landing={order.landing} />
) : (
order.criteria.length > 0 && <Crit list={order.criteria} />
))}
</div>
<div className="jc-fc-ft">
<button className="jc-btn jc-btn-ghost jc-fc-open" onClick={() => onLocate(order.id)}>
<Ico name="panel-left-open" s={13} /> 打开工单 <Ico name="arrow-right" s={12} />
<Ico name="panel-left-open" s={13} /> 打开报告 <Ico name="arrow-right" s={12} />
</button>
</div>
</div>
);
}
/**
* 判据逐条 —— 验收卡与办结卡同一份。
*
* **「裁了但办不到」不是「未达标」**(2026-08-01 用户当场指出:那条 L4 的红叉「为啥还在」)。
* 裁量项(L3/L4)有三态,卡上要分得出来:
* · 裁成满足 → 达标(绿);
* · **裁成不满足 → 「AI 判为不满足」**(琥珀)+ 理由 —— 它不是活没干完,
* 是 AI 办过、办不到,**把判断交给你**;提交门本来就允许这样提交(裁过即可)。
* · 没裁过 → 未达标(红)—— 那才是活没干完。
* 从前三态挤成两态,人看到一条永远不绿的红叉,既不知道 AI 有没有办过、也不知道为什么,
* 只能凭感觉签 —— 而理由那一句正是他要看的东西。
*/
function Crit({
list,
}: {
list: ReadonlyArray<{ label: string; level: string; pass: boolean; settled?: boolean | undefined; rationale?: string | undefined }>;
}) {
return (
<ul className="jc-rp-crit sm">
{list.map((c, i) => {
// 裁量项:settled 有值才是它。裁过而未满足 = 交给人定,不是缺口
const judged = c.settled === true && !c.pass;
return (
<li key={i} className={c.pass ? 'ok' : judged ? 'judged' : 'no'}>
<span className={'jc-lv jc-lv-' + c.level}>{c.level}</span>
<span className="t">
{c.label}
{judged && c.rationale !== undefined && <em className="jc-crit-why">{c.rationale}</em>}
</span>
<span className="r">
<Ico name={c.pass ? 'circle-check' : judged ? 'circle-alert' : 'circle-x'} s={12} />
{c.pass ? '达标' : judged ? 'AI 判为不满足 · 交你定' : '未达标'}
</span>
</li>
);
})}
</ul>
);
}
/**
* 落地结果 —— **办结之后那一屏**:这一签下去,世界上发生了什么。
*
* 三件事,一件也不多:进了哪几张表各几行、转去哪儿了、试跑挡下了几行。
* 摆的都是放行那一刻的结构化留痕,不转述、不估算 ——
* 「合并 9 行」而其中两行没进表的话,那句话就是假的,所以试跑那一行必须说。
*/
function Landing({
landing,
}: {
landing: NonNullable<Extract<WorkOrder, { mode: 'acceptance' }>['landing']>;
}) {
return (
<div className="jc-landing">
<p className="jc-landing-h">
<Ico name="circle-check" s={13} />
<b>已落地</b>
<span>{landing.rows} 行进正式表</span>
</p>
{landing.tables.length > 0 && (
<ul className="jc-rp-crit sm jc-guide">
{landing.tables.map((t, i) => (
<li key={i}>
<span className="jc-lv jc-lv-done">
<Ico name="file-spreadsheet" s={11} />
</span>
<span className="t">{t.label}</span>
<span className="r">{t.rows} 行</span>
</li>
))}
</ul>
)}
{/*
* **收口要答两件事**(2026-08-02 用户定):落了什么、往哪儿去。
* 往哪儿去分两种:转给下一个活动(带了哪些字段一并说清),
* 或者一个口都没发 —— 那就是这条流到此为止,得说出来,别让人自己猜。
*/}
{landing.ports.length > 0 ? (
<p className="jc-landing-p">
<Ico name="git-branch" s={12} />
<span>
<b>转出</b>
{landing.ports.join(' · ')}
{landing.carried.length > 0 && (
<em className="jc-landing-carry">带过去:{landing.carried.slice(0, 12).join('、')}
{landing.carried.length > 12 ? ` 等 ${landing.carried.length} 项` : ''}
</em>
)}
</span>
</p>
) : (
<p className="jc-landing-p">
<Ico name="circle-check" s={12} />
<span>
<b>到此为止</b>
没有下游出口 —— 这条流走完了
</span>
</p>
)}
{landing.simulated > 0 && (
/* 界面不撒谎:试跑挡下的那几行没进表,不说就等于报了一个假数 */
<p className="jc-landing-p warn">
<Ico name="alert-triangle" s={12} />
<span>试跑:另有 {landing.simulated} 行主数据只留在活动缓存,未落正式表</span>
</p>
)}
</div>
);
}
/**
* 作业指导书 —— **与验收判据同一套版面**(2026-08-02 用户定)。
*
* 「这一单要你做什么」与「做成什么算数」是同一类东西:一条一行、左边一枚章、
* 右边一句结论。判据那一摞已经是这个样子,指导书从前却是一串松散的圆点 ——
* 同一张卡上两套版面语言,人得在两种读法之间来回切。
*
* 与判据的差别只有一处:**指导书没有「达标 / 未达标」** —— 它是要办的事,
* 不是已办成的事。右边那一格只留给最后那条「算做完的标准」:
* 它是这几步的收口,摆在末行才读得顺。**不给步骤画对勾** ——
* 系统并不知道人做没做,画上去就是撒谎。
*/
function Guide({ steps, standard }: { steps?: readonly string[] | undefined; standard?: string | undefined }) {
const list = steps ?? [];
if (list.length === 0 && standard === undefined) return null;
return (
<ul className="jc-rp-crit sm jc-guide">
{list.map((x, i) => (
<li key={i}>
<span className="jc-lv jc-lv-step">{i + 1}</span>
<span className="t">{x}</span>
</li>
))}
{standard !== undefined && (
<li className="done">
<span className="jc-lv jc-lv-done">
<Ico name="target" s={11} />
</span>
<span className="t">
<b>算做完的标准</b>
{standard}
</span>
</li>
)}
</ul>
);
}
/**
* 执行工单卡 —— **作业指导书 + 依据 + 填报表单 + 提交,全在对话区**(2026-08-01 用户定)。
*
* 从前这三样都长在工作区的工单报告里,而那份报告又把活动推的那一屏包在自己里面 ——
* 于是人看到的是「一份报告里嵌着另一份报告」。用户原话:
* 「你用你自己的报告格式嵌套了一层我要的报告格式」。
* 现在分开:**工作区只放件本身**(那份 HTML 模版,可就地改),
* 判据与指导书跟着「要你办的这件事」走,落在对话区的卡上。
*
* 表格那一份值不在这儿收 —— 人是在工作区的件上改的,由外层缓冲带过来
* (与验收裁决同一条路子:编辑与提交分在两个区,缓冲得由壳层持有)。
*/
function ExecForm({
order,
onReplyOrder,
onLocate,
orderValues,
onField,
}: {
order: Extract<WorkOrder, { mode: 'execution' }>;
onReplyOrder: (reply: WorkOrderReply) => void;
onLocate: (id: string) => void;
orderValues?: Readonly<Record<string, string>> | undefined;
onField: (key: string, value: string) => void;
}) {
const val = (f: FormField) => orderValues?.[f.key] ?? f.ai ?? '';
/*
* **必填还空着就按不下提交**(2026-08-02 用户定)——两处必填,一个门:
* · 卡上那几格表单字段(`fields[].required`);
* · 件上那张表里的必填列(`gate`,由基座按台账声明现算,与放行门的 L1 同源)。
*
* 从前只看前者:件上那张表压根不在这道门里,于是一条料缺着年用量也能交回来 ——
* 这一单一路走到放行门才被 L1 拦住,模型连拒五拍熔断(实测)。
* 同一件事在提交那一刻拦下便宜得多:那时缺的是哪一格就摆在他眼前。
* 算不出来(卡上没绑表)就没有 `gate`,按钮照常能按 —— 没有依据的时候不拦人。
*/
const gate = order.gate;
const ok = order.fields.every((f) => f.required !== true || val(f).trim() !== '') && gate?.ready !== false;
return (
<div className="jc-fc t-exe">
<div className="jc-fc-h">
<Ico name="clipboard-check" s={13} />
<b>工单 · 执行</b>
{order.steps !== undefined && order.steps.length > 0 && <em>{order.steps.length} 步</em>}
</div>
<div className="jc-fc-b">
<p className="jc-fc-q strong">{order.title}</p>
{/* 件在工作区 —— 这一钮就是去看它、去改它的门 */}
{/*
* 与验收卡那一钮**同一个形状**(2026-08-02 用户:「格式不统一」)——
* 图标一格、四字粗体一行、小字说明一行、右侧一枚箭头。
* 两张卡是同一族的东西,长成两个样子只会让人多认一次。
*/}
<button className="jc-fc-doc" onClick={() => onLocate(order.id)}>
<span className="jc-fc-doc-ico">
<Ico name="file-text" s={15} />
</span>
<span className="jc-fc-doc-bd">
<b>执行工单</b>
<em>整份清单在工作区,每一格都能就地改;改完回这里提交</em>
</span>
<Ico name="chevron-right" s={14} />
</button>
<p className="jc-fc-q">{order.ask}</p>
<Guide steps={order.steps} standard={order.standard} />
{order.basis.length > 0 && <Kv items={order.basis} />}
{order.fields.map((f) => (
<label key={f.key} className="jc-fc-fld">
<span>
{f.label}
{f.required === true && <em className="jc-req">必填</em>}
</span>
<textarea
className="jc-ta"
rows={2}
placeholder={f.placeholder ?? '写一句就行'}
value={val(f)}
onChange={(e) => onField(f.key, e.target.value)}
/>
</label>
))}
</div>
{gate !== undefined && !gate.ready && (
/* 拦住就得说清差在哪 —— 只有一个灰按钮的话,人只能猜自己哪儿做错了 */
<div className="jc-fc-gate">
<p>
<Ico name="alert-triangle" s={13} />
<b>{gate.why}</b>
</p>
{gate.blanks.length > 0 && (
<ul>
{gate.blanks.slice(0, 8).map((x, i) => (
<li key={i}>{x}</li>
))}
{gate.blanks.length > 8 && <li className="more">还有 {gate.blanks.length - 8} 条</li>}
</ul>
)}
</div>
)}
<div className="jc-fc-ft">
<button
className="jc-btn jc-fc-go"
disabled={!ok}
onClick={() =>
onReplyOrder({
mode: 'execution',
workOrderId: order.id,
values: { ...orderValues },
})
}
>
<Ico name="check" s={13} /> {order.submitLabel ?? '提交'}
</button>
</div>
</div>
@@ -413,10 +865,20 @@ function AcceptForm({
order,
onReplyOrder,
onLocate,
orderValues,
}: {
order: Extract<WorkOrder, { mode: 'acceptance' }>;
onReplyOrder: (reply: WorkOrderReply) => void;
onLocate: (id: string) => void;
/**
* 人在工作区报告上改过的填报值 —— **裁决时一并带走**。
*
* 报告是「AI 已填好、可编辑」,裁决在这儿:两半分在两个区里做。
* 不带过来,回传的 `values` 就恒空 —— 人改了报告,落地时用的还是 AI 的原值
* (第 12 章③ 的一半:人工修订永远落不下来)。
* 一个字没改就是空的 —— 空 = 全盘采信 AI 拟值,契约本来就这么定的。
*/
orderValues?: Readonly<Record<string, string>> | undefined;
}) {
const [why, setWhy] = useState('');
/** 选择组:恰选一口,选口即裁分支 —— 没选之前「通过」不亮 */
@@ -439,23 +901,14 @@ function AcceptForm({
<Ico name="file-text" s={15} />
</span>
<span className="jc-fc-doc-bd">
<em>成果报告 · 视图区可读可编辑</em>
<b>{order.title}</b>
{/* 四字粗体在上、说明在下 —— 与执行卡同序,人扫两张卡时落点一致 */}
<b>验收工单</b>
<em>{order.title} · 成果报告在工作区,可读可就地改</em>
</span>
<Ico name="chevron-right" s={14} />
</button>
<ul className="jc-rp-crit sm">
{order.criteria.map((c, i) => (
<li key={i} className={c.pass ? 'ok' : 'no'}>
<span className={'jc-lv jc-lv-' + c.level}>{c.level}</span>
<span className="t">{c.label}</span>
<span className="r">
<Ico name={c.pass ? 'circle-check' : 'circle-x'} s={13} />
{c.pass ? '达标' : '未达标'}
</span>
</li>
))}
</ul>
{/* 与办结卡同一份 —— 两处各写一遍,三态迟早只在一处生效 */}
<Crit list={order.criteria} />
{order.actions !== undefined && order.actions.length > 0 && (
<Kv
items={order.actions.map((a) => ({
@@ -464,6 +917,34 @@ function AcceptForm({
}))}
/>
)}
{/*
* **算出来的去向:只告诉他,不给他选**(2026-08-01)。
*
* 这一单往哪走是分类表那几个数的函数(有没有 C 类、有没有 B 类),
* 契约给每个出口写了判据、基座按序求值定下 —— 摆成选择题,
* 等于把一个可回放的判定换成一次不可回放的手感。
* 但**必须让他看见**:凭哪一条走的这一口,他要能回核。
*/}
{order.routedPort !== undefined && (
/*
* **卡上不摆口名与判据表达式**(2026-08-02 用户指出:卡片上还有
* 「凭 quote_intake_record_type_tag_c > 0」)。
*
* `to-alternative` 与那一串谓词都是给排障看的内部名;签字的人要知道的是
* **「这一单转到哪儿去、凭什么」**,而「凭什么」的人话答案是那一口自己的说明
* (「有 C 类要替代 —— 去选型」),不是它的表达式。
* 报告那一侧已经这么改了,卡这一侧漏了 —— 同一件事两处呈现,口径得一致。
*/
<div className="jc-ports routed">
<p className="jc-ports-t">
这一单的去向 <em>已按料号分类算出 —— 不用选,改分类它就跟着变</em>
</p>
<div className="jc-port on">
<span className="jc-port-dot" />
<b>{order.routedPort.label ?? order.routedPort.name}</b>
</div>
</div>
)}
{choice !== undefined && (
<div className="jc-ports">
<p className="jc-ports-t">
@@ -498,7 +979,8 @@ function AcceptForm({
mode: 'acceptance',
workOrderId: order.id,
verdict: 'redo',
values: {},
// 驳回也把改过的值带上:人常常一边改一边发现问题,改的痕不该丢
values: { ...(orderValues ?? {}) },
comment: why.trim(),
selectedPorts: {},
})
@@ -516,9 +998,25 @@ function AcceptForm({
mode: 'acceptance',
workOrderId: order.id,
verdict: 'pass',
values: {},
// 恰选一口:选中的口随放行一并确认 —— 它就是这次运行的 outcome
selectedPorts: choice === undefined || port === undefined ? {} : { [choice.group]: port },
// 人在报告上改过的值随放行落地;一个字没改就是空的(= 全盘采信 AI 拟值)
values: { ...(orderValues ?? {}) },
/*
* 恰选一口 —— **算出来的那一口也要交回去**。
*
* 两条来路:能算就算(`routedPort`,基座按出口谓词求值)、算不出才让人选
* (`portChoice`)。从前这里只认后者:去向算出来的那一路 `selectedPorts`
* 恒空,人按下「确认通过」→ 放行落地当场判「选择组未选出口」→ 整单 blocked
* (实测:台账都合并进正式表了,单子却死在最后一步)。
* 试跑脚本一直自己带着 port,所以这条只在真人点的那条路上现形。
*
* 人按下的是「确认」:确认的正是基座算出来的那一口。
*/
selectedPorts:
order.routedPort !== undefined
? { [order.routedPort.group]: order.routedPort.name }
: choice === undefined || port === undefined
? {}
: { [choice.group]: port },
})
}
>
@@ -561,11 +1059,18 @@ function FillForm({
onDone,
onCant,
}: {
fields: ReadonlyArray<{ key: string; label: string; placeholder?: string | undefined }>;
fields: ReadonlyArray<{ key: string; label: string; placeholder?: string | undefined; value?: string | undefined }>;
onDone: (values: Record<string, string>) => void;
onCant: (reason: string) => void;
}) {
const [vals, setVals] = useState<Record<string, string>>({});
/*
* **以预填值开局** —— AI 从来件里读得出的(公司名、联系人姓名)已经填在格子里,
* 人核对一眼、补上它填不出的那个即可。
* 用惰性初值而不是 `useEffect` 回填:回填会在人已经开始打字之后把他敲的字冲掉。
*/
const [vals, setVals] = useState<Record<string, string>>(() =>
Object.fromEntries(fields.filter((f) => (f.value ?? '') !== '').map((f) => [f.key, f.value ?? ''])),
);
const [cant, setCant] = useState(false);
const [reason, setReason] = useState('');
const ok = fields.every((f) => (vals[f.key] ?? '').trim() !== '');
@@ -666,3 +1171,130 @@ function RateForm({ onDone }: { onDone: (score: number, note: string) => void })
</div>
);
}
/**
* 数据录入卡的表单 —— AI 预填、人可改、必填空着不让确认。
*
* 与补全卡的 `FillForm` 分开写而不是加个开关:两者的**语义**不同 ——
* 那边按「确认」是交答案,这边按下去是**往台账写一行**。
* 按钮上的字、落表的提示、必填的拦法都得说这件事,合成一个组件只会让两边都含糊。
*/
function EntryCard({
card,
onDone,
}: {
card: Extract<ChatMessage, { k: 'entry' }>;
onDone: (values: Record<string, string>) => void;
}) {
/*
* **一张卡可以落好几张表**(2026-08-02 用户:「新建客户 联系人和公司都需要确认」)。
* 建一位新客户落的是两条主数据:公司档 + 联系人档。从前卡上只有公司那一半,
* 联系人由 AI 自己写 —— 而联系人档的 `contact_code` 是它编出来的,人一眼没看过。
* 值按段隔开(`段号.列名`):两张表都有 `name` 这一列,平铺就会互相盖掉。
*/
const groups = [
{
title: '',
ledger: card.ledger,
...(card.ledgerLabel === undefined ? {} : { ledgerLabel: card.ledgerLabel }),
key: card.key,
...(card.keyLabels === undefined ? {} : { keyLabels: card.keyLabels }),
fields: card.fields,
...(card.autoNote === undefined ? {} : { autoNote: card.autoNote }),
},
...(card.also ?? []),
];
const at = (i: number, key: string) => `${i}.${key}`;
/* 以 AI 预填值开局 —— 惰性初值,不用 effect 回填(那会冲掉人已经敲的字) */
const [vals, setVals] = useState<Record<string, string>>(() =>
Object.fromEntries(groups.flatMap((g, i) => g.fields.map((f) => [at(i, f.key), f.value ?? '']))),
);
const done = card.done !== undefined;
const shown = (i: number, key: string) =>
done ? (card.done?.values[at(i, key)] ?? card.done?.values[key] ?? '') : (vals[at(i, key)] ?? '');
const blank = groups.flatMap((g, i) => g.fields.filter((f) => f.required && shown(i, f.key).trim() === ''));
/*
* **落表那一句合成一条**(2026-08-02 用户:「多个合并成一个:已写入 xxx、xxx」)。
*
* 从前每一段各摆一条「确认后写入 · 客户档案」——两段就是两条横幅,说的是同一件事
* (这一下确认会落库),只是落的表不同。人要知道的是「会写到哪几张表」,
* 那是一句话:**确认后写入 客户档案、客户联系人**。
*/
const tables = groups.map((g) => g.ledgerLabel ?? g.ledger).join('、');
/*
* **主键行只摆整张卡都填不到的那几格**(2026-08-02)。
*
* 从前按段算:联系人那段的表单里没有客户编码,于是它被当成「上游定死的」摆了出来 ——
* 可人正要在**上一段**亲手填它。同一个值在一张卡上出现两次,后一次还是旁白。
* 现在跨段看:任何一段的表单里有,就不摆;一格不剩就整行不出。
*/
const onAnyForm = new Set(groups.flatMap((g) => g.fields.map((f) => f.key)));
const fixed = groups
.flatMap((g, i) =>
Object.entries(g.key)
.filter(([k, v]) => !onAnyForm.has(k) && (shown(i, k) || v) !== '')
.map(([k, v]) => `${g.keyLabels?.[k] ?? k} ${shown(i, k) || v}`),
)
.filter((x, i, all) => all.indexOf(x) === i);
return (
<>
{/* 落表提示用公用的 `jc-fc-note` —— 它自带本卡色调,不必自造一条横幅 */}
<p className="jc-fc-note">
<Ico name="database" s={13} />
<span>
<b>{done ? '已写入' : '确认后写入'}</b>
{/* 摆的是登记的人读名(「客户档案」);没登记人读名才退回地址 —— 界面不摆英文表名 */}
<span className="jc-entry-tbl">{tables}</span>
{fixed.length > 0 && <em className="jc-entry-key">{fixed.join(' · ')}</em>}
</span>
</p>
{groups.map((g, i) => {
return (
<div key={i}>
{/* 两段以上才需要小标题 —— 一段的卡上摆「客户档案」是把上面那句又说了一遍 */}
{g.title !== '' && <p className="jc-entry-sec">{g.title}</p>}
{g.autoNote !== undefined && (
/* 自增的那几列压根不在表单里 —— 但不能一声不吭地拿掉,人会以为这张档少了一格 */
<p className="jc-entry-auto">
<Ico name="circle-dot" s={12} />
{g.autoNote}
</p>
)}
<div className="jc-cfill">
{g.fields.map((f) => {
const v = shown(i, f.key);
const todo = !done && v.trim() === '';
return (
<label key={f.key} className={'jc-cfill-f' + (todo ? ' todo' : '')}>
<span>
{f.label}
{f.required && <i className="req">*</i>}
{/* 空着的明标「待填写」—— AI 填不出来的那几格,人一眼看得见 */}
{todo && <em className="jc-entry-todo">待填写</em>}
</span>
<input
value={v}
disabled={done}
placeholder={f.placeholder ?? (f.required ? '必填' : '可留空')}
onChange={(e) => setVals({ ...vals, [at(i, f.key)]: e.target.value })}
/>
</label>
);
})}
</div>
</div>
);
})}
{!done && (
<div className="jc-cfill-acts">
{blank.length > 0 && <span className="jc-entry-warn">还差:{blank.map((f) => f.label).join('、')}</span>}
<button className="jc-btn" disabled={blank.length > 0} onClick={() => onDone(vals)}>
<Ico name="database" s={13} /> 确认写入
</button>
</div>
)}
</>
);
}
@@ -3,14 +3,17 @@
*
* 自原型 `_components/ViewRegion.tsx` 的 `Report` 与卡组二平移,DOM 与类名逐字照搬。
*
* **当前未挂载**:工作区正处在「一步一步描述、一步一步接」的阶段,待处理页签暂为空占位。
* 这一份留在这里等着接回来 —— 描述到工单该长什么样时,直接用或按新口径改,不必重写。
* 两种工单同一张报告,差别只在**表单的来路与裁决的落点**:
* · 执行 —— 空占位表单(值只来自人填),报告页就地提交;
* · 验收 —— AI 已填好、可编辑,**裁决在对话区的验收单上**(PRD 口径)。
* 所以编辑缓冲不能只活在这一份里 —— 它由上层持有,对话区裁决时一并带走。
*/
import { useState } from 'react';
import type { FormField, TaskDetail, ViewBlock, WorkOrder, WorkOrderReply } from '@contracts';
import { ACTION_EFFECT_LABEL, WORK_ORDER_RESULT_LABEL, isRejection } from '@contracts';
import { Ico } from '@web-shared/Ico.tsx';
import { ViewBlocks, ViewContainer } from '@ui';
import { atMinute } from '@web-shared/at.ts';
function Part({ label, aside }: { label: string; aside?: string | undefined }) {
return (
@@ -69,12 +72,52 @@ export function WorkOrderReport({
onField: (key: string, value: string) => void;
onReply: (reply: WorkOrderReply) => void;
}) {
/*
* 验收单默认只读(要按「修改」才进编辑);**执行工单上那张可填的表就是表单本身** ——
* 它没有「修改」按钮,人打开就该能填。默认只读会让这条路直接走不通:
* 活动派了一张标着 editable 的需求表,人看到的是一张改不动的表(实测)。
*/
/** 人在可编辑表上改出来的行 —— 按块序号存,提交时并进回传载荷 */
const [tableEdits, setTableEdits] = useState<Readonly<Record<number, readonly (readonly string[])[]>>>({});
const [editing, setEditing] = useState(false);
/*
* **换一张工单要重算编辑态** —— `useState` 的初值只在首次挂载时算一次。
*
* 从前这里把「执行工单带可编辑表 → 默认进编辑态」写进 useState 的初值:
* 首次挂载那张卡若不是这种,`editing` 就此钉死为 false,之后翻页 / 换任务
* 组件并不重新挂载,于是那张明明标了 `editable: true` 的表**永远渲染成只读**。
* 实测:A1 那一单好的,同一个界面切到 A2 就一个输入框都没有,而载荷里两张表都是 editable。
*
* 渲染期对齐(与本仓 §9.2「前端零过程状态」同款,不用 effect):
* 记住这一张是谁,换了就按它自己的形态重算。
*/
const [seenOrder, setSeenOrder] = useState<string | undefined>(undefined);
if (seenOrder !== order.id) {
setSeenOrder(order.id);
setTableEdits({});
/*
* 可编辑与**是不是验收单无关**(2026-08-01 去掉 execution 那道限制)。
*
* 验收是这一单最重的一道门:人得能在**成果本身**上改,而不只是在旁边几个字段上改。
* 放行卡上那张绑了台账的成果表就是给他改的 —— 改完由基座写回草稿层、随放行合并。
* 从前写死只有执行单才进编辑态,于是验收页上那张表摆着却动不了:
* 「可修订报告」四个字成了摆设。
*/
setEditing((order.blocks ?? []).some((b) => b.t === 'table' && b.editable === true));
}
const isAcc = order.mode === 'acceptance';
const disabled = order.done !== undefined;
const fields = isAcc ? [] : order.fields;
/** 执行 = 空占位表单:值只来自人填(或办结留痕),不吃 AI 拟值 */
const val = (f: FormField) => values[f.key] ?? order.done?.values?.[f.key] ?? '';
/*
* **两种工单都有填报表单,只是来路不同**:
* · 执行 = 空占位表单,值只来自人填(AI 不替人办事);
* · 验收 = **AI 已填好、可编辑**(PRD:一张工单 → 卡组一「HTML 报告 · AI 已填好、可编辑」)。
*
* 从前这里写的是 `isAcc ? [] : order.fields` —— 验收工单的 fields 被整个丢掉:
* 「修改」按钮点了没有任何东西可改,人工修订也就永远落不下来(第 12 章③ 的一半)。
*/
const fields = order.fields;
/** 值的三层来路:人这次改的 > 办结留痕 > **AI 拟值**(只有验收单带) */
const val = (f: FormField) => values[f.key] ?? order.done?.values?.[f.key] ?? f.ai ?? '';
const ok = fields.every((f) => f.required !== true || val(f).trim() !== '');
const result = order.done?.result;
const rejected = result !== undefined && isRejection(result);
@@ -125,12 +168,12 @@ export function WorkOrderReport({
</span>
<span>
<i>派发</i>
<b className="jc-mono">{order.at.slice(5, 16).replace('T', ' ')}</b>
<b className="jc-mono">{atMinute(order.at)}</b>
</span>
{order.done !== undefined && (
<span>
<i>办结</i>
<b className="jc-mono">{order.done.at.slice(5, 16).replace('T', ' ')}</b>
<b className="jc-mono">{atMinute(order.done.at)}</b>
</span>
)}
</div>
@@ -159,17 +202,27 @@ export function WorkOrderReport({
{isAcc && order.criteria.length > 0 && (
<>
<Part label="验收判据" aside={`${order.criteria.filter((c) => c.pass).length}/${order.criteria.length} 达标`} />
{/*
裁量项三态与对话区那份同一套口径:裁成不满足 = AI 办过、办不到、交你定,
不是「活没干完」。两处措辞若分叉,同一条判据在两屏上会给出两种印象。
*/}
<ul className="jc-rp-crit">
{order.criteria.map((c, i) => (
<li key={i} className={c.pass ? 'ok' : 'no'}>
<span className={'jc-lv jc-lv-' + c.level}>{c.level}</span>
<span className="t">{c.label}</span>
<span className="r">
<Ico name={c.pass ? 'circle-check' : 'circle-x'} s={13} />
{c.pass ? '达标' : '未达标'}
</span>
</li>
))}
{order.criteria.map((c, i) => {
const judged = c.settled === true && !c.pass;
return (
<li key={i} className={c.pass ? 'ok' : judged ? 'judged' : 'no'}>
<span className={'jc-lv jc-lv-' + c.level}>{c.level}</span>
<span className="t">
{c.label}
{judged && c.rationale !== undefined && <em className="jc-crit-why">{c.rationale}</em>}
</span>
<span className="r">
<Ico name={c.pass ? 'circle-check' : judged ? 'circle-alert' : 'circle-x'} s={13} />
{c.pass ? '达标' : judged ? 'AI 判为不满足 · 交你定' : '未达标'}
</span>
</li>
);
})}
</ul>
</>
)}
@@ -201,7 +254,20 @@ export function WorkOrderReport({
{blocks.length > 0 && (
<div>
<Part label={html === undefined ? (isAcc ? '成果内容' : '数据与分析') : '结构化明细'} aside={srcTitle} />
<ViewBlocks blocks={blocks} editing={isAcc && editing && !disabled} />
{/*
* 可编辑与**是不是验收单无关** —— 它是块自己说了算的事(`editable: true`)。
*
* 从前这里写死 `isAcc &&`:于是执行工单上那张标了 editable 的表**永远只读**。
* A1 的「我去问客户」那条路正卡在这儿:活动派了一张可填的需求表,
* 人打开看到的却是一张改不动的表 —— 他问到了值也没地方填,这条路走不通。
* 验收单的判据表照旧只在 `editing` 时可改,那由块的 `editable` 与这里的
* `editing` 共同决定,不需要再拿单子的种类兜一层。
*/}
<ViewBlocks
blocks={blocks}
editing={editing && !disabled}
onTableChange={(i, rows) => setTableEdits((prev) => ({ ...prev, [i]: rows }))}
/>
</div>
)}
@@ -244,7 +310,7 @@ export function WorkOrderReport({
</b>
<span>
{disabled
? `已于 ${(order.done?.at ?? order.at).slice(5, 16).replace('T', ' ')} 办结 · 已记入工作日志`
? `已于 ${atMinute(order.done?.at ?? order.at)} 办结 · 已记入工作日志`
: isAcc
? editing
? '改完记得保存 —— 裁决在对话区的验收单上'
@@ -274,7 +340,23 @@ export function WorkOrderReport({
onReply({
mode: 'execution',
workOrderId: order.id,
values: Object.fromEntries(fields.map((f) => [f.key, val(f)])),
/*
* 回传 = 表单字段 + **人在可编辑表上改的行**。
*
* 表格那一份从前根本没交出去(只活在 ViewBlocks 的局部 state 里):
* 人填满 13 个格子提交,Agent 收到的是空的,于是又发一张同样的单 ——
* 人以为自己白填了一遍。表按块序号编键(`table:0`),值是整张表的行,
* Agent 拿它对着自己派出去的那张表逐行读回来。
*/
values: {
...Object.fromEntries(fields.map((f) => [f.key, val(f)])),
...Object.fromEntries(
Object.entries(tableEdits).map(([i, rows]) => [
`table:${i}`,
JSON.stringify([blocks[Number(i)]?.t === 'table' ? (blocks[Number(i)] as { cols: string[] }).cols : [], ...rows]),
]),
),
},
})
}
>