基线:鉴权会话+工作组+工作台三区修复+编排工作区+产品设计文档
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
+380
-116
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user