Files
RiskAgent/web/src/charts/ScoreGauge.tsx
T
freedakgmail c670b9e454 外包风险评估系统:领域引擎+前端+服务端持久化与生产部署
- 确定性领域引擎(分类/评分/分级/红线/费用/裁决)+LLM(通义千问)语言理解
- 6步评估向导、服务端草稿持久化(跨设备/编辑草稿保护)
- 工作流(草稿→风控→管理层)、RBAC、报告导出、校准、客户/费率/红线/最低工资管理
- 专业图标体系替换全部emoji、看板美化
- 生产化:API_BASE可配置(同源反代)、auth密钥惰性读取修复RBAC
- 444单测+204前端测试+51 e2e
2026-06-13 01:06:39 +08:00

261 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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>
);
}