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

- 确定性领域引擎(分类/评分/分级/红线/费用/裁决)+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
+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>
);
}