/** * 治理台小件 —— 自 `~/coding/JCAI_PRD/governance/_components/Governance.tsx` 第 138–387 行平移。 * * **DOM 结构与类名原样保留**(`g-*` + 色调字典 `t-*`),只做三处必要改写(README 平移规则 1/2/4): * · 去掉 `"use client"`(我们不是 Next); * · 显式导出(原型是同文件私有函数); * · 显式返回类型(编码规范 §11.3 `explicit-module-boundary-types` 不覆盖 apps/web,但同风格保留)。 * * 这些件是**纯呈现**:不取数、不调 API,数据一律由调用方传入。 */ import { useState, type CSSProperties, type Dispatch, type ReactNode, type SetStateAction } from 'react'; import { Ico } from '@web-shared/Ico.tsx'; export function Chip({ tone = 'mute', ico, children }: { tone?: string; ico?: string; children: ReactNode }) { return ( {ico && } {children} ); } export function St({ tone, ico, children }: { tone: string; ico: string; children: ReactNode }) { return ( {children} ); } export function Ava({ name }: { name: string }) { return {name.slice(0, 1)}; } export function Note({ tone, children }: { tone?: 'warn' | 'ok'; children: ReactNode }) { return (
{children}
); } export function Band({ ico, children, extra }: { ico: string; children: ReactNode; extra?: string }) { return (
{children} {extra && {extra}}
); } export function Sec({ ico, title, extra, children }: { ico: string; title: string; extra?: string; children: ReactNode }) { return (
{title} {children}
); } export function Kpi({ tone, ico, label, value, unit, sub, dial, }: { tone?: string; ico: string; label: string; value: ReactNode; unit?: string; sub?: string; dial?: number; }) { return (
{label} {dial !== undefined ? (
{value} {unit && {unit}}
) : ( {value} {unit && {unit}} )} {sub && {sub}}
); } export function Dial({ v, tone = 'brand', size = 42 }: { v: number; tone?: string; size?: number }) { const r = (size - 6) / 2; const c = 2 * Math.PI * r; return ( ); } export function Card({ ico, title, extra, children }: { ico: string; title: string; extra?: ReactNode; children: ReactNode }) { return (
{title} {extra && {extra}}
{children}
); } export function Rows({ items }: { items: ReadonlyArray<{ k: string; v: ReactNode; mono?: boolean }> }) { return (
{items.map((r, i) => (
{r.k} {r.v}
))}
); } export function Dist({ rows }: { rows: ReadonlyArray<{ k: string; v: number; tone: string; label?: string }> }) { return (
{rows.map((r) => (
{r.k} {r.label ?? `${r.v}%`}
))}
); } export function Bars({ data, tone = 'brand' }: { data: readonly number[]; tone?: string }) { const max = Math.max(1, ...data); return ( {data.map((d, i) => ( ))} ); } export function Load({ n, max, tone = 'brand' }: { n: number; max: number; tone?: string }) { return ( {n} ); } /** 小事实块(预算 / 要素统计等定量并列项) */ export function Facts({ items }: { items: ReadonlyArray<{ k: string; v: string; tone?: string; ratio?: number }> }) { return (
{items.map((f, i) => (
{f.k} {f.v} {f.ratio !== undefined && ( )}
))}
); } /* ---- 可下钻的清单行:清单只负责导航,动作留给详情页 ---- */ export function NList({ children }: { children: ReactNode }) { return
{children}
; } export function NRow({ tone = 'mute', ico, title, sub, cols, right, onClick, }: { tone?: string; ico: string; title: ReactNode; sub?: ReactNode; cols?: ReadonlyArray<{ k: string; v: ReactNode }>; right?: ReactNode; onClick: () => void; }) { return ( ); } /** 下一级入口(详情页里指向子维度页) */ export function Entry({ ico, tone = 'brand', title, desc, meta, onClick, }: { ico: string; tone?: string; title: string; desc: string; meta?: ReactNode; onClick: () => void; }) { return ( ); } /* ---- 属性卡:就地填写,不下钻 ---- */ export function Prop({ ico, title, extra, children }: { ico: string; title: string; extra?: ReactNode; children: ReactNode }) { return (
{title} {extra && {extra}}
{children}
); } export function Field({ k, hint, children }: { k: string; hint?: string; children: ReactNode }) { return (
{k} {hint && {hint}} {children}
); } export function Seg({ value, options, onPick, }: { value: string; options: ReadonlyArray<{ k: string; label: string }>; onPick: (k: string) => void; }) { return (
{options.map((o) => ( ))}
); } export function ListEdit({ items, placeholder, onChange, }: { items: readonly string[]; placeholder: string; onChange: (v: string[]) => void; }) { return (
{items.map((it, i) => (
onChange(items.map((x, j) => (j === i ? e.target.value : x)))} />
))} {items.length === 0 && 未声明}
); } /* ---- 就地确认带:影响预览三必答,不弹窗、不遮挡 ---- */ export type AskSpec = { title: string; note?: ReactNode; /** 三必答 —— 治理动作在按下之前就把后果讲清楚 */ impact: { who: string; undo: string; when: string }; okLabel: string; onOk: () => void; }; export type ActSpec = { label: string; ico?: string; primary?: boolean; danger?: boolean; disabled?: boolean; ask?: AskSpec; onClick?: () => void; }; export function AskBand({ spec, danger, onCancel }: { spec: AskSpec; danger?: boolean; onCancel: () => void }) { return (
{spec.title}
{spec.note}
影响谁 {spec.impact.who}
可否撤销 {spec.impact.undo}
何时生效 {spec.impact.when}
); } export function Acts({ items, align = 'start' }: { items: readonly ActSpec[]; align?: 'start' | 'end' }) { const [armed, setArmed] = useState(null); const a = armed !== null ? items[armed] : null; return (
{items.map((it, i) => ( ))}
{a?.ask && setArmed(null)} />}
); } /* ---- 详情页头 ---- */ export function PageHead({ ico, tone = 'brand', kicker, title, chips, desc, acts, }: { ico: string; tone?: string; kicker: string; title: string; chips?: ReactNode; desc?: ReactNode; acts?: readonly ActSpec[]; }) { const [armed, setArmed] = useState(null); const a = acts && armed !== null ? acts[armed] : null; return (
{kicker}

{title}

{chips &&
{chips}
} {desc &&

{desc}

}
{acts && (
{acts.map((it, i) => ( ))}
)}
{a?.ask && setArmed(null)} />}
); } /** 需处置卡:一条偏差 + 就地处置 */ export function Todo({ tone = 'danger', ico, title, desc, acts, }: { tone?: string; ico: string; title: string; desc: string; acts: readonly ActSpec[]; }) { return (
{title} {desc}
); } export function Clear({ children }: { children: ReactNode }) { return (
{children}
); } /** 区首页/详情页共用的板栅 */ export function Board({ children, style }: { children: ReactNode; style?: CSSProperties }) { return (
{children}
); } export function Kpis({ children }: { children: ReactNode }) { return
{children}
; } export function Todos({ children }: { children: ReactNode }) { return
{children}
; } export type Armed = [number | null, Dispatch>]; /** * 取数态:载入 / 无权 / 出错 —— 三种都得**说出来**。 * * 治理台尤其不能把「无权查看」画成空清单:那会让人误以为系统里什么都没有。 * 权限不足按岗位说明缺哪一项,而不是笼统「失败」。 */ export function QueryGate({ query, children, }: { query: { isPending: boolean; isError: boolean; error: unknown }; children: ReactNode; }) { if (query.isPending) { return (
载入中…
); } if (query.isError) { const error = query.error as { status?: number; message?: string } | undefined; const forbidden = error?.status === 403; return ( {forbidden ? ( <> 无权查看 —— {error?.message ?? ''}。权限按岗位授予:客体三类 × 动作三种; 换岗即换权,需要请找有该治理功能授权的岗位开通。 ) : ( <> 取数失败 —— {error?.message ?? '未知错误'} )} ); } return <>{children}; }