基线:鉴权会话+工作组+工作台三区修复+编排工作区+产品设计文档

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
2026-07-27 20:43:35 +08:00
co-authored by Claude Fable 5
parent 61fe181678
commit ac85ecf2ea
119 changed files with 16634 additions and 1246 deletions
+380 -116
View File
@@ -1,161 +1,425 @@
/**
* 视图块渲染 —— 无业务语义的展示原语(编码规范 §2 packages/ui-kit)。
* DOM 与类名自原型平移(`jc-*`),渲染输出保持 1:1。
*
* DOM 与类名逐字取自原型 `_components/ViewRegion.tsx` 的 `Block`(2026-07-26 定稿):
* 皮肤(`proto.vendor.css` / `app.css`)是原型的原样,类名对不上就等于没样式 ——
* 所以这里**不新造类名**,块的骨架、层级、元素种类一律照搬。
*/
import type { ViewBlock } from '@contracts';
import { useState } from 'react';
import type { FileDoc, Tone, ViewBlock } from '@contracts';
import { Ico } from './Ico.tsx';
export function ViewBlocks({ blocks }: { readonly blocks: readonly ViewBlock[] }) {
/** 语义色 → 皮肤色位(原型口径: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;
};
export function ViewBlocks({ blocks, editing = false, onOpenFile }: ViewBlocksProps) {
return (
<>
{blocks.map((block, i) => (
<Block key={i} block={block} />
<Block key={i} b={block} editing={editing} onOpenFile={onOpenFile} />
))}
</>
);
}
function Block({ block }: { block: ViewBlock }) {
switch (block.t) {
function Block({
b,
editing,
onOpenFile,
}: {
b: ViewBlock;
editing: boolean;
onOpenFile?: ((file: FileDoc) => void) | undefined;
}) {
switch (b.t) {
case 'callout':
return <p className={'jc-callout tone-' + block.tone}>{block.text}</p>;
return (
<div className={'jc-callout t-' + TONE_CLASS[b.tone]}>
<Ico name="info" s={15} />
<p>{b.text}</p>
</div>
);
case 'kv':
return (
<section className="jc-kv">
{block.title !== undefined && <h4>{block.title}</h4>}
<dl>
{block.items.map((item, i) => (
<div key={i}>
<dt>{item.k}</dt>
<dd>{item.v}</dd>
<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>
))}
</dl>
</section>
</div>
</Section>
);
case 'badges':
return (
<section className="jc-badges">
{block.title !== undefined && <h4>{block.title}</h4>}
{block.items.map((item, i) => (
<span key={i} className={item.on ? 'on' : ''}>
{item.label}
</span>
))}
</section>
<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 (
<section className="jc-table-wrap">
{block.title !== undefined && <h4>{block.title}</h4>}
<table className="jc-table">
<thead>
<tr>
{block.cols.map((c, i) => (
<th key={i}>{c}</th>
))}
</tr>
</thead>
<tbody>
{block.rows.map((row, i) => (
<tr key={i}>
{row.map((cell, j) => (
<td key={j}>{cell}</td>
))}
</tr>
))}
</tbody>
</table>
</section>
);
return <TableBlock b={b} editing={editing} />;
case 'bars':
return (
<section className="jc-bars">
{block.title !== undefined && <h4>{block.title}</h4>}
{block.items.map((item, i) => (
<div key={i} className={'jc-bar tone-' + (item.tone ?? 'brand')}>
<span className="jc-bar-label">{item.label}</span>
<span className="jc-bar-track">
<i style={{ width: `${Math.min((item.value / (block.max ?? 100)) * 100, 100)}%` }} />
</span>
<span className="jc-bar-value">{item.text ?? item.value}</span>
</div>
))}
</section>
<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 (
<section className="jc-donut">
{block.title !== undefined && <h4>{block.title}</h4>}
<ul>
{block.items.map((item, i) => (
<li key={i} className={'tone-' + item.tone}>
<b>{item.value}</b>
<span>{item.label}</span>
</li>
))}
</ul>
</section>
);
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 className="jc-cards">
{block.title !== undefined && <h4>{block.title}</h4>}
{block.items.map((item, i) => (
<article key={i} className={'jc-mini-card tone-' + (item.tone ?? 'brand')}>
<span className="jc-mini-tag">{item.tag}</span>
<h5>{item.title}</h5>
{item.swap !== undefined && (
<p className="jc-swap">
<code>{item.swap.from}</code> → <code>{item.swap.to}</code>
</p>
)}
<b className="jc-mini-big">{item.big}</b>
{item.bigLabel !== undefined && <em>{item.bigLabel}</em>}
{item.note !== undefined && <p className="jc-mini-note">{item.note}</p>}
</article>
))}
</section>
<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 (
<p className="jc-waiting">
{block.label}
{block.hint !== undefined && <em>{block.hint}</em>}
</p>
);
case 'mail':
return (
<article className={'jc-mail dir-' + block.dir}>
<header>
<b>{block.subject}</b>
<span>
{block.from} → {block.to}
</span>
<time>{block.time}</time>
</header>
<pre className="jc-mail-body">{block.body}</pre>
</article>
<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 className="jc-files">
{block.title !== undefined && <h4>{block.title}</h4>}
{block.items.map((item, i) => (
<div key={i} className="jc-file">
<b>{item.label}</b>
<span>{item.desc}</span>
</div>
))}
</section>
<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 }: { b: Extract<ViewBlock, { t: 'table' }>; editing: boolean }) {
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 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
value={cell}
onChange={(e) =>
setRows(rows.map((x, i) => (i === ri ? x.map((y, j) => (j === ci ? e.target.value : y)) : x)))
}
/>
) : (
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>
);
}