c670b9e454
- 确定性领域引擎(分类/评分/分级/红线/费用/裁决)+LLM(通义千问)语言理解 - 6步评估向导、服务端草稿持久化(跨设备/编辑草稿保护) - 工作流(草稿→风控→管理层)、RBAC、报告导出、校准、客户/费率/红线/最低工资管理 - 专业图标体系替换全部emoji、看板美化 - 生产化:API_BASE可配置(同源反代)、auth密钥惰性读取修复RBAC - 444单测+204前端测试+51 e2e
261 lines
8.5 KiB
TypeScript
261 lines
8.5 KiB
TypeScript
/**
|
||
* ScoreGauge — 风险总分仪表盘(task 19.3,Req 20.1 / 20.6)。
|
||
*
|
||
* 同时呈现 Risk_Score 数值与其对应的 Risk_Grade 文本标签(Req 20.6):
|
||
* - Risk_Grade 由本地 `classifyGrade(score)` 派生,逐字镜像领域层分级规则,
|
||
* 因此仪表盘展示的等级恒等于该分值的分类结果(task 19.9 / Property 71)。
|
||
* - 等级配色经 `riskGradeColorToken(grade)` 取得稳定 Color_Token,最终取值由
|
||
* ThemeProvider 解析为 CSS 变量,不在图表层硬编码具体颜色(Req 19.6)。
|
||
*
|
||
* 通过通用 `ChartContainer` 取得一致的框架(标题 / 三态 / 标签),仪表盘图形以
|
||
* 确定性 SVG 半圆弧表达,分值与等级以可见文本节点呈现(便于无障碍与测试)。
|
||
* loading / empty 三态由容器统一处理(Req 20.4 / 20.5)。
|
||
*/
|
||
|
||
import type { CSSProperties } from 'react';
|
||
import {
|
||
colorTokenToCssVarName,
|
||
riskGradeColorToken,
|
||
spacing,
|
||
typography,
|
||
} from '../design-system/index.js';
|
||
import type { ColorToken } from '../design-system/index.js';
|
||
import { ChartContainer } from './ChartContainer.js';
|
||
import type { ChartSpec, Label, Series } from './chart-types.js';
|
||
import { classifyGrade } from './riskGrade.js';
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* 局部样式原语(取自 Design Tokens,颜色经 CSS 变量引用,与 ChartContainer 一致)
|
||
* ------------------------------------------------------------------ */
|
||
|
||
/** 将 Color_Token 映射为 CSS 变量引用。 */
|
||
function colorVar(token: ColorToken): string {
|
||
return `var(${colorTokenToCssVarName(token)})`;
|
||
}
|
||
|
||
/** 取间距标度第 `step` 档(px);越界回退为 0。 */
|
||
function space(step: number): number {
|
||
return spacing[step] ?? 0;
|
||
}
|
||
|
||
const FONT_FAMILY =
|
||
"'Inter', 'PingFang SC', 'Microsoft YaHei', system-ui, -apple-system, sans-serif";
|
||
|
||
/** 取具名排版层级的字体样式(找不到则回退到 body 近似值)。 */
|
||
function typographyStyle(name: string): CSSProperties {
|
||
const level = typography.find((t) => t.name === name);
|
||
if (level === undefined) {
|
||
return { fontSize: '14px', lineHeight: '22px' };
|
||
}
|
||
return { fontSize: `${level.fontSize}px`, lineHeight: `${level.lineHeight}px` };
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* Risk_Score 取值域
|
||
* ------------------------------------------------------------------ */
|
||
|
||
/** Risk_Score 下界(Req:0 至 100)。 */
|
||
const SCORE_MIN = 0;
|
||
/** Risk_Score 上界(Req:0 至 100)。 */
|
||
const SCORE_MAX = 100;
|
||
|
||
/** 将任意输入夹取到 [0, 100] 取值域内,保证弧形渲染稳健。 */
|
||
function clampScore(score: number): number {
|
||
if (Number.isNaN(score)) {
|
||
return SCORE_MIN;
|
||
}
|
||
if (score < SCORE_MIN) {
|
||
return SCORE_MIN;
|
||
}
|
||
if (score > SCORE_MAX) {
|
||
return SCORE_MAX;
|
||
}
|
||
return score;
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* 半圆弧几何(确定性,无外部依赖)
|
||
* ------------------------------------------------------------------ */
|
||
|
||
const GAUGE_WIDTH = 220;
|
||
const GAUGE_HEIGHT = 120;
|
||
const GAUGE_CX = GAUGE_WIDTH / 2;
|
||
const GAUGE_CY = GAUGE_HEIGHT - 10;
|
||
const GAUGE_RADIUS = 90;
|
||
const GAUGE_STROKE = 16;
|
||
|
||
/** 极坐标 → 笛卡尔坐标(角度以度计,0°=正左,180°=正右,沿上半圆)。 */
|
||
function polar(angleDeg: number): { readonly x: number; readonly y: number } {
|
||
const rad = (Math.PI * (180 - angleDeg)) / 180;
|
||
return {
|
||
x: GAUGE_CX + GAUGE_RADIUS * Math.cos(rad),
|
||
y: GAUGE_CY - GAUGE_RADIUS * Math.sin(rad),
|
||
};
|
||
}
|
||
|
||
/** 构造从 `startDeg` 到 `endDeg` 的半圆弧 path 数据(上半圆,0..180°)。 */
|
||
function arcPath(startDeg: number, endDeg: number): string {
|
||
const start = polar(startDeg);
|
||
const end = polar(endDeg);
|
||
const largeArc = endDeg - startDeg > 180 ? 1 : 0;
|
||
return `M ${start.x} ${start.y} A ${GAUGE_RADIUS} ${GAUGE_RADIUS} 0 ${largeArc} 1 ${end.x} ${end.y}`;
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* 仪表盘图形(SVG)
|
||
* ------------------------------------------------------------------ */
|
||
|
||
function GaugeArc({
|
||
score,
|
||
gradeColorToken,
|
||
}: {
|
||
readonly score: number;
|
||
readonly gradeColorToken: ColorToken;
|
||
}): JSX.Element {
|
||
// 分值在 [0,100] 线性映射到半圆 [0°,180°]。
|
||
const sweep = (score - SCORE_MIN) / (SCORE_MAX - SCORE_MIN) * 180;
|
||
return (
|
||
<svg
|
||
width={GAUGE_WIDTH}
|
||
height={GAUGE_HEIGHT}
|
||
viewBox={`0 0 ${GAUGE_WIDTH} ${GAUGE_HEIGHT}`}
|
||
role="img"
|
||
aria-hidden={true}
|
||
focusable={false}
|
||
data-gauge-arc="true"
|
||
>
|
||
{/* 轨道:完整半圆底色。 */}
|
||
<path
|
||
d={arcPath(0, 180)}
|
||
fill="none"
|
||
stroke={colorVar('color.border.default')}
|
||
strokeWidth={GAUGE_STROKE}
|
||
strokeLinecap="round"
|
||
/>
|
||
{/* 进度弧:长度对应分值,配色取自 Risk_Grade 的 Color_Token。 */}
|
||
{sweep > 0 ? (
|
||
<path
|
||
d={arcPath(0, sweep)}
|
||
fill="none"
|
||
stroke={colorVar(gradeColorToken)}
|
||
strokeWidth={GAUGE_STROKE}
|
||
strokeLinecap="round"
|
||
data-gauge-progress="true"
|
||
/>
|
||
) : null}
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* ScoreGauge
|
||
* ------------------------------------------------------------------ */
|
||
|
||
/** `ScoreGauge` 组件属性。 */
|
||
export interface ScoreGaugeProps {
|
||
/** Risk_Score 数值(期望 0 至 100;越界将被夹取以稳健渲染)。 */
|
||
readonly score: number;
|
||
/** 数据是否正在计算/请求中(true → 由容器呈现 Loading_State,Req 20.5)。 */
|
||
readonly loading?: boolean;
|
||
/** 可选标题,缺省为「风险总分」。 */
|
||
readonly title?: string;
|
||
}
|
||
|
||
/**
|
||
* 风险总分仪表盘:同时呈现 Risk_Score 数值与其对应 Risk_Grade(Req 20.6)。
|
||
*
|
||
* 等级由 `classifyGrade(score)` 派生(与领域分级规则一致),等级配色取自
|
||
* `riskGradeColorToken(grade)`。分值与等级文本均以可见 DOM 文本节点呈现,
|
||
* 并附 `data-risk-score` / `data-risk-grade` 便于测试与无障碍读取。
|
||
*/
|
||
export function ScoreGauge({
|
||
score,
|
||
loading,
|
||
title,
|
||
}: ScoreGaugeProps): JSX.Element {
|
||
const safeScore = clampScore(score);
|
||
const grade = classifyGrade(safeScore);
|
||
const gradeColorToken = riskGradeColorToken(grade);
|
||
|
||
// 单一系列的仪表盘:以一个数据点承载分值,使容器进入就绪态并渲染图形。
|
||
const series: Series[] = loading === true
|
||
? []
|
||
: [
|
||
{
|
||
id: 'risk-score',
|
||
label: '风险总分',
|
||
encoding: {
|
||
colorToken: gradeColorToken,
|
||
textLabel: `风险总分(${grade})`,
|
||
pattern: 'solid',
|
||
},
|
||
points: [{ label: `Risk_Score:${safeScore}`, value: safeScore }],
|
||
},
|
||
];
|
||
|
||
const labels: Label[] = [
|
||
{ kind: 'axis', text: 'Risk_Score(0–100)' },
|
||
{ kind: 'partition', text: `Risk_Grade:${grade}` },
|
||
];
|
||
|
||
const spec: ChartSpec = {
|
||
type: 'ScoreGauge',
|
||
status: loading === true ? 'loading' : 'ready',
|
||
series,
|
||
labels,
|
||
title: title ?? '风险总分',
|
||
emptyMessage: '暂无可展示的风险总分',
|
||
loadingMessage: '正在计算风险总分…',
|
||
};
|
||
|
||
return (
|
||
<ChartContainer spec={spec}>
|
||
<div
|
||
data-score-gauge="true"
|
||
style={{
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
alignItems: 'center',
|
||
gap: `${space(2)}px`,
|
||
fontFamily: FONT_FAMILY,
|
||
}}
|
||
>
|
||
<GaugeArc score={safeScore} gradeColorToken={gradeColorToken} />
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
alignItems: 'center',
|
||
gap: `${space(1)}px`,
|
||
}}
|
||
>
|
||
{/* Risk_Score 数值(Req 20.6)。 */}
|
||
<span
|
||
data-risk-score={safeScore}
|
||
aria-label={`风险总分 ${safeScore}`}
|
||
style={{
|
||
...typographyStyle('display'),
|
||
fontWeight: 700,
|
||
color: colorVar(gradeColorToken),
|
||
}}
|
||
>
|
||
{safeScore}
|
||
</span>
|
||
{/* Risk_Grade 文本标签(Req 20.6)。 */}
|
||
<span
|
||
data-risk-grade={grade}
|
||
aria-label={`风险分级 ${grade}`}
|
||
style={{
|
||
...typographyStyle('title'),
|
||
fontWeight: 700,
|
||
color: colorVar(gradeColorToken),
|
||
}}
|
||
>
|
||
{grade}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</ChartContainer>
|
||
);
|
||
}
|