fix(web): 录入卡在等人时,工作区摆占位页,不摆件

建档那一步(「客户 123 不在客户档案里,请核对建档」)要人做的事全在对话区那张录入卡上,
而工作区里躺着的是**上一拍**推的 HTML 件 —— 摆着只会让人以为要在那份工单上办点什么。
`pendingCardsOf`(纯函数,5 测)认出未确认的录入卡 → `ViewRegion` 在 running 之后、viewHtml 之前
插一支占位 `EmptyState`(点名那张表的人读名,指向对话区)。
决策卡**不走**这一支 —— 人要决定的对象常常就是件上那份稿,件得留着给他看。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
This commit is contained in:
xcs
2026-08-28 01:13:07 +08:00
co-authored by Claude Fable 5
parent 282584d26e
commit e3479e0b9b
3 changed files with 122 additions and 3 deletions
@@ -0,0 +1,68 @@
/**
* 工作区看对话区的脸色 —— 录入卡在等人时摆占位页,不摆件(2026-08-28 用户定)。
*/
import { describe, expect, it } from 'vitest';
import { dataEntryCardSchema, decisionCardSchema } from '@contracts';
import type { TaskDetail } from '@contracts';
import { pendingCardsOf } from '../internal/pending-cards.ts';
const AT = '2026-08-28T00:55:30.054Z';
function entry(id: string, extra: Record<string, unknown> = {}) {
return dataEntryCardSchema.parse({
k: 'entry',
id,
at: AT,
ask: '客户 123 不在客户档案里,请核对建档信息后确认',
ledger: 'customer.profile',
ledgerLabel: '客户档案',
key: { customer_code: '123' },
fields: [],
...extra,
});
}
function decision(id: string, extra: Record<string, unknown> = {}) {
return decisionCardSchema.parse({
k: 'decision',
id,
at: AT,
ask: '经营计划是否通过?',
basis: [{ k: '依据', v: '计划表 4 行' }],
options: [
{ value: 'pass', label: '通过' },
{ value: 'reject', label: '驳回' },
],
...extra,
});
}
const chat = (...items: TaskDetail['chat']): TaskDetail['chat'] => items;
describe('pendingCardsOf', () => {
it('未确认的录入卡在场 → entry,并带上那张表的人读名', () => {
expect(pendingCardsOf(chat(entry('c-1')))).toEqual({ decision: false, entry: true, entryLedger: '客户档案' });
});
it('录入卡已确认(done 落了)→ 不再算在等', () => {
const done = { at: AT, by: 'S-1', values: { name: '测试公司' } };
expect(pendingCardsOf(chat(entry('c-1', { done })))).toEqual({ decision: false, entry: false });
});
it('登记里没有人读名时退回对象地址 —— 宁可笼统,不可编', () => {
expect(pendingCardsOf(chat(entry('c-1', { ledgerLabel: undefined }))).entryLedger).toBe('customer.profile');
});
it('未选的决策卡 → decision;它不触发占位页(人要决定的常常就是件上那份稿)', () => {
expect(pendingCardsOf(chat(decision('d-1')))).toEqual({ decision: true, entry: false });
expect(pendingCardsOf(chat(decision('d-1', { chosen: { at: AT, by: 'S-1', value: 'pass' } })))).toEqual({
decision: false,
entry: false,
});
});
it('两张未回的录入卡:点名最早那一张', () => {
const later = entry('c-2', { at: '2026-08-28T01:00:00.000Z', ledgerLabel: '联系人档案' });
expect(pendingCardsOf(chat(entry('c-1'), later)).entryLedger).toBe('客户档案');
});
});
@@ -21,6 +21,7 @@ import ScrollArea from '@web-shared/ScrollArea.tsx';
import { EmptyState } from '@web-shared/desk/module-kit.tsx'; import { EmptyState } from '@web-shared/desk/module-kit.tsx';
import { ViewBlocks, ViewContainer } from '@ui'; import { ViewBlocks, ViewContainer } from '@ui';
import { pagerFace, pagerText } from './pager.ts'; import { pagerFace, pagerText } from './pager.ts';
import { pendingCardsOf } from './pending-cards.ts';
import type { ViewEdits, ViewSave } from '@ui'; import type { ViewEdits, ViewSave } from '@ui';
import { GuideCard, WorkOrderReport } from './work-order-report.tsx'; import { GuideCard, WorkOrderReport } from './work-order-report.tsx';
import { FileViewer } from './FileViewer.tsx'; import { FileViewer } from './FileViewer.tsx';
@@ -173,9 +174,8 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
* 出现在这一页(它压根不是工单)。界面不撒谎 —— 这里改成读一眼 `detail.chat` * 出现在这一页(它压根不是工单)。界面不撒谎 —— 这里改成读一眼 `detail.chat`
* 里有没有未回的决策卡 / 录入卡,是就换一句指向对话区的实话。 * 里有没有未回的决策卡 / 录入卡,是就换一句指向对话区的实话。
*/ */
const pendingChatCard = detail.chat.some( const pending = pendingCardsOf(detail.chat);
(m) => (m.k === 'decision' && m.chosen === undefined) || (m.k === 'entry' && m.done === undefined), const pendingChatCard = pending.decision || pending.entry;
);
/* /*
* 标记已读 / 未读 —— 乐观更新交给失效:标完服务端广播 task 触点, * 标记已读 / 未读 —— 乐观更新交给失效:标完服务端广播 task 触点,
@@ -776,6 +776,22 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
* viewHtml 前面,陈旧的件只在「AI 这会儿不在算」时才配拿来顶上。 * viewHtml 前面,陈旧的件只在「AI 这会儿不在算」时才配拿来顶上。
*/ */
<WorkingScene doneN={doneList.length} /> <WorkingScene doneN={doneList.length} />
) : pending.entry ? (
/*
* **录入卡在等人时,工作区摆占位页,不摆件**(2026-08-28 用户看柴浩那单
* 「客户 123 不在客户档案里,请核对建档」定的)。建档那一步要人做的事全在
* 对话区那张卡上;这时 `viewHtml` 里躺着的是**上一拍**推的那份 HTML 件,
* 与建档无关 —— 摆着只会让人以为要在那份工单上办点什么。
* 这一支排在 viewHtml 之前、running 之后:AI 在算时照旧是推进场景。
* 决策卡不走这一支 —— 人要决定的对象常常就是件上那份稿,件得留着给他看。
*/
<EmptyState
ico="users"
tone="brand"
title="先把这份档案建起来"
desc={`AI 认不出这位客户,已在右边对话区发了一张「${pending.entryLedger ?? '档案'}」录入卡。核对、确认后档案落进台账,AI 接着往下推进 —— 这一步工作区没有要你看的件。`}
hint="录入在对话区那张卡上,不在这里"
/>
) : detail.viewHtml !== undefined ? ( ) : detail.viewHtml !== undefined ? (
/* /*
* **工作区只放那份件本身**(2026-08-01 用户定)。 * **工作区只放那份件本身**(2026-08-01 用户定)。
@@ -0,0 +1,35 @@
/**
* 对话区里**还在等人回**的卡 —— 工作区据此决定摆什么。
*
* decide / entry 发的是对话区的卡(chat_card),不进 `work_order`;它们挂起时
* `runtime` 同样是 `awaiting-event`,与「等外部回音」一个值。工作区要分清这两条岔,
* 只能读一眼 `detail.chat`。
*
* **录入卡在场时工作区摆占位页,不摆件**(2026-08-28 用户定):建档那一步要人做的事
* 全在对话区那张卡上,工作区里上一拍留下的那份 HTML 件与这件事无关 —— 摆着只会让人
* 以为「要在这份工单上办点什么」。决策卡不同:人要决定的对象常常就是件上那份计划 / 审批稿,
* 件得留着给他看。
*
* 纯函数,抽出来是为了可测:这段判断写在 JSX 里改一次漏一处(`pendingChatCard` 从前就是内联的)。
*/
import type { TaskDetail } from '@contracts';
export type PendingCards = {
/** 有未选的决策卡 */
readonly decision: boolean;
/** 有未确认的数据录入卡 */
readonly entry: boolean;
/** 那张录入卡要落的表的人读名(卡面同一个名字);没有录入卡时缺席 */
readonly entryLedger?: string;
};
export function pendingCardsOf(chat: TaskDetail['chat']): PendingCards {
let decision = false;
let entryLedger: string | undefined;
for (const m of chat) {
if (m.k === 'decision' && m.chosen === undefined) decision = true;
// 多张未回的录入卡,占位页点名最早那一张(对话区也是按时间排的,人先看到它)
if (m.k === 'entry' && m.done === undefined && entryLedger === undefined) entryLedger = m.ledgerLabel ?? m.ledger;
}
return entryLedger === undefined ? { decision, entry: false } : { decision, entry: true, entryLedger };
}