/**
* 治理台小件 —— 自 `~/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 (
);
}
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}>;
}