外包风险评估系统:领域引擎+前端+服务端持久化与生产部署
- 确定性领域引擎(分类/评分/分级/红线/费用/裁决)+LLM(通义千问)语言理解 - 6步评估向导、服务端草稿持久化(跨设备/编辑草稿保护) - 工作流(草稿→风控→管理层)、RBAC、报告导出、校准、客户/费率/红线/最低工资管理 - 专业图标体系替换全部emoji、看板美化 - 生产化:API_BASE可配置(同源反代)、auth密钥惰性读取修复RBAC - 444单测+204前端测试+51 e2e
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* ChartContainer — 通用 Chart 容器(task 19.1)。
|
||||
*
|
||||
* 实现 `renderChart(spec)` 的通用契约,由具体图表(task 19.2–19.5)包裹其图形内容
|
||||
* (recharts)后复用:
|
||||
* - status `loading` → 呈现 Loading_State(role="status" + aria-busy,Req 20.5)。
|
||||
* - status `empty` 或无数据 → 呈现 Empty_State + 非空「无可展示数据」提示(Req 20.4)。
|
||||
* - 就绪态:当系列/类别 ≥2 时呈现图例,且图例标签与数据元素标签一致(Req 20.2);
|
||||
* 呈现坐标轴/数据点/分区的非空文本标签(Req 20.3);每个类别以文本标签 + 图案在
|
||||
* 颜色之外区分(Req 23.6)。
|
||||
*
|
||||
* 配色经 Color_Token 以 CSS 自定义属性引用(`var(--color-...)`),取值由 ThemeProvider
|
||||
* 解析,图表层不硬编码具体颜色(Req 19.6)。派生逻辑全部委托 ./helpers.ts 的纯函数,
|
||||
* 便于属性测试(task 19.6–19.11)确定性验证。
|
||||
*/
|
||||
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import {
|
||||
colorTokenToCssVarName,
|
||||
Icon,
|
||||
spacing,
|
||||
typography,
|
||||
} from '../design-system/index.js';
|
||||
import type { ColorToken } from '../design-system/index.js';
|
||||
import type {
|
||||
ChartPattern,
|
||||
ChartSpec,
|
||||
Label,
|
||||
LegendItem,
|
||||
} from './chart-types.js';
|
||||
import {
|
||||
deriveLegend,
|
||||
isEmptyState,
|
||||
isLoadingState,
|
||||
shouldShowLegend,
|
||||
} from './helpers.js';
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 局部样式原语(取自 Design Tokens,颜色经 CSS 变量引用)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/** 将 Color_Token 映射为 CSS 变量引用(与基础组件库一致)。 */
|
||||
function colorVar(token: ColorToken): string {
|
||||
return `var(${colorTokenToCssVarName(token)})`;
|
||||
}
|
||||
|
||||
/** 取间距标度第 `step` 档(px);越界回退为 0,保证渲染稳健。 */
|
||||
function space(step: number): number {
|
||||
return spacing[step] ?? 0;
|
||||
}
|
||||
|
||||
const FONT_FAMILY =
|
||||
"'Inter', 'PingFang SC', 'Microsoft YaHei', system-ui, -apple-system, sans-serif";
|
||||
|
||||
/** 取具名排版层级的字体样式(找不到则回退到 body 近似值)。 */
|
||||
function typographyStyle(name: string): CSSProperties {
|
||||
const level = typography.find((t) => t.name === name);
|
||||
if (level === undefined) {
|
||||
return { fontSize: '14px', lineHeight: '22px' };
|
||||
}
|
||||
return { fontSize: `${level.fontSize}px`, lineHeight: `${level.lineHeight}px` };
|
||||
}
|
||||
|
||||
/** 默认空态/加载态文案(确保非空,Req 20.4 / 20.5)。 */
|
||||
const DEFAULT_EMPTY_MESSAGE = '暂无可展示数据';
|
||||
const DEFAULT_LOADING_MESSAGE = '正在加载图表数据…';
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 图案色块(颜色之外的区分,Req 23.6 / Property 79)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/** 图案 → 人类可读名称(也作为色块的无障碍补充说明)。 */
|
||||
const PATTERN_LABEL: Record<ChartPattern, string> = {
|
||||
solid: '实心',
|
||||
diagonal: '斜纹',
|
||||
horizontal: '横纹',
|
||||
vertical: '竖纹',
|
||||
grid: '网格',
|
||||
dots: '圆点',
|
||||
crosshatch: '交叉纹',
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染一个带图案的图例色块:底色取自 Color_Token,叠加确定性 SVG 图案,
|
||||
* 使类别在不依赖颜色(如灰度打印)时仍可区分。色块为装饰性,类别识别依赖相邻文本。
|
||||
*/
|
||||
function PatternSwatch({
|
||||
colorToken,
|
||||
pattern,
|
||||
size = 14,
|
||||
}: {
|
||||
readonly colorToken: ColorToken;
|
||||
readonly pattern: ChartPattern;
|
||||
readonly size?: number;
|
||||
}): JSX.Element {
|
||||
const patternId = `chart-pat-${pattern}`;
|
||||
const stroke = colorVar('color.text.primary');
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 14 14"
|
||||
aria-hidden={true}
|
||||
focusable={false}
|
||||
style={{ flexShrink: 0, borderRadius: '2px' }}
|
||||
data-pattern={pattern}
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
id={patternId}
|
||||
width={4}
|
||||
height={4}
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
{pattern === 'diagonal' ? (
|
||||
<path d="M0 4 L4 0" stroke={stroke} strokeWidth={1} />
|
||||
) : null}
|
||||
{pattern === 'horizontal' ? (
|
||||
<path d="M0 2 L4 2" stroke={stroke} strokeWidth={1} />
|
||||
) : null}
|
||||
{pattern === 'vertical' ? (
|
||||
<path d="M2 0 L2 4" stroke={stroke} strokeWidth={1} />
|
||||
) : null}
|
||||
{pattern === 'grid' ? (
|
||||
<path d="M0 2 L4 2 M2 0 L2 4" stroke={stroke} strokeWidth={1} />
|
||||
) : null}
|
||||
{pattern === 'crosshatch' ? (
|
||||
<path d="M0 4 L4 0 M0 0 L4 4" stroke={stroke} strokeWidth={1} />
|
||||
) : null}
|
||||
{pattern === 'dots' ? (
|
||||
<circle cx={2} cy={2} r={1} fill={stroke} />
|
||||
) : null}
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width={14} height={14} fill={colorVar(colorToken)} />
|
||||
{pattern !== 'solid' ? (
|
||||
<rect width={14} height={14} fill={`url(#${patternId})`} />
|
||||
) : null}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 状态视图:Loading / Empty
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function LoadingState({ message }: { readonly message: string }): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-busy={true}
|
||||
data-chart-state="loading"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: `${space(2)}px`,
|
||||
padding: `${space(6)}px`,
|
||||
color: colorVar('color.text.secondary'),
|
||||
fontFamily: FONT_FAMILY,
|
||||
...typographyStyle('body'),
|
||||
}}
|
||||
>
|
||||
<Icon name="info" size={20} color={colorVar('color.brand.primary')} />
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ message }: { readonly message: string }): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
data-chart-state="empty"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: `${space(2)}px`,
|
||||
padding: `${space(6)}px`,
|
||||
color: colorVar('color.text.secondary'),
|
||||
fontFamily: FONT_FAMILY,
|
||||
...typographyStyle('body'),
|
||||
}}
|
||||
>
|
||||
<Icon name="info" size={20} color={colorVar('color.text.secondary')} />
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 图例与标签视图
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function ChartLegend({ items }: { readonly items: readonly LegendItem[] }): JSX.Element {
|
||||
return (
|
||||
<ul
|
||||
data-chart-legend="true"
|
||||
aria-label="图例"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: `${space(3)}px`,
|
||||
listStyle: 'none',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
fontFamily: FONT_FAMILY,
|
||||
color: colorVar('color.text.primary'),
|
||||
...typographyStyle('caption'),
|
||||
}}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<li
|
||||
key={item.label}
|
||||
data-legend-item={item.label}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: `${space(1)}px` }}
|
||||
>
|
||||
<PatternSwatch colorToken={item.colorToken} pattern={item.pattern} />
|
||||
<span>{item.label}</span>
|
||||
<span style={{ color: colorVar('color.text.secondary') }}>
|
||||
({PATTERN_LABEL[item.pattern]})
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartLabels({ labels }: { readonly labels: readonly Label[] }): JSX.Element {
|
||||
// 视觉隐藏(保留于 DOM 供无障碍读取与属性测试定位),避免与图表自带的可见数值摘要重复。
|
||||
return (
|
||||
<ul
|
||||
data-chart-labels="true"
|
||||
aria-label="图表标签"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
padding: 0,
|
||||
margin: '-1px',
|
||||
overflow: 'hidden',
|
||||
clip: 'rect(0 0 0 0)',
|
||||
whiteSpace: 'nowrap',
|
||||
border: 0,
|
||||
}}
|
||||
>
|
||||
{labels.map((label, index) => (
|
||||
<li key={`${label.kind}-${index}-${label.text}`} data-label-kind={label.kind}>
|
||||
{label.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* ChartContainer
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/** `ChartContainer` / `Chart` 组件属性。 */
|
||||
export interface ChartContainerProps {
|
||||
/** 图表视图模型(renderChart 的输入契约)。 */
|
||||
readonly spec: ChartSpec;
|
||||
/** 具体图表的图形内容(如 recharts 元素);空数据/加载态时不渲染。 */
|
||||
readonly children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用 Chart 容器:根据 `spec.status` 与数据呈现 Loading/Empty/就绪三态,
|
||||
* 并在就绪态渲染图例(≥2 类别)、文本标签与图形内容。
|
||||
*/
|
||||
export function ChartContainer({ spec, children }: ChartContainerProps): JSX.Element {
|
||||
if (isLoadingState(spec)) {
|
||||
return <LoadingState message={spec.loadingMessage ?? DEFAULT_LOADING_MESSAGE} />;
|
||||
}
|
||||
|
||||
if (isEmptyState(spec)) {
|
||||
return <EmptyState message={spec.emptyMessage ?? DEFAULT_EMPTY_MESSAGE} />;
|
||||
}
|
||||
|
||||
// 就绪态:图例(≥2 类别时)取自 spec.legend,缺省时由系列派生以保证一致。
|
||||
const legend = shouldShowLegend(spec.series)
|
||||
? (spec.legend ?? deriveLegend(spec.series))
|
||||
: undefined;
|
||||
|
||||
const containerStyle: CSSProperties = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: `${space(3)}px`,
|
||||
fontFamily: FONT_FAMILY,
|
||||
color: colorVar('color.text.primary'),
|
||||
};
|
||||
|
||||
return (
|
||||
<figure
|
||||
data-chart-type={spec.type}
|
||||
data-chart-state="ready"
|
||||
role="group"
|
||||
aria-label={spec.title ?? spec.type}
|
||||
style={{ ...containerStyle, margin: 0 }}
|
||||
>
|
||||
{spec.title !== undefined ? (
|
||||
<figcaption style={{ ...typographyStyle('title'), fontWeight: 700 }}>
|
||||
{spec.title}
|
||||
</figcaption>
|
||||
) : null}
|
||||
{legend !== undefined ? <ChartLegend items={legend} /> : null}
|
||||
{children !== undefined ? <div data-chart-graphics="true">{children}</div> : null}
|
||||
<ChartLabels labels={spec.labels} />
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 函数式契约入口:`renderChart(spec)` 返回容器元素(design.md:renderChart)。
|
||||
* 与 `<ChartContainer>` 等价,便于以函数形式被具体图表与测试调用。
|
||||
*/
|
||||
export function renderChart(spec: ChartSpec, children?: ReactNode): JSX.Element {
|
||||
return <ChartContainer spec={spec}>{children}</ChartContainer>;
|
||||
}
|
||||
|
||||
/** `Chart` 为 `ChartContainer` 的别名(对应 design.md 的 `<Chart>` 契约)。 */
|
||||
export const Chart = ChartContainer;
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* CostBreakdownChart — 费用拆解图(task 19.4,Req 20.1)。
|
||||
*
|
||||
* 将 Cost_Engine 的费用拆解明细(各成本项金额)以 recharts 条形图呈现:每个拆解项
|
||||
* 一根条形,按金额取高度,并在 DOM 中以可见文本节点呈现「项名 + 金额」标签
|
||||
* (非仅 canvas),便于无障碍读取与 jsdom 下标签/数值属性测试定位(Req 20.3)。
|
||||
*
|
||||
* 通过通用 `ChartContainer` 取得一致框架(标题 / 三态 / 图例 / 标签)。各拆解项作为
|
||||
* 独立类别,附非空文本标签与图案以在颜色之外区分(Req 23.6 / Property 79);
|
||||
* 配色经 Color_Token 的 CSS 自定义属性引用(Req 19.6),不在图表层硬编码具体颜色。
|
||||
*/
|
||||
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
LabelList,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { ChartContainer } from './ChartContainer.js';
|
||||
import {
|
||||
deriveLegend,
|
||||
patternForIndex,
|
||||
shouldShowLegend,
|
||||
} from './helpers.js';
|
||||
import type { ChartSpec, Label, Series } from './chart-types.js';
|
||||
import type { ColorToken } from '../design-system/index.js';
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 输入类型(本地镜像,web 不跨 rootDir 引用领域层)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/** 单个费用拆解项(镜像领域层 `CostLineItem` 的展示子集)。 */
|
||||
export interface CostBreakdownItemInput {
|
||||
/** 拆解项名称,如「垫资利息」(非空文本标签)。 */
|
||||
readonly name: string;
|
||||
/** 该项测算金额(元,非负)。 */
|
||||
readonly amount: number;
|
||||
}
|
||||
|
||||
/** `CostBreakdownChart` 组件属性。 */
|
||||
export interface CostBreakdownChartProps {
|
||||
/** 费用拆解项集合。 */
|
||||
readonly items: readonly CostBreakdownItemInput[];
|
||||
/** 是否加载中(驱动 Loading_State,Req 20.5)。 */
|
||||
readonly loading?: boolean;
|
||||
/** 图表标题;缺省为「费用拆解」。 */
|
||||
readonly title?: string;
|
||||
/** 图形区宽度(px),默认 640。 */
|
||||
readonly width?: number;
|
||||
/** 图形区高度(px),默认 320。 */
|
||||
readonly height?: number;
|
||||
}
|
||||
|
||||
/** 费用拆解项的轮转配色令牌(不硬编码具体颜色,Req 19.6)。 */
|
||||
const BREAKDOWN_COLOR_TOKENS: readonly ColorToken[] = [
|
||||
'color.brand.primary',
|
||||
'color.risk.low',
|
||||
'color.risk.medium',
|
||||
'color.risk.high',
|
||||
'color.risk.critical',
|
||||
'color.heat.3',
|
||||
] as const;
|
||||
|
||||
/** 按索引确定性取配色令牌(轮转)。 */
|
||||
function colorTokenForIndex(index: number): ColorToken {
|
||||
const tokens = BREAKDOWN_COLOR_TOKENS;
|
||||
const normalized = ((index % tokens.length) + tokens.length) % tokens.length;
|
||||
return tokens[normalized] as ColorToken;
|
||||
}
|
||||
|
||||
/** 单条拆解条形的渲染数据。 */
|
||||
interface BreakdownBarDatum {
|
||||
readonly name: string;
|
||||
readonly amount: number;
|
||||
}
|
||||
|
||||
/** 人民币金额格式化(保留两位小数,千分位)。 */
|
||||
function formatMoney(value: number): string {
|
||||
return value.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
|
||||
const CHART_FONT_FAMILY =
|
||||
"'Inter', 'PingFang SC', 'Microsoft YaHei', system-ui, -apple-system, sans-serif";
|
||||
|
||||
/**
|
||||
* 费用拆解图:每个拆解项一根条形,按金额取高度并标注金额。空数据/加载态由容器接管。
|
||||
* 每项均带非空文本标签与金额,渲染为可见 DOM 文本节点。
|
||||
*/
|
||||
export function CostBreakdownChart({
|
||||
items,
|
||||
loading,
|
||||
title,
|
||||
width = 640,
|
||||
height = 320,
|
||||
}: CostBreakdownChartProps): JSX.Element {
|
||||
const series: Series[] = loading === true
|
||||
? []
|
||||
: items.map((item, index) => ({
|
||||
id: `cost-item-${index}`,
|
||||
label: item.name,
|
||||
encoding: {
|
||||
colorToken: colorTokenForIndex(index),
|
||||
textLabel: item.name,
|
||||
pattern: patternForIndex(index),
|
||||
},
|
||||
points: [
|
||||
{
|
||||
label: `${item.name}:${formatMoney(item.amount)} 元`,
|
||||
value: item.amount,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const labels: Label[] = [
|
||||
{ kind: 'axis', text: '费用项' },
|
||||
{ kind: 'axis', text: '金额(元)' },
|
||||
...(loading === true
|
||||
? []
|
||||
: items.map<Label>((item) => ({
|
||||
kind: 'point',
|
||||
text: `${item.name}:${formatMoney(item.amount)} 元`,
|
||||
}))),
|
||||
];
|
||||
|
||||
const status: ChartSpec['status'] =
|
||||
loading === true ? 'loading' : items.length === 0 ? 'empty' : 'ready';
|
||||
|
||||
const spec: ChartSpec = {
|
||||
type: 'CostBreakdown',
|
||||
status,
|
||||
series,
|
||||
...(shouldShowLegend(series) ? { legend: deriveLegend(series) } : {}),
|
||||
labels,
|
||||
title: title ?? '费用拆解',
|
||||
emptyMessage: '暂无可展示的费用拆解数据',
|
||||
loadingMessage: '正在计算费用拆解…',
|
||||
};
|
||||
|
||||
const data: BreakdownBarDatum[] = items.map((item) => ({
|
||||
name: item.name,
|
||||
amount: item.amount,
|
||||
}));
|
||||
|
||||
return (
|
||||
<ChartContainer spec={spec}>
|
||||
<div data-cost-breakdown="true">
|
||||
<div style={{ overflowX: 'auto', maxWidth: '100%' }}>
|
||||
<BarChart
|
||||
width={width}
|
||||
height={height}
|
||||
data={data}
|
||||
layout="vertical"
|
||||
margin={{ top: 8, right: 64, bottom: 8, left: 8 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border-default)" />
|
||||
<XAxis
|
||||
type="number"
|
||||
dataKey="amount"
|
||||
stroke="var(--color-text-secondary)"
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
width={140}
|
||||
stroke="var(--color-text-secondary)"
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="amount"
|
||||
fill="var(--color-brand-primary)"
|
||||
isAnimationActive={false}
|
||||
name="费用金额"
|
||||
>
|
||||
<LabelList
|
||||
dataKey="amount"
|
||||
position="right"
|
||||
style={{ fill: 'var(--color-text-primary)', fontSize: 12 }}
|
||||
/>
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</div>
|
||||
{/* 各拆解项金额的统一摘要(两列对齐、字号统一,便于 jsdom 下属性测试定位)。 */}
|
||||
<ul
|
||||
data-cost-breakdown-values="true"
|
||||
style={{
|
||||
listStyle: 'none',
|
||||
margin: '12px 0 0',
|
||||
padding: 0,
|
||||
fontFamily: CHART_FONT_FAMILY,
|
||||
}}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<li
|
||||
key={`${item.name}-${index}`}
|
||||
data-cost-item={item.name}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: '12px',
|
||||
padding: '6px 0',
|
||||
borderBottom: '1px solid var(--color-border-default)',
|
||||
fontSize: '13px',
|
||||
lineHeight: '20px',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: 'var(--color-text-secondary)' }}>{item.name}</span>
|
||||
<span style={{ color: 'var(--color-text-primary)', fontWeight: 600 }}>
|
||||
{formatMoney(item.amount)} 元
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</ChartContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* PortfolioCompareChart — 跨项目组合对比图(task 19.5,Req 20.1)。
|
||||
*
|
||||
* 消费持久化层 `compare(assessmentIds)` / 组合看板的对比数据(Property 59:≥2 个评估时
|
||||
* 返回每个被选中 Assessment 的 Risk_Grade、Risk_Score 与关键风险对比数据),以通用
|
||||
* `ChartContainer` 承载三态与标签一致性(Req 20.2–20.5),并以分组条形图 + 文本表格
|
||||
* 并呈各项目的 Risk_Score 与 Risk_Grade。
|
||||
*
|
||||
* Web 层为独立 bounded context(`web/tsconfig.json` 的 rootDir = `web/`),无法跨
|
||||
* rootDir 引用领域层 `ComparisonResult`。本模块在此本地镜像组合对比所需的最小输入视图
|
||||
* 模型 `PortfolioCompareRow`(assessmentId / 标签 / Risk_Score / Risk_Grade,及可选关键
|
||||
* 风险),使本组件与属性测试(task 19.6–19.11)围绕同一份本地契约工作。
|
||||
*
|
||||
* 配色:每个项目(数据类别)以其 Risk_Grade 对应的稳定 Color_Token 着色
|
||||
* (`riskGradeColorToken`),最终取值由 ThemeProvider 解析为 CSS 变量,不在图表层硬编码
|
||||
* 具体颜色(Req 19.6)。当项目数 ≥2 时,由系列派生图例,图例标签集合恒与数据元素(项目)
|
||||
* 标签集合相等(Property 68)。
|
||||
*
|
||||
* recharts 在 jsdom 下绘制有限,故使用显式宽高的 `BarChart`;同时每个项目的标签、
|
||||
* Risk_Score 与 Risk_Grade 以可见 DOM 文本节点(表格)呈现,便于无障碍与标签属性测试
|
||||
* (Property 69)定位。
|
||||
*/
|
||||
|
||||
import type { CSSProperties } from 'react';
|
||||
import { Bar, BarChart, CartesianGrid, Cell, LabelList, XAxis, YAxis } from 'recharts';
|
||||
import {
|
||||
colorTokenToCssVarName,
|
||||
riskGradeColorToken,
|
||||
spacing,
|
||||
typography,
|
||||
} from '../design-system/index.js';
|
||||
import type { ColorToken, RiskGrade } from '../design-system/index.js';
|
||||
import { ChartContainer } from './ChartContainer.js';
|
||||
import { RiskBadge } from './RiskBadge.js';
|
||||
import type { ChartSpec, Label, Series } from './chart-types.js';
|
||||
import {
|
||||
chartStatus,
|
||||
deriveLegend,
|
||||
patternForIndex,
|
||||
shouldShowLegend,
|
||||
} from './helpers.js';
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 本地输入视图模型(镜像持久化层 compare/组合看板对比数据,Property 59)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/** 组合对比中单个项目的关键风险项(用于关键风险对比,可选)。 */
|
||||
export interface PortfolioCompareKeyRisk {
|
||||
/** 所属维度标识。 */
|
||||
readonly dimensionId: string;
|
||||
/** 指标标识。 */
|
||||
readonly indicatorId: string;
|
||||
/** 评分项得分。 */
|
||||
readonly score: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组合对比的单行(单个被选中 Assessment 的对比数据)。
|
||||
* 至少包含 Risk_Score 与 Risk_Grade;关键风险对比数据可选(Property 59)。
|
||||
*/
|
||||
export interface PortfolioCompareRow {
|
||||
/** 评估唯一标识。 */
|
||||
readonly assessmentId: string;
|
||||
/** 项目展示标签(非空文本,构成数据元素/图例标签集合,Property 68)。 */
|
||||
readonly label: string;
|
||||
/** 该评估的 Risk_Score(0–100)。 */
|
||||
readonly riskScore: number;
|
||||
/** 该评估的 Risk_Grade(低/中/高/极高)。 */
|
||||
readonly riskGrade: RiskGrade;
|
||||
/** 可选关键风险对比项。 */
|
||||
readonly keyRisks?: readonly PortfolioCompareKeyRisk[];
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 局部样式原语(取自 Design Tokens,颜色经 CSS 变量引用,与其它图表一致)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/** 将 Color_Token 映射为 CSS 变量引用。 */
|
||||
function colorVar(token: ColorToken): string {
|
||||
return `var(${colorTokenToCssVarName(token)})`;
|
||||
}
|
||||
|
||||
/** 取间距标度第 `step` 档(px);越界回退为 0。 */
|
||||
function space(step: number): number {
|
||||
return spacing[step] ?? 0;
|
||||
}
|
||||
|
||||
const FONT_FAMILY =
|
||||
"'Inter', 'PingFang SC', 'Microsoft YaHei', system-ui, -apple-system, sans-serif";
|
||||
|
||||
/** 取具名排版层级的字体样式(找不到则回退到 body 近似值)。 */
|
||||
function typographyStyle(name: string): CSSProperties {
|
||||
const level = typography.find((t) => t.name === name);
|
||||
if (level === undefined) {
|
||||
return { fontSize: '14px', lineHeight: '22px' };
|
||||
}
|
||||
return { fontSize: `${level.fontSize}px`, lineHeight: `${level.lineHeight}px` };
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 纯派生:组合对比行 → ChartSpec
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/** `buildPortfolioCompareSpec` 的可选项。 */
|
||||
export interface PortfolioCompareSpecOptions {
|
||||
/** 是否加载中(驱动 Loading_State,Req 20.5)。 */
|
||||
readonly loading?: boolean;
|
||||
/** 图表标题;缺省提供默认非空标题。 */
|
||||
readonly title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 由组合对比行构造 ChartSpec(纯函数,确定性,便于属性测试)。
|
||||
*
|
||||
* 每个项目即一个数据类别(Series):以其 Risk_Grade 的稳定 Color_Token 着色,并附非空
|
||||
* 文本标签与图案(Property 79)。每个项目生成「项目:Risk_Score X(Grade)」的非空点标签
|
||||
* 与可选关键风险点标签(Property 69)。项目数 ≥2 时由 `deriveLegend` 产出与数据元素标签
|
||||
* 一致的图例(Property 68)。空输入 → `empty`(Property 70)。
|
||||
*/
|
||||
export function buildPortfolioCompareSpec(
|
||||
rows: readonly PortfolioCompareRow[],
|
||||
options: PortfolioCompareSpecOptions = {},
|
||||
): ChartSpec {
|
||||
const status = chartStatus({
|
||||
data: rows,
|
||||
...(options.loading !== undefined ? { loading: options.loading } : {}),
|
||||
});
|
||||
|
||||
const series: Series[] = rows.map((row, index) => {
|
||||
const keyRiskPoints = (row.keyRisks ?? []).map((risk) => ({
|
||||
label: `${row.label} · ${risk.dimensionId} / ${risk.indicatorId}(得分 ${risk.score})`,
|
||||
value: risk.score,
|
||||
}));
|
||||
return {
|
||||
id: row.assessmentId,
|
||||
label: row.label,
|
||||
encoding: {
|
||||
colorToken: riskGradeColorToken(row.riskGrade),
|
||||
textLabel: `${row.label}(${row.riskGrade})`,
|
||||
pattern: patternForIndex(index),
|
||||
},
|
||||
points: [
|
||||
{
|
||||
label: `${row.label}:Risk_Score ${row.riskScore}(${row.riskGrade})`,
|
||||
value: row.riskScore,
|
||||
},
|
||||
...keyRiskPoints,
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const labels: Label[] = [
|
||||
{ kind: 'axis', text: '项目' },
|
||||
{ kind: 'axis', text: 'Risk_Score(0–100)' },
|
||||
...rows.map<Label>((row) => ({
|
||||
kind: 'partition',
|
||||
text: `${row.label}:Risk_Score ${row.riskScore} · Risk_Grade ${row.riskGrade}`,
|
||||
})),
|
||||
];
|
||||
|
||||
return {
|
||||
type: 'PortfolioCompare',
|
||||
status,
|
||||
series,
|
||||
...(shouldShowLegend(series) ? { legend: deriveLegend(series) } : {}),
|
||||
labels,
|
||||
title: options.title ?? '跨项目组合对比',
|
||||
emptyMessage: '暂无可对比的项目(至少需选择 2 个评估)',
|
||||
loadingMessage: '正在加载组合对比数据…',
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* PortfolioCompareChart
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/** `PortfolioCompareChart` 组件属性。 */
|
||||
export interface PortfolioCompareChartProps {
|
||||
/** 组合对比行(每行为一个被选中 Assessment 的对比数据)。 */
|
||||
readonly rows: readonly PortfolioCompareRow[];
|
||||
/** 是否加载中(驱动 Loading_State,Req 20.5)。 */
|
||||
readonly loading?: boolean;
|
||||
/** 图表标题;缺省由 `buildPortfolioCompareSpec` 提供默认非空标题。 */
|
||||
readonly title?: string;
|
||||
/** 图形区宽度(px),默认 640。 */
|
||||
readonly width?: number;
|
||||
/** 图形区高度(px),默认 320。 */
|
||||
readonly height?: number;
|
||||
}
|
||||
|
||||
/** 单个条形的渲染数据。 */
|
||||
interface BarDatum {
|
||||
/** 分类标签:项目。 */
|
||||
readonly name: string;
|
||||
/** 条形高度:Risk_Score。 */
|
||||
readonly score: number;
|
||||
/** 条形配色令牌(取自 Risk_Grade)。 */
|
||||
readonly colorToken: ColorToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* 跨项目组合对比图。空数据/加载态由容器接管;就绪态以分组条形图按项目并呈 Risk_Score,
|
||||
* 并以文本表格呈现各项目标签、Risk_Score 与 Risk_Grade 徽章(可见 DOM 文本节点)。
|
||||
*/
|
||||
export function PortfolioCompareChart({
|
||||
rows,
|
||||
loading,
|
||||
title,
|
||||
width = 640,
|
||||
height = 320,
|
||||
}: PortfolioCompareChartProps): JSX.Element {
|
||||
const options: { loading?: boolean; title?: string } = {};
|
||||
if (loading !== undefined) {
|
||||
options.loading = loading;
|
||||
}
|
||||
if (title !== undefined) {
|
||||
options.title = title;
|
||||
}
|
||||
const spec = buildPortfolioCompareSpec(rows, options);
|
||||
|
||||
const data: BarDatum[] = rows.map((row) => ({
|
||||
name: row.label,
|
||||
score: row.riskScore,
|
||||
colorToken: riskGradeColorToken(row.riskGrade),
|
||||
}));
|
||||
|
||||
return (
|
||||
<ChartContainer spec={spec}>
|
||||
<div
|
||||
data-portfolio-compare="true"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: `${space(3)}px`,
|
||||
fontFamily: FONT_FAMILY,
|
||||
}}
|
||||
>
|
||||
<BarChart
|
||||
width={width}
|
||||
height={height}
|
||||
data={data}
|
||||
margin={{ top: 8, right: 16, bottom: 8, left: 8 }}
|
||||
data-portfolio-chart="true"
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border-default)" />
|
||||
<XAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
stroke="var(--color-text-secondary)"
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<YAxis
|
||||
type="number"
|
||||
domain={[0, 100]}
|
||||
dataKey="score"
|
||||
stroke="var(--color-text-secondary)"
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<Bar dataKey="score" isAnimationActive={false} name="Risk_Score">
|
||||
{data.map((datum) => (
|
||||
<Cell key={datum.name} fill={colorVar(datum.colorToken)} />
|
||||
))}
|
||||
<LabelList
|
||||
dataKey="score"
|
||||
position="top"
|
||||
style={{ fill: 'var(--color-text-primary)', fontSize: 12 }}
|
||||
/>
|
||||
</Bar>
|
||||
</BarChart>
|
||||
|
||||
{/* 文本表格:各项目标签 + Risk_Score + Risk_Grade(可见 DOM 文本)。 */}
|
||||
<table
|
||||
data-portfolio-table="true"
|
||||
style={{
|
||||
borderCollapse: 'collapse',
|
||||
width: '100%',
|
||||
color: colorVar('color.text.primary'),
|
||||
...typographyStyle('caption'),
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'left', padding: `${space(1)}px` }}>项目</th>
|
||||
<th style={{ textAlign: 'right', padding: `${space(1)}px` }}>Risk_Score</th>
|
||||
<th style={{ textAlign: 'left', padding: `${space(1)}px` }}>Risk_Grade</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.assessmentId} data-portfolio-row={row.assessmentId}>
|
||||
<td data-portfolio-label="true" style={{ padding: `${space(1)}px` }}>
|
||||
{row.label}
|
||||
</td>
|
||||
<td
|
||||
data-portfolio-score={row.riskScore}
|
||||
style={{ textAlign: 'right', padding: `${space(1)}px` }}
|
||||
>
|
||||
{row.riskScore}
|
||||
</td>
|
||||
<td style={{ padding: `${space(1)}px` }}>
|
||||
<RiskBadge grade={row.riskGrade} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</ChartContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* QuoteCompareChart — 基准 vs 风险调整后报价对比图(task 19.4,Req 20.1 / 20.7)。
|
||||
*
|
||||
* 同时呈现三个数值(Req 20.7 / Property 72):
|
||||
* - 基准报价(baselineQuote)
|
||||
* - 风险调整后报价(riskAdjustedQuote)
|
||||
* - 二者差额(difference = riskAdjusted - baseline)
|
||||
*
|
||||
* 差额由纯函数 `quoteDifference(baseline, riskAdjusted)` 按构造计算,使
|
||||
* `difference === riskAdjusted - baseline` 恒成立(task 19.10 / Property 72 验证)。
|
||||
* 三个数值均以可见 DOM 文本节点呈现(非仅 canvas),并附 `data-quote-*` 属性,
|
||||
* 便于无障碍读取与标签/数值属性测试在 jsdom 下定位。
|
||||
*
|
||||
* 通过通用 `ChartContainer` 取得一致框架(标题 / 三态 / 图例 / 标签)。图形以 recharts
|
||||
* `BarChart` 呈现三根并列条形;配色经 Color_Token 的 CSS 自定义属性引用(Req 19.6),
|
||||
* 图表层不硬编码具体颜色。
|
||||
*/
|
||||
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
LabelList,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { ChartContainer } from './ChartContainer.js';
|
||||
import {
|
||||
deriveLegend,
|
||||
patternForIndex,
|
||||
shouldShowLegend,
|
||||
} from './helpers.js';
|
||||
import type { ChartSpec, Label, Series } from './chart-types.js';
|
||||
import type { ColorToken } from '../design-system/index.js';
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 纯helper(供 task 19.10 / Property 72 验证)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* 报价差额(确定性纯函数):`difference = riskAdjusted - baseline`(Req 20.7)。
|
||||
*
|
||||
* 对比图据此呈现第三个数值,保证差额与另外两值的算术关系恒成立。
|
||||
*
|
||||
* @param baseline 基准报价。
|
||||
* @param riskAdjusted 风险调整后报价。
|
||||
* @returns 风险调整后报价减基准报价之差额(可正可负)。
|
||||
*/
|
||||
export function quoteDifference(baseline: number, riskAdjusted: number): number {
|
||||
return riskAdjusted - baseline;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 输入类型(本地镜像,web 不跨 rootDir 引用领域层)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/** 报价对比图所需的报价输入(镜像领域层 `CostEstimate` 的报价子集)。 */
|
||||
export interface QuoteCompareInput {
|
||||
/** 基准报价(元)。 */
|
||||
readonly baselineQuote: number;
|
||||
/** 风险调整后报价(元)。 */
|
||||
readonly riskAdjustedQuote: number;
|
||||
}
|
||||
|
||||
/** `QuoteCompareChart` 组件属性。 */
|
||||
export interface QuoteCompareChartProps {
|
||||
/** 报价输入(基准 + 风险调整后)。 */
|
||||
readonly quote: QuoteCompareInput;
|
||||
/** 是否加载中(驱动 Loading_State,Req 20.5)。 */
|
||||
readonly loading?: boolean;
|
||||
/** 图表标题;缺省为「基准 vs 风险调整后报价对比」。 */
|
||||
readonly title?: string;
|
||||
/** 图形区宽度(px),默认 520。 */
|
||||
readonly width?: number;
|
||||
/** 图形区高度(px),默认 300。 */
|
||||
readonly height?: number;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 三个对比类别(颜色 + 文本标签 + 图案,Req 23.6 / Property 79)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
interface QuoteCategory {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly colorToken: ColorToken;
|
||||
}
|
||||
|
||||
const QUOTE_CATEGORIES: readonly QuoteCategory[] = [
|
||||
{ id: 'baseline', label: '基准报价', colorToken: 'color.brand.primary' },
|
||||
{ id: 'risk-adjusted', label: '风险调整后报价', colorToken: 'color.risk.high' },
|
||||
{ id: 'difference', label: '差额(风险调整后 − 基准)', colorToken: 'color.risk.medium' },
|
||||
] as const;
|
||||
|
||||
/** 单条对比条形的渲染数据。 */
|
||||
interface QuoteBarDatum {
|
||||
readonly name: string;
|
||||
readonly amount: number;
|
||||
}
|
||||
|
||||
/** 人民币金额格式化(保留两位小数,千分位)。 */
|
||||
function formatMoney(value: number): string {
|
||||
return value.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
|
||||
const CHART_FONT_FAMILY =
|
||||
"'Inter', 'PingFang SC', 'Microsoft YaHei', system-ui, -apple-system, sans-serif";
|
||||
|
||||
/**
|
||||
* 基准 vs 风险调整后报价对比图:同时呈现基准报价、风险调整后报价与二者差额。
|
||||
* 差额由 `quoteDifference` 按构造计算,三值均渲染为可见 DOM 文本。
|
||||
*/
|
||||
export function QuoteCompareChart({
|
||||
quote,
|
||||
loading,
|
||||
title,
|
||||
width = 520,
|
||||
height = 300,
|
||||
}: QuoteCompareChartProps): JSX.Element {
|
||||
const baseline = quote.baselineQuote;
|
||||
const riskAdjusted = quote.riskAdjustedQuote;
|
||||
const difference = quoteDifference(baseline, riskAdjusted);
|
||||
|
||||
const values: readonly number[] = [baseline, riskAdjusted, difference];
|
||||
|
||||
const series: Series[] = loading === true
|
||||
? []
|
||||
: QUOTE_CATEGORIES.map((category, index) => ({
|
||||
id: category.id,
|
||||
label: category.label,
|
||||
encoding: {
|
||||
colorToken: category.colorToken,
|
||||
textLabel: category.label,
|
||||
pattern: patternForIndex(index),
|
||||
},
|
||||
points: [
|
||||
{
|
||||
label: `${category.label}:${formatMoney(values[index] ?? 0)} 元`,
|
||||
value: values[index] ?? 0,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const labels: Label[] = [
|
||||
{ kind: 'axis', text: '报价类别' },
|
||||
{ kind: 'axis', text: '金额(元)' },
|
||||
{ kind: 'point', text: `基准报价:${formatMoney(baseline)} 元` },
|
||||
{ kind: 'point', text: `风险调整后报价:${formatMoney(riskAdjusted)} 元` },
|
||||
{ kind: 'point', text: `差额(风险调整后 − 基准):${formatMoney(difference)} 元` },
|
||||
];
|
||||
|
||||
const spec: ChartSpec = {
|
||||
type: 'QuoteCompare',
|
||||
status: loading === true ? 'loading' : 'ready',
|
||||
series,
|
||||
...(shouldShowLegend(series) ? { legend: deriveLegend(series) } : {}),
|
||||
labels,
|
||||
title: title ?? '基准 vs 风险调整后报价对比',
|
||||
emptyMessage: '暂无可展示的报价数据',
|
||||
loadingMessage: '正在计算报价对比…',
|
||||
};
|
||||
|
||||
const data: QuoteBarDatum[] = [
|
||||
{ name: '基准报价', amount: baseline },
|
||||
{ name: '风险调整后报价', amount: riskAdjusted },
|
||||
{ name: '差额(风险调整后 − 基准)', amount: difference },
|
||||
];
|
||||
|
||||
return (
|
||||
<ChartContainer spec={spec}>
|
||||
<div data-quote-compare="true">
|
||||
<div style={{ overflowX: 'auto', maxWidth: '100%' }}>
|
||||
<BarChart
|
||||
width={width}
|
||||
height={height}
|
||||
data={data}
|
||||
margin={{ top: 24, right: 16, bottom: 8, left: 8 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border-default)" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="var(--color-text-secondary)"
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<YAxis stroke="var(--color-text-secondary)" tick={{ fontSize: 12 }} />
|
||||
<Bar
|
||||
dataKey="amount"
|
||||
fill="var(--color-brand-primary)"
|
||||
isAnimationActive={false}
|
||||
name="报价金额"
|
||||
>
|
||||
<LabelList
|
||||
dataKey="amount"
|
||||
position="top"
|
||||
style={{ fill: 'var(--color-text-primary)', fontSize: 12 }}
|
||||
/>
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</div>
|
||||
{/* 三个数值的统一摘要(Req 20.7;样式与字号统一,便于 jsdom 下属性测试定位)。 */}
|
||||
<dl
|
||||
data-quote-values="true"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))',
|
||||
gap: '12px',
|
||||
margin: '12px 0 0',
|
||||
fontFamily: CHART_FONT_FAMILY,
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ dt: '基准报价', dd: formatMoney(baseline), attr: { 'data-quote-baseline': baseline } },
|
||||
{ dt: '风险调整后报价', dd: formatMoney(riskAdjusted), attr: { 'data-quote-risk-adjusted': riskAdjusted } },
|
||||
{ dt: '差额(风险调整后 − 基准)', dd: formatMoney(difference), attr: { 'data-quote-difference': difference } },
|
||||
].map((item) => (
|
||||
<div
|
||||
key={item.dt}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
padding: '10px 12px',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: 'var(--color-bg-surface)',
|
||||
}}
|
||||
>
|
||||
<dt style={{ fontSize: '12px', lineHeight: '16px', color: 'var(--color-text-secondary)' }}>{item.dt}</dt>
|
||||
<dd
|
||||
{...item.attr}
|
||||
style={{ margin: 0, fontSize: '14px', lineHeight: '22px', fontWeight: 600, color: 'var(--color-text-primary)' }}
|
||||
>
|
||||
{item.dd} 元
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
</ChartContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* RiskBadge — Risk_Grade 徽章(task 19.2,Req 20.1)。
|
||||
*
|
||||
* 以 `riskGradeColorToken(grade)` 对应的 Color_Token 着色,并**始终**呈现该 Risk_Grade
|
||||
* 的文字标签(Req 20.1)。配色经 CSS 自定义属性引用(取值由 ThemeProvider 解析,
|
||||
* 不硬编码颜色,Req 19.6)。
|
||||
*
|
||||
* 非颜色编码:文字标签恒存在,使等级在不依赖颜色(灰度打印、色觉障碍)时仍可识别
|
||||
* (Req 23.6),并与 Property 64「同一 Risk_Grade 全 UI 取得同一稳定令牌名」保持一致——
|
||||
* 本组件取色的唯一来源即 `riskGradeColorToken`。
|
||||
*/
|
||||
|
||||
import type { CSSProperties } from 'react';
|
||||
import { colorTokenToCssVarName, riskGradeColorToken } from '../design-system/index.js';
|
||||
import type { RiskGrade } from '../design-system/index.js';
|
||||
|
||||
/** `RiskBadge` 组件属性。 */
|
||||
export interface RiskBadgeProps {
|
||||
/** 待呈现的风险分级(低/中/高/极高)。 */
|
||||
readonly grade: RiskGrade;
|
||||
/** 可选自定义文字标签;缺省使用 Risk_Grade 文本本身(始终非空)。 */
|
||||
readonly label?: string;
|
||||
/** 可选前缀(如「风险等级」);用于补充语义,置于等级文本之前。 */
|
||||
readonly prefix?: string;
|
||||
}
|
||||
|
||||
const FONT_FAMILY =
|
||||
"'Inter', 'PingFang SC', 'Microsoft YaHei', system-ui, -apple-system, sans-serif";
|
||||
|
||||
/**
|
||||
* Risk_Grade 徽章。底色取自该等级的稳定 Color_Token,文字标签恒呈现于 DOM。
|
||||
*/
|
||||
export function RiskBadge({ grade, label, prefix }: RiskBadgeProps): JSX.Element {
|
||||
const token = riskGradeColorToken(grade);
|
||||
const text = label ?? grade;
|
||||
|
||||
const style: CSSProperties = {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '2px 10px',
|
||||
borderRadius: '4px',
|
||||
fontFamily: FONT_FAMILY,
|
||||
fontSize: '12px',
|
||||
lineHeight: '16px',
|
||||
fontWeight: 700,
|
||||
color: 'var(--color-text-inverse)',
|
||||
backgroundColor: `var(${colorTokenToCssVarName(token)})`,
|
||||
whiteSpace: 'nowrap',
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
data-risk-badge="true"
|
||||
data-risk-grade={grade}
|
||||
data-color-token={token}
|
||||
role="status"
|
||||
aria-label={`${prefix ?? '风险等级'}:${text}`}
|
||||
style={style}
|
||||
>
|
||||
{prefix !== undefined ? (
|
||||
<span data-risk-badge-prefix="true" style={{ fontWeight: 400 }}>
|
||||
{prefix}
|
||||
</span>
|
||||
) : null}
|
||||
<span data-risk-badge-label="true">{text}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* RiskHeatmap — 风险热力图(task 19.2,Req 20.1)。
|
||||
*
|
||||
* 按 Dimension(行)× Indicator(列)× Risk_Level(严重度)渲染网格,每个有数据的
|
||||
* 单元格附**数值 Risk_Level 标签**(Req 20.1)。单元格底色取自 `heatColorToken(level)`
|
||||
* 对应的 Color_Token,经 CSS 自定义属性引用(`var(--color-heat-N)`),不在图表层
|
||||
* 硬编码具体颜色(Req 19.6)。
|
||||
*
|
||||
* 视图模型由 `buildHeatmapSpec` 构造,三态(ready/loading/empty)与图例/标签一致性
|
||||
* 交由通用 `ChartContainer` 处理(Req 20.2–20.5)。数值标签以文本形式渲染于 DOM
|
||||
* (非 canvas),便于标签属性测试(task 19.6–19.11)定位。
|
||||
*/
|
||||
|
||||
import type { CSSProperties } from 'react';
|
||||
import { colorTokenToCssVarName, heatColorToken } from '../design-system/index.js';
|
||||
import type { ColorToken } from '../design-system/index.js';
|
||||
import { ChartContainer } from './ChartContainer.js';
|
||||
import { buildHeatmapSpec } from './helpers.js';
|
||||
import type { HeatmapCellInput, RiskLevel } from './chart-types.js';
|
||||
|
||||
/** `RiskHeatmap` 组件属性。 */
|
||||
export interface RiskHeatmapProps {
|
||||
/** Scoring_Engine 输出的热力图单元格(Dimension×Indicator×Risk_Level)。 */
|
||||
readonly cells: readonly HeatmapCellInput[];
|
||||
/** 是否加载中(驱动 Loading_State,Req 20.5)。 */
|
||||
readonly loading?: boolean;
|
||||
/** 图表标题;缺省由 `buildHeatmapSpec` 提供默认非空标题。 */
|
||||
readonly title?: string;
|
||||
}
|
||||
|
||||
/** 将 Color_Token 映射为 CSS 变量引用(取值由 ThemeProvider 解析)。 */
|
||||
function colorVar(token: ColorToken): string {
|
||||
return `var(${colorTokenToCssVarName(token)})`;
|
||||
}
|
||||
|
||||
const FONT_FAMILY =
|
||||
"'Inter', 'PingFang SC', 'Microsoft YaHei', system-ui, -apple-system, sans-serif";
|
||||
|
||||
/** 保持出现顺序去重。 */
|
||||
function uniqueInOrder(values: readonly string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const v of values) {
|
||||
if (!seen.has(v)) {
|
||||
seen.add(v);
|
||||
out.push(v);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 单元格定位键。 */
|
||||
function cellKey(dimensionId: string, indicatorId: string): string {
|
||||
return `${dimensionId}\u0000${indicatorId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 风险热力图。渲染 Dimension×Indicator 网格,每个有数据单元格显示数值 Risk_Level
|
||||
* 标签并以热力色着色;空数据/加载态由容器接管。
|
||||
*/
|
||||
export function RiskHeatmap({ cells, loading, title }: RiskHeatmapProps): JSX.Element {
|
||||
const options: { loading?: boolean; title?: string } = {};
|
||||
if (loading !== undefined) {
|
||||
options.loading = loading;
|
||||
}
|
||||
if (title !== undefined) {
|
||||
options.title = title;
|
||||
}
|
||||
const spec = buildHeatmapSpec(cells, options);
|
||||
|
||||
// 行(维度)与列(指标)按出现顺序去重。
|
||||
const dimensions = uniqueInOrder(cells.map((c) => c.dimensionId));
|
||||
const indicators = uniqueInOrder(cells.map((c) => c.indicatorId));
|
||||
|
||||
// (维度,指标) → Risk_Level 查找表。
|
||||
const levelByCell = new Map<string, RiskLevel>();
|
||||
for (const c of cells) {
|
||||
levelByCell.set(cellKey(c.dimensionId, c.indicatorId), c.riskLevel);
|
||||
}
|
||||
|
||||
const thStyle: CSSProperties = {
|
||||
padding: '6px 8px',
|
||||
textAlign: 'left',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-secondary)',
|
||||
borderBottom: '1px solid var(--color-border-default)',
|
||||
whiteSpace: 'nowrap',
|
||||
};
|
||||
|
||||
const cornerStyle: CSSProperties = {
|
||||
...thStyle,
|
||||
color: 'var(--color-text-primary)',
|
||||
};
|
||||
|
||||
return (
|
||||
<ChartContainer spec={spec}>
|
||||
<table
|
||||
data-heatmap-grid="true"
|
||||
style={{
|
||||
borderCollapse: 'collapse',
|
||||
fontFamily: FONT_FAMILY,
|
||||
fontSize: '12px',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" style={cornerStyle}>
|
||||
维度 \ 指标
|
||||
</th>
|
||||
{indicators.map((indicator) => (
|
||||
<th key={indicator} scope="col" style={thStyle}>
|
||||
{indicator}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dimensions.map((dimension) => (
|
||||
<tr key={dimension}>
|
||||
<th scope="row" style={thStyle}>
|
||||
{dimension}
|
||||
</th>
|
||||
{indicators.map((indicator) => {
|
||||
const level = levelByCell.get(cellKey(dimension, indicator));
|
||||
if (level === undefined) {
|
||||
return (
|
||||
<td
|
||||
key={indicator}
|
||||
data-heatmap-cell="empty"
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
textAlign: 'center',
|
||||
color: 'var(--color-text-secondary)',
|
||||
border: '1px solid var(--color-border-default)',
|
||||
}}
|
||||
>
|
||||
—
|
||||
</td>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<td
|
||||
key={indicator}
|
||||
data-heatmap-cell="filled"
|
||||
data-risk-level={level}
|
||||
aria-label={`${dimension} / ${indicator}:风险等级 ${level}`}
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
textAlign: 'center',
|
||||
fontWeight: 700,
|
||||
color: 'var(--color-text-inverse)',
|
||||
backgroundColor: colorVar(heatColorToken(level)),
|
||||
border: '1px solid var(--color-border-default)',
|
||||
}}
|
||||
>
|
||||
{level}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</ChartContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* ScoreGauge — 风险总分仪表盘(task 19.3,Req 20.1 / 20.6)。
|
||||
*
|
||||
* 同时呈现 Risk_Score 数值与其对应的 Risk_Grade 文本标签(Req 20.6):
|
||||
* - Risk_Grade 由本地 `classifyGrade(score)` 派生,逐字镜像领域层分级规则,
|
||||
* 因此仪表盘展示的等级恒等于该分值的分类结果(task 19.9 / Property 71)。
|
||||
* - 等级配色经 `riskGradeColorToken(grade)` 取得稳定 Color_Token,最终取值由
|
||||
* ThemeProvider 解析为 CSS 变量,不在图表层硬编码具体颜色(Req 19.6)。
|
||||
*
|
||||
* 通过通用 `ChartContainer` 取得一致的框架(标题 / 三态 / 标签),仪表盘图形以
|
||||
* 确定性 SVG 半圆弧表达,分值与等级以可见文本节点呈现(便于无障碍与测试)。
|
||||
* loading / empty 三态由容器统一处理(Req 20.4 / 20.5)。
|
||||
*/
|
||||
|
||||
import type { CSSProperties } from 'react';
|
||||
import {
|
||||
colorTokenToCssVarName,
|
||||
riskGradeColorToken,
|
||||
spacing,
|
||||
typography,
|
||||
} from '../design-system/index.js';
|
||||
import type { ColorToken } from '../design-system/index.js';
|
||||
import { ChartContainer } from './ChartContainer.js';
|
||||
import type { ChartSpec, Label, Series } from './chart-types.js';
|
||||
import { classifyGrade } from './riskGrade.js';
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 局部样式原语(取自 Design Tokens,颜色经 CSS 变量引用,与 ChartContainer 一致)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/** 将 Color_Token 映射为 CSS 变量引用。 */
|
||||
function colorVar(token: ColorToken): string {
|
||||
return `var(${colorTokenToCssVarName(token)})`;
|
||||
}
|
||||
|
||||
/** 取间距标度第 `step` 档(px);越界回退为 0。 */
|
||||
function space(step: number): number {
|
||||
return spacing[step] ?? 0;
|
||||
}
|
||||
|
||||
const FONT_FAMILY =
|
||||
"'Inter', 'PingFang SC', 'Microsoft YaHei', system-ui, -apple-system, sans-serif";
|
||||
|
||||
/** 取具名排版层级的字体样式(找不到则回退到 body 近似值)。 */
|
||||
function typographyStyle(name: string): CSSProperties {
|
||||
const level = typography.find((t) => t.name === name);
|
||||
if (level === undefined) {
|
||||
return { fontSize: '14px', lineHeight: '22px' };
|
||||
}
|
||||
return { fontSize: `${level.fontSize}px`, lineHeight: `${level.lineHeight}px` };
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Risk_Score 取值域
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/** Risk_Score 下界(Req:0 至 100)。 */
|
||||
const SCORE_MIN = 0;
|
||||
/** Risk_Score 上界(Req:0 至 100)。 */
|
||||
const SCORE_MAX = 100;
|
||||
|
||||
/** 将任意输入夹取到 [0, 100] 取值域内,保证弧形渲染稳健。 */
|
||||
function clampScore(score: number): number {
|
||||
if (Number.isNaN(score)) {
|
||||
return SCORE_MIN;
|
||||
}
|
||||
if (score < SCORE_MIN) {
|
||||
return SCORE_MIN;
|
||||
}
|
||||
if (score > SCORE_MAX) {
|
||||
return SCORE_MAX;
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 半圆弧几何(确定性,无外部依赖)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
const GAUGE_WIDTH = 220;
|
||||
const GAUGE_HEIGHT = 120;
|
||||
const GAUGE_CX = GAUGE_WIDTH / 2;
|
||||
const GAUGE_CY = GAUGE_HEIGHT - 10;
|
||||
const GAUGE_RADIUS = 90;
|
||||
const GAUGE_STROKE = 16;
|
||||
|
||||
/** 极坐标 → 笛卡尔坐标(角度以度计,0°=正左,180°=正右,沿上半圆)。 */
|
||||
function polar(angleDeg: number): { readonly x: number; readonly y: number } {
|
||||
const rad = (Math.PI * (180 - angleDeg)) / 180;
|
||||
return {
|
||||
x: GAUGE_CX + GAUGE_RADIUS * Math.cos(rad),
|
||||
y: GAUGE_CY - GAUGE_RADIUS * Math.sin(rad),
|
||||
};
|
||||
}
|
||||
|
||||
/** 构造从 `startDeg` 到 `endDeg` 的半圆弧 path 数据(上半圆,0..180°)。 */
|
||||
function arcPath(startDeg: number, endDeg: number): string {
|
||||
const start = polar(startDeg);
|
||||
const end = polar(endDeg);
|
||||
const largeArc = endDeg - startDeg > 180 ? 1 : 0;
|
||||
return `M ${start.x} ${start.y} A ${GAUGE_RADIUS} ${GAUGE_RADIUS} 0 ${largeArc} 1 ${end.x} ${end.y}`;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 仪表盘图形(SVG)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function GaugeArc({
|
||||
score,
|
||||
gradeColorToken,
|
||||
}: {
|
||||
readonly score: number;
|
||||
readonly gradeColorToken: ColorToken;
|
||||
}): JSX.Element {
|
||||
// 分值在 [0,100] 线性映射到半圆 [0°,180°]。
|
||||
const sweep = (score - SCORE_MIN) / (SCORE_MAX - SCORE_MIN) * 180;
|
||||
return (
|
||||
<svg
|
||||
width={GAUGE_WIDTH}
|
||||
height={GAUGE_HEIGHT}
|
||||
viewBox={`0 0 ${GAUGE_WIDTH} ${GAUGE_HEIGHT}`}
|
||||
role="img"
|
||||
aria-hidden={true}
|
||||
focusable={false}
|
||||
data-gauge-arc="true"
|
||||
>
|
||||
{/* 轨道:完整半圆底色。 */}
|
||||
<path
|
||||
d={arcPath(0, 180)}
|
||||
fill="none"
|
||||
stroke={colorVar('color.border.default')}
|
||||
strokeWidth={GAUGE_STROKE}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* 进度弧:长度对应分值,配色取自 Risk_Grade 的 Color_Token。 */}
|
||||
{sweep > 0 ? (
|
||||
<path
|
||||
d={arcPath(0, sweep)}
|
||||
fill="none"
|
||||
stroke={colorVar(gradeColorToken)}
|
||||
strokeWidth={GAUGE_STROKE}
|
||||
strokeLinecap="round"
|
||||
data-gauge-progress="true"
|
||||
/>
|
||||
) : null}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* ScoreGauge
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/** `ScoreGauge` 组件属性。 */
|
||||
export interface ScoreGaugeProps {
|
||||
/** Risk_Score 数值(期望 0 至 100;越界将被夹取以稳健渲染)。 */
|
||||
readonly score: number;
|
||||
/** 数据是否正在计算/请求中(true → 由容器呈现 Loading_State,Req 20.5)。 */
|
||||
readonly loading?: boolean;
|
||||
/** 可选标题,缺省为「风险总分」。 */
|
||||
readonly title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 风险总分仪表盘:同时呈现 Risk_Score 数值与其对应 Risk_Grade(Req 20.6)。
|
||||
*
|
||||
* 等级由 `classifyGrade(score)` 派生(与领域分级规则一致),等级配色取自
|
||||
* `riskGradeColorToken(grade)`。分值与等级文本均以可见 DOM 文本节点呈现,
|
||||
* 并附 `data-risk-score` / `data-risk-grade` 便于测试与无障碍读取。
|
||||
*/
|
||||
export function ScoreGauge({
|
||||
score,
|
||||
loading,
|
||||
title,
|
||||
}: ScoreGaugeProps): JSX.Element {
|
||||
const safeScore = clampScore(score);
|
||||
const grade = classifyGrade(safeScore);
|
||||
const gradeColorToken = riskGradeColorToken(grade);
|
||||
|
||||
// 单一系列的仪表盘:以一个数据点承载分值,使容器进入就绪态并渲染图形。
|
||||
const series: Series[] = loading === true
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: 'risk-score',
|
||||
label: '风险总分',
|
||||
encoding: {
|
||||
colorToken: gradeColorToken,
|
||||
textLabel: `风险总分(${grade})`,
|
||||
pattern: 'solid',
|
||||
},
|
||||
points: [{ label: `Risk_Score:${safeScore}`, value: safeScore }],
|
||||
},
|
||||
];
|
||||
|
||||
const labels: Label[] = [
|
||||
{ kind: 'axis', text: 'Risk_Score(0–100)' },
|
||||
{ kind: 'partition', text: `Risk_Grade:${grade}` },
|
||||
];
|
||||
|
||||
const spec: ChartSpec = {
|
||||
type: 'ScoreGauge',
|
||||
status: loading === true ? 'loading' : 'ready',
|
||||
series,
|
||||
labels,
|
||||
title: title ?? '风险总分',
|
||||
emptyMessage: '暂无可展示的风险总分',
|
||||
loadingMessage: '正在计算风险总分…',
|
||||
};
|
||||
|
||||
return (
|
||||
<ChartContainer spec={spec}>
|
||||
<div
|
||||
data-score-gauge="true"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: `${space(2)}px`,
|
||||
fontFamily: FONT_FAMILY,
|
||||
}}
|
||||
>
|
||||
<GaugeArc score={safeScore} gradeColorToken={gradeColorToken} />
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: `${space(1)}px`,
|
||||
}}
|
||||
>
|
||||
{/* Risk_Score 数值(Req 20.6)。 */}
|
||||
<span
|
||||
data-risk-score={safeScore}
|
||||
aria-label={`风险总分 ${safeScore}`}
|
||||
style={{
|
||||
...typographyStyle('display'),
|
||||
fontWeight: 700,
|
||||
color: colorVar(gradeColorToken),
|
||||
}}
|
||||
>
|
||||
{safeScore}
|
||||
</span>
|
||||
{/* Risk_Grade 文本标签(Req 20.6)。 */}
|
||||
<span
|
||||
data-risk-grade={grade}
|
||||
aria-label={`风险分级 ${grade}`}
|
||||
style={{
|
||||
...typographyStyle('title'),
|
||||
fontWeight: 700,
|
||||
color: colorVar(gradeColorToken),
|
||||
}}
|
||||
>
|
||||
{grade}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</ChartContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* TopNRiskChart — Top N 关键风险条形图(task 19.2,Req 20.1)。
|
||||
*
|
||||
* 消费 Scoring_Engine 的关键风险清单(`RiskItemInput[]`),经 `buildTopNSpec` 构造
|
||||
* 视图模型并以通用 `ChartContainer` 承载三态与标签一致性(Req 20.2–20.5)。图形以
|
||||
* recharts `BarChart` 呈现,每个条形按「维度/指标」分类(X 轴)并以得分高度表示,
|
||||
* 条端以 `LabelList` 标注得分数值。
|
||||
*
|
||||
* 配色经 Color_Token 的 CSS 自定义属性引用(`var(--color-risk-high)`,Req 19.6)。
|
||||
* recharts 在 jsdom 下绘制有限,故使用显式宽高的 `BarChart` 以保证可渲染;同时每个
|
||||
* 风险项的「维度/指标(得分)」文本由容器的标签区渲染于 DOM(非 canvas),便于标签
|
||||
* 属性测试(task 19.6–19.11)定位。
|
||||
*/
|
||||
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
LabelList,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { ChartContainer } from './ChartContainer.js';
|
||||
import { buildTopNSpec } from './helpers.js';
|
||||
import type { RiskItemInput } from './chart-types.js';
|
||||
|
||||
/** `TopNRiskChart` 组件属性。 */
|
||||
export interface TopNRiskChartProps {
|
||||
/** Scoring_Engine 输出的关键风险项(建议已按得分降序)。 */
|
||||
readonly items: readonly RiskItemInput[];
|
||||
/** 是否加载中(驱动 Loading_State,Req 20.5)。 */
|
||||
readonly loading?: boolean;
|
||||
/** 图表标题;缺省由 `buildTopNSpec` 提供默认非空标题。 */
|
||||
readonly title?: string;
|
||||
/** 图形区宽度(px),默认 640。 */
|
||||
readonly width?: number;
|
||||
/** 图形区高度(px),默认 320。 */
|
||||
readonly height?: number;
|
||||
}
|
||||
|
||||
/** 单个条形的渲染数据。 */
|
||||
interface BarDatum {
|
||||
/** 分类标签:维度/指标。 */
|
||||
readonly name: string;
|
||||
/** 条形高度:得分。 */
|
||||
readonly score: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Top N 关键风险条形图。空数据/加载态由容器接管;就绪态渲染 recharts 条形图,
|
||||
* 条形按维度/指标分类、按得分取高度,并在条端标注得分。
|
||||
*/
|
||||
export function TopNRiskChart({
|
||||
items,
|
||||
loading,
|
||||
title,
|
||||
width = 640,
|
||||
height = 320,
|
||||
}: TopNRiskChartProps): JSX.Element {
|
||||
const options: { loading?: boolean; title?: string } = {};
|
||||
if (loading !== undefined) {
|
||||
options.loading = loading;
|
||||
}
|
||||
if (title !== undefined) {
|
||||
options.title = title;
|
||||
}
|
||||
const spec = buildTopNSpec(items, options);
|
||||
|
||||
const data: BarDatum[] = items.map((item) => ({
|
||||
name: `${item.dimensionId} / ${item.indicatorId}`,
|
||||
score: item.score,
|
||||
}));
|
||||
|
||||
return (
|
||||
<ChartContainer spec={spec}>
|
||||
<BarChart
|
||||
width={width}
|
||||
height={height}
|
||||
data={data}
|
||||
layout="vertical"
|
||||
margin={{ top: 8, right: 48, bottom: 8, left: 8 }}
|
||||
data-topn-chart="true"
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border-default)" />
|
||||
<XAxis
|
||||
type="number"
|
||||
dataKey="score"
|
||||
stroke="var(--color-text-secondary)"
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
width={180}
|
||||
stroke="var(--color-text-secondary)"
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="score"
|
||||
fill="var(--color-risk-high)"
|
||||
isAnimationActive={false}
|
||||
name="关键风险得分"
|
||||
>
|
||||
<LabelList
|
||||
dataKey="score"
|
||||
position="right"
|
||||
style={{ fill: 'var(--color-text-primary)', fontSize: 12 }}
|
||||
/>
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* PortfolioCompareChart 单元测试(task 19.5,Req 20.1)。
|
||||
*
|
||||
* 验证:
|
||||
* - 就绪态并呈各项目标签、Risk_Score 与 Risk_Grade(可见 DOM 文本,Property 69)。
|
||||
* - 项目数 ≥2 时呈现图例,且图例标签与数据元素(项目)标签一致(Property 68)。
|
||||
* - 每个项目以其 Risk_Grade 的稳定 Color_Token 着色(Req 19.6 / Property 64)。
|
||||
* - 空集 → Empty_State + 非空提示(Req 20.4);loading → Loading_State(Req 20.5)。
|
||||
*
|
||||
* 注:跨全输入空间的属性测试属 task 19.6–19.11,本文件仅覆盖代表性样例与边界。
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import { axe } from 'vitest-axe';
|
||||
import { riskGradeColorToken } from '../../design-system/index.js';
|
||||
import {
|
||||
PortfolioCompareChart,
|
||||
buildPortfolioCompareSpec,
|
||||
type PortfolioCompareRow,
|
||||
} from '../PortfolioCompareChart.js';
|
||||
import { legendMatchesData, labelsComplete } from '../helpers.js';
|
||||
|
||||
const ROWS: readonly PortfolioCompareRow[] = [
|
||||
{ assessmentId: 'a-1', label: '项目甲', riskScore: 20, riskGrade: '低' },
|
||||
{ assessmentId: 'a-2', label: '项目乙', riskScore: 60, riskGrade: '高' },
|
||||
{
|
||||
assessmentId: 'a-3',
|
||||
label: '项目丙',
|
||||
riskScore: 88,
|
||||
riskGrade: '极高',
|
||||
keyRisks: [{ dimensionId: '财务', indicatorId: '现金流', score: 4 }],
|
||||
},
|
||||
];
|
||||
|
||||
describe('buildPortfolioCompareSpec(纯派生)', () => {
|
||||
it('每个项目为一个数据类别,类型为 PortfolioCompare', () => {
|
||||
const spec = buildPortfolioCompareSpec(ROWS);
|
||||
expect(spec.type).toBe('PortfolioCompare');
|
||||
expect(spec.status).toBe('ready');
|
||||
expect(spec.series).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('≥2 项目时图例标签集合与数据元素标签集合一致(Property 68)', () => {
|
||||
const spec = buildPortfolioCompareSpec(ROWS);
|
||||
expect(spec.legend).toBeDefined();
|
||||
expect(legendMatchesData(spec)).toBe(true);
|
||||
});
|
||||
|
||||
it('全部数据元素文本标签非空(Property 69)', () => {
|
||||
expect(labelsComplete(buildPortfolioCompareSpec(ROWS))).toBe(true);
|
||||
});
|
||||
|
||||
it('每个项目以其 Risk_Grade 的稳定 Color_Token 着色', () => {
|
||||
const spec = buildPortfolioCompareSpec(ROWS);
|
||||
for (const [index, row] of ROWS.entries()) {
|
||||
expect(spec.series[index]?.encoding.colorToken).toBe(
|
||||
riskGradeColorToken(row.riskGrade),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('空集 → empty 状态并附非空提示', () => {
|
||||
const spec = buildPortfolioCompareSpec([]);
|
||||
expect(spec.status).toBe('empty');
|
||||
expect(spec.emptyMessage).toBeTruthy();
|
||||
});
|
||||
|
||||
it('loading 优先于 empty', () => {
|
||||
expect(buildPortfolioCompareSpec([], { loading: true }).status).toBe('loading');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PortfolioCompareChart(跨项目组合对比图)', () => {
|
||||
it('并呈各项目标签、Risk_Score 与 Risk_Grade 文本', () => {
|
||||
render(<PortfolioCompareChart rows={ROWS} />);
|
||||
|
||||
const table = screen.getByRole('table');
|
||||
for (const row of ROWS) {
|
||||
const tr = within(table).getByText(row.label).closest('tr');
|
||||
expect(tr).not.toBeNull();
|
||||
expect(within(tr as HTMLElement).getByText(String(row.riskScore))).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it('项目数 ≥2 时呈现图例', () => {
|
||||
const { container } = render(<PortfolioCompareChart rows={ROWS} />);
|
||||
const legend = container.querySelector('[data-chart-legend="true"]');
|
||||
expect(legend).not.toBeNull();
|
||||
for (const row of ROWS) {
|
||||
expect(
|
||||
container.querySelector(`[data-legend-item="${row.label}"]`),
|
||||
).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('空集呈现 Empty_State(Req 20.4)', () => {
|
||||
render(<PortfolioCompareChart rows={[]} />);
|
||||
const status = screen.getByRole('status');
|
||||
expect(status).toHaveAttribute('data-chart-state', 'empty');
|
||||
expect(
|
||||
within(status).getByText('暂无可对比的项目(至少需选择 2 个评估)'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('loading 态呈现 Loading_State(Req 20.5)', () => {
|
||||
render(<PortfolioCompareChart rows={ROWS} loading={true} />);
|
||||
const status = screen.getByRole('status');
|
||||
expect(status).toHaveAttribute('data-chart-state', 'loading');
|
||||
});
|
||||
|
||||
it('无明显可访问性违规', async () => {
|
||||
const { container } = render(<PortfolioCompareChart rows={ROWS} />);
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* ScoreGauge + 本地 classifyGrade 单元测试(task 19.3,Req 20.1 / 20.6)。
|
||||
*
|
||||
* 验证:
|
||||
* - 本地 classifyGrade 边界归属与领域分级规则逐字一致
|
||||
* ([0,25]→低、(25,50]→中、(50,75]→高、(75,100]→极高)。
|
||||
* - 仪表盘同时呈现 Risk_Score 数值与对应 Risk_Grade 文本(Req 20.6),
|
||||
* 且所呈现等级恒等于 classifyGrade(score)。
|
||||
* - loading 态由容器呈现 Loading_State。
|
||||
*
|
||||
* 注:跨全取值域的属性测试(Property 71)属 task 19.9,本文件不实现。
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import { axe } from 'vitest-axe';
|
||||
import { ScoreGauge } from '../ScoreGauge.js';
|
||||
import { classifyGrade, RISK_GRADE_VALUES } from '../riskGrade.js';
|
||||
|
||||
describe('classifyGrade(本地镜像领域分级规则)', () => {
|
||||
it('边界值精确归属:右闭左开衔接,首区间左闭', () => {
|
||||
expect(classifyGrade(0)).toBe('低');
|
||||
expect(classifyGrade(25)).toBe('低');
|
||||
expect(classifyGrade(26)).toBe('中');
|
||||
expect(classifyGrade(50)).toBe('中');
|
||||
expect(classifyGrade(51)).toBe('高');
|
||||
expect(classifyGrade(75)).toBe('高');
|
||||
expect(classifyGrade(76)).toBe('极高');
|
||||
expect(classifyGrade(100)).toBe('极高');
|
||||
});
|
||||
|
||||
it('输出恒为四级合法 Risk_Grade 之一', () => {
|
||||
for (let score = 0; score <= 100; score += 1) {
|
||||
expect(RISK_GRADE_VALUES).toContain(classifyGrade(score));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('ScoreGauge(风险总分仪表盘)', () => {
|
||||
it('同时呈现 Risk_Score 数值与对应 Risk_Grade(Req 20.6)', () => {
|
||||
render(<ScoreGauge score={60} />);
|
||||
|
||||
const scoreNode = screen.getByLabelText('风险总分 60');
|
||||
expect(scoreNode).toBeInTheDocument();
|
||||
expect(scoreNode).toHaveTextContent('60');
|
||||
|
||||
// 60 → (50,75] → 高
|
||||
const gradeNode = screen.getByLabelText('风险分级 高');
|
||||
expect(gradeNode).toBeInTheDocument();
|
||||
expect(gradeNode).toHaveTextContent('高');
|
||||
});
|
||||
|
||||
it('所呈现等级恒等于 classifyGrade(score)(边界样例)', () => {
|
||||
for (const score of [0, 25, 26, 50, 51, 75, 76, 100]) {
|
||||
const expectedGrade = classifyGrade(score);
|
||||
const { unmount } = render(<ScoreGauge score={score} />);
|
||||
expect(screen.getByLabelText(`风险分级 ${expectedGrade}`)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(`风险总分 ${score}`)).toHaveTextContent(
|
||||
String(score),
|
||||
);
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('越界分值被夹取到 [0,100]', () => {
|
||||
render(<ScoreGauge score={150} />);
|
||||
expect(screen.getByLabelText('风险总分 100')).toHaveTextContent('100');
|
||||
expect(screen.getByLabelText('风险分级 极高')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('loading 态呈现 Loading_State(Req 20.5)', () => {
|
||||
render(<ScoreGauge score={42} loading={true} />);
|
||||
const status = screen.getByRole('status');
|
||||
expect(status).toHaveAttribute('data-chart-state', 'loading');
|
||||
expect(within(status).getByText('正在计算风险总分…')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('无明显可访问性违规', async () => {
|
||||
const { container } = render(<ScoreGauge score={30} />);
|
||||
const results = await axe(container);
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* 全套图表组件渲染单元测试(task 19.12,Req 20.1)。
|
||||
*
|
||||
* Req 20.1 规定系统应提供全套图表组件。本文件以代表性非空数据逐一渲染七类图表,
|
||||
* 断言「可渲染且关键内容呈现于 DOM」:
|
||||
* 1. RiskHeatmap —— 热力图:表格 + 数值 Risk_Level 单元格
|
||||
* 2. ScoreGauge —— 仪表盘:Risk_Score 数值 + Risk_Grade 文本
|
||||
* 3. RiskBadge —— 徽章:Risk_Grade 文字标签
|
||||
* 4. TopNRiskChart —— Top N:关键风险得分标签
|
||||
* 5. CostBreakdownChart —— 费用拆解:各拆解项名称 + 金额
|
||||
* 6. QuoteCompareChart —— 报价对比:基准 / 风险调整后 / 差额 三值
|
||||
* 7. PortfolioCompareChart —— 组合对比:各项目 Risk_Score 与 Risk_Grade
|
||||
*
|
||||
* 关注「渲染 + 关键内容存在」,不重复验证标签/状态等属性级不变量(task 19.6–19.11
|
||||
* 已覆盖)。组件以 Color_Token 经 CSS 变量取色,无需 ThemeProvider 即可渲染。
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import {
|
||||
RiskHeatmap,
|
||||
ScoreGauge,
|
||||
RiskBadge,
|
||||
TopNRiskChart,
|
||||
CostBreakdownChart,
|
||||
QuoteCompareChart,
|
||||
PortfolioCompareChart,
|
||||
type HeatmapCellInput,
|
||||
type RiskItemInput,
|
||||
type CostBreakdownItemInput,
|
||||
type PortfolioCompareRow,
|
||||
} from '../index.js';
|
||||
|
||||
const HEATMAP_CELLS: readonly HeatmapCellInput[] = [
|
||||
{ dimensionId: '财务', indicatorId: '现金流', riskLevel: 4 },
|
||||
{ dimensionId: '财务', indicatorId: '负债率', riskLevel: 2 },
|
||||
{ dimensionId: '合规', indicatorId: '资质', riskLevel: 5 },
|
||||
];
|
||||
|
||||
const TOP_N_ITEMS: readonly RiskItemInput[] = [
|
||||
{ dimensionId: '财务', indicatorId: '现金流', score: 80, rationale: '现金流紧张' },
|
||||
{ dimensionId: '合规', indicatorId: '资质', score: 65, rationale: '资质不全' },
|
||||
];
|
||||
|
||||
const COST_ITEMS: readonly CostBreakdownItemInput[] = [
|
||||
{ name: '垫资利息', amount: 100000 },
|
||||
{ name: '保函费用', amount: 25000 },
|
||||
];
|
||||
|
||||
const PORTFOLIO_ROWS: readonly PortfolioCompareRow[] = [
|
||||
{ assessmentId: 'a-1', label: '项目甲', riskScore: 20, riskGrade: '低' },
|
||||
{ assessmentId: 'a-2', label: '项目乙', riskScore: 88, riskGrade: '极高' },
|
||||
];
|
||||
|
||||
describe('全套图表组件渲染(task 19.12,Req 20.1)', () => {
|
||||
it('1. RiskHeatmap:渲染表格与数值 Risk_Level 单元格', () => {
|
||||
const { container } = render(<RiskHeatmap cells={HEATMAP_CELLS} />);
|
||||
|
||||
// 容器就绪态 + 类型标识。
|
||||
expect(container.querySelector('[data-chart-type="Heatmap"]')).not.toBeNull();
|
||||
// 网格表格存在。
|
||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||
// 有数据单元格附数值等级标签。
|
||||
const filled = screen.getByLabelText('财务 / 现金流:风险等级 4');
|
||||
expect(filled).toHaveTextContent('4');
|
||||
expect(screen.getByLabelText('合规 / 资质:风险等级 5')).toHaveTextContent('5');
|
||||
});
|
||||
|
||||
it('2. ScoreGauge:同时呈现 Risk_Score 数值与 Risk_Grade 文本', () => {
|
||||
render(<ScoreGauge score={60} />);
|
||||
|
||||
expect(screen.getByLabelText('风险总分 60')).toHaveTextContent('60');
|
||||
// 60 → (50,75] → 高
|
||||
expect(screen.getByLabelText('风险分级 高')).toHaveTextContent('高');
|
||||
});
|
||||
|
||||
it('3. RiskBadge:始终呈现 Risk_Grade 文字标签', () => {
|
||||
render(<RiskBadge grade="高" prefix="风险等级" />);
|
||||
|
||||
const badge = screen.getByRole('status');
|
||||
expect(badge).toHaveAttribute('data-risk-grade', '高');
|
||||
expect(within(badge).getByText('高')).toBeInTheDocument();
|
||||
expect(within(badge).getByText('风险等级')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('4. TopNRiskChart:渲染关键风险得分标签', () => {
|
||||
const { container } = render(<TopNRiskChart items={TOP_N_ITEMS} />);
|
||||
|
||||
expect(container.querySelector('[data-chart-type="TopNRiskChart"]')).not.toBeNull();
|
||||
// 容器标签区以可见 DOM 文本呈现各关键风险(含得分)。
|
||||
expect(screen.getByText(/财务 \/ 现金流(得分 80)/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/合规 \/ 资质(得分 65)/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('5. CostBreakdownChart:渲染各费用拆解项名称与金额', () => {
|
||||
const { container } = render(<CostBreakdownChart items={COST_ITEMS} />);
|
||||
|
||||
expect(container.querySelector('[data-chart-type="CostBreakdown"]')).not.toBeNull();
|
||||
const interest = container.querySelector('[data-cost-item="垫资利息"]');
|
||||
expect(interest).not.toBeNull();
|
||||
expect(interest).toHaveTextContent('垫资利息');
|
||||
expect(interest).toHaveTextContent('100,000.00 元');
|
||||
const guarantee = container.querySelector('[data-cost-item="保函费用"]');
|
||||
expect(guarantee).toHaveTextContent('保函费用');
|
||||
expect(guarantee).toHaveTextContent('25,000.00 元');
|
||||
});
|
||||
|
||||
it('6. QuoteCompareChart:呈现基准 / 风险调整后 / 差额 三个数值', () => {
|
||||
const { container } = render(
|
||||
<QuoteCompareChart quote={{ baselineQuote: 1000000, riskAdjustedQuote: 1200000 }} />,
|
||||
);
|
||||
|
||||
expect(container.querySelector('[data-chart-type="QuoteCompare"]')).not.toBeNull();
|
||||
|
||||
const baseline = container.querySelector('[data-quote-baseline]');
|
||||
const riskAdjusted = container.querySelector('[data-quote-risk-adjusted]');
|
||||
const difference = container.querySelector('[data-quote-difference]');
|
||||
|
||||
expect(baseline).toHaveAttribute('data-quote-baseline', '1000000');
|
||||
expect(riskAdjusted).toHaveAttribute('data-quote-risk-adjusted', '1200000');
|
||||
// 差额 = 风险调整后 − 基准 = 200000
|
||||
expect(difference).toHaveAttribute('data-quote-difference', '200000');
|
||||
expect(difference).toHaveTextContent('200,000.00 元');
|
||||
});
|
||||
|
||||
it('7. PortfolioCompareChart:并呈各项目 Risk_Score 与 Risk_Grade', () => {
|
||||
render(<PortfolioCompareChart rows={PORTFOLIO_ROWS} />);
|
||||
|
||||
const table = screen.getByRole('table');
|
||||
for (const row of PORTFOLIO_ROWS) {
|
||||
const tr = within(table).getByText(row.label).closest('tr');
|
||||
expect(tr).not.toBeNull();
|
||||
expect(
|
||||
within(tr as HTMLElement).getByText(String(row.riskScore)),
|
||||
).toBeInTheDocument();
|
||||
// Risk_Grade 徽章呈现该等级文字。
|
||||
expect(
|
||||
within(tr as HTMLElement).getByText(row.riskGrade),
|
||||
).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Property 79: 图表非纯颜色编码 的属性化测试(通用 Chart 契约,Req 23.6)。
|
||||
*
|
||||
* 属性陈述:对任意 Chart 中的数据类别,必存在颜色之外的区分编码(文本标签或图案),
|
||||
* 使该类别在不依赖颜色的情况下仍可识别。
|
||||
*
|
||||
* 实现语义(见 helpers.ts `isDistinctlyEncoded`):当某类别 `encoding.textLabel`
|
||||
* 去除首尾空白后非空,即视为「颜色之外可区分」(文本标签本身不依赖颜色)。
|
||||
* 因此本测试以「文本标签是否非空」为可区分性的判据:
|
||||
* - 任意含非空 textLabel 的 encoding → isDistinctlyEncoded === true。
|
||||
* - 系列数组中每个 encoding 的 textLabel 均非空 → allCategoriesDistinct === true。
|
||||
* - 反例:textLabel 为空/纯空白 → isDistinctlyEncoded === false
|
||||
* (判据确实要求非颜色线索,而非恒真)。
|
||||
* - 由 buildHeatmapSpec / buildTopNSpec 自非空输入构造的 spec,其全部类别均
|
||||
* 可区分,且每个 encoding 同时具备非空 textLabel 与取自 PATTERN_SEQUENCE 的图案。
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 79: 图表非纯颜色编码
|
||||
* Validates: Requirements 23.6
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import {
|
||||
allCategoriesDistinct,
|
||||
buildHeatmapSpec,
|
||||
buildTopNSpec,
|
||||
categoryEncodings,
|
||||
isDistinctlyEncoded,
|
||||
PATTERN_SEQUENCE,
|
||||
} from '../index.js';
|
||||
import type {
|
||||
CategoryEncoding,
|
||||
ChartPattern,
|
||||
HeatmapCellInput,
|
||||
RiskItemInput,
|
||||
RiskLevel,
|
||||
Series,
|
||||
} from '../index.js';
|
||||
import type { ColorToken } from '../../design-system/index.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 生成器
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/** 合法的配色令牌名(数据编码色子集,足以覆盖 colorToken 字段)。 */
|
||||
const colorTokenArb: fc.Arbitrary<ColorToken> = fc.constantFrom<ColorToken>(
|
||||
'color.risk.low',
|
||||
'color.risk.medium',
|
||||
'color.risk.high',
|
||||
'color.risk.critical',
|
||||
'color.heat.1',
|
||||
'color.heat.2',
|
||||
'color.heat.3',
|
||||
'color.heat.4',
|
||||
'color.heat.5',
|
||||
);
|
||||
|
||||
/** 合法的图案枚举值(取自实现的 PATTERN_SEQUENCE)。 */
|
||||
const patternArb: fc.Arbitrary<ChartPattern> = fc.constantFrom<ChartPattern>(
|
||||
...PATTERN_SEQUENCE,
|
||||
);
|
||||
|
||||
/**
|
||||
* 非空文本标签生成器:至少含一个非空白字符。
|
||||
* 在任意字符串前拼接一个固定的非空白字符,确保 trim() 后长度 ≥1。
|
||||
*/
|
||||
const nonEmptyTextLabelArb: fc.Arbitrary<string> = fc
|
||||
.string({ maxLength: 12 })
|
||||
.map((s) => `类别${s}`);
|
||||
|
||||
/** 含非空 textLabel 的类别编码。 */
|
||||
const distinctEncodingArb: fc.Arbitrary<CategoryEncoding> = fc.record({
|
||||
colorToken: colorTokenArb,
|
||||
textLabel: nonEmptyTextLabelArb,
|
||||
pattern: patternArb,
|
||||
});
|
||||
|
||||
/** 单个数据点:非空标签 + 数值。 */
|
||||
const dataPointArb = fc.record({
|
||||
label: fc.string({ minLength: 1, maxLength: 10 }),
|
||||
value: fc.double({ noNaN: true, noDefaultInfinity: true }),
|
||||
});
|
||||
|
||||
/** 含非空 textLabel 编码的数据系列。 */
|
||||
const distinctSeriesArb: fc.Arbitrary<Series> = fc.record({
|
||||
id: fc.string({ minLength: 1, maxLength: 8 }),
|
||||
label: fc.string({ minLength: 1, maxLength: 12 }),
|
||||
encoding: distinctEncodingArb,
|
||||
points: fc.array(dataPointArb, { maxLength: 4 }),
|
||||
});
|
||||
|
||||
/** 系列数组:长度 0..6。 */
|
||||
const distinctSeriesArrayArb: fc.Arbitrary<Series[]> = fc.array(distinctSeriesArb, {
|
||||
minLength: 0,
|
||||
maxLength: 6,
|
||||
});
|
||||
|
||||
/** 空/纯空白文本(用于反例)。 */
|
||||
const blankTextArb: fc.Arbitrary<string> = fc.constantFrom('', ' ', ' ', '\t', '\n', ' \t\n ');
|
||||
|
||||
/** 风险等级(1–5)。 */
|
||||
const riskLevelArb: fc.Arbitrary<RiskLevel> = fc.constantFrom<RiskLevel>(1, 2, 3, 4, 5);
|
||||
|
||||
/** 热力图单元格输入生成器。 */
|
||||
const heatmapCellArb: fc.Arbitrary<HeatmapCellInput> = fc.record({
|
||||
dimensionId: fc.string({ minLength: 1, maxLength: 6 }),
|
||||
indicatorId: fc.string({ minLength: 1, maxLength: 6 }),
|
||||
riskLevel: riskLevelArb,
|
||||
});
|
||||
|
||||
/** 关键风险项输入生成器。 */
|
||||
const riskItemArb: fc.Arbitrary<RiskItemInput> = fc.record({
|
||||
dimensionId: fc.string({ minLength: 1, maxLength: 6 }),
|
||||
indicatorId: fc.string({ minLength: 1, maxLength: 6 }),
|
||||
score: fc.double({ noNaN: true, noDefaultInfinity: true, min: 0, max: 100 }),
|
||||
rationale: fc.string({ minLength: 1, maxLength: 20 }),
|
||||
});
|
||||
|
||||
describe('Property 79: 图表非纯颜色编码', () => {
|
||||
it('含非空 textLabel 的 encoding → isDistinctlyEncoded 为真', () => {
|
||||
fc.assert(
|
||||
fc.property(distinctEncodingArb, (encoding) => {
|
||||
expect(isDistinctlyEncoded(encoding)).toBe(true);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('每个 encoding 的 textLabel 均非空 → allCategoriesDistinct 为真', () => {
|
||||
fc.assert(
|
||||
fc.property(distinctSeriesArrayArb, (series) => {
|
||||
expect(allCategoriesDistinct(series)).toBe(true);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('反例:textLabel 为空/纯空白 → isDistinctlyEncoded 为假(判据确需非颜色线索)', () => {
|
||||
fc.assert(
|
||||
fc.property(colorTokenArb, patternArb, blankTextArb, (colorToken, pattern, textLabel) => {
|
||||
const encoding: CategoryEncoding = { colorToken, textLabel, pattern };
|
||||
expect(isDistinctlyEncoded(encoding)).toBe(false);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('buildHeatmapSpec:非空输入构造的全部类别可区分,且具备非空 textLabel + 合法图案', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.array(heatmapCellArb, { minLength: 1, maxLength: 8 }), (cells) => {
|
||||
const spec = buildHeatmapSpec(cells);
|
||||
expect(allCategoriesDistinct(spec.series)).toBe(true);
|
||||
for (const encoding of categoryEncodings(spec.series)) {
|
||||
expect(encoding.textLabel.trim().length).toBeGreaterThan(0);
|
||||
expect(PATTERN_SEQUENCE).toContain(encoding.pattern);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('buildTopNSpec:非空输入构造的全部类别可区分,且具备非空 textLabel + 合法图案', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.array(riskItemArb, { minLength: 1, maxLength: 8 }), (items) => {
|
||||
const spec = buildTopNSpec(items);
|
||||
expect(allCategoriesDistinct(spec.series)).toBe(true);
|
||||
for (const encoding of categoryEncodings(spec.series)) {
|
||||
expect(encoding.textLabel.trim().length).toBeGreaterThan(0);
|
||||
expect(PATTERN_SEQUENCE).toContain(encoding.pattern);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Property 69: 图表文本标签齐备 的属性化测试(Charts,Req 20.3)。
|
||||
*
|
||||
* 属性陈述:对任意 Chart,其每个坐标轴、数据点或分区必具有非空的文本标签。
|
||||
*
|
||||
* 本测试从两个层面验证:
|
||||
* 1. 经 `buildHeatmapSpec` / `buildTopNSpec` 由任意「非空领域输入」构造的 ChartSpec,
|
||||
* `labelsComplete(spec)` 恒为 true —— 即全部 axis/point/partition 标签以及
|
||||
* 每个数据点的标签文本均非空。
|
||||
* 2. 纯谓词 `allLabelsNonEmpty` 本身可信:对任意「文本非空」的 Label 数组返回 true;
|
||||
* 而一旦数组中混入空/空白文本的 Label,则返回 false —— 证明谓词确能检出缺失标签。
|
||||
*
|
||||
* 领域不变量:Dimension/Indicator 标识恒为非空字符串,故生成器对 dimensionId /
|
||||
* indicatorId 约束为非空白字符串,以保持「标签齐备」属性的语义意义。
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 69: 图表文本标签齐备
|
||||
* Validates: Requirements 20.3
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import {
|
||||
allLabelsNonEmpty,
|
||||
buildHeatmapSpec,
|
||||
buildTopNSpec,
|
||||
collectDataElementLabels,
|
||||
labelsComplete,
|
||||
} from '../index.js';
|
||||
import type {
|
||||
HeatmapCellInput,
|
||||
Label,
|
||||
LabelKind,
|
||||
RiskItemInput,
|
||||
RiskLevel,
|
||||
} from '../index.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 生成器
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/** 非空白字符串(trim 后长度 ≥1)——镜像「领域标识恒非空」不变量。 */
|
||||
const nonBlankStringArb: fc.Arbitrary<string> = fc
|
||||
.string({ minLength: 1, maxLength: 16 })
|
||||
.filter((s) => s.trim().length > 0);
|
||||
|
||||
/** 风险等级(1–5)。 */
|
||||
const riskLevelArb: fc.Arbitrary<RiskLevel> = fc.constantFrom<RiskLevel>(1, 2, 3, 4, 5);
|
||||
|
||||
/** 任意热力图单元格(标识非空白、等级合法)。 */
|
||||
const heatmapCellArb: fc.Arbitrary<HeatmapCellInput> = fc.record({
|
||||
dimensionId: nonBlankStringArb,
|
||||
indicatorId: nonBlankStringArb,
|
||||
riskLevel: riskLevelArb,
|
||||
});
|
||||
|
||||
/** 任意关键风险项(标识非空白、得分有限、判定依据任意)。 */
|
||||
const riskItemArb: fc.Arbitrary<RiskItemInput> = fc.record({
|
||||
dimensionId: nonBlankStringArb,
|
||||
indicatorId: nonBlankStringArb,
|
||||
score: fc.double({ noNaN: true, noDefaultInfinity: true }),
|
||||
rationale: fc.string({ maxLength: 32 }),
|
||||
});
|
||||
|
||||
/** 标签种类。 */
|
||||
const labelKindArb: fc.Arbitrary<LabelKind> = fc.constantFrom<LabelKind>(
|
||||
'axis',
|
||||
'point',
|
||||
'partition',
|
||||
);
|
||||
|
||||
/** 文本非空白的标签。 */
|
||||
const nonEmptyLabelArb: fc.Arbitrary<Label> = fc.record({
|
||||
kind: labelKindArb,
|
||||
text: nonBlankStringArb,
|
||||
});
|
||||
|
||||
/** 文本为空/纯空白的标签(用于构造反例)。 */
|
||||
const blankLabelArb: fc.Arbitrary<Label> = fc.record({
|
||||
kind: labelKindArb,
|
||||
text: fc.constantFrom('', ' ', ' ', '\t', '\n', ' \t\n '),
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 属性测试
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
describe('Property 69: 图表文本标签齐备 (Req 20.3)', () => {
|
||||
it('buildHeatmapSpec 由任意非空单元格构造的图表标签恒齐备', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(heatmapCellArb, { minLength: 1, maxLength: 20 }),
|
||||
fc.boolean(),
|
||||
(cells, loading) => {
|
||||
const spec = buildHeatmapSpec(cells, { loading });
|
||||
expect(labelsComplete(spec)).toBe(true);
|
||||
// 逐条断言收集到的数据元素文本均非空。
|
||||
for (const text of collectDataElementLabels(spec)) {
|
||||
expect(text.trim().length).toBeGreaterThan(0);
|
||||
}
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('buildTopNSpec 由任意非空风险项构造的图表标签恒齐备', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(riskItemArb, { minLength: 1, maxLength: 20 }),
|
||||
fc.boolean(),
|
||||
(items, loading) => {
|
||||
const spec = buildTopNSpec(items, { loading });
|
||||
expect(labelsComplete(spec)).toBe(true);
|
||||
for (const text of collectDataElementLabels(spec)) {
|
||||
expect(text.trim().length).toBeGreaterThan(0);
|
||||
}
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('allLabelsNonEmpty 对全部文本非空的标签数组恒为 true', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(nonEmptyLabelArb, { minLength: 0, maxLength: 20 }),
|
||||
(labels) => {
|
||||
expect(allLabelsNonEmpty(labels)).toBe(true);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('allLabelsNonEmpty 检出任意含空/空白文本标签的数组(返回 false)', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(nonEmptyLabelArb, { minLength: 0, maxLength: 10 }),
|
||||
blankLabelArb,
|
||||
fc.array(nonEmptyLabelArb, { minLength: 0, maxLength: 10 }),
|
||||
(before, blank, after) => {
|
||||
// 构造反例:在任意非空标签之间插入一条空白文本标签。
|
||||
const labels: Label[] = [...before, blank, ...after];
|
||||
expect(allLabelsNonEmpty(labels)).toBe(false);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Property 68: 图表图例与数据系列一致 的属性化测试(通用 Chart 契约,Req 20.2)。
|
||||
*
|
||||
* 属性陈述:对任意含两个及以上数据系列或类别的 Chart,其图例必存在,且图例标签
|
||||
* 集合恒与该 Chart 中对应数据元素的标签集合相等。系列/类别 <2 时无需图例。
|
||||
*
|
||||
* 本测试以智能生成器构造任意 Series 数组(系列数跨越 0..6,覆盖 <2 与 ≥2):
|
||||
* - 标签可重复亦可唯一,以检验「集合相等(忽略顺序、去重)」语义;
|
||||
* - encoding 的 colorToken 取自合法 ColorToken 名集合,pattern 取自合法 ChartPattern;
|
||||
* - points 各附非空标签,使 ChartSpec 在 status='ready' 下贴近真实形态。
|
||||
*
|
||||
* 断言:
|
||||
* - 系列 ≥2:deriveLegend(series) 的标签集合 == seriesLabels(series) 的集合
|
||||
* (labelSetsEqual);且以派生图例构造的 spec 满足 legendMatchesData === true。
|
||||
* - 系列 ≥2 且无 legend:legendMatchesData === false(图例必需)。
|
||||
* - 系列 <2:无论是否提供 legend,legendMatchesData === true(无需图例)。
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 68: 图表图例与数据系列一致
|
||||
* Validates: Requirements 20.2
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import {
|
||||
deriveLegend,
|
||||
labelSetsEqual,
|
||||
legendLabels,
|
||||
legendMatchesData,
|
||||
seriesLabels,
|
||||
shouldShowLegend,
|
||||
} from '../index.js';
|
||||
import type {
|
||||
ChartPattern,
|
||||
ChartSpec,
|
||||
Label,
|
||||
Series,
|
||||
} from '../index.js';
|
||||
import type { ColorToken } from '../../design-system/index.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 生成器:构造任意数据系列 Series。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/** 合法的配色令牌名(语义/数据编码色子集,足以覆盖编码字段)。 */
|
||||
const colorTokenArb: fc.Arbitrary<ColorToken> = fc.constantFrom<ColorToken>(
|
||||
'color.risk.low',
|
||||
'color.risk.medium',
|
||||
'color.risk.high',
|
||||
'color.risk.critical',
|
||||
'color.heat.1',
|
||||
'color.heat.2',
|
||||
'color.heat.3',
|
||||
'color.heat.4',
|
||||
'color.heat.5',
|
||||
);
|
||||
|
||||
/** 合法的图案枚举值。 */
|
||||
const patternArb: fc.Arbitrary<ChartPattern> = fc.constantFrom<ChartPattern>(
|
||||
'solid',
|
||||
'diagonal',
|
||||
'horizontal',
|
||||
'vertical',
|
||||
'grid',
|
||||
'dots',
|
||||
'crosshatch',
|
||||
);
|
||||
|
||||
/**
|
||||
* 标签生成器:从一个较小的取值池中抽取,以提升「标签重复」的概率,
|
||||
* 从而检验集合(去重)相等语义;同时偶尔产出唯一长标签覆盖唯一情形。
|
||||
*/
|
||||
const labelArb: fc.Arbitrary<string> = fc.oneof(
|
||||
fc.constantFrom('系列甲', '系列乙', '系列丙', '系列甲'),
|
||||
fc.string({ minLength: 1, maxLength: 12 }),
|
||||
);
|
||||
|
||||
/** 单个数据点:非空标签 + 数值。 */
|
||||
const dataPointArb = fc.record({
|
||||
label: fc.string({ minLength: 1, maxLength: 10 }),
|
||||
value: fc.double({ noNaN: true, noDefaultInfinity: true }),
|
||||
});
|
||||
|
||||
/** 单个数据系列。 */
|
||||
const seriesArb: fc.Arbitrary<Series> = fc.record({
|
||||
id: fc.string({ minLength: 1, maxLength: 8 }),
|
||||
label: labelArb,
|
||||
encoding: fc.record({
|
||||
colorToken: colorTokenArb,
|
||||
textLabel: fc.string({ minLength: 1, maxLength: 12 }),
|
||||
pattern: patternArb,
|
||||
}),
|
||||
points: fc.array(dataPointArb, { maxLength: 4 }),
|
||||
});
|
||||
|
||||
/** 系列数组:长度跨越 0..6,覆盖 <2 与 ≥2 两类。 */
|
||||
const seriesArrayArb: fc.Arbitrary<Series[]> = fc.array(seriesArb, {
|
||||
minLength: 0,
|
||||
maxLength: 6,
|
||||
});
|
||||
|
||||
/** 由系列数组构造 status='ready' 的 ChartSpec(legend 可选传入)。 */
|
||||
function buildSpec(
|
||||
series: readonly Series[],
|
||||
legend?: readonly { label: string; colorToken: ColorToken; pattern: ChartPattern }[],
|
||||
): ChartSpec {
|
||||
const labels: Label[] = [{ kind: 'axis', text: '轴' }];
|
||||
return {
|
||||
type: 'Heatmap',
|
||||
status: 'ready',
|
||||
series,
|
||||
...(legend !== undefined ? { legend } : {}),
|
||||
labels,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Property 68: 图表图例与数据系列一致', () => {
|
||||
it('系列 ≥2:派生图例的标签集合 == 数据元素标签集合,且 legendMatchesData 为真', () => {
|
||||
fc.assert(
|
||||
fc.property(seriesArrayArb, (series) => {
|
||||
fc.pre(shouldShowLegend(series)); // 仅检验 ≥2 情形
|
||||
const legend = deriveLegend(series);
|
||||
// 图例必存在且标签集合与数据系列标签集合相等。
|
||||
expect(labelSetsEqual(legendLabels(legend), seriesLabels(series))).toBe(true);
|
||||
const spec = buildSpec(series, legend);
|
||||
expect(legendMatchesData(spec)).toBe(true);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('系列 ≥2 且缺失图例:legendMatchesData 为假(图例必需)', () => {
|
||||
fc.assert(
|
||||
fc.property(seriesArrayArb, (series) => {
|
||||
fc.pre(shouldShowLegend(series));
|
||||
const spec = buildSpec(series); // 不提供 legend
|
||||
expect(legendMatchesData(spec)).toBe(false);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('系列 <2:无论是否提供图例,legendMatchesData 恒为真(无需图例)', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(seriesArb, { minLength: 0, maxLength: 1 }),
|
||||
fc.boolean(),
|
||||
(series, withLegend) => {
|
||||
const spec = withLegend
|
||||
? buildSpec(series, deriveLegend(series))
|
||||
: buildSpec(series);
|
||||
expect(legendMatchesData(spec)).toBe(true);
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Property 72: 费用对比图三值并呈且差额一致(Req 20.7)。
|
||||
*
|
||||
* 属性陈述:对任意基准报价与风险调整后报价对,费用对比图必同时呈现基准报价金额、
|
||||
* 风险调整后报价金额与二者差额,且所呈现差额恒等于风险调整后报价减基准报价。
|
||||
*
|
||||
* 本测试以智能生成器构造任意有限的 (baseline, riskAdjusted) 报价对(含正负、含
|
||||
* 大小数量级),并从两个层面验证:
|
||||
* - 纯函数层:`quoteDifference(baseline, riskAdjusted)` 恒等于 `riskAdjusted - baseline`。
|
||||
* - 渲染层:`<QuoteCompareChart quote={{...}} />` 同时渲染三个 `data-quote-*` 文本节点
|
||||
* (baseline / risk-adjusted / difference),三者皆出现于 DOM;其中 difference 节点
|
||||
* 承载的原始数值(取自 `data-quote-difference` 属性,非格式化文本)恒等于
|
||||
* `riskAdjusted - baseline`。
|
||||
*
|
||||
* 断言基于 `data-quote-*` 属性的原始数值而非 toLocaleString 后的展示文本,以避开
|
||||
* 千分位/小数位格式化对相等比较的干扰;属性数值经 React 渲染为最短可往返字符串,
|
||||
* `Number(attr)` 可精确还原。每次随机渲染后显式 cleanup,避免 DOM 残留串扰。
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 72: 费用对比图三值并呈且差额一致
|
||||
* Validates: Requirements 20.7
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { cleanup, render } from '@testing-library/react';
|
||||
import fc from 'fast-check';
|
||||
import { QuoteCompareChart, quoteDifference } from '../QuoteCompareChart.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 生成器:任意有限报价金额(覆盖正负与多数量级,排除 NaN/±Infinity)。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
const quoteAmountArb: fc.Arbitrary<number> = fc
|
||||
.double({
|
||||
noNaN: true,
|
||||
noDefaultInfinity: true,
|
||||
min: -1e9,
|
||||
max: 1e9,
|
||||
})
|
||||
// 归一化负零:货币报价无 -0 语义,且 React 将 -0 序列化为属性字符串 "0",
|
||||
// 经 Number(attr) 往返还原为 +0,会与 Object.is(+0, -0)===false 冲突。
|
||||
// 这是测试数据产物而非实现缺陷,故将 -0 折叠为 +0,其余有限数值范围保持不变。
|
||||
.map((n) => (Object.is(n, -0) ? 0 : n));
|
||||
|
||||
describe('Property 72: 费用对比图三值并呈且差额一致', () => {
|
||||
it('纯函数:quoteDifference(baseline, riskAdjusted) 恒等于 riskAdjusted - baseline', () => {
|
||||
fc.assert(
|
||||
fc.property(quoteAmountArb, quoteAmountArb, (baseline, riskAdjusted) => {
|
||||
expect(quoteDifference(baseline, riskAdjusted)).toBe(riskAdjusted - baseline);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('渲染:三值并呈,且 difference 节点的原始数值恒等于 riskAdjusted - baseline', () => {
|
||||
fc.assert(
|
||||
fc.property(quoteAmountArb, quoteAmountArb, (baseline, riskAdjusted) => {
|
||||
try {
|
||||
const { container } = render(
|
||||
<QuoteCompareChart
|
||||
quote={{ baselineQuote: baseline, riskAdjustedQuote: riskAdjusted }}
|
||||
/>,
|
||||
);
|
||||
|
||||
const baselineNode = container.querySelector('[data-quote-baseline]');
|
||||
const riskAdjustedNode = container.querySelector('[data-quote-risk-adjusted]');
|
||||
const differenceNode = container.querySelector('[data-quote-difference]');
|
||||
|
||||
// 三个数值节点必须同时出现于 DOM。
|
||||
expect(baselineNode).not.toBeNull();
|
||||
expect(riskAdjustedNode).not.toBeNull();
|
||||
expect(differenceNode).not.toBeNull();
|
||||
|
||||
// 基准与风险调整后节点承载的原始数值与输入一致。
|
||||
expect(Number(baselineNode?.getAttribute('data-quote-baseline'))).toBe(baseline);
|
||||
expect(
|
||||
Number(riskAdjustedNode?.getAttribute('data-quote-risk-adjusted')),
|
||||
).toBe(riskAdjusted);
|
||||
|
||||
// 所呈现差额恒等于 风险调整后报价 − 基准报价。
|
||||
expect(
|
||||
Number(differenceNode?.getAttribute('data-quote-difference')),
|
||||
).toBe(riskAdjusted - baseline);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* ScoreGauge — Property 71(task 19.9,Req 20.6)。
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 71: 仪表盘同时呈现总分与分级
|
||||
*
|
||||
* 对任意 Risk_Score([0,100] 整数),风险总分仪表盘必同时呈现该 Risk_Score
|
||||
* 数值与其按分级规则对应的 Risk_Grade,且所呈现 Risk_Grade 恒与分级函数
|
||||
* `classifyGrade` 的输出一致。
|
||||
*
|
||||
* 说明:RTL 仅在 vitest `afterEach` 自动 cleanup(即每个 `it` 之后一次),而本
|
||||
* 属性在单个 `it` 内跑 ≥100 次 render,故每次迭代后显式调用 `cleanup()` 清空
|
||||
* DOM,避免重复节点导致 `getByLabelText` 命中多个元素。
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import fc from 'fast-check';
|
||||
import { ScoreGauge } from '../ScoreGauge.js';
|
||||
import { classifyGrade, RISK_GRADE_VALUES } from '../riskGrade.js';
|
||||
|
||||
describe('ScoreGauge — Property 71(仪表盘同时呈现总分与分级,Req 20.6)', () => {
|
||||
it('对任意 Risk_Score 同时呈现分值与等级,且等级恒等于 classifyGrade(score)', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.integer({ min: 0, max: 100 }), (score) => {
|
||||
try {
|
||||
const expectedGrade = classifyGrade(score);
|
||||
|
||||
render(<ScoreGauge score={score} />);
|
||||
|
||||
// 分值数值节点存在,且文本为该分值。
|
||||
const scoreNode = screen.getByLabelText(`风险总分 ${score}`);
|
||||
expect(scoreNode).toBeInTheDocument();
|
||||
expect(scoreNode).toHaveTextContent(String(score));
|
||||
|
||||
// 等级文本节点存在,且等于 classifyGrade(score)。
|
||||
const gradeNode = screen.getByLabelText(`风险分级 ${expectedGrade}`);
|
||||
expect(gradeNode).toBeInTheDocument();
|
||||
expect(gradeNode).toHaveTextContent(expectedGrade);
|
||||
|
||||
// 所呈现等级恒为合法 Risk_Grade 之一。
|
||||
expect(RISK_GRADE_VALUES).toContain(expectedGrade);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* Property 70: 图表空态与加载态呈现(通用 Chart 容器,Req 20.4 / 20.5)。
|
||||
*
|
||||
* 属性陈述:对任意 Chart——
|
||||
* - 当其对应数据为空时,必呈现 Empty_State 并提示无可展示数据(非空文案);
|
||||
* - 当其对应数据正在请求或计算中时,必呈现 Loading_State。
|
||||
*
|
||||
* 本测试分两层覆盖:
|
||||
* 1. 纯派生层(`chartStatus`):用 fast-check(≥100 次)覆盖状态优先级——
|
||||
* 加载中恒为 `loading`(无论数据如何);非加载且数据为空恒为 `empty`;
|
||||
* 非加载且数据非空恒为 `ready`。这是 Empty_State / Loading_State 的判定依据。
|
||||
* 2. 渲染层(`ChartContainer` / `renderChart`):对任意「空数据」ChartSpec,
|
||||
* 渲染结果必含 `data-chart-state="empty"` 且其可见文案非空;对任意「加载中」
|
||||
* ChartSpec,渲染结果必含 `data-chart-state="loading"` 且其可见文案非空。
|
||||
* 文案在缺省(emptyMessage/loadingMessage 未提供)时由容器回退为默认非空提示。
|
||||
*
|
||||
* Feature: outsourcing-risk-assessment, Property 70: 图表空态与加载态呈现
|
||||
* Validates: Requirements 20.4, 20.5
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import { cleanup, render } from '@testing-library/react';
|
||||
import { ChartContainer, chartStatus } from '../index.js';
|
||||
import type {
|
||||
ChartPattern,
|
||||
ChartSpec,
|
||||
ChartType,
|
||||
Label,
|
||||
Series,
|
||||
} from '../index.js';
|
||||
import type { ColorToken } from '../../design-system/index.js';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 生成器。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/** 任意图表类型。 */
|
||||
const chartTypeArb: fc.Arbitrary<ChartType> = fc.constantFrom(
|
||||
'Heatmap',
|
||||
'ScoreGauge',
|
||||
'RiskBadge',
|
||||
'TopNRiskChart',
|
||||
'CostBreakdown',
|
||||
'QuoteCompare',
|
||||
'PortfolioCompare',
|
||||
);
|
||||
|
||||
/** 任意图案。 */
|
||||
const patternArb: fc.Arbitrary<ChartPattern> = fc.constantFrom(
|
||||
'solid',
|
||||
'diagonal',
|
||||
'horizontal',
|
||||
'vertical',
|
||||
'grid',
|
||||
'dots',
|
||||
'crosshatch',
|
||||
);
|
||||
|
||||
/** 任意配色令牌(取若干合法令牌即可,本属性不依赖具体取值)。 */
|
||||
const colorTokenArb: fc.Arbitrary<ColorToken> = fc.constantFrom(
|
||||
'color.risk.high',
|
||||
'color.risk.medium',
|
||||
'color.risk.low',
|
||||
'color.brand.primary',
|
||||
);
|
||||
|
||||
/** 任意非空、非纯空白的文本(保证 trim 后仍非空)。 */
|
||||
const nonEmptyTextArb: fc.Arbitrary<string> = fc
|
||||
.string({ minLength: 1 })
|
||||
.map((s) => `文本${s}`);
|
||||
|
||||
/** 任意数据系列(可含 0..n 个数据点)。 */
|
||||
const seriesArb: fc.Arbitrary<Series> = fc.record({
|
||||
id: nonEmptyTextArb,
|
||||
label: nonEmptyTextArb,
|
||||
encoding: fc.record({
|
||||
colorToken: colorTokenArb,
|
||||
textLabel: nonEmptyTextArb,
|
||||
pattern: patternArb,
|
||||
}),
|
||||
points: fc.array(
|
||||
fc.record({ label: nonEmptyTextArb, value: fc.double({ noNaN: true }) }),
|
||||
{ maxLength: 4 },
|
||||
),
|
||||
});
|
||||
|
||||
/** 任意标签集合。 */
|
||||
const labelsArb: fc.Arbitrary<Label[]> = fc.array(
|
||||
fc.record({
|
||||
kind: fc.constantFrom('axis', 'point', 'partition'),
|
||||
text: nonEmptyTextArb,
|
||||
}),
|
||||
{ maxLength: 4 },
|
||||
);
|
||||
|
||||
/** 可选的非空文案(present 且非空,或 absent)。 */
|
||||
const optionalMessageArb: fc.Arbitrary<string | undefined> = fc.option(
|
||||
nonEmptyTextArb,
|
||||
{ nil: undefined },
|
||||
);
|
||||
|
||||
/** 可选标题。 */
|
||||
const optionalTitleArb: fc.Arbitrary<string | undefined> = fc.option(
|
||||
nonEmptyTextArb,
|
||||
{ nil: undefined },
|
||||
);
|
||||
|
||||
/**
|
||||
* 构造 ChartSpec,按 exactOptionalPropertyTypes 要求条件性附加可选字段。
|
||||
*/
|
||||
function makeSpec(parts: {
|
||||
type: ChartType;
|
||||
status: ChartSpec['status'];
|
||||
series: readonly Series[];
|
||||
labels: readonly Label[];
|
||||
title?: string | undefined;
|
||||
emptyMessage?: string | undefined;
|
||||
loadingMessage?: string | undefined;
|
||||
}): ChartSpec {
|
||||
return {
|
||||
type: parts.type,
|
||||
status: parts.status,
|
||||
series: parts.series,
|
||||
labels: parts.labels,
|
||||
...(parts.title !== undefined ? { title: parts.title } : {}),
|
||||
...(parts.emptyMessage !== undefined ? { emptyMessage: parts.emptyMessage } : {}),
|
||||
...(parts.loadingMessage !== undefined
|
||||
? { loadingMessage: parts.loadingMessage }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 任意「空数据」ChartSpec:
|
||||
* - 变体 A:status === 'empty'(series 任意,含可非空系列)。
|
||||
* - 变体 B:status === 'ready' 但所有系列均无数据点(空数据)。
|
||||
* 二者均应被容器判定为 Empty_State。emptyMessage 取「缺省」或「非空」。
|
||||
*/
|
||||
const emptySpecArb: fc.Arbitrary<ChartSpec> = fc.oneof(
|
||||
fc.record({
|
||||
type: chartTypeArb,
|
||||
series: fc.array(seriesArb, { maxLength: 3 }),
|
||||
labels: labelsArb,
|
||||
title: optionalTitleArb,
|
||||
emptyMessage: optionalMessageArb,
|
||||
}).map((r) =>
|
||||
makeSpec({
|
||||
type: r.type,
|
||||
status: 'empty',
|
||||
series: r.series,
|
||||
labels: r.labels,
|
||||
title: r.title,
|
||||
emptyMessage: r.emptyMessage,
|
||||
}),
|
||||
),
|
||||
fc.record({
|
||||
type: chartTypeArb,
|
||||
// status 'ready' 但每个系列 points 为空 → 无任何数据 → Empty_State。
|
||||
series: fc.array(
|
||||
fc.record({
|
||||
id: nonEmptyTextArb,
|
||||
label: nonEmptyTextArb,
|
||||
encoding: fc.record({
|
||||
colorToken: colorTokenArb,
|
||||
textLabel: nonEmptyTextArb,
|
||||
pattern: patternArb,
|
||||
}),
|
||||
points: fc.constant([] as Series['points']),
|
||||
}),
|
||||
{ maxLength: 3 },
|
||||
),
|
||||
labels: labelsArb,
|
||||
title: optionalTitleArb,
|
||||
emptyMessage: optionalMessageArb,
|
||||
}).map((r) =>
|
||||
makeSpec({
|
||||
type: r.type,
|
||||
status: 'ready',
|
||||
series: r.series,
|
||||
labels: r.labels,
|
||||
title: r.title,
|
||||
emptyMessage: r.emptyMessage,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* 任意「加载中」ChartSpec:status === 'loading',series/labels 任意,
|
||||
* loadingMessage 取「缺省」或「非空」。
|
||||
*/
|
||||
const loadingSpecArb: fc.Arbitrary<ChartSpec> = fc
|
||||
.record({
|
||||
type: chartTypeArb,
|
||||
series: fc.array(seriesArb, { maxLength: 3 }),
|
||||
labels: labelsArb,
|
||||
title: optionalTitleArb,
|
||||
loadingMessage: optionalMessageArb,
|
||||
})
|
||||
.map((r) =>
|
||||
makeSpec({
|
||||
type: r.type,
|
||||
status: 'loading',
|
||||
series: r.series,
|
||||
labels: r.labels,
|
||||
title: r.title,
|
||||
loadingMessage: r.loadingMessage,
|
||||
}),
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 1. 纯派生层:chartStatus 状态优先级(Empty_State / Loading_State 的判定依据)。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
describe('Property 70: 图表空态与加载态呈现 — chartStatus 派生 (Req 20.4/20.5)', () => {
|
||||
it('加载中恒为 loading(无论数据为空或非空)', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.array(fc.anything()), (data) => {
|
||||
expect(chartStatus({ loading: true, data })).toBe('loading');
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('非加载且数据为空恒为 empty', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.boolean(), (provideLoadingFalse) => {
|
||||
const status = provideLoadingFalse
|
||||
? chartStatus({ loading: false, data: [] })
|
||||
: chartStatus({ data: [] });
|
||||
expect(status).toBe('empty');
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('非加载且数据非空恒为 ready', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(fc.anything(), { minLength: 1 }),
|
||||
fc.boolean(),
|
||||
(data, provideLoadingFalse) => {
|
||||
const status = provideLoadingFalse
|
||||
? chartStatus({ loading: false, data })
|
||||
: chartStatus({ data });
|
||||
expect(status).toBe('ready');
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 2. 渲染层:ChartContainer 呈现 Empty_State / Loading_State。
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
describe('Property 70: 图表空态与加载态呈现 — 渲染 (Req 20.4/20.5)', () => {
|
||||
it('任意空数据 Chart 必呈现 Empty_State 且文案非空,且不呈现 Loading_State', () => {
|
||||
fc.assert(
|
||||
fc.property(emptySpecArb, (spec) => {
|
||||
const { container } = render(<ChartContainer spec={spec} />);
|
||||
try {
|
||||
const empty = container.querySelector('[data-chart-state="empty"]');
|
||||
expect(empty).not.toBeNull();
|
||||
// Empty_State 必为可访问状态区域。
|
||||
expect(empty?.getAttribute('role')).toBe('status');
|
||||
// 必提示「无可展示数据」——文案非空。
|
||||
expect((empty?.textContent ?? '').trim().length).toBeGreaterThan(0);
|
||||
// 提供了 emptyMessage 时,所呈现文案必包含该文案。
|
||||
if (spec.emptyMessage !== undefined) {
|
||||
expect(empty?.textContent ?? '').toContain(spec.emptyMessage);
|
||||
}
|
||||
// 空态不得同时呈现 Loading_State。
|
||||
expect(container.querySelector('[data-chart-state="loading"]')).toBeNull();
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('任意加载中 Chart 必呈现 Loading_State 且文案非空,且不呈现 Empty_State', () => {
|
||||
fc.assert(
|
||||
fc.property(loadingSpecArb, (spec) => {
|
||||
const { container } = render(<ChartContainer spec={spec} />);
|
||||
try {
|
||||
const loading = container.querySelector('[data-chart-state="loading"]');
|
||||
expect(loading).not.toBeNull();
|
||||
expect(loading?.getAttribute('role')).toBe('status');
|
||||
expect((loading?.textContent ?? '').trim().length).toBeGreaterThan(0);
|
||||
if (spec.loadingMessage !== undefined) {
|
||||
expect(loading?.textContent ?? '').toContain(spec.loadingMessage);
|
||||
}
|
||||
// 加载态不得同时呈现 Empty_State。
|
||||
expect(container.querySelector('[data-chart-state="empty"]')).toBeNull();
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Charts — 视图模型类型(view-model types,task 19.1)。
|
||||
*
|
||||
* 纯类型,无 React、无运行时值。描述通用 Chart 容器 `renderChart(spec)` 所消费的
|
||||
* `ChartSpec` 形态,使具体图表(task 19.2–19.5)与属性测试(task 19.6–19.11)
|
||||
* 都围绕同一份契约工作:
|
||||
* - status:驱动 Empty_State / Loading_State(Req 20.4 / 20.5)。
|
||||
* - series + legend:≥2 系列/类别必有图例,且图例标签集合与数据元素标签集合
|
||||
* 相等(Req 20.2 / Property 68)。
|
||||
* - labels:坐标轴 / 数据点 / 分区的非空文本标签(Req 20.3 / Property 69)。
|
||||
* - 每个类别的 `CategoryEncoding`:颜色之外另以文本标签或图案区分
|
||||
* (Req 23.6 / Property 79)。
|
||||
*
|
||||
* 配色一律以 `ColorToken` 引用,最终取值经 ThemeProvider 的 Color_Token 解析,
|
||||
* 不在图表层硬编码具体颜色(Req 19.6)。Web 层为独立 bounded context
|
||||
* (`web/tsconfig.json` 的 rootDir = `web/`),因此 Scoring_Engine 的输入类型在
|
||||
* 此本地镜像,而非跨 rootDir 引用领域层。
|
||||
*/
|
||||
|
||||
import type { ColorToken } from '../design-system/index.js';
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 通用图表枚举
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/** 全套图表类型(Req 20.1)。 */
|
||||
export type ChartType =
|
||||
| 'Heatmap' // 风险热力图
|
||||
| 'ScoreGauge' // 风险总分仪表盘
|
||||
| 'RiskBadge' // Risk_Badge
|
||||
| 'TopNRiskChart' // Top N 关键风险图
|
||||
| 'CostBreakdown' // 费用拆解图
|
||||
| 'QuoteCompare' // 基准 vs 风险调整后报价对比图
|
||||
| 'PortfolioCompare'; // 跨项目组合对比图
|
||||
|
||||
/** 图表状态:就绪 / 加载中 / 空数据(Req 20.4 / 20.5)。 */
|
||||
export type ChartStatus = 'ready' | 'loading' | 'empty';
|
||||
|
||||
/**
|
||||
* 颜色之外的图案编码(Req 23.6 / Property 79)。
|
||||
* 用于在不依赖颜色的情况下区分类别(如打印为灰度、色觉障碍场景)。
|
||||
*/
|
||||
export type ChartPattern =
|
||||
| 'solid'
|
||||
| 'diagonal'
|
||||
| 'horizontal'
|
||||
| 'vertical'
|
||||
| 'grid'
|
||||
| 'dots'
|
||||
| 'crosshatch';
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 类别编码 / 数据系列 / 标签
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* 单个数据类别的编码(design.md:categoryEncoding)。
|
||||
* 颜色(`colorToken`)之外,必含非空 `textLabel` 与 `pattern`,
|
||||
* 使类别不依赖颜色即可识别(Req 23.6 / Property 79)。
|
||||
*/
|
||||
export interface CategoryEncoding {
|
||||
/** 类别配色令牌名(取值由 ThemeProvider 解析,不硬编码)。 */
|
||||
readonly colorToken: ColorToken;
|
||||
/** 类别的非空文本标签(颜色之外的区分手段之一)。 */
|
||||
readonly textLabel: string;
|
||||
/** 类别的图案(颜色之外的区分手段之一)。 */
|
||||
readonly pattern: ChartPattern;
|
||||
}
|
||||
|
||||
/** 单个数据点(数据元素),附非空文本标签(Req 20.3 / Property 69)。 */
|
||||
export interface DataPoint {
|
||||
/** 数据点的非空文本标签。 */
|
||||
readonly label: string;
|
||||
/** 数据点取值。 */
|
||||
readonly value: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据系列 / 类别。每个系列即一个可被图例引用的数据类别:
|
||||
* - `label`:系列/类别标签,构成「数据元素标签集合」(Property 68)。
|
||||
* - `encoding`:该类别的颜色 + 文本 + 图案编码(Property 79)。
|
||||
* - `points`:该系列下的数据点。
|
||||
*/
|
||||
export interface Series {
|
||||
/** 系列唯一标识。 */
|
||||
readonly id: string;
|
||||
/** 系列/类别的非空文本标签(与图例标签一致,Property 68)。 */
|
||||
readonly label: string;
|
||||
/** 类别编码(颜色 + 文本 + 图案,Property 79)。 */
|
||||
readonly encoding: CategoryEncoding;
|
||||
/** 该系列下的数据点(各附非空标签)。 */
|
||||
readonly points: readonly DataPoint[];
|
||||
}
|
||||
|
||||
/** 文本标签的归属种类:坐标轴 / 数据点 / 分区(Req 20.3)。 */
|
||||
export type LabelKind = 'axis' | 'point' | 'partition';
|
||||
|
||||
/** 一条文本标签:种类 + 非空文本(Property 69)。 */
|
||||
export interface Label {
|
||||
/** 标签归属:坐标轴 / 数据点 / 分区。 */
|
||||
readonly kind: LabelKind;
|
||||
/** 非空文本内容。 */
|
||||
readonly text: string;
|
||||
}
|
||||
|
||||
/** 图例项:文本标签 + 配色令牌 + 图案(Req 20.2 / 23.6)。 */
|
||||
export interface LegendItem {
|
||||
/** 图例文本标签(与对应数据元素标签一致,Property 68)。 */
|
||||
readonly label: string;
|
||||
/** 图例色块的配色令牌名。 */
|
||||
readonly colorToken: ColorToken;
|
||||
/** 图例色块的图案(颜色之外的区分,Property 79)。 */
|
||||
readonly pattern: ChartPattern;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* ChartSpec —— renderChart 的输入契约
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* 通用图表视图模型(design.md:ChartViewModel / ChartSpec)。
|
||||
*
|
||||
* `renderChart(spec)` 据此渲染:
|
||||
* - status `empty` 或 series 为空 → Empty_State + 非空「无可展示数据」提示。
|
||||
* - status `loading` → Loading_State。
|
||||
* - series/类别 ≥2 → 必含图例,且图例标签集合 == 数据元素标签集合。
|
||||
* - 每个 axis/point/partition 标签文本非空。
|
||||
* - 每个类别经 `encoding` 在颜色之外可区分。
|
||||
*/
|
||||
export interface ChartSpec {
|
||||
/** 图表类型(Req 20.1)。 */
|
||||
readonly type: ChartType;
|
||||
/** 图表状态(Req 20.4 / 20.5)。 */
|
||||
readonly status: ChartStatus;
|
||||
/** 数据系列/类别集合。 */
|
||||
readonly series: readonly Series[];
|
||||
/** 图例项;当系列/类别 ≥2 时必须提供(Property 68)。 */
|
||||
readonly legend?: readonly LegendItem[];
|
||||
/** 坐标轴/数据点/分区的文本标签集合(Property 69)。 */
|
||||
readonly labels: readonly Label[];
|
||||
/** 可选标题。 */
|
||||
readonly title?: string;
|
||||
/** Empty_State 文案;缺省时由容器使用默认非空提示(Req 20.4)。 */
|
||||
readonly emptyMessage?: string;
|
||||
/** Loading_State 文案;缺省时由容器使用默认提示(Req 20.5)。 */
|
||||
readonly loadingMessage?: string;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Scoring_Engine 输出的本地镜像输入类型(消费任务 4.15 / 4.17 的产物)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/** 风险等级(1–5),镜像领域层 `RiskLevel`。 */
|
||||
export type RiskLevel = 1 | 2 | 3 | 4 | 5;
|
||||
|
||||
/**
|
||||
* 热力图单元格输入(镜像领域层 `HeatmapCell`,task 4.15)。
|
||||
* Dimension 行 × Indicator 列 × Risk_Level 严重度。
|
||||
*/
|
||||
export interface HeatmapCellInput {
|
||||
/** 行:所属维度标识。 */
|
||||
readonly dimensionId: string;
|
||||
/** 列:指标标识。 */
|
||||
readonly indicatorId: string;
|
||||
/** 严重度:风险等级(1–5)。 */
|
||||
readonly riskLevel: RiskLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* 关键风险项输入(镜像领域层 `RiskItem`,task 4.17)。
|
||||
* Top N 关键风险清单中的单项。
|
||||
*/
|
||||
export interface RiskItemInput {
|
||||
/** 所属维度标识。 */
|
||||
readonly dimensionId: string;
|
||||
/** 指标标识。 */
|
||||
readonly indicatorId: string;
|
||||
/** 评分项得分(降序排序主键)。 */
|
||||
readonly score: number;
|
||||
/** 判定依据(Req 7.5)。 */
|
||||
readonly rationale: string;
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* Charts — 纯派生函数(task 19.1)。
|
||||
*
|
||||
* 全部为确定性纯函数(无 React、无 DOM、无可变全局状态),承载通用 Chart 契约的
|
||||
* 可验证逻辑,供 `ChartContainer` 渲染与属性测试(task 19.6–19.11)共同复用:
|
||||
* - `chartStatus`:由「是否加载中 + 数据是否为空」派生状态(Req 20.4 / 20.5)。
|
||||
* - `shouldShowLegend` / `deriveLegend`:≥2 系列/类别必有图例,且图例标签集合
|
||||
* 与数据元素标签集合相等(Req 20.2 / Property 68)。
|
||||
* - `seriesLabels` / `legendLabels` / `labelSetsEqual`:标签集合比较工具。
|
||||
* - `categoryEncodings` / `isDistinctlyEncoded`:颜色之外的区分编码
|
||||
* (Req 23.6 / Property 79)。
|
||||
* - `allLabelsNonEmpty` / `collectDataElementLabels`:文本标签齐备校验
|
||||
* (Req 20.3 / Property 69)。
|
||||
* - `buildHeatmapSpec` / `buildTopNSpec`:消费 Scoring_Engine 输出(task 4.15 /
|
||||
* 4.17)构造 ChartSpec。
|
||||
*/
|
||||
|
||||
import { heatColorToken } from '../design-system/index.js';
|
||||
import type {
|
||||
CategoryEncoding,
|
||||
ChartPattern,
|
||||
ChartSpec,
|
||||
ChartStatus,
|
||||
HeatmapCellInput,
|
||||
Label,
|
||||
LegendItem,
|
||||
RiskItemInput,
|
||||
RiskLevel,
|
||||
Series,
|
||||
} from './chart-types.js';
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 状态派生(Req 20.4 / 20.5 / Property 70)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/** `chartStatus` 的输入:是否加载中 + 数据数组。 */
|
||||
export interface ChartStatusInput {
|
||||
/** 数据是否正在请求或计算中(true → loading)。 */
|
||||
readonly loading?: boolean;
|
||||
/** 待展示的数据集合(长度为 0 → empty)。 */
|
||||
readonly data: readonly unknown[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 由输入派生图表状态(确定性,Property 70)。
|
||||
* 优先级:加载中 → `loading`;否则数据为空 → `empty`;否则 `ready`。
|
||||
*/
|
||||
export function chartStatus(input: ChartStatusInput): ChartStatus {
|
||||
if (input.loading === true) {
|
||||
return 'loading';
|
||||
}
|
||||
return input.data.length === 0 ? 'empty' : 'ready';
|
||||
}
|
||||
|
||||
/** 是否应呈现 Empty_State:状态为 `empty` 或无任何系列/数据点。 */
|
||||
export function isEmptyState(spec: ChartSpec): boolean {
|
||||
if (spec.status === 'empty') {
|
||||
return true;
|
||||
}
|
||||
if (spec.status === 'loading') {
|
||||
return false;
|
||||
}
|
||||
return !hasAnyData(spec.series);
|
||||
}
|
||||
|
||||
/** 是否应呈现 Loading_State:状态为 `loading`。 */
|
||||
export function isLoadingState(spec: ChartSpec): boolean {
|
||||
return spec.status === 'loading';
|
||||
}
|
||||
|
||||
/** 系列集合中是否存在至少一个数据点。 */
|
||||
function hasAnyData(series: readonly Series[]): boolean {
|
||||
return series.some((s) => s.points.length > 0);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 图例与数据元素标签(Req 20.2 / Property 68)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/** 数据元素(系列/类别)标签集合,保持系列声明顺序。 */
|
||||
export function seriesLabels(series: readonly Series[]): string[] {
|
||||
return series.map((s) => s.label);
|
||||
}
|
||||
|
||||
/** 图例项标签集合,保持图例声明顺序。 */
|
||||
export function legendLabels(legend: readonly LegendItem[]): string[] {
|
||||
return legend.map((item) => item.label);
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否应提供图例:系列/类别数 ≥2(Req 20.2)。
|
||||
* 单一系列/类别无需图例。
|
||||
*/
|
||||
export function shouldShowLegend(series: readonly Series[]): boolean {
|
||||
return series.length >= 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* 由系列派生图例(Property 68)。每个系列产出一条图例项,其文本标签、配色令牌
|
||||
* 与图案均取自该系列的类别编码,从而图例标签集合恒与数据元素标签集合相等。
|
||||
*/
|
||||
export function deriveLegend(series: readonly Series[]): LegendItem[] {
|
||||
return series.map((s) => ({
|
||||
label: s.label,
|
||||
colorToken: s.encoding.colorToken,
|
||||
pattern: s.encoding.pattern,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 两个标签集合(忽略顺序、去重后)是否相等。 */
|
||||
export function labelSetsEqual(a: readonly string[], b: readonly string[]): boolean {
|
||||
const setA = new Set(a);
|
||||
const setB = new Set(b);
|
||||
if (setA.size !== setB.size) {
|
||||
return false;
|
||||
}
|
||||
for (const label of setA) {
|
||||
if (!setB.has(label)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验图例与数据系列一致(Property 68):
|
||||
* 当系列/类别 ≥2 时,`legend` 必须存在且其标签集合与系列标签集合相等;
|
||||
* 当系列/类别 <2 时,恒视为一致(无需图例)。
|
||||
*/
|
||||
export function legendMatchesData(spec: ChartSpec): boolean {
|
||||
if (!shouldShowLegend(spec.series)) {
|
||||
return true;
|
||||
}
|
||||
if (spec.legend === undefined) {
|
||||
return false;
|
||||
}
|
||||
return labelSetsEqual(legendLabels(spec.legend), seriesLabels(spec.series));
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 类别非颜色编码(Req 23.6 / Property 79)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/** 收集每个类别(系列)的编码。 */
|
||||
export function categoryEncodings(series: readonly Series[]): CategoryEncoding[] {
|
||||
return series.map((s) => s.encoding);
|
||||
}
|
||||
|
||||
/**
|
||||
* 某类别是否在颜色之外可区分(Property 79):
|
||||
* 含非空文本标签即满足(文本本身不依赖颜色);图案亦作为补充区分手段。
|
||||
*/
|
||||
export function isDistinctlyEncoded(encoding: CategoryEncoding): boolean {
|
||||
return encoding.textLabel.trim().length > 0;
|
||||
}
|
||||
|
||||
/** 是否所有类别都在颜色之外可区分。 */
|
||||
export function allCategoriesDistinct(series: readonly Series[]): boolean {
|
||||
return categoryEncodings(series).every(isDistinctlyEncoded);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 文本标签齐备(Req 20.3 / Property 69)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/** 是否每条 axis/point/partition 标签文本均非空。 */
|
||||
export function allLabelsNonEmpty(labels: readonly Label[]): boolean {
|
||||
return labels.every((label) => label.text.trim().length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集图表中应具备非空文本标签的全部「数据元素」文本:
|
||||
* 显式标签(轴/点/分区)+ 每个数据点的标签。供 Property 69 校验齐备性。
|
||||
*/
|
||||
export function collectDataElementLabels(spec: ChartSpec): string[] {
|
||||
const texts: string[] = spec.labels.map((label) => label.text);
|
||||
for (const s of spec.series) {
|
||||
for (const point of s.points) {
|
||||
texts.push(point.label);
|
||||
}
|
||||
}
|
||||
return texts;
|
||||
}
|
||||
|
||||
/** 图表全部数据元素文本标签是否齐备(均非空)。 */
|
||||
export function labelsComplete(spec: ChartSpec): boolean {
|
||||
return collectDataElementLabels(spec).every((text) => text.trim().length > 0);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 图案分配(确定性,供类别在颜色之外区分)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/** 图案序列(确定性轮转分配)。 */
|
||||
export const PATTERN_SEQUENCE: readonly ChartPattern[] = [
|
||||
'solid',
|
||||
'diagonal',
|
||||
'horizontal',
|
||||
'vertical',
|
||||
'grid',
|
||||
'dots',
|
||||
'crosshatch',
|
||||
] as const;
|
||||
|
||||
/** 按索引确定性取图案(轮转)。 */
|
||||
export function patternForIndex(index: number): ChartPattern {
|
||||
const normalized = ((index % PATTERN_SEQUENCE.length) + PATTERN_SEQUENCE.length) %
|
||||
PATTERN_SEQUENCE.length;
|
||||
// 轮转索引恒在范围内,断言非空以满足 noUncheckedIndexedAccess。
|
||||
return PATTERN_SEQUENCE[normalized] as ChartPattern;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 消费 Scoring_Engine 输出 → ChartSpec(task 4.15 / 4.17)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/** 风险等级 → 中文文本标签(颜色之外的区分,Req 23.6)。 */
|
||||
const RISK_LEVEL_TEXT: Record<RiskLevel, string> = {
|
||||
1: '风险等级 1(很低)',
|
||||
2: '风险等级 2(较低)',
|
||||
3: '风险等级 3(中等)',
|
||||
4: '风险等级 4(较高)',
|
||||
5: '风险等级 5(很高)',
|
||||
};
|
||||
|
||||
/** 升序的全部风险等级取值。 */
|
||||
const RISK_LEVELS: readonly RiskLevel[] = [1, 2, 3, 4, 5] as const;
|
||||
|
||||
/**
|
||||
* 消费热力图单元格(task 4.15)构造 ChartSpec。
|
||||
*
|
||||
* 类别 = 出现的 Risk_Level(升序),各以 `color.heat.*` 令牌 + 文本标签 + 图案
|
||||
* 编码(Property 79)。每个单元格生成 `维度/指标 → 等级` 的非空点标签
|
||||
* (Property 69)。系列/类别 ≥2 时由 `deriveLegend` 自动产出一致图例
|
||||
* (Property 68)。空输入 → `empty`(Property 70)。
|
||||
*/
|
||||
export function buildHeatmapSpec(
|
||||
cells: readonly HeatmapCellInput[],
|
||||
options: { readonly loading?: boolean; readonly title?: string } = {},
|
||||
): ChartSpec {
|
||||
const status = chartStatus({
|
||||
data: cells,
|
||||
...(options.loading !== undefined ? { loading: options.loading } : {}),
|
||||
});
|
||||
|
||||
const presentLevels = RISK_LEVELS.filter((level) =>
|
||||
cells.some((cell) => cell.riskLevel === level),
|
||||
);
|
||||
|
||||
const series: Series[] = presentLevels.map((level, index) => {
|
||||
const cellsForLevel = cells.filter((cell) => cell.riskLevel === level);
|
||||
return {
|
||||
id: `risk-level-${level}`,
|
||||
label: RISK_LEVEL_TEXT[level],
|
||||
encoding: {
|
||||
colorToken: heatColorToken(level),
|
||||
textLabel: RISK_LEVEL_TEXT[level],
|
||||
pattern: patternForIndex(index),
|
||||
},
|
||||
points: cellsForLevel.map((cell) => ({
|
||||
label: `${cell.dimensionId} / ${cell.indicatorId}:${RISK_LEVEL_TEXT[level]}`,
|
||||
value: level,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
const labels: Label[] = [
|
||||
{ kind: 'axis', text: '维度(行)' },
|
||||
{ kind: 'axis', text: '指标(列)' },
|
||||
...presentLevels.map<Label>((level) => ({
|
||||
kind: 'partition',
|
||||
text: RISK_LEVEL_TEXT[level],
|
||||
})),
|
||||
];
|
||||
|
||||
return {
|
||||
type: 'Heatmap',
|
||||
status,
|
||||
series,
|
||||
...(shouldShowLegend(series) ? { legend: deriveLegend(series) } : {}),
|
||||
labels,
|
||||
title: options.title ?? '风险热力图',
|
||||
emptyMessage: '暂无可展示的热力图数据',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 消费 Top N 关键风险清单(task 4.17)构造 ChartSpec。
|
||||
*
|
||||
* 单一系列的条形图:每个风险项为一个数据点,点标签为「维度/指标(得分)」非空文本
|
||||
* (Property 69),并以判定依据补充。坐标轴标签齐备。单一类别无需图例
|
||||
* (Property 68 对 <2 类别恒成立);类别仍带文本标签与图案以满足 Property 79。
|
||||
* 空输入 → `empty`(Property 70)。
|
||||
*/
|
||||
export function buildTopNSpec(
|
||||
items: readonly RiskItemInput[],
|
||||
options: { readonly loading?: boolean; readonly title?: string } = {},
|
||||
): ChartSpec {
|
||||
const status = chartStatus({
|
||||
data: items,
|
||||
...(options.loading !== undefined ? { loading: options.loading } : {}),
|
||||
});
|
||||
|
||||
const series: Series[] = [
|
||||
{
|
||||
id: 'top-key-risks',
|
||||
label: '关键风险得分',
|
||||
encoding: {
|
||||
colorToken: 'color.risk.high',
|
||||
textLabel: '关键风险得分',
|
||||
pattern: patternForIndex(0),
|
||||
},
|
||||
points: items.map((item) => ({
|
||||
label: `${item.dimensionId} / ${item.indicatorId}(得分 ${item.score})`,
|
||||
value: item.score,
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
const labels: Label[] = [
|
||||
{ kind: 'axis', text: '关键风险(维度/指标)' },
|
||||
{ kind: 'axis', text: '得分' },
|
||||
...items.map<Label>((item) => ({
|
||||
kind: 'point',
|
||||
text: `${item.dimensionId} / ${item.indicatorId}(得分 ${item.score}):${item.rationale}`,
|
||||
})),
|
||||
];
|
||||
|
||||
return {
|
||||
type: 'TopNRiskChart',
|
||||
status,
|
||||
series,
|
||||
labels,
|
||||
title: options.title ?? 'Top N 关键风险',
|
||||
emptyMessage: '暂无可展示的关键风险数据',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Charts 公共入口(barrel,task 19)。
|
||||
*
|
||||
* 暴露通用 Chart 容器(`renderChart` / `ChartContainer` / `Chart`)、视图模型类型
|
||||
* (`ChartSpec` 等)与纯派生函数(图例/标签/状态/非颜色编码、Scoring_Engine 输出适配)。
|
||||
* 具体图表(task 19.2–19.5)与属性测试(task 19.6–19.11)均从此处引用。
|
||||
*/
|
||||
|
||||
export type {
|
||||
ChartType,
|
||||
ChartStatus,
|
||||
ChartPattern,
|
||||
CategoryEncoding,
|
||||
DataPoint,
|
||||
Series,
|
||||
LabelKind,
|
||||
Label,
|
||||
LegendItem,
|
||||
ChartSpec,
|
||||
RiskLevel,
|
||||
HeatmapCellInput,
|
||||
RiskItemInput,
|
||||
} from './chart-types.js';
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
Chart,
|
||||
renderChart,
|
||||
} from './ChartContainer.js';
|
||||
export type { ChartContainerProps } from './ChartContainer.js';
|
||||
|
||||
export {
|
||||
chartStatus,
|
||||
isEmptyState,
|
||||
isLoadingState,
|
||||
seriesLabels,
|
||||
legendLabels,
|
||||
shouldShowLegend,
|
||||
deriveLegend,
|
||||
labelSetsEqual,
|
||||
legendMatchesData,
|
||||
categoryEncodings,
|
||||
isDistinctlyEncoded,
|
||||
allCategoriesDistinct,
|
||||
allLabelsNonEmpty,
|
||||
collectDataElementLabels,
|
||||
labelsComplete,
|
||||
PATTERN_SEQUENCE,
|
||||
patternForIndex,
|
||||
buildHeatmapSpec,
|
||||
buildTopNSpec,
|
||||
} from './helpers.js';
|
||||
export type { ChartStatusInput } from './helpers.js';
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 风险总分仪表盘(task 19.3,Req 20.1 / 20.6)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
export { classifyGrade, RISK_GRADE_VALUES } from './riskGrade.js';
|
||||
|
||||
export { ScoreGauge } from './ScoreGauge.js';
|
||||
export type { ScoreGaugeProps } from './ScoreGauge.js';
|
||||
|
||||
/* 具体图表组件(task 19.2,Req 20.1) */
|
||||
export { RiskHeatmap } from './RiskHeatmap.js';
|
||||
export type { RiskHeatmapProps } from './RiskHeatmap.js';
|
||||
|
||||
export { RiskBadge } from './RiskBadge.js';
|
||||
export type { RiskBadgeProps } from './RiskBadge.js';
|
||||
|
||||
export { TopNRiskChart } from './TopNRiskChart.js';
|
||||
export type { TopNRiskChartProps } from './TopNRiskChart.js';
|
||||
|
||||
/* 跨项目组合对比图(task 19.5,Req 20.1) */
|
||||
export { PortfolioCompareChart, buildPortfolioCompareSpec } from './PortfolioCompareChart.js';
|
||||
export type {
|
||||
PortfolioCompareChartProps,
|
||||
PortfolioCompareRow,
|
||||
PortfolioCompareKeyRisk,
|
||||
PortfolioCompareSpecOptions,
|
||||
} from './PortfolioCompareChart.js';
|
||||
|
||||
/* 费用拆解图与报价对比图(task 19.4,Req 20.1 / 20.7) */
|
||||
export { CostBreakdownChart } from './CostBreakdownChart.js';
|
||||
export type {
|
||||
CostBreakdownChartProps,
|
||||
CostBreakdownItemInput,
|
||||
} from './CostBreakdownChart.js';
|
||||
|
||||
export { QuoteCompareChart, quoteDifference } from './QuoteCompareChart.js';
|
||||
export type {
|
||||
QuoteCompareChartProps,
|
||||
QuoteCompareInput,
|
||||
} from './QuoteCompareChart.js';
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Charts — 本地 Risk_Grade 分级器(task 19.3)。
|
||||
*
|
||||
* Web 层为独立 bounded context(`web/tsconfig.json` 的 rootDir = `web/`),
|
||||
* 无法跨 rootDir 引用领域层 `src/scoring/classifyGrade.ts`。本模块在此本地镜像
|
||||
* 领域层完全一致的分级规则,使风险总分仪表盘(ScoreGauge)展示的 Risk_Grade
|
||||
* 恒等于该 Risk_Score 按领域规则分类得到的等级(task 19.9 / Property 71 验证)。
|
||||
*
|
||||
* 区间约定(与 design.md / Req 5.1-5.4 及领域层 `classifyGrade` 逐字一致):
|
||||
*
|
||||
* [0, 25] → 低
|
||||
* (25, 50] → 中
|
||||
* (50, 75] → 高
|
||||
* (75, 100] → 极高
|
||||
*
|
||||
* 各区间右闭左开(首区间左闭),相邻区间互不重叠且无缝衔接,因此 [0, 100] 内
|
||||
* 任一取值被唯一区间覆盖,函数为每个 Risk_Score 输出且仅输出一个 Risk_Grade
|
||||
* (Req 5.5)。纯函数,无 React、无副作用,便于属性测试确定性验证。
|
||||
*/
|
||||
|
||||
import type { RiskGrade } from '../design-system/index.js';
|
||||
|
||||
/** 全部 Risk_Grade 取值(按严重度升序),与领域层一致(Req 5)。 */
|
||||
export const RISK_GRADE_VALUES = ['低', '中', '高', '极高'] as const;
|
||||
|
||||
/**
|
||||
* 将 Risk_Score 映射为 Risk_Grade(Req 5),逐字镜像领域层 `classifyGrade`。
|
||||
*
|
||||
* 自上而下按区间上界依次判定,命中即返回,保证输出且仅输出一个分级:
|
||||
* [0,25]→低、(25,50]→中、(50,75]→高、(75,100]→极高。
|
||||
*
|
||||
* @param score 归一化风险总分,期望为 0 至 100 的整数(Req 4.3)。
|
||||
* @returns 对应的 Risk_Grade(低 / 中 / 高 / 极高 之一)。
|
||||
*/
|
||||
export function classifyGrade(score: number): RiskGrade {
|
||||
if (score <= 25) {
|
||||
return '低';
|
||||
}
|
||||
if (score <= 50) {
|
||||
return '中';
|
||||
}
|
||||
if (score <= 75) {
|
||||
return '高';
|
||||
}
|
||||
return '极高';
|
||||
}
|
||||
Reference in New Issue
Block a user