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