Archived
手改是**兜 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
471 lines
16 KiB
TypeScript
471 lines
16 KiB
TypeScript
/**
|
|
* 视图块渲染 —— 无业务语义的展示原语(编码规范 §2 packages/ui-kit)。
|
|
*
|
|
* DOM 与类名逐字取自原型 `_components/ViewRegion.tsx` 的 `Block`(2026-07-26 定稿):
|
|
* 皮肤(`proto.vendor.css` / `app.css`)是原型的原样,类名对不上就等于没样式 ——
|
|
* 所以这里**不新造类名**,块的骨架、层级、元素种类一律照搬。
|
|
*/
|
|
import { useState } from 'react';
|
|
import type { FileDoc, Tone, ViewBlock } from '@contracts';
|
|
import { Ico } from './Ico.tsx';
|
|
|
|
/** 语义色 → 皮肤色位(原型口径:danger 走 bad,muted 走 mute) */
|
|
const TONE_CLASS: Readonly<Record<Tone, string>> = {
|
|
ok: 'ok',
|
|
warn: 'warn',
|
|
danger: 'bad',
|
|
brand: 'brand',
|
|
muted: 'mute',
|
|
};
|
|
|
|
export type ViewBlocksProps = {
|
|
readonly blocks: readonly ViewBlock[];
|
|
/** 报告级「修改」态:表 / 邮件就地进入编辑(存与弃由外层报告统一管) */
|
|
readonly editing?: boolean | undefined;
|
|
readonly onOpenFile?: ((file: FileDoc) => void) | undefined;
|
|
/**
|
|
* 表被改了 —— **改完的行要交出去**(第几块、整张表的行)。
|
|
*
|
|
* 从前没有这个口子:可编辑表的修改只活在 `TableBlock` 自己的 `setRows` 里,
|
|
* 人填完提交,回传载荷里一个格子都没有。实测后果最难看 ——
|
|
* 活动派了一张「空的地方标红待填写」的执行单,人老老实实填了 13 个格子、提交,
|
|
* Agent 收到的回传是空的,于是认为没人答,**又发了一张一模一样的单**。
|
|
* 人以为自己白填了一遍,而系统看起来在原地打转。
|
|
*/
|
|
readonly onTableChange?: ((index: number, rows: readonly (readonly string[])[]) => void) | undefined;
|
|
};
|
|
|
|
export function ViewBlocks({ blocks, editing = false, onOpenFile, onTableChange }: ViewBlocksProps) {
|
|
return (
|
|
<>
|
|
{blocks.map((block, i) => (
|
|
<Block
|
|
key={i}
|
|
b={block}
|
|
editing={editing}
|
|
onOpenFile={onOpenFile}
|
|
{...(onTableChange === undefined ? {} : { onTableChange: (rows: readonly (readonly string[])[]) => onTableChange(i, rows) })}
|
|
/>
|
|
))}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function Block({
|
|
b,
|
|
editing,
|
|
onOpenFile,
|
|
onTableChange,
|
|
}: {
|
|
b: ViewBlock;
|
|
editing: boolean;
|
|
onOpenFile?: ((file: FileDoc) => void) | undefined;
|
|
onTableChange?: ((rows: readonly (readonly string[])[]) => void) | undefined;
|
|
}) {
|
|
switch (b.t) {
|
|
case 'callout':
|
|
return (
|
|
<div className={'jc-callout t-' + TONE_CLASS[b.tone]}>
|
|
<Ico name="info" s={15} />
|
|
<p>{b.text}</p>
|
|
</div>
|
|
);
|
|
|
|
case 'kv':
|
|
return (
|
|
<Section title={b.title}>
|
|
<div className="jc-kv">
|
|
{b.items.map((it, i) => (
|
|
<div key={i} className="jc-kv-i">
|
|
<i>{it.k}</i>
|
|
<b>{it.v}</b>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Section>
|
|
);
|
|
|
|
case 'badges':
|
|
return (
|
|
<Section title={b.title}>
|
|
<div className="jc-badges">
|
|
{b.items.map((it, i) => (
|
|
<span key={i} className={'jc-badge' + (it.on ? ' on' : '')}>
|
|
<Ico name={it.on ? 'circle-check' : 'circle-x'} s={14} />
|
|
{it.label}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</Section>
|
|
);
|
|
|
|
case 'table':
|
|
return <TableBlock b={b} editing={editing} {...(onTableChange === undefined ? {} : { onChange: onTableChange })} />;
|
|
|
|
case 'bars':
|
|
return (
|
|
<Section title={b.title}>
|
|
<div className="jc-bars">
|
|
{b.items.map((it, i) => {
|
|
const max = b.max ?? Math.max(...b.items.map((x) => x.value));
|
|
return (
|
|
<div key={i} className="jc-bar-row">
|
|
<span className="jc-bar-l">{it.label}</span>
|
|
<div className="jc-bar-t">
|
|
<i
|
|
className={'t-' + TONE_CLASS[it.tone ?? 'brand']}
|
|
style={{ width: Math.min(100, (it.value / max) * 100) + '%' }}
|
|
/>
|
|
{b.redline !== undefined && (
|
|
<em className="jc-redline" style={{ left: Math.min(100, (b.redline / max) * 100) + '%' }} />
|
|
)}
|
|
</div>
|
|
<span className="jc-bar-v jc-mono">{it.text ?? it.value}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</Section>
|
|
);
|
|
|
|
case 'donut':
|
|
return <Donut title={b.title} unit={b.unit} items={b.items} />;
|
|
|
|
case 'mail':
|
|
return <MailBlock b={b} editing={editing} onOpenFile={onOpenFile} />;
|
|
|
|
case 'cards':
|
|
return (
|
|
<Section title={b.title}>
|
|
<div className="jc-rcards">
|
|
{b.items.map((it, i) => (
|
|
<div key={i} className={'jc-rcard t-' + TONE_CLASS[it.tone ?? 'brand']}>
|
|
<div className="jc-rcard-h">
|
|
<span className="jc-rcard-tag">
|
|
<Ico name={it.icon ?? 'circle-dot'} s={13} />
|
|
{it.tag}
|
|
</span>
|
|
{it.code !== undefined && <code className="jc-mono">{it.code}</code>}
|
|
</div>
|
|
{it.swap === undefined ? (
|
|
<b className="jc-rcard-t">{it.title}</b>
|
|
) : (
|
|
<div className="jc-swap">
|
|
<s>{it.swap.from}</s>
|
|
<Ico name="arrow-right" s={14} />
|
|
<b>{it.swap.to}</b>
|
|
{it.swap.hint !== undefined && <em>{it.swap.hint}</em>}
|
|
</div>
|
|
)}
|
|
<div className="jc-rcard-big">
|
|
<b>{it.big}</b>
|
|
{it.bigLabel !== undefined && <span>{it.bigLabel}</span>}
|
|
</div>
|
|
{it.meta !== undefined && it.meta.length > 0 && (
|
|
<div className="jc-rcard-meta">
|
|
{it.meta.map((m, k) => (
|
|
<span key={k}>
|
|
<i>{m.k}</i>
|
|
{m.v}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
{it.note !== undefined && <p className="jc-rcard-note">{it.note}</p>}
|
|
{(it.from !== undefined || it.time !== undefined) && (
|
|
<div className="jc-rcard-sign">
|
|
{it.from}
|
|
{it.time !== undefined && <code className="jc-mono">{it.time}</code>}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Section>
|
|
);
|
|
|
|
case 'waiting':
|
|
return (
|
|
<div className="jc-waitbar">
|
|
<em className="jc-pulse-dot" />
|
|
<div>
|
|
<b>{b.label}</b>
|
|
{b.hint !== undefined && <p>{b.hint}</p>}
|
|
</div>
|
|
<div className="jc-skel">
|
|
<i />
|
|
<i />
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
case 'files':
|
|
return (
|
|
<Section title={b.title}>
|
|
<div className="jc-files">
|
|
{b.items.map((it, i) => (
|
|
<button
|
|
key={i}
|
|
className="jc-file"
|
|
onClick={onOpenFile === undefined ? undefined : () => onOpenFile(it.file)}
|
|
>
|
|
<Ico
|
|
name={it.file.kind === 'excel' ? 'file-spreadsheet' : it.file.kind === 'pdf' ? 'file-type' : 'file-text'}
|
|
s={18}
|
|
/>
|
|
<div>
|
|
<b>{it.label}</b>
|
|
<span>{it.desc}</span>
|
|
</div>
|
|
<Ico name="chevron-right" s={15} />
|
|
</button>
|
|
))}
|
|
</div>
|
|
</Section>
|
|
);
|
|
}
|
|
}
|
|
|
|
function Section({ title, children }: { title?: string | undefined; children: React.ReactNode }) {
|
|
return (
|
|
<section className="jc-sec">
|
|
{title !== undefined && <h3>{title}</h3>}
|
|
{children}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
/** 可编辑表:报告「修改」态下就地编辑;数据源一换即复位(渲染期调整 state,不用 effect) */
|
|
function TableBlock({
|
|
b,
|
|
editing,
|
|
onChange,
|
|
}: {
|
|
b: Extract<ViewBlock, { t: 'table' }>;
|
|
editing: boolean;
|
|
onChange?: ((rows: readonly (readonly string[])[]) => void) | undefined;
|
|
}) {
|
|
const [rows, setRows] = useState<readonly (readonly string[])[]>(b.rows);
|
|
const [srcRows, setSrcRows] = useState(b.rows);
|
|
if (srcRows !== b.rows) {
|
|
setSrcRows(b.rows);
|
|
setRows(b.rows);
|
|
}
|
|
/** 改一格就往外交一次 —— 外层报告拿它拼回传载荷,不必等「保存」那一下 */
|
|
const put = (next: readonly (readonly string[])[]): void => {
|
|
setRows(next);
|
|
onChange?.(next);
|
|
};
|
|
|
|
const live = editing && b.editable === true;
|
|
const canEditCol = (c: number) => b.editableCols === undefined || b.editableCols.includes(c);
|
|
const donut = b.linkDonut;
|
|
|
|
return (
|
|
<Section title={b.title}>
|
|
{donut !== undefined && (
|
|
<Donut
|
|
title={donut.title}
|
|
unit={donut.unit}
|
|
live={live}
|
|
items={donut.map.map((m) => ({
|
|
label: m.label,
|
|
tone: m.tone,
|
|
value: rows.filter((r) => (r[donut.col] ?? '').includes(m.match)).length,
|
|
}))}
|
|
/>
|
|
)}
|
|
<div className="jc-tbl-wrap">
|
|
<table className="jc-tbl">
|
|
<thead>
|
|
<tr>
|
|
{b.cols.map((c, i) => (
|
|
<th key={i}>{c}</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.map((r, ri) => (
|
|
<tr key={ri}>
|
|
{r.map((cell, ci) => (
|
|
<td key={ci} className={ci === 0 ? 'jc-mono' : ''}>
|
|
{live && canEditCol(ci) ? (
|
|
/*
|
|
* 空格子**自己喊「待填写」**(红字占位)。
|
|
*
|
|
* 一张全可编辑的表摆在人面前,哪几格是空的、哪几格本来就没有,
|
|
* 光看是分不出来的 —— 尤其这份表大半列都填着值,空的那两格
|
|
* 在一片文字里根本不显眼。人于是签完了才发现漏填。
|
|
* 占位字 + 红边把「这里等你」摆到脸上,填上就恢复常态。
|
|
*/
|
|
<input
|
|
className={cell.trim() === '' ? 'jc-tbl-todo' : undefined}
|
|
placeholder="待填写"
|
|
aria-label={`${b.cols[ci] ?? ''}${cell.trim() === '' ? '(待填写)' : ''}`}
|
|
value={cell}
|
|
onChange={(e) =>
|
|
put(rows.map((x, i) => (i === ri ? x.map((y, j) => (j === ci ? e.target.value : y)) : x)))
|
|
}
|
|
/>
|
|
) : cell.trim() === '' ? (
|
|
/* 只读态也别留一片空白:没填就是没填,如实说 */
|
|
<em className="jc-tbl-empty">未填</em>
|
|
) : (
|
|
cell
|
|
)}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
/** 环图:圆心 = 合计,图例带数值;`live` 标注随编辑联动 */
|
|
function Donut({
|
|
title,
|
|
unit,
|
|
items,
|
|
live = false,
|
|
}: {
|
|
title?: string | undefined;
|
|
unit?: string | undefined;
|
|
items: ReadonlyArray<{ label: string; value: number; tone: Tone }>;
|
|
live?: boolean | undefined;
|
|
}) {
|
|
const total = items.reduce((s, x) => s + x.value, 0) || 1;
|
|
const R = 52;
|
|
const C = 2 * Math.PI * R;
|
|
// 各段占比与累计偏移一次算完(纯函数式,不在渲染中改闭包变量)
|
|
const segs = items.reduce<Array<{ tone: Tone; fr: number; off: number }>>((acc, x) => {
|
|
const prev = acc[acc.length - 1];
|
|
return [...acc, { tone: x.tone, fr: x.value / total, off: prev === undefined ? 0 : prev.off + prev.fr }];
|
|
}, []);
|
|
return (
|
|
<Section title={title}>
|
|
<div className="jc-donut">
|
|
<svg viewBox="0 0 140 140" width="140" height="140">
|
|
<circle cx="70" cy="70" r={R} className="jc-donut-bg" />
|
|
{segs.map((sg, i) => (
|
|
<circle
|
|
key={i}
|
|
cx="70"
|
|
cy="70"
|
|
r={R}
|
|
className={'jc-donut-seg t-' + TONE_CLASS[sg.tone]}
|
|
strokeDasharray={`${C * sg.fr} ${C * (1 - sg.fr)}`}
|
|
strokeDashoffset={-C * sg.off}
|
|
/>
|
|
))}
|
|
<text x="70" y="66" className="jc-donut-n">
|
|
{total}
|
|
</text>
|
|
<text x="70" y="84" className="jc-donut-u">
|
|
{unit ?? ''}
|
|
</text>
|
|
</svg>
|
|
<ul className="jc-donut-lg">
|
|
{items.map((it, i) => (
|
|
<li key={i}>
|
|
<em className={'t-' + TONE_CLASS[it.tone]} />
|
|
<span>{it.label}</span>
|
|
<b className="jc-mono">{it.value}</b>
|
|
</li>
|
|
))}
|
|
{live && (
|
|
<li className="jc-donut-live">
|
|
<em className="jc-pulse-dot" />
|
|
<span>随编辑实时联动</span>
|
|
</li>
|
|
)}
|
|
</ul>
|
|
</div>
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
function MailBlock({
|
|
b,
|
|
editing,
|
|
onOpenFile,
|
|
}: {
|
|
b: Extract<ViewBlock, { t: 'mail' }>;
|
|
editing: boolean;
|
|
onOpenFile?: ((file: FileDoc) => void) | undefined;
|
|
}) {
|
|
const [d, setD] = useState({ subject: b.subject, to: b.to, cc: b.cc ?? '', body: b.body });
|
|
const [src, setSrc] = useState(b);
|
|
if (src !== b) {
|
|
setSrc(b);
|
|
setD({ subject: b.subject, to: b.to, cc: b.cc ?? '', body: b.body });
|
|
}
|
|
const edit = editing && b.editable === true;
|
|
|
|
return (
|
|
<div className={'jc-letter ' + (b.dir === 'in' ? 'in' : 'out')}>
|
|
<div className="jc-letter-h">
|
|
<span className="jc-letter-dir">
|
|
<Ico name={b.dir === 'in' ? 'mail-open' : 'send'} s={14} />
|
|
{b.dir === 'in' ? '收件' : '发件'}
|
|
</span>
|
|
{b.statusLabel !== undefined && <span className="jc-chip">{b.statusLabel}</span>}
|
|
<code className="jc-mono">{b.time}</code>
|
|
</div>
|
|
<div className="jc-letter-meta">
|
|
<p>
|
|
<i>发件人</i>
|
|
{b.from}
|
|
{b.fromAddr !== undefined && <code className="jc-mono">{b.fromAddr}</code>}
|
|
</p>
|
|
<p>
|
|
<i>收件人</i>
|
|
{edit ? <input value={d.to} onChange={(e) => setD({ ...d, to: e.target.value })} /> : d.to}
|
|
</p>
|
|
{(d.cc !== '' || edit) && (
|
|
<p>
|
|
<i>抄送</i>
|
|
{edit ? <input value={d.cc} onChange={(e) => setD({ ...d, cc: e.target.value })} /> : d.cc}
|
|
</p>
|
|
)}
|
|
</div>
|
|
{edit ? (
|
|
<input className="jc-letter-sub" value={d.subject} onChange={(e) => setD({ ...d, subject: e.target.value })} />
|
|
) : (
|
|
<h4 className="jc-letter-sub">{d.subject}</h4>
|
|
)}
|
|
{edit ? (
|
|
<textarea
|
|
className="jc-letter-body"
|
|
value={d.body}
|
|
onChange={(e) => setD({ ...d, body: e.target.value })}
|
|
rows={7}
|
|
/>
|
|
) : (
|
|
<p className="jc-letter-body">{d.body}</p>
|
|
)}
|
|
{b.attachments !== undefined && b.attachments.length > 0 && (
|
|
<div className="jc-atts">
|
|
{b.attachments.map((a, i) => {
|
|
const file = a.file;
|
|
return (
|
|
<button
|
|
key={i}
|
|
className="jc-att"
|
|
disabled={file === undefined}
|
|
onClick={file === undefined || onOpenFile === undefined ? undefined : () => onOpenFile(file)}
|
|
>
|
|
<Ico name="paperclip" s={13} />
|
|
<b>{a.name}</b>
|
|
{a.meta !== undefined && <span>{a.meta}</span>}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|