Archived
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:
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user