diff --git a/apps/web/src/features/desk-task/__tests__/pending-cards.test.ts b/apps/web/src/features/desk-task/__tests__/pending-cards.test.ts new file mode 100644 index 0000000..f39dbaa --- /dev/null +++ b/apps/web/src/features/desk-task/__tests__/pending-cards.test.ts @@ -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 = {}) { + 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 = {}) { + 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('客户档案'); + }); +}); diff --git a/apps/web/src/features/desk-task/internal/ViewRegion.tsx b/apps/web/src/features/desk-task/internal/ViewRegion.tsx index 44d85aa..8b02924 100644 --- a/apps/web/src/features/desk-task/internal/ViewRegion.tsx +++ b/apps/web/src/features/desk-task/internal/ViewRegion.tsx @@ -21,6 +21,7 @@ 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 { pendingCardsOf } from './pending-cards.ts'; import type { ViewEdits, ViewSave } from '@ui'; import { GuideCard, WorkOrderReport } from './work-order-report.tsx'; import { FileViewer } from './FileViewer.tsx'; @@ -173,9 +174,8 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi * 出现在这一页(它压根不是工单)。界面不撒谎 —— 这里改成读一眼 `detail.chat` * 里有没有未回的决策卡 / 录入卡,是就换一句指向对话区的实话。 */ - const pendingChatCard = detail.chat.some( - (m) => (m.k === 'decision' && m.chosen === undefined) || (m.k === 'entry' && m.done === undefined), - ); + const pending = pendingCardsOf(detail.chat); + const pendingChatCard = pending.decision || pending.entry; /* * 标记已读 / 未读 —— 乐观更新交给失效:标完服务端广播 task 触点, @@ -776,6 +776,22 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi * viewHtml 前面,陈旧的件只在「AI 这会儿不在算」时才配拿来顶上。 */ + ) : pending.entry ? ( + /* + * **录入卡在等人时,工作区摆占位页,不摆件**(2026-08-28 用户看柴浩那单 + * 「客户 123 不在客户档案里,请核对建档」定的)。建档那一步要人做的事全在 + * 对话区那张卡上;这时 `viewHtml` 里躺着的是**上一拍**推的那份 HTML 件, + * 与建档无关 —— 摆着只会让人以为要在那份工单上办点什么。 + * 这一支排在 viewHtml 之前、running 之后:AI 在算时照旧是推进场景。 + * 决策卡不走这一支 —— 人要决定的对象常常就是件上那份稿,件得留着给他看。 + */ + ) : detail.viewHtml !== undefined ? ( /* * **工作区只放那份件本身**(2026-08-01 用户定)。 diff --git a/apps/web/src/features/desk-task/internal/pending-cards.ts b/apps/web/src/features/desk-task/internal/pending-cards.ts new file mode 100644 index 0000000..d45cecc --- /dev/null +++ b/apps/web/src/features/desk-task/internal/pending-cards.ts @@ -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 }; +}