/** * 视图块渲染 —— 无业务语义的展示原语(编码规范 §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> = { 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) => ( 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 (

{b.text}

); case 'kv': return (
{b.items.map((it, i) => (
{it.k} {it.v}
))}
); case 'badges': return (
{b.items.map((it, i) => ( {it.label} ))}
); case 'table': return ; case 'bars': return (
{b.items.map((it, i) => { const max = b.max ?? Math.max(...b.items.map((x) => x.value)); return (
{it.label}
{b.redline !== undefined && ( )}
{it.text ?? it.value}
); })}
); case 'donut': return ; case 'mail': return ; case 'cards': return (
{b.items.map((it, i) => (
{it.tag} {it.code !== undefined && {it.code}}
{it.swap === undefined ? ( {it.title} ) : (
{it.swap.from} {it.swap.to} {it.swap.hint !== undefined && {it.swap.hint}}
)}
{it.big} {it.bigLabel !== undefined && {it.bigLabel}}
{it.meta !== undefined && it.meta.length > 0 && (
{it.meta.map((m, k) => ( {m.k} {m.v} ))}
)} {it.note !== undefined &&

{it.note}

} {(it.from !== undefined || it.time !== undefined) && (
{it.from} {it.time !== undefined && {it.time}}
)}
))}
); case 'waiting': return (
{b.label} {b.hint !== undefined &&

{b.hint}

}
); case 'files': return (
{b.items.map((it, i) => ( ))}
); } } function Section({ title, children }: { title?: string | undefined; children: React.ReactNode }) { return (
{title !== undefined &&

{title}

} {children}
); } /** 可编辑表:报告「修改」态下就地编辑;数据源一换即复位(渲染期调整 state,不用 effect) */ function TableBlock({ b, editing, onChange, }: { b: Extract; editing: boolean; onChange?: ((rows: readonly (readonly string[])[]) => void) | undefined; }) { const [rows, setRows] = useState(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 (
{donut !== undefined && ( ({ label: m.label, tone: m.tone, value: rows.filter((r) => (r[donut.col] ?? '').includes(m.match)).length, }))} /> )}
{b.cols.map((c, i) => ( ))} {rows.map((r, ri) => ( {r.map((cell, ci) => ( ))} ))}
{c}
{live && canEditCol(ci) ? ( /* * 空格子**自己喊「待填写」**(红字占位)。 * * 一张全可编辑的表摆在人面前,哪几格是空的、哪几格本来就没有, * 光看是分不出来的 —— 尤其这份表大半列都填着值,空的那两格 * 在一片文字里根本不显眼。人于是签完了才发现漏填。 * 占位字 + 红边把「这里等你」摆到脸上,填上就恢复常态。 */ put(rows.map((x, i) => (i === ri ? x.map((y, j) => (j === ci ? e.target.value : y)) : x))) } /> ) : cell.trim() === '' ? ( /* 只读态也别留一片空白:没填就是没填,如实说 */ 未填 ) : ( cell )}
); } /** 环图:圆心 = 合计,图例带数值;`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>((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 (
{segs.map((sg, i) => ( ))} {total} {unit ?? ''}
    {items.map((it, i) => (
  • {it.label} {it.value}
  • ))} {live && (
  • 随编辑实时联动
  • )}
); } function MailBlock({ b, editing, onOpenFile, }: { b: Extract; 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 (
{b.dir === 'in' ? '收件' : '发件'} {b.statusLabel !== undefined && {b.statusLabel}} {b.time}

发件人 {b.from} {b.fromAddr !== undefined && {b.fromAddr}}

收件人 {edit ? setD({ ...d, to: e.target.value })} /> : d.to}

{(d.cc !== '' || edit) && (

抄送 {edit ? setD({ ...d, cc: e.target.value })} /> : d.cc}

)}
{edit ? ( setD({ ...d, subject: e.target.value })} /> ) : (

{d.subject}

)} {edit ? (