外包风险评估系统:领域引擎+前端+服务端持久化与生产部署

- 确定性领域引擎(分类/评分/分级/红线/费用/裁决)+LLM(通义千问)语言理解
- 6步评估向导、服务端草稿持久化(跨设备/编辑草稿保护)
- 工作流(草稿→风控→管理层)、RBAC、报告导出、校准、客户/费率/红线/最低工资管理
- 专业图标体系替换全部emoji、看板美化
- 生产化:API_BASE可配置(同源反代)、auth密钥惰性读取修复RBAC
- 444单测+204前端测试+51 e2e
This commit is contained in:
freedakgmail
2026-06-13 01:06:39 +08:00
commit c670b9e454
404 changed files with 61820 additions and 0 deletions
+322
View File
@@ -0,0 +1,322 @@
/**
* ChartContainer — 通用 Chart 容器(task 19.1)。
*
* 实现 `renderChart(spec)` 的通用契约,由具体图表(task 19.2–19.5)包裹其图形内容
* recharts)后复用:
* - status `loading` → 呈现 Loading_Staterole="status" + aria-busyReq 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.mdrenderChart)。
* 与 `<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;
+222
View File
@@ -0,0 +1,222 @@
/**
* CostBreakdownChart — 费用拆解图(task 19.4Req 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_StateReq 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>
);
}
+310
View File
@@ -0,0 +1,310 @@
/**
* PortfolioCompareChart — 跨项目组合对比图(task 19.5Req 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_Score0100)。 */
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_StateReq 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_Score0100' },
...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_StateReq 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>
);
}
+243
View File
@@ -0,0 +1,243 @@
/**
* QuoteCompareChart — 基准 vs 风险调整后报价对比图(task 19.4Req 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_StateReq 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>
);
}
+69
View File
@@ -0,0 +1,69 @@
/**
* RiskBadge — Risk_Grade 徽章(task 19.2Req 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>
);
}
+167
View File
@@ -0,0 +1,167 @@
/**
* RiskHeatmap — 风险热力图(task 19.2Req 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.220.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_StateReq 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>
);
}
+260
View File
@@ -0,0 +1,260 @@
/**
* ScoreGauge — 风险总分仪表盘(task 19.3Req 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 下界(Req0 至 100)。 */
const SCORE_MIN = 0;
/** Risk_Score 上界(Req0 至 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_StateReq 20.5)。 */
readonly loading?: boolean;
/** 可选标题,缺省为「风险总分」。 */
readonly title?: string;
}
/**
* 风险总分仪表盘:同时呈现 Risk_Score 数值与其对应 Risk_GradeReq 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_Score0100' },
{ 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>
);
}
+113
View File
@@ -0,0 +1,113 @@
/**
* TopNRiskChart — Top N 关键风险条形图(task 19.2Req 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.619.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_StateReq 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.5Req 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_StateReq 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_StateReq 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_StateReq 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.3Req 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_GradeReq 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_StateReq 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.12Req 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.619.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.12Req 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 ');
/** 风险等级(15)。 */
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);
/** 风险等级(15)。 */
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' 下贴近真实形态。
*
* 断言:
* - 系列 ≥2deriveLegend(series) 的标签集合 == seriesLabels(series) 的集合
* labelSetsEqual);且以派生图例构造的 spec 满足 legendMatchesData === true。
* - 系列 ≥2 且无 legendlegendMatchesData === false(图例必需)。
* - 系列 <2:无论是否提供 legendlegendMatchesData === 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' 的 ChartSpeclegend 可选传入)。 */
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 71task 19.9Req 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
* - 变体 Astatus === 'empty'series 任意,含可非空系列)。
* - 变体 Bstatus === '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,
}),
),
);
/**
* 任意「加载中」ChartSpecstatus === '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 },
);
});
});
+182
View File
@@ -0,0 +1,182 @@
/**
* Charts — 视图模型类型(view-model typestask 19.1)。
*
* 纯类型,无 React、无运行时值。描述通用 Chart 容器 `renderChart(spec)` 所消费的
* `ChartSpec` 形态,使具体图表(task 19.219.5)与属性测试(task 19.619.11
* 都围绕同一份契约工作:
* - status:驱动 Empty_State / Loading_StateReq 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.mdcategoryEncoding)。
* 颜色(`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.mdChartViewModel / 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 的产物)
* ------------------------------------------------------------------ */
/** 风险等级(15),镜像领域层 `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;
}
+337
View File
@@ -0,0 +1,337 @@
/**
* Charts — 纯派生函数(task 19.1)。
*
* 全部为确定性纯函数(无 React、无 DOM、无可变全局状态),承载通用 Chart 契约的
* 可验证逻辑,供 `ChartContainer` 渲染与属性测试(task 19.619.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 输出 → ChartSpectask 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: '暂无可展示的关键风险数据',
};
}
+94
View File
@@ -0,0 +1,94 @@
/**
* Charts 公共入口(barreltask 19)。
*
* 暴露通用 Chart 容器(`renderChart` / `ChartContainer` / `Chart`)、视图模型类型
* `ChartSpec` 等)与纯派生函数(图例/标签/状态/非颜色编码、Scoring_Engine 输出适配)。
* 具体图表(task 19.219.5)与属性测试(task 19.619.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.3Req 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.2Req 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.5Req 20.1 */
export { PortfolioCompareChart, buildPortfolioCompareSpec } from './PortfolioCompareChart.js';
export type {
PortfolioCompareChartProps,
PortfolioCompareRow,
PortfolioCompareKeyRisk,
PortfolioCompareSpecOptions,
} from './PortfolioCompareChart.js';
/* 费用拆解图与报价对比图(task 19.4Req 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';
+46
View File
@@ -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_GradeReq 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 '极高';
}