harness 与工作台同日修:数据工单确认即入库 · 叫了人不再自动推进(两道结构闸)· 审核卡 = 终局验收卡结构 · 验收清单人点达标 · 迟到守卫不丢放行裁决 · 画面跟着这一拍发出去的卡走 · 成果详情只翻页 · 占位居中 · 进度条

human-tool / ledger / loop-engine / release / task / interaction 六模块与 app 层(desk 路由、engines、consumers、
data-order-landing、case-stamp、reset-runs、seed-activities、容器与 server 装配),web 的 desk-task
(ViewRegion / chat-bubbles / review-face)与 app.css、ui-kit 图标;契约 card / manifest;
迁移 0034_task_seq 与迁移日志;api-report 快照;CLAUDE.md 快照、debt、活动开发规范、HTML 报告规范同步。
逐条口径见 CLAUDE.md 快照(2026-08-27 下午至晚)。另一会话做的,一直留在工作树没入库;与同批另两条提交互相依赖。

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-27 23:20:16 +08:00
co-authored by Claude Fable 5
parent 909b1fe4b7
commit 7b85a79323
79 changed files with 3903 additions and 1297 deletions
+18
View File
@@ -2002,6 +2002,17 @@
.proto-root .wf-card.jc-wc-fill > .jc-sa { flex: 1 1 auto; min-height: 0; }
/* 正文滚动视口:卡已经有 16/18 的内边距,正文不再重复留白(右侧留一点给自绘 thumb) */
.proto-root .wf-body { min-height: 0; padding-right: 4px; }
/*
工作区正文里的占位(AI 推进中 / 这单已收尾 / 还没有往来记录 / 手上没有待办 …)要**上下居中**
(2026-08-27 用户:「中央的几个占位的页面内容居中显示」)。
`.jc-empty` 早写了 `margin: auto`,可视口(`.wf-body` = 自绘滚动区的视口)是块级的 ——
`margin-top/bottom: auto` 只在 flex/grid 容器里才吃得开,于是占位全贴在顶边。
与 `.execution-panel.jc-panel-card:has(> .jc-empty)` 同一招:按内容挑,只有直接儿子是占位时
才把视口改成纵向 flex;件、日志、往来记录这些正文照旧从顶往下排。 */
.proto-root .wf-body:has(> .jc-empty),
.proto-root .wf-body:has(> .jc-working) { display: flex; flex-direction: column; }
.proto-root .wf-body > .jc-empty,
.proto-root .wf-body > .jc-working { margin: auto; flex: 0 0 auto; }
.proto-root .wf-body > .jc-mst-doc { padding: 0 0 6px; animation: rise var(--t-med) both; }
.proto-root .wf-body > .wf-log { padding-top: 2px; }
@@ -2440,6 +2451,13 @@
.proto-root .jc-fc-doc:hover > svg { transform: translateX(2px); color: var(--fc); }
/* 判据表(卡内紧凑版) */
.proto-root .jc-fc .jc-rp-crit.sm li { padding: 6px 11px; font-size: 11.5px; }
/* 人工验收清单:序号格 + 右侧「待确认 / 已确认」开关(人点的,不是系统判的)—— 待确认灰、已确认绿 */
.proto-root .jc-checklist .jc-ck-n { flex: none; width: 18px; height: 18px; border-radius: 6px; display: grid; place-items: center; font-size: 10px; font-weight: 800; font-family: var(--mono); color: var(--ink-3); background: var(--soft); border: 1px solid var(--line-soft); }
.proto-root .jc-checklist li.ok .jc-ck-n { color: #fff; background: var(--ok); border-color: var(--ok); }
.proto-root .jc-checklist li.pend .r { color: var(--ink-3); }
.proto-root .jc-checklist .jc-tg { cursor: pointer; font: inherit; font-size: 11px; font-weight: 800; border: 1px solid var(--line); background: var(--soft); border-radius: 999px; padding: 2px 9px; color: var(--ink-3); transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast); }
.proto-root .jc-checklist li.ok .jc-tg { color: var(--ok); border-color: #bfe6b4; background: var(--ok-soft); }
.proto-root .jc-checklist .jc-tg:hover { border-color: var(--fc); }
/*
* **作业指导书与验收判据同一套版面**(2026-08-02 用户定)。
* 从前指导书是一串松散的圆点(`.jc-steps`),判据是一摞带章的行 ——
@@ -0,0 +1,73 @@
/**
* 审核卡穿验收工单的衣服 —— 钮对选项、重做交什么(2026-08-27 用户:验收卡不许有两种样式)。
*/
import { describe, expect, it } from 'vitest';
import { cardIdSchema } from '@contracts';
import { approveReply, checklistMissing, redoReply, reviewChoices } from '../internal/review-face.ts';
const c1 = cardIdSchema.parse('c1');
const opts = [
{ value: 'approve', label: '通过', recommended: true },
{ value: 'reject', label: '驳回(不写具体意见)' },
];
describe('reviewChoices —— 两颗钮对哪两个选项', () => {
it('值叫 approve / reject 的直接对上', () => {
const c = reviewChoices(opts);
expect(c?.approve.value).toBe('approve');
expect(c?.reject?.value).toBe('reject');
});
it('没按这个名起的:推荐的 = 通过,其余第一个 = 驳回', () => {
const c = reviewChoices([{ value: 'no', label: '退回' }, { value: 'ok', label: '可以', recommended: true }]);
expect(c?.approve.value).toBe('ok');
expect(c?.reject?.value).toBe('no');
});
it('只有一个选项:通过有了、驳回没有;一个都没有对不出面孔', () => {
const c = reviewChoices([{ value: 'approve', label: '通过' }]);
expect(c?.approve.value).toBe('approve');
expect(c?.reject).toBeUndefined();
expect(reviewChoices([])).toBeUndefined();
});
});
describe('redoReply / approveReply —— 交什么', () => {
const c = reviewChoices(opts)!;
it('写了理由且允许自定义 → custom:<理由>,note 带原文(基座按字面点名对行)', () => {
expect(redoReply({ id: c1, allowCustom: true }, c, ' 测试111 建议动作改成拜访CTO ')).toEqual({
k: 'decision',
cardId: c1,
value: 'custom:测试111 建议动作改成拜访CTO',
note: '测试111 建议动作改成拜访CTO',
});
});
it('没写理由 → 卡上的驳回项;不许自定义时写了理由也走驳回项、理由进 note', () => {
expect(redoReply({ id: c1, allowCustom: true }, c, '')).toEqual({ k: 'decision', cardId: c1, value: 'reject' });
expect(redoReply({ id: c1, allowCustom: false }, c, '再看看')).toEqual({ k: 'decision', cardId: c1, value: 'reject', note: '再看看' });
});
it('既没驳回项又不许自定义 → 交不了(钮该灰着)', () => {
const only = reviewChoices([{ value: 'approve', label: '通过' }])!;
expect(redoReply({ id: c1, allowCustom: false }, only, '')).toBeUndefined();
expect(redoReply({ id: c1, allowCustom: false }, only, '理由')).toBeUndefined();
// 允许自定义就有路
expect(redoReply({ id: c1, allowCustom: true }, only, '理由')).toMatchObject({ value: 'custom:理由' });
});
it('确认通过交 approve 那一项;带上点成「达标」的清单条目', () => {
expect(approveReply({ id: c1 }, c)).toEqual({ k: 'decision', cardId: c1, value: 'approve' });
expect(approveReply({ id: c1 }, c, ['核对甲'])).toEqual({ k: 'decision', cardId: c1, value: 'approve', checked: ['核对甲'] });
});
});
describe('checklistMissing —— 清单点全了才能按「确认通过」', () => {
it('没点的那几条原样列出来;点全为空;没有清单也为空', () => {
expect(checklistMissing(['甲', '乙'], new Set(['甲']))).toEqual(['乙']);
expect(checklistMissing(['甲', '乙'], new Set(['甲', '乙']))).toEqual([]);
expect(checklistMissing(undefined, new Set())).toEqual([]);
});
});
@@ -270,7 +270,6 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
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 });
@@ -301,6 +300,15 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
setPage(Math.max(0, openList.findIndex((o) => o.id === locate.orderId)));
setDocPage(0);
}
} else if (locate.orderId !== undefined && locate.tab === undefined) {
/*
* 找不到那张单 —— 多半是审核卡(决策卡穿验收工单的衣服,2026-08-27)上的「验收工单」钮:
* 它没有工单行,件却在待处理那一屏(活动按剧本 `view` 推的)。至少把那一屏翻出来,
* 别让人点了没反应。
*/
setOpenFile(undefined);
setView('open');
setDocPage(0);
}
}
@@ -440,35 +448,16 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
/*
* 成果详情右上角摆两组翻页 + 返回列表:
* · **翻页**(2026-08-21):同一份带多页的件归了档,翻页的能力不该不见了;
* · **翻份**(2026-08-23 用户:「工作成果点进一个工作成果也需要这个东西」)——
* 从前点进详情只有「返回列表」:想看下一份得先退出去再点一次,
* 而一单常有三五份定稿(执行单快照 / 验收单快照 / 决策卡快照),对着看是常事。
* 换份时页码归零 —— 上一份翻到第 3 页,下一份可能只有一页。
* 各自只在多于一个时现身;都只有一个就照旧只留「返回列表」。
* · 08-23 曾在这里加过「翻份」(在几份定稿之间切换),2026-08-27 用户定撤掉:
* 右上角那对箭头的意思是**这一份件里翻页**;换一份成果回列表点。
*/
<>
{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}
/>
)}
{/*
* **右上角只翻页,不翻份**(2026-08-27 用户:「这个东西不是让你在整个工作成果里面切换,
* 是让你在单个工单有多个页面的情况下切换的」)。08-23 加的「翻份」撤掉:换一份成果回列表点,
* 右上角那对箭头只在这一份件里翻页 —— 与待处理那一屏同一个意思。一页也照旧摆 1/1(定得住)。
*/}
<Pager 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>
@@ -20,6 +20,8 @@ import type { FormField, AiTag, ChatCardReply, ChatMessage, WorkOrder, WorkOrder
import { EVENT_TYPE_LABEL, isRejection } from '@contracts';
import { Ico } from '@web-shared/Ico.tsx';
import { Btn } from '@web-shared/Btn.tsx';
import { approveReply, checklistMissing, redoReply, reviewChoices } from './review-face.ts';
import type { DecisionMessage } from './review-face.ts';
import { ChatAvatar } from '@web-shared/ChatAvatar.tsx';
import { atClock } from '@web-shared/at.ts';
@@ -506,14 +508,20 @@ function Msg({
<p className="jc-content">{m.ask}</p>
{/*
* 审核卡(契约 `variant:'review'`)摆成验收工单的样子(2026-08-27 用户:「验收工单弹出的是
* 需要你定,明显不对」):平台的验收工单是终局,中间审核只能靠决策卡循环 —— 面孔换成
* 「工单 · 验收」,选项就是通过 / 驳回,机制不变。
* 需要你定,明显不对」):平台的验收工单是终局,中间审核只能靠决策卡循环 —— 机制不变,面孔换。
* **面孔要与终局验收卡一模一样**(同日用户第二次指出:「验收卡怎么有两种样式」):
* 第一版只换了头上的字、身子还是 A/B 选项列表 —— 人扫两张「验收」卡,落点不一致。
* 现在照 `AcceptForm` 那一套摆:验收工单入口 + 理由框 + [重做] … [确认通过](见 `ReviewForm`);
* 选项对不出面孔(一个选项都没有)才退回普通决策卡。
*/}
<div className={'jc-fc ' + (m.variant === 'review' ? 't-acc' : 't-dec')}>
{m.variant === 'review' && reviewChoices(m.options) !== undefined ? (
<ReviewForm m={m} onReplyCard={onReplyCard} onLocate={onLocate} chosenLabel={chosenLabel} />
) : (
<div className="jc-fc t-dec">
<div className="jc-fc-h">
<Ico name={m.variant === 'review' ? 'shield-check' : 'git-branch'} s={13} />
<b>{m.variant === 'review' ? '工单 · 验收' : '需要你定'}</b>
<em>{m.variant === 'review' ? '审核' : `${m.options.length + (m.allowCustom ? 1 : 0)} 选 1`}</em>
<Ico name="git-branch" s={13} />
<b>需要你定</b>
<em>{`${m.options.length + (m.allowCustom ? 1 : 0)} 选 1`}</em>
</div>
<div className="jc-fc-b">
<Kv items={m.basis} />
@@ -567,12 +575,176 @@ function Msg({
)}
</div>
</div>
)}
</div>
</Ag>
);
}
/*
* 审核卡的面孔 = 终局验收卡的面孔(`AcceptForm`):同一个头(请你验收)、同一个入口钮(验收工单 →
* 工作区那份件)、同一个理由框、同一对钮(重做 … 确认通过)。差别只在交出去的是决策卡的回传:
* 通过 = approve 那一项;重做 = 写了理由走 `custom:<理由>`(基座按字面点名对行),没写走驳回项。
* 判断在 `review-face.ts`(纯函数,有测);这里只摆。
*/
function ReviewForm({
m,
onReplyCard,
onLocate,
chosenLabel,
}: {
m: DecisionMessage;
onReplyCard: (reply: ChatCardReply) => void;
onLocate: (id: string) => void;
chosenLabel: string;
}) {
const busy = useReplyBusy();
const [why, setWhy] = useState('');
const choices = reviewChoices(m.options)!;
const redo = redoReply(m, choices, why);
const decided = m.chosen !== undefined;
const approved = decided && m.chosen?.value === choices.approve.value;
/*
* 判据那一摞与终局验收卡**同一摞**(服务端读时现算、L5 不上卡;见 `decisionCardSchema.criteria`)——
* 卡上没有「参考信息」这一段(2026-08-27 用户:「验收卡的样式和结构,没有参考信息这个东西,变成 L1、L2」)。
* 答过的卡服务端不再带判据,那一栏就不摆。
*/
/*
* 人工验收清单(与终局验收卡同一件事):一条一枚「达标 / 不达标」开关,全部达标才让按「确认通过」。
* 答过的卡摆当时点成达标的那几条(`chosen.checked`),只读。
*/
const checklist = m.checklist ?? [];
const [checked, setChecked] = useState<ReadonlySet<string>>(() => new Set());
const toggle = (item: string) =>
setChecked((prev) => {
const next = new Set(prev);
if (next.has(item)) next.delete(item);
else next.add(item);
return next;
});
const shown = decided ? new Set(m.chosen?.checked ?? []) : checked;
const unchecked = checklistMissing(checklist, shown);
/*
* 答过之后卡上说的是**验收的话**,不是选项的原标签(2026-08-27 用户实测:写了重做意见,
* 卡上却是「你选了:驳回(不写具体意见)」—— 那是模型给选项起的名,人看见的应是他自己写的意见)。
*/
const verdictText = ((): string => {
const c = m.chosen;
if (c === undefined) return chosenLabel;
if (c.value === choices.approve.value) return '确认通过';
const note = (c.note ?? '').trim();
if (c.value.startsWith('custom:')) return `重做:${note !== '' ? note : c.value.slice(7)}`;
return note !== '' ? `重做:${note}` : '重做(未写意见)';
})();
return (
<div className="jc-fc t-acc">
<div className="jc-fc-h">
<Ico name="shield-check" s={13} />
<b>请你验收</b>
{decided ? (
<em className={approved ? 'ok' : 'bad'}>{approved ? '已通过' : '已退回'}</em>
) : checklist.length > 0 ? (
<em className={unchecked.length === 0 ? 'ok' : 'bad'}>
已确认 {checklist.length - unchecked.length}/{checklist.length}
</em>
) : (
<em>审核</em>
)}
</div>
<div className="jc-fc-b">
{/* 与终局验收卡同一个入口:件在工作区(这一步推的那一屏),点它就跳过去 */}
<button className="jc-fc-doc" onClick={() => onLocate(m.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>
{checklist.length > 0 && <Checklist items={checklist} checked={shown} onToggle={toggle} readonly={decided} />}
{decided ? (
<p className="jc-chosen">
<Ico name={approved ? 'circle-check' : 'circle-alert'} s={14} /> 你的裁决:
<b>{verdictText}</b>
<code className="jc-mono">{hm(m.chosen?.at ?? m.at)}</code>
</p>
) : (
<textarea
className="jc-note"
placeholder="要重做?写明原因(也可以在下面对话里聊)"
value={why}
onChange={(e) => setWhy(e.target.value)}
/>
)}
</div>
{!decided && (
<div className="jc-fc-ft">
<Btn className="jc-btn jc-btn-bad" disabled={redo === undefined} busy={busy} onClick={() => redo !== undefined && onReplyCard(redo)}>
重做
</Btn>
<span className="jc-fc-sp" />
<Btn
className="jc-btn jc-btn-ok"
busy={busy}
disabled={unchecked.length > 0}
title={unchecked.length > 0 ? `还有 ${unchecked.length} 条待确认` : undefined}
onClick={() => onReplyCard(approveReply(m, choices, checklist.length === 0 ? undefined : [...checked]))}
>
<Ico name="circle-check" s={14} /> 确认通过
</Btn>
</div>
)}
</div>
);
}
/* 参考信息:表单卡内的结构化 kv 表 */
/*
* **人工验收清单**(2026-08-27 用户定):人核这份工单要走的步骤,每条一枚「达标 / 不达标」开关。
* 版面与判据表同一套(`jc-rp-crit`),差别只在右边那一格是人点的,不是系统判的。
*/
function Checklist({
items,
checked,
onToggle,
readonly = false,
}: {
items: ReadonlyArray<string>;
checked: ReadonlySet<string>;
onToggle?: (item: string) => void;
readonly?: boolean;
}) {
return (
<ul className="jc-rp-crit sm jc-checklist">
{items.map((item, i) => {
const ok = checked.has(item);
// 两态:待确认(灰,默认)↔ 已确认(绿)—— 2026-08-27 用户定;不叫「不达标」,没点过不是判了不合格
const face = (
<>
<Ico name={ok ? 'circle-check' : 'circle'} s={12} />
{ok ? '已确认' : '待确认'}
</>
);
return (
<li key={i} className={ok ? 'ok' : 'pend'}>
<span className="jc-ck-n">{i + 1}</span>
<span className="t">{item}</span>
{readonly ? (
<span className="r">{face}</span>
) : (
<button type="button" className="r jc-tg" aria-pressed={ok} onClick={() => onToggle?.(item)}>
{face}
</button>
)}
</li>
);
})}
</ul>
);
}
function Kv({ items }: { items: ReadonlyArray<{ k: string; v: string }> }) {
return (
<div className="jc-fc-kv">
@@ -959,15 +1131,36 @@ function AcceptForm({
const [port, setPort] = useState<string | undefined>(undefined);
const choice = order.portChoice;
const needChoice = choice !== undefined;
/*
* **人工验收清单**(2026-08-27 用户定):卡上的条目是人核这份工单要走的步骤,不是系统判的判据。
* 一条一枚「达标 / 不达标」开关,全部点成达标才让按「确认通过」;点过的条目随回传留痕(`checked`)。
* 没声明清单的老卡退回系统判据那一摞。
*/
const checklist = order.checklist ?? [];
const [checked, setChecked] = useState<ReadonlySet<string>>(() => new Set());
const toggle = (item: string) =>
setChecked((prev) => {
const next = new Set(prev);
if (next.has(item)) next.delete(item);
else next.add(item);
return next;
});
const unchecked = checklistMissing(checklist, checked);
const passN = order.criteria.filter((c) => c.pass).length;
return (
<div className="jc-fc t-acc">
<div className="jc-fc-h">
<Ico name="shield-check" s={13} />
<b>请你验收</b>
<em className={passN === order.criteria.length ? 'ok' : 'bad'}>
{passN}/{order.criteria.length} 达标
</em>
{checklist.length > 0 ? (
<em className={unchecked.length === 0 ? 'ok' : 'bad'}>
已确认 {checklist.length - unchecked.length}/{checklist.length}
</em>
) : (
<em className={passN === order.criteria.length ? 'ok' : 'bad'}>
{passN}/{order.criteria.length} 达标
</em>
)}
</div>
<div className="jc-fc-b">
<button className="jc-fc-doc" onClick={() => onLocate(order.id)}>
@@ -981,8 +1174,8 @@ function AcceptForm({
</span>
<Ico name="chevron-right" s={14} />
</button>
{/* 与办结卡同一份 —— 两处各写一遍,三态迟早只在一处生效 */}
<Crit list={order.criteria} />
{/* 有人工验收清单就摆清单(人点);没有的老卡退回系统判据 —— 与办结卡同一份 */}
{checklist.length > 0 ? <Checklist items={checklist} checked={checked} onToggle={toggle} /> : <Crit list={order.criteria} />}
{order.actions !== undefined && order.actions.length > 0 && (
<Kv
items={order.actions.map((a) => ({
@@ -1066,9 +1259,15 @@ function AcceptForm({
<span className="jc-fc-sp" />
<Btn
className="jc-btn jc-btn-ok"
disabled={needChoice && port === undefined}
disabled={(needChoice && port === undefined) || unchecked.length > 0}
busy={busy}
title={needChoice && port === undefined ? `先在上面选一个「${choice.group}」` : undefined}
title={
unchecked.length > 0
? `还有 ${unchecked.length} 条待确认`
: needChoice && port === undefined
? `先在上面选一个「${choice.group}」`
: undefined
}
onClick={() =>
onReplyOrder({
mode: 'acceptance',
@@ -1076,6 +1275,8 @@ function AcceptForm({
verdict: 'pass',
// 人在报告上改过的值随放行落地;一个字没改就是空的(= 全盘采信 AI 拟值)
values: { ...(orderValues ?? {}) },
// 人工验收清单里点成「达标」的条目随回传留痕;服务端按它把关
...(checklist.length === 0 ? {} : { checked: [...checked] }),
/*
* 恰选一口 —— **算出来的那一口也要交回去**。
*
@@ -0,0 +1,57 @@
/**
* 审核卡(决策卡的 `variant:'review'`)穿验收工单的衣服 —— 两颗钮对应哪两个选项、
* 「重做」按下去交什么(2026-08-27 用户:「验收卡怎么有两种样式,统一都使用评审放行与
* 落账确认这一步派出的这个验收卡的样式」)。
*
* 机制不变:它仍是一张决策卡,回传仍是 `{k:'decision', value}`。变的只是面孔 ——
* 与终局验收卡同一套:「验收工单」入口 + 理由框 + [重做] … [确认通过]。
* 纯函数,零 React:选项怎么对到钮上是个判断,判断要能钉测。
*/
import type { ChatCardReply, ChatMessage } from '@contracts';
export type DecisionMessage = Extract<ChatMessage, { k: 'decision' }>;
type Option = DecisionMessage['options'][number];
export type ReviewChoices = {
/** 「确认通过」交的选项 */
readonly approve: Option;
/** 「重做」交的选项(理由框空着时);卡上没有这一项就只能带理由走 custom */
readonly reject: Option | undefined;
};
/**
* 选项对到钮上:值叫 `approve` / `reject` 的优先(说明书里三个 value 只有这两种加 custom);
* 没按这个名起的,退回「推荐的那个 = 通过,其余第一个 = 驳回」。
* 一个选项都没有的卡对不出面孔 —— 回 undefined,调用方退回普通决策卡的样子。
*/
export function reviewChoices(options: ReadonlyArray<Option>): ReviewChoices | undefined {
if (options.length === 0) return undefined;
const approve = options.find((o) => o.value === 'approve') ?? options.find((o) => o.recommended === true) ?? options[0]!;
const reject = options.find((o) => o.value === 'reject') ?? options.find((o) => o !== approve);
return { approve, reject };
}
/**
* 「重做」交什么:写了理由且卡允许自定义 → `custom:<理由>`(基座按字面点名对行,见 note-anchors);
* 没写理由 → 卡上的驳回项(「驳回(不写具体意见)」正是为它留的);
* 两样都没有 → 交不了(钮该灰着)。
*/
export function redoReply(m: Pick<DecisionMessage, 'id' | 'allowCustom'>, choices: ReviewChoices, why: string): ChatCardReply | undefined {
const txt = why.trim();
if (txt !== '' && m.allowCustom) return { k: 'decision', cardId: m.id, value: `custom:${txt}`, note: txt };
if (choices.reject !== undefined) return { k: 'decision', cardId: m.id, value: choices.reject.value, ...(txt === '' ? {} : { note: txt }) };
return undefined;
}
/** 「确认通过」交什么 —— 带上人工验收清单里点成「达标」的那几条(服务端按它把关) */
export function approveReply(m: Pick<DecisionMessage, 'id'>, choices: ReviewChoices, checked?: ReadonlyArray<string>): ChatCardReply {
return { k: 'decision', cardId: m.id, value: choices.approve.value, ...(checked === undefined ? {} : { checked: [...checked] }) };
}
/**
* 人工验收清单还有哪几条没点成「达标」—— 空 = 可以按「确认通过」。
* 与服务端 `checklistMissing` 同一口径(2026-08-27 用户定:只有人把这些判据都点成达标了,才能点击确认通过)。
*/
export function checklistMissing(checklist: ReadonlyArray<string> | undefined, checked: ReadonlySet<string>): string[] {
return (checklist ?? []).filter((item) => !checked.has(item));
}