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

- 确定性领域引擎(分类/评分/分级/红线/费用/裁决)+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
+57
View File
@@ -0,0 +1,57 @@
/**
* 应用根组件:BrowserRouter + 登录/路由守卫 + AppShell 布局壳 + 页面路由。
*/
import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router-dom';
import { useAuthStore } from './stores/authStore.js';
import { AppShell } from './app/AppShell.js';
import { Dashboard } from './pages/Dashboard.js';
import { NewAssessment } from './pages/NewAssessment.js';
import { AssessmentDetail } from './pages/AssessmentDetail.js';
import { Login } from './pages/Login.js';
import { RateManagement } from './pages/RateManagement.js';
import { RedlineManagement } from './pages/RedlineManagement.js';
import { CustomerManagement } from './pages/CustomerManagement.js';
/** 路由守卫:未登录重定向到登录页。 */
function ProtectedRoute(): JSX.Element {
const { isAuthenticated } = useAuthStore();
return isAuthenticated ? <Outlet /> : <Navigate to="/login" replace />;
}
/** 角色守卫:当前角色不在允许列表时重定向回首页。 */
function RoleRoute({ allow }: { readonly allow: readonly string[] }): JSX.Element {
const { user } = useAuthStore();
const role = user?.role ?? '';
return allow.includes(role) ? <Outlet /> : <Navigate to="/" replace />;
}
export function App(): JSX.Element {
return (
<BrowserRouter
future={{
v7_startTransition: true,
v7_relativeSplatPath: true,
}}
>
<Routes>
<Route path="/login" element={<Login />} />
<Route element={<ProtectedRoute />}>
<Route element={<AppShell />}>
<Route path="/" element={<Dashboard />} />
<Route path="/new" element={<NewAssessment />} />
<Route path="/assessments/:id" element={<AssessmentDetail />} />
{/* 费率/红线管理:仅管理层 */}
<Route element={<RoleRoute allow={['管理层']} />}>
<Route path="/rates" element={<RateManagement />} />
<Route path="/redlines" element={<RedlineManagement />} />
</Route>
{/* 客户档案:销售 + 管理层 */}
<Route element={<RoleRoute allow={['商务/销售', '管理层']} />}>
<Route path="/customers" element={<CustomerManagement />} />
</Route>
</Route>
</Route>
</Routes>
</BrowserRouter>
);
}
File diff suppressed because one or more lines are too long
+19
View File
@@ -0,0 +1,19 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { axe } from 'vitest-axe';
import { App } from '../App';
describe('App smoke test (jsdom + RTL + axe toolchain)', () => {
it('renders the main heading into the document', () => {
render(<App />);
expect(
screen.getByRole('heading', { level: 1, name: '外包项目风险评估' }),
).toBeInTheDocument();
});
it('has no detectable accessibility violations', async () => {
const { container } = render(<App />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
@@ -0,0 +1,162 @@
/**
* 主题切换与导出性能检查(task 24.2Req 19.7 / 24.4)。
*
* 这是性能/时序检查(非属性测试)。覆盖两条带时间预算的需求:
* - Req 19.7:会话内切换 Theme 时,UI 在 **1 秒内**对当前页面应用所选 Theme,
* 且不丢失当前页面已录入的数据。本测试真实渲染 `ThemeProvider`jsdom),
* 切换主题后测量「CSS 自定义属性取值刷新到目标主题」的实际耗时远小于 1000ms,
* 并断言已录入的输入数据原样保留。
* - Req 24.4:报告导出在评估者请求后 **30 秒内**呈现「导出完成或导出失败」终态。
* 本测试驱动真实的 `exportWithProgress` 包装层:
* · 正常导出:实测解析为 `completed` 终态且实际耗时远小于 30000ms;
* · 卡住导出:以可配置超时强制落入 `failed` 终态(确定性、不真正等待 30 秒);
* · 终态边界默认值即为 30 秒(Req 24.4 的预算)。
*
* 时序采用真实测量(`performance.now()`)以反映实现的实际表现;强制超时分支以小
* 超时值模拟「计时到达预算即落终态」,保持快速与确定性,不实际 sleep 30 秒。
*
* Feature: outsourcing-risk-assessment, task 24.2
* Validates: Requirements 19.7, 24.4
*/
import { describe, it, expect } from 'vitest';
import { act, render, screen } from '@testing-library/react';
import {
ThemeProvider,
useTheme,
buildThemeCssVariables,
colorTokenToCssVarName,
} from '../design-system/index.js';
import type { Theme } from '../design-system/index.js';
import {
exportWithProgress,
DEFAULT_EXPORT_TIMEOUT_MS,
type ExportProgress,
} from '../feedback/index.js';
/** Req 19.7 的主题切换时间预算(毫秒)。 */
const THEME_SWITCH_BUDGET_MS = 1_000;
/** Req 24.4 的导出终态时间预算(毫秒)。 */
const EXPORT_TERMINAL_BUDGET_MS = 30_000;
/**
* 测试夹具:将当前主题与 `setTheme` 经由 `useTheme` 暴露给测试,并渲染一个承载
* 「已录入数据」的输入框,用于验证切换主题后数据不丢失(Req 19.7)。
*/
function ThemeHarness({
onReady,
}: {
readonly onReady: (api: { theme: Theme; setTheme: (t: Theme) => void }) => void;
}): JSX.Element {
const { theme, setTheme } = useTheme();
onReady({ theme, setTheme });
// 非受控输入:DOM 节点在主题切换(仅替换 CSS 变量取值)期间始终挂载,
// 因此其已录入取值天然保留,符合 Req 19.7「不丢失已录入数据」。
return <input aria-label="供应商名称" defaultValue="ACME 外包服务商" />;
}
describe('主题切换性能与数据保留(Req 19.7)', () => {
it('切换 Theme 在 1 秒内对当前页面生效且不丢失已录入数据', () => {
// 隔离的承载元素,避免污染全局 :rootThemeProvider 将 CSS 变量写入其上。
const target = document.createElement('div');
document.body.appendChild(target);
let api: { theme: Theme; setTheme: (t: Theme) => void } | null = null;
render(
<ThemeProvider initialTheme="Light" target={target}>
<ThemeHarness onReady={(a) => (api = a)} />
</ThemeProvider>,
);
// 在已录入的输入框中确立「已录入数据」。
const input = screen.getByLabelText('供应商名称') as HTMLInputElement;
expect(input.value).toBe('ACME 外包服务商');
// 初始 Light 主题的 CSS 变量取值已由首次 effect 写入目标元素。
const riskLowVar = colorTokenToCssVarName('color.risk.low');
const lightExpected = buildThemeCssVariables('Light')[riskLowVar];
const darkExpected = buildThemeCssVariables('Dark')[riskLowVar];
expect(darkExpected).not.toBe(lightExpected); // 两主题取值确有差异,确保断言有效
expect(target.style.getPropertyValue(riskLowVar)).toBe(lightExpected);
if (api === null) {
throw new Error('test setup: ThemeProvider 上下文未就绪');
}
const ready = api as { theme: Theme; setTheme: (t: Theme) => void };
// 测量「切换主题 → CSS 变量取值刷新到目标主题」的实际耗时。
const start = performance.now();
act(() => {
ready.setTheme('Dark');
});
const elapsedMs = performance.now() - start;
// 主题已对当前页面生效:全部令牌取值替换为 Dark 主题取值。
const darkVars = buildThemeCssVariables('Dark');
for (const [name, value] of Object.entries(darkVars)) {
expect(target.style.getPropertyValue(name)).toBe(value);
}
expect(target.style.getPropertyValue(riskLowVar)).toBe(darkExpected);
// Req 19.7:在 1 秒内生效。
expect(elapsedMs).toBeLessThan(THEME_SWITCH_BUDGET_MS);
// Req 19.7:已录入数据不丢失(输入节点未重建,取值原样保留)。
expect(input.value).toBe('ACME 外包服务商');
document.body.removeChild(target);
});
});
describe('报告导出终态时间预算(Req 24.4', () => {
it('终态边界默认值即为 30 秒预算', () => {
expect(DEFAULT_EXPORT_TIMEOUT_MS).toBe(EXPORT_TERMINAL_BUDGET_MS);
});
it('正常导出在 30 秒内解析为完成终态', async () => {
const progress: ExportProgress[] = [];
// 以一段真实的内存序列化工作模拟报告导出(与领域层「同步、即时」的导出契约一致)。
const exportFn = async (): Promise<string> => {
const payload = {
report: Array.from({ length: 200 }, (_, i) => ({
dimension: `维度-${i}`,
score: i % 5,
note: '判定依据与风险影响'.repeat(8),
})),
};
return JSON.stringify(payload);
};
const start = performance.now();
const result = await exportWithProgress(exportFn, {
operationName: '报告导出',
onProgress: (p) => progress.push(p),
});
const elapsedMs = performance.now() - start;
// 呈现执行中进度并落入完成终态。
expect(progress[0]?.phase).toBe('exporting');
expect(result.state).toBe('completed');
expect(progress.at(-1)?.phase).toBe('completed');
// Req 24.430 秒内给出终态。
expect(elapsedMs).toBeLessThan(EXPORT_TERMINAL_BUDGET_MS);
});
it('卡住的导出在终态边界内被强制落入失败终态(确定性,不真正等待 30 秒)', async () => {
// 永不 resolve 的导出,模拟外部卡死;以小超时模拟「计时到达预算即落终态」。
const stuck = new Promise<string>(() => {
/* 永不 resolve */
});
const start = performance.now();
const result = await exportWithProgress(() => stuck, { timeoutMs: 20 });
const elapsedMs = performance.now() - start;
// 必现失败终态(而非悬挂),且远在 30 秒预算内(此处由小超时确定性保证)。
expect(result.state).toBe('failed');
expect(result.message).toContain('失败');
expect(result.correctiveAction).toBeDefined();
expect(elapsedMs).toBeLessThan(EXPORT_TERMINAL_BUDGET_MS);
});
});
+30
View File
@@ -0,0 +1,30 @@
/**
* Web (jsdom) test setup.
*
* NOTE: vitest registers `setupFiles` for every test file regardless of its
* environment, so everything here MUST be safe to merely *load* in the node
* environment used by the domain tests under src/. We therefore only register
* matchers (which only touch the DOM when actually invoked by a jsdom test) and
* configure fast-check. React Testing Library performs its own automatic
* cleanup after each test once `render` is imported by a jsdom test file, so no
* explicit cleanup hook is registered here (that hook would touch `document`
* and break node-environment tests).
*
* It wires up the UI testing toolchain:
* - @testing-library/jest-dom matchers (toBeInTheDocument, etc.)
* - vitest-axe accessibility matcher (toHaveNoViolations)
* - fast-check global config (≥100 iterations per property, matching the
* domain-test guarantee in src/__tests__/setup.ts)
*/
import '@testing-library/jest-dom/vitest';
import { expect } from 'vitest';
import * as axeMatchers from 'vitest-axe/matchers';
// The `toHaveNoViolations` matcher is type-declared in web/src/vitest-axe.d.ts
// and registered at runtime below via expect.extend.
import fc from 'fast-check';
expect.extend(axeMatchers);
fc.configureGlobal({
numRuns: 100,
});
@@ -0,0 +1,195 @@
/**
* Property 67: UI 状态转换保留已录入数据 的属性化测试
* (跨表现层 Design_System / Wizard / 响应式布局,Req 19.7 / 21.2 / 22.4)。
*
* 属性陈述:在任意 UI 状态转换序列下——
* 1. 切换 ThemeLight ↔ Darkdesign-system:仅替换 Color_Token 取值,Req 19.7);
* 2. 切换录入方式(对话式 ↔ 表单,wizard`switchInputMode`Req 21.2);
* 3. 跨断点变更布局(viewport`onViewportChange`Req 22.4)——
* 页面已录入数据(`enteredData`)既不丢失也不被篡改:转换前后逐键一致、
* 键集合不变、取值字节级相等。
*
* 本测试以智能生成器构造:
* - 任意已录入数据(字符串键 → 字符串/数值/布尔/null/嵌套结构的不透明值);
* - 任意长度的三类转换混合序列(每步随机选定目标主题/录入方式/视口宽度);
* 然后将序列归约施加于统一 UI 状态,断言末态 `enteredData` 与初始快照
* 深度相等(toStrictEqual),且实现以引用保留(=== 恒等)未发生任何拷贝/篡改。
*
* 转换 1/2/3 分别复用真实实现 `buildThemeCssVariables` / `switchInputMode`
* / `onViewportChange`,不对其行为做任何模拟。
*
* Feature: outsourcing-risk-assessment, Property 67: UI 状态转换保留已录入数据
* Validates: Requirements 19.7, 21.2, 22.4
*/
import { describe, expect, it } from 'vitest';
import fc from 'fast-check';
import type { Theme } from '../design-system/index.js';
import { buildThemeCssVariables } from '../design-system/index.js';
import {
createSession,
switchInputMode,
type InputMode,
type WizardSession,
} from '../wizard/index.js';
import { createWizard } from '../wizard/index.js';
import {
onViewportChange,
selectLayout,
type Layout,
type UIState,
} from '../viewport/index.js';
/** 统一 UI 状态:承载当前主题、录入会话(含录入方式与已录入数据)与布局。 */
interface CombinedUIState {
readonly theme: Theme;
readonly session: WizardSession;
readonly layout: Layout;
}
/** 三类 UI 状态转换命令(与三条受验需求一一对应)。 */
type Transition =
| { readonly kind: 'theme'; readonly theme: Theme }
| { readonly kind: 'inputMode'; readonly mode: InputMode }
| { readonly kind: 'viewport'; readonly width: number };
/**
* 施加单步转换(纯归约)。三类转换均**不触碰** `enteredData`
* - theme:复用 `buildThemeCssVariables`(仅依赖主题、与录入数据无关),仅换主题字段;
* - inputMode:复用 `switchInputMode`,按引用原样保留 `enteredData`
* - viewport:复用 `onViewportChange`,按引用原样保留 `enteredData`。
*/
function applyTransition(state: CombinedUIState, t: Transition): CombinedUIState {
switch (t.kind) {
case 'theme': {
// 模拟 Theme Provider 行为:切换主题仅重算 CSS 变量取值,不接触录入数据。
void buildThemeCssVariables(t.theme);
return { ...state, theme: t.theme };
}
case 'inputMode': {
return { ...state, session: switchInputMode(state.session, t.mode) };
}
case 'viewport': {
const ui: UIState = { layout: state.layout, enteredData: state.session.enteredData };
const next = onViewportChange(ui, t.width);
// 将布局变更回写,并把(应为同引用的)录入数据回填到会话。
return {
...state,
layout: next.layout,
session: { ...state.session, enteredData: next.enteredData },
};
}
}
}
/** 已录入数据值:覆盖常见标量、null 与浅层嵌套结构。 */
const enteredValueArb: fc.Arbitrary<unknown> = fc.oneof(
fc.string(),
fc.integer(),
fc.double({ noNaN: true, noDefaultInfinity: true }),
fc.boolean(),
fc.constant(null),
fc.array(fc.string(), { maxLength: 4 }),
fc.dictionary(fc.string(), fc.string(), { maxKeys: 4 }),
);
/** 任意已录入数据:字符串键 → 不透明值的映射。 */
const enteredDataArb: fc.Arbitrary<Readonly<Record<string, unknown>>> = fc.dictionary(
fc.string(),
enteredValueArb,
{ maxKeys: 8 },
);
const themeArb: fc.Arbitrary<Theme> = fc.constantFrom('Light', 'Dark');
const inputModeArb: fc.Arbitrary<InputMode> = fc.constantFrom('对话式', '表单');
/** 视口宽度:跨三段断点并显式纳入边界,确保布局确实发生跨断点变更。 */
const widthArb: fc.Arbitrary<number> = fc.oneof(
fc.integer({ min: -100, max: 3000 }),
fc.constantFrom(320, 767, 768, 1024, 1279, 1280, 1920),
);
/** 单步转换生成器:三类等概率。 */
const transitionArb: fc.Arbitrary<Transition> = fc.oneof(
themeArb.map((theme) => ({ kind: 'theme', theme }) as const),
inputModeArb.map((mode) => ({ kind: 'inputMode', mode }) as const),
widthArb.map((width) => ({ kind: 'viewport', width }) as const),
);
/** 初始统一状态构造器(步骤序列对本属性无影响,置最小占位)。 */
function makeInitialState(
enteredData: Readonly<Record<string, unknown>>,
theme: Theme,
mode: InputMode,
width: number,
): CombinedUIState {
const wizard = createWizard([{ id: 'step-1', title: '步骤一' }]);
return {
theme,
session: createSession(wizard, mode, enteredData),
layout: selectLayout(width),
};
}
describe('Property 67: UI 状态转换保留已录入数据 (Req 19.7, 21.2, 22.4)', () => {
it('任意主题/录入方式/布局转换序列后,已录入数据逐键一致、不丢失不篡改', () => {
fc.assert(
fc.property(
enteredDataArb,
themeArb,
inputModeArb,
widthArb,
fc.array(transitionArb, { maxLength: 30 }),
(enteredData, theme0, mode0, width0, transitions) => {
const initial = makeInitialState(enteredData, theme0, mode0, width0);
// 初始已录入数据的独立深拷贝快照,作为「不丢失不篡改」的判定基准。
const snapshot = structuredClone(initial.session.enteredData);
const finalState = transitions.reduce(applyTransition, initial);
// 末态录入数据与初始快照逐键深度相等(既未丢键,也未篡改任何取值)。
expect(finalState.session.enteredData).toStrictEqual(snapshot);
// 键集合不变。
expect(Object.keys(finalState.session.enteredData).sort()).toStrictEqual(
Object.keys(snapshot).sort(),
);
},
),
{ numRuns: 100 },
);
});
it('转换序列以引用保留已录入数据(不复制、不重建)', () => {
fc.assert(
fc.property(
enteredDataArb,
fc.array(transitionArb, { maxLength: 30 }),
(enteredData, transitions) => {
const initial = makeInitialState(enteredData, 'Light', '对话式', 1280);
const originalRef = initial.session.enteredData;
const finalState = transitions.reduce(applyTransition, initial);
// 三类转换均不触碰 enteredData,引用恒等可证明零拷贝/零篡改。
expect(finalState.session.enteredData).toBe(originalRef);
},
),
{ numRuns: 100 },
);
});
it('单独施加任一类转换均保留已录入数据(逐类回归)', () => {
const enteredData = { : '外包项目A', 金额: 120000, 跨境: true, 备注: null };
const initial = makeInitialState(enteredData, 'Light', '对话式', 1280);
const snapshot = structuredClone(enteredData);
const afterTheme = applyTransition(initial, { kind: 'theme', theme: 'Dark' });
expect(afterTheme.session.enteredData).toStrictEqual(snapshot);
const afterMode = applyTransition(initial, { kind: 'inputMode', mode: '表单' });
expect(afterMode.session.enteredData).toStrictEqual(snapshot);
const afterViewport = applyTransition(initial, { kind: 'viewport', width: 375 });
expect(afterViewport.session.enteredData).toStrictEqual(snapshot);
expect(afterViewport.layout).toBe('MobileLayout');
});
});
@@ -0,0 +1,253 @@
/**
* 可视化回归基线快照测试(task 24.1Req 19.1 / 20.3)。
*
* 目标:对关键页面与全套图表建立**基线快照**,覆盖 Light/Dark 双主题与三档断点
* (≥1280 桌面 / 7681279 紧凑 / <768 移动)布局,作为后续视觉回归的对照基准,
* 验证:
* - 排版/间距视觉一致(Design Tokens 的 typography/spacing 以内联 px 值固化进
* 序列化 DOM,快照固定其取值)。
* - 配色视觉一致(Color_Token 在 Light/Dark 下的解析取值由
* `buildThemeCssVariables(theme)` 快照固化;组件以 `var(--color-...)` 引用,
* 主题切换仅替换令牌取值,故同一主题恒得同一配色)。
* - 图表标签不被遮挡(jsdom 无真实像素渲染,遮挡以「标签文本存在于序列化 DOM」
* 为代理断言:坐标轴/数据点/分区/图例标签均可在 DOM 中定位到非空文本)。
*
* 实现说明(与 vitest.config.ts / 既有 web 测试约定一致):
* - 运行环境为 Vitest + jsdom(无真实浏览器像素渲染),因此使用 DOM 序列化快照
* `toMatchSnapshot` of 渲染后 markup)而非图像快照。
* - 组件经 `var(--color-...)` 取色,主题取值经 `ThemeProvider` 写入根元素 CSS 变量;
* 本测试在每个主题下挂载组件树以走完主题上下文,并单独快照该主题的 CSS 变量映射,
* 使配色基线显式可对照。
* - 断点以纯函数 `selectLayout(width)` / `retainedSections(layout)` 驱动看板区块的
* 保留集合,故移动布局快照与桌面/紧凑布局快照在结构上有意义地不同。
* - 首次运行将写入快照基线(`__snapshots__/visual-regression.test.tsx.snap`),green。
*/
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, render, within } from '@testing-library/react';
import { App } from '../App.js';
import {
ThemeProvider,
buildThemeCssVariables,
THEME_VALUES,
} from '../design-system/index.js';
import type { Theme } from '../design-system/index.js';
import { DefaultView } from '../routing/index.js';
import type { Role } from '../routing/index.js';
import { selectLayout, retainedSections } from '../viewport/index.js';
import type { Layout, DashboardSection } from '../viewport/index.js';
import {
RiskHeatmap,
ScoreGauge,
RiskBadge,
TopNRiskChart,
CostBreakdownChart,
QuoteCompareChart,
PortfolioCompareChart,
type HeatmapCellInput,
type RiskItemInput,
type CostBreakdownItemInput,
type PortfolioCompareRow,
} from '../charts/index.js';
afterEach(() => {
cleanup();
});
/* ------------------------------------------------------------------ *
* 代表性固定数据(确定性,保证快照稳定可复现)
* ------------------------------------------------------------------ */
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 QUOTE_INPUT = { baselineQuote: 1000000, riskAdjustedQuote: 1200000 } as const;
const PORTFOLIO_ROWS: readonly PortfolioCompareRow[] = [
{ assessmentId: 'a-1', label: '项目甲', riskScore: 20, riskGrade: '低' },
{ assessmentId: 'a-2', label: '项目乙', riskScore: 88, riskGrade: '极高' },
];
/** 三档断点的代表宽度(≥1280 / 7681279 / <768),各唯一映射到一种布局。 */
const BREAKPOINT_WIDTHS: readonly number[] = [1440, 1024, 375];
/* ------------------------------------------------------------------ *
* 看板:按布局保留的区块渲染全套图表(移动布局仅保留最小集合,Req 22.3)
* ------------------------------------------------------------------ */
/** 渲染单个看板区块对应的图表组件。 */
function renderSection(section: DashboardSection): JSX.Element | null {
switch (section) {
case 'Risk_Score':
return <ScoreGauge score={60} title="风险总分" />;
case 'Risk_Grade':
return <RiskBadge grade="高" prefix="风险等级" />;
case 'Top_N':
return <TopNRiskChart items={TOP_N_ITEMS} title="Top N 关键风险" />;
case 'Risk_Heatmap':
return <RiskHeatmap cells={HEATMAP_CELLS} title="风险热力图" />;
case 'Cost_Breakdown':
return <CostBreakdownChart items={COST_ITEMS} title="费用拆解" />;
case 'Quote_Compare':
return <QuoteCompareChart quote={QUOTE_INPUT} title="报价对比" />;
case 'Portfolio_Compare':
return <PortfolioCompareChart rows={PORTFOLIO_ROWS} title="跨项目组合对比" />;
default:
return null;
}
}
/** 看板:按当前布局保留的区块呈现全套图表。 */
function Dashboard({ layout }: { readonly layout: Layout }): JSX.Element {
const sections = retainedSections(layout);
return (
<main data-dashboard="true" data-layout={layout}>
{sections.map((section) => (
<section key={section} data-section={section}>
{renderSection(section)}
</section>
))}
</main>
);
}
/* ------------------------------------------------------------------ *
* 1. 配色基线:Light/Dark 双主题的 Color_Token 解析取值
* ------------------------------------------------------------------ */
describe('可视化回归基线 — 配色(Req 19.6 / 19.7', () => {
for (const theme of THEME_VALUES as readonly Theme[]) {
it(`${theme} 主题的 Color_Token 解析取值快照(配色一致性基线)`, () => {
// buildThemeCssVariables 为纯函数:同一主题恒得同一「CSS 变量 → 取值」映射。
expect(buildThemeCssVariables(theme)).toMatchSnapshot();
});
}
});
/* ------------------------------------------------------------------ *
* 2. 全套图表基线:双主题 × 三断点(排版/间距/结构 + 标签不被遮挡)
* ------------------------------------------------------------------ */
describe('可视化回归基线 — 全套图表看板(Req 19.1 / 20.3', () => {
for (const theme of THEME_VALUES as readonly Theme[]) {
for (const width of BREAKPOINT_WIDTHS) {
const layout = selectLayout(width);
it(`看板 markup 快照:${theme} 主题 / ${layout}${width}px`, () => {
const target = document.createElement('div');
document.body.appendChild(target);
const { container } = render(
<ThemeProvider initialTheme={theme} target={target}>
<Dashboard layout={layout} />
</ThemeProvider>,
);
// 排版/间距以内联 px 值固化进 markup;结构按布局保留区块固化。
expect(container.innerHTML).toMatchSnapshot();
target.remove();
});
it(`图表标签不被遮挡(${theme} 主题 / ${layout}):标签文本存在于序列化 DOM`, () => {
const { container } = render(
<ThemeProvider initialTheme={theme}>
<Dashboard layout={layout} />
</ThemeProvider>,
);
const scope = within(container);
// 当前布局保留的区块集合(移动布局仅最小集合,Req 22.3)。
const sections = retainedSections(layout);
// Risk_Score / Risk_Grade / Top_N 三区块在所有布局均保留。
// 风险总分数值标签可定位(坐标轴/数据点标签未被遮挡的代理)。
expect(scope.getByLabelText('风险总分 60')).toHaveTextContent('60');
// Risk_Grade 文字标签恒呈现。
expect(scope.getAllByText('高').length).toBeGreaterThan(0);
// Top N 关键风险得分标签呈现于 DOM 文本。
expect(scope.getByText(/财务 \/ 现金流(得分 80/)).toBeInTheDocument();
if (sections.includes('Risk_Heatmap')) {
// 热力图数值 Risk_Level 单元格标签可定位。
expect(
scope.getByLabelText('合规 / 资质:风险等级 5'),
).toHaveTextContent('5');
}
if (sections.includes('Cost_Breakdown')) {
// 各费用拆解项名称 + 金额标签呈现(非空、可定位)。
const costItem = container.querySelector('[data-cost-item="垫资利息"]');
expect(costItem).toHaveTextContent('垫资利息');
expect(costItem).toHaveTextContent('100,000.00 元');
}
if (sections.includes('Quote_Compare')) {
// 报价对比三值标签(差额 = 风险调整后 − 基准 = 200000)。
const difference = container.querySelector('[data-quote-difference]');
expect(difference).toHaveTextContent('200,000.00 元');
}
if (sections.includes('Portfolio_Compare')) {
// 各项目 Risk_Score 与 Risk_Grade 标签呈现(页面含多张表格,定位组合对比表)。
const table = container.querySelector('[data-portfolio-table="true"]');
expect(table).not.toBeNull();
expect(within(table as HTMLElement).getByText('项目甲')).toBeInTheDocument();
}
});
}
}
});
/* ------------------------------------------------------------------ *
* 3. 断点布局基线:宽度 → 布局 → 保留区块(响应式结构一致性)
* ------------------------------------------------------------------ */
describe('可视化回归基线 — 断点布局(Req 22.122.3', () => {
for (const width of BREAKPOINT_WIDTHS) {
it(`视口 ${width}px → 布局与保留区块快照`, () => {
const layout = selectLayout(width);
const sections = retainedSections(layout);
expect({ width, layout, sections: [...sections] }).toMatchSnapshot();
});
}
});
/* ------------------------------------------------------------------ *
* 4. 关键页面基线:App 脚手架 + 角色化默认视图(双主题)
* ------------------------------------------------------------------ */
const ROLES: readonly Role[] = ['商务/销售', '风控', '管理层', '无角色'];
describe('可视化回归基线 — 关键页面(Req 19.1 / 25.x', () => {
for (const theme of THEME_VALUES as readonly Theme[]) {
it(`App 首屏 markup 快照(${theme} 主题)`, () => {
const { container } = render(
<ThemeProvider initialTheme={theme}>
<App />
</ThemeProvider>,
);
expect(container.innerHTML).toMatchSnapshot();
});
for (const role of ROLES) {
it(`默认视图 markup 快照:${role}${theme} 主题)`, () => {
const { container } = render(
<ThemeProvider initialTheme={theme}>
<DefaultView role={role} />
</ThemeProvider>,
);
expect(container.innerHTML).toMatchSnapshot();
});
}
}
});
+101
View File
@@ -0,0 +1,101 @@
/**
* AccessibleField — 组合「非空标签 + 控件 + 无障碍错误」的可访问表单字段(task 22.1)。
*
* 复用 Design_System 的 `Input`(其已实现 `<label htmlFor>` 关联、错误 `role="alert"`
* 与 `aria-describedby`/`aria-invalid`),并以本目录的纯函数 `associateLabel` /
* `renderFieldError` 串联:
* - `associateLabel` 保证标签为**非空**可达名称(Req 23.4)——缺失标签会在开发期抛错;
* - `renderFieldError` 在校验失败时产出**非空**且可被辅助技术识别的错误提示(Req 23.5),
* 并将其文本传入 `Input`,由 `Input` 完成与控件的 ARIA 关联。
*
* `FocusRingStyles` 提供全局可见焦点指示(Req 23.2):挂载一次即为全部交互控件在
* 键盘聚焦(`:focus-visible`)时呈现统一焦点环。
*/
import type { JSX } from 'react';
import { Input } from '../design-system/index.js';
import type { InputProps, IconName } from '../design-system/index.js';
import { associateLabel, renderFieldError } from './forms.js';
import type { FieldValidation } from './forms.js';
import { buildFocusVisibleCss } from './focus.js';
/** `AccessibleField` 组件属性。 */
export interface AccessibleFieldProps {
/** 控件 id(必填、非空),用于标签与错误的 ARIA 关联。 */
readonly id: string;
/** 字段标签文本(必填、非空,Req 23.4)。 */
readonly label: string;
/** 当前值(受控)。 */
readonly value: string;
/** 值变更回调。 */
readonly onChange: (value: string) => void;
/** 字段校验结果;失败时呈现无障碍错误提示(Req 23.5)。缺省视为通过。 */
readonly validation?: FieldValidation;
/** 原生输入类型,默认 `text`。 */
readonly type?: string;
/** 占位提示。 */
readonly placeholder?: string;
/** 表单字段名。 */
readonly name?: string;
/** 是否禁用。 */
readonly disabled?: boolean;
/** 是否必填。 */
readonly required?: boolean;
/** 前置图标名。 */
readonly iconLeft?: IconName;
}
/**
* 可访问表单字段:标签非空可达(Req 23.4),校验失败呈现无障碍错误(Req 23.5)。
*
* @throws {Error} 当 `label` 为空(或仅空白)时(经 `associateLabel`Req 23.4)。
*/
export function AccessibleField({
id,
label,
value,
onChange,
validation = { valid: true },
type,
placeholder,
name,
disabled,
required,
iconLeft,
}: AccessibleFieldProps): JSX.Element {
const association = associateLabel({ id, label });
const error = renderFieldError(association.controlId, validation);
const inputProps: InputProps = {
id: association.controlId,
label: association.accessibleName,
value,
onChange,
...(type !== undefined ? { type } : {}),
...(placeholder !== undefined ? { placeholder } : {}),
...(name !== undefined ? { name } : {}),
...(disabled !== undefined ? { disabled } : {}),
...(required !== undefined ? { required } : {}),
...(iconLeft !== undefined ? { iconLeft } : {}),
...(error !== null ? { error: error.message } : {}),
};
return <Input {...inputProps} />;
}
/** `FocusRingStyles` 组件属性。 */
export interface FocusRingStylesProps {
/** 自定义目标选择器(默认覆盖常见交互控件)。 */
readonly selector?: string;
}
/**
* 注入全局可见焦点指示样式(Req 23.2)。
*
* 在应用根部挂载一次,即为全部交互控件在键盘聚焦(`:focus-visible`)时呈现统一焦点环,
* 覆盖部分组件 `outline: none` 的默认外观。鼠标点击不会触发焦点环,避免视觉噪声。
*/
export function FocusRingStyles({ selector }: FocusRingStylesProps): JSX.Element {
const css = selector !== undefined ? buildFocusVisibleCss(selector) : buildFocusVisibleCss();
return <style>{css}</style>;
}
@@ -0,0 +1,74 @@
/**
* 单元测试:AccessibleField 组件(task 22.1 / Req 23.4, 23.5)。
*
* 验证标签与控件关联(非空可达名称)、校验失败时呈现可被辅助技术识别的错误提示
* 并以 aria-invalid / aria-describedby 关联。
*/
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { AccessibleField } from '../AccessibleField.js';
describe('AccessibleField (Req 23.4)', () => {
it('以非空标签关联控件,控件可由可达名称定位', () => {
render(<AccessibleField id="vendor" label="供应商名称" value="" onChange={() => {}} />);
const input = screen.getByLabelText('供应商名称');
expect(input).toBeInTheDocument();
expect(input).toHaveAttribute('id', 'vendor');
});
it('标签为空时抛错(非空文本标签为硬约束)', () => {
expect(() =>
render(<AccessibleField id="vendor" label=" " value="" onChange={() => {}} />),
).toThrow(/non-empty/);
});
});
describe('AccessibleField (Req 23.5)', () => {
it('校验通过时不呈现错误且控件非无效态', () => {
render(
<AccessibleField
id="budget"
label="预算"
value="100"
onChange={() => {}}
validation={{ valid: true }}
/>,
);
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(screen.getByLabelText('预算')).toHaveAttribute('aria-invalid', 'false');
});
it('校验失败时呈现可被辅助技术识别的非空错误并与控件关联', () => {
render(
<AccessibleField
id="budget"
label="预算"
value=""
onChange={() => {}}
validation={{ valid: false, message: '预算必须为正数' }}
/>,
);
const alert = screen.getByRole('alert');
expect(alert).toHaveTextContent('预算必须为正数');
expect(alert).toHaveAttribute('id', 'budget-error');
const input = screen.getByLabelText('预算');
expect(input).toHaveAttribute('aria-invalid', 'true');
expect(input).toHaveAttribute('aria-describedby', 'budget-error');
});
it('校验失败但无消息时回退到非空默认提示', () => {
render(
<AccessibleField
id="budget"
label="预算"
value=""
onChange={() => {}}
validation={{ valid: false }}
/>,
);
const alert = screen.getByRole('alert');
expect((alert.textContent ?? '').length).toBeGreaterThan(0);
});
});
+186
View File
@@ -0,0 +1,186 @@
/**
* 自动化无障碍检查(task 22.5 / Req 23.1, 23.2)。
*
* 以 axe 规则集对代表性页面与基础组件进行自动化扫描,验证:
* - 交互控件具备可达名称、正确的 ARIA 语义与原生可聚焦语义(支撑键盘可达,Req 23.1);
* - 标签与控件的关联、错误提示的 ARIA 关联正确(表单可访问性);
* - 全局可见焦点指示样式(`FocusRingStyles`)可与其余 UI 共存且不引入违规(Req 23.2)。
*
* 说明:axe 的「键盘可见焦点环」属于人工/运行时校验项,自动化层面通过断言控件
* 渲染为原生可聚焦元素(`<button>`/`<input>`/`<a href>`)并具备可达名称来覆盖
* Req 23.1 的可机检部分;可见焦点指示(Req 23.2)由 `FocusRingStyles` 注入并随
* 组合页一并扫描,确保不产生无障碍违规。
*
* 这是自动化无障碍测试(非属性测试)。
*/
import { describe, it, expect } from 'vitest';
import type { JSX } from 'react';
import { useState } from 'react';
import { render } from '@testing-library/react';
import { axe } from 'vitest-axe';
import { App } from '../../App.js';
import { AccessibleField, FocusRingStyles } from '../AccessibleField.js';
import {
Button,
Card,
Dialog,
Input,
Nav,
Table,
Toast,
} from '../../design-system/index.js';
import type { TableColumn } from '../../design-system/index.js';
/** 渲染容器并断言 axe 无违规。 */
async function expectNoViolations(ui: JSX.Element): Promise<void> {
const { container } = render(ui);
const results = await axe(container);
expect(results).toHaveNoViolations();
}
describe('axe 自动化无障碍检查 (Req 23.1, 23.2)', () => {
it('App 根页面无可检测的无障碍违规', async () => {
await expectNoViolations(<App />);
});
it('基础交互控件(Button)具备可达名称且无违规', async () => {
await expectNoViolations(
<main>
<h1></h1>
<Button></Button>
<Button variant="secondary"></Button>
<Button variant="ghost" iconLeft="info" ariaLabel="更多信息">
</Button>
<Button disabled></Button>
</main>,
);
});
it('受控输入控件(Input)标签关联且无违规', async () => {
await expectNoViolations(
<main>
<h1></h1>
<Input id="vendor" label="供应商名称" value="" onChange={() => {}} />
<Input
id="budget"
label="预算"
value=""
onChange={() => {}}
error="预算必须为正数"
/>
</main>,
);
});
it('AccessibleField(含错误态)与全局焦点指示样式无违规', async () => {
await expectNoViolations(
<main>
<FocusRingStyles />
<h1></h1>
<form>
<AccessibleField
id="contact"
label="联系人"
value="张三"
onChange={() => {}}
/>
<AccessibleField
id="amount"
label="金额"
value=""
onChange={() => {}}
required
validation={{ valid: false, message: '金额必须为正数' }}
/>
</form>
</main>,
);
});
it('导航(Nav)链接/按钮两态语义正确且无违规', async () => {
await expectNoViolations(
<Nav
ariaLabel="主导航"
items={[
{ key: 'home', label: '概览', icon: 'info', href: '#home', active: true },
{ key: 'vendors', label: '供应商', href: '#vendors' },
{ key: 'settings', label: '设置', onSelect: () => {} },
]}
/>,
);
});
it('数据表格(Table)语义化结构且无违规', async () => {
interface Row {
readonly id: string;
readonly name: string;
readonly score: number;
}
const columns: ReadonlyArray<TableColumn<Row>> = [
{ key: 'name', header: '名称', field: 'name' },
{ key: 'score', header: '评分', align: 'right', field: 'score' },
];
const data: readonly Row[] = [
{ id: 'a', name: '供应商 A', score: 82 },
{ id: 'b', name: '供应商 B', score: 64 },
];
await expectNoViolations(
<main>
<h1></h1>
<Table
caption="供应商评分"
columns={columns}
data={data}
getRowKey={(row) => row.id}
/>
</main>,
);
});
it('提示(Toast)各语义变体无违规', async () => {
await expectNoViolations(
<main>
<h1></h1>
<Toast variant="info"></Toast>
<Toast variant="success"></Toast>
<Toast variant="warning" onClose={() => {}}>
</Toast>
<Toast variant="error"></Toast>
</main>,
);
});
it('卡片(Card)容器结构无违规', async () => {
await expectNoViolations(
<main>
<h1></h1>
<Card title="风险概览" footer="更新于今日">
<p></p>
</Card>
</main>,
);
});
it('打开态对话框(Dialog)模态语义与可聚焦关闭按钮无违规', async () => {
function DialogHost(): JSX.Element {
const [open] = useState(true);
return (
<main>
<h1></h1>
<Dialog
open={open}
onClose={() => {}}
title="确认操作"
footer={<Button></Button>}
>
<p></p>
</Dialog>
</main>
);
}
await expectNoViolations(<DialogHost />);
});
});
@@ -0,0 +1,168 @@
/**
* Property 77: 文本对比度达标(WCAG 文本可读性,Req 23.3)。
*
* 属性陈述:对任意 Theme 下任意正文文本与其背景的 Color_Token 对,二者计算所得
* 相对对比度恒不低于 4.5:1;对任意大号文本与其背景的 Color_Token 对,对比度恒不
* 低于 3:1(大号阈值 3:1 弱于正文 4.5:1,故满足正文者必满足大号)。
*
* 该属性依赖 `contrastRatio` 正确实现 WCAG 相对对比度。故本测试用 fast-check
* (≥100 次)覆盖两层:
* 1. 数学性质(对任意颜色对):
* - 对称性:contrastRatio(a, b) === contrastRatio(b, a)。
* - 取值范围:恒落在 [1, 21]。
* - 同色为 1:1;相对亮度恒落在 [0, 1]。
* - 灰阶单调性:8-bit 通道值更大 → 相对亮度不减。
* 2. 已知取值:黑/白 = 21,白亮度 = 1,黑亮度 = 0。
* 3. Design_System 令牌对:正文文本/背景令牌对在 Light 与 Dark 主题下均满足
* 正文阈值(≥4.5:1),从而也满足大号阈值(≥3:1)。
*
* Feature: outsourcing-risk-assessment, Property 77: 文本对比度达标
* Validates: Requirements 23.3
*/
import { describe, expect, it } from 'vitest';
import fc from 'fast-check';
import {
contrastRatio,
relativeLuminance,
meetsAA,
AA_NORMAL_MIN,
AA_LARGE_MIN,
} from '../contrast.js';
import { resolveColorToken, THEME_VALUES } from '../../design-system/index.js';
import type { ColorToken, Theme } from '../../design-system/index.js';
// ----------------------------------------------------------------------------
// 生成器。
// ----------------------------------------------------------------------------
/** 单个 8-bit 通道值(0..255)。 */
const channelArb: fc.Arbitrary<number> = fc.integer({ min: 0, max: 255 });
/** 将 0..255 通道值格式化为两位十六进制。 */
function toHex2(value: number): string {
return value.toString(16).padStart(2, '0');
}
/** 任意 `#RRGGBB` 颜色串。 */
const colorArb: fc.Arbitrary<string> = fc
.tuple(channelArb, channelArb, channelArb)
.map(([r, g, b]) => `#${toHex2(r)}${toHex2(g)}${toHex2(b)}`);
/** 任意灰阶(R=G=B)颜色串,附带其 8-bit 取值,用于单调性检查。 */
const grayArb: fc.Arbitrary<{ value: number; color: string }> = channelArb.map(
(value) => {
const h = toHex2(value);
return { value, color: `#${h}${h}${h}` };
},
);
/** 任意 ThemeLight 或 Dark)。 */
const themeArb: fc.Arbitrary<Theme> = fc.constantFrom(...THEME_VALUES);
/**
* Design_System 中用于正文渲染的「文本令牌 × 背景令牌」对。
* 取自 design.mdtext.primary / text.secondary 配三类表面背景;
* text.onAccent 配 brand.primary(按钮等强调表面上的正文)。
* 这些对在两主题下均应满足正文阈值(≥4.5:1)。
*/
const BODY_TEXT_BACKGROUND_PAIRS: ReadonlyArray<
readonly [ColorToken, ColorToken]
> = [
['color.text.primary', 'color.bg.canvas'],
['color.text.primary', 'color.bg.surface'],
['color.text.primary', 'color.bg.elevated'],
['color.text.secondary', 'color.bg.canvas'],
['color.text.secondary', 'color.bg.surface'],
['color.text.secondary', 'color.bg.elevated'],
['color.text.onAccent', 'color.brand.primary'],
];
const pairArb: fc.Arbitrary<readonly [ColorToken, ColorToken]> =
fc.constantFrom(...BODY_TEXT_BACKGROUND_PAIRS);
// ----------------------------------------------------------------------------
// 测试。
// ----------------------------------------------------------------------------
describe('Property 77: 文本对比度达标 (Req 23.3)', () => {
it('对称性:contrastRatio(a, b) === contrastRatio(b, a)', () => {
fc.assert(
fc.property(colorArb, colorArb, (a, b) => {
expect(contrastRatio(a, b)).toBe(contrastRatio(b, a));
}),
{ numRuns: 100 },
);
});
it('取值范围:对比度恒落在 [1, 21],相对亮度恒落在 [0, 1]', () => {
fc.assert(
fc.property(colorArb, colorArb, (a, b) => {
const ratio = contrastRatio(a, b);
expect(ratio).toBeGreaterThanOrEqual(1);
// 浮点容差:理论上限为 21。
expect(ratio).toBeLessThanOrEqual(21 + 1e-9);
for (const c of [a, b]) {
const l = relativeLuminance(c);
expect(l).toBeGreaterThanOrEqual(0);
expect(l).toBeLessThanOrEqual(1);
}
}),
{ numRuns: 100 },
);
});
it('同色对比度恒为 1:1', () => {
fc.assert(
fc.property(colorArb, (c) => {
expect(contrastRatio(c, c)).toBeCloseTo(1, 10);
}),
{ numRuns: 100 },
);
});
it('灰阶单调性:通道值更大则相对亮度不减', () => {
fc.assert(
fc.property(grayArb, grayArb, (x, y) => {
const lx = relativeLuminance(x.color);
const ly = relativeLuminance(y.color);
if (x.value <= y.value) {
expect(lx).toBeLessThanOrEqual(ly + 1e-12);
} else {
expect(lx).toBeGreaterThanOrEqual(ly - 1e-12);
}
}),
{ numRuns: 100 },
);
});
it('已知取值:黑/白 = 21:1,白亮度 = 1,黑亮度 = 0', () => {
expect(contrastRatio('#000000', '#FFFFFF')).toBeCloseTo(21, 5);
expect(contrastRatio('#FFFFFF', '#000000')).toBeCloseTo(21, 5);
expect(relativeLuminance('#FFFFFF')).toBeCloseTo(1, 10);
expect(relativeLuminance('#000000')).toBeCloseTo(0, 10);
});
it('Design_System 正文文本/背景令牌对在两主题下均满足正文阈值(≥4.5:1,故亦满足大号 ≥3:1', () => {
// 阈值常量符合 WCAG AA,且大号阈值弱于正文阈值。
expect(AA_NORMAL_MIN).toBe(4.5);
expect(AA_LARGE_MIN).toBe(3);
expect(AA_LARGE_MIN).toBeLessThanOrEqual(AA_NORMAL_MIN);
fc.assert(
fc.property(pairArb, themeArb, ([textToken, bgToken], theme) => {
const fg = resolveColorToken(textToken, theme);
const bg = resolveColorToken(bgToken, theme);
const ratio = contrastRatio(fg, bg);
// 正文阈值达标。
expect(ratio).toBeGreaterThanOrEqual(AA_NORMAL_MIN);
expect(meetsAA(fg, bg, false)).toBe(true);
// 满足正文必满足大号。
expect(meetsAA(fg, bg, true)).toBe(true);
}),
{ numRuns: 100 },
);
});
});
+74
View File
@@ -0,0 +1,74 @@
/**
* 单元测试:WCAG 对比度计算(task 22.2 / Req 23.3)。
*/
import { describe, it, expect } from 'vitest';
import {
contrastRatio,
relativeLuminance,
meetsAA,
AA_NORMAL_MIN,
AA_LARGE_MIN,
} from '../contrast.js';
describe('relativeLuminance', () => {
it('黑色亮度为 0', () => {
expect(relativeLuminance('#000000')).toBeCloseTo(0, 10);
});
it('白色亮度为 1', () => {
expect(relativeLuminance('#FFFFFF')).toBeCloseTo(1, 10);
});
it('支持 #RGB 简写(大小写不敏感)', () => {
expect(relativeLuminance('#fff')).toBeCloseTo(1, 10);
expect(relativeLuminance('#FFF')).toBeCloseTo(1, 10);
expect(relativeLuminance('#000')).toBeCloseTo(0, 10);
});
});
describe('contrastRatio', () => {
it('黑白对比度为 21:1', () => {
expect(contrastRatio('#000000', '#FFFFFF')).toBeCloseTo(21, 5);
});
it('同色对比度为 1:1', () => {
expect(contrastRatio('#1A2B3C', '#1A2B3C')).toBeCloseTo(1, 10);
});
it('与参数顺序无关(对称)', () => {
expect(contrastRatio('#1F5FAE', '#FFFFFF')).toBeCloseTo(
contrastRatio('#FFFFFF', '#1F5FAE'),
10,
);
});
it('取值落在 [1, 21] 区间', () => {
const ratio = contrastRatio('#475569', '#F5F7FA');
expect(ratio).toBeGreaterThanOrEqual(1);
expect(ratio).toBeLessThanOrEqual(21);
});
it('对非法十六进制输入抛错', () => {
expect(() => contrastRatio('1A2B3C', '#FFFFFF')).toThrow();
expect(() => contrastRatio('#12', '#FFFFFF')).toThrow();
expect(() => contrastRatio('#GGGGGG', '#FFFFFF')).toThrow();
});
});
describe('meetsAA', () => {
it('黑底白字满足正文与大号文本', () => {
expect(meetsAA('#000000', '#FFFFFF', false)).toBe(true);
expect(meetsAA('#000000', '#FFFFFF', true)).toBe(true);
});
it('同色不满足任何阈值', () => {
expect(meetsAA('#777777', '#777777', false)).toBe(false);
expect(meetsAA('#777777', '#777777', true)).toBe(false);
});
it('阈值常量符合 WCAG AA', () => {
expect(AA_NORMAL_MIN).toBe(4.5);
expect(AA_LARGE_MIN).toBe(3);
});
});
+44
View File
@@ -0,0 +1,44 @@
/**
* 单元测试:键盘可达与可见焦点指示(task 22.1 / Req 23.1, 23.2)。
*/
import { describe, it, expect } from 'vitest';
import {
focusRingStyle,
buildFocusVisibleCss,
focusableProps,
FOCUS_RING_WIDTH_PX,
FOCUS_RING_OFFSET_PX,
} from '../focus.js';
describe('focusRingStyle (Req 23.2)', () => {
it('提供可见的 outline 焦点环', () => {
expect(focusRingStyle.outline).toContain(`${FOCUS_RING_WIDTH_PX}px solid`);
expect(focusRingStyle.outlineOffset).toBe(`${FOCUS_RING_OFFSET_PX}px`);
});
});
describe('buildFocusVisibleCss (Req 23.2)', () => {
it('默认覆盖常见交互控件并生成 :focus-visible 规则', () => {
const css = buildFocusVisibleCss();
expect(css).toContain(':focus-visible');
expect(css).toContain('button:focus-visible');
expect(css).toContain('input:focus-visible');
expect(css).toContain('outline:');
});
it('支持自定义选择器', () => {
const css = buildFocusVisibleCss('.my-control');
expect(css).toContain('.my-control:focus-visible');
});
});
describe('focusableProps (Req 23.1)', () => {
it('原生交互元素无需附加 tabIndex', () => {
expect(focusableProps(true)).toEqual({});
});
it('自定义非交互元素需 tabIndex 0 以进入 Tab 序列', () => {
expect(focusableProps(false)).toEqual({ tabIndex: 0 });
});
});
@@ -0,0 +1,129 @@
/**
* 表单可访问标注与无障碍错误 — Property 78task 22.4Req 23.4 / 23.5)。
*
* Feature: outsourcing-risk-assessment, Property 78: 表单可访问标注与错误提示
*
* 对任意表单输入,`associateLabel` 产出非空的可达名称关系(控件↔标签);当校验失败时,
* `renderFieldError` 为每个失败控件产出可被辅助技术识别的非空错误提示。
*
* 本测试分两层覆盖(均 ≥100 次迭代):
* 1. 纯函数层(`associateLabel` / `renderFieldError`):对任意 (id, label, 校验态)
* 断言可达名称非空且等于 label.trim()、id 关联稳定;校验通过返回 null,校验失败
* 返回携带 ariaInvalid / describedBy=errorId=`${id}-error`)的非空错误。
* 2. 渲染层(`AccessibleField` + RTL):断言控件可由可达名称定位、aria-invalid 反映
* 校验态;校验失败时存在 role="alert" 的非空错误文本,且控件 aria-describedby
* 指向该错误元素 id。
*
* 说明:RTL 仅在 vitest `afterEach` 自动 cleanup,本属性在单个 `it` 内跑 ≥100 次
* render,故每次迭代后显式 `cleanup()` 清空 DOM,避免重复节点导致定位命中多个元素。
*/
import { describe, expect, it } from 'vitest';
import { cleanup, render, screen } from '@testing-library/react';
import fc from 'fast-check';
import { AccessibleField } from '../AccessibleField.js';
import {
associateLabel,
renderFieldError,
fieldErrorId,
fieldLabelId,
type FieldValidation,
} from '../forms.js';
/** 无空白的标识/标签内核(避免 DOM 文本规范化导致的匹配抖动)。 */
const wordArb = fc
.array(
fc.constantFrom(
'供', '应', '商', '名', '称', '预', '算', '区', '域',
'a', 'b', 'c', 'd', 'e', '0', '1', '2', '3',
),
{ minLength: 1, maxLength: 16 },
)
.map((chars) => chars.join(''));
/** 控件 id:无空白内核 + 可选首尾空白(用于验证 trim 行为)。 */
const idArb = fc
.tuple(fc.constantFrom('', ' ', ' '), wordArb, fc.constantFrom('', ' ', ' '))
.map(([lead, core, trail]) => `${lead}${core}${trail}`);
/** 标签:无内部多空白的内核 + 可选首尾空白。 */
const labelArb = fc
.tuple(fc.constantFrom('', ' ', ' '), wordArb, fc.constantFrom('', ' ', ' '))
.map(([lead, core, trail]) => `${lead}${core}${trail}`);
/** 校验态:通过 / 失败(带消息)/ 失败(空白消息)/ 失败(无消息)。 */
const validationArb: fc.Arbitrary<FieldValidation> = fc.oneof(
fc.constant({ valid: true } as const),
fc.record({ valid: fc.constant(false as const), message: wordArb }),
fc.constant({ valid: false, message: ' ' } as const),
fc.constant({ valid: false } as const),
);
describe('表单可访问标注与错误提示 — Property 78Req 23.4 / 23.5', () => {
it('纯函数:对任意输入产出非空可达名称关系,校验失败产出可识别的非空错误', () => {
fc.assert(
fc.property(idArb, labelArb, validationArb, (id, label, validation) => {
// Req 23.4:可达名称关系非空且稳定。
const assoc = associateLabel({ id, label });
const trimmedId = id.trim();
const trimmedLabel = label.trim();
expect(assoc.controlId).toBe(trimmedId);
expect(assoc.controlId.length).toBeGreaterThan(0);
expect(assoc.labelId).toBe(fieldLabelId(trimmedId));
expect(assoc.accessibleName).toBe(trimmedLabel);
expect(assoc.accessibleName.length).toBeGreaterThan(0);
// Req 23.5:校验失败的控件必有可识别的非空错误。
const error = renderFieldError(assoc.controlId, validation);
if (validation.valid) {
expect(error).toBeNull();
} else {
expect(error).not.toBeNull();
expect(error?.controlId).toBe(trimmedId);
expect(error?.errorId).toBe(fieldErrorId(trimmedId));
expect(error?.ariaInvalid).toBe(true);
expect(error?.describedBy).toBe(error?.errorId);
expect((error?.message.length ?? 0)).toBeGreaterThan(0);
}
}),
{ numRuns: 200 },
);
});
it('渲染:控件可由非空可达名称定位,校验失败时呈现关联的非空无障碍错误', () => {
fc.assert(
fc.property(idArb, labelArb, validationArb, (id, label, validation) => {
try {
const trimmedId = id.trim();
const accessibleName = label.trim();
render(
<AccessibleField id={id} label={label} value="" onChange={() => {}} validation={validation} />,
);
// Req 23.4:控件可由非空可达名称定位,且 id 关联稳定。
const input = screen.getByLabelText(accessibleName);
expect(input).toBeInTheDocument();
expect(input).toHaveAttribute('id', trimmedId);
if (validation.valid) {
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(input).toHaveAttribute('aria-invalid', 'false');
} else {
// Req 23.5:失败控件呈现可被辅助技术识别的非空错误,并与控件关联。
const alert = screen.getByRole('alert');
const errorId = fieldErrorId(trimmedId);
expect((alert.textContent ?? '').length).toBeGreaterThan(0);
expect(alert).toHaveAttribute('id', errorId);
expect(input).toHaveAttribute('aria-invalid', 'true');
expect(input).toHaveAttribute('aria-describedby', errorId);
}
} finally {
cleanup();
}
}),
{ numRuns: 100 },
);
});
});
+82
View File
@@ -0,0 +1,82 @@
/**
* 单元测试:表单可访问标注与无障碍错误(task 22.1 / Req 23.4, 23.5)。
*/
import { describe, it, expect } from 'vitest';
import {
associateLabel,
renderFieldError,
fieldErrorId,
fieldLabelId,
DEFAULT_FIELD_ERROR_MESSAGE,
} from '../forms.js';
describe('associateLabel (Req 23.4)', () => {
it('返回稳定 id 与非空可达名称', () => {
const result = associateLabel({ id: 'vendor-name', label: '供应商名称' });
expect(result).toEqual({
controlId: 'vendor-name',
labelId: 'vendor-name-label',
accessibleName: '供应商名称',
});
expect(result.accessibleName.length).toBeGreaterThan(0);
});
it('去除标签首尾空白后作为可达名称', () => {
expect(associateLabel({ id: 'x', label: ' 预算 ' }).accessibleName).toBe('预算');
});
it('标签为空或仅空白时抛错(非空文本标签为硬约束)', () => {
expect(() => associateLabel({ id: 'x', label: '' })).toThrow(/non-empty/);
expect(() => associateLabel({ id: 'x', label: ' ' })).toThrow(/non-empty/);
});
it('控件 id 为空时抛错', () => {
expect(() => associateLabel({ id: ' ', label: '标签' })).toThrow(/non-empty/);
});
});
describe('renderFieldError (Req 23.5)', () => {
it('校验通过时返回 null', () => {
expect(renderFieldError('vendor-name', { valid: true })).toBeNull();
});
it('校验失败时返回关联的非空错误,携带 ariaInvalid 与 describedBy', () => {
const err = renderFieldError('budget', { valid: false, message: '预算必须为正数' });
expect(err).not.toBeNull();
expect(err).toEqual({
errorId: 'budget-error',
controlId: 'budget',
message: '预算必须为正数',
ariaInvalid: true,
describedBy: 'budget-error',
});
expect(err?.describedBy).toBe(err?.errorId);
expect((err?.message.length ?? 0)).toBeGreaterThan(0);
});
it('校验失败但无消息时回退到非空默认提示', () => {
const err = renderFieldError('budget', { valid: false });
expect(err?.message).toBe(DEFAULT_FIELD_ERROR_MESSAGE);
expect((err?.message.length ?? 0)).toBeGreaterThan(0);
});
it('校验失败但消息仅空白时回退到默认提示', () => {
const err = renderFieldError('budget', { valid: false, message: ' ' });
expect(err?.message).toBe(DEFAULT_FIELD_ERROR_MESSAGE);
});
it('控件 id 为空时抛错', () => {
expect(() => renderFieldError(' ', { valid: false })).toThrow(/non-empty/);
});
});
describe('id 推导辅助', () => {
it('错误元素 id 与 Design_System Input 约定一致(${id}-error', () => {
expect(fieldErrorId('vendor-name')).toBe('vendor-name-error');
});
it('标签元素 id 为 ${id}-label', () => {
expect(fieldLabelId('vendor-name')).toBe('vendor-name-label');
});
});
+123
View File
@@ -0,0 +1,123 @@
/**
* WCAG 2.1 对比度计算(Req 23.3 / task 22.2)。
*
* 提供纯函数 `contrastRatio(fg, bg)` 计算两个颜色的相对对比度,供文本可读性
* 达标校验使用:正文(normal)≥ 4.5:1、大号文本(large)≥ 3:1Property 77)。
*
* 取值来源为 Design_System 的 `ColorValue``#RGB` / `#RRGGBB` 十六进制串)。
* 全部函数纯且确定(pure & deterministic),无副作用、无外部状态。
*
* 计算依据(WCAG 2.1):
* - 相对亮度 L = 0.2126*R + 0.7152*G + 0.0722*B
* 其中各通道 c(sRGB,归一化到 0..1)线性化:
* c ≤ 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ^ 2.4
* - 对比度 = (L_lighter + 0.05) / (L_darker + 0.05),取值范围 [1, 21]。
*/
import type { ColorValue } from '../design-system/index.js';
/** 正文(常规文本)的 WCAG AA 最小对比度。 */
export const AA_NORMAL_MIN = 4.5;
/** 大号文本(≥18pt 常规 / ≥14pt 粗体)的 WCAG AA 最小对比度。 */
export const AA_LARGE_MIN = 3;
/** 解析后的 sRGB 三通道(每通道整数 0..255)。 */
interface Rgb {
readonly r: number;
readonly g: number;
readonly b: number;
}
/**
* 将 `#RGB` / `#RRGGBB`(大小写不敏感)十六进制颜色解析为 0..255 三通道。
* 对明显非法的输入抛出 `Error`。
*
* @param color 形如 `#1A2B3C` 或 `#abc` 的十六进制颜色串。
* @throws {Error} 当格式非法(非字符串、缺少 `#`、长度/字符不合法)时。
*/
function parseHexColor(color: ColorValue): Rgb {
if (typeof color !== 'string') {
throw new TypeError(`Invalid color value: expected string, got ${typeof color}`);
}
const trimmed = color.trim();
const match = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.exec(trimmed);
const hex = match?.[1];
if (hex === undefined) {
throw new Error(`Invalid hex color: "${color}"`);
}
// 规整为 6 位:#RGB → 每位扩展为两位(如 a → aa)。
const full =
hex.length === 3
? hex.replace(/./g, (ch) => ch + ch)
: hex;
const r = Number.parseInt(full.slice(0, 2), 16);
const g = Number.parseInt(full.slice(2, 4), 16);
const b = Number.parseInt(full.slice(4, 6), 16);
return { r, g, b };
}
/**
* 将单个 sRGB 通道(0..255)线性化为线性 RGB 分量(0..1)。
*
* @param channel8bit 0..255 的整数通道值。
*/
function linearizeChannel(channel8bit: number): number {
const c = channel8bit / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}
/**
* 计算颜色的 WCAG 相对亮度 L(0..1)。
*
* @param color `#RGB` / `#RRGGBB` 十六进制颜色串。
* @returns 相对亮度,范围 [0, 1](黑=0,白=1)。
* @throws {Error} 当颜色格式非法时。
*/
export function relativeLuminance(color: ColorValue): number {
const { r, g, b } = parseHexColor(color);
const rLin = linearizeChannel(r);
const gLin = linearizeChannel(g);
const bLin = linearizeChannel(b);
return 0.2126 * rLin + 0.7152 * gLin + 0.0722 * bLin;
}
/**
* 计算前景色与背景色之间的 WCAG 相对对比度。
*
* 与参数顺序无关(对称):`contrastRatio(a, b) === contrastRatio(b, a)`。
*
* @param fg 前景(文本)颜色。
* @param bg 背景颜色。
* @returns 对比度,范围 [1, 21]。
* @throws {Error} 当任一颜色格式非法时。
*/
export function contrastRatio(fg: ColorValue, bg: ColorValue): number {
const l1 = relativeLuminance(fg);
const l2 = relativeLuminance(bg);
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
/**
* 判定前景/背景对是否满足 WCAG AA 文本对比度。
*
* @param fg 前景(文本)颜色。
* @param bg 背景颜色。
* @param isLargeText 是否为大号文本(true → 阈值 3:1false → 4.5:1)。
* @returns 达标返回 `true`,否则 `false`。
* @throws {Error} 当任一颜色格式非法时。
*/
export function meetsAA(
fg: ColorValue,
bg: ColorValue,
isLargeText: boolean,
): boolean {
const ratio = contrastRatio(fg, bg);
const min = isLargeText ? AA_LARGE_MIN : AA_NORMAL_MIN;
return ratio >= min;
}
+77
View File
@@ -0,0 +1,77 @@
/**
* 键盘可达与可见焦点指示(Req 23.1 / 23.2task 22.1)。
*
* 本模块以**纯函数 / 常量**承载焦点相关的样式与可达性约定,供基础组件与表现层复用:
* - 可见焦点指示(Req 23.2):`focusRingStyle` 提供一组可附加到控件的内联样式,
* `buildFocusVisibleCss(selector)` 生成基于 `:focus-visible` 的全局样式串——
* 仅在键盘聚焦时呈现焦点环,覆盖部分组件 `outline: none` 的默认外观,
* 确保所有交互控件聚焦时都有清晰可见的指示。
* - 键盘可达(Req 23.1):原生 `<button>` / `<a href>` / `<input>` 等元素天然可
* 聚焦并可由 Enter/Space 触发;对以 `div`/`span` 等非交互元素实现的自定义控件,
* `focusableProps(isNativelyInteractive)` 返回应附加的属性(如 `tabIndex: 0`),
* 使其可进入 Tab 焦点序列。
*
* 设计要点:颜色取自 Design_System Color_Token(经 `colorTokenToCssVarName` 映射为
* CSS 变量引用),随 Theme 切换自动更新;全部导出无副作用、确定性。
*/
import type { CSSProperties } from 'react';
import { colorTokenToCssVarName } from '../design-system/index.js';
/** 焦点环线宽(px)。足够明显以满足可见性要求(Req 23.2)。 */
export const FOCUS_RING_WIDTH_PX = 2;
/** 焦点环与控件边缘的间距(px),避免环与内容粘连。 */
export const FOCUS_RING_OFFSET_PX = 2;
/** 焦点环颜色(以品牌主色的 CSS 变量引用,随 Theme 切换更新)。 */
export const FOCUS_RING_COLOR = `var(${colorTokenToCssVarName('color.brand.primary')})`;
/**
* 可见焦点指示的内联样式(Req 23.2)。
*
* 以 `outline` 实现(不占布局、不触发回流),可直接展开到控件聚焦态样式中。
* 例如:`style={{ ...(focused ? focusRingStyle : null) }}`。
*/
export const focusRingStyle: CSSProperties = {
outline: `${FOCUS_RING_WIDTH_PX}px solid ${FOCUS_RING_COLOR}`,
outlineOffset: `${FOCUS_RING_OFFSET_PX}px`,
};
/**
* 生成基于 `:focus-visible` 的全局焦点环样式串,供注入 `<style>` 使用(见
* `FocusRingStyles` 组件)。仅在键盘聚焦(focus-visible)时呈现焦点环,
* 不影响鼠标点击的视觉。默认选择器覆盖全部常见交互控件。
*
* @param selector 目标选择器,默认覆盖 `a[href]`、`button`、`input`、`select`、
* `textarea` 以及任何 `[tabindex]` 元素。
*/
export function buildFocusVisibleCss(
selector = 'a[href], button, input, select, textarea, [tabindex]',
): string {
return `${selector} { outline: none; }
${selector
.split(',')
.map((part) => `${part.trim()}:focus-visible`)
.join(', ')} { outline: ${FOCUS_RING_WIDTH_PX}px solid ${FOCUS_RING_COLOR}; outline-offset: ${FOCUS_RING_OFFSET_PX}px; }`;
}
/** `focusableProps` 的返回:附加到元素以保证键盘可达的属性。 */
export interface FocusableProps {
/** Tab 焦点序列中的位置;自定义控件取 `0` 以进入自然顺序。 */
readonly tabIndex?: 0;
}
/**
* 返回为使元素键盘可达应附加的属性(Req 23.1)。
*
* - 原生交互元素(`<button>` / `<a href>` / `<input>` 等,`isNativelyInteractive=true`
* 默认即可聚焦并可由键盘触发,无需附加 `tabIndex` → 返回空对象。
* - 以非交互元素(`div`/`span` 等)实现的自定义控件(`isNativelyInteractive=false`
* 需 `tabIndex: 0` 才能进入 Tab 焦点序列 → 返回 `{ tabIndex: 0 }`。
*
* @param isNativelyInteractive 元素是否为原生可聚焦的交互元素。
*/
export function focusableProps(isNativelyInteractive: boolean): FocusableProps {
return isNativelyInteractive ? {} : { tabIndex: 0 };
}
+140
View File
@@ -0,0 +1,140 @@
/**
* 表单可访问标注与无障碍错误(Req 23.4 / 23.5task 22.1)。
*
* 提供两个**纯函数**,供表现层与属性测试(task 22.4 / Property 78)共同复用:
* - `associateLabel(input)`:为表单输入控件生成稳定的 `controlId` / `labelId`
* 并返回**非空**且可被辅助技术识别的 `accessibleName`Req 23.4)。
* - `renderFieldError(controlId, validation)`:当校验失败时,为该控件返回可被
* 辅助技术识别的**非空**错误提示(`AccessibleError`),并携带将 `<input>` 与
* 错误文本关联所需的 `ariaInvalid` / `describedBy`Req 23.5);校验通过返回 `null`。
*
* 设计要点:
* - 全部函数无副作用、不修改入参、对同一输入恒返回同一结果(pure & deterministic)。
* - id 约定与 Design_System 的 `Input` 保持一致:错误元素 id 为 `${controlId}-error`
* (见 design-system/components/Input.tsx),从而 `aria-describedby` 可正确指向。
* - 「非空标签」「非空错误」为硬约束:缺失标签即抛错(Req 23.4 要求非空文本标签),
* 校验失败但未提供消息时回退到稳定的默认提示,保证错误提示恒非空(Req 23.5)。
*/
/** 表单输入控件的描述符(标注所需的最小信息)。 */
export interface FieldInput {
/** 控件 id,用于 `<label htmlFor>` 与 `<input id>` 关联(必填、非空)。 */
readonly id: string;
/** 字段标签文本(必填、非空——Req 23.4 要求可被辅助技术识别的非空文本标签)。 */
readonly label: string;
}
/** `associateLabel` 的返回:稳定 id 与非空可达名称(Req 23.4)。 */
export interface LabelAssociation {
/** 控件元素 id`<input id>`)。 */
readonly controlId: string;
/** 标签元素 id`<label id>`),用于 `aria-labelledby` 等关联场景。 */
readonly labelId: string;
/** 可被辅助技术识别的非空可达名称(去除首尾空白后的标签文本)。 */
readonly accessibleName: string;
}
/**
* 字段校验结果(判别联合)。
* - `{ valid: true }`:校验通过。
* - `{ valid: false, message? }`:校验失败,可携带具体错误文本;缺省时回退默认提示。
*/
export type FieldValidation =
| { readonly valid: true }
| { readonly valid: false; readonly message?: string };
/** 校验失败控件的无障碍错误描述(Req 23.5)。 */
export interface AccessibleError {
/** 错误提示元素 id(恒为 `${controlId}-error`)。 */
readonly errorId: string;
/** 关联的控件 id。 */
readonly controlId: string;
/** 可被辅助技术识别的**非空**错误提示文本。 */
readonly message: string;
/** 标注控件为无效态(`aria-invalid="true"`)。 */
readonly ariaInvalid: true;
/** 控件 `aria-describedby` 应指向的元素 id(恒等于 `errorId`)。 */
readonly describedBy: string;
}
/** 校验失败但未提供消息时的稳定默认错误提示(保证错误提示恒非空,Req 23.5)。 */
export const DEFAULT_FIELD_ERROR_MESSAGE = '此项填写无效,请检查后重新输入。';
/** 由控件 id 推导错误提示元素 id(与 Design_System `Input` 约定一致)。 */
export function fieldErrorId(controlId: string): string {
return `${controlId}-error`;
}
/** 由控件 id 推导标签元素 id。 */
export function fieldLabelId(controlId: string): string {
return `${controlId}-label`;
}
/**
* 为表单输入控件生成可访问标注(Req 23.4)。
*
* 返回稳定的 `controlId` / `labelId` 与**非空**的 `accessibleName`(去除首尾空白后
* 的标签文本)。当 `id` 或标签为空(或仅空白)时抛错——Req 23.4 要求每个输入控件
* 必关联可被辅助技术识别的非空文本标签,缺失即视为不达标,应在开发期暴露而非静默退化。
*
* @param input 控件描述符(`id` + `label`)。
* @throws {Error} 当 `id` 为空、或标签去空白后为空时。
*/
export function associateLabel(input: FieldInput): LabelAssociation {
const controlId = input.id.trim();
if (controlId.length === 0) {
throw new Error('associateLabel: control id must be a non-empty string');
}
const accessibleName = input.label.trim();
if (accessibleName.length === 0) {
throw new Error(
`associateLabel: label for control "${controlId}" must be a non-empty text (Req 23.4)`,
);
}
return {
controlId,
labelId: fieldLabelId(controlId),
accessibleName,
};
}
/**
* 依据校验结果为控件生成无障碍错误(Req 23.5)。
*
* - 校验通过(`valid: true`)→ 返回 `null`(无错误提示)。
* - 校验失败(`valid: false`)→ 返回 `AccessibleError`,其 `message` 恒非空
* (未提供或仅空白时回退到 `DEFAULT_FIELD_ERROR_MESSAGE`),并携带
* `ariaInvalid: true` 与 `describedBy = errorId`,使错误文本可被辅助技术识别并
* 与控件关联。
*
* @param controlId 关联的控件 id(非空)。
* @param validation 字段校验结果。
* @throws {Error} 当 `controlId` 为空时。
*/
export function renderFieldError(
controlId: string,
validation: FieldValidation,
): AccessibleError | null {
const trimmedControlId = controlId.trim();
if (trimmedControlId.length === 0) {
throw new Error('renderFieldError: control id must be a non-empty string');
}
if (validation.valid) {
return null;
}
const provided = validation.message?.trim() ?? '';
const message = provided.length > 0 ? provided : DEFAULT_FIELD_ERROR_MESSAGE;
const errorId = fieldErrorId(trimmedControlId);
return {
errorId,
controlId: trimmedControlId,
message,
ariaInvalid: true,
describedBy: errorId,
};
}
+58
View File
@@ -0,0 +1,58 @@
/**
* 可访问性(a11y)公共入口(barrel)。
*
* 暴露 WCAG 对比度计算 APItask 22.2 / Req 23.3)。
*/
export {
contrastRatio,
relativeLuminance,
meetsAA,
AA_NORMAL_MIN,
AA_LARGE_MIN,
} from './contrast.js';
/* ------------------------------------------------------------------ *
* 表单可访问标注与无障碍错误(task 22.1 / Req 23.4, 23.5
* ------------------------------------------------------------------ */
export {
associateLabel,
renderFieldError,
fieldErrorId,
fieldLabelId,
DEFAULT_FIELD_ERROR_MESSAGE,
} from './forms.js';
export type {
FieldInput,
LabelAssociation,
FieldValidation,
AccessibleError,
} from './forms.js';
/* ------------------------------------------------------------------ *
* 键盘可达与可见焦点指示(task 22.1 / Req 23.1, 23.2
* ------------------------------------------------------------------ */
export {
focusRingStyle,
buildFocusVisibleCss,
focusableProps,
FOCUS_RING_WIDTH_PX,
FOCUS_RING_OFFSET_PX,
FOCUS_RING_COLOR,
} from './focus.js';
export type { FocusableProps } from './focus.js';
/* ------------------------------------------------------------------ *
* 可访问表单字段组件(task 22.1)
* ------------------------------------------------------------------ */
export { AccessibleField, FocusRingStyles } from './AccessibleField.js';
export type {
AccessibleFieldProps,
FocusRingStylesProps,
} from './AccessibleField.js';
+882
View File
@@ -0,0 +1,882 @@
/**
* HTTP API 客户端:封装 fetch 调用后端 REST APIReq 16.2)。
*
* 所有 API 返回 Promise<T>,错误时抛出 ApiError(含 status 与 message)。
*/
/**
* 后端 API 基址。
* - 开发模式:默认 http://localhost:3005Vite 代理或直连本地后端)。
* - 生产模式:默认空串 → 走相对路径 /api/...,由 nginx 同源反代到后端,避免硬编码域名/端口。
* - 可用环境变量 VITE_API_BASE 覆盖。
*/
export const API_BASE =
(import.meta.env.VITE_API_BASE as string | undefined) ??
(import.meta.env.DEV ? 'http://localhost:3005' : '');
/** 读取本地保存的鉴权令牌(生产 RBAC);演示模式下为空,请求不带令牌。 */
function authHeader(): Record<string, string> {
try {
const t = localStorage.getItem('risk-agent-token');
return t !== null && t !== '' ? { Authorization: `Bearer ${t}` } : {};
} catch {
return {};
}
}
/** API 调用错误。 */
export class ApiError extends Error {
constructor(
readonly status: number,
message: string,
) {
super(message);
this.name = 'ApiError';
}
}
async function request<T>(
method: 'GET' | 'POST',
path: string,
body?: unknown,
): Promise<T> {
const url = `${API_BASE}${path}`;
const init: RequestInit = {
method,
headers: { 'Content-Type': 'application/json', ...authHeader() },
};
if (body !== undefined) {
init.body = JSON.stringify(body);
}
const res = await fetch(url, init);
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new ApiError(
res.status,
typeof data.error === 'string' ? data.error : `HTTP ${res.status}`,
);
}
return data as T;
}
/** 分类结果(镜像后端 ClassificationResult)。 */
export interface ClassificationResult {
readonly businessType: string;
readonly businessTypeConfidence: number;
readonly businessTypeCandidates: ReadonlyArray<{ value: string; confidence: number }>;
readonly needsBusinessTypeConfirm: boolean;
readonly industry: string;
readonly industryConfidence: number;
readonly industryCandidates: ReadonlyArray<{ value: string; confidence: number }>;
readonly needsIndustryConfirm: boolean;
}
/** 评估运行结果(镜像后端 RunAssessment 响应)。 */
export interface RunAssessmentResult {
readonly assessmentId: string;
readonly assessment: unknown;
readonly report: unknown;
readonly classification: ClassificationResult;
readonly confirmed: { businessType: string; industry: string };
readonly riskScore: number;
readonly riskGrade: string;
readonly acceptability: string;
readonly residualGaps: readonly string[];
}
/** 工作流状态。 */
export type WorkflowStatus =
| 'draft'
| 'pending_risk_review'
| 'risk_reviewed'
| 'pending_management_approval'
| 'approved'
| 'rejected'
| 'abandoned';
/** 操作记录条目。 */
export interface AuditLogEntry {
readonly role: string;
readonly username: string;
readonly action: string;
readonly comment?: string;
readonly timestamp: string;
}
/** 评估列表项。 */
export interface AssessmentListItem {
readonly id: string;
readonly projectDescription: string;
readonly businessType: string;
readonly industry: string;
readonly region: string;
readonly riskScore?: number;
readonly riskGrade?: string;
readonly acceptability?: string;
readonly createdAt: string;
readonly assessorId: string;
readonly status: WorkflowStatus;
readonly auditLog: AuditLogEntry[];
readonly archived?: boolean;
readonly recommendation?: { level: string; title: string };
}
/** 综合承接建议。 */
export interface Recommendation {
readonly level: 'accept' | 'conditional' | 'caution' | 'reject';
readonly title: string;
readonly note: string;
readonly targetMargin: number;
readonly netMargin: number | null;
}
/** 运营控制指标——单条。 */
export interface ControlMetric {
readonly name: string;
readonly target: string;
readonly current: string | null;
readonly rationale: string;
readonly action: string;
}
/** 运营控制指标——分组。 */
export interface ControlGroup {
readonly category: '盈利管控' | '运营管控' | '质量管控';
readonly metrics: readonly ControlMetric[];
}
/** 运营控制指标集合(建议承接项目的盈利/运营/质量管控看板)。 */
export interface OperatingControls {
readonly groups: readonly ControlGroup[];
readonly note: string;
}
/** 模板列表项。 */
export interface TemplateListItem {
readonly id: string;
readonly name: string;
readonly businessType: string;
readonly industry: string;
readonly isDefault: boolean;
readonly dimensions: ReadonlyArray<{
readonly id: string;
readonly name: string;
readonly weight: number;
readonly enabled: boolean;
readonly indicators: ReadonlyArray<{
readonly id: string;
readonly name: string;
readonly weight: number;
readonly enabled: boolean;
}>;
}>;
readonly redlines: ReadonlyArray<{
readonly id: string;
readonly triggerCondition: string;
readonly consequence: string;
readonly enabled: boolean;
}>;
}
/** 输入项目描述,返回分类识别结果。 */
export async function classifyProject(
projectDescription: string,
): Promise<ClassificationResult> {
return request<ClassificationResult>('POST', '/api/assessments/classify', {
projectDescription,
});
}
/** 运行完整评估流程。 */
export async function runAssessment(
params: {
readonly projectDescription: string;
readonly confirmation?: { readonly businessType?: string; readonly industry?: string };
readonly region?: string;
readonly assessorId?: string;
readonly assessmentId?: string;
readonly expectedSavedAt?: string;
readonly clientTotalHeadcount?: number;
readonly knownData?: ReadonlyArray<readonly [string, number]>;
readonly costInputs?: Record<string, number>;
readonly useLlm?: boolean;
readonly profitabilityInputs?: ProfitabilityInputs;
},
): Promise<RunAssessmentResult> {
return request<RunAssessmentResult>('POST', '/api/assessments/run', params);
}
/** 单个指标的分级选项。 */
export interface IndicatorLevelOption {
readonly level: number;
readonly label: string;
readonly description: string;
}
/** LLM 对某指标的预填建议。 */
export interface IndicatorSuggestion {
readonly indicatorId: string;
readonly level: number;
readonly confidence: number;
readonly rationale: string;
}
/** 追问指标项(含话术、分级含义与 LLM 预填)。 */
export interface IndicatorQuestion {
readonly dimensionId: string;
readonly dimensionName: string;
readonly indicatorId: string;
readonly name: string;
readonly askPrompt: string;
readonly evidenceRequired: string;
readonly levels: readonly IndicatorLevelOption[];
readonly suggestion: IndicatorSuggestion | null;
}
/** questions 接口响应。 */
export interface QuestionsResponse {
readonly businessType: string;
readonly industry: string;
readonly llmEnabled: boolean;
readonly indicators: readonly IndicatorQuestion[];
}
/** 获取指定业务类型/行业下的指标清单(含 LLM 预填)。 */
export async function fetchQuestions(params: {
readonly projectDescription: string;
readonly businessType: string;
readonly industry?: string;
readonly skipPrefill?: boolean;
}): Promise<QuestionsResponse> {
return request<QuestionsResponse>('POST', '/api/assessments/questions', params);
}
/** 用 LLM 从项目描述抽取岗位明细(名称+人数)。失败时后端返回空数组。 */
export async function extractPositions(
projectDescription: string,
): Promise<Array<{ name: string; headcount: number }>> {
const resp = await request<{ positions?: Array<{ name: string; headcount: number }> }>(
'POST',
'/api/assessments/extract-positions',
{ projectDescription },
);
return resp.positions ?? [];
}
/** LLM 综合研判结果。 */
export interface SynthesisResult {
readonly suggestedGrade: string;
readonly confidence: number;
readonly overall: string;
readonly crossRisks: readonly string[];
readonly suggestedConditions: readonly string[];
readonly divergent: boolean;
}
/** 盈利分析——岗位明细输入。 */
export interface PositionInput {
name: string;
headcount: number;
monthlyGrossSalary: number;
socialInsuranceBase?: number;
housingFundBase?: number;
monthlyBenefits?: number;
unitPrice?: number;
}
/** 盈利分析输入。 */
export interface ProfitabilityInputs {
businessType: string;
region?: string;
pricingModel: 'per_head' | 'cost_plus' | 'fixed_total' | 'volume';
contractMonths?: number;
positions: PositionInput[];
managementFeePerHeadMonth?: number;
markupRate?: number;
contractTotal?: number;
attritionMonthlyRate?: number;
recruitingCostPerHire?: number;
trainingCostPerHead?: number;
employerLiabilityInsuranceRate?: number;
managementSpan?: number;
managementMonthlyCostPerManager?: number;
accountPeriodMonths?: number;
annualInterestRate?: number;
periodExpenseRate?: number;
utilizationRate?: number;
vatMode?: 'general' | 'simplified_diff';
}
/** 盈利分析结果(镜像后端 ProfitabilityResult 关键字段)。 */
export interface ProfitabilityResult {
businessType: string;
pricingModel: string;
region: string;
contractMonths: number;
vatMode: string;
positions: ReadonlyArray<{
name: string;
headcount: number;
perHead: {
grossSalary: number;
socialInsurance: number;
housingFund: number;
benefits: number;
employerInsurance: number;
recruitingAmortized: number;
trainingAmortized: number;
managementAllocated: number;
fullyLoaded: number;
};
loadingFactor: number;
monthlyCost: number;
monthlyRevenue: number;
}>;
totalHeadcount: number;
loadingFactor: number;
monthly: {
revenueGross: number;
revenueNet: number;
laborCost: number;
managementCost: number;
totalCost: number;
grossProfit: number;
grossMargin: number;
periodExpense: number;
financeCost: number;
vat: number;
surcharge: number;
riskReserve: number;
badDebtReserve: number;
netProfit: number;
netMargin: number;
};
contract: {
revenueGross: number;
revenueNet: number;
totalCost: number;
grossProfit: number;
netProfit: number;
};
breakeven: { unitPrice?: number; markupRate?: number; utilization?: number };
sensitivity: ReadonlyArray<{ variable: string; baseNetMargin: number; shockedNetMargin: number; deltaPct: number }>;
cashflow: {
maxAdvance: number;
peakMonth: number;
points: ReadonlyArray<{ month: number; cumulative: number }>;
};
marginCurve: ReadonlyArray<{ priceFactor: number; unitPrice: number | null; netMargin: number }>;
assumptions: readonly string[];
}
/** 盈利分析(无状态计算)。 */
export async function analyzeProfitability(inputs: ProfitabilityInputs): Promise<ProfitabilityResult> {
return request<ProfitabilityResult>('POST', '/api/assessments/profitability', inputs);
}
/** 对已完成评估生成 LLM 综合研判。 */
export async function fetchSynthesis(id: string): Promise<SynthesisResult> {
return request<SynthesisResult>('POST', `/api/assessments/${id}/synthesis`, {});
}
/** 重新生成综合承接建议(可指定目标净利率)。 */
export async function regenerateRecommendation(
id: string,
targetMargin?: number,
): Promise<Recommendation> {
return request<Recommendation>(
'POST',
`/api/assessments/${id}/recommendation`,
targetMargin !== undefined ? { targetMargin } : {},
);
}
/** 获取评估列表(全量,向后兼容)。 */
export async function fetchAssessments(): Promise<AssessmentListItem[]> {
return request<AssessmentListItem[]>('GET', '/api/assessments');
}
/** 分页列表响应。 */
export interface AssessmentPage {
readonly items: AssessmentListItem[];
readonly total: number;
readonly page: number;
readonly pageSize: number;
}
/** 服务端分页 + 状态过滤 + 关键词搜索(直接走 SQL)。 */
export async function fetchAssessmentsPage(params: {
readonly page: number;
readonly pageSize: number;
readonly status?: string;
readonly q?: string;
readonly archived?: 'active' | 'archived' | 'all';
}): Promise<AssessmentPage> {
const sp = new URLSearchParams();
sp.set('page', String(params.page));
sp.set('pageSize', String(params.pageSize));
if (params.status !== undefined && params.status !== '' && params.status !== 'all') {
sp.set('status', params.status);
}
if (params.q !== undefined && params.q.trim() !== '') {
sp.set('q', params.q.trim());
}
if (params.archived !== undefined && params.archived !== 'active') {
sp.set('archived', params.archived);
}
return request<AssessmentPage>('GET', `/api/assessments?${sp.toString()}`);
}
/** 归档 / 取消归档评估。 */
export async function archiveAssessment(
id: string,
archived: boolean,
user?: string,
): Promise<{ id: string; archived: boolean; status: WorkflowStatus }> {
return request('POST', `/api/assessments/${id}/archive`, {
archived,
...(user !== undefined ? { user } : {}),
});
}
/** 工作台统计。 */
export interface AssessmentSummary {
readonly total: number;
readonly byStatus: Record<string, number>;
readonly archived?: number;
}
/** 获取各状态评估数统计。 */
export async function fetchSummary(): Promise<AssessmentSummary> {
return request<AssessmentSummary>('GET', '/api/assessments/summary');
}
/** 评估详情响应。 */
export interface AssessmentDetailResponse {
readonly assessment: unknown;
readonly report: unknown;
readonly savedAt: string;
readonly status: WorkflowStatus;
readonly auditLog: AuditLogEntry[];
readonly profitability?: ProfitabilityResult | null;
readonly recommendation?: Recommendation | null;
readonly operatingControls?: OperatingControls | null;
readonly archived?: boolean;
/** 红线 id → 中文标题映射(用于将结果中的红线 id 显示为中文)。 */
readonly redlineTitles?: Record<string, string>;
/** 盈利测算原始输入(供编辑时完整带入)。 */
readonly profitabilityInputs?: ProfitabilityInputs | null;
/** 评估有效期(到期需重新评估)。 */
readonly expiresAt?: string | null;
}
/** 获取单条评估详情。 */
export async function fetchAssessmentDetail(id: string): Promise<AssessmentDetailResponse> {
return request<AssessmentDetailResponse>('GET', `/api/assessments/${id}`);
}
/** 下载评估报告(json/html 自包含文件)。 */
export async function downloadReport(id: string, format: 'json' | 'html'): Promise<void> {
const res = await fetch(`${API_BASE}/api/assessments/${id}/report/export?format=${format}`, {
headers: authHeader(),
});
if (!res.ok) {
const err = await res.json().catch(() => ({})) as { error?: string };
throw new ApiError(res.status, err.error ?? `HTTP ${res.status}`);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `risk-report-${id}.${format}`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
/** 风控审核。 */
export async function reviewAssessment(
id: string,
action: 'approve' | 'reject',
user: string,
comment?: string,
): Promise<{ status: WorkflowStatus }> {
return request('POST', `/api/assessments/${id}/review`, {
action,
user,
...(comment !== undefined && comment !== '' ? { comment } : {}),
});
}
/** 管理层审批。通过=最终通过,放弃=终态,驳回可退回风控复审或退回销售。 */
export async function approveAssessment(
id: string,
action: 'approve' | 'reject' | 'abandon',
user: string,
comment?: string,
rejectTo?: 'risk' | 'origin',
): Promise<{ status: WorkflowStatus }> {
return request('POST', `/api/assessments/${id}/approve`, {
action,
user,
...(comment !== undefined && comment !== '' ? { comment } : {}),
...(rejectTo !== undefined ? { rejectTo } : {}),
});
}
/** 重新提交评估。 */
export async function resubmitAssessment(id: string, user: string): Promise<{ status: WorkflowStatus }> {
return request('POST', `/api/assessments/${id}/resubmit`, { user });
}
/** 申报:将草稿评估报送风控审核。 */
export async function submitAssessment(id: string, user: string): Promise<{ status: WorkflowStatus }> {
return request('POST', `/api/assessments/${id}/submit`, { user });
}
/** 应用预测准确度校准(调整目标净利率基准,管理层)。 */
export async function applyCalibration(): Promise<{ appliedBase: number; previousBase: number; deviationPct: number }> {
return request('POST', '/api/calibration/apply', {});
}
/** 风控/管理层对「待核实」红线进行人工裁定(命中/未命中),闭环判定。 */
export async function submitRedlineVerdict(
id: string,
params: { redlineId: string; status: '命中' | '未命中'; note?: string; user: string; role?: string; title?: string },
): Promise<{ redlineId: string; status: string; acceptability: string }> {
return request('POST', `/api/assessments/${id}/redline-verdict`, params);
}
/** 管理层对「已通过」或「已放弃」项目直接调整工作流状态(留痕)。 */
export async function overrideAssessmentStatus(
id: string,
status: WorkflowStatus,
user: string,
comment?: string,
): Promise<{ status: WorkflowStatus }> {
return request('POST', `/api/assessments/${id}/override`, {
status,
user,
...(comment !== undefined && comment !== '' ? { comment } : {}),
});
}
/** 获取默认模板列表。 */
export async function fetchTemplates(): Promise<TemplateListItem[]> {
return request<TemplateListItem[]>('GET', '/api/templates');
}
/* ------------------------------------------------------------------ *
* P1-P3 新增功能 API
* ------------------------------------------------------------------ */
/** 费率条目。 */
export interface RateEntry {
id: number;
region: string;
category: string;
key: string;
value: number;
effectiveDate: string;
version: number;
reviewed: boolean;
}
export async function fetchAllRates(): Promise<RateEntry[]> {
return request<RateEntry[]>('GET', '/api/rates');
}
export async function createRate(entry: Omit<RateEntry, 'id'>): Promise<RateEntry> {
return request('POST', '/api/rates', entry);
}
export async function reviewRate(id: number): Promise<void> {
await request('POST', `/api/rates/${id}/review`, {});
}
export async function deleteRateEntry(id: number): Promise<void> {
await fetch(`${API_BASE}/api/rates/${id}`, { method: 'DELETE', headers: authHeader() });
}
/** 社保单位部分各险种费率。 */
export interface SocialInsuranceRates {
pension: number;
medical: number;
unemployment: number;
injury: number;
maternity: number;
}
/** 地域完整费率套(与引擎 RegionRates 对齐)。 */
export interface RegionRates {
regionName: string;
socialInsurance: SocialInsuranceRates;
housingFund: number;
vatGeneralRate: number;
vatSimplifiedRate: number;
surchargeRate: number;
}
/** 地域费率套记录(含复核状态)。 */
export interface RegionRateRecord {
region: string;
rates: RegionRates;
reviewed: boolean;
updatedBy: string | null;
updatedAt: string;
}
export async function fetchEngineDefaults(): Promise<{ national: RegionRates; regions: Record<string, RegionRates> }> {
return request('GET', '/api/region-rates/engine-defaults');
}
export async function fetchRegionRates(): Promise<RegionRateRecord[]> {
return request<RegionRateRecord[]>('GET', '/api/region-rates');
}
export async function saveRegionRate(region: string, rates: RegionRates, updatedBy?: string): Promise<void> {
await request('POST', '/api/region-rates', { region, rates, ...(updatedBy ? { updatedBy } : {}) });
}
export async function reviewRegionRate(region: string): Promise<void> {
await request('POST', `/api/region-rates/${encodeURIComponent(region)}/review`, {});
}
export async function deleteRegionRate(region: string): Promise<void> {
await fetch(`${API_BASE}/api/region-rates/${encodeURIComponent(region)}`, { method: 'DELETE', headers: authHeader() });
}
/** 红线规则。 */
export interface RedlineRuleItem {
id: string;
title: string;
triggerCondition: string;
consequence: string;
region: string | null;
businessType: string | null;
enabled: boolean;
version: number;
regulationRef: string | null;
/** 关联度量(可计算红线):4 个数值度量,或 `ind:<指标id>`(绑定指标风险等级);null 表示人工核实。 */
linkedMetric?: string | null;
/** 比较运算符。 */
compareOp?: '>=' | '<=' | '>' | '<' | null;
/** 阈值(百分比类按百分数;逾期按天;指标等级按 1-5)。 */
threshold?: number | null;
/** 可选第二条件(与主条件 AND 组合)。 */
linkedMetric2?: string | null;
compareOp2?: '>=' | '<=' | '>' | '<' | null;
threshold2?: number | null;
}
export async function fetchRedlineRules(): Promise<RedlineRuleItem[]> {
return request<RedlineRuleItem[]>('GET', '/api/redline-rules');
}
export async function createRedlineRule(rule: RedlineRuleItem): Promise<RedlineRuleItem> {
return request('POST', '/api/redline-rules', rule);
}
export async function deleteRedlineRuleApi(id: string): Promise<void> {
await fetch(`${API_BASE}/api/redline-rules/${id}`, { method: 'DELETE', headers: authHeader() });
}
/** 客户档案。 */
export interface CustomerItem {
id: string;
name: string;
creditRating: string;
avgOverdueDays: number;
totalContractAmount: number;
assessmentCount: number;
notes: string | null;
}
export async function fetchCustomers(): Promise<CustomerItem[]> {
return request<CustomerItem[]>('GET', '/api/customers');
}
export async function createCustomer(c: CustomerItem): Promise<CustomerItem> {
return request('POST', '/api/customers', c);
}
export async function deleteCustomerApi(id: string): Promise<void> {
await fetch(`${API_BASE}/api/customers/${id}`, { method: 'DELETE', headers: authHeader() });
}
export async function fetchConcentration(id: string): Promise<{ concentration: number; warning: string | null }> {
return request('GET', `/api/customers/${id}/concentration`);
}
/** 客户回款记录。 */
export interface CustomerPayment {
id: number;
customerId: string;
invoiceAmount: number;
dueDate: string;
paidDate: string | null;
note: string | null;
}
export async function fetchPayments(customerId: string): Promise<CustomerPayment[]> {
return request<CustomerPayment[]>('GET', `/api/customers/${customerId}/payments`);
}
export async function addPayment(
customerId: string,
p: { invoiceAmount: number; dueDate: string; paidDate?: string | null; note?: string | null },
): Promise<{ saved: boolean; avgOverdueDays: number }> {
return request('POST', `/api/customers/${customerId}/payments`, p);
}
export async function deletePaymentApi(customerId: string, paymentId: number): Promise<{ avgOverdueDays: number }> {
const res = await fetch(`${API_BASE}/api/customers/${customerId}/payments/${paymentId}`, { method: 'DELETE', headers: authHeader() });
return res.json();
}
/** 各地域最低工资标准。 */
export interface MinWageItem {
region: string;
monthlyWage: number;
updatedBy: string | null;
updatedAt: string;
}
export async function fetchMinWages(): Promise<MinWageItem[]> {
return request<MinWageItem[]>('GET', '/api/min-wages');
}
export async function saveMinWage(region: string, monthlyWage: number, updatedBy?: string): Promise<void> {
await request('POST', '/api/min-wages', { region, monthlyWage, ...(updatedBy ? { updatedBy } : {}) });
}
export async function deleteMinWageApi(region: string): Promise<void> {
await fetch(`${API_BASE}/api/min-wages/${encodeURIComponent(region)}`, { method: 'DELETE', headers: authHeader() });
}
/** 向导草稿(服务端持久化,跨设备)。列表项不含 form。 */
export interface DraftItem {
id: string;
assessorId: string | null;
sourceAssessmentId: string | null;
projectName: string | null;
updatedAt: string;
}
/** 草稿详情(含完整向导快照 form)。 */
export interface DraftRecord extends DraftItem {
form: unknown;
}
/** 列出草稿(可按评估人过滤)。 */
export async function listDrafts(assessorId?: string): Promise<DraftItem[]> {
const q = assessorId ? `?assessorId=${encodeURIComponent(assessorId)}` : '';
return request<DraftItem[]>('GET', `/api/drafts${q}`);
}
/** 取草稿详情(含 form)。 */
export async function getDraft(id: string): Promise<DraftRecord> {
return request<DraftRecord>('GET', `/api/drafts/${encodeURIComponent(id)}`);
}
/** 保存(新增/更新)草稿。 */
export async function saveDraft(input: {
id: string;
assessorId?: string | null;
sourceAssessmentId?: string | null;
projectName?: string | null;
form: unknown;
}): Promise<DraftRecord> {
return request<DraftRecord>('POST', '/api/drafts', input);
}
/** 删除草稿。 */
export async function deleteDraftApi(id: string): Promise<void> {
await fetch(`${API_BASE}/api/drafts/${encodeURIComponent(id)}`, { method: 'DELETE', headers: authHeader() });
}
/** 方案对比。 */
export interface ScenarioItem {
id: string;
label: string;
inputs: ProfitabilityInputs;
result: ProfitabilityResult;
}
export async function fetchScenarios(assessmentId: string): Promise<ScenarioItem[]> {
return request<ScenarioItem[]>('GET', `/api/assessments/${assessmentId}/scenarios`);
}
export async function createScenario(assessmentId: string, label: string, inputs: ProfitabilityInputs): Promise<ScenarioItem> {
return request('POST', `/api/assessments/${assessmentId}/scenarios`, { label, inputs });
}
/** 相似项目。 */
export interface SimilarProjectItem {
id: string;
projectDescription: string;
businessType: string;
industry: string;
riskScore: number | null;
riskGrade: string | null;
netMargin: number | null;
rank: number;
}
export async function fetchSimilarProjects(description: string): Promise<SimilarProjectItem[]> {
const q = encodeURIComponent(description);
return request<SimilarProjectItem[]>('GET', `/api/similar?description=${q}`);
}
/** 看板统计。 */
export interface DashboardStats {
byBusinessType: Array<{ dimension: string; count: number; avgRiskScore: number | null; passRate: number | null }>;
byIndustry: Array<{ dimension: string; count: number; avgRiskScore: number | null; passRate: number | null }>;
byRegion: Array<{ dimension: string; count: number; avgRiskScore: number | null; passRate: number | null }>;
riskDist: Array<{ grade: string; count: number }>;
trend: Array<{ month: string; count: number }>;
}
export async function fetchDashboardStats(): Promise<DashboardStats> {
return request<DashboardStats>('GET', '/api/dashboard/stats');
}
/** 经验库。 */
export interface ExperienceItem {
id: number;
assessmentId: string;
businessType: string;
industry: string;
projectSummary: string;
lesson: string;
tags: string[];
}
export async function fetchExperience(businessType?: string, industry?: string): Promise<ExperienceItem[]> {
const sp = new URLSearchParams();
if (businessType) sp.set('businessType', businessType);
if (industry) sp.set('industry', industry);
const qs = sp.toString();
return request<ExperienceItem[]>('GET', `/api/experience${qs ? '?' + qs : ''}`);
}
/** 销售修改项目资料(仅驳回状态)。 */
export async function updateAssessment(
id: string,
data: { projectDescription?: string; region?: string; profitabilityInputs?: ProfitabilityInputs; user?: string },
): Promise<{ updated: boolean; profitability: ProfitabilityResult | null; recommendation: Recommendation | null }> {
const res = await fetch(`${API_BASE}/api/assessments/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...authHeader() },
body: JSON.stringify(data),
});
if (!res.ok) {
const err = await res.json().catch(() => ({})) as { error?: string };
throw new ApiError(res.status, err.error ?? `HTTP ${res.status}`);
}
return res.json();
}
+239
View File
@@ -0,0 +1,239 @@
/**
* AppShell — 应用外壳:顶部栏(标题 / 导航 / 用户信息 / 主题切换)+ 角色化内容区。
*
* - 登录后按角色显示导航:商务/销售可见「新建评估」,风控可见「待办审核」,管理层可见「待办审批」。
* - 顶部栏展示当前登录用户的用户名与角色标签,并提供退出登录。
* - 主题切换复用 Design_System 的 ThemeProviderReq 19.6/19.7)。
*/
import { useEffect, useState } from 'react';
import type { CSSProperties } from 'react';
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
import { useTheme } from '../design-system/index.js';
import { Button } from '../design-system/index.js';
import {
colorVar,
FONT_FAMILY,
space,
typographyStyle,
} from '../design-system/components/styles.js';
import { selectLayout } from '../viewport/index.js';
import type { Layout } from '../viewport/index.js';
import { useAuthStore } from '../stores/authStore.js';
import { GlossaryButton } from './Guidance.js';
import type { AuthRole } from '../stores/authStore.js';
/** 读取当前视口宽度(SSR/测试安全)。 */
function currentWidth(): number {
return typeof window === 'undefined' ? 1280 : window.innerWidth;
}
/** 监听视口宽度,返回确定性布局(Req 22)。 */
function useLayout(): Layout {
const [width, setWidth] = useState<number>(currentWidth);
useEffect(() => {
const onResize = (): void => setWidth(window.innerWidth);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
return selectLayout(width);
}
/** 角色配色映射(柔和底色 + 同色系文字,替代高饱和实心色块)。 */
const ROLE_STYLE: Record<AuthRole, { bg: string; fg: string }> = {
'商务/销售': { bg: 'rgba(16, 128, 61, 0.12)', fg: '#15803D' },
'风控': { bg: 'rgba(180, 83, 9, 0.12)', fg: '#B45309' },
'管理层': { bg: 'rgba(79, 70, 229, 0.12)', fg: '#4F46E5' },
};
export function AppShell(): JSX.Element {
const { theme, setTheme } = useTheme();
const layout = useLayout();
const isMobile = layout === 'MobileLayout';
const navigate = useNavigate();
const location = useLocation();
const { user, logout } = useAuthStore();
const role = user?.role ?? '商务/销售';
const pageStyle: CSSProperties = {
minHeight: '100vh',
backgroundColor: colorVar('color.bg.canvas'),
color: colorVar('color.text.primary'),
fontFamily: FONT_FAMILY,
};
const headerStyle: CSSProperties = {
display: 'flex',
flexDirection: isMobile ? 'column' : 'row',
alignItems: isMobile ? 'stretch' : 'center',
justifyContent: 'space-between',
gap: `${space(3)}px`,
padding: `${space(3)}px ${space(5)}px`,
backgroundColor: colorVar('color.bg.elevated'),
borderBottom: `1px solid ${colorVar('color.border.default')}`,
position: 'sticky',
top: 0,
zIndex: 10,
};
const mainStyle: CSSProperties = {
maxWidth: 1280,
margin: '0 auto',
padding: `${space(7)}px ${space(5)}px`,
};
const navLinkStyle = (path: string): CSSProperties => {
const active = location.pathname === path;
return {
color: active ? colorVar('color.brand.primary') : colorVar('color.text.secondary'),
backgroundColor: active ? colorVar('color.bg.surface') : 'transparent',
textDecoration: 'none',
fontWeight: active ? 600 : 500,
cursor: 'pointer',
padding: `${space(2)}px ${space(3)}px`,
borderRadius: `${space(2)}px`,
...typographyStyle('body'),
};
};
return (
<div style={pageStyle}>
<header style={headerStyle}>
<div style={{ display: 'flex', alignItems: 'center', gap: `${space(3)}px` }}>
<div
aria-hidden="true"
style={{
width: 36,
height: 36,
borderRadius: `${space(2)}px`,
background: `linear-gradient(135deg, ${colorVar('color.brand.primary')}, #7C83F0)`,
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 700,
fontSize: '16px',
flexShrink: 0,
boxShadow: '0 1px 3px rgba(16,24,40,0.16)',
}}
>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
<span style={{ ...typographyStyle('title'), fontWeight: 700, letterSpacing: '-0.02em' }}>
</span>
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>
</span>
</div>
</div>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: `${space(2)}px`,
}}
role="group"
aria-label="导航与用户信息"
>
<span data-nav-link style={navLinkStyle('/')} onClick={() => navigate('/')}>
</span>
{role === '商务/销售' && (
<span data-nav-link style={navLinkStyle('/new')} onClick={() => navigate('/new')}>
</span>
)}
{role === '风控' && (
<span data-nav-link style={navLinkStyle('/')} onClick={() => navigate('/')}>
</span>
)}
{role === '管理层' && (
<span data-nav-link style={navLinkStyle('/')} onClick={() => navigate('/')}>
</span>
)}
{role === '管理层' && (
<>
<span data-nav-link style={navLinkStyle('/rates')} onClick={() => navigate('/rates')}>
</span>
<span data-nav-link style={navLinkStyle('/redlines')} onClick={() => navigate('/redlines')}>
线
</span>
</>
)}
{(role === '管理层' || role === '商务/销售') && (
<span data-nav-link style={navLinkStyle('/customers')} onClick={() => navigate('/customers')}>
</span>
)}
<GlossaryButton />
<div
style={{
display: 'flex',
alignItems: 'center',
gap: `${space(2)}px`,
padding: `${space(1)}px ${space(2)}px ${space(1)}px ${space(3)}px`,
backgroundColor: colorVar('color.bg.surface'),
border: `1px solid ${colorVar('color.border.default')}`,
borderRadius: `${space(5)}px`,
}}
>
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.primary'), fontWeight: 600 }}>
{user?.username}
</span>
<span
style={{
...typographyStyle('caption'),
padding: '2px 8px',
borderRadius: '999px',
backgroundColor: ROLE_STYLE[role].bg,
color: ROLE_STYLE[role].fg,
fontWeight: 600,
}}
>
{role}
</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setTheme(theme === 'Light' ? 'Dark' : 'Light')}
ariaLabel="切换明暗主题"
>
{theme === 'Light' ? '切换暗色' : '切换亮色'}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => {
logout();
navigate('/login');
}}
>
退
</Button>
</div>
</header>
<main style={mainStyle}>
<Outlet context={{ role }} />
</main>
</div>
);
}
+155
View File
@@ -0,0 +1,155 @@
/**
* 引导与提示组件:降低系统学习成本。
* - GuideBanner:可关闭的引导横幅(按 id 记忆于 localStorage
* - InfoTip:术语「?」悬浮解释
* - GlossaryButton:顶栏帮助/术语表入口
* - nextActionHint:按 角色×状态 给出"下一步该做什么"
*/
import { useRef, useState } from 'react';
import { colorVar, FONT_FAMILY, RADIUS, SHADOW, space, typographyStyle } from '../design-system/components/styles.js';
import { Icon } from '../design-system/index.js';
/* ------------------------------------------------------------------ *
* 术语表
* ------------------------------------------------------------------ */
export const GLOSSARY: ReadonlyArray<{ term: string; desc: string }> = [
{ term: '三性岗位', desc: '临时性、辅助性、替代性岗位。劳务派遣只能用于三性岗位,超范围用工有合规风险。' },
{ term: '账期', desc: '客户从收到发票到实际付款的周期(月)。账期越长,企业垫资压力与坏账风险越大。' },
{ term: '单客户集中度', desc: '单一客户合同额占全部客户合同额的比例。过高(如>50%)则收入过度依赖单一客户。' },
{ term: '成本加成率', desc: '在全口径成本之上加价的比例。报价 = 成本 ×(1+加成率),用于成本加成(T&M)报价模式。' },
{ term: 'SLA', desc: '服务等级协议,约定可量化的交付/质量标准(如月度达标率99.9%)。SLA越模糊,验收扯皮风险越高。' },
{ term: '派遣用工比例', desc: '派遣人数占客户用工总量的比例,法定上限10%。逼近或超限有合规风险。' },
{ term: '坏账准备金', desc: '按客户信用等级预提的潜在坏账损失(占收入比例)。信用越差计提越高,直接影响净利。' },
{ term: '数据完整度', desc: '评估中由人工/外部数据确认(非系统估算)的指标占比。越高代表结论越可靠。' },
{ term: '目标净利率分层', desc: '按风险等级要求不同的目标净利率:风险越高要求的利润补偿越高(高风险需更高净利)。' },
{ term: '全口径成本', desc: '工资+社保+公积金+福利+雇主险+招聘培训摊销+管理分摊的人月总成本,而非仅工资。' },
{ term: '红线', desc: '一票否决项。命中任一红线即"不可接受",优先级高于风险评分。' },
{ term: '可接受性', desc: '纯风险结论:可接受 / 有条件接受 / 不可接受。叠加盈利后得出最终"承接建议"。' },
];
/* ------------------------------------------------------------------ *
* 可关闭引导横幅
* ------------------------------------------------------------------ */
function dismissed(id: string): boolean {
try { return localStorage.getItem('guide-dismissed-' + id) === '1'; } catch { return false; }
}
export function GuideBanner({ id, tone = 'brand', children }: {
readonly id: string;
readonly tone?: 'brand' | 'info' | 'warn';
readonly children: React.ReactNode;
}): JSX.Element | null {
const [closed, setClosed] = useState(() => dismissed(id));
if (closed) return null;
const palette = tone === 'warn'
? { bg: 'rgba(180,83,9,0.08)', border: 'rgba(180,83,9,0.25)', fg: '#B45309' }
: tone === 'info'
? { bg: 'rgba(37,99,235,0.08)', border: 'rgba(37,99,235,0.22)', fg: '#2563EB' }
: { bg: 'rgba(79,70,229,0.07)', border: 'rgba(79,70,229,0.2)', fg: colorVar('color.brand.primary') };
return (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: `${space(2)}px`, padding: `${space(2)}px ${space(3)}px`, marginBottom: `${space(3)}px`, backgroundColor: palette.bg, border: `1px solid ${palette.border}`, borderRadius: `${RADIUS.md}px`, fontFamily: FONT_FAMILY }}>
<span style={{ display: 'inline-flex', color: palette.fg, marginTop: 1 }}><Icon name="lightbulb" size={16} /></span>
<div style={{ flex: 1, ...typographyStyle('caption'), color: colorVar('color.text.primary'), lineHeight: 1.7 }}>{children}</div>
<button
type="button"
onClick={() => { try { localStorage.setItem('guide-dismissed-' + id, '1'); } catch { /* ignore */ } setClosed(true); }}
title="不再显示"
style={{ display: 'inline-flex', border: 'none', background: 'transparent', color: colorVar('color.text.secondary'), cursor: 'pointer' }}
aria-label="不再显示"
><Icon name="close" size={15} /></button>
</div>
);
}
/* ------------------------------------------------------------------ *
* 术语「?」悬浮提示
* ------------------------------------------------------------------ */
export function InfoTip({ text }: { readonly text: string }): JSX.Element {
const [pos, setPos] = useState<{ left: number; top: number } | null>(null);
const ref = useRef<HTMLSpanElement>(null);
const WIDTH = 240;
function open(): void {
const el = ref.current;
if (el === null) return;
const r = el.getBoundingClientRect();
const vw = window.innerWidth;
// 居中于图标,但夹取到视口内(左右各留 8px 边距)
const left = Math.min(Math.max(8, r.left + r.width / 2 - WIDTH / 2), vw - WIDTH - 8);
setPos({ left, top: r.top });
}
return (
<span ref={ref} style={{ display: 'inline-flex' }}>
<span
onMouseEnter={open}
onMouseLeave={() => setPos(null)}
onClick={(e) => { e.stopPropagation(); if (pos === null) open(); else setPos(null); }}
role="button"
tabIndex={0}
aria-label={text}
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 14, height: 14, marginLeft: 4, borderRadius: '50%', border: `1px solid ${colorVar('color.text.secondary')}`, color: colorVar('color.text.secondary'), fontSize: 10, lineHeight: '12px', cursor: 'help', userSelect: 'none' }}
>?</span>
{pos !== null && (
<span style={{ position: 'fixed', left: pos.left, top: pos.top, transform: 'translateY(-100%) translateY(-8px)', width: WIDTH, padding: `${space(2)}px`, backgroundColor: colorVar('color.bg.elevated'), border: `1px solid ${colorVar('color.border.default')}`, borderRadius: `${RADIUS.md}px`, boxShadow: SHADOW.lg, ...typographyStyle('caption'), color: colorVar('color.text.primary'), zIndex: 3000, lineHeight: 1.6, fontWeight: 400, pointerEvents: 'none' }}>
{text}
</span>
)}
</span>
);
}
/* ------------------------------------------------------------------ *
* 顶栏「帮助/术语」入口
* ------------------------------------------------------------------ */
export function GlossaryButton(): JSX.Element {
const [open, setOpen] = useState(false);
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
title="帮助与术语表"
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, borderRadius: '50%', border: `1px solid ${colorVar('color.border.default')}`, background: 'transparent', color: colorVar('color.text.secondary'), cursor: 'pointer', fontWeight: 700 }}
>?</button>
{open && (
<div style={{ position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 2000 }} onClick={() => setOpen(false)}>
<div style={{ backgroundColor: colorVar('color.bg.elevated'), borderRadius: `${RADIUS.lg}px`, padding: `${space(5)}px`, maxWidth: 620, width: '92%', maxHeight: '85vh', overflowY: 'auto', boxShadow: SHADOW.lg, fontFamily: FONT_FAMILY }} onClick={(e) => e.stopPropagation()}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: `${space(3)}px` }}>
<h2 style={{ ...typographyStyle('heading'), margin: 0, color: colorVar('color.text.primary') }}></h2>
<button type="button" onClick={() => setOpen(false)} style={{ border: 'none', background: 'transparent', fontSize: 20, cursor: 'pointer', color: colorVar('color.text.secondary') }}>×</button>
</div>
<div style={{ marginBottom: `${space(4)}px`, padding: `${space(3)}px`, backgroundColor: colorVar('color.bg.surface'), borderRadius: `${RADIUS.md}px`, ...typographyStyle('caption'), color: colorVar('color.text.secondary'), lineHeight: 1.8 }}>
<strong style={{ color: colorVar('color.text.primary') }}></strong> / //线/AI //
</div>
{GLOSSARY.map((g) => (
<div key={g.term} style={{ padding: `${space(2)}px 0`, borderBottom: `1px solid ${colorVar('color.border.default')}` }}>
<div style={{ ...typographyStyle('body'), fontWeight: 700, color: colorVar('color.text.primary') }}>{g.term}</div>
<div style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), lineHeight: 1.7 }}>{g.desc}</div>
</div>
))}
</div>
</div>
)}
</>
);
}
/* ------------------------------------------------------------------ *
* 详情页"下一步该做什么"
* ------------------------------------------------------------------ */
export function nextActionHint(role: string, status: string): { tone: 'brand' | 'info' | 'warn'; text: string } | null {
if (role === '商务/销售') {
if (status === 'draft') return { tone: 'brand', text: '资料确认无误后,点下方「申报(报送风控)」提交审核;如需修改可点「编辑评估」。' };
if (status === 'rejected') return { tone: 'warn', text: '该评估已被驳回。可点「修改并重新评估」调整资料后重报,或「原资料重新提交」。' };
if (status === 'pending_risk_review') return { tone: 'info', text: '已报送风控,等待审核中。如需调整可在审核前「编辑评估」。' };
}
if (role === '风控') {
if (status === 'pending_risk_review') return { tone: 'brand', text: '请审阅风险评分与红线校验,给出「审核通过」或「驳回(退回销售)」;红线"需人工核实"项请先在下方裁定。' };
}
if (role === '管理层') {
if (status === 'risk_reviewed' || status === 'pending_risk_review') return { tone: 'brand', text: '请做最终决策:「审批通过」/「驳回(退回风控或销售)」/「放弃」。' };
if (status === 'approved' || status === 'abandoned') return { tone: 'info', text: '该项目已为终态。可「导出报告」存档、「归档」收起,或在需要时直接调整状态。' };
}
return null;
}
+305
View File
@@ -0,0 +1,305 @@
/**
* 角色化视图:商务/销售、风控、管理层(Req 13.1–13.3),复用全套图表与基础组件。
*
* - SalesView:可接受性结论 + 接受条件清单 + 风险调整后报价(含费用拆解/报价对比图)。
* - RiskView:评分项明细(Risk_Level/依据/Provenance/Confidence+ 红线校验 + 缺口尽调。
* - ManagementViewRisk_Grade + 风险热力图 + Top N 关键风险 + 跨项目组合对比。
*/
import type { CSSProperties, ReactNode } from 'react';
import { Card, PaginatedTable } from '../design-system/index.js';
import type { TableColumn } from '../design-system/index.js';
import {
colorVar,
FONT_FAMILY,
space,
typographyStyle,
} from '../design-system/components/styles.js';
import {
RiskBadge,
ScoreGauge,
RiskHeatmap,
TopNRiskChart,
CostBreakdownChart,
QuoteCompareChart,
PortfolioCompareChart,
} from '../charts/index.js';
import { GapPanel, listGaps } from '../wizard/index.js';
import type {
AssessmentView,
ScoringItemView,
RedlineResultView,
AcceptanceConditionView,
} from './sampleData.js';
import { PORTFOLIO_ROWS } from './sampleData.js';
/* ------------------------------------------------------------------ *
* 布局原语
* ------------------------------------------------------------------ */
/** 响应式网格容器:自动按可用宽度排布卡片。 */
function Grid({ children, min = 320 }: { readonly children: ReactNode; readonly min?: number }): JSX.Element {
const style: CSSProperties = {
display: 'grid',
gridTemplateColumns: `repeat(auto-fit, minmax(${min}px, 1fr))`,
gap: `${space(4)}px`,
alignItems: 'start',
};
return <div style={style}>{children}</div>;
}
/** 纵向堆叠容器。 */
function Stack({ children, gap = 4 }: { readonly children: ReactNode; readonly gap?: number }): JSX.Element {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: `${space(gap)}px`, fontFamily: FONT_FAMILY }}>
{children}
</div>
);
}
/** 简单的「标签 + 值」行。 */
function MetaRow({ label, value }: { readonly label: string; readonly value: ReactNode }): JSX.Element {
return (
<div style={{ display: 'flex', justifyContent: 'space-between', gap: `${space(3)}px`, ...typographyStyle('body') }}>
<span style={{ color: colorVar('color.text.secondary') }}>{label}</span>
<span style={{ color: colorVar('color.text.primary'), fontWeight: 600 }}>{value}</span>
</div>
);
}
/** 金额格式化。 */
function money(value: number): string {
return `${value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} 元`;
}
/** 项目概要卡片(各角色视图通用顶部)。 */
function ProjectSummary({ assessment }: { readonly assessment: AssessmentView }): JSX.Element {
return (
<Card title="项目概要">
<Stack gap={2}>
<MetaRow label="项目名称" value={assessment.projectName} />
<MetaRow label="业务类型" value={assessment.businessType} />
<MetaRow label="所属行业" value={assessment.industry} />
<MetaRow label="适用地域" value={assessment.region} />
<MetaRow
label="风险分级"
value={<RiskBadge grade={assessment.riskGrade} prefix="风险等级" />}
/>
</Stack>
</Card>
);
}
/** 可接受性结论的语义化提示色。 */
function acceptabilityColor(acceptability: AssessmentView['acceptability']): string {
switch (acceptability) {
case '可接受':
return colorVar('color.risk.low');
case '有条件接受':
return colorVar('color.risk.high');
case '不可接受':
return colorVar('color.risk.critical');
}
}
/** 可接受性结论的说明文案(依据实际结论动态生成)。 */
function acceptabilityNote(assessment: AssessmentView): string {
const hitRedline = assessment.redlines.some((r) => r.status === '命中');
switch (assessment.acceptability) {
case '可接受':
return '本项目风险总体可控,未触发红线,可正常承接。';
case '有条件接受':
return '本项目为有条件接受,需完成下方接受条件清单的整改后方可承接。';
case '不可接受':
return hitRedline
? '本项目命中红线(一票否决),建议放弃承接。'
: '本项目风险等级过高,可接受性结论为不可接受,建议放弃承接。';
}
}
/* ------------------------------------------------------------------ *
* 商务/销售视图(Req 13.1
* ------------------------------------------------------------------ */
export function SalesView({ assessment }: { readonly assessment: AssessmentView }): JSX.Element {
const conditionColumns: ReadonlyArray<TableColumn<AcceptanceConditionView>> = [
{ key: 'text', header: '接受条件', field: 'text' },
{ key: 'risk', header: '关联关键风险', field: 'relatedRisk' },
{ key: 'cost', header: '成本影响测算', field: 'costImpact' },
];
return (
<Stack>
<Grid>
<ProjectSummary assessment={assessment} />
<Card title="可接受性结论">
<Stack gap={2}>
<span
style={{
...typographyStyle('heading'),
fontWeight: 700,
color: acceptabilityColor(assessment.acceptability),
}}
>
{assessment.acceptability}
</span>
<p style={{ ...typographyStyle('body'), margin: 0, color: colorVar('color.text.secondary') }}>
{acceptabilityNote(assessment)}
</p>
</Stack>
</Card>
<Card title="报价概览">
<Stack gap={2}>
<MetaRow label="基准报价" value={money(assessment.quote.baselineQuote)} />
<MetaRow label="风险调整后报价" value={money(assessment.quote.riskAdjustedQuote)} />
<MetaRow
label="风险溢价"
value={money(assessment.quote.riskAdjustedQuote - assessment.quote.baselineQuote)}
/>
</Stack>
</Card>
</Grid>
<Card title="接受条件清单">
<PaginatedTable
columns={conditionColumns}
data={assessment.acceptanceConditions}
getRowKey={(row) => row.id}
caption="每个条件关联至少一个关键风险并附成本影响测算"
emptyMessage="暂无接受条件"
pageSize={5}
pageSizeOptions={[5, 10, 20]}
/>
</Card>
<Grid min={420}>
<Card title="基准 vs 风险调整后报价">
<QuoteCompareChart quote={assessment.quote} />
</Card>
<Card title="费用拆解">
<CostBreakdownChart items={assessment.costBreakdown} />
</Card>
</Grid>
</Stack>
);
}
/* ------------------------------------------------------------------ *
* 风控视图(Req 13.2
* ------------------------------------------------------------------ */
function redlineColor(status: RedlineResultView['status']): string {
switch (status) {
case '命中':
return colorVar('color.risk.critical');
case '待核实':
return colorVar('color.risk.medium');
case '未命中':
return colorVar('color.risk.low');
}
}
export function RiskView({ assessment }: { readonly assessment: AssessmentView }): JSX.Element {
const scoringColumns: ReadonlyArray<TableColumn<ScoringItemView>> = [
{ key: 'dim', header: '维度', field: 'dimensionId' },
{ key: 'ind', header: '指标', field: 'indicatorId' },
{ key: 'level', header: 'Risk_Level', align: 'center', render: (r) => `${r.riskLevel}` },
{ key: 'score', header: '得分', align: 'right', render: (r) => `${r.score}` },
{ key: 'prov', header: '数据来源', field: 'provenance' },
{ key: 'conf', header: '置信度', align: 'right', render: (r) => r.confidence.toFixed(2) },
{ key: 'rationale', header: '判定依据', field: 'rationale' },
];
const redlineColumns: ReadonlyArray<TableColumn<RedlineResultView>> = [
{ key: 'title', header: '红线规则', field: 'title' },
{
key: 'status',
header: '状态',
render: (r) => (
<span style={{ fontWeight: 700, color: redlineColor(r.status) }}>{r.status}</span>
),
},
{ key: 'detail', header: '判定依据', field: 'detail' },
];
const gaps = listGaps({ sources: assessment.gapSources });
return (
<Stack>
<Grid>
<ProjectSummary assessment={assessment} />
<Card title="风险总分">
<ScoreGauge score={assessment.riskScore} />
</Card>
</Grid>
<Card title="评分项明细">
<PaginatedTable
columns={scoringColumns}
data={assessment.scoringItems}
getRowKey={(row) => `${row.dimensionId}-${row.indicatorId}`}
caption="每个评分项含 Risk_Level、得分、数据来源、置信度与判定依据"
emptyMessage="暂无评分项明细"
pageSize={10}
pageSizeOptions={[10, 20, 50]}
/>
</Card>
<Grid min={420}>
<Card title="红线校验结果">
<PaginatedTable
columns={redlineColumns}
data={assessment.redlines}
getRowKey={(row) => row.redlineId}
emptyMessage="暂无红线校验结果"
pageSize={5}
pageSizeOptions={[5, 10, 20]}
/>
</Card>
<Card title="信息缺口尽调事项">
<GapPanel gaps={gaps} />
</Card>
</Grid>
</Stack>
);
}
/* ------------------------------------------------------------------ *
* 管理层看板(Req 13.3 / 13.4
* ------------------------------------------------------------------ */
export function ManagementView({ assessment }: { readonly assessment: AssessmentView }): JSX.Element {
return (
<Stack>
<Grid>
<Card title="风险总分与分级">
<ScoreGauge score={assessment.riskScore} />
</Card>
<ProjectSummary assessment={assessment} />
<Card title="利润 vs 风险">
<Stack gap={2}>
<MetaRow label="风险调整后报价" value={money(assessment.quote.riskAdjustedQuote)} />
<MetaRow
label="风险溢价"
value={money(assessment.quote.riskAdjustedQuote - assessment.quote.baselineQuote)}
/>
<MetaRow label="可接受性" value={assessment.acceptability} />
</Stack>
</Card>
</Grid>
<Card title="风险热力图">
<RiskHeatmap cells={assessment.heatmap} />
</Card>
<Grid min={460}>
<Card title="Top N 关键风险">
<TopNRiskChart items={assessment.topRisks} />
</Card>
<Card title="跨项目组合对比">
<PortfolioCompareChart rows={PORTFOLIO_ROWS} />
</Card>
</Grid>
</Stack>
);
}
+106
View File
@@ -0,0 +1,106 @@
/**
* 维度/指标/红线 机器标识 → 中文名 映射与翻译工具。
*
* 领域引擎在评分项、热力图、关键风险、接受条件等处使用机器 id(如 dim-position /
* position-nature)。前端展示层据此映射为中文名,并提供 translateIds 将自由文本中
* 嵌入的 id 一并替换为中文。
*/
/** 维度 id → 中文名。 */
export const DIMENSION_LABELS: Record<string, string> = {
'dim-customer': '客户风险',
'dim-position': '岗位风险',
'dim-business': '业务与交付风险',
'dim-labor': '用工与人力风险',
'dim-legal': '法律与合规风险',
'dim-financial': '财务与商务风险',
'dim-external': '外部与发展风险',
'dim-lifecycle': '业务发展阶段风险',
'dim-macro': '宏观与外部风险',
};
/** 指标 id → 中文名。 */
export const INDICATOR_LABELS: Record<string, string> = {
'customer-credit': '客户资信',
'payment-ability': '付款能力与账期',
'position-nature': '岗位性质(三性)',
'delivery-standard': '交付标准与SLA',
'scope-clarity': '需求范围与变更控制',
'capacity-stability': '产能与利用率稳定性',
'data-security': '数据安全与合规',
'dispatch-ratio': '派遣用工比例',
'layoff-risk': '裁员/退场风险',
'injury-risk': '工伤风险',
'qualification': '资质与合规',
'gross-margin': '毛利率与成本结构',
'lifecycle-stage': '业务生命周期阶段',
'macro-policy': '政策与外部环境',
};
/** 红线 id → 中文名。 */
export const REDLINE_LABELS: Record<string, string> = {
'redline-customer-blacklist': '客户严重失信 / 被执行',
'redline-negative-margin': '毛利为负且无溢价空间',
};
/** 维度 id → 中文名(找不到则原样返回)。 */
export function dimLabel(id: string): string {
return DIMENSION_LABELS[id] ?? id;
}
/** 指标 id → 中文名(找不到则原样返回)。 */
export function indLabel(id: string): string {
return INDICATOR_LABELS[id] ?? id;
}
/** 红线 id → 中文名(找不到则原样返回)。 */
export function redlineLabel(id: string): string {
return REDLINE_LABELS[id] ?? id;
}
/** 「维度id / 指标id」→「维度名 / 指标名」。 */
export function riskPairLabel(dimensionId: string, indicatorId: string): string {
return `${dimLabel(dimensionId)} / ${indLabel(indicatorId)}`;
}
/** 所有可替换 id(按长度降序,避免短 id 先替换造成残留)。 */
const ALL_LABELS: ReadonlyArray<readonly [string, string]> = [
...Object.entries(DIMENSION_LABELS),
...Object.entries(INDICATOR_LABELS),
...Object.entries(REDLINE_LABELS),
// 技术术语 → 中文
['Risk_Grade', '风险分级'] as [string, string],
['Risk_Score', '风险分'] as [string, string],
['Risk_Level', '风险等级'] as [string, string],
].sort((a, b) => b[0].length - a[0].length);
/** 转义正则特殊字符。 */
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
const ID_PATTERN = new RegExp(ALL_LABELS.map(([id]) => escapeRegExp(id)).join('|'), 'g');
const LABEL_BY_ID = new Map(ALL_LABELS);
/** 业务可读化的短语替换(口语化生硬的内部措辞)。 */
const PHRASE_REPLACEMENTS: ReadonlyArray<readonly [string, string]> = [
['行业默认值(追问轮次耗尽兜底)', '未提供资料,按行业默认估算'],
['(追问轮次耗尽兜底)', '(未提供资料,按行业默认估算)'],
['(未提供具体取值,依据现有已知信息综合判定)', '(依据描述综合判断,未提供具体数值)'],
['对应数据点取值:', '取值依据:'],
];
/**
* 将文本中出现的所有已知 id / 技术术语替换为中文,并口语化生硬措辞。
* 用于翻译嵌入了 id/术语的自由文本(如评分依据、接受条件、应对措施)。
*/
export function translateIds(text: string): string {
if (text === '') {
return text;
}
let out = text.replace(ID_PATTERN, (m) => LABEL_BY_ID.get(m) ?? m);
for (const [from, to] of PHRASE_REPLACEMENTS) {
out = out.split(from).join(to);
}
return out;
}
+250
View File
@@ -0,0 +1,250 @@
/**
* 演示用样例评估数据(前端表现层)。
*
* 这些数据用于驱动角色化视图与全套图表组件的真实渲染。数值与结构镜像领域引擎
* Scoring_Engine / Cost_Engine / Strategy_Engine / 持久化层)的输出契约,但不跨
* rootDir 引用领域层,保持 web bounded context 自包含。
*/
import type { RiskGrade } from '../design-system/index.js';
import type {
HeatmapCellInput,
RiskItemInput,
CostBreakdownItemInput,
QuoteCompareInput,
} from '../charts/index.js';
import type { PortfolioCompareRow } from '../charts/index.js';
import type { GapSource } from '../wizard/index.js';
/** 数据来源标注(镜像领域层 Data_Provenance 三态)。 */
export type Provenance = '用户输入' | '外部数据' | '智能体假设';
/** 可接受性结论。 */
export type Acceptability = '可接受' | '有条件接受' | '不可接受';
/** 红线校验状态。 */
export type RedlineStatus = '命中' | '未命中' | '待核实';
/** 单个评分项明细(风控视图)。 */
export interface ScoringItemView {
readonly dimensionId: string;
readonly indicatorId: string;
readonly riskLevel: 1 | 2 | 3 | 4 | 5;
readonly score: number;
readonly provenance: Provenance;
readonly confidence: number;
readonly rationale: string;
readonly riskImpact: string;
readonly recommendation: string;
}
/** 红线校验结果。 */
export interface RedlineResultView {
readonly redlineId: string;
readonly title: string;
readonly status: RedlineStatus;
readonly detail: string;
/** 待核实细分原因:'数据缺失' | '规则未配置' | '数据待确认';非待核实时为空。 */
readonly reviewKind?: string;
}
/** 接受条件(商务视图)。 */
export interface AcceptanceConditionView {
readonly id: string;
readonly text: string;
readonly relatedRisk: string;
readonly costImpact: string;
}
/** 一次完整评估的展示模型。 */
export interface AssessmentView {
readonly id: string;
readonly projectName: string;
readonly businessType: string;
readonly industry: string;
readonly region: string;
readonly riskScore: number;
readonly riskGrade: RiskGrade;
readonly acceptability: Acceptability;
readonly heatmap: readonly HeatmapCellInput[];
readonly topRisks: readonly RiskItemInput[];
readonly scoringItems: readonly ScoringItemView[];
readonly redlines: readonly RedlineResultView[];
readonly gapSources: readonly GapSource[];
readonly costBreakdown: readonly CostBreakdownItemInput[];
readonly quote: QuoteCompareInput;
readonly acceptanceConditions: readonly AcceptanceConditionView[];
readonly managementMeasures: readonly string[];
readonly costMeasures: readonly string[];
}
/** 当前评估(高风险、有条件接受的代表性算例)。 */
export const CURRENT_ASSESSMENT: AssessmentView = {
id: 'asmt-2024-0312',
projectName: '某制造企业产线劳务派遣项目',
businessType: '劳务派遣',
industry: '制造业',
region: '中国大陆(CN',
riskScore: 68,
riskGrade: '高',
acceptability: '有条件接受',
heatmap: [
{ dimensionId: '客户风险', indicatorId: '资信状况', riskLevel: 3 },
{ dimensionId: '客户风险', indicatorId: '涉诉记录', riskLevel: 2 },
{ dimensionId: '客户风险', indicatorId: '回款账期', riskLevel: 4 },
{ dimensionId: '用工与人力风险', indicatorId: '派遣比例', riskLevel: 5 },
{ dimensionId: '用工与人力风险', indicatorId: '社保合规', riskLevel: 4 },
{ dimensionId: '用工与人力风险', indicatorId: '最低工资', riskLevel: 2 },
{ dimensionId: '合规风险', indicatorId: '经济补偿', riskLevel: 3 },
{ dimensionId: '财务风险', indicatorId: '垫资规模', riskLevel: 4 },
],
topRisks: [
{ dimensionId: '用工与人力风险', indicatorId: '派遣比例', score: 90, rationale: '派遣用工比例约 18%,超过法定上限 10%' },
{ dimensionId: '用工与人力风险', indicatorId: '社保合规', score: 78, rationale: '部分员工社保缴费基数低于当地法定下限' },
{ dimensionId: '客户风险', indicatorId: '回款账期', score: 72, rationale: '约定账期 90 天,垫资压力较大' },
{ dimensionId: '财务风险', indicatorId: '垫资规模', score: 70, rationale: '月度垫资规模约 320 万元' },
{ dimensionId: '客户风险', indicatorId: '资信状况', score: 55, rationale: '客户为区域龙头,资信总体稳健但负债率偏高' },
],
scoringItems: [
{
dimensionId: '用工与人力风险',
indicatorId: '派遣比例',
riskLevel: 5,
score: 90,
provenance: '用户输入',
confidence: 0.95,
rationale: '依据《劳务派遣暂行规定》派遣用工不得超过用工总量 10%,本项目约 18%。',
riskImpact: '面临用工合规整改与行政处罚风险,可能被要求退场。',
recommendation: '将派遣比例降至 10% 以内,超出部分转为业务外包或直接用工。',
},
{
dimensionId: '用工与人力风险',
indicatorId: '社保合规',
riskLevel: 4,
score: 78,
provenance: '外部数据',
confidence: 0.8,
rationale: '抽样显示部分员工社保缴费基数低于当地法定下限。',
riskImpact: '存在补缴社保与滞纳金风险,影响成本测算。',
recommendation: '按法定下限统一核定缴费基数并计提补缴准备金。',
},
{
dimensionId: '客户风险',
indicatorId: '回款账期',
riskLevel: 4,
score: 72,
provenance: '用户输入',
confidence: 0.9,
rationale: '合同约定账期 90 天,结合月度结算规模垫资压力较大。',
riskImpact: '增加垫资利息与坏账敞口。',
recommendation: '争取缩短账期或收取预付/保证金,计提坏账准备金。',
},
{
dimensionId: '财务风险',
indicatorId: '垫资规模',
riskLevel: 4,
score: 70,
provenance: '智能体假设',
confidence: 0.6,
rationale: '缺少明确月度用工人数,采用行业默认值估算月度垫资约 320 万元。',
riskImpact: '垫资利息显著,影响风险调整后报价。',
recommendation: '补充确认实际用工规模与结算口径以校准垫资测算。',
},
{
dimensionId: '客户风险',
indicatorId: '资信状况',
riskLevel: 3,
score: 55,
provenance: '外部数据',
confidence: 0.85,
rationale: '工商与征信数据显示客户为区域龙头,负债率偏高但无重大涉诉。',
riskImpact: '中等回款风险。',
recommendation: '持续监控客户经营与涉诉动态。',
},
],
redlines: [
{
redlineId: 'RL-DISPATCH-RATIO',
title: '劳务派遣比例超过法定上限',
status: '命中',
detail: '派遣用工比例约 18% > 法定上限 10%,触发一票否决条件。',
},
{
redlineId: 'RL-MIN-WAGE',
title: '约定薪酬低于当地最低工资',
status: '未命中',
detail: '约定薪酬高于当地最低工资标准。',
},
{
redlineId: 'RL-LITIGATION',
title: '客户存在重大失信/涉诉',
status: '待核实',
detail: '外部涉诉数据部分缺失,暂无法确定是否命中,需补充核实。',
},
],
gapSources: [
{
indicatorId: '垫资规模',
dimensionId: '财务风险',
label: '月度用工人数与结算口径(影响垫资测算)',
satisfied: false,
locateAnchor: 'field-headcount',
},
{
indicatorId: '涉诉记录',
dimensionId: '客户风险',
label: '客户近三年涉诉与失信记录',
satisfied: false,
locateAnchor: 'field-litigation',
},
],
costBreakdown: [
{ name: '垫资利息', amount: 96000 },
{ name: '保险费用', amount: 42000 },
{ name: '补偿准备金', amount: 60000 },
{ name: '坏账准备金', amount: 48000 },
{ name: '社保补缴准备金', amount: 54000 },
],
quote: { baselineQuote: 1000000, riskAdjustedQuote: 1180000 },
acceptanceConditions: [
{
id: 'cond-ratio',
text: '派遣用工比例整改至 10% 以内',
relatedRisk: '用工与人力风险 / 派遣比例',
costImpact: '用工结构调整成本约 812 万元',
},
{
id: 'cond-social',
text: '按法定下限统一社保缴费基数',
relatedRisk: '用工与人力风险 / 社保合规',
costImpact: '社保补缴准备金约 5.4 万元/月',
},
{
id: 'cond-account',
text: '账期缩短至 60 天或收取 10% 预付款',
relatedRisk: '客户风险 / 回款账期',
costImpact: '降低垫资利息约 3.2 万元/月',
},
],
managementMeasures: [
'合同条款:明确派遣比例上限与违约责任',
'用工合规整改:限期将派遣比例降至 10% 以内',
'退场预案:约定不可整改情形下的有序退场机制',
'过程监控:按月核查社保缴纳与用工台账',
],
costMeasures: [
'风险溢价定价:在基准报价上加价 15%–20%',
'预付/保证金:收取合同额 10% 预付款',
'保险转移:投保雇主责任险与履约保证保险',
'账期成本:将 90 天账期纳入垫资利息测算',
'准备金计提:计提社保补缴与坏账准备金',
],
};
/** 组合看板:跨项目对比数据(≥2 个评估)。 */
export const PORTFOLIO_ROWS: readonly PortfolioCompareRow[] = [
{ assessmentId: 'asmt-2024-0312', label: '产线劳务派遣项目', riskScore: 68, riskGrade: '高' },
{ assessmentId: 'asmt-2024-0291', label: '园区保洁岗位外包', riskScore: 32, riskGrade: '中' },
{ assessmentId: 'asmt-2024-0277', label: '客服 BPO 项目', riskScore: 18, riskGrade: '低' },
{ assessmentId: 'asmt-2024-0260', label: '跨境仓储项目制外包', riskScore: 84, riskGrade: '极高' },
];
+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 '极高';
}
@@ -0,0 +1,81 @@
/**
* Property 66: 主题配色令牌解析正确(Design_System Theme / Color_TokenReq 19.6)。
*
* 属性陈述:对任意 Color_Token 与任意 ThemeLight 或 Dark),`resolveColorToken`
* 必返回该令牌在所选 Theme 下定义的取值;切换 Theme 后,页面所有引用该令牌处必
* 一致地反映所选 Theme 的取值。
*
* 「页面所有引用该令牌处一致反映所选 Theme」被建模为 `buildThemeCssVariables(theme)`
* 页面通过 CSS 自定义属性引用令牌,故只要该映射在 `colorTokenToCssVarName(token)`
* 处的取值恒等于 `resolveColorToken(token, theme)`,则所有经由该 CSS 变量的引用都
* 一致地反映所选 Theme 的取值。
*
* 用 fast-check(≥100 次)覆盖:
* - 正确性:对任意令牌与主题,解析结果恰为 `colors[token].light`Light)或 `.dark`Dark)。
* - 确定性:对同一 `(token, theme)` 重复调用返回同一取值。
* - 引用一致性:`buildThemeCssVariables(theme)` 在每个令牌的 CSS 变量名处的取值
* 恒等于 `resolveColorToken(token, theme)`。
*
* Feature: outsourcing-risk-assessment, Property 66: 主题配色令牌解析正确
* Validates: Requirements 19.6
*/
import { describe, expect, it } from 'vitest';
import fc from 'fast-check';
import {
THEME_VALUES,
colors,
resolveColorToken,
colorTokenToCssVarName,
buildThemeCssVariables,
} from '../index.js';
import type { ColorToken, Theme } from '../index.js';
// ----------------------------------------------------------------------------
// 生成器。
// ----------------------------------------------------------------------------
/** 任意已定义的 Color_Token(取自 colors 令牌表)。 */
const tokenArb: fc.Arbitrary<ColorToken> = fc.constantFrom(
...(Object.keys(colors) as ColorToken[]),
);
/** 任意 ThemeLight 或 Dark)。 */
const themeArb: fc.Arbitrary<Theme> = fc.constantFrom(...THEME_VALUES);
describe('Property 66: 主题配色令牌解析正确 (Req 19.6)', () => {
it('正确性与确定性:任意令牌×任意主题解析为该主题定义的取值,且重复调用稳定', () => {
fc.assert(
fc.property(tokenArb, themeArb, (token, theme) => {
const themeValue = colors[token];
// token 取自 Object.keys(colors),故定义必存在;显式断言以满足严格索引检查。
expect(themeValue).toBeDefined();
const definition = themeValue as (typeof colors)[ColorToken];
const expected = theme === 'Light' ? definition.light : definition.dark;
const resolved = resolveColorToken(token, theme);
// 解析结果恰为该令牌在所选主题下的定义取值。
expect(resolved).toBe(expected);
// 确定性:重复调用返回同一取值。
expect(resolveColorToken(token, theme)).toBe(resolved);
}),
{ numRuns: 100 },
);
});
it('引用一致性:切换主题后所有经 CSS 变量的引用一致反映所选主题取值', () => {
fc.assert(
fc.property(themeArb, (theme) => {
const variables = buildThemeCssVariables(theme);
// 页面任一处经 CSS 变量引用某令牌时,取得的值恒等于该令牌在所选主题下的取值。
for (const token of Object.keys(colors) as ColorToken[]) {
const cssVarName = colorTokenToCssVarName(token);
expect(variables[cssVarName]).toBe(resolveColorToken(token, theme));
}
}),
{ numRuns: 100 },
);
});
});
@@ -0,0 +1,126 @@
/**
* Property 64: Risk_Grade 配色令牌一致且唯一(Design_System Color_TokenReq 19.3)。
*
* 属性陈述:对任意 Risk_GradeUI 在 Risk_Badge、风险热力图与 Top N 关键风险图等
* 所有等级相关呈现中取得的 Color_Token 必为同一稳定令牌名;且四级(低、中、高、
* 极高)对应的 Color_Token 互不相同。
*
* 本测试把三类「呈现上下文」(badge / heatmap / topN)建模为对 `riskGradeColorToken`
* 的独立调用,断言它们就同一 Risk_Grade 取得完全一致的稳定令牌名;并验证四级映射
* 互不相同,且每个令牌都存在于 `colors` 令牌表中(含 light 与 dark 双取值)。
*
* 用 fast-check(≥100 次)覆盖:
* - 一致性/确定性:同一 Risk_Grade 在三个上下文下取得的令牌名两两相等,且等于
* 重复调用的结果(无副作用、稳定)。
* - 唯一性:任取两个不同 Risk_Grade,其令牌名互不相同(四级 → 四个不同令牌)。
* - 良构性:返回的令牌名是 `colors` 表中已定义的令牌,且 light/dark 取值均存在。
*
* Feature: outsourcing-risk-assessment, Property 64: Risk_Grade 配色令牌一致且唯一
* Validates: Requirements 19.3
*/
import { describe, expect, it } from 'vitest';
import fc from 'fast-check';
import {
RISK_GRADE_VALUES,
RISK_GRADE_COLOR_TOKENS,
colors,
riskGradeColorToken,
} from '../index.js';
import type { ColorToken, RiskGrade } from '../index.js';
// ----------------------------------------------------------------------------
// 呈现上下文模型:Risk_Badge / 风险热力图 / Top N 关键风险图。
// 每个上下文独立调用同一映射函数取得其 Color_Token(取色入口唯一)。
// ----------------------------------------------------------------------------
/** Risk_Badge 呈现中取得某 Risk_Grade 的 Color_Token。 */
function tokenFromBadge(grade: RiskGrade): ColorToken {
return riskGradeColorToken(grade);
}
/** 风险热力图等级相关呈现中取得某 Risk_Grade 的 Color_Token。 */
function tokenFromHeatmap(grade: RiskGrade): ColorToken {
return riskGradeColorToken(grade);
}
/** Top N 关键风险图呈现中取得某 Risk_Grade 的 Color_Token。 */
function tokenFromTopN(grade: RiskGrade): ColorToken {
return riskGradeColorToken(grade);
}
/** 某令牌是否为 `colors` 表中已定义、且 light/dark 取值均存在的良构令牌。 */
function isWellFormedToken(token: ColorToken): boolean {
const value = colors[token];
return (
value !== undefined &&
typeof value.light === 'string' &&
value.light.length > 0 &&
typeof value.dark === 'string' &&
value.dark.length > 0
);
}
// ----------------------------------------------------------------------------
// 生成器。
// ----------------------------------------------------------------------------
/** 任意 Risk_Grade(四级互斥完备)。 */
const gradeArb: fc.Arbitrary<RiskGrade> = fc.constantFrom(...RISK_GRADE_VALUES);
/** 任意一对不同的 Risk_Grade。 */
const distinctGradePairArb: fc.Arbitrary<readonly [RiskGrade, RiskGrade]> = fc
.tuple(gradeArb, gradeArb)
.filter(([a, b]) => a !== b);
describe('Property 64: Risk_Grade 配色令牌一致且唯一 (Req 19.3)', () => {
it('四级映射存在、唯一且良构(互不相同,且均为 colors 表中含 light/dark 的令牌)', () => {
// 四级各有映射。
expect(RISK_GRADE_VALUES).toHaveLength(4);
const tokens = RISK_GRADE_VALUES.map((grade) => riskGradeColorToken(grade));
// 映射函数与映射表一致。
for (const grade of RISK_GRADE_VALUES) {
expect(riskGradeColorToken(grade)).toBe(RISK_GRADE_COLOR_TOKENS[grade]);
}
// 四个令牌互不相同。
expect(new Set(tokens).size).toBe(tokens.length);
// 每个令牌在 colors 表中良构(含 light/dark 取值)。
for (const token of tokens) {
expect(isWellFormedToken(token)).toBe(true);
}
});
it('一致性:任意 Risk_Grade 在 badge/heatmap/topN 及重复调用下取得同一稳定令牌', () => {
fc.assert(
fc.property(gradeArb, (grade) => {
const badge = tokenFromBadge(grade);
const heatmap = tokenFromHeatmap(grade);
const topN = tokenFromTopN(grade);
// 三个呈现上下文取得同一令牌名。
expect(heatmap).toBe(badge);
expect(topN).toBe(badge);
// 确定性:重复调用结果稳定不变。
expect(riskGradeColorToken(grade)).toBe(badge);
// 取得的令牌为 colors 表中良构令牌。
expect(isWellFormedToken(badge)).toBe(true);
}),
{ numRuns: 100 },
);
});
it('唯一性:任意两个不同 Risk_Grade 取得互不相同的令牌', () => {
fc.assert(
fc.property(distinctGradePairArb, ([a, b]) => {
expect(riskGradeColorToken(a)).not.toBe(riskGradeColorToken(b));
}),
{ numRuns: 100 },
);
});
});
@@ -0,0 +1,89 @@
/**
* Property 65: 间距为 4 像素整数倍(Design_System SpacingReq 19.4)。
*
* 属性陈述:对任意 Design_System 间距标度取值与任意组件间距,其像素值恒为 4 的
* 非负整数倍。
*
* `spacing` 标度是组件间距的唯一事实来源(任意组件间距只能取自该标度),故"任意
* 组件间距"被建模为"从标度中任取一个取值"。本测试用 fast-check(≥100 次)覆盖:
* - 基数:`SPACING_BASE === 4`。
* - 完备性:标度中每个取值均为非负整数,且为 4 的整数倍(value % 4 === 0)。
* - 任意组件间距(按下标随机取标度内取值):恒满足"4 的非负整数倍"不变式。
* - 越界拒绝:任意非 4 整数倍的数不在标度内,即不存在"脱离标度"的组件间距。
*
* Feature: outsourcing-risk-assessment, Property 65: 间距为 4 像素整数倍
* Validates: Requirements 19.4
*/
import { describe, expect, it } from 'vitest';
import fc from 'fast-check';
import { spacing, SPACING_BASE } from '../index.js';
// ----------------------------------------------------------------------------
// 不变式判定:"4 像素整数倍" = 非负 + 整数 + 可被 4 整除。
// ----------------------------------------------------------------------------
/** 判定 value 是否为 SPACING_BASE 的非负整数倍。 */
function isMultipleOfBase(value: number): boolean {
return Number.isInteger(value) && value >= 0 && value % SPACING_BASE === 0;
}
/** 标度内全部取值集合(用于"越界"判定)。 */
const definedSpacing: ReadonlySet<number> = new Set(spacing);
// ----------------------------------------------------------------------------
// 生成器。
// ----------------------------------------------------------------------------
/** 任意组件间距:随机选用标度内的一个取值下标(标度为唯一来源)。 */
const definedIndexArb: fc.Arbitrary<number> = fc.nat({
max: spacing.length - 1,
});
/** 任意整数(含标度内取值与大量越界值,用于"非 4 整数倍"判定)。 */
const arbitraryNumberArb: fc.Arbitrary<number> = fc.oneof(
fc.constantFrom(...spacing),
fc.integer({ min: -1000, max: 1000 }),
);
describe('Property 65: 间距为 4 像素整数倍 (Req 19.4)', () => {
it('基数为 4,且标度每个取值均为 4 的非负整数倍', () => {
expect(SPACING_BASE).toBe(4);
expect(spacing.length).toBeGreaterThan(0);
for (const value of spacing) {
expect(typeof value).toBe('number');
expect(Number.isInteger(value)).toBe(true);
expect(value).toBeGreaterThanOrEqual(0);
expect(value % SPACING_BASE).toBe(0);
}
});
it('任意组件间距(按下标取标度内取值)恒为 4 的非负整数倍', () => {
fc.assert(
fc.property(definedIndexArb, (index) => {
const value = spacing[index];
expect(value).toBeDefined();
if (value === undefined) return;
// 任意组件间距取自标度,故恒满足不变式。
expect(isMultipleOfBase(value)).toBe(true);
}),
{ numRuns: 100 },
);
});
it('任意数:命中标度内者为 4 整数倍,非 4 整数倍者不在标度内', () => {
fc.assert(
fc.property(arbitraryNumberArb, (value) => {
if (isMultipleOfBase(value)) {
// 4 的非负整数倍不一定在标度内(标度是其子集),不作断言。
return;
}
// 非 4 整数倍的数不可能作为合法组件间距(不在标度内)。
expect(definedSpacing.has(value)).toBe(false);
}),
{ numRuns: 100 },
);
});
});
@@ -0,0 +1,151 @@
/**
* Property 63: 排版层级完备且文本取自层级(Design_System TypographyReq 19.2)。
*
* 属性陈述:对任意 UI 文本元素,其排版样式必解析到 Design_System 已定义的某个具名
* Typography 层级;且已定义层级恒不少于 4 级,每级具有固定字号与固定行高。
*
* 本测试从 `typography` 标度出发,建立"文本取自层级"的解析模型:
* - 任一 UI 文本元素由它选用的层级名(`TypographyLevel.name`)标识;
* - 以 `resolveLevel(name)` 将层级名解析回标度中的层级;解析成功即视为"取自层级"。
*
* 用 fast-check(≥100 次)覆盖:
* - 完备性:标度恒 ≥4 级;每级字号/行高为固定正数;层级名非空且互不相同。
* - 任意取自层级的文本:随机取标度内层级名 / 层级下标,其解析结果恒等于唯一一个
* 已定义具名层级(同一对象、字号/行高完全一致)。
* - 越界拒绝:随机的层级名(不在标度内)与随机字号(不属于任一层级)均不被
* 当作合法层级,即不存在"脱离标度"的文本样式。
*
* Feature: outsourcing-risk-assessment, Property 63: 排版层级完备且文本取自层级
* Validates: Requirements 19.2
*/
import { describe, expect, it } from 'vitest';
import fc from 'fast-check';
import { typography, TYPOGRAPHY_LEVEL_NAMES } from '../index.js';
import type { TypographyLevel } from '../index.js';
// ----------------------------------------------------------------------------
// 解析模型:"文本取自层级" = 以层级名解析回标度中的层级。
// ----------------------------------------------------------------------------
/** 标度内全部具名层级名(事实来源即 typography 自身)。 */
const definedNames: readonly string[] = typography.map((level) => level.name);
/** 标度内全部固定字号集合(用于"越界字号"判定)。 */
const definedFontSizes: ReadonlySet<number> = new Set(
typography.map((level) => level.fontSize),
);
/**
* 将层级名解析回标度中的具名层级;未定义则返回 undefined。
* 模型化"UI 文本元素其排版样式取自某具名层级"。
*/
function resolveLevel(name: string): TypographyLevel | undefined {
return typography.find((level) => level.name === name);
}
// ----------------------------------------------------------------------------
// 生成器。
// ----------------------------------------------------------------------------
/** 任意"取自层级"的文本元素:随机选用标度内的一个层级下标。 */
const definedIndexArb: fc.Arbitrary<number> = fc.nat({
max: typography.length - 1,
});
/** 任意层级名(多数命中标度内名,少量随机以触发越界分支)。 */
const arbitraryNameArb: fc.Arbitrary<string> = fc.oneof(
fc.constantFrom(...definedNames),
fc.string(),
);
/** 任意字号(含标度内固定值与大量越界值)。 */
const arbitraryFontSizeArb: fc.Arbitrary<number> = fc.oneof(
fc.constantFrom(...typography.map((l) => l.fontSize)),
fc.double({ min: -100, max: 200, noNaN: true, noDefaultInfinity: true }),
fc.integer({ min: -100, max: 200 }),
);
describe('Property 63: 排版层级完备且文本取自层级 (Req 19.2)', () => {
it('标度完备:≥4 级,每级具名且字号/行高为固定正数,名互不相同', () => {
// 层级数 ≥4。
expect(typography.length).toBeGreaterThanOrEqual(4);
// 每级具名 + 固定正数字号 + 固定正数行高。
for (const level of typography) {
expect(typeof level.name).toBe('string');
expect(level.name.length).toBeGreaterThan(0);
expect(typeof level.fontSize).toBe('number');
expect(Number.isFinite(level.fontSize)).toBe(true);
expect(level.fontSize).toBeGreaterThan(0);
expect(typeof level.lineHeight).toBe('number');
expect(Number.isFinite(level.lineHeight)).toBe(true);
expect(level.lineHeight).toBeGreaterThan(0);
}
// 层级名互不相同。
expect(new Set(definedNames).size).toBe(typography.length);
// 具名标识常量与标度一一对应。
expect([...TYPOGRAPHY_LEVEL_NAMES]).toEqual(definedNames);
});
it('任意取自层级的文本(按下标)解析到唯一已定义具名层级', () => {
fc.assert(
fc.property(definedIndexArb, (index) => {
const level = typography[index];
expect(level).toBeDefined();
if (level === undefined) return;
const resolved = resolveLevel(level.name);
// 取自层级:解析结果存在且即为同一已定义层级对象。
expect(resolved).toBeDefined();
expect(resolved).toBe(level);
// 字号 / 行高为该层级固定取值。
expect(resolved?.fontSize).toBe(level.fontSize);
expect(resolved?.lineHeight).toBe(level.lineHeight);
// 解析命中的层级恰为标度中唯一同名层级。
const matches = typography.filter((l) => l.name === level.name);
expect(matches).toHaveLength(1);
}),
{ numRuns: 100 },
);
});
it('任意层级名:命中标度内则取自层级,否则不被当作合法层级', () => {
fc.assert(
fc.property(arbitraryNameArb, (name) => {
const resolved = resolveLevel(name);
if (definedNames.includes(name)) {
// 取自层级:必解析到一个已定义具名层级。
expect(resolved).toBeDefined();
expect(definedNames).toContain(resolved?.name);
} else {
// 脱离标度的层级名不被接受。
expect(resolved).toBeUndefined();
}
}),
{ numRuns: 100 },
);
});
it('任意字号:不在标度固定字号集合内者不构成合法层级', () => {
fc.assert(
fc.property(arbitraryFontSizeArb, (fontSize) => {
const levelWithSize = typography.find((l) => l.fontSize === fontSize);
if (definedFontSizes.has(fontSize)) {
// 标度内字号必对应某已定义层级。
expect(levelWithSize).toBeDefined();
} else {
// 越界字号不属于任一具名层级(无"脱离标度"的文本样式)。
expect(levelWithSize).toBeUndefined();
}
}),
{ numRuns: 100 },
);
});
});
+116
View File
@@ -0,0 +1,116 @@
/**
* Button — 令牌驱动的按钮组件(task 18.8Req 19.1 / 19.5)。
*
* 渲染真实 `<button>` 元素(利于键盘可达与原生语义,便于后续 task 22.x 无障碍)。
* 同一 variant/size 的按钮在全 UI 外观与交互行为一致(Req 19.1):所有视觉值都从
* 共享样式原语与 Color_Token 取得,组件本身不出现散落的颜色/字号字面量。
* 可选图标装饰一律经 `Icon` 取自单一来源图标集(Req 19.5)。
*/
import type { CSSProperties, ReactNode } from 'react';
import { colorVar, FONT_FAMILY, RADIUS, space, TRANSITION, typographyStyle } from './styles.js';
import { Icon } from './Icon.js';
import type { IconName } from './Icon.js';
/** 按钮外观变体。 */
export type ButtonVariant = 'primary' | 'secondary' | 'ghost';
/** 按钮尺寸。 */
export type ButtonSize = 'sm' | 'md' | 'lg';
/** `Button` 组件属性。 */
export interface ButtonProps {
/** 按钮内容(文本/节点)。 */
readonly children: ReactNode;
/** 外观变体,默认 `primary`。 */
readonly variant?: ButtonVariant;
/** 尺寸,默认 `md`。 */
readonly size?: ButtonSize;
/** 原生按钮类型,默认 `button`(避免在表单内意外提交)。 */
readonly type?: 'button' | 'submit' | 'reset';
/** 是否禁用。 */
readonly disabled?: boolean;
/** 是否占满父容器宽度。 */
readonly fullWidth?: boolean;
/** 前置图标名(取自单一来源图标集,Req 19.5)。 */
readonly iconLeft?: IconName;
/** 后置图标名(取自单一来源图标集,Req 19.5)。 */
readonly iconRight?: IconName;
/** 点击回调。 */
readonly onClick?: () => void;
/** 无障碍标签(当按钮仅含图标时提供可访问名称)。 */
readonly ariaLabel?: string;
}
/** 各尺寸的内边距档位与排版层级(集中定义,确保全局一致)。 */
const SIZE_STYLE: Record<ButtonSize, CSSProperties> = {
sm: { padding: `${space(1)}px ${space(3)}px`, ...typographyStyle('caption') },
md: { padding: `${space(2)}px ${space(4)}px`, ...typographyStyle('body') },
lg: { padding: `${space(3)}px ${space(5)}px`, ...typographyStyle('title') },
};
/** 各变体的配色(全部以 Color_Token 经 CSS 变量引用)。 */
const VARIANT_STYLE: Record<ButtonVariant, CSSProperties> = {
primary: {
backgroundColor: colorVar('color.brand.primary'),
color: colorVar('color.text.onAccent'),
border: `1px solid ${colorVar('color.brand.primary')}`,
},
secondary: {
backgroundColor: colorVar('color.bg.surface'),
color: colorVar('color.text.primary'),
border: `1px solid ${colorVar('color.border.default')}`,
},
ghost: {
backgroundColor: 'transparent',
color: colorVar('color.brand.primary'),
border: '1px solid transparent',
},
};
/**
* 令牌驱动的按钮。外观由 `variant`/`size` 决定,且全 UI 保持一致(Req 19.1)。
*/
export function Button({
children,
variant = 'primary',
size = 'md',
type = 'button',
disabled = false,
fullWidth = false,
iconLeft,
iconRight,
onClick,
ariaLabel,
}: ButtonProps): JSX.Element {
const style: CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: `${space(2)}px`,
fontFamily: FONT_FAMILY,
fontWeight: 600,
letterSpacing: '-0.01em',
borderRadius: `${RADIUS.md}px`,
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.5 : 1,
transition: `background-color ${TRANSITION}, color ${TRANSITION}, opacity ${TRANSITION}`,
width: fullWidth ? '100%' : undefined,
...SIZE_STYLE[size],
...VARIANT_STYLE[variant],
};
return (
<button
type={type}
disabled={disabled}
onClick={onClick}
aria-label={ariaLabel}
style={style}
>
{iconLeft !== undefined ? <Icon name={iconLeft} size={18} /> : null}
<span>{children}</span>
{iconRight !== undefined ? <Icon name={iconRight} size={18} /> : null}
</button>
);
}
+77
View File
@@ -0,0 +1,77 @@
/**
* Card — 令牌驱动的内容卡片容器(task 18.8Req 19.1)。
*
* 提供统一的表面(背景/边框/圆角/内边距)以承载分组内容。可选标题与脚注区。
* 全 UI 卡片外观一致(Req 19.1)。
*/
import type { CSSProperties, ReactNode } from 'react';
import { colorVar, FONT_FAMILY, RADIUS, SHADOW, space, typographyStyle } from './styles.js';
/** `Card` 组件属性。 */
export interface CardProps {
/** 卡片主体内容。 */
readonly children: ReactNode;
/** 可选标题(呈现于卡片头部)。 */
readonly title?: ReactNode;
/** 可选脚注区(呈现于卡片底部)。 */
readonly footer?: ReactNode;
/** 是否带内边距,默认 `true`。 */
readonly padded?: boolean;
}
/**
* 统一表面容器。背景/边框/圆角/间距均取自 Design TokensReq 19.1 / 19.4)。
*/
export function Card({
children,
title,
footer,
padded = true,
}: CardProps): JSX.Element {
const containerStyle: CSSProperties = {
display: 'flex',
flexDirection: 'column',
backgroundColor: colorVar('color.bg.elevated'),
border: `1px solid ${colorVar('color.border.default')}`,
borderRadius: `${RADIUS.lg}px`,
boxShadow: SHADOW.sm,
fontFamily: FONT_FAMILY,
color: colorVar('color.text.primary'),
overflow: 'hidden',
transition: 'box-shadow 150ms ease',
};
const pad = padded ? `${space(4)}px` : '0';
return (
<section data-card="true" style={containerStyle}>
{title !== undefined ? (
<header
style={{
...typographyStyle('title'),
fontWeight: 600,
letterSpacing: '-0.01em',
padding: `${space(3)}px ${pad === '0' ? `${space(4)}px` : pad}`,
borderBottom: `1px solid ${colorVar('color.border.default')}`,
}}
>
{title}
</header>
) : null}
<div style={{ padding: pad, flex: 1 }}>{children}</div>
{footer !== undefined ? (
<footer
style={{
...typographyStyle('caption'),
color: colorVar('color.text.secondary'),
padding: `${space(3)}px ${pad === '0' ? `${space(4)}px` : pad}`,
borderTop: `1px solid ${colorVar('color.border.default')}`,
}}
>
{footer}
</footer>
) : null}
</section>
);
}
+166
View File
@@ -0,0 +1,166 @@
/**
* Dialog — 令牌驱动的模态对话框(task 18.8Req 19.1 / 19.5)。
*
* 受控显隐(`open` + `onClose`)。可访问性(便于 task 22.x):
* - 容器 `role="dialog"` 且 `aria-modal="true"`
* - `aria-labelledby` 关联标题;
* - 打开时自动聚焦关闭按钮;按 `Escape` 触发关闭;
* - 关闭按钮图标取自单一来源图标集(Req 19.5)。
*
* 完整的焦点陷阱(focus trap)留待 task 22.x;此处提供合理的基础行为。
*/
import { useEffect, useRef } from 'react';
import type { CSSProperties, ReactNode } from 'react';
import { colorVar, FONT_FAMILY, RADIUS, space, typographyStyle } from './styles.js';
import { Icon } from './Icon.js';
/** `Dialog` 组件属性。 */
export interface DialogProps {
/** 是否显示。 */
readonly open: boolean;
/** 请求关闭的回调(点击遮罩、关闭按钮或按 Escape 时触发)。 */
readonly onClose: () => void;
/** 对话框标题。 */
readonly title: string;
/** 对话框主体内容。 */
readonly children: ReactNode;
/** 可选底部操作区。 */
readonly footer?: ReactNode;
/** 关闭按钮的无障碍标签,默认「关闭」。 */
readonly closeLabel?: string;
}
/**
* 模态对话框。显隐受控,具备基础键盘与焦点行为(Req 19.1)。
*/
export function Dialog({
open,
onClose,
title,
children,
footer,
closeLabel = '关闭',
}: DialogProps): JSX.Element | null {
const closeButtonRef = useRef<HTMLButtonElement>(null);
const titleId = useRef(`dialog-title-${Math.random().toString(36).slice(2)}`).current;
// 打开时聚焦关闭按钮,提供合理的初始焦点。
useEffect(() => {
if (open) {
closeButtonRef.current?.focus();
}
}, [open]);
// Escape 关闭。
useEffect(() => {
if (!open) {
return;
}
const handler = (event: KeyboardEvent): void => {
if (event.key === 'Escape') {
onClose();
}
};
document.addEventListener('keydown', handler);
return () => {
document.removeEventListener('keydown', handler);
};
}, [open, onClose]);
if (!open) {
return null;
}
const overlayStyle: CSSProperties = {
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: `${space(4)}px`,
zIndex: 1000,
};
const dialogStyle: CSSProperties = {
display: 'flex',
flexDirection: 'column',
width: '100%',
maxWidth: '480px',
maxHeight: '100%',
backgroundColor: colorVar('color.bg.elevated'),
border: `1px solid ${colorVar('color.border.default')}`,
borderRadius: `${RADIUS.md}px`,
fontFamily: FONT_FAMILY,
color: colorVar('color.text.primary'),
overflow: 'hidden',
};
const headerStyle: CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: `${space(3)}px`,
padding: `${space(3)}px ${space(4)}px`,
borderBottom: `1px solid ${colorVar('color.border.default')}`,
...typographyStyle('title'),
fontWeight: 700,
};
const closeButtonStyle: CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: `${space(1)}px`,
background: 'transparent',
border: 'none',
borderRadius: `${RADIUS.sm}px`,
cursor: 'pointer',
color: colorVar('color.text.secondary'),
};
return (
<div
style={overlayStyle}
onClick={(event) => {
if (event.target === event.currentTarget) {
onClose();
}
}}
>
<div role="dialog" aria-modal="true" aria-labelledby={titleId} style={dialogStyle}>
<header style={headerStyle}>
<h2 id={titleId} style={{ margin: 0, ...typographyStyle('title') }}>
{title}
</h2>
<button
ref={closeButtonRef}
type="button"
aria-label={closeLabel}
onClick={onClose}
style={closeButtonStyle}
>
<Icon name="close" size={20} />
</button>
</header>
<div style={{ padding: `${space(4)}px`, overflowY: 'auto', ...typographyStyle('body') }}>
{children}
</div>
{footer !== undefined ? (
<footer
style={{
display: 'flex',
justifyContent: 'flex-end',
gap: `${space(2)}px`,
padding: `${space(3)}px ${space(4)}px`,
borderTop: `1px solid ${colorVar('color.border.default')}`,
}}
>
{footer}
</footer>
) : null}
</div>
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
/**
* Icon — 单一来源图标组件(task 18.8Req 19.5)。
*
* 全 UI 的图标**只能**经由此组件渲染,且其图形数据**仅**取自 Design_System 的
* 单一来源图标集 `icons`tokens.ts)。任何组件(Dialog 关闭、Nav、Toast 状态、
* Input 装饰等)需要图标时都引用本组件,禁止在别处内联任意 SVG。
*
* 可访问性:未提供 `title` 时图标视为装饰性(`aria-hidden`);提供 `title` 时以
* `role="img"` + `<title>` 暴露可访问名称。
*/
import type { CSSProperties } from 'react';
import { icons } from '../tokens.js';
/** 单一来源图标集中的可用图标名(取自 `icons` 的键集)。 */
export type IconName = keyof typeof icons & string;
/** `Icon` 组件属性。 */
export interface IconProps {
/** 图标名,必须存在于单一来源图标集 `icons` 中(Req 19.5)。 */
readonly name: IconName;
/** 方形边长(px),默认 20。 */
readonly size?: number;
/** 颜色,默认 `currentColor`(随父级文字颜色,便于令牌驱动)。 */
readonly color?: string;
/** 可访问名称;提供时图标对辅助技术可见,否则视为装饰性图标。 */
readonly title?: string;
/** 附加类名。 */
readonly className?: string;
}
/**
* 渲染来自单一来源图标集的一个图标。
* @throws 当图标名不在 `icons` 图标集内时抛出,强制图标取自单一来源(Req 19.5)。
*/
export function Icon({
name,
size = 20,
color = 'currentColor',
title,
className,
}: IconProps): JSX.Element {
const ref = icons[name];
if (ref === undefined) {
throw new Error(`Unknown icon: ${name}. Icons must come from the single IconSet.`);
}
const style: CSSProperties = {
display: 'inline-block',
flexShrink: 0,
verticalAlign: 'middle',
fill: color,
};
const accessible = title !== undefined;
return (
<svg
width={size}
height={size}
viewBox={ref.viewBox}
style={style}
className={className}
role={accessible ? 'img' : undefined}
aria-hidden={accessible ? undefined : true}
aria-label={accessible ? title : undefined}
focusable={false}
>
{accessible ? <title>{title}</title> : null}
<path d={ref.path} />
</svg>
);
}
+134
View File
@@ -0,0 +1,134 @@
/**
* Input — 令牌驱动的文本输入控件(task 18.8Req 19.1 / 19.5)。
*
* 受控组件:值由 `value` 决定、变更经 `onChange(nextValue)` 上抛。全 UI 的输入框
* 外观与交互一致(Req 19.1)。可访问性(便于 task 22.x):
* - `<label htmlFor>` 与 `<input id>` 关联;
* - 错误信息通过 `aria-describedby` 关联,并以 `aria-invalid` 标注无效态;
* - 可选前置图标取自单一来源图标集(Req 19.5)。
*/
import type { ChangeEvent, CSSProperties } from 'react';
import { colorVar, FONT_FAMILY, RADIUS, space, TRANSITION, typographyStyle } from './styles.js';
import { Icon } from './Icon.js';
import type { IconName } from './Icon.js';
/** `Input` 组件属性。 */
export interface InputProps {
/** 控件 id,用于 `<label htmlFor>` 关联(必填以保证可访问性)。 */
readonly id: string;
/** 字段标签文本。 */
readonly label: string;
/** 当前值(受控)。 */
readonly value: string;
/** 值变更回调,参数为输入的新值。 */
readonly onChange: (value: string) => void;
/** 原生输入类型,默认 `text`。 */
readonly type?: string;
/** 占位提示。 */
readonly placeholder?: string;
/** 表单字段名。 */
readonly name?: string;
/** 是否禁用。 */
readonly disabled?: boolean;
/** 是否必填。 */
readonly required?: boolean;
/** 错误信息;提供时进入无效态并以无障碍方式呈现。 */
readonly error?: string;
/** 前置图标名(取自单一来源图标集,Req 19.5)。 */
readonly iconLeft?: IconName;
}
/**
* 受控文本输入框。标签与输入关联,错误信息以无障碍方式呈现(Req 19.1)。
*/
export function Input({
id,
label,
value,
onChange,
type = 'text',
placeholder,
name,
disabled = false,
required = false,
error,
iconLeft,
}: InputProps): JSX.Element {
const hasError = error !== undefined;
const errorId = `${id}-error`;
const wrapperStyle: CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: `${space(1)}px`,
fontFamily: FONT_FAMILY,
};
const labelStyle: CSSProperties = {
...typographyStyle('caption'),
fontWeight: 600,
color: colorVar('color.text.secondary'),
};
const fieldStyle: CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: `${space(2)}px`,
padding: `${space(2)}px ${space(3)}px`,
backgroundColor: colorVar('color.bg.elevated'),
border: `1px solid ${hasError ? colorVar('color.risk.critical') : colorVar('color.border.default')}`,
borderRadius: `${RADIUS.sm}px`,
transition: `border-color ${TRANSITION}`,
opacity: disabled ? 0.5 : 1,
};
const inputStyle: CSSProperties = {
flex: 1,
border: 'none',
outline: 'none',
background: 'transparent',
color: colorVar('color.text.primary'),
fontFamily: FONT_FAMILY,
...typographyStyle('body'),
};
const errorStyle: CSSProperties = {
...typographyStyle('caption'),
color: colorVar('color.risk.critical'),
};
return (
<div style={wrapperStyle}>
<label htmlFor={id} style={labelStyle}>
{label}
{required ? <span aria-hidden="true"> *</span> : null}
</label>
<div style={fieldStyle}>
{iconLeft !== undefined ? (
<Icon name={iconLeft} size={18} color={colorVar('color.text.secondary')} />
) : null}
<input
id={id}
name={name}
type={type}
value={value}
placeholder={placeholder}
disabled={disabled}
required={required}
aria-invalid={hasError}
aria-describedby={hasError ? errorId : undefined}
style={inputStyle}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
onChange(event.target.value);
}}
/>
</div>
{hasError ? (
<p id={errorId} role="alert" style={errorStyle}>
{error}
</p>
) : null}
</div>
);
}
+107
View File
@@ -0,0 +1,107 @@
/**
* Nav — 令牌驱动的导航组件(task 18.8Req 19.1 / 19.5)。
*
* 渲染语义化 `<nav>` + 列表。每个导航项可为链接(`href`)或按钮(`onSelect`),
* 当前项以 `aria-current="page"` 标注。可选图标取自单一来源图标集(Req 19.5)。
* 全 UI 导航外观与交互一致(Req 19.1)。
*/
import type { CSSProperties } from 'react';
import { colorVar, FONT_FAMILY, RADIUS, space, TRANSITION, typographyStyle } from './styles.js';
import { Icon } from './Icon.js';
import type { IconName } from './Icon.js';
/** 单个导航项定义。 */
export interface NavItem {
/** 唯一键。 */
readonly key: string;
/** 显示文本。 */
readonly label: string;
/** 可选图标名(取自单一来源图标集,Req 19.5)。 */
readonly icon?: IconName;
/** 链接地址;提供时渲染为 `<a>`,否则渲染为 `<button>`。 */
readonly href?: string;
/** 是否为当前项。 */
readonly active?: boolean;
/** 选中回调(按钮模式)。 */
readonly onSelect?: (key: string) => void;
}
/** `Nav` 组件属性。 */
export interface NavProps {
/** 导航项集合。 */
readonly items: readonly NavItem[];
/** `<nav>` 的无障碍标签,默认「主导航」。 */
readonly ariaLabel?: string;
}
/**
* 语义化导航。链接/按钮两态,当前项以 `aria-current` 标注(Req 19.1)。
*/
export function Nav({ items, ariaLabel = '主导航' }: NavProps): JSX.Element {
const listStyle: CSSProperties = {
listStyle: 'none',
margin: 0,
padding: 0,
display: 'flex',
flexDirection: 'column',
gap: `${space(1)}px`,
fontFamily: FONT_FAMILY,
};
const itemStyle = (active: boolean): CSSProperties => ({
display: 'flex',
alignItems: 'center',
gap: `${space(2)}px`,
width: '100%',
padding: `${space(2)}px ${space(3)}px`,
border: 'none',
borderRadius: `${RADIUS.sm}px`,
textAlign: 'left',
textDecoration: 'none',
cursor: 'pointer',
backgroundColor: active ? colorVar('color.bg.surface') : 'transparent',
color: active ? colorVar('color.brand.primary') : colorVar('color.text.primary'),
fontWeight: active ? 700 : 500,
transition: `background-color ${TRANSITION}, color ${TRANSITION}`,
...typographyStyle('body'),
});
return (
<nav aria-label={ariaLabel}>
<ul style={listStyle}>
{items.map((item) => {
const active = item.active ?? false;
const content = (
<>
{item.icon !== undefined ? <Icon name={item.icon} size={18} /> : null}
<span>{item.label}</span>
</>
);
return (
<li key={item.key}>
{item.href !== undefined ? (
<a
href={item.href}
aria-current={active ? 'page' : undefined}
style={itemStyle(active)}
>
{content}
</a>
) : (
<button
type="button"
aria-current={active ? 'page' : undefined}
onClick={() => item.onSelect?.(item.key)}
style={itemStyle(active)}
>
{content}
</button>
)}
</li>
);
})}
</ul>
</nav>
);
}
@@ -0,0 +1,122 @@
import { useEffect, useMemo, useState } from 'react';
import type { CSSProperties } from 'react';
import { Button } from './Button.js';
import { Table } from './Table.js';
import type { TableColumn } from './Table.js';
import { colorVar, FONT_FAMILY, RADIUS, space, typographyStyle } from './styles.js';
export interface PaginatedTableProps<T> {
readonly columns: ReadonlyArray<TableColumn<T>>;
readonly data: readonly T[];
readonly getRowKey: (row: T, index: number) => string | number;
readonly caption?: string;
readonly emptyMessage?: string;
readonly pageSize?: number;
readonly pageSizeOptions?: readonly number[];
}
export function PaginatedTable<T>({
columns,
data,
getRowKey,
caption,
emptyMessage,
pageSize = 10,
pageSizeOptions = [5, 10, 20, 50],
}: PaginatedTableProps<T>): JSX.Element {
const [currentPage, setCurrentPage] = useState(1);
const [currentPageSize, setCurrentPageSize] = useState(pageSize);
const totalPages = Math.max(1, Math.ceil(data.length / currentPageSize));
const safePage = Math.min(currentPage, totalPages);
useEffect(() => {
if (currentPage > totalPages) {
setCurrentPage(totalPages);
}
}, [currentPage, totalPages]);
const pageData = useMemo(() => {
const start = (safePage - 1) * currentPageSize;
return data.slice(start, start + currentPageSize);
}, [currentPageSize, data, safePage]);
const startIndex = data.length === 0 ? 0 : (safePage - 1) * currentPageSize + 1;
const endIndex = Math.min(data.length, safePage * currentPageSize);
const footerStyle: CSSProperties = {
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
justifyContent: 'space-between',
gap: `${space(3)}px`,
paddingTop: `${space(3)}px`,
borderTop: `1px solid ${colorVar('color.border.default')}`,
fontFamily: FONT_FAMILY,
};
const selectStyle: CSSProperties = {
padding: `${space(1)}px ${space(2)}px`,
border: `1px solid ${colorVar('color.border.default')}`,
borderRadius: `${RADIUS.sm}px`,
backgroundColor: colorVar('color.bg.elevated'),
color: colorVar('color.text.primary'),
fontFamily: FONT_FAMILY,
...typographyStyle('caption'),
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: `${space(3)}px` }}>
<div style={{ overflowX: 'auto' }}>
<Table
columns={columns}
data={pageData}
getRowKey={getRowKey}
{...(caption !== undefined ? { caption } : {})}
{...(emptyMessage !== undefined ? { emptyMessage } : {})}
/>
</div>
<div style={footerStyle}>
<div style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>
{data.length} {startIndex}-{endIndex}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: `${space(2)}px` }}>
<label style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>
<select
value={currentPageSize}
onChange={(event) => {
setCurrentPageSize(Number(event.target.value));
setCurrentPage(1);
}}
style={{ ...selectStyle, marginLeft: `${space(1)}px` }}
>
{pageSizeOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<Button size="sm" variant="secondary" disabled={safePage <= 1} onClick={() => setCurrentPage(1)}>
</Button>
<Button size="sm" variant="secondary" disabled={safePage <= 1} onClick={() => setCurrentPage(safePage - 1)}>
</Button>
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>
{safePage} / {totalPages}
</span>
<Button size="sm" variant="secondary" disabled={safePage >= totalPages} onClick={() => setCurrentPage(safePage + 1)}>
</Button>
<Button size="sm" variant="secondary" disabled={safePage >= totalPages} onClick={() => setCurrentPage(totalPages)}>
</Button>
</div>
</div>
</div>
);
}
+143
View File
@@ -0,0 +1,143 @@
/**
* Table — 令牌驱动的数据表格(task 18.8Req 19.1)。
*
* 泛型、受控、纯由 props 驱动。渲染语义化 `<table>`thead/tbody/th/td),全 UI
* 表格外观与交互一致(Req 19.1)。空数据时呈现占位提示行(完整 Empty_State 由
* 后续任务负责)。
*/
import type { CSSProperties, ReactNode } from 'react';
import { colorVar, FONT_FAMILY, space, typographyStyle } from './styles.js';
/** 列对齐方式。 */
export type ColumnAlign = 'left' | 'center' | 'right';
/** 表格列定义。 */
export interface TableColumn<T> {
/** 列唯一键。 */
readonly key: string;
/** 表头内容。 */
readonly header: ReactNode;
/** 单元格对齐,默认 `left`。 */
readonly align?: ColumnAlign;
/** 自定义单元格渲染;优先于 `field`。 */
readonly render?: (row: T) => ReactNode;
/** 取值字段名;未提供 `render` 时按此字段读取并字符串化。 */
readonly field?: keyof T;
}
/** `Table` 组件属性。 */
export interface TableProps<T> {
/** 列定义。 */
readonly columns: ReadonlyArray<TableColumn<T>>;
/** 行数据。 */
readonly data: readonly T[];
/** 行键提取器,保证 React key 稳定。 */
readonly getRowKey: (row: T, index: number) => string | number;
/** 表格标题(`<caption>`),利于可访问性。 */
readonly caption?: string;
/** 空数据时的提示文本,默认「暂无数据」。 */
readonly emptyMessage?: string;
}
/** 读取单元格内容:自定义渲染优先,其次字段取值,最后空。 */
function cellContent<T>(column: TableColumn<T>, row: T): ReactNode {
if (column.render !== undefined) {
return column.render(row);
}
if (column.field !== undefined) {
const value = row[column.field];
return value === null || value === undefined ? '' : String(value);
}
return '';
}
/**
* 语义化数据表格,外观与交互在全 UI 保持一致(Req 19.1)。
*/
export function Table<T>({
columns,
data,
getRowKey,
caption,
emptyMessage = '暂无数据',
}: TableProps<T>): JSX.Element {
const tableStyle: CSSProperties = {
width: '100%',
borderCollapse: 'collapse',
fontFamily: FONT_FAMILY,
...typographyStyle('body'),
color: colorVar('color.text.primary'),
};
const thStyle = (align: ColumnAlign): CSSProperties => ({
textAlign: align,
padding: `${space(2)}px ${space(3)}px`,
borderBottom: `1px solid ${colorVar('color.border.default')}`,
backgroundColor: colorVar('color.bg.surface'),
color: colorVar('color.text.secondary'),
...typographyStyle('caption'),
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.04em',
whiteSpace: 'nowrap',
});
const tdStyle = (align: ColumnAlign): CSSProperties => ({
textAlign: align,
padding: `${space(2)}px ${space(3)}px`,
borderBottom: `1px solid ${colorVar('color.border.default')}`,
});
return (
<table style={tableStyle}>
{caption !== undefined ? (
<caption
style={{
...typographyStyle('caption'),
color: colorVar('color.text.secondary'),
textAlign: 'left',
paddingBottom: `${space(2)}px`,
}}
>
{caption}
</caption>
) : null}
<thead>
<tr>
{columns.map((column) => (
<th key={column.key} scope="col" style={thStyle(column.align ?? 'left')}>
{column.header}
</th>
))}
</tr>
</thead>
<tbody>
{data.length === 0 ? (
<tr>
<td
colSpan={columns.length}
style={{
...tdStyle('center'),
color: colorVar('color.text.secondary'),
padding: `${space(5)}px ${space(3)}px`,
}}
>
{emptyMessage}
</td>
</tr>
) : (
data.map((row, index) => (
<tr key={getRowKey(row, index)}>
{columns.map((column) => (
<td key={column.key} style={tdStyle(column.align ?? 'left')}>
{cellContent(column, row)}
</td>
))}
</tr>
))
)}
</tbody>
</table>
);
}
+103
View File
@@ -0,0 +1,103 @@
/**
* Toast — 令牌驱动的提示组件(task 18.8Req 19.1 / 19.5)。
*
* 以语义化角色暴露提示:成功/信息使用 `role="status"`(礼貌播报),警告/错误使用
* `role="alert"`(即时播报)。状态图标取自单一来源图标集(Req 19.5)。全 UI 提示
* 外观与交互一致(Req 19.1)。
*/
import type { CSSProperties, ReactNode } from 'react';
import type { ColorToken } from '../types.js';
import { colorVar, FONT_FAMILY, RADIUS, space, typographyStyle } from './styles.js';
import { Icon } from './Icon.js';
import type { IconName } from './Icon.js';
/** 提示语义变体。 */
export type ToastVariant = 'info' | 'success' | 'warning' | 'error';
/** `Toast` 组件属性。 */
export interface ToastProps {
/** 提示内容。 */
readonly children: ReactNode;
/** 语义变体,默认 `info`。 */
readonly variant?: ToastVariant;
/** 关闭回调;提供时渲染关闭按钮。 */
readonly onClose?: () => void;
/** 关闭按钮的无障碍标签,默认「关闭」。 */
readonly closeLabel?: string;
}
interface VariantConfig {
readonly icon: IconName;
readonly accent: ColorToken;
}
/** 各变体的图标与强调色令牌(集中定义,确保全局一致)。 */
const VARIANT_CONFIG: Record<ToastVariant, VariantConfig> = {
info: { icon: 'info', accent: 'color.brand.primary' },
success: { icon: 'check', accent: 'color.risk.low' },
warning: { icon: 'warning', accent: 'color.risk.medium' },
error: { icon: 'warning', accent: 'color.risk.critical' },
};
/** 警告/错误需即时播报,使用 `alert`;其余使用 `status`。 */
function roleFor(variant: ToastVariant): 'status' | 'alert' {
return variant === 'warning' || variant === 'error' ? 'alert' : 'status';
}
/**
* 语义化提示。角色随变体在 `status`/`alert` 间切换(Req 19.1)。
*/
export function Toast({
children,
variant = 'info',
onClose,
closeLabel = '关闭',
}: ToastProps): JSX.Element {
const config = VARIANT_CONFIG[variant];
const accent = colorVar(config.accent);
const containerStyle: CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: `${space(2)}px`,
padding: `${space(3)}px ${space(4)}px`,
backgroundColor: colorVar('color.bg.elevated'),
border: `1px solid ${colorVar('color.border.default')}`,
borderLeft: `${space(1)}px solid ${accent}`,
borderRadius: `${RADIUS.sm}px`,
fontFamily: FONT_FAMILY,
color: colorVar('color.text.primary'),
...typographyStyle('body'),
};
const closeButtonStyle: CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
marginLeft: 'auto',
padding: `${space(1)}px`,
background: 'transparent',
border: 'none',
borderRadius: `${RADIUS.sm}px`,
cursor: 'pointer',
color: colorVar('color.text.secondary'),
};
return (
<div role={roleFor(variant)} style={containerStyle}>
<Icon name={config.icon} size={20} color={accent} />
<span style={{ flex: 1 }}>{children}</span>
{onClose !== undefined ? (
<button
type="button"
aria-label={closeLabel}
onClick={onClose}
style={closeButtonStyle}
>
<Icon name="close" size={18} />
</button>
) : null}
</div>
);
}
@@ -0,0 +1,221 @@
/**
* 组件库一致性与单一图标集组件测试(task 18.9Req 19.1 / 19.5)。
*
* 这是 RTL 组件测试(非属性测试)。验证两件事:
* - Req 19.1:同类组件(同 variant/size)在不同「页面」上下文渲染时外观与交互
* 行为一致 —— 相同的解析后内联样式、相同的语义角色与交互回调。
* - Req 19.5UI 全部图标只能经 `Icon` 取自单一来源图标集 `icons`;渲染出的
* `<svg>` 的 `path`/`viewBox` 与 `icons` 一致;未知图标名被拒绝(抛错);
* 使用图标的组件(Dialog 关闭、Nav、Toast 状态)均取自该图标集。
*/
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button, Dialog, Icon, Input, Nav, Toast } from '../index.js';
import type { IconName } from '../index.js';
import { icons } from '../../tokens.js';
/** 读取某元素内首个 `<svg>` 的 path `d` 值,便于与 `icons` 比对。 */
function svgPathOf(element: HTMLElement): string | null {
return element.querySelector('svg path')?.getAttribute('d') ?? null;
}
/** 从单一来源图标集取得某图标,缺失即视为测试前置失败。 */
function requireIcon(name: string): { readonly viewBox: string; readonly path: string } {
const ref = icons[name];
if (ref === undefined) {
throw new Error(`test setup: icon "${name}" missing from the single IconSet`);
}
return ref;
}
describe('组件库一致性(Req 19.1', () => {
it('同一 variant/size 的 Button 在不同页面上下文渲染出一致的解析样式', () => {
// 两个不同的「页面」包裹同样配置的按钮。
function PageA(): JSX.Element {
return (
<section aria-label="page-a">
<Button variant="primary" size="md">
</Button>
</section>
);
}
function PageB(): JSX.Element {
return (
<section aria-label="page-b">
<p></p>
<Button variant="primary" size="md">
</Button>
</section>
);
}
const { unmount } = render(<PageA />);
const buttonA = screen.getByRole('button', { name: '保存' });
const cssA = buttonA.style.cssText;
// 关键视觉值取自令牌:颜色经 CSS 变量引用、字号/行高来自排版层级。
expect(buttonA.style.backgroundColor).toBe('var(--color-brand-primary)');
expect(buttonA.style.color).toBe('var(--color-text-onAccent)');
expect(buttonA.style.fontSize).toBe('14px');
expect(buttonA.style.lineHeight).toBe('22px');
unmount();
render(<PageB />);
const buttonB = screen.getByRole('button', { name: '保存' });
// 不同页面、相同配置 → 完全一致的解析后内联样式。
expect(buttonB.style.cssText).toBe(cssA);
});
it('不同 variant 的 Button 解析样式有别(确认样式确实由令牌驱动而非恒等)', () => {
const { unmount } = render(
<Button variant="primary" size="md">
</Button>,
);
const primaryCss = screen.getByRole('button', { name: '主' }).style.cssText;
unmount();
render(
<Button variant="ghost" size="md">
</Button>,
);
const ghostCss = screen.getByRole('button', { name: '次' }).style.cssText;
expect(ghostCss).not.toBe(primaryCss);
});
it('Button 在不同页面上下文交互行为一致:onClick 触发且角色为 button', async () => {
const user = userEvent.setup();
const onClickA = vi.fn();
const onClickB = vi.fn();
const { unmount } = render(
<section aria-label="ctx-a">
<Button onClick={onClickA}>A</Button>
</section>,
);
await user.click(screen.getByRole('button', { name: 'A' }));
expect(onClickA).toHaveBeenCalledTimes(1);
unmount();
render(
<section aria-label="ctx-b">
<Button onClick={onClickB}>B</Button>
</section>,
);
await user.click(screen.getByRole('button', { name: 'B' }));
expect(onClickB).toHaveBeenCalledTimes(1);
});
it('Input 标签关联与受控交互在不同页面上下文一致', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
<Input id="field-x" label="供应商名称" value="" onChange={onChange} />,
);
// label↔input 关联:可通过标签文本取到输入框。
const input = screen.getByLabelText('供应商名称');
expect(input).toHaveAttribute('id', 'field-x');
await user.type(input, 'a');
expect(onChange).toHaveBeenCalledWith('a');
});
it('Toast 角色随语义变体一致映射(info/success→statuswarning/error→alert', () => {
const cases: ReadonlyArray<{
readonly variant: 'info' | 'success' | 'warning' | 'error';
readonly role: 'status' | 'alert';
}> = [
{ variant: 'info', role: 'status' },
{ variant: 'success', role: 'status' },
{ variant: 'warning', role: 'alert' },
{ variant: 'error', role: 'alert' },
];
for (const { variant, role } of cases) {
const { unmount } = render(<Toast variant={variant}>{variant}</Toast>);
expect(screen.getByRole(role)).toHaveTextContent(variant);
unmount();
}
});
});
describe('单一来源图标集(Req 19.5', () => {
it('Icon 渲染出的 svg path/viewBox 与 icons 图标集一致', () => {
const sampleNames: readonly IconName[] = [
'dashboard',
'warning',
'close',
'check',
'info',
];
for (const name of sampleNames) {
const { container, unmount } = render(<Icon name={name} title={name} />);
const svg = container.querySelector('svg');
const path = container.querySelector('svg path');
const ref = requireIcon(name);
expect(svg).not.toBeNull();
expect(svg?.getAttribute('viewBox')).toBe(ref.viewBox);
expect(path?.getAttribute('d')).toBe(ref.path);
unmount();
}
});
it('Icon 对图标集之外的未知名称予以拒绝(抛错)', () => {
// 绕过编译期 IconName 约束,模拟运行期传入非法图标名的情形。
const unknown = 'definitely-not-an-icon' as IconName;
// 渲染抛错的组件会同步抛出;以 spy 静默 React 的错误日志。
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(() => render(<Icon name={unknown} />)).toThrow(/Unknown icon/);
errorSpy.mockRestore();
});
it('Dialog 关闭按钮的图标取自图标集(path 与 icons.close 一致)', () => {
render(
<Dialog open onClose={() => {}} title="确认">
</Dialog>,
);
const closeButton = screen.getByRole('button', { name: '关闭' });
expect(svgPathOf(closeButton)).toBe(requireIcon('close').path);
});
it('Toast 状态图标取自图标集(success→checkwarning→warning', () => {
const { unmount } = render(<Toast variant="success"></Toast>);
expect(svgPathOf(screen.getByRole('status'))).toBe(requireIcon('check').path);
unmount();
render(<Toast variant="warning"></Toast>);
expect(svgPathOf(screen.getByRole('alert'))).toBe(requireIcon('warning').path);
});
it('Nav 导航项图标取自图标集(path 与对应 icons 项一致)', () => {
render(
<Nav
items={[
{ key: 'home', label: '总览', icon: 'dashboard', active: true },
{ key: 'heat', label: '热力图', icon: 'heatmap' },
]}
/>,
);
const homeItem = screen.getByRole('button', { name: /总览/ });
expect(svgPathOf(homeItem)).toBe(requireIcon('dashboard').path);
const heatItem = screen.getByRole('button', { name: /热力图/ });
expect(svgPathOf(heatItem)).toBe(requireIcon('heatmap').path);
});
it('组件所用的图标名全部存在于单一来源图标集中', () => {
const usedByComponents: readonly string[] = [
'close', // Dialog 关闭、Toast 关闭
'check', // Toast success
'warning', // Toast warning/error
'info', // Toast info
'dashboard', // Nav 示例
'heatmap', // Nav 示例
];
for (const name of usedByComponents) {
expect(Object.prototype.hasOwnProperty.call(icons, name)).toBe(true);
}
});
});
+34
View File
@@ -0,0 +1,34 @@
/**
* 基础组件库公共入口(barreltask 18.8)。
*
* 全 UI 从此处统一引用按钮、表单控件、表格、卡片、对话框、导航与提示组件,
* 保证同类组件在不同页面的外观与交互行为一致(Req 19.1);图标统一经 `Icon`
* 取自单一来源图标集(Req 19.5)。组件视觉值均源自 Design Tokensstyles.ts)。
*/
export { Icon } from './Icon.js';
export type { IconName, IconProps } from './Icon.js';
export { Button } from './Button.js';
export type { ButtonProps, ButtonVariant, ButtonSize } from './Button.js';
export { Input } from './Input.js';
export type { InputProps } from './Input.js';
export { Table } from './Table.js';
export type { TableProps, TableColumn, ColumnAlign } from './Table.js';
export { PaginatedTable } from './PaginatedTable.js';
export type { PaginatedTableProps } from './PaginatedTable.js';
export { Card } from './Card.js';
export type { CardProps } from './Card.js';
export { Dialog } from './Dialog.js';
export type { DialogProps } from './Dialog.js';
export { Nav } from './Nav.js';
export type { NavProps, NavItem } from './Nav.js';
export { Toast } from './Toast.js';
export type { ToastProps, ToastVariant } from './Toast.js';
@@ -0,0 +1,93 @@
/**
* 基础组件库的共享样式原语(task 18.8Req 19.1)。
*
* 所有基础组件**仅**从此模块取得「排版 / 间距 / 配色」的样式来源,从而保证
* 同类组件在全 UI 的外观与交互行为一致(Req 19.1)。配色一律以 CSS 自定义属性
* 引用(`var(--color-...)`),由 ThemeProvider 在切换 Theme 时仅替换取值
* theme-provider.tsx / Req 19.619.7),组件无需重渲染即可随主题更新。
*
* 关键约束:
* - 颜色 → 经 `colorTokenToCssVarName` 映射为 CSS 变量名后以 `var(...)` 引用。
* - 字号 / 行高 → 取自 `typography` 具名层级(Req 19.2),不写散落字面量。
* - 间距 / 圆角 → 取自 `spacing`4 像素基数的整数倍,Req 19.4)。
*/
import type { CSSProperties } from 'react';
import type { ColorToken, TypographyLevel } from '../types.js';
import { colorTokenToCssVarName } from '../theme.js';
import { spacing, typography } from '../tokens.js';
/**
* 将 Color_Token 映射为可用于内联样式的 CSS 变量引用。
* 例如 `color.brand.primary` → `var(--color-brand-primary)`。
*/
export function colorVar(token: ColorToken): string {
return `var(${colorTokenToCssVarName(token)})`;
}
/** Design_System 支持的具名排版层级标识(Req 19.2)。 */
export type TypographyLevelName =
| 'display'
| 'heading'
| 'title'
| 'body'
| 'caption';
/** 具名层级 → 层级定义的查找表(单一来源即 `typography`)。 */
const TYPOGRAPHY_BY_NAME: ReadonlyMap<string, TypographyLevel> = new Map(
typography.map((level) => [level.name, level]),
);
/**
* 返回某具名排版层级对应的内联字体样式(固定字号 + 固定行高,单位 px)。
* @throws 当层级名未在 Design Tokens 中定义时抛出,避免静默退化。
*/
export function typographyStyle(name: TypographyLevelName): CSSProperties {
const level = TYPOGRAPHY_BY_NAME.get(name);
if (level === undefined) {
throw new Error(`Unknown typography level: ${name}`);
}
return { fontSize: `${level.fontSize}px`, lineHeight: `${level.lineHeight}px` };
}
/**
* 返回间距标度中第 `step` 档的取值(px,恒为 4 的整数倍,Req 19.4)。
* @throws 当档位越界时抛出。
*/
export function space(step: number): number {
const value = spacing[step];
if (value === undefined) {
throw new Error(`Spacing step out of range: ${step}`);
}
return value;
}
/** 全 UI 统一字体族(仅排版层级的字号/行高取自令牌;字体族为统一系统字栈)。 */
export const FONT_FAMILY =
"'Inter', 'PingFang SC', 'Microsoft YaHei', system-ui, -apple-system, sans-serif";
/** 统一圆角标度(取自间距基数的整数倍,保持与间距体系一致)。 */
export const RADIUS = {
/** 小圆角:用于徽章、标签等。 */
sm: space(1), // 4px
/** 中圆角:用于输入框、按钮等交互控件。 */
md: space(2), // 8px
/** 大圆角:用于卡片、对话框、提示等表面容器。 */
lg: space(3), // 12px
} as const;
/**
* 统一阴影标度(精致、低强度,Liner 风格以细边框 + 微阴影替代重投影)。
* 取值为静态 rgba 字符串,明暗主题通用(暗色下因表面较深,阴影自然减弱)。
*/
export const SHADOW = {
/** 卡片等表面的细微抬升。 */
sm: '0 1px 2px rgba(16, 24, 40, 0.04), 0 1px 3px rgba(16, 24, 40, 0.06)',
/** 浮层/悬停态的中等抬升。 */
md: '0 4px 12px rgba(16, 24, 40, 0.08), 0 2px 4px rgba(16, 24, 40, 0.04)',
/** 对话框/弹层的强抬升。 */
lg: '0 12px 32px rgba(16, 24, 40, 0.16), 0 4px 8px rgba(16, 24, 40, 0.06)',
} as const;
/** 统一过渡时长,保证同类组件交互反馈节奏一致(Req 19.1)。 */
export const TRANSITION = '150ms ease';
+62
View File
@@ -0,0 +1,62 @@
/**
* Design_System 公共入口(barrel)。
*
* 暴露 Design Tokens 的原始数据、类型与令牌名映射,作为排版/间距/图标/配色的
* 单一事实来源(Req 19.219.6)。Theme Provider 与 `resolveColorToken` 在
* task 18.6 基于这些令牌实现。
*/
export type {
Theme,
ColorValue,
ColorToken,
ThemeColorValue,
RiskGrade,
TypographyLevel,
IconRef,
IconSet,
DesignTokens,
} from './types.js';
export {
THEME_VALUES,
RISK_GRADE_VALUES,
TYPOGRAPHY_LEVEL_NAMES,
typography,
LARGE_TEXT_MIN_FONT_SIZE_PX,
SPACING_BASE,
spacing,
RISK_GRADE_COLOR_TOKENS,
HEAT_COLOR_TOKENS,
HEAT_COLOR_TOKEN_BY_LEVEL,
TEXT_COLOR_TOKENS,
BACKGROUND_COLOR_TOKENS,
colors,
icons,
designTokens,
riskGradeColorToken,
heatColorToken,
} from './tokens.js';
/* ------------------------------------------------------------------ *
* Theme Provider / 取色(task 18.6Req 19.6 / 19.7
* ------------------------------------------------------------------ */
export {
resolveColorToken,
colorTokenToCssVarName,
buildThemeCssVariables,
} from './theme.js';
export { ThemeProvider, useTheme } from './theme-provider.js';
export type {
ThemeContextValue,
ThemeProviderProps,
} from './theme-provider.js';
/* ------------------------------------------------------------------ *
* 基础组件库(task 18.8Req 19.1 / 19.5
* ------------------------------------------------------------------ */
export * from './components/index.js';
+112
View File
@@ -0,0 +1,112 @@
/**
* Theme Provider / Context / `useTheme`Req 19.6 / 19.7task 18.6)。
*
* 设计要点:
* - 当前 Theme 经由 React Context 承载;`setTheme` 切换主题。
* - 切换主题时**仅替换 Color_Token 取值**:把 `buildThemeCssVariables(theme)`
* 生成的「CSS 自定义属性 → 取值」写入根元素内联样式(默认
* `document.documentElement`,即 `:root`)。令牌名不变、组件树不卸载,
* 因此页面已录入数据恒保持不变(Req 19.7 / Property 67)。
* - 全部取色逻辑复用纯函数 `resolveColorToken`theme.ts / Property 66)。
*
* 纯函数与 CSS 变量构造位于 ./theme.ts,本文件只负责 React 集成与 DOM 写入。
*/
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import type { ReactNode } from 'react';
import type { ColorToken, ColorValue, Theme } from './types.js';
import { buildThemeCssVariables, resolveColorToken } from './theme.js';
/** `useTheme` 返回的上下文形态。 */
export interface ThemeContextValue {
/** 当前界面主题(`Light` / `Dark`)。 */
readonly theme: Theme;
/** 切换主题:仅替换令牌取值,保留页面已录入数据。 */
setTheme(theme: Theme): void;
/** 便捷取色:等价于 `resolveColorToken(token, currentTheme)`。 */
resolveToken(token: ColorToken): ColorValue;
}
/**
* Theme 上下文。默认 `null` 用于在 `useTheme` 中检测「Provider 缺失」并报错,
* 避免静默返回错误的默认主题。
*/
const ThemeContext = createContext<ThemeContextValue | null>(null);
ThemeContext.displayName = 'ThemeContext';
/** `ThemeProvider` 属性。 */
export interface ThemeProviderProps {
/** 受 Provider 包裹的子树;主题切换时保持挂载、不重建。 */
readonly children: ReactNode;
/** 初始主题,默认 `Light`。 */
readonly initialTheme?: Theme;
/**
* 承载 CSS 自定义属性的目标元素,默认 `document.documentElement``:root`)。
* 测试中可注入隔离的元素以避免污染全局根。
*/
readonly target?: HTMLElement | null;
}
/**
* 将某主题的全部 Color_Token 取值写为目标元素上的 CSS 自定义属性。
* 仅设置变量取值,不移除既有内联样式,保证「仅替换令牌取值」语义。
*/
function applyThemeVariables(target: HTMLElement, theme: Theme): void {
const variables = buildThemeCssVariables(theme);
for (const [name, value] of Object.entries(variables)) {
target.style.setProperty(name, value);
}
}
/**
* Theme Provider:持有当前主题,并把对应的 Color_Token 取值以 CSS 变量写入
* 根元素。切换主题只更新变量取值,子树始终挂载,因此已录入数据不丢失。
*/
export function ThemeProvider({
children,
initialTheme = 'Light',
target = null,
}: ThemeProviderProps): JSX.Element {
const [theme, setTheme] = useState<Theme>(initialTheme);
// 主题变更时仅刷新 CSS 变量取值(不卸载/重建子树)。
useEffect(() => {
const root =
target ?? (typeof document !== 'undefined' ? document.documentElement : null);
if (root === null) {
return;
}
applyThemeVariables(root, theme);
}, [theme, target]);
const resolveToken = useCallback(
(token: ColorToken): ColorValue => resolveColorToken(token, theme),
[theme],
);
const value = useMemo<ThemeContextValue>(
() => ({ theme, setTheme, resolveToken }),
[theme, resolveToken],
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
/**
* 读取当前主题上下文。必须在 `ThemeProvider` 内调用。
* @throws 当不在 `ThemeProvider` 子树内调用时抛出。
*/
export function useTheme(): ThemeContextValue {
const context = useContext(ThemeContext);
if (context === null) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}
+69
View File
@@ -0,0 +1,69 @@
/**
* 主题取色与 CSS 变量映射(Req 19.6 / 19.7task 18.6)。
*
* 本模块为**纯函数**实现(无 React、无 DOM 副作用),供:
* - `resolveColorToken(token, theme)`:确定性返回令牌在某主题下的取值
* design.md / Property 66)。
* - `colorTokenToCssVarName(token)`:将令牌名确定性映射为 CSS 自定义属性名
* (如 `color.risk.low` → `--color-risk-low`)。
* - `buildThemeCssVariables(theme)`:构造某主题下全部令牌的 CSS 变量名→取值表,
* 由 Theme Providertheme.tsx)写入根元素,切换主题时仅替换取值。
*
* `riskGradeColorToken(grade)` 已在 tokens.ts 实现,此处仅再导出,不重复实现。
*/
import type { ColorToken, ColorValue, Theme } from './types.js';
import { colors } from './tokens.js';
export { riskGradeColorToken } from './tokens.js';
/**
* 确定性返回某 Color_Token 在指定 Theme 下的取值(Property 66 / Req 19.6)。
*
* 取值仅由令牌的双主题定义与所选主题决定,不依赖任何外部可变状态;对同一
* `(token, theme)` 入参恒返回同一取值。
*
* @param token 配色令牌名(如 `color.risk.low`)。
* @param theme 目标主题(`Light` 或 `Dark`)。
* @returns 该令牌在所选主题下的具体取值。
* @throws 当令牌名未在 Design Tokens 中定义时抛出,避免静默返回 `undefined`。
*/
export function resolveColorToken(token: ColorToken, theme: Theme): ColorValue {
const value = colors[token];
if (value === undefined) {
throw new Error(`Unknown Color_Token: ${token}`);
}
return theme === 'Light' ? value.light : value.dark;
}
/**
* 将 Color_Token 名确定性映射为 CSS 自定义属性名。
*
* 规则:以 `--` 前缀,并将令牌名中的点 `.` 替换为连字符 `-`。
* 例如 `color.risk.low` → `--color-risk-low`、`color.heat.1` → `--color-heat-1`。
*
* @param token 配色令牌名。
* @returns 对应的 CSS 自定义属性名(含 `--` 前缀)。
*/
export function colorTokenToCssVarName(token: ColorToken): string {
return `--${token.replace(/\./g, '-')}`;
}
/**
* 构造某主题下「全部 Color_Token」的 CSS 自定义属性名 → 取值映射。
*
* Theme Provider 在主题变更时把该映射写到根元素的内联样式上,从而「仅替换令牌
* 取值」而不改变令牌名、不重建组件树(Req 19.7:保留页面已录入数据)。
*
* @param theme 目标主题。
* @returns CSS 变量名(如 `--color-risk-low`)→ 该主题取值 的映射。
*/
export function buildThemeCssVariables(
theme: Theme,
): Readonly<Record<string, ColorValue>> {
const variables: Record<string, ColorValue> = {};
for (const token of Object.keys(colors)) {
variables[colorTokenToCssVarName(token)] = resolveColorToken(token, theme);
}
return variables;
}
+349
View File
@@ -0,0 +1,349 @@
/**
* Design Tokens — 排版 / 间距 / 图标 / Color_TokenReq 19.2, 19.3, 19.4, 19.5, 19.6)。
*
* 这是 Design_System 的单一事实来源(pure data + types,无 React)。下游消费:
* - Theme Provider / `resolveColorToken`task 18.6,按 Theme 选 light/dark
* - 基础组件库(task 18.8)、图表库(task 19
* - 对比度 / 可访问性校验(task 22.x / Property 77
*
* 关键不变式(供 task 18.3/18.4/18.5 属性测试验证):
* - Property 63`typography` 恒 ≥4 级,每级具名且字号/行高固定。
* - Property 64:四级 Risk_Grade 各映射唯一稳定令牌名,且四者互不相同。
* - Property 65`spacing` 每个取值恒为 4 的非负整数倍。
* - Property 66/77:每个 Color_Token 含 light/dark 取值;正文/大号文本与背景
* 的取值对在两主题下均满足 WCAG AA 对比度。
*/
import type {
ColorToken,
DesignTokens,
IconRef,
IconSet,
RiskGrade,
ThemeColorValue,
TypographyLevel,
} from './types.js';
/** 全部主题取值(Req 19.6)。 */
export const THEME_VALUES = ['Light', 'Dark'] as const;
/** 全部 Risk_Grade 取值(按严重度升序),与领域层一致。 */
export const RISK_GRADE_VALUES = ['低', '中', '高', '极高'] as const;
/* ------------------------------------------------------------------ *
* TypographyReq 19.2):≥4 级具名层级,固定字号(px)与固定行高(px)。
* ------------------------------------------------------------------ */
/** 排版层级具名标识。 */
export const TYPOGRAPHY_LEVEL_NAMES = [
'display',
'heading',
'title',
'body',
'caption',
] as const;
/**
* 排版层级表:5 级(≥4),每级具名 + 固定字号 + 固定行高(单位 px)。
* 行高以固定像素值表示,确保"固定行高"语义(非相对单位)。
*/
export const typography: readonly TypographyLevel[] = [
{ name: 'display', fontSize: 28, lineHeight: 36 },
{ name: 'heading', fontSize: 22, lineHeight: 30 },
{ name: 'title', fontSize: 18, lineHeight: 26 },
{ name: 'body', fontSize: 14, lineHeight: 22 },
{ name: 'caption', fontSize: 12, lineHeight: 16 },
] as const;
/**
* "正文"与"大号文本"的判定阈值(用于 WCAG 文本对比度分级,Property 77)。
* WCAG:≥18pt(≈24px)常规字重,或 ≥14pt(≈18.66px)粗体,视为大号文本。
* 此处以字号 ≥ 24px 作为大号文本阈值的保守取值。
*/
export const LARGE_TEXT_MIN_FONT_SIZE_PX = 24;
/* ------------------------------------------------------------------ *
* SpacingReq 19.4):以 4 像素为基数,取值均为 4 的非负整数倍。
* ------------------------------------------------------------------ */
/** 间距基数(px)。 */
export const SPACING_BASE = 4;
/** 间距标度(px):每个取值均为 SPACING_BASE 的非负整数倍。 */
export const spacing: readonly number[] = [
0, 4, 8, 12, 16, 20, 24, 32, 40, 48, 64, 96,
] as const;
/* ------------------------------------------------------------------ *
* Color_TokenReq 19.3, 19.6):稳定具名令牌,每令牌含 Light/Dark 双主题取值。
* ------------------------------------------------------------------ */
/**
* Risk_Grade → Color_Token 名映射(Req 19.3 / Property 64)。
* 四级各映射唯一稳定令牌名,且四者互不相同;全 UI(Risk_Badge、热力图、
* Top N 关键风险图)对同一 Risk_Grade 一致使用其对应令牌。
*/
export const RISK_GRADE_COLOR_TOKENS = {
: 'color.risk.low',
: 'color.risk.medium',
: 'color.risk.high',
: 'color.risk.critical',
} as const satisfies Record<RiskGrade, ColorToken>;
/** 热力图顺序色令牌名(color.heat.1 .. color.heat.5Req 20 / 23.6)。 */
export const HEAT_COLOR_TOKENS = [
'color.heat.1',
'color.heat.2',
'color.heat.3',
'color.heat.4',
'color.heat.5',
] as const;
/** 热力图严重度档位(1..5)→ 顺序色令牌名映射。 */
export const HEAT_COLOR_TOKEN_BY_LEVEL = {
1: 'color.heat.1',
2: 'color.heat.2',
3: 'color.heat.3',
4: 'color.heat.4',
5: 'color.heat.5',
} as const satisfies Record<1 | 2 | 3 | 4 | 5, ColorToken>;
/**
* 文本类令牌名。正文文本从这些令牌取色,与 `BACKGROUND_COLOR_TOKENS` 组成
* 对比度校验的取值对(Property 77)。
*/
export const TEXT_COLOR_TOKENS = [
'color.text.primary',
'color.text.secondary',
'color.text.inverse',
'color.text.onAccent',
] as const;
/** 背景类令牌名(页面/卡片/浮层等表面)。 */
export const BACKGROUND_COLOR_TOKENS = [
'color.bg.canvas',
'color.bg.surface',
'color.bg.elevated',
] as const;
/**
* 全部 Color_Token 的双主题取值(Req 19.6)。
*
* 文本/背景取值经选取以满足 WCAG AAProperty 77):
* - 正文(≥4.5:1):text.primary / text.secondary 配 bg.canvas / bg.surface /
* bg.elevated,两主题均达标;text.onAccent 配 brand.primary 达标。
* - text.inverse 用于深色表面上的浅色正文。
* Risk_Grade 与热力图令牌为语义/数据编码色,辅以文字标签与图案(Req 23.6)。
*/
export const colors: Readonly<Record<ColorToken, ThemeColorValue>> = {
// —— 文本 ——
'color.text.primary': { light: '#14151A', dark: '#F4F5F7' },
'color.text.secondary': { light: '#565E6C', dark: '#A2AAB8' },
'color.text.inverse': { light: '#FFFFFF', dark: '#0B0D12' },
'color.text.onAccent': { light: '#FFFFFF', dark: '#0E1018' },
// —— 背景/表面 ——
'color.bg.canvas': { light: '#F7F8FA', dark: '#0B0D12' },
'color.bg.surface': { light: '#EDEFF3', dark: '#14161D' },
'color.bg.elevated': { light: '#FFFFFF', dark: '#191C24' },
// —— 边框/品牌 ——
'color.border.default': { light: '#E4E7EC', dark: '#2A2F3A' },
'color.brand.primary': { light: '#4F46E5', dark: '#A5B0F5' },
// —— Risk_Grade 语义色(Req 19.3)——
'color.risk.low': { light: '#15803D', dark: '#4ADE80' },
'color.risk.medium': { light: '#B45309', dark: '#FBBF24' },
'color.risk.high': { light: '#C2410C', dark: '#FB923C' },
'color.risk.critical': { light: '#BE123C', dark: '#FB7185' },
// —— 热力图顺序色(Req 20 / 23.6)——
'color.heat.1': { light: '#15803D', dark: '#BBF7D0' },
'color.heat.2': { light: '#4D7C0F', dark: '#D9F99D' },
'color.heat.3': { light: '#B45309', dark: '#FED7AA' },
'color.heat.4': { light: '#C2410C', dark: '#FDBA74' },
'color.heat.5': { light: '#B91C1C', dark: '#FCA5A5' },
};
/* ------------------------------------------------------------------ *
* IconSetReq 19.5):单一来源图标集,全 UI 图标从该集合引用。
* 24×24 viewBoxpath 为 SVG `d` 数据(轮廓化的常用界面图标)。
* ------------------------------------------------------------------ */
const ICON_REFS: readonly IconRef[] = [
{
name: 'dashboard',
viewBox: '0 0 24 24',
path: 'M3 3h8v8H3V3zm10 0h8v5h-8V3zM3 13h8v8H3v-8zm10 3h8v5h-8v-5z',
},
{
name: 'heatmap',
viewBox: '0 0 24 24',
path: 'M3 3h6v6H3V3zm0 12h6v6H3v-6zM15 3h6v6h-6V3zm0 12h6v6h-6v-6zM9 9h6v6H9V9z',
},
{
name: 'risk',
viewBox: '0 0 24 24',
path: 'M12 2 1 21h22L12 2zm0 5 7.5 13h-15L12 7zm-1 4v4h2v-4h-2zm0 6v2h2v-2h-2z',
},
{
name: 'warning',
viewBox: '0 0 24 24',
path: 'M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z',
},
{
name: 'info',
viewBox: '0 0 24 24',
path: 'M12 2a10 10 0 100 20 10 10 0 000-20zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z',
},
{
name: 'check',
viewBox: '0 0 24 24',
path: 'M9 16.2 4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4L9 16.2z',
},
{
name: 'close',
viewBox: '0 0 24 24',
path: 'M19 6.4 17.6 5 12 10.6 6.4 5 5 6.4 10.6 12 5 17.6 6.4 19 12 13.4 17.6 19 19 17.6 13.4 12 19 6.4z',
},
{
name: 'menu',
viewBox: '0 0 24 24',
path: 'M3 6h18v2H3V6zm0 5h18v2H3v-2zm0 5h18v2H3v-2z',
},
{
name: 'chevron-down',
viewBox: '0 0 24 24',
path: 'M7.4 8.6 12 13.2l4.6-4.6L18 10l-6 6-6-6 1.4-1.4z',
},
{
name: 'search',
viewBox: '0 0 24 24',
path: 'M15.5 14h-.8l-.3-.3a6.5 6.5 0 10-.7.7l.3.3v.8l5 5 1.5-1.5-5-5zm-6 0a4.5 4.5 0 110-9 4.5 4.5 0 010 9z',
},
{
name: 'export',
viewBox: '0 0 24 24',
path: 'M5 20h14v-2H5v2zM12 2 6 8l1.4 1.4L11 5.8V16h2V5.8l3.6 3.6L18 8l-6-6z',
},
{
name: 'settings',
viewBox: '0 0 24 24',
path: 'M12 8a4 4 0 100 8 4 4 0 000-8zm9 4a7 7 0 00-.1-1.2l2-1.6-2-3.4-2.4 1a7 7 0 00-2-1.2l-.4-2.6h-4l-.4 2.6a7 7 0 00-2 1.2l-2.4-1-2 3.4 2 1.6A7 7 0 003 12c0 .4 0 .8.1 1.2l-2 1.6 2 3.4 2.4-1c.6.5 1.3.9 2 1.2l.4 2.6h4l.4-2.6c.7-.3 1.4-.7 2-1.2l2.4 1 2-3.4-2-1.6c.1-.4.1-.8.1-1.2z',
},
{
name: 'trending-up',
viewBox: '0 0 24 24',
path: 'M16 6l2.29 2.29-4.88 4.88-4-4L2 16.59 3.41 18l6-6 4 4 6.3-6.29L22 12V6h-6z',
},
{
name: 'clock',
viewBox: '0 0 24 24',
path: 'M12 2a10 10 0 100 20 10 10 0 000-20zm0 18a8 8 0 110-16 8 8 0 010 16zm.5-13H11v6l5.2 3.2.8-1.3-4.5-2.7V7z',
},
{
name: 'alert',
viewBox: '0 0 24 24',
path: 'M12 2a10 10 0 100 20 10 10 0 000-20zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z',
},
{
name: 'check-circle',
viewBox: '0 0 24 24',
path: 'M12 2a10 10 0 100 20 10 10 0 000-20zm-2 15-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z',
},
{
name: 'edit',
viewBox: '0 0 24 24',
path: 'M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 000-1.41l-2.34-2.34a1 1 0 00-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z',
},
{
name: 'file',
viewBox: '0 0 24 24',
path: 'M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zm4 18H6V4h7v5h5v11z',
},
{
name: 'save',
viewBox: '0 0 24 24',
path: 'M17 3H5a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2V7l-4-4zm-5 16a3 3 0 110-6 3 3 0 010 6zm3-10H5V5h10v4z',
},
{
name: 'refresh',
viewBox: '0 0 24 24',
path: 'M17.65 6.35A8 8 0 1019.73 14h-2.08A6 6 0 1112 6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z',
},
{
name: 'sparkles',
viewBox: '0 0 24 24',
path: 'M12 2l2.4 5.6L20 10l-5.6 2.4L12 18l-2.4-5.6L4 10l5.6-2.4L12 2zm6 12l1 2.3 2.3 1-2.3 1-1 2.3-1-2.3-2.3-1 2.3-1 1-2.3z',
},
{
name: 'money',
viewBox: '0 0 24 24',
path: 'M12 2a10 10 0 100 20 10 10 0 000-20zm3 9h-2v1h2v2h-2v2h-2v-2H9v-2h2v-1H9V9h1.4L8.3 5.6 10 4.8 12 8l2-3.2 1.7.8L13.6 9H15v2z',
},
{
name: 'user',
viewBox: '0 0 24 24',
path: 'M12 12a5 5 0 100-10 5 5 0 000 10zm0 2c-5 0-9 2.5-9 5.5V22h18v-2.5c0-3-4-5.5-9-5.5z',
},
{
name: 'chart',
viewBox: '0 0 24 24',
path: 'M4 13h3v7H4v-7zm6.5-6h3v13h-3V7zM17 10h3v10h-3V10z',
},
{
name: 'lightbulb',
viewBox: '0 0 24 24',
path: 'M9 21h6v-1H9v1zm3-19a7 7 0 00-4 12.7V17h8v-2.3A7 7 0 0012 2zm2 11.2-.6.4V15h-2.8v-1.4l-.6-.4a5 5 0 114 0z',
},
{
name: 'plus',
viewBox: '0 0 24 24',
path: 'M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z',
},
{
name: 'arrow-right',
viewBox: '0 0 24 24',
path: 'M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8-8-8z',
},
{
name: 'ban',
viewBox: '0 0 24 24',
path: 'M12 2a10 10 0 100 20 10 10 0 000-20zM4 12a8 8 0 0112.9-6.3L5.7 16.9A7.96 7.96 0 014 12zm8 8a7.96 7.96 0 01-4.9-1.7L18.3 7.1A8 8 0 0112 20z',
},
];
/** 单一来源图标集:图标名 → 图标引用(Req 19.5)。 */
export const icons: IconSet = Object.freeze(
Object.fromEntries(ICON_REFS.map((ref) => [ref.name, ref])),
);
/* ------------------------------------------------------------------ *
* 令牌总集 + 映射函数
* ------------------------------------------------------------------ */
/** Design_System 令牌总集(design.mdDesignTokens)。 */
export const designTokens: DesignTokens = {
colors,
typography,
spacing,
icons,
};
/**
* 返回某 Risk_Grade 对应的唯一稳定 Color_Token 名(Req 19.3 / Property 64)。
* 全 UI 对同一 Risk_Grade 一致取得同一令牌名;四级互不相同。
*
* 注:实际取色由 task 18.6 的 `resolveColorToken(token, theme)` 完成。
*/
export function riskGradeColorToken(grade: RiskGrade): ColorToken {
return RISK_GRADE_COLOR_TOKENS[grade];
}
/**
* 返回热力图严重度档位(1..5)对应的顺序色 Color_Token 名(Req 20 / 23.6)。
* @param level 1 至 5 的整数严重度档位。
*/
export function heatColorToken(level: 1 | 2 | 3 | 4 | 5): ColorToken {
return HEAT_COLOR_TOKEN_BY_LEVEL[level];
}
+90
View File
@@ -0,0 +1,90 @@
/**
* Design_System token type vocabulary (Req 19.219.6).
*
* Pure types only — no runtime values and no React. These types describe the
* single source of truth that downstream layers consume:
* - Theme Provider / `resolveColorToken` (task 18.6)
* - Base component library (task 18.8)
* - Charts (task 19)
* - Contrast / a11y checks (task 22.x)
*
* The web layer is its own bounded context; `RiskGrade` is mirrored here from
* the domain's four-grade vocabulary (`src/domain/common.ts`) so the design
* system stays self-contained under `web/tsconfig.json` (rootDir = `web/`).
*/
/** 界面主题(Req 19.6):明亮 / 暗黑。 */
export type Theme = 'Light' | 'Dark';
/**
* 具体颜色取值。首版以 7 位十六进制(`#RRGGBB`)表示,便于 WCAG 对比度计算
* task 22.x / Property 77)与 CSS 自定义属性承载(task 18.6)。
*/
export type ColorValue = string;
/**
* 配色令牌名:Design_System 中具名且取值稳定的颜色变量(Req 19.3)。
* 例如 `color.risk.low`、`color.heat.1`、`color.text.primary`。
*/
export type ColorToken = string;
/**
* 单个 Color_Token 的双主题取值(Req 19.6):每个令牌必含 Light 与 Dark 两套取值,
* 切换 Theme 时仅替换取值、令牌名不变。
*/
export interface ThemeColorValue {
/** 明亮(Light)主题下的取值。 */
readonly light: ColorValue;
/** 暗黑(Dark)主题下的取值。 */
readonly dark: ColorValue;
}
/**
* 风险分级(四级互斥且完备,Req 5)。与领域层 `RiskGrade` 取值一致:
* [0,25]→低、(25,50]→中、(50,75]→高、(75,100]→极高。
*/
export type RiskGrade = '低' | '中' | '高' | '极高';
/**
* 具名排版层级(Req 19.2):具名标识 + 固定字号 + 固定行高。
* 字号与行高均以 CSS 像素(px)的固定数值表示(非相对/可变单位)。
*/
export interface TypographyLevel {
/** 具名标识,如 `body`、`heading`。全 UI 文本从该层级取值。 */
readonly name: string;
/** 固定字号(px)。 */
readonly fontSize: number;
/** 固定行高(px)。 */
readonly lineHeight: number;
}
/**
* 单一来源图标集中的一个图标引用(Req 19.5)。
* 以 SVG `viewBox` + 路径数据承载,保证全 UI 图标取自同一来源。
*/
export interface IconRef {
/** 图标名(图标集内唯一)。 */
readonly name: string;
/** SVG 视口,如 `0 0 24 24`。 */
readonly viewBox: string;
/** SVG `path` 的 `d` 数据。 */
readonly path: string;
}
/** 单一来源图标集:图标名 → 图标引用(Req 19.5)。 */
export type IconSet = Readonly<Record<string, IconRef>>;
/**
* Design_System 令牌总集(design.mdDesignTokens)。
* 这是排版/间距/图标/配色的单一事实来源,供后续任务消费。
*/
export interface DesignTokens {
/** 每个 Color_Token 的双主题取值(Req 19.6)。 */
readonly colors: Readonly<Record<ColorToken, ThemeColorValue>>;
/** ≥4 级具名排版层级,各含固定字号与固定行高(Req 19.2)。 */
readonly typography: readonly TypographyLevel[];
/** 以 4 像素为基数的间距标度,取值均为 4 的非负整数倍(Req 19.4)。 */
readonly spacing: readonly number[];
/** 单一来源图标集(Req 19.5)。 */
readonly icons: IconSet;
}
+157
View File
@@ -0,0 +1,157 @@
/**
* OperationFeedbackView — 操作反馈展示组件(task 23.1Req 24.124.3)。
*
* 将 `OperationFeedback` 渲染为可访问的 UI 反馈,全部组件取自 Design_SystemReq 19.1),
* 图标统一经 `Icon` 取自单一来源图标集(Req 19.5):
* - `idle` → 不呈现任何反馈(返回 `null`)。
* - `loading` → Loading_State:以 `role="status"`(礼貌播报)呈现进行中文本,并附一个
* 装饰性旋转图标作为加载指示(Req 24.1)。
* - `success` → 成功反馈:`Toast variant="success"``role="status"`)(Req 24.2)。
* - `error` → 错误反馈:`Toast variant="error"``role="alert"`,即时播报)呈现可读
* 错误信息,并以 `Button` 呈现指向修正路径的操作入口(Req 24.3)。
*
* 视觉值统一取自 Design Tokens(颜色经 CSS 变量引用,随 ThemeProvider 切换)。
* 纯映射逻辑位于 `feedback.ts`(供 Property 80 验证),本组件仅负责渲染。
*/
import type { CSSProperties } from 'react';
import {
Button,
Icon,
Toast,
colorTokenToCssVarName,
spacing,
typography,
} from '../design-system/index.js';
import type { ColorToken } from '../design-system/index.js';
import type { OperationFeedback } from './feedback.js';
/* ------------------------------------------------------------------ *
* 局部样式原语(取自 Design Tokens,与 Wizard 等表现层一致)
* ------------------------------------------------------------------ */
/** 将 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` };
}
/* ------------------------------------------------------------------ *
* 组件
* ------------------------------------------------------------------ */
/** `OperationFeedbackView` 组件属性。 */
export interface OperationFeedbackViewProps {
/** 待呈现的操作反馈(由 `feedbackFor` / `runOperation` 产出)。 */
readonly feedback: OperationFeedback;
/** 点击修正路径操作入口的回调;入参为 `correctiveAction.target`Req 24.3)。 */
readonly onCorrectiveAction?: (target: string) => void;
/** 成功/错误反馈的关闭回调;提供时渲染关闭按钮。 */
readonly onDismiss?: () => void;
}
/**
* 依据 `feedback.state` 渲染对应的可访问操作反馈(Req 24.1–24.3)。
*/
export function OperationFeedbackView({
feedback,
onCorrectiveAction,
onDismiss,
}: OperationFeedbackViewProps): JSX.Element | null {
switch (feedback.state) {
case 'idle':
return null;
case 'loading': {
const loadingStyle: CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
gap: `${space(2)}px`,
fontFamily: FONT_FAMILY,
color: colorVar('color.text.secondary'),
...typographyStyle('body'),
};
return (
<div
role="status"
aria-live="polite"
data-feedback-state="loading"
style={loadingStyle}
>
{/* keyframes 注入(仅一次声明即可全局复用)。 */}
<style>
{'@keyframes orx-feedback-spin{to{transform:rotate(360deg)}}'}
</style>
<span
aria-hidden={true}
data-feedback-spinner="true"
style={{
display: 'inline-flex',
animation: 'orx-feedback-spin 0.8s linear infinite',
}}
>
<Icon name="settings" size={18} color={colorVar('color.brand.primary')} />
</span>
<span>{feedback.message}</span>
</div>
);
}
case 'success':
return (
<div data-feedback-state="success">
<Toast variant="success" {...(onDismiss !== undefined ? { onClose: onDismiss } : {})}>
{feedback.message}
</Toast>
</div>
);
case 'error': {
const action = feedback.correctiveAction;
const messageStyle: CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: `${space(2)}px`,
};
return (
<div data-feedback-state="error">
<Toast variant="error" {...(onDismiss !== undefined ? { onClose: onDismiss } : {})}>
<span style={messageStyle}>
<span>{feedback.message}</span>
{action !== undefined ? (
<span data-feedback-corrective-target={action.target}>
<Button
variant="secondary"
size="sm"
iconLeft="settings"
{...(onCorrectiveAction !== undefined
? { onClick: (): void => onCorrectiveAction(action.target) }
: {})}
>
{action.label}
</Button>
</span>
) : null}
</span>
</Toast>
</div>
);
}
}
}
@@ -0,0 +1,56 @@
/**
* OperationFeedbackView 组件测试(task 23.1Req 24.124.3)。
*
* RTL 组件测试(非属性测试)。验证各状态渲染出正确的可访问语义与修正路径入口。
*/
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { OperationFeedbackView, feedbackFor } from '../index.js';
describe('OperationFeedbackViewReq 24.124.3', () => {
it('idle → 不渲染任何反馈', () => {
const { container } = render(
<OperationFeedbackView feedback={feedbackFor('idle')} />,
);
expect(container.firstChild).toBeNull();
});
it('loading → 以 role=status 呈现 Loading_State 与进行中文本(Req 24.1', () => {
render(
<OperationFeedbackView feedback={feedbackFor('loading', { operationName: '保存' })} />,
);
const status = screen.getByRole('status');
expect(status).toHaveAttribute('data-feedback-state', 'loading');
expect(status.textContent ?? '').toContain('保存');
});
it('success → 以 role=status 呈现成功反馈(Req 24.2', () => {
render(
<OperationFeedbackView feedback={feedbackFor('success', { operationName: '提交' })} />,
);
const status = screen.getByRole('status');
expect(status.textContent ?? '').toContain('成功');
});
it('error → 以 role=alert 呈现错误信息与修正路径按钮,并触发回调(Req 24.3)', async () => {
const onCorrectiveAction = vi.fn();
render(
<OperationFeedbackView
feedback={feedbackFor('error', {
operationName: '导出',
cause: '网络错误',
correctiveAction: { label: '重新导出', target: '/export' },
})}
onCorrectiveAction={onCorrectiveAction}
/>,
);
const alert = screen.getByRole('alert');
expect(alert.textContent ?? '').toContain('网络错误');
const button = screen.getByRole('button', { name: '重新导出' });
await userEvent.click(button);
expect(onCorrectiveAction).toHaveBeenCalledWith('/export');
});
});
@@ -0,0 +1,182 @@
/**
* Property 80: 操作状态反馈映射 的属性化测试(Req 24.1–24.3)。
*
* 属性陈述:`runOperation` 的状态映射恒正确:
* - 进行中 → Loading_Stateloading 反馈,含非空进行中文本)(Req 24.1)。
* - 成功 → 成功反馈(success 状态,含非空成功文本)(Req 24.2)。
* - 失败 → 可读且非空的错误信息(说明失败原因)+ 指向修正路径的修正入口
* `correctiveAction`label 与 target 均非空)(Req 24.3)。
*
* 测试策略:以任意操作名称、修正入口与「成功/失败(携带任意失败原因)」结果驱动
* `runOperation`,并断言:
* - 回调序列恒为 loading→success 或 loading→error(先呈现 Loading_State)。
* - 终态反馈状态与操作结果一致。
* - error 终态恒携带非空可读 message(含失败原因)与非空 correctiveAction。
* - 直接验证纯映射器 `feedbackFor`:error 分支无论上下文是否提供(含空白/缺省),
* 恒产出非空 message 与非空 label+target 的修正入口。
*
* Feature: outsourcing-risk-assessment, Property 80: 操作状态反馈映射
* Validates: Requirements 24.1, 24.2, 24.3
*/
import { describe, expect, it } from 'vitest';
import fc from 'fast-check';
import {
feedbackFor,
runOperation,
type CorrectiveAction,
type OperationFeedback,
} from '../index.js';
// ----------------------------------------------------------------------------
// 生成器
// ----------------------------------------------------------------------------
/** 任意操作名称(含空字符串与纯空白,覆盖回退到默认名的路径)。 */
const operationNameArb: fc.Arbitrary<string> = fc.oneof(
fc.string({ maxLength: 12 }),
fc.constantFrom('', ' ', '\t', '保存草稿', '提交评估'),
);
/** 任意失败原因消息(含空白,覆盖回退到默认原因的路径)。 */
const causeArb: fc.Arbitrary<string> = fc.oneof(
fc.string({ maxLength: 24 }),
fc.constantFrom('', ' ', '网络连接超时', '磁盘已满'),
);
/** 任意修正入口(label/target 可能为空白,覆盖回退到默认入口的路径)。 */
const correctiveActionArb: fc.Arbitrary<CorrectiveAction> = fc.record({
label: fc.oneof(fc.string({ maxLength: 12 }), fc.constantFrom('', ' ', '检查网络后重试')),
target: fc.oneof(fc.string({ maxLength: 12 }), fc.constantFrom('', ' ', '/retry', '#field')),
});
/** 可选上下文片段:operationName / correctiveAction 可能缺省。 */
const optionalNameArb = fc.option(operationNameArb, { nil: undefined });
const optionalActionArb = fc.option(correctiveActionArb, { nil: undefined });
/** 断言修正入口非空(label 与 target 去空白后均非空)。 */
function expectNonEmptyCorrectiveAction(action: CorrectiveAction | undefined): void {
expect(action).toBeDefined();
expect((action?.label ?? '').trim().length).toBeGreaterThan(0);
expect((action?.target ?? '').trim().length).toBeGreaterThan(0);
}
describe('Property 80: 操作状态反馈映射', () => {
it('成功操作:回调序列为 loading→success,终态为 success 且含非空成功文本(Req 24.1/24.2', async () => {
await fc.assert(
fc.asyncProperty(optionalNameArb, optionalActionArb, async (operationName, correctiveAction) => {
const seen: OperationFeedback[] = [];
const result = await runOperation(async () => 42, {
...(operationName !== undefined ? { operationName } : {}),
...(correctiveAction !== undefined ? { correctiveAction } : {}),
onFeedback: (fb) => seen.push(fb),
});
// 先呈现 Loading_StateReq 24.1),随后呈现 successReq 24.2)。
expect(seen.map((f) => f.state)).toEqual(['loading', 'success']);
expect((seen[0]?.message ?? '').length).toBeGreaterThan(0);
expect(result.state).toBe('success');
expect((result.message ?? '').length).toBeGreaterThan(0);
// 成功反馈不携带修正入口。
expect(result.correctiveAction).toBeUndefined();
}),
{ numRuns: 100 },
);
});
it('失败操作:回调序列为 loading→error,终态含非空可读原因 + 非空修正入口(Req 24.1/24.3', async () => {
await fc.assert(
fc.asyncProperty(
optionalNameArb,
optionalActionArb,
causeArb,
async (operationName, correctiveAction, cause) => {
const seen: OperationFeedback[] = [];
const result = await runOperation(
async () => {
throw new Error(cause);
},
{
...(operationName !== undefined ? { operationName } : {}),
...(correctiveAction !== undefined ? { correctiveAction } : {}),
onFeedback: (fb) => seen.push(fb),
},
);
// 先呈现 Loading_StateReq 24.1),随后呈现 errorReq 24.3)。
expect(seen.map((f) => f.state)).toEqual(['loading', 'error']);
// error 终态恒携带非空可读信息与非空修正入口(Req 24.3)。
expect(result.state).toBe('error');
expect((result.message ?? '').trim().length).toBeGreaterThan(0);
expectNonEmptyCorrectiveAction(result.correctiveAction);
// 失败原因可读地体现在信息中:非空原因须被纳入信息文本。
const trimmedCause = cause.trim();
if (trimmedCause.length > 0) {
expect(result.message).toContain(trimmedCause);
}
},
),
{ numRuns: 100 },
);
});
it('feedbackFor:任意状态映射正确,error 分支恒非空信息 + 非空修正入口(Req 24.124.3', () => {
fc.assert(
fc.property(
fc.constantFrom('idle', 'loading', 'success', 'error') as fc.Arbitrary<
OperationFeedback['state']
>,
optionalNameArb,
optionalActionArb,
causeArb,
(state, operationName, correctiveAction, cause) => {
const fb = feedbackFor(state, {
...(operationName !== undefined ? { operationName } : {}),
...(correctiveAction !== undefined ? { correctiveAction } : {}),
cause,
});
expect(fb.state).toBe(state);
switch (state) {
case 'idle':
expect(fb.message).toBeUndefined();
expect(fb.correctiveAction).toBeUndefined();
break;
case 'loading':
case 'success':
expect((fb.message ?? '').length).toBeGreaterThan(0);
expect(fb.correctiveAction).toBeUndefined();
break;
case 'error':
expect((fb.message ?? '').trim().length).toBeGreaterThan(0);
expectNonEmptyCorrectiveAction(fb.correctiveAction);
break;
}
},
),
{ numRuns: 100 },
);
});
it('确定性:相同 (state, context) 恒返回结构等价反馈', () => {
fc.assert(
fc.property(
fc.constantFrom('idle', 'loading', 'success', 'error') as fc.Arbitrary<
OperationFeedback['state']
>,
operationNameArb,
causeArb,
correctiveActionArb,
(state, operationName, cause, correctiveAction) => {
const ctx = { operationName, cause, correctiveAction };
expect(feedbackFor(state, ctx)).toEqual(feedbackFor(state, ctx));
},
),
{ numRuns: 100 },
);
});
});
+145
View File
@@ -0,0 +1,145 @@
/**
* 操作反馈纯逻辑单元测试(task 23.1Req 24.124.4)。
*
* 这是示例/边界单元测试(非属性测试;Property 80 属 task 23.2,单独实现)。
* 覆盖 `feedbackFor` 映射、`runOperation` 状态转移与 `exportWithProgress` 进度+终态边界。
*/
import { describe, it, expect } from 'vitest';
import {
feedbackFor,
runOperation,
exportWithProgress,
DEFAULT_EXPORT_TIMEOUT_MS,
type OperationFeedback,
type ExportProgress,
} from '../index.js';
describe('feedbackFor 映射(Req 24.124.3', () => {
it('idle → 仅状态,无文本与修正入口', () => {
const fb = feedbackFor('idle');
expect(fb.state).toBe('idle');
expect(fb.message).toBeUndefined();
expect(fb.correctiveAction).toBeUndefined();
});
it('loading → Loading_State 反馈,含非空进行中文本(Req 24.1)', () => {
const fb = feedbackFor('loading', { operationName: '保存草稿' });
expect(fb.state).toBe('loading');
expect(fb.message).toContain('保存草稿');
expect((fb.message ?? '').length).toBeGreaterThan(0);
});
it('success → 成功反馈,含非空成功文本(Req 24.2)', () => {
const fb = feedbackFor('success', { operationName: '提交评估' });
expect(fb.state).toBe('success');
expect(fb.message).toContain('提交评估');
expect(fb.message).toContain('成功');
});
it('error → 非空错误信息(含原因) + 非空修正入口(label+target)Req 24.3', () => {
const fb = feedbackFor('error', {
operationName: '提交评估',
cause: '网络连接超时',
correctiveAction: { label: '检查网络后重试', target: '/retry' },
});
expect(fb.state).toBe('error');
expect(fb.message).toContain('提交评估');
expect(fb.message).toContain('网络连接超时');
expect(fb.correctiveAction).toBeDefined();
expect((fb.correctiveAction?.label ?? '').length).toBeGreaterThan(0);
expect((fb.correctiveAction?.target ?? '').length).toBeGreaterThan(0);
});
it('error 缺省上下文也保证非空原因与默认修正入口(Req 24.3)', () => {
const fb = feedbackFor('error');
expect((fb.message ?? '').length).toBeGreaterThan(0);
expect((fb.correctiveAction?.label ?? '').length).toBeGreaterThan(0);
expect((fb.correctiveAction?.target ?? '').length).toBeGreaterThan(0);
});
it('error 修正入口 label/target 为空白时回退到默认入口(保证非空)', () => {
const fb = feedbackFor('error', {
correctiveAction: { label: ' ', target: '' },
});
expect((fb.correctiveAction?.label ?? '').length).toBeGreaterThan(0);
expect((fb.correctiveAction?.target ?? '').length).toBeGreaterThan(0);
});
it('确定性:相同输入返回结构等价反馈', () => {
const ctx = { operationName: 'X', cause: 'Y' };
expect(feedbackFor('error', ctx)).toEqual(feedbackFor('error', ctx));
});
});
describe('runOperation 状态转移(Req 24.124.3', () => {
it('成功路径:先 loading 后 success', async () => {
const seen: OperationFeedback[] = [];
const result = await runOperation(async () => 42, {
operationName: '保存',
onFeedback: (fb) => seen.push(fb),
});
expect(seen.map((f) => f.state)).toEqual(['loading', 'success']);
expect(result.state).toBe('success');
});
it('失败路径:先 loading 后 error,错误信息含抛出原因与修正入口', async () => {
const seen: OperationFeedback[] = [];
const result = await runOperation(
async () => {
throw new Error('磁盘已满');
},
{ operationName: '保存', onFeedback: (fb) => seen.push(fb) },
);
expect(seen.map((f) => f.state)).toEqual(['loading', 'error']);
expect(result.state).toBe('error');
expect(result.message).toContain('磁盘已满');
expect(result.correctiveAction).toBeDefined();
});
});
describe('exportWithProgress 进度反馈与 30 秒终态边界(Req 24.4', () => {
it('默认终态边界为 30 秒', () => {
expect(DEFAULT_EXPORT_TIMEOUT_MS).toBe(30_000);
});
it('导出成功:呈现执行中进度并落入 completed 终态', async () => {
const progress: ExportProgress[] = [];
const result = await exportWithProgress(async () => 'blob', {
onProgress: (p) => progress.push(p),
});
expect(progress[0]?.phase).toBe('exporting');
expect(result.state).toBe('completed');
expect(progress.at(-1)?.phase).toBe('completed');
expect(progress.at(-1)?.percent).toBe(100);
});
it('导出失败:落入 failed 终态并提供修正入口(Req 24.3 / 24.4', async () => {
const result = await exportWithProgress(
async () => {
throw new Error('生成 PDF 失败');
},
{ operationName: '报告导出' },
);
expect(result.state).toBe('failed');
expect(result.message).toContain('生成 PDF 失败');
expect(result.correctiveAction).toBeDefined();
});
it('超过终态边界强制落入 failed 终态(Req 24.4', async () => {
const never = new Promise<string>(() => {
/* 永不 resolve,模拟卡住的导出 */
});
const result = await exportWithProgress(() => never, { timeoutMs: 10 });
expect(result.state).toBe('failed');
expect(result.message).toContain('失败');
});
it('在终态边界内返回成功不受超时影响', async () => {
const result = await exportWithProgress(
() => new Promise<string>((resolve) => setTimeout(() => resolve('ok'), 5)),
{ timeoutMs: 1_000 },
);
expect(result.state).toBe('completed');
});
});
+311
View File
@@ -0,0 +1,311 @@
/**
* 操作反馈 — 纯状态模型、确定性映射与可测的状态转移逻辑(task 23.1Req 24.124.4)。
*
* 本模块承载「操作反馈与状态提示」的纯逻辑,供 `OperationFeedbackView.tsx` 渲染与
* 属性测试(task 23.2 / Property 80)共同复用:
* - `feedbackFor(state, context)`**纯且确定性**的映射器,将 `OperationState`
* 映射为 `OperationFeedback`
* · loading → Loading_State 反馈(Req 24.1
* · success → 成功反馈(Req 24.2
* · error → 可读且非空的错误信息(说明失败原因)+ 非空的修正路径操作入口
* `correctiveAction`label + target)(Req 24.3
* - `runOperation(op, options)`:异步 helper,按 idle→loading→(success|error) 推进,
* 并在每次状态变化时回调相应的 `OperationFeedback`Req 24.124.3)。
* - `exportWithProgress(exportFn, options)`:报告导出 helper,导出执行中持续呈现进度
* 反馈,并以可配置的超时(默认 30 秒,模型化 Req 24.4 的 30 秒边界)强制落入
* `completed` / `failed` 终态(Req 24.4)。
*
* 设计要点(保证 Property 80):
* - `feedbackFor` 无副作用、不读取外部可变状态、对相同输入恒返回相同输出。
* - error 分支恒产生**非空** `message`(含失败原因)与**非空** `correctiveAction`
* label 与 target 均非空),即便 `context` 未提供也回退到安全默认值。
* - 使用条件展开适配 `exactOptionalPropertyTypes`,不写出 `undefined` 字段。
*/
/* ------------------------------------------------------------------ *
* 类型
* ------------------------------------------------------------------ */
/** 用户触发操作的生命周期状态(Req 24)。 */
export type OperationState = 'idle' | 'loading' | 'success' | 'error';
/**
* 修正路径操作入口(Req 24.3):失败反馈须提供一个指向修正路径的入口,
* 含可见的 `label` 与可定位的 `target`(如路由/字段锚点/动作标识)。
*/
export interface CorrectiveAction {
/** 操作入口的可见文本标签(非空)。 */
readonly label: string;
/** 修正路径目标标识(非空),如 `'retry'`、`'#field-id'`、路由路径等。 */
readonly target: string;
}
/**
* 某一 `OperationState` 对应的 UI 反馈视图模型(Req 24)。
* - `message`:可读反馈文本(loading/success/error 均提供;idle 省略)。
* - `correctiveAction`:仅 error 提供,指向修正路径的操作入口(Req 24.3)。
*/
export interface OperationFeedback {
/** 当前操作状态。 */
readonly state: OperationState;
/** 可读反馈文本;idle 态省略。 */
readonly message?: string;
/** 修正路径操作入口;仅 error 态提供(Req 24.3)。 */
readonly correctiveAction?: CorrectiveAction;
}
/** `feedbackFor` 的上下文:用于生成更具体的可读文本与修正入口。 */
export interface FeedbackContext {
/** 操作名称(如「保存草稿」「提交评估」),用于拼装可读文本。 */
readonly operationName?: string;
/** 失败原因(error 态用于说明失败原因,Req 24.3)。 */
readonly cause?: string;
/** 自定义修正路径操作入口(error 态,Req 24.3);缺省回退为「重试」。 */
readonly correctiveAction?: CorrectiveAction;
}
/* ------------------------------------------------------------------ *
* 纯工具
* ------------------------------------------------------------------ */
/** 去除首尾空白;为空白或 `undefined` 时返回 `undefined`(用于「非空」判定)。 */
function nonEmpty(value: string | undefined): string | undefined {
if (value === undefined) {
return undefined;
}
const trimmed = value.trim();
return trimmed.length === 0 ? undefined : trimmed;
}
/** 默认的修正路径入口:重试当前操作(保证 error 态 `correctiveAction` 恒非空)。 */
function defaultCorrectiveAction(operationName: string): CorrectiveAction {
return { label: `重试${operationName}`, target: 'retry' };
}
/** 规范化外部传入的修正入口;label 或 target 为空则回退到默认入口。 */
function normalizeCorrectiveAction(
action: CorrectiveAction | undefined,
operationName: string,
): CorrectiveAction {
if (action === undefined) {
return defaultCorrectiveAction(operationName);
}
const label = nonEmpty(action.label);
const target = nonEmpty(action.target);
if (label === undefined || target === undefined) {
return defaultCorrectiveAction(operationName);
}
return { label, target };
}
/* ------------------------------------------------------------------ *
* 纯映射器(Property 80 验证目标)
* ------------------------------------------------------------------ */
/**
* 将 `OperationState` 确定性映射为 `OperationFeedback`Req 24.124.3)。
*
* 纯函数:无副作用,对相同 `(state, context)` 恒返回结构等价的反馈。
* - `idle` → 仅状态,无文本(操作尚未开始)。
* - `loading` → Loading_State 反馈,含进行中文本(Req 24.1)。
* - `success` → 成功反馈,含成功文本(Req 24.2)。
* - `error` → **非空**的可读错误信息(说明失败原因)+ **非空**的修正路径入口
* `correctiveAction`label 与 target 均非空)(Req 24.3)。
*/
export function feedbackFor(
state: OperationState,
context: FeedbackContext = {},
): OperationFeedback {
const operationName = nonEmpty(context.operationName) ?? '操作';
switch (state) {
case 'idle':
return { state: 'idle' };
case 'loading':
return { state: 'loading', message: `${operationName}处理中…` };
case 'success':
return { state: 'success', message: `${operationName}成功` };
case 'error': {
const cause = nonEmpty(context.cause) ?? '发生未知错误';
const correctiveAction = normalizeCorrectiveAction(
context.correctiveAction,
operationName,
);
return {
state: 'error',
message: `${operationName}失败:${cause}`,
correctiveAction,
};
}
}
}
/* ------------------------------------------------------------------ *
* 异步状态转移 helperidle → loading → success|error
* ------------------------------------------------------------------ */
/** `runOperation` 选项。 */
export interface RunOperationOptions {
/** 操作名称,用于拼装可读反馈文本。 */
readonly operationName?: string;
/** 失败时使用的自定义修正路径入口(Req 24.3);缺省回退为「重试」。 */
readonly correctiveAction?: CorrectiveAction;
/** 每次状态变化(loading / success / error)的反馈回调,用于驱动 UI。 */
readonly onFeedback?: (feedback: OperationFeedback) => void;
}
/** 由 `RunOperationOptions` 构造 `FeedbackContext`(条件展开适配可选属性精确性)。 */
function contextFromOptions(options: RunOperationOptions): FeedbackContext {
const operationName = nonEmpty(options.operationName);
return {
...(operationName !== undefined ? { operationName } : {}),
...(options.correctiveAction !== undefined
? { correctiveAction: options.correctiveAction }
: {}),
};
}
/**
* 执行一个异步操作并产出对应的反馈状态序列(Req 24.1–24.3)。
*
* 转移:先回调 `loading`Req 24.1);操作 resolve 时回调并返回 `success`Req 24.2);
* 操作 reject 时以错误信息为失败原因回调并返回 `error`(Req 24.3)。
*
* 状态转移逻辑独立于 UI,便于测试;`onFeedback` 用于让视图层逐态渲染。
*
* @returns 终态反馈(`success` 或 `error`)。
*/
export async function runOperation<T>(
op: () => Promise<T>,
options: RunOperationOptions = {},
): Promise<OperationFeedback> {
const context = contextFromOptions(options);
const loading = feedbackFor('loading', context);
options.onFeedback?.(loading);
try {
await op();
const success = feedbackFor('success', context);
options.onFeedback?.(success);
return success;
} catch (error: unknown) {
const cause = error instanceof Error ? error.message : String(error);
const errorFeedback = feedbackFor('error', { ...context, cause });
options.onFeedback?.(errorFeedback);
return errorFeedback;
}
}
/* ------------------------------------------------------------------ *
* 报告导出(进度反馈 + 30 秒终态边界,Req 24.4
* ------------------------------------------------------------------ */
/** 报告导出的 30 秒终态边界(Req 24.4),模型化为可配置超时的默认值。 */
export const DEFAULT_EXPORT_TIMEOUT_MS = 30_000;
/** 导出终态。 */
export type ExportTerminalState = 'completed' | 'failed';
/** 导出过程中向 UI 呈现的进度反馈。 */
export interface ExportProgress {
/** 进度阶段:执行中 / 完成 / 失败。 */
readonly phase: 'exporting' | ExportTerminalState;
/** 进度百分比(0–100)。 */
readonly percent: number;
/** 可读进度文本。 */
readonly message: string;
/** 失败时指向修正路径的操作入口(Req 24.3 / 24.4)。 */
readonly correctiveAction?: CorrectiveAction;
}
/** 导出终态结果(Req 24.4 / Req 10.5)。 */
export interface ExportResult {
/** 终态:`completed` 或 `failed`。 */
readonly state: ExportTerminalState;
/** 可读终态文本。 */
readonly message: string;
/** 失败时指向修正路径的操作入口(Req 24.3 / 24.4)。 */
readonly correctiveAction?: CorrectiveAction;
}
/** `exportWithProgress` 选项。 */
export interface ExportWithProgressOptions {
/** 终态超时(毫秒),默认 {@link DEFAULT_EXPORT_TIMEOUT_MS}Req 24.4 的 30 秒边界)。 */
readonly timeoutMs?: number;
/** 操作名称,默认「报告导出」。 */
readonly operationName?: string;
/** 失败时使用的自定义修正路径入口(Req 24.3);缺省回退为「重试」。 */
readonly correctiveAction?: CorrectiveAction;
/** 进度反馈回调,用于驱动 UI 呈现进度(Req 24.4)。 */
readonly onProgress?: (progress: ExportProgress) => void;
}
/** 超过终态边界(默认 30 秒)仍未完成时抛出,强制落入 `failed` 终态(Req 24.4)。 */
export class ExportTimeoutError extends Error {
constructor(timeoutMs: number) {
super(`导出在 ${timeoutMs} 毫秒内未完成`);
this.name = 'ExportTimeoutError';
}
}
/**
* 执行报告导出并呈现进度反馈,在终态边界内强制落入完成/失败终态(Req 24.4)。
*
* 不与领域导出耦合:导出动作由注入的 `exportFn` 提供(其内部已绑定具体 report)。
* 导出执行中先回调一次「执行中」进度反馈;随后将 `exportFn()` 与超时竞速:
* - `exportFn` 先 resolve → 回调并返回 `completed`(进度 100%)。
* - `exportFn` reject 或超过 `timeoutMs` → 回调并返回 `failed`(附修正入口)。
*
* 由此保证「请求导出后在 `timeoutMs`(默认 30 秒)内必现完成或失败终态」(Req 24.4)。
*
* @returns 导出终态结果(`completed` 或 `failed`)。
*/
export async function exportWithProgress<R>(
exportFn: () => Promise<R>,
options: ExportWithProgressOptions = {},
): Promise<ExportResult> {
const timeoutMs = options.timeoutMs ?? DEFAULT_EXPORT_TIMEOUT_MS;
const operationName = nonEmpty(options.operationName) ?? '报告导出';
const correctiveAction = normalizeCorrectiveAction(
options.correctiveAction,
operationName,
);
options.onProgress?.({
phase: 'exporting',
percent: 0,
message: `${operationName}进行中…`,
});
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => {
reject(new ExportTimeoutError(timeoutMs));
}, timeoutMs);
});
try {
await Promise.race([exportFn(), timeout]);
const message = `${operationName}完成`;
options.onProgress?.({ phase: 'completed', percent: 100, message });
return { state: 'completed', message };
} catch (error: unknown) {
const cause = error instanceof Error ? error.message : String(error);
const message = `${operationName}失败:${cause}`;
options.onProgress?.({
phase: 'failed',
percent: 100,
message,
correctiveAction,
});
return { state: 'failed', message, correctiveAction };
} finally {
if (timer !== undefined) {
clearTimeout(timer);
}
}
}
+33
View File
@@ -0,0 +1,33 @@
/**
* Feedback 公共入口(barreltask 23.1)。
*
* 暴露「操作反馈与状态提示」的纯映射器与状态转移 helper(供 Property 80 / task 23.2
* 验证)以及展示组件:
* - `feedbackFor`:纯且确定性的 OperationState → OperationFeedback 映射(Req 24.124.3)。
* - `runOperation`idle→loading→success|error 的异步状态转移 helper。
* - `exportWithProgress`:报告导出进度反馈 + 30 秒终态边界(Req 24.4)。
* - `OperationFeedbackView`:将反馈渲染为可访问 UI 的展示组件。
*/
export {
feedbackFor,
runOperation,
exportWithProgress,
ExportTimeoutError,
DEFAULT_EXPORT_TIMEOUT_MS,
} from './feedback.js';
export type {
OperationState,
OperationFeedback,
CorrectiveAction,
FeedbackContext,
RunOperationOptions,
ExportTerminalState,
ExportProgress,
ExportResult,
ExportWithProgressOptions,
} from './feedback.js';
export { OperationFeedbackView } from './OperationFeedbackView.js';
export type { OperationFeedbackViewProps } from './OperationFeedbackView.js';
+22
View File
@@ -0,0 +1,22 @@
/**
* 前端运行时入口:将 React 应用挂载到 #root,并由 Design_System 的 ThemeProvider
* 注入 Color_Token CSS 变量(默认 Light 主题)。
*/
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App.js';
import { ThemeProvider } from './design-system/index.js';
import './styles/global.css';
const container = document.getElementById('root');
if (container === null) {
throw new Error('找不到挂载节点 #root');
}
createRoot(container).render(
<StrictMode>
<ThemeProvider initialTheme="Light">
<App />
</ThemeProvider>
</StrictMode>,
);
File diff suppressed because it is too large Load Diff
+85
View File
@@ -0,0 +1,85 @@
/**
* 附件管理区:上传/列表,显示在详情页。
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { colorVar, FONT_FAMILY, RADIUS, space, typographyStyle } from '../design-system/components/styles.js';
import { API_BASE } from '../api/client.js';
interface AttachmentMeta {
id: string;
filename: string;
mimeType: string;
sizeBytes: number;
uploadedBy: string | null;
uploadedAt: string;
}
const API = API_BASE;
export function AttachmentSection({ assessmentId, user }: { readonly assessmentId: string; readonly user?: string }): JSX.Element {
const [attachments, setAttachments] = useState<AttachmentMeta[]>([]);
const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const load = useCallback(() => {
fetch(`${API}/api/assessments/${assessmentId}/attachments`)
.then((r) => r.json())
.then(setAttachments)
.catch(() => setAttachments([]));
}, [assessmentId]);
useEffect(() => { load(); }, [load]);
async function handleUpload(): Promise<void> {
const file = fileRef.current?.files?.[0];
if (!file) return;
setUploading(true);
try {
const buf = await file.arrayBuffer();
const base64 = btoa(String.fromCharCode(...new Uint8Array(buf)));
await fetch(`${API}/api/assessments/${assessmentId}/attachments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: file.name, mimeType: file.type || 'application/octet-stream', base64, uploadedBy: user }),
});
load();
if (fileRef.current) fileRef.current.value = '';
} finally {
setUploading(false);
}
}
return (
<div style={{ marginTop: `${space(3)}px`, padding: `${space(3)}px`, backgroundColor: colorVar('color.bg.elevated'), borderRadius: `${RADIUS.lg}px`, border: `1px solid ${colorVar('color.border.default')}` }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: `${space(2)}px` }}>
<span style={{ ...typographyStyle('title'), fontWeight: 700, color: colorVar('color.text.primary') }}>
{attachments.length}
</span>
<div style={{ display: 'flex', gap: `${space(2)}px`, alignItems: 'center' }}>
<input ref={fileRef} type="file" style={{ ...typographyStyle('caption'), fontFamily: FONT_FAMILY }} />
<button
onClick={handleUpload}
disabled={uploading}
style={{ padding: `${space(1)}px ${space(3)}px`, borderRadius: `${RADIUS.sm}px`, border: 'none', backgroundColor: colorVar('color.brand.primary'), color: '#fff', cursor: 'pointer', fontWeight: 600, ...typographyStyle('caption') }}
>
{uploading ? '上传中…' : '上传'}
</button>
</div>
</div>
{attachments.length === 0 ? (
<p style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}></p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
{attachments.map((a) => (
<div key={a.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: `${space(1)}px ${space(2)}px`, borderBottom: `1px solid ${colorVar('color.border.default')}`, ...typographyStyle('caption') }}>
<span style={{ fontWeight: 600, color: colorVar('color.text.primary') }}>{a.filename}</span>
<span style={{ color: colorVar('color.text.secondary') }}>
{(a.sizeBytes / 1024).toFixed(1)} KB · {a.uploadedBy ?? '未知'} · {new Date(a.uploadedAt).toLocaleString('zh-CN')}
</span>
</div>
))}
</div>
)}
</div>
);
}
+524
View File
@@ -0,0 +1,524 @@
/**
* 客户档案 — 专业 CRM(对标费率管理设计水准)。
*
* 模块:
* 1. 信用评分模型面板(类似引擎默认对照)
* 2. 统计看板(客户数/等级分布/平均逾期/预警数)
* 3. 客户列表(按信用等级分组 + 筛选排序)
* 4. 客户详情面板(信用仪表 + 关联评估 + 合作概况 + 预警规则)
* 5. 新增/编辑表单
*/
import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { colorVar, FONT_FAMILY, RADIUS, SHADOW, space, typographyStyle } from '../design-system/components/styles.js';
import { Card, Icon } from '../design-system/index.js';
import { fetchCustomers, createCustomer, deleteCustomerApi, fetchConcentration, fetchAssessmentsPage, fetchPayments, addPayment, deletePaymentApi, type CustomerItem, type AssessmentListItem, type CustomerPayment } from '../api/client.js';
/* ------------------------------------------------------------------ *
* 信用评分模型(引擎对照基准)
* ------------------------------------------------------------------ */
const CREDIT_MODEL = [
{ grade: 'AAA', label: '极优', badDebt: 0.01, color: '#15803D', desc: '历史无逾期,付款极准时,大型国企/上市公司' },
{ grade: 'AA', label: '优良', badDebt: 0.015, color: '#15803D', desc: '偶有短期逾期(<7天),付款习惯好' },
{ grade: 'A', label: '良好', badDebt: 0.02, color: '#2563EB', desc: '正常付款周期,偶有延迟(7-15天)' },
{ grade: 'BBB', label: '一般', badDebt: 0.03, color: '#B45309', desc: '付款需催促,平均逾期 15-30 天' },
{ grade: 'BB', label: '关注', badDebt: 0.04, color: '#B45309', desc: '经常逾期 30+ 天,需加强催收' },
{ grade: 'B', label: '风险', badDebt: 0.06, color: '#BE123C', desc: '严重逾期 60+ 天,存在坏账风险' },
{ grade: '未评级', label: '待评', badDebt: 0.03, color: '#64748B', desc: '新客户,暂按一般标准计提' },
] as const;
const CREDIT_COLOR: Record<string, string> = Object.fromEntries(CREDIT_MODEL.map((c) => [c.grade, c.color]));
const CREDIT_BAD_DEBT: Record<string, number> = Object.fromEntries(CREDIT_MODEL.map((c) => [c.grade, c.badDebt]));
const CREDIT_OPTIONS = CREDIT_MODEL.map((c) => c.grade);
/* ------------------------------------------------------------------ *
* 分组
* ------------------------------------------------------------------ */
const GROUPS = [
{ label: '风险客户', grades: ['B', 'BB'], color: '#BE123C' },
{ label: '关注客户', grades: ['BBB', '未评级'], color: '#B45309' },
{ label: '良好客户', grades: ['A', 'AA', 'AAA'], color: '#15803D' },
] as const;
function getGroup(grade: string): string {
for (const g of GROUPS) {
if (g.grades.includes(grade as never)) return g.label;
}
return '关注客户';
}
/* ------------------------------------------------------------------ *
* 页面
* ------------------------------------------------------------------ */
const EMPTY_FORM = { id: '', name: '', creditRating: 'A', avgOverdueDays: '0', totalContractAmount: '0', assessmentCount: '0', notes: '' };
export function CustomerManagement(): JSX.Element {
const navigate = useNavigate();
const [customers, setCustomers] = useState<CustomerItem[]>([]);
const [loading, setLoading] = useState(true);
const [concentrations, setConcentrations] = useState<Record<string, { concentration: number; warning: string | null }>>({});
const [form, setForm] = useState(EMPTY_FORM);
const [editing, setEditing] = useState(false);
const [showForm, setShowForm] = useState(false);
const [detail, setDetail] = useState<CustomerItem | null>(null);
const [detailAssessments, setDetailAssessments] = useState<AssessmentListItem[]>([]);
const [search, setSearch] = useState('');
const [filterGrade, setFilterGrade] = useState('');
// 信用评分模型对照表默认收起。
const [modelOpen, setModelOpen] = useState(false);
const load = useCallback(() => {
setLoading(true);
fetchCustomers().then((c) => {
setCustomers(c);
c.forEach((cust) => fetchConcentration(cust.id).then((r) => setConcentrations((prev) => ({ ...prev, [cust.id]: r }))));
}).finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
// 打开详情时加载关联评估
useEffect(() => {
if (detail === null) { setDetailAssessments([]); return; }
fetchAssessmentsPage({ page: 1, pageSize: 50, q: detail.name, archived: 'all' })
.then((res) => setDetailAssessments(res.items))
.catch(() => setDetailAssessments([]));
}, [detail]);
async function handleSave(): Promise<void> {
if (!form.id || !form.name) return;
await createCustomer({
id: form.id, name: form.name, creditRating: form.creditRating,
avgOverdueDays: Number(form.avgOverdueDays) || 0,
totalContractAmount: Number(form.totalContractAmount.replace(/,/g, '')) || 0,
assessmentCount: Number(form.assessmentCount) || 0,
notes: form.notes || null,
});
setForm(EMPTY_FORM); setShowForm(false); setEditing(false); load();
}
function handleEdit(c: CustomerItem): void {
setForm({ id: c.id, name: c.name, creditRating: c.creditRating, avgOverdueDays: String(c.avgOverdueDays), totalContractAmount: c.totalContractAmount.toLocaleString('zh-CN'), assessmentCount: String(c.assessmentCount), notes: c.notes ?? '' });
setEditing(true); setShowForm(true); setDetail(null);
}
// 筛选
const filtered = customers.filter((c) => {
if (search && !c.name.includes(search) && !c.id.includes(search)) return false;
if (filterGrade && c.creditRating !== filterGrade) return false;
return true;
});
// 统计
const totalCustomers = customers.length;
const warningCount = customers.filter((c) => { const cc = concentrations[c.id]; return c.avgOverdueDays > 30 || (cc !== undefined && cc.concentration > 0.3); }).length;
const avgOverdue = totalCustomers > 0 ? Math.round(customers.reduce((s, c) => s + c.avgOverdueDays, 0) / totalCustomers) : 0;
const gradeDistribution = CREDIT_MODEL.map((m) => ({ ...m, count: customers.filter((c) => c.creditRating === m.grade).length })).filter((m) => m.count > 0);
const inputStyle: React.CSSProperties = {
padding: `${space(2)}px ${space(3)}px`,
border: `1px solid ${colorVar('color.border.default')}`,
borderRadius: `${RADIUS.md}px`,
fontFamily: FONT_FAMILY, ...typographyStyle('body'),
backgroundColor: colorVar('color.bg.canvas'), color: colorVar('color.text.primary'),
width: '100%',
};
return (
<div style={{ fontFamily: FONT_FAMILY, maxWidth: 1200, margin: '0 auto' }}>
{/* 页头 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: `${space(4)}px` }}>
<div>
<h1 style={{ ...typographyStyle('heading'), color: colorVar('color.text.primary'), margin: 0 }}></h1>
<p style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), margin: `${space(1)}px 0 0` }}>
/
</p>
</div>
<button onClick={() => { setShowForm(!showForm); if (!showForm) { setForm(EMPTY_FORM); setEditing(false); } }} style={{ padding: `${space(2)}px ${space(4)}px`, borderRadius: `${RADIUS.md}px`, border: 'none', backgroundColor: colorVar('color.brand.primary'), color: '#fff', cursor: 'pointer', fontWeight: 600 }}>
{showForm ? '收起' : '+ 新增客户'}
</button>
</div>
{/* 统计看板 */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: `${space(3)}px`, marginBottom: `${space(3)}px` }}>
<MetricCard icon="user" label="客户总数" value={String(totalCustomers)} accent={colorVar('color.brand.primary')} />
<MetricCard icon="warning" label="预警客户" value={String(warningCount)} accent={warningCount > 0 ? '#BE123C' : '#15803D'} sub={warningCount > 0 ? '逾期或集中度超限' : '暂无预警'} />
<MetricCard icon="clock" label="平均逾期" value={`${avgOverdue}`} accent={avgOverdue > 30 ? '#B45309' : '#15803D'} sub={avgOverdue > 30 ? '超 30 天警戒线' : '处于正常区间'} />
</div>
{/* 等级分布 */}
{gradeDistribution.length > 0 && (
<div style={{ padding: `${space(4)}px`, backgroundColor: colorVar('color.bg.elevated'), border: `1px solid ${colorVar('color.border.default')}`, borderRadius: `${RADIUS.lg}px`, marginBottom: `${space(4)}px`, boxShadow: '0 1px 2px rgba(16,24,40,0.04)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: `${space(2)}px`, marginBottom: `${space(3)}px` }}>
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 32, height: 32, borderRadius: 8, backgroundColor: `${colorVar('color.brand.primary')}1A`, color: colorVar('color.brand.primary') }}>
<Icon name="chart" size={18} />
</span>
<span style={{ ...typographyStyle('body'), fontWeight: 700, color: colorVar('color.text.primary') }}></span>
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}> {totalCustomers} </span>
</div>
{/* 堆叠占比条 */}
<div style={{ display: 'flex', height: 10, borderRadius: 999, overflow: 'hidden', marginBottom: `${space(3)}px`, backgroundColor: colorVar('color.bg.surface') }}>
{gradeDistribution.map((g) => (
<div key={g.grade} title={`${g.grade} ${g.count}`} style={{ width: `${(g.count / totalCustomers) * 100}%`, backgroundColor: g.color }} />
))}
</div>
{/* 图例 chips */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: `${space(2)}px` }}>
{gradeDistribution.map((g) => (
<div key={g.grade} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: `4px ${space(2)}px`, borderRadius: 999, backgroundColor: `${g.color}12`, border: `1px solid ${g.color}33` }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: g.color, display: 'inline-block' }} />
<span style={{ ...typographyStyle('caption'), fontWeight: 700, color: g.color }}>{g.grade}</span>
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>{g.label}</span>
<span style={{ ...typographyStyle('caption'), fontWeight: 700, color: colorVar('color.text.primary') }}>{g.count}</span>
</div>
))}
</div>
</div>
)}
{/* 信用评分模型面板(引擎对照,默认收起) */}
<Card
title={
<button
type="button"
onClick={() => setModelOpen((v) => !v)}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%', border: 'none', background: 'transparent', cursor: 'pointer', padding: 0, fontFamily: FONT_FAMILY, color: colorVar('color.text.primary'), ...typographyStyle('title'), fontWeight: 600 }}
aria-expanded={modelOpen}
>
<span></span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, ...typographyStyle('caption'), color: colorVar('color.text.secondary'), fontWeight: 400 }}>
{modelOpen ? '收起' : '展开查看'}
<span style={{ display: 'inline-flex', transform: modelOpen ? 'rotate(180deg)' : 'none', transition: 'transform 150ms ease' }}><Icon name="chevron-down" size={18} /></span>
</span>
</button>
}
padded={modelOpen}
>
{modelOpen ? (
<>
<p style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), margin: `0 0 ${space(2)}px` }}>
,,
</p>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead><tr>
{['信用等级', '等级含义', '坏账准备金率', '评估影响', '典型客户画像'].map((h) => (
<th key={h} style={{ textAlign: 'left', padding: `${space(1)}px ${space(2)}px`, borderBottom: `2px solid ${colorVar('color.border.default')}`, color: colorVar('color.text.secondary'), ...typographyStyle('caption'), fontWeight: 700 }}>{h}</th>
))}
</tr></thead>
<tbody>
{CREDIT_MODEL.map((m) => (
<tr key={m.grade}>
<td style={{ ...tds, fontWeight: 700, color: m.color }}>{m.grade} ({m.label})</td>
<td style={tds}>{m.desc}</td>
<td style={{ ...tds, fontWeight: 700, color: m.color }}>{(m.badDebt * 100).toFixed(1)}%</td>
<td style={{ ...tds, ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}> {(m.badDebt * 100).toFixed(1)}% </td>
<td style={{ ...tds, ...typographyStyle('caption') }}>{m.badDebt <= 0.02 ? '大型国企/上市公司' : m.badDebt <= 0.03 ? '中型企业' : '小微/新客户'}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
) : null}
</Card>
{/* 新增/编辑弹窗 */}
{showForm && (
<div style={{ position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }} onClick={() => { setShowForm(false); setEditing(false); }}>
<div style={{ backgroundColor: colorVar('color.bg.elevated'), borderRadius: `${RADIUS.lg}px`, padding: `${space(5)}px`, maxWidth: 600, width: '90%', boxShadow: SHADOW.lg }} onClick={(e) => e.stopPropagation()}>
<h3 style={{ ...typographyStyle('title'), margin: `0 0 ${space(3)}px`, color: colorVar('color.text.primary') }}>{editing ? '编辑客户' : '新增客户'}</h3>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: `${space(3)}px` }}>
<FormField label="客户ID"><input style={inputStyle} value={form.id} disabled={editing} onChange={(e) => setForm((f) => ({ ...f, id: e.target.value }))} placeholder="唯一标识" /></FormField>
<FormField label="客户全称(须与评估【客户】一致)"><input style={inputStyle} value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} placeholder="如 某省电信公司" /></FormField>
<FormField label="资信等级"><select style={inputStyle} value={form.creditRating} onChange={(e) => setForm((f) => ({ ...f, creditRating: e.target.value }))}>{CREDIT_OPTIONS.map((o) => { const m = CREDIT_MODEL.find((x) => x.grade === o); return <option key={o} value={o}>{o} {m?.label ?? ''}{((m?.badDebt ?? 0) * 100).toFixed(1)}%</option>; })}</select></FormField>
<FormField label="平均逾期(天)"><input style={inputStyle} value={form.avgOverdueDays} onChange={(e) => setForm((f) => ({ ...f, avgOverdueDays: e.target.value }))} inputMode="numeric" /></FormField>
<FormField label="累计合同额(元)">
<input
style={inputStyle}
value={form.totalContractAmount}
onChange={(e) => setForm((f) => ({ ...f, totalContractAmount: e.target.value.replace(/,/g, '') }))}
onFocus={(e) => { e.target.value = form.totalContractAmount; }}
onBlur={() => {
const n = Number(form.totalContractAmount.replace(/,/g, ''));
if (Number.isFinite(n) && n > 0) {
setForm((f) => ({ ...f, totalContractAmount: n.toLocaleString('zh-CN') }));
}
}}
inputMode="decimal"
/>
</FormField>
<FormField label="备注"><input style={inputStyle} value={form.notes} onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))} placeholder="内部备注" /></FormField>
</div>
<div style={{ display: 'flex', gap: `${space(2)}px`, marginTop: `${space(3)}px`, justifyContent: 'flex-end' }}>
<button onClick={() => { setShowForm(false); setEditing(false); }} style={{ padding: `${space(2)}px ${space(4)}px`, borderRadius: `${RADIUS.md}px`, border: `1px solid ${colorVar('color.border.default')}`, background: 'transparent', cursor: 'pointer' }}></button>
<button onClick={handleSave} style={{ padding: `${space(2)}px ${space(5)}px`, borderRadius: `${RADIUS.md}px`, border: 'none', backgroundColor: colorVar('color.brand.primary'), color: '#fff', cursor: 'pointer', fontWeight: 600 }}>{editing ? '保存' : '添加'}</button>
</div>
</div>
</div>
)}
{/* 筛选 */}
<div style={{ display: 'flex', gap: `${space(3)}px`, marginTop: `${space(4)}px`, marginBottom: `${space(3)}px`, flexWrap: 'wrap', alignItems: 'center' }}>
<input style={{ ...inputStyle, maxWidth: 280, width: 'auto' }} placeholder="搜索客户名称或ID" value={search} onChange={(e) => setSearch(e.target.value)} />
<select style={{ ...inputStyle, width: 'auto', minWidth: 130 }} value={filterGrade} onChange={(e) => setFilterGrade(e.target.value)}>
<option value=""></option>
{CREDIT_OPTIONS.map((g) => <option key={g} value={g}>{g}</option>)}
</select>
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>
{filtered.length} {filterGrade ? ` · ${filterGrade}` : ''}
</span>
</div>
{/* 客户列表(按信用分组) */}
{loading ? <p style={{ color: colorVar('color.text.secondary') }}></p> : (
GROUPS.map((g) => {
const groupCustomers = filtered.filter((c) => getGroup(c.creditRating) === g.label);
if (groupCustomers.length === 0) return null;
return (
<div key={g.label} style={{ marginBottom: `${space(4)}px` }}>
<div style={{ ...typographyStyle('title'), fontWeight: 700, color: g.color, padding: `${space(2)}px 0`, borderBottom: `2px solid ${g.color}`, marginBottom: `${space(2)}px` }}>
{g.label}{groupCustomers.length}
</div>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead><tr>
{['客户名称', '信用等级', '坏账准备金率', '平均逾期', '合同累计', '集中度', '评估次数', '操作'].map((h) => (
<th key={h} style={{ textAlign: 'left', padding: `${space(2)}px ${space(3)}px`, borderBottom: `1px solid ${colorVar('color.border.default')}`, color: colorVar('color.text.secondary'), ...typographyStyle('caption'), fontWeight: 700 }}>{h}</th>
))}
</tr></thead>
<tbody>
{groupCustomers.map((c) => {
const conc = concentrations[c.id];
const concVal = conc !== undefined ? conc.concentration : 0;
const concDanger = concVal > 0.3;
const overdueDanger = c.avgOverdueDays > 30;
const cc = CREDIT_COLOR[c.creditRating] ?? '#64748B';
return (
<tr key={c.id} style={{ cursor: 'pointer' }} onClick={() => setDetail(c)}>
<td style={{ ...tds, fontWeight: 600 }}>{c.name}</td>
<td style={tds}><span style={{ padding: '2px 8px', borderRadius: '999px', backgroundColor: cc + '14', color: cc, fontWeight: 700, ...typographyStyle('caption') }}>{c.creditRating}</span></td>
<td style={{ ...tds, color: cc, fontWeight: 600 }}>{((CREDIT_BAD_DEBT[c.creditRating] ?? 0.02) * 100).toFixed(1)}%</td>
<td style={{ ...tds, color: overdueDanger ? '#BE123C' : undefined, fontWeight: overdueDanger ? 700 : 400 }}>{c.avgOverdueDays} {overdueDanger ? <Icon name="warning" size={12} color="#BE123C" /> : null}</td>
<td style={tds}>{c.totalContractAmount.toLocaleString('zh-CN')} </td>
<td style={{ ...tds, color: concDanger ? '#BE123C' : undefined, fontWeight: concDanger ? 700 : 400 }}>{(concVal * 100).toFixed(1)}%{concDanger ? <Icon name="warning" size={12} color="#BE123C" /> : null}</td>
<td style={tds}>{c.assessmentCount}</td>
<td style={tds} onClick={(e) => e.stopPropagation()}>
<div style={{ display: 'flex', gap: `${space(2)}px` }}>
<button onClick={() => handleEdit(c)} style={linkBtnS(colorVar('color.brand.primary'))}></button>
<button onClick={() => deleteCustomerApi(c.id).then(load)} style={linkBtnS('#BE123C')}></button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
})
)}
{/* 详情面板 */}
{detail !== null && (
<div style={{ position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }} onClick={() => setDetail(null)}>
<div style={{ backgroundColor: colorVar('color.bg.elevated'), borderRadius: `${RADIUS.lg}px`, padding: `${space(5)}px`, maxWidth: 700, width: '92%', boxShadow: SHADOW.lg, maxHeight: '90vh', overflowY: 'auto' }} onClick={(e) => e.stopPropagation()}>
{/* 头部 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: `${space(4)}px` }}>
<div>
<h2 style={{ ...typographyStyle('heading'), margin: 0, color: colorVar('color.text.primary') }}>{detail.name}</h2>
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>ID: {detail.id}</span>
</div>
<span style={{ padding: '4px 14px', borderRadius: '999px', backgroundColor: (CREDIT_COLOR[detail.creditRating] ?? '#64748B') + '14', color: CREDIT_COLOR[detail.creditRating] ?? '#64748B', fontWeight: 700, ...typographyStyle('title') }}>{detail.creditRating}</span>
</div>
{/* 信用仪表盘 */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: `${space(2)}px`, marginBottom: `${space(3)}px` }}>
<MCard label="信用等级" value={detail.creditRating} sub={CREDIT_MODEL.find((m) => m.grade === detail.creditRating)?.label ?? ''} color={CREDIT_COLOR[detail.creditRating]} />
<MCard label="坏账准备金率" value={`${((CREDIT_BAD_DEBT[detail.creditRating] ?? 0.02) * 100).toFixed(1)}%`} sub="按等级自动计提" color={CREDIT_COLOR[detail.creditRating]} />
<MCard label="平均逾期" value={`${detail.avgOverdueDays}`} sub={detail.avgOverdueDays > 30 ? '超警戒' : '正常'} color={detail.avgOverdueDays > 30 ? '#BE123C' : undefined} />
{(() => {
const cc = concentrations[detail.id];
const cv = cc !== undefined ? cc.concentration : 0;
return <MCard label="收入集中度" value={`${(cv * 100).toFixed(1)}%`} sub={cv > 0.3 ? '超阈值' : '正常'} color={cv > 0.3 ? '#BE123C' : undefined} />;
})()}
</div>
{/* 合作概况 */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: `${space(3)}px`, marginBottom: `${space(3)}px`, padding: `${space(3)}px`, backgroundColor: colorVar('color.bg.surface'), borderRadius: `${RADIUS.md}px` }}>
<div><div style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}></div><div style={{ ...typographyStyle('title'), fontWeight: 700 }}>{detail.totalContractAmount.toLocaleString('zh-CN')} </div></div>
<div><div style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}></div><div style={{ ...typographyStyle('title'), fontWeight: 700 }}>{detail.assessmentCount} </div></div>
<div><div style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}></div><div style={{ ...typographyStyle('title'), fontWeight: 700, color: GROUPS.find((g) => g.grades.includes(detail.creditRating as never))?.color }}>{getGroup(detail.creditRating)}</div></div>
</div>
{/* 预警 */}
{(() => {
const cc = concentrations[detail.id];
const concHigh = cc !== undefined && cc.concentration > 0.3;
if (detail.avgOverdueDays <= 30 && !concHigh) return null;
return (
<div style={{ padding: `${space(3)}px`, backgroundColor: 'rgba(190,18,60,0.06)', border: '1px solid rgba(190,18,60,0.2)', borderRadius: `${RADIUS.md}px`, marginBottom: `${space(3)}px` }}>
<div style={{ ...typographyStyle('caption'), fontWeight: 700, color: '#BE123C', marginBottom: 4, display: 'flex', alignItems: 'center', gap: 6 }}><Icon name="warning" size={14} /> </div>
{detail.avgOverdueDays > 30 && <div style={{ ...typographyStyle('caption'), color: '#BE123C' }}> {detail.avgOverdueDays} &gt; 30 建议:提高坏账准备金 / / </div>}
{concHigh && cc !== undefined && <div style={{ ...typographyStyle('caption'), color: '#BE123C' }}> {(cc.concentration * 100).toFixed(1)}% &gt; 30% 建议:分散客户结构 / </div>}
</div>
);
})()}
{/* 关联评估历史 */}
<div style={{ marginBottom: `${space(3)}px` }}>
<div style={{ ...typographyStyle('title'), fontWeight: 700, color: colorVar('color.text.primary'), marginBottom: `${space(2)}px` }}>{detailAssessments.length}</div>
{detailAssessments.length === 0 ? (
<p style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}></p>
) : (
<div style={{ maxHeight: 200, overflowY: 'auto', border: `1px solid ${colorVar('color.border.default')}`, borderRadius: `${RADIUS.md}px` }}>
{detailAssessments.map((a) => (
<div key={a.id} onClick={() => { setDetail(null); navigate(`/assessments/${a.id}`); }} style={{ padding: `${space(2)}px ${space(3)}px`, borderBottom: `1px solid ${colorVar('color.border.default')}`, cursor: 'pointer', display: 'flex', justifyContent: 'space-between', alignItems: 'center', ...typographyStyle('caption') }}>
<span style={{ color: colorVar('color.text.primary') }}>{a.projectDescription.slice(0, 30)}</span>
<div style={{ display: 'flex', gap: `${space(2)}px`, alignItems: 'center' }}>
{a.riskGrade !== undefined && <span style={{ padding: '1px 6px', borderRadius: '999px', backgroundColor: colorVar('color.bg.surface'), fontWeight: 600 }}>{a.riskGrade}</span>}
<span style={{ color: colorVar('color.text.secondary') }}>{a.createdAt.slice(0, 10)}</span>
</div>
</div>
))}
</div>
)}
</div>
{/* 回款记录(自动计算平均逾期天数) */}
<PaymentsPanel customerId={detail.id} onChanged={() => { load(); }} />
{/* 评估联动说明 */}
<div style={{ padding: `${space(3)}px`, backgroundColor: colorVar('color.bg.surface'), borderRadius: `${RADIUS.md}px`, marginBottom: `${space(3)}px`, ...typographyStyle('caption'), color: colorVar('color.text.secondary'), display: 'flex', alignItems: 'flex-start', gap: 6 }}>
<Icon name="lightbulb" size={14} color={colorVar('color.brand.primary')} />
<span><strong></strong>{detail.name} {((CREDIT_BAD_DEBT[detail.creditRating] ?? 0.02) * 100).toFixed(1)}% {detail.avgOverdueDays > 30 ? ';标注逾期预警' : ''}</span>
</div>
{detail.notes && (
<div style={{ padding: `${space(2)}px ${space(3)}px`, backgroundColor: colorVar('color.bg.surface'), borderRadius: `${RADIUS.md}px`, marginBottom: `${space(3)}px` }}>
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}></span>{detail.notes}
</div>
)}
<div style={{ display: 'flex', gap: `${space(2)}px`, justifyContent: 'flex-end' }}>
<button onClick={() => handleEdit(detail)} style={{ padding: `${space(2)}px ${space(4)}px`, borderRadius: `${RADIUS.md}px`, border: `1px solid ${colorVar('color.brand.primary')}`, background: 'transparent', color: colorVar('color.brand.primary'), cursor: 'pointer', fontWeight: 600 }}></button>
<button onClick={() => { deleteCustomerApi(detail.id).then(load); setDetail(null); }} style={{ padding: `${space(2)}px ${space(4)}px`, borderRadius: `${RADIUS.md}px`, border: 'none', backgroundColor: '#BE123C', color: '#fff', cursor: 'pointer', fontWeight: 600 }}></button>
<button onClick={() => setDetail(null)} style={{ padding: `${space(2)}px ${space(4)}px`, borderRadius: `${RADIUS.md}px`, border: `1px solid ${colorVar('color.border.default')}`, background: 'transparent', cursor: 'pointer' }}></button>
</div>
</div>
</div>
)}
</div>
);
}
/* ------------------------------------------------------------------ *
* 子组件
* ------------------------------------------------------------------ */
const tds: React.CSSProperties = { padding: `${space(2)}px ${space(3)}px`, borderBottom: '1px solid var(--color-border-default)', fontSize: '14px' };
function linkBtnS(color: string): React.CSSProperties {
return { cursor: 'pointer', border: 'none', background: 'none', color, fontWeight: 600, fontSize: '12px' };
}
function FormField({ label, children }: { label: string; children: React.ReactNode }): JSX.Element {
return <div><label style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), display: 'block', marginBottom: 4 }}>{label}</label>{children}</div>;
}
function MetricCard({ icon, label, value, accent, sub }: { icon: 'user' | 'warning' | 'clock'; label: string; value: string; accent: string; sub?: string }): JSX.Element {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: `${space(3)}px`, padding: `${space(4)}px`, backgroundColor: colorVar('color.bg.elevated'), border: `1px solid ${colorVar('color.border.default')}`, borderRadius: `${RADIUS.lg}px`, boxShadow: '0 1px 2px rgba(16,24,40,0.04)' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 40, height: 40, borderRadius: 10, backgroundColor: `${accent}1A`, color: accent, flexShrink: 0 }}>
<Icon name={icon} size={20} />
</span>
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>{label}</span>
<span style={{ ...typographyStyle('heading'), fontWeight: 800, color: accent, lineHeight: 1.1 }}>{value}</span>
{sub !== undefined && <span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), fontSize: '11px' }}>{sub}</span>}
</div>
</div>
);
}
function MCard({ label, value, sub, color }: { label: string; value: string; sub: string; color?: string | undefined }): JSX.Element {
return (
<div style={{ padding: `${space(2)}px`, borderRadius: `${RADIUS.md}px`, backgroundColor: colorVar('color.bg.surface'), textAlign: 'center' }}>
<div style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>{label}</div>
<div style={{ ...typographyStyle('title'), fontWeight: 700, color: color ?? colorVar('color.text.primary'), margin: '2px 0' }}>{value}</div>
<div style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), fontSize: '11px' }}>{sub}</div>
</div>
);
}
/** 回款记录面板:录入应收/到期/回款,自动重算平均逾期天数(驱动逾期红线)。 */
function PaymentsPanel({ customerId, onChanged }: { readonly customerId: string; readonly onChanged: () => void }): JSX.Element {
const [items, setItems] = useState<CustomerPayment[]>([]);
const [dueDate, setDueDate] = useState('');
const [paidDate, setPaidDate] = useState('');
const [amount, setAmount] = useState('');
const [avg, setAvg] = useState<number | null>(null);
const reload = useCallback(() => {
fetchPayments(customerId).then(setItems).catch(() => setItems([]));
}, [customerId]);
useEffect(() => { reload(); }, [reload]);
const inputStyle: React.CSSProperties = {
padding: `${space(1)}px ${space(2)}px`, border: `1px solid ${colorVar('color.border.default')}`,
borderRadius: `${RADIUS.md}px`, fontFamily: FONT_FAMILY, ...typographyStyle('caption'),
backgroundColor: colorVar('color.bg.canvas'), color: colorVar('color.text.primary'),
};
async function handleAdd(): Promise<void> {
if (dueDate === '') return;
const res = await addPayment(customerId, { invoiceAmount: Number(amount) || 0, dueDate, paidDate: paidDate || null });
setAvg(res.avgOverdueDays);
setDueDate(''); setPaidDate(''); setAmount('');
reload();
onChanged();
}
async function handleDelete(pid: number): Promise<void> {
const res = await deletePaymentApi(customerId, pid);
setAvg(res.avgOverdueDays);
reload();
onChanged();
}
function overdueOf(p: CustomerPayment): number {
const end = p.paidDate !== null ? new Date(p.paidDate) : new Date();
return Math.max(0, Math.round((end.getTime() - new Date(p.dueDate).getTime()) / 86400000));
}
return (
<div style={{ marginBottom: `${space(3)}px` }}>
<div style={{ ...typographyStyle('title'), fontWeight: 700, color: colorVar('color.text.primary'), marginBottom: `${space(2)}px` }}>
{items.length}{avg !== null ? ` · 已重算平均逾期 ${avg}` : ''}
</div>
<div style={{ display: 'flex', gap: `${space(2)}px`, flexWrap: 'wrap', alignItems: 'center', marginBottom: `${space(2)}px` }}>
<input type="date" style={inputStyle} value={dueDate} onChange={(e) => setDueDate(e.target.value)} title="到期日" />
<input type="date" style={inputStyle} value={paidDate} onChange={(e) => setPaidDate(e.target.value)} title="实际回款日(留空=未回款)" />
<input style={{ ...inputStyle, width: 120 }} value={amount} onChange={(e) => setAmount(e.target.value)} placeholder="发票金额(选填)" inputMode="decimal" />
<button type="button" onClick={handleAdd} disabled={dueDate === ''} style={{ padding: `${space(1)}px ${space(3)}px`, borderRadius: `${RADIUS.md}px`, border: 'none', backgroundColor: colorVar('color.brand.primary'), color: '#fff', cursor: dueDate === '' ? 'not-allowed' : 'pointer', opacity: dueDate === '' ? 0.6 : 1, fontWeight: 600 }}>+ </button>
</div>
{items.length === 0 ? (
<p style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>/</p>
) : (
<div style={{ maxHeight: 180, overflowY: 'auto', border: `1px solid ${colorVar('color.border.default')}`, borderRadius: `${RADIUS.md}px` }}>
{items.map((p) => {
const od = overdueOf(p);
return (
<div key={p.id} style={{ padding: `${space(1)}px ${space(3)}px`, borderBottom: `1px solid ${colorVar('color.border.default')}`, display: 'flex', justifyContent: 'space-between', alignItems: 'center', ...typographyStyle('caption') }}>
<span style={{ color: colorVar('color.text.primary') }}> {p.dueDate} · {p.paidDate !== null ? `回款 ${p.paidDate}` : '未回款'}{p.invoiceAmount > 0 ? ` · ${p.invoiceAmount.toLocaleString('zh-CN')}` : ''}</span>
<div style={{ display: 'flex', gap: `${space(2)}px`, alignItems: 'center' }}>
<span style={{ color: od > 0 ? '#BE123C' : '#15803D', fontWeight: 600 }}> {od} </span>
<button type="button" onClick={() => handleDelete(p.id)} style={{ display: 'inline-flex', alignItems: 'center', border: 'none', background: 'transparent', color: colorVar('color.risk.critical'), cursor: 'pointer' }} aria-label="删除"><Icon name="close" size={15} /></button>
</div>
</div>
);
})}
</div>
)}
</div>
);
}
+732
View File
@@ -0,0 +1,732 @@
/**
* Dashboard — 评估历史列表与按角色的待办工作台。
*
* 列表与待办均走**服务端 SQL 分页**(page/pageSize/status/q),统计卡走 summary 聚合接口,
* 不再把全量数据载入前端,支持大数据量。
*/
import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button, Card, Table } from '../design-system/index.js';
import type { TableColumn } from '../design-system/index.js';
import { Icon } from '../design-system/index.js';
import {
colorVar,
FONT_FAMILY,
RADIUS,
space,
typographyStyle,
} from '../design-system/components/styles.js';
import { fetchAssessmentsPage, fetchSummary, archiveAssessment, applyCalibration, listDrafts, deleteDraftApi, API_BASE } from '../api/client.js';
import type { AssessmentListItem, WorkflowStatus, DraftItem } from '../api/client.js';
import { useAuthStore } from '../stores/authStore.js';
import { GuideBanner } from '../app/Guidance.js';
import { RiskBadge } from '../charts/index.js';
const STATUS_LABEL: Record<WorkflowStatus, string> = {
draft: '草稿待申报',
pending_risk_review: '待风控审核',
risk_reviewed: '风控已审核',
pending_management_approval: '待管理层审批',
approved: '已通过',
rejected: '已驳回',
abandoned: '已放弃',
};
const STATUS_STYLE: Record<WorkflowStatus, { bg: string; fg: string }> = {
draft: { bg: 'rgba(100, 116, 139, 0.12)', fg: '#64748B' },
pending_risk_review: { bg: 'rgba(180, 83, 9, 0.12)', fg: '#B45309' },
risk_reviewed: { bg: 'rgba(37, 99, 235, 0.12)', fg: '#2563EB' },
pending_management_approval: { bg: 'rgba(124, 58, 237, 0.12)', fg: '#7C3AED' },
approved: { bg: 'rgba(16, 128, 61, 0.12)', fg: '#15803D' },
rejected: { bg: 'rgba(190, 18, 60, 0.12)', fg: '#BE123C' },
abandoned: { bg: 'rgba(100, 116, 139, 0.14)', fg: '#475569' },
};
type StatusFilter = 'all' | WorkflowStatus;
const STATUS_FILTER_OPTIONS: ReadonlyArray<{ value: StatusFilter; label: string }> = [
{ value: 'all', label: '全部状态' },
{ value: 'draft', label: STATUS_LABEL.draft },
{ value: 'pending_risk_review', label: STATUS_LABEL.pending_risk_review },
{ value: 'risk_reviewed', label: STATUS_LABEL.risk_reviewed },
{ value: 'pending_management_approval', label: STATUS_LABEL.pending_management_approval },
{ value: 'approved', label: STATUS_LABEL.approved },
{ value: 'rejected', label: STATUS_LABEL.rejected },
{ value: 'abandoned', label: STATUS_LABEL.abandoned },
];
/** 角色 → 待办状态。 */
const TODO_STATUS: Record<string, WorkflowStatus> = {
'商务/销售': 'rejected',
'风控': 'pending_risk_review',
'管理层': 'risk_reviewed',
};
/** 承接建议等级配色。 */
const REC_BG: Record<string, string> = {
accept: 'rgba(16,128,61,0.12)',
conditional: 'rgba(194,65,12,0.12)',
caution: 'rgba(194,65,12,0.12)',
reject: 'rgba(190,18,60,0.12)',
};
const REC_FG: Record<string, string> = {
accept: '#15803D',
conditional: '#C2410C',
caution: '#C2410C',
reject: '#BE123C',
};
function formatDateTime(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
}
export function Dashboard(): JSX.Element {
const navigate = useNavigate();
const { user } = useAuthStore();
const role = user?.role ?? '商务/销售';
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
// 历史列表(服务端分页)
const [items, setItems] = useState<AssessmentListItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
const [archivedView, setArchivedView] = useState<'active' | 'archived'>('active');
const [searchInput, setSearchInput] = useState('');
const [search, setSearch] = useState('');
// 待办列表
const [todoItems, setTodoItems] = useState<AssessmentListItem[]>([]);
// 草稿箱(销售:未运行/未提交的向导进度,服务端持久化)
const [drafts, setDrafts] = useState<DraftItem[]>([]);
// 统计
const [summary, setSummary] = useState<{ total: number; byStatus: Record<string, number>; archived?: number }>({ total: 0, byStatus: {} });
// 告警
const [expiring, setExpiring] = useState<Array<{ id: string; project: string }>>([]);
const [overdue, setOverdue] = useState<Array<{ id: string; project: string; overdueHours: number }>>([]);
const [rejectStats, setRejectStats] = useState<Array<{ reasonType: string; count: number }>>([]);
const [accuracy, setAccuracy] = useState<{ count: number; avgPredictedPct: number | null; avgActualPct: number | null; avgDeviationPct: number | null; bias: string | null }>({ count: 0, avgPredictedPct: null, avgActualPct: null, avgDeviationPct: null, bias: null });
// 搜索防抖
useEffect(() => {
const t = setTimeout(() => {
setSearch(searchInput);
setPage(1);
}, 350);
return () => clearTimeout(t);
}, [searchInput]);
const loadList = useCallback(() => {
setLoading(true);
fetchAssessmentsPage({ page, pageSize, status: statusFilter, q: search, archived: archivedView })
.then((res) => {
setItems(res.items);
setTotal(res.total);
setError(null);
})
.catch((err: unknown) => setError(err instanceof Error ? err.message : '加载失败'))
.finally(() => setLoading(false));
}, [page, pageSize, statusFilter, search, archivedView]);
useEffect(() => {
loadList();
}, [loadList]);
const loadAux = useCallback(() => {
fetchSummary().then(setSummary).catch(() => undefined);
const todoStatus = TODO_STATUS[role];
if (todoStatus !== undefined) {
fetchAssessmentsPage({ page: 1, pageSize: 50, status: todoStatus })
.then((res) => setTodoItems(res.items))
.catch(() => setTodoItems([]));
} else {
setTodoItems([]);
}
// 告警数据
fetch(`${API_BASE}/api/assessments/expiring`).then((r) => r.json()).then(setExpiring).catch(() => setExpiring([]));
fetch(`${API_BASE}/api/assessments/overdue`).then((r) => r.json()).then(setOverdue).catch(() => setOverdue([]));
fetch(`${API_BASE}/api/reject-reason-stats`).then((r) => r.json()).then(setRejectStats).catch(() => setRejectStats([]));
fetch(`${API_BASE}/api/accuracy`).then((r) => r.json()).then(setAccuracy).catch(() => undefined);
// 草稿箱(仅销售展示):列出当前用户的向导草稿。
if (role === '商务/销售') {
listDrafts(user?.username ?? undefined).then(setDrafts).catch(() => setDrafts([]));
} else {
setDrafts([]);
}
}, [role, user?.username]);
/** 删除一条草稿并刷新草稿箱。 */
const removeDraft = useCallback(async (id: string): Promise<void> => {
try { await deleteDraftApi(id); } catch { /* ignore */ }
setDrafts((ds) => ds.filter((d) => d.id !== id));
}, []);
useEffect(() => {
loadAux();
}, [loadAux]);
const handleArchive = useCallback(
(id: string, archived: boolean) => {
archiveAssessment(id, archived, user?.username)
.then(() => {
loadList();
loadAux();
})
.catch((err: unknown) => setError(err instanceof Error ? err.message : '操作失败'));
},
[loadList, loadAux, user],
);
const columns: ReadonlyArray<TableColumn<AssessmentListItem>> = [
{
key: 'project',
header: '项目描述',
render: (r) => {
// 提取【项目】后的名称,去掉【客户】部分
const nameMatch = r.projectDescription.match(/【项目】([^|\n]+)/);
const name = nameMatch !== null && nameMatch[1] !== undefined ? nameMatch[1].trim() : r.projectDescription.slice(0, 20);
const display = name.length > 18 ? `${name.slice(0, 18)}` : name;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px', maxWidth: 200 }}>
<span style={{ fontWeight: 700, color: colorVar('color.text.primary'), whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{display}
</span>
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), whiteSpace: 'nowrap' }}>{r.assessorId}</span>
</div>
);
},
},
{ key: 'type', header: '业务类型', field: 'businessType' },
{ key: 'industry', header: '行业', field: 'industry' },
{ key: 'score', header: '风险分', align: 'right', render: (r) => (r.riskScore !== undefined ? r.riskScore.toFixed(1) : '-') },
{
key: 'grade',
header: '风险分级',
render: (r) => (r.riskGrade !== undefined ? <RiskBadge grade={r.riskGrade as '低' | '中' | '高' | '极高'} /> : '-'),
},
{
key: 'status',
header: '状态',
render: (r) => (
<span
style={{
display: 'inline-block',
padding: '2px 10px',
borderRadius: '999px',
backgroundColor: STATUS_STYLE[r.status].bg,
color: STATUS_STYLE[r.status].fg,
...typographyStyle('caption'),
fontWeight: 600,
whiteSpace: 'nowrap',
}}
>
{STATUS_LABEL[r.status]}
</span>
),
},
{ key: 'acceptability', header: '可接受性', render: (r) => r.acceptability ?? '-' },
{
key: 'recommendation',
header: '承接建议',
render: (r) =>
r.recommendation ? (
<span
style={{
display: 'inline-block',
padding: '2px 10px',
borderRadius: '999px',
...typographyStyle('caption'),
fontWeight: 600,
whiteSpace: 'nowrap',
backgroundColor: REC_BG[r.recommendation.level] ?? 'transparent',
color: REC_FG[r.recommendation.level] ?? colorVar('color.text.secondary'),
}}
>
{r.recommendation.title}
</span>
) : (
<span style={{ color: colorVar('color.text.secondary') }}>-</span>
),
},
{
key: 'latest',
header: '最近处理',
render: (r) => {
const latest = r.auditLog.at(-1);
return latest === undefined ? (
<span style={{ color: colorVar('color.text.secondary'), whiteSpace: 'nowrap' }}></span>
) : (
<div style={{ whiteSpace: 'nowrap', maxWidth: 140, overflow: 'hidden', textOverflow: 'ellipsis' }}>
<span style={{ ...typographyStyle('caption') }}>{latest.action.length > 10 ? latest.action.slice(0, 10) + '…' : latest.action}</span>
<div style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>
{latest.role} · {formatDateTime(latest.timestamp)}
</div>
</div>
);
},
},
{ key: 'created', header: '创建时间', render: (r) => formatDateTime(r.createdAt) },
{
key: 'action',
header: '操作',
render: (r) => {
const canArchive = r.status === 'approved' || r.status === 'abandoned';
return (
<div style={{ display: 'flex', gap: `${space(1)}px`, whiteSpace: 'nowrap' }}>
<Button size="sm" variant="ghost" onClick={() => navigate(`/assessments/${r.id}`)}>
</Button>
{archivedView === 'archived' ? (
<Button size="sm" variant="ghost" onClick={() => handleArchive(r.id, false)}>
</Button>
) : (
<span title={canArchive ? '归档' : '仅「最终通过」或「已放弃」可归档'} style={{ display: 'inline-flex' }}>
<Button
size="sm"
variant="ghost"
disabled={!canArchive}
onClick={() => handleArchive(r.id, true)}
>
</Button>
</span>
)}
</div>
);
},
},
];
const todoCount = summary.byStatus[TODO_STATUS[role] ?? ''] ?? 0;
const summaryItems = [
{ label: '全部评估', value: summary.total, tone: colorVar('color.brand.primary') },
{ label: '我的待办', value: todoCount, tone: colorVar('color.risk.high') },
{ label: '待风控', value: summary.byStatus.pending_risk_review ?? 0, tone: colorVar('color.risk.medium') },
{ label: '已通过', value: summary.byStatus.approved ?? 0, tone: colorVar('color.risk.low') },
];
const pageTitle = role === '风控' ? '待办审核' : role === '管理层' ? '待办审批' : '评估历史';
const roleDescription =
role === '风控'
? '聚焦待复核项目,补充风险判断并留下处理痕迹。'
: role === '管理层'
? '查看全部历史与高风险事项,完成最终审批决策。'
: '发起新评估,跟踪退回事项,并查看全量历史记录。';
const totalPages = Math.max(1, Math.ceil(total / pageSize));
return (
<div style={{ fontFamily: FONT_FAMILY }}>
{role === '商务/销售' && (
<GuideBanner id="role-sales" tone="brand">
<strong> /</strong>线 <strong>+ </strong> <strong></strong> <strong></strong>退
</GuideBanner>
)}
{role === '风控' && (
<GuideBanner id="role-risk" tone="brand">
<strong> </strong> <strong></strong> 线<strong> / </strong>线"需人工核实"
</GuideBanner>
)}
{role === '管理层' && (
<GuideBanner id="role-mgmt" tone="brand">
<strong> </strong> <strong></strong> // <strong> / 线 / </strong>
</GuideBanner>
)}
<div
style={{
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'space-between',
alignItems: 'center',
gap: `${space(3)}px`,
marginBottom: `${space(5)}px`,
padding: `${space(5)}px`,
background: `linear-gradient(135deg, ${colorVar('color.bg.elevated')} 0%, ${colorVar('color.bg.surface')} 100%)`,
border: `1px solid ${colorVar('color.border.default')}`,
borderRadius: `${RADIUS.lg}px`,
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: `${space(2)}px` }}>
<span style={{ ...typographyStyle('caption'), color: colorVar('color.brand.primary'), fontWeight: 700 }}>{role} </span>
<h1 style={{ ...typographyStyle('heading'), margin: 0, color: colorVar('color.text.primary') }}>{pageTitle}</h1>
<p style={{ margin: 0, color: colorVar('color.text.secondary'), maxWidth: 620 }}>{roleDescription}</p>
</div>
{role === '商务/销售' && (
<Button onClick={() => navigate('/new')} size="lg"><span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}><Icon name="plus" size={16} color="currentColor" /> </span></Button>
)}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: `${space(3)}px`, marginBottom: `${space(4)}px` }}>
{summaryItems.map((item) => (
<div
key={item.label}
style={{
padding: `${space(4)}px`,
backgroundColor: colorVar('color.bg.elevated'),
border: `1px solid ${colorVar('color.border.default')}`,
borderRadius: `${RADIUS.lg}px`,
display: 'flex',
flexDirection: 'column',
gap: `${space(2)}px`,
}}
>
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>{item.label}</span>
<span style={{ ...typographyStyle('heading'), color: item.tone, fontWeight: 800 }}>{item.value}</span>
</div>
))}
</div>
{/* 告警卡片 */}
{(expiring.length > 0 || overdue.length > 0 || rejectStats.length > 0 || accuracy.count > 0) && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: `${space(3)}px`, marginBottom: `${space(4)}px` }}>
{accuracy.count > 0 && accuracy.bias !== null && (() => {
const consistent = accuracy.bias === '基本一致';
const accent = consistent ? '#15803D' : '#B45309';
const dev = accuracy.avgDeviationPct ?? 0;
return (
<div style={{ padding: `${space(4)}px`, backgroundColor: colorVar('color.bg.elevated'), border: `1px solid ${colorVar('color.border.default')}`, borderRadius: `${RADIUS.lg}px`, boxShadow: '0 1px 2px rgba(16,24,40,0.04)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: `${space(2)}px`, marginBottom: `${space(3)}px` }}>
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 32, height: 32, borderRadius: 8, backgroundColor: `${accent}1A`, color: accent }}>
<Icon name="trending-up" size={18} />
</span>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span style={{ ...typographyStyle('body'), fontWeight: 700, color: colorVar('color.text.primary') }}></span>
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}> {accuracy.count} </span>
</div>
</div>
<div style={{ display: 'flex', gap: `${space(3)}px`, marginBottom: `${space(3)}px` }}>
<div style={{ flex: 1, padding: `${space(2)}px ${space(3)}px`, backgroundColor: colorVar('color.bg.surface'), borderRadius: `${RADIUS.md}px` }}>
<div style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}></div>
<div style={{ ...typographyStyle('body'), fontWeight: 800, color: colorVar('color.text.primary') }}>{accuracy.avgPredictedPct}%</div>
</div>
<div style={{ flex: 1, padding: `${space(2)}px ${space(3)}px`, backgroundColor: colorVar('color.bg.surface'), borderRadius: `${RADIUS.md}px` }}>
<div style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}></div>
<div style={{ ...typographyStyle('body'), fontWeight: 800, color: colorVar('color.text.primary') }}>{accuracy.avgActualPct}%</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: `${space(2)}px` }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: `2px ${space(2)}px`, borderRadius: '999px', backgroundColor: `${accent}1A`, color: accent, ...typographyStyle('caption'), fontWeight: 700 }}>
<Icon name={consistent ? 'check-circle' : 'alert'} size={13} />
{dev > 0 ? '+' : ''}{accuracy.avgDeviationPct} pp · {accuracy.bias}
</span>
</div>
{!consistent && (
<div style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), marginTop: `${space(2)}px`, lineHeight: 1.6 }}>
{accuracy.bias === '预测偏乐观' ? '建议下调报价预期或上调成本假设进行校准。' : '成本假设或偏保守,可适当下调目标基准。'}
</div>
)}
{role === '管理层' && !consistent && (
<button
type="button"
onClick={() => {
applyCalibration()
.then((r) => { setError(null); setNotice(`已应用校准:目标净利率基准 ${(r.previousBase * 100).toFixed(1)}% → ${(r.appliedBase * 100).toFixed(1)}%`); loadAux(); })
.catch((e: unknown) => setError(e instanceof Error ? e.message : '校准失败'));
}}
style={{ marginTop: `${space(3)}px`, display: 'inline-flex', alignItems: 'center', gap: 6, padding: `${space(1)}px ${space(3)}px`, borderRadius: `${RADIUS.md}px`, border: `1px solid ${accent}`, background: 'transparent', color: accent, cursor: 'pointer', fontFamily: FONT_FAMILY, ...typographyStyle('caption'), fontWeight: 600 }}
>
<Icon name="settings" size={14} />
</button>
)}
</div>
);
})()}
{expiring.length > 0 && (
<div style={{ padding: `${space(4)}px`, backgroundColor: colorVar('color.bg.elevated'), border: `1px solid ${colorVar('color.border.default')}`, borderRadius: `${RADIUS.lg}px`, boxShadow: '0 1px 2px rgba(16,24,40,0.04)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: `${space(2)}px`, marginBottom: `${space(2)}px` }}>
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 32, height: 32, borderRadius: 8, backgroundColor: 'rgba(180,83,9,0.1)', color: '#B45309' }}>
<Icon name="clock" size={18} />
</span>
<span style={{ ...typographyStyle('body'), fontWeight: 700, color: colorVar('color.text.primary') }}>{expiring.length}</span>
</div>
{expiring.slice(0, 3).map((e) => (
<div key={e.id} style={{ ...typographyStyle('caption'), color: colorVar('color.text.primary'), padding: '3px 0' }}>{e.project.slice(0, 25)}</div>
))}
</div>
)}
{overdue.length > 0 && (
<div style={{ padding: `${space(4)}px`, backgroundColor: colorVar('color.bg.elevated'), border: `1px solid ${colorVar('color.border.default')}`, borderRadius: `${RADIUS.lg}px`, boxShadow: '0 1px 2px rgba(16,24,40,0.04)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: `${space(2)}px`, marginBottom: `${space(2)}px` }}>
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 32, height: 32, borderRadius: 8, backgroundColor: 'rgba(190,18,60,0.1)', color: '#BE123C' }}>
<Icon name="alert" size={18} />
</span>
<span style={{ ...typographyStyle('body'), fontWeight: 700, color: colorVar('color.text.primary') }}>{overdue.length}</span>
</div>
{overdue.slice(0, 3).map((e) => (
<div key={e.id} style={{ ...typographyStyle('caption'), color: colorVar('color.text.primary'), padding: '3px 0' }}>{e.project.slice(0, 20)} · {e.overdueHours}h</div>
))}
</div>
)}
{rejectStats.length > 0 && (() => {
const maxCount = Math.max(...rejectStats.map((s) => s.count), 1);
return (
<div style={{ padding: `${space(4)}px`, backgroundColor: colorVar('color.bg.elevated'), border: `1px solid ${colorVar('color.border.default')}`, borderRadius: `${RADIUS.lg}px`, boxShadow: '0 1px 2px rgba(16,24,40,0.04)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: `${space(2)}px`, marginBottom: `${space(3)}px` }}>
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 32, height: 32, borderRadius: 8, backgroundColor: 'rgba(190,18,60,0.1)', color: '#BE123C' }}>
<Icon name="ban" size={18} />
</span>
<span style={{ ...typographyStyle('body'), fontWeight: 700, color: colorVar('color.text.primary') }}> Top </span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: `${space(2)}px` }}>
{rejectStats.slice(0, 4).map((s) => (
<div key={s.reasonType} style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', ...typographyStyle('caption'), color: colorVar('color.text.primary') }}>
<span>{s.reasonType}</span>
<span style={{ fontWeight: 700, color: '#BE123C' }}>{s.count} </span>
</div>
<div style={{ height: 6, borderRadius: 999, backgroundColor: colorVar('color.bg.surface'), overflow: 'hidden' }}>
<div style={{ width: `${(s.count / maxCount) * 100}%`, height: '100%', borderRadius: 999, backgroundColor: '#BE123C', opacity: 0.85 }} />
</div>
</div>
))}
</div>
</div>
);
})()}
</div>
)}
{error !== null && (
<div style={{ padding: `${space(3)}px`, backgroundColor: 'rgba(190,18,60,0.08)', color: colorVar('color.risk.critical'), borderRadius: `${RADIUS.md}px`, marginBottom: `${space(3)}px` }}>
{error}
</div>
)}
{notice !== null && (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: `${space(3)}px`, backgroundColor: 'rgba(16,128,61,0.08)', color: '#15803D', borderRadius: `${RADIUS.md}px`, marginBottom: `${space(3)}px`, fontWeight: 600 }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}><Icon name="check-circle" size={16} /> {notice}</span>
<button type="button" onClick={() => setNotice(null)} style={{ display: 'inline-flex', border: 'none', background: 'transparent', color: '#15803D', cursor: 'pointer' }} aria-label="关闭"><Icon name="close" size={16} /></button>
</div>
)}
{todoItems.length > 0 && (
<div style={{ marginBottom: `${space(4)}px` }}>
<Card title={`待处理 (${todoCount})`}>
<Table columns={columns} data={todoItems} getRowKey={(row) => row.id} caption={`${role} 待处理列表`} emptyMessage="当前没有需要你处理的评估" />
</Card>
</div>
)}
{role === '商务/销售' && drafts.length > 0 && (
<div style={{ marginBottom: `${space(4)}px` }}>
<Card title={`草稿箱 (${drafts.length})`}>
<p style={{ margin: `0 0 ${space(2)}px`, ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>
/
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: `${space(1)}px` }}>
{drafts.map((d) => (
<div key={d.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: `${space(2)}px`, padding: `${space(2)}px ${space(3)}px`, border: `1px solid ${colorVar('color.border.default')}`, borderRadius: `${RADIUS.md}px` }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }}>
<span style={{ ...typographyStyle('body'), color: colorVar('color.text.primary'), fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<Icon name={d.sourceAssessmentId !== null ? 'edit' : 'file'} size={15} color={colorVar('color.brand.primary')} />
{d.sourceAssessmentId !== null ? '编辑草稿' : '新建草稿'}{d.projectName ?? '未命名项目'}
</span>
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>
{formatDateTime(d.updatedAt)}
</span>
</div>
<div style={{ display: 'flex', gap: `${space(1)}px`, whiteSpace: 'nowrap' }}>
<Button size="sm" onClick={() => navigate(`/new?draft=${encodeURIComponent(d.id)}`)}></Button>
<Button size="sm" variant="ghost" onClick={() => { void removeDraft(d.id); }}></Button>
</div>
</div>
))}
</div>
</Card>
</div>
)}
<Card
title={
<div style={{ display: 'flex', flexDirection: 'column', gap: `${space(1)}px` }}>
<span></span>
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), fontWeight: 400 }}>
· {total}
</span>
</div>
}
>
<div style={{ display: 'flex', gap: `${space(2)}px`, marginBottom: `${space(3)}px`, borderBottom: `1px solid ${colorVar('color.border.default')}`, paddingBottom: `${space(2)}px` }}>
{(['active', 'archived'] as const).map((v) => {
const active = archivedView === v;
return (
<button
key={v}
type="button"
onClick={() => {
setArchivedView(v);
setPage(1);
}}
style={{
padding: `${space(1)}px ${space(3)}px`,
borderRadius: '999px',
border: `1px solid ${active ? colorVar('color.brand.primary') : colorVar('color.border.default')}`,
backgroundColor: active ? colorVar('color.brand.primary') : 'transparent',
color: active ? '#fff' : colorVar('color.text.secondary'),
cursor: 'pointer',
fontFamily: FONT_FAMILY,
...typographyStyle('caption'),
fontWeight: 600,
}}
>
{v === 'active' ? '进行中' : `已归档${summary.archived ? ` (${summary.archived})` : ''}`}
</button>
);
})}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'space-between', gap: `${space(3)}px`, marginBottom: `${space(3)}px` }}>
<input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="搜索项目、业务类型、行业或发起人"
style={{
minWidth: 280,
flex: '1 1 320px',
padding: `${space(2)}px ${space(3)}px`,
border: `1px solid ${colorVar('color.border.default')}`,
borderRadius: `${RADIUS.md}px`,
backgroundColor: colorVar('color.bg.canvas'),
color: colorVar('color.text.primary'),
fontFamily: FONT_FAMILY,
...typographyStyle('body'),
}}
/>
<select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value as StatusFilter);
setPage(1);
}}
style={{
minWidth: 160,
padding: `${space(2)}px ${space(3)}px`,
border: `1px solid ${colorVar('color.border.default')}`,
borderRadius: `${RADIUS.md}px`,
backgroundColor: colorVar('color.bg.canvas'),
color: colorVar('color.text.primary'),
fontFamily: FONT_FAMILY,
...typographyStyle('body'),
}}
>
{STATUS_FILTER_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
{/* 状态图例 */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: `${space(2)}px`, marginBottom: `${space(3)}px` }}>
{(['draft', 'pending_risk_review', 'risk_reviewed', 'approved', 'rejected', 'abandoned'] as WorkflowStatus[]).map((s) => (
<span key={s} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>
<span style={{ width: 10, height: 10, borderRadius: '50%', backgroundColor: STATUS_STYLE[s].fg, display: 'inline-block' }} />
{STATUS_LABEL[s]}
</span>
))}
</div>
{loading ? (
<div style={{ padding: `${space(4)}px`, color: colorVar('color.text.secondary') }}></div>
) : (
<>
<Table
columns={columns}
data={items}
getRowKey={(row) => row.id}
caption="全部评估记录列表"
emptyMessage={total === 0 ? (archivedView === 'archived' ? '暂无已归档的评估' : (role === '商务/销售' ? '还没有评估。点击右上角「+ 新建评估」开始第一单。' : '暂无评估记录')) : '没有匹配筛选条件的评估'}
/>
<Pager
page={page}
totalPages={totalPages}
total={total}
pageSize={pageSize}
onPage={setPage}
onPageSize={(s) => {
setPageSize(s);
setPage(1);
}}
/>
</>
)}
</Card>
</div>
);
}
/** 服务端分页器。 */
function Pager({
page,
totalPages,
total,
pageSize,
onPage,
onPageSize,
}: {
readonly page: number;
readonly totalPages: number;
readonly total: number;
readonly pageSize: number;
readonly onPage: (p: number) => void;
readonly onPageSize: (s: number) => void;
}): JSX.Element {
const btn = (label: string, to: number, disabled: boolean): JSX.Element => (
<button
type="button"
disabled={disabled}
onClick={() => onPage(to)}
style={{
padding: `${space(1)}px ${space(2)}px`,
borderRadius: `${RADIUS.sm}px`,
border: `1px solid ${colorVar('color.border.default')}`,
background: 'transparent',
color: disabled ? colorVar('color.text.secondary') : colorVar('color.text.primary'),
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.5 : 1,
fontFamily: FONT_FAMILY,
...typographyStyle('caption'),
}}
>
{label}
</button>
);
return (
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'space-between', gap: `${space(2)}px`, marginTop: `${space(3)}px` }}>
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>
{total} {page} / {totalPages}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: `${space(2)}px` }}>
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}></span>
<select
value={pageSize}
onChange={(e) => onPageSize(Number(e.target.value))}
style={{
padding: `${space(1)}px ${space(2)}px`,
borderRadius: `${RADIUS.sm}px`,
border: `1px solid ${colorVar('color.border.default')}`,
backgroundColor: colorVar('color.bg.canvas'),
color: colorVar('color.text.primary'),
fontFamily: FONT_FAMILY,
...typographyStyle('caption'),
}}
>
{[10, 20, 50].map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
{btn('首页', 1, page <= 1)}
{btn('上一页', page - 1, page <= 1)}
{btn('下一页', page + 1, page >= totalPages)}
{btn('末页', totalPages, page >= totalPages)}
</div>
</div>
);
}
+189
View File
@@ -0,0 +1,189 @@
/**
* 登录页面 — 3 个测试角色账号。
*/
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
colorVar,
FONT_FAMILY,
RADIUS,
SHADOW,
space,
typographyStyle,
} from '../design-system/components/styles.js';
import { useAuthStore, TEST_ACCOUNTS } from '../stores/authStore.js';
export function Login(): JSX.Element {
const navigate = useNavigate();
const { login, error, clearError } = useAuthStore();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = (e: React.FormEvent): void => {
e.preventDefault();
clearError();
const ok = login(username, password);
if (ok) {
navigate('/');
}
};
const pageStyle: React.CSSProperties = {
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: `${space(5)}px`,
padding: `${space(6)}px ${space(4)}px`,
backgroundColor: colorVar('color.bg.canvas'),
fontFamily: FONT_FAMILY,
};
const cardStyle: React.CSSProperties = {
fontFamily: FONT_FAMILY,
width: '100%',
maxWidth: 400,
padding: `${space(7)}px ${space(6)}px`,
backgroundColor: colorVar('color.bg.elevated'),
borderRadius: `${RADIUS.lg}px`,
border: `1px solid ${colorVar('color.border.default')}`,
boxShadow: SHADOW.md,
};
const inputStyle: React.CSSProperties = {
width: '100%',
padding: `${space(2)}px ${space(3)}px`,
border: `1px solid ${colorVar('color.border.default')}`,
borderRadius: `${RADIUS.md}px`,
fontFamily: FONT_FAMILY,
...typographyStyle('body'),
backgroundColor: colorVar('color.bg.canvas'),
color: colorVar('color.text.primary'),
boxSizing: 'border-box',
};
const buttonStyle: React.CSSProperties = {
width: '100%',
padding: `${space(3)}px`,
backgroundColor: colorVar('color.brand.primary'),
color: colorVar('color.text.onAccent'),
border: 'none',
borderRadius: `${RADIUS.md}px`,
cursor: 'pointer',
...typographyStyle('body'),
fontWeight: 600,
letterSpacing: '-0.01em',
};
return (
<div style={pageStyle}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: `${space(3)}px` }}>
<div
aria-hidden="true"
style={{
width: 52,
height: 52,
borderRadius: `${RADIUS.lg}px`,
background: `linear-gradient(135deg, ${colorVar('color.brand.primary')}, #7C83F0)`,
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 700,
fontSize: '24px',
boxShadow: SHADOW.sm,
}}
>
</div>
<div style={{ textAlign: 'center', display: 'flex', flexDirection: 'column', gap: `${space(1)}px` }}>
<h1 style={{ ...typographyStyle('heading'), margin: 0, letterSpacing: '-0.02em', color: colorVar('color.text.primary') }}>
</h1>
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>
</span>
</div>
</div>
<div style={cardStyle}>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: `${space(3)}px` }}>
<div>
<label style={{ display: 'block', marginBottom: `${space(1)}px`, color: colorVar('color.text.secondary'), ...typographyStyle('caption') }}>
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="请输入用户名"
style={inputStyle}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: `${space(1)}px`, color: colorVar('color.text.secondary'), ...typographyStyle('caption') }}>
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="请输入密码"
style={inputStyle}
/>
</div>
{error !== null && (
<div style={{ color: colorVar('color.risk.critical'), ...typographyStyle('caption'), textAlign: 'center' }}>
{error}
</div>
)}
<button type="submit" style={buttonStyle}>
</button>
</form>
<div style={{ marginTop: `${space(5)}px`, paddingTop: `${space(4)}px`, borderTop: `1px solid ${colorVar('color.border.default')}` }}>
<p style={{ margin: `0 0 ${space(3)}px`, color: colorVar('color.text.secondary'), ...typographyStyle('caption'), textAlign: 'center' }}>
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: `${space(2)}px` }}>
{TEST_ACCOUNTS.map((a) => (
<button
key={a.username}
type="button"
onClick={() => {
setUsername(a.username);
setPassword(a.password);
}}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: `${space(2)}px ${space(3)}px`,
backgroundColor: colorVar('color.bg.surface'),
border: `1px solid ${colorVar('color.border.default')}`,
borderRadius: `${RADIUS.md}px`,
cursor: 'pointer',
fontFamily: FONT_FAMILY,
textAlign: 'left',
width: '100%',
color: colorVar('color.text.primary'),
}}
>
<span style={{ fontWeight: 600 }}>{a.role}</span>
<span style={{ color: colorVar('color.text.secondary'), ...typographyStyle('caption') }}>
{a.username} / {a.password}
</span>
</button>
))}
</div>
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
+352
View File
@@ -0,0 +1,352 @@
/**
* 费率管理页面 — 地域费率套维护(与评估引擎对齐)。
*
* 专业设计:
* - 以「地域费率套」为单位维护(一套 = 五险单位费率 + 公积金 + 增值税 + 附加税)
* - 展示引擎内置默认费率(全国/上海/北京/广东)作为对照基准
* - 与默认对比:偏离默认值的项高亮
* - 复核流程:编辑即重置待复核,复核后驱动评估盈利测算
* - 实时显示社保单位合计、用工成本加载估算
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
import { colorVar, FONT_FAMILY, RADIUS, space, typographyStyle } from '../design-system/components/styles.js';
import { Card, Icon } from '../design-system/index.js';
import {
fetchEngineDefaults,
fetchRegionRates,
saveRegionRate,
reviewRegionRate,
deleteRegionRate,
fetchMinWages,
saveMinWage,
deleteMinWageApi,
type RegionRates,
type RegionRateRecord,
type MinWageItem,
} from '../api/client.js';
import { useAuthStore } from '../stores/authStore.js';
/** 费率项定义(路径 + 标签 + 分组 + 单位/个人说明)。 */
const RATE_FIELDS: ReadonlyArray<{ path: string; label: string; group: string; hint?: string }> = [
{ path: 'socialInsurance.pension', label: '养老保险(单位)', group: '社会保险(单位部分)', hint: '通常 14%~16%' },
{ path: 'socialInsurance.medical', label: '医疗保险(单位)', group: '社会保险(单位部分)', hint: '通常 4.5%~10%' },
{ path: 'socialInsurance.unemployment', label: '失业保险(单位)', group: '社会保险(单位部分)', hint: '通常 0.5%~0.8%' },
{ path: 'socialInsurance.injury', label: '工伤保险(单位)', group: '社会保险(单位部分)', hint: '按行业风险 0.16%~1.9%' },
{ path: 'socialInsurance.maternity', label: '生育保险(单位)', group: '社会保险(单位部分)', hint: '与医疗并轨地区填 0' },
{ path: 'housingFund', label: '住房公积金(单位)', group: '公积金', hint: '5%~12%,按属地' },
{ path: 'vatGeneralRate', label: '增值税(一般计税)', group: '税率', hint: '现代服务业 6%' },
{ path: 'vatSimplifiedRate', label: '增值税(简易/差额)', group: '税率', hint: '劳务派遣差额 5%' },
{ path: 'surchargeRate', label: '附加税费(占增值税)', group: '税率', hint: '城建+教育附加,约 12%' },
];
const REGIONS = ['全国默认', '上海', '北京', '广东', '深圳', '江苏', '浙江', '河北', '四川', '重庆', '湖北', '天津'];
function getPath(obj: RegionRates, path: string): number {
const parts = path.split('.');
let cur: unknown = obj;
for (const p of parts) cur = (cur as Record<string, unknown>)?.[p];
return typeof cur === 'number' ? cur : 0;
}
function setPath(obj: RegionRates, path: string, value: number): RegionRates {
const clone: RegionRates = JSON.parse(JSON.stringify(obj));
const parts = path.split('.');
let cur: Record<string, unknown> = clone as unknown as Record<string, unknown>;
for (let i = 0; i < parts.length - 1; i += 1) cur = cur[parts[i]!] as Record<string, unknown>;
cur[parts[parts.length - 1]!] = value;
return clone;
}
function emptyRates(regionName: string): RegionRates {
return {
regionName,
socialInsurance: { pension: 0, medical: 0, unemployment: 0, injury: 0, maternity: 0 },
housingFund: 0, vatGeneralRate: 0.06, vatSimplifiedRate: 0.05, surchargeRate: 0.12,
};
}
function socialTotal(r: RegionRates): number {
const s = r.socialInsurance;
return s.pension + s.medical + s.unemployment + s.injury + s.maternity;
}
/** 全成本加载系数估算(应发=1,加社保+公积金)。 */
function loadingFactor(r: RegionRates): number {
return 1 + socialTotal(r) + r.housingFund;
}
export function RateManagement(): JSX.Element {
const { user } = useAuthStore();
const [defaults, setDefaults] = useState<{ national: RegionRates; regions: Record<string, RegionRates> } | null>(null);
const [records, setRecords] = useState<RegionRateRecord[]>([]);
const [loading, setLoading] = useState(true);
const [editRegion, setEditRegion] = useState('');
const [editRates, setEditRates] = useState<RegionRates | null>(null);
const load = useCallback(() => {
setLoading(true);
Promise.all([fetchEngineDefaults(), fetchRegionRates()])
.then(([d, r]) => { setDefaults(d); setRecords(r); })
.finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
// 当前编辑地域的"引擎默认"基准(用于对比高亮)
const baseline = useMemo<RegionRates | null>(() => {
if (defaults === null || editRegion === '') return null;
const key = ['上海', '北京', '广东'].find((k) => editRegion.includes(k));
return key ? (defaults.regions[key] ?? defaults.national) : defaults.national;
}, [defaults, editRegion]);
function startEdit(region: string): void {
const existing = records.find((r) => r.region === region);
if (existing) {
setEditRates(existing.rates);
} else {
// 以引擎默认为初始值
const key = ['上海', '北京', '广东'].find((k) => region.includes(k));
const init = defaults ? (key ? (defaults.regions[key] ?? defaults.national) : defaults.national) : emptyRates(region);
setEditRates({ ...JSON.parse(JSON.stringify(init)), regionName: region });
}
setEditRegion(region);
}
async function handleSave(): Promise<void> {
if (editRates === null || editRegion === '') return;
await saveRegionRate(editRegion, { ...editRates, regionName: editRegion }, user?.username);
setEditRegion('');
setEditRates(null);
load();
}
const inputStyle: React.CSSProperties = {
padding: `${space(1)}px ${space(2)}px`,
border: `1px solid ${colorVar('color.border.default')}`,
borderRadius: `${RADIUS.sm}px`,
fontFamily: FONT_FAMILY, ...typographyStyle('body'),
backgroundColor: colorVar('color.bg.canvas'), color: colorVar('color.text.primary'),
width: 90, textAlign: 'right',
};
const groups = [...new Set(RATE_FIELDS.map((f) => f.group))];
return (
<div style={{ fontFamily: FONT_FAMILY, maxWidth: 1200, margin: '0 auto' }}>
<div style={{ marginBottom: `${space(4)}px` }}>
<h1 style={{ ...typographyStyle('heading'), color: colorVar('color.text.primary'), margin: 0 }}></h1>
<p style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), margin: `${space(1)}px 0 0` }}>
<strong></strong>
</p>
</div>
{loading ? <p style={{ color: colorVar('color.text.secondary') }}></p> : (
<>
{/* 最低工资标准(驱动"低于最低工资"红线) */}
<div style={{ marginBottom: `${space(4)}px` }}>
<MinWagePanel />
</div>
{/* 已维护的地域费率套 */}
<Card title={`已维护地域费率套(${records.length}`}>
{records.length === 0 ? (
<p style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>
使
</p>
) : (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead><tr>
{['地域', '社保单位合计', '公积金', '增值税(一般)', '附加税', '加载系数', '复核状态', '更新', '操作'].map((h) => (
<th key={h} style={{ textAlign: 'left', padding: `${space(2)}px`, borderBottom: `2px solid ${colorVar('color.border.default')}`, color: colorVar('color.text.secondary'), ...typographyStyle('caption'), fontWeight: 700 }}>{h}</th>
))}
</tr></thead>
<tbody>
{records.map((rec) => (
<tr key={rec.region}>
<td style={tdStyle}>{rec.region}</td>
<td style={tdStyle}>{(socialTotal(rec.rates) * 100).toFixed(2)}%</td>
<td style={tdStyle}>{(rec.rates.housingFund * 100).toFixed(1)}%</td>
<td style={tdStyle}>{(rec.rates.vatGeneralRate * 100).toFixed(1)}%</td>
<td style={tdStyle}>{(rec.rates.surchargeRate * 100).toFixed(0)}%</td>
<td style={{ ...tdStyle, fontWeight: 700, color: colorVar('color.brand.primary') }}>{loadingFactor(rec.rates).toFixed(3)}×</td>
<td style={tdStyle}>{rec.reviewed ? <span style={{ color: '#15803D', fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: 4 }}><Icon name="check-circle" size={14} /> </span> : <span style={{ color: '#B45309', display: 'inline-flex', alignItems: 'center', gap: 4 }}><Icon name="clock" size={14} /> </span>}</td>
<td style={{ ...tdStyle, ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>{rec.updatedBy ?? '—'}</td>
<td style={tdStyle}>
<div style={{ display: 'flex', gap: `${space(2)}px` }}>
<button onClick={() => startEdit(rec.region)} style={linkBtn(colorVar('color.brand.primary'))}></button>
{!rec.reviewed && <button onClick={() => reviewRegionRate(rec.region).then(load)} style={linkBtn('#15803D')}></button>}
<button onClick={() => deleteRegionRate(rec.region).then(load)} style={linkBtn(colorVar('color.risk.critical'))}></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div style={{ marginTop: `${space(3)}px`, display: 'flex', gap: `${space(2)}px`, flexWrap: 'wrap', alignItems: 'center' }}>
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>/</span>
{REGIONS.filter((r) => !records.some((rec) => rec.region === r)).map((r) => (
<button key={r} onClick={() => startEdit(r)} style={{ padding: `${space(1)}px ${space(3)}px`, borderRadius: `${RADIUS.md}px`, border: `1px dashed ${colorVar('color.brand.primary')}`, background: 'transparent', color: colorVar('color.brand.primary'), cursor: 'pointer', ...typographyStyle('caption') }}>+ {r}</button>
))}
</div>
</Card>
{/* 编辑表单 */}
{editRates !== null && (
<div style={{ marginTop: `${space(4)}px` }}>
<Card title={`维护「${editRegion}」费率套`}>
<p style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), margin: `0 0 ${space(3)}px` }}>
0.16 16%<span style={{ color: '#B45309' }}></span>
</p>
{groups.map((g) => (
<div key={g} style={{ marginBottom: `${space(3)}px` }}>
<div style={{ ...typographyStyle('caption'), fontWeight: 700, color: colorVar('color.text.secondary'), marginBottom: `${space(1)}px` }}>{g}</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: `${space(2)}px` }}>
{RATE_FIELDS.filter((f) => f.group === g).map((f) => {
const val = getPath(editRates, f.path);
const baseVal = baseline ? getPath(baseline, f.path) : val;
const deviated = Math.abs(val - baseVal) > 1e-6;
return (
<div key={f.path} style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
<label style={{ ...typographyStyle('caption'), color: colorVar('color.text.primary') }}>{f.label}</label>
<div style={{ display: 'flex', alignItems: 'center', gap: `${space(1)}px` }}>
<input
style={{ ...inputStyle, borderColor: deviated ? '#B45309' : colorVar('color.border.default'), color: deviated ? '#B45309' : colorVar('color.text.primary'), fontWeight: deviated ? 700 : 400 }}
value={val}
onChange={(e) => setEditRates((r) => r ? setPath(r, f.path, Number(e.target.value) || 0) : r)}
inputMode="decimal"
/>
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>{(val * 100).toFixed(2)}%</span>
</div>
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), fontSize: '11px' }}>
{f.hint}{deviated ? ` · 默认 ${(baseVal * 100).toFixed(2)}%` : ''}
</span>
</div>
);
})}
</div>
</div>
))}
{/* 汇总 */}
<div style={{ display: 'flex', gap: `${space(4)}px`, padding: `${space(3)}px`, backgroundColor: colorVar('color.bg.surface'), borderRadius: `${RADIUS.md}px`, marginBottom: `${space(3)}px`, flexWrap: 'wrap' }}>
<Summary label="社保单位合计" value={`${(socialTotal(editRates) * 100).toFixed(2)}%`} />
<Summary label="公积金" value={`${(editRates.housingFund * 100).toFixed(1)}%`} />
<Summary label="全成本加载系数" value={`${loadingFactor(editRates).toFixed(3)}×`} highlight />
<Summary label="说明" value="加载系数 = 1 + 社保 + 公积金(不含福利/摊销)" small />
</div>
<div style={{ display: 'flex', gap: `${space(2)}px` }}>
<button onClick={handleSave} style={{ padding: `${space(2)}px ${space(5)}px`, borderRadius: `${RADIUS.md}px`, border: 'none', backgroundColor: colorVar('color.brand.primary'), color: '#fff', cursor: 'pointer', fontWeight: 600 }}></button>
<button onClick={() => { setEditRegion(''); setEditRates(null); }} style={{ padding: `${space(2)}px ${space(4)}px`, borderRadius: `${RADIUS.md}px`, border: `1px solid ${colorVar('color.border.default')}`, background: 'transparent', cursor: 'pointer' }}></button>
</div>
</Card>
</div>
)}
{/* 引擎内置默认费率(只读对照) */}
{defaults !== null && (
<div style={{ marginTop: `${space(4)}px` }}>
<Card title="引擎内置默认费率(只读对照基准)">
<p style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), margin: `0 0 ${space(2)}px` }}>
</p>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead><tr>
{['地域', '养老', '医疗', '失业', '工伤', '生育', '公积金', '增值税', '附加税', '加载系数'].map((h) => (
<th key={h} style={{ textAlign: 'left', padding: `${space(1)}px ${space(2)}px`, borderBottom: `1px solid ${colorVar('color.border.default')}`, color: colorVar('color.text.secondary'), ...typographyStyle('caption'), fontWeight: 700 }}>{h}</th>
))}
</tr></thead>
<tbody>
{[defaults.national, ...Object.values(defaults.regions).filter((r) => r.regionName !== '全国(默认)')].map((r) => (
<tr key={r.regionName}>
<td style={defTd}>{r.regionName}</td>
<td style={defTd}>{(r.socialInsurance.pension * 100).toFixed(1)}%</td>
<td style={defTd}>{(r.socialInsurance.medical * 100).toFixed(1)}%</td>
<td style={defTd}>{(r.socialInsurance.unemployment * 100).toFixed(2)}%</td>
<td style={defTd}>{(r.socialInsurance.injury * 100).toFixed(2)}%</td>
<td style={defTd}>{(r.socialInsurance.maternity * 100).toFixed(2)}%</td>
<td style={defTd}>{(r.housingFund * 100).toFixed(1)}%</td>
<td style={defTd}>{(r.vatGeneralRate * 100).toFixed(1)}%</td>
<td style={defTd}>{(r.surchargeRate * 100).toFixed(0)}%</td>
<td style={{ ...defTd, fontWeight: 700, color: colorVar('color.brand.primary') }}>{loadingFactor(r).toFixed(3)}×</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
)}
</>
)}
</div>
);
}
const tdStyle: React.CSSProperties = { padding: `${space(2)}px`, borderBottom: '1px solid var(--color-border-default)', fontSize: '14px' };
const defTd: React.CSSProperties = { padding: `${space(1)}px ${space(2)}px`, borderBottom: '1px solid var(--color-border-default)', fontSize: '13px', whiteSpace: 'nowrap' };
function linkBtn(color: string): React.CSSProperties {
return { cursor: 'pointer', border: 'none', background: 'none', color, fontWeight: 600, fontSize: '12px' };
}
function Summary({ label, value, highlight, small }: { label: string; value: string; highlight?: boolean; small?: boolean }): JSX.Element {
return (
<div>
<div style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>{label}</div>
<div style={{ ...(small ? typographyStyle('caption') : typographyStyle('title')), fontWeight: small ? 400 : 700, color: highlight ? colorVar('color.brand.primary') : colorVar('color.text.primary'), marginTop: 2 }}>{value}</div>
</div>
);
}
/** 最低工资标准维护面板:驱动"低于最低工资"红线自动比对。 */
function MinWagePanel(): JSX.Element {
const [items, setItems] = useState<MinWageItem[]>([]);
const [region, setRegion] = useState('');
const [wage, setWage] = useState('');
const reload = useCallback(() => {
fetchMinWages().then(setItems).catch(() => setItems([]));
}, []);
useEffect(() => { reload(); }, [reload]);
const inputStyle: React.CSSProperties = {
padding: `${space(1)}px ${space(2)}px`, border: `1px solid ${colorVar('color.border.default')}`,
borderRadius: `${RADIUS.md}px`, fontFamily: FONT_FAMILY, ...typographyStyle('caption'),
backgroundColor: colorVar('color.bg.canvas'), color: colorVar('color.text.primary'),
};
async function handleSave(): Promise<void> {
const w = Number(wage);
if (region.trim() === '' || !Number.isFinite(w) || w <= 0) return;
await saveMinWage(region.trim(), w);
setRegion(''); setWage('');
reload();
}
return (
<Card title={`各地域最低工资标准(${items.length}`}>
<p style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), margin: `0 0 ${space(2)}px` }}>
线 HR/
</p>
<div style={{ display: 'flex', gap: `${space(2)}px`, flexWrap: 'wrap', alignItems: 'center', marginBottom: `${space(2)}px` }}>
<input style={{ ...inputStyle, width: 120 }} value={region} onChange={(e) => setRegion(e.target.value)} placeholder="地域(如 北京)" />
<input style={{ ...inputStyle, width: 140 }} value={wage} onChange={(e) => setWage(e.target.value)} placeholder="月最低工资(元)" inputMode="decimal" />
<button type="button" onClick={handleSave} style={{ padding: `${space(1)}px ${space(3)}px`, borderRadius: `${RADIUS.md}px`, border: 'none', backgroundColor: colorVar('color.brand.primary'), color: '#fff', cursor: 'pointer', fontWeight: 600 }}>/</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: `${space(2)}px` }}>
{items.map((m) => (
<div key={m.region} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: `${space(1)}px ${space(2)}px`, border: `1px solid ${colorVar('color.border.default')}`, borderRadius: `${RADIUS.md}px`, ...typographyStyle('caption') }}>
<span style={{ color: colorVar('color.text.primary') }}>{m.region}</span>
<div style={{ display: 'flex', gap: `${space(2)}px`, alignItems: 'center' }}>
<span style={{ fontWeight: 700 }}>{m.monthlyWage.toLocaleString('zh-CN')} </span>
<button type="button" onClick={() => { void deleteMinWageApi(m.region).then(reload); }} style={{ display: 'inline-flex', alignItems: 'center', border: 'none', background: 'transparent', color: colorVar('color.risk.critical'), cursor: 'pointer' }} aria-label="删除"><Icon name="close" size={15} /></button>
</div>
</div>
))}
</div>
</Card>
);
}
+329
View File
@@ -0,0 +1,329 @@
/**
* 合规红线规则管理 — 规则引擎风格,与评估引擎对齐。
*
* 设计:
* - 后台维护的已启用红线会在评估时自动注入到模板 redlines(真正驱动评估)
* - 严重等级(一般/严重/致命)+ 颜色编码
* - 生效条件:地域 + 业务类型组合
* - 详情弹窗展示完整信息 + 引擎对照
* - 显示模板内置红线作为对照基准
*/
import { useCallback, useEffect, useState } from 'react';
import { colorVar, FONT_FAMILY, RADIUS, SHADOW, space, typographyStyle } from '../design-system/components/styles.js';
import { Card, Icon } from '../design-system/index.js';
import { fetchRedlineRules, createRedlineRule, deleteRedlineRuleApi, type RedlineRuleItem } from '../api/client.js';
/* ------------------------------------------------------------------ *
* 严重等级
* ------------------------------------------------------------------ */
const SEVERITY = [
{ value: '致命', label: '致命(一票否决)', color: '#BE123C', bg: 'rgba(190,18,60,0.08)' },
{ value: '严重', label: '严重(需整改)', color: '#B45309', bg: 'rgba(180,83,9,0.08)' },
{ value: '一般', label: '一般(关注)', color: '#2563EB', bg: 'rgba(37,99,235,0.08)' },
] as const;
function inferSeverity(consequence: string): (typeof SEVERITY)[number] {
if (/一票否决|立即停止|严禁|不得承接|违法/.test(consequence)) return SEVERITY[0];
if (/整改|限期|必须|超限/.test(consequence)) return SEVERITY[1];
return SEVERITY[2];
}
/* ------------------------------------------------------------------ *
* 页面
* ------------------------------------------------------------------ */
const REGIONS = ['', '全国', '上海', '北京', '广东', '深圳', '江苏', '浙江', '河北', '四川'];
const BIZ_TYPES = ['', '岗位外包', '劳务派遣', '业务/服务外包', 'BPO', '项目制外包'];
/** 可自动判定的关联度量。 */
const METRIC_OPTIONS = [
{ value: '', label: '(人工核实,不自动判定)' },
{ value: 'netMargin', label: '月净利率(%)' },
{ value: 'grossMargin', label: '月毛利率(%)' },
{ value: 'avgOverdueDays', label: '客户平均逾期天数(天)' },
{ value: 'concentration', label: '单客户集中度(%)' },
{ value: 'dispatchRatio', label: '派遣用工比例(%)' },
{ value: 'belowMinWageCount', label: '低于最低工资的岗位数' },
{ value: 'ind:qualification', label: '指标·资质与合规 等级(1-5)' },
{ value: 'ind:data-security', label: '指标·数据安全 等级(1-5)' },
{ value: 'ind:position-nature', label: '指标·岗位三性 等级(1-5)' },
{ value: 'ind:dispatch-ratio', label: '指标·派遣比例 等级(1-5)' },
{ value: 'ind:delivery-standard', label: '指标·交付SLA 等级(1-5)' },
{ value: 'ind:injury-risk', label: '指标·工伤风险 等级(1-5)' },
{ value: 'ind:customer-credit', label: '指标·客户资信 等级(1-5)' },
] as const;
const OP_OPTIONS = ['<', '<=', '>', '>='] as const;
const METRIC_LABEL: Record<string, string> = {
netMargin: '月净利率(%)', grossMargin: '月毛利率(%)', avgOverdueDays: '客户平均逾期天数(天)', concentration: '单客户集中度(%)',
};
/** 度量键 → 中文(含指标等级)。 */
function metricLabelOf(key: string): string {
const opt = METRIC_OPTIONS.find((o) => o.value === key);
return opt?.label ?? METRIC_LABEL[key] ?? key;
}
/** 是否指标等级类度量(阈值按 1-5 等级)。 */
function isLevelMetric(key: string): boolean {
return key.startsWith('ind:');
}
const EMPTY_FORM = { id: '', title: '', triggerCondition: '', consequence: '', region: '', businessType: '', regulationRef: '', severity: '严重' as string, linkedMetric: '', compareOp: '>' as string, threshold: '', linkedMetric2: '', compareOp2: '>' as string, threshold2: '' };
export function RedlineManagement(): JSX.Element {
const [rules, setRules] = useState<RedlineRuleItem[]>([]);
const [loading, setLoading] = useState(true);
const [form, setForm] = useState(EMPTY_FORM);
const [editing, setEditing] = useState(false);
const [showForm, setShowForm] = useState(false);
const [detail, setDetail] = useState<RedlineRuleItem | null>(null);
const [filterRegion, setFilterRegion] = useState('');
const [filterBiz, setFilterBiz] = useState('');
const load = useCallback(() => {
setLoading(true);
fetchRedlineRules().then(setRules).finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
async function handleSave(): Promise<void> {
if (!form.id || !form.title || !form.triggerCondition || !form.consequence) return;
await createRedlineRule({
id: form.id, title: form.title,
triggerCondition: form.triggerCondition,
consequence: form.consequence,
region: form.region || null,
businessType: form.businessType || null,
enabled: true, version: 1,
regulationRef: form.regulationRef || null,
linkedMetric: form.linkedMetric ? form.linkedMetric : null,
compareOp: form.linkedMetric ? (form.compareOp as '>=' | '<=' | '>' | '<') : null,
threshold: form.linkedMetric && form.threshold !== '' ? Number(form.threshold) : null,
linkedMetric2: form.linkedMetric && form.linkedMetric2 ? form.linkedMetric2 : null,
compareOp2: form.linkedMetric && form.linkedMetric2 ? (form.compareOp2 as '>=' | '<=' | '>' | '<') : null,
threshold2: form.linkedMetric && form.linkedMetric2 && form.threshold2 !== '' ? Number(form.threshold2) : null,
});
setForm(EMPTY_FORM); setShowForm(false); setEditing(false);
load();
}
function handleEdit(r: RedlineRuleItem): void {
setForm({ id: r.id, title: r.title, triggerCondition: r.triggerCondition, consequence: r.consequence, region: r.region ?? '', businessType: r.businessType ?? '', regulationRef: r.regulationRef ?? '', severity: inferSeverity(r.consequence).value, linkedMetric: r.linkedMetric ?? '', compareOp: r.compareOp ?? '>', threshold: r.threshold != null ? String(r.threshold) : '', linkedMetric2: r.linkedMetric2 ?? '', compareOp2: r.compareOp2 ?? '>', threshold2: r.threshold2 != null ? String(r.threshold2) : '' });
setEditing(true); setShowForm(true); setDetail(null);
}
const filtered = rules.filter((r) => {
if (filterRegion && (r.region ?? '') !== filterRegion && r.region !== null) return false;
if (filterBiz && (r.businessType ?? '') !== filterBiz && r.businessType !== null) return false;
return true;
});
const inputStyle: React.CSSProperties = {
padding: `${space(2)}px ${space(3)}px`,
border: `1px solid ${colorVar('color.border.default')}`,
borderRadius: `${RADIUS.md}px`,
fontFamily: FONT_FAMILY, ...typographyStyle('body'),
backgroundColor: colorVar('color.bg.canvas'), color: colorVar('color.text.primary'),
width: '100%',
};
return (
<div style={{ fontFamily: FONT_FAMILY, maxWidth: 1200, margin: '0 auto' }}>
{/* 页头 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: `${space(4)}px` }}>
<div>
<h1 style={{ ...typographyStyle('heading'), color: colorVar('color.text.primary'), margin: 0 }}>线</h1>
<p style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), margin: `${space(1)}px 0 0` }}>
线线 /
</p>
</div>
<button onClick={() => { setShowForm(!showForm); if (!showForm) { setForm(EMPTY_FORM); setEditing(false); } }} style={{ padding: `${space(2)}px ${space(4)}px`, borderRadius: `${RADIUS.md}px`, border: 'none', backgroundColor: colorVar('color.brand.primary'), color: '#fff', cursor: 'pointer', fontWeight: 600 }}>
{showForm ? '收起' : '+ 新增红线'}
</button>
</div>
{/* 新增/编辑表单 */}
{showForm && (
<Card title={editing ? '编辑红线规则' : '新增红线规则'}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: `${space(3)}px` }}>
<FormField label="规则ID(唯一标识)"><input style={inputStyle} value={form.id} disabled={editing} onChange={(e) => setForm((f) => ({ ...f, id: e.target.value }))} placeholder="如 dispatch-ratio-limit" /></FormField>
<FormField label="规则标题"><input style={inputStyle} value={form.title} onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))} placeholder="如 劳务派遣比例超限" /></FormField>
<FormField label="触发条件(什么情况下命中)"><input style={inputStyle} value={form.triggerCondition} onChange={(e) => setForm((f) => ({ ...f, triggerCondition: e.target.value }))} placeholder="如 派遣人数/总用工 > 10%" /></FormField>
<FormField label="触发后果(命中后怎么办)"><input style={inputStyle} value={form.consequence} onChange={(e) => setForm((f) => ({ ...f, consequence: e.target.value }))} placeholder="如 一票否决,不予承接" /></FormField>
<FormField label="适用地域"><select style={inputStyle} value={form.region} onChange={(e) => setForm((f) => ({ ...f, region: e.target.value }))}>{REGIONS.map((r) => <option key={r} value={r}>{r || '全国通用'}</option>)}</select></FormField>
<FormField label="适用业务类型"><select style={inputStyle} value={form.businessType} onChange={(e) => setForm((f) => ({ ...f, businessType: e.target.value }))}>{BIZ_TYPES.map((b) => <option key={b} value={b}>{b || '全部业务'}</option>)}</select></FormField>
<FormField label="关联法规条文"><input style={inputStyle} value={form.regulationRef} onChange={(e) => setForm((f) => ({ ...f, regulationRef: e.target.value }))} placeholder="如 《劳务派遣暂行规定》第四条" /></FormField>
</div>
<div style={{ marginTop: `${space(3)}px`, padding: `${space(3)}px`, backgroundColor: colorVar('color.bg.surface'), borderRadius: `${RADIUS.md}px` }}>
<div style={{ ...typographyStyle('caption'), fontWeight: 700, color: colorVar('color.text.primary'), marginBottom: `${space(2)}px` }}>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: `${space(3)}px` }}>
<FormField label="关联度量">
<select style={inputStyle} value={form.linkedMetric} onChange={(e) => setForm((f) => ({ ...f, linkedMetric: e.target.value }))}>
{METRIC_OPTIONS.map((m) => <option key={m.value} value={m.value}>{m.label}</option>)}
</select>
</FormField>
<FormField label="比较运算符">
<select style={inputStyle} value={form.compareOp} disabled={!form.linkedMetric} onChange={(e) => setForm((f) => ({ ...f, compareOp: e.target.value }))}>
{OP_OPTIONS.map((op) => <option key={op} value={op}>{op}</option>)}
</select>
</FormField>
<FormField label={isLevelMetric(form.linkedMetric) ? '等级阈值(1-5' : form.linkedMetric === 'avgOverdueDays' ? '阈值(天)' : '阈值(百分数,如 0 / 5 / 50'}>
<input style={inputStyle} value={form.threshold} disabled={!form.linkedMetric} inputMode="decimal" onChange={(e) => setForm((f) => ({ ...f, threshold: e.target.value }))} placeholder={isLevelMetric(form.linkedMetric) ? '如 5' : form.linkedMetric === 'avgOverdueDays' ? '如 90' : '如 0'} />
</FormField>
</div>
{form.linkedMetric && (
<div style={{ ...typographyStyle('caption'), color: colorVar('color.brand.primary'), marginTop: `${space(2)}px` }}>
{metricLabelOf(form.linkedMetric)} {form.compareOp} {form.threshold || '?'}{form.linkedMetric2 ? `${metricLabelOf(form.linkedMetric2)} ${form.compareOp2} ${form.threshold2 || '?'}` : ''}
</div>
)}
{form.linkedMetric && (
<div style={{ marginTop: `${space(3)}px` }}>
<div style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), marginBottom: `${space(2)}px` }}>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: `${space(3)}px` }}>
<FormField label="关联度量">
<select style={inputStyle} value={form.linkedMetric2} onChange={(e) => setForm((f) => ({ ...f, linkedMetric2: e.target.value }))}>
<option value=""></option>
{METRIC_OPTIONS.filter((m) => m.value !== '').map((m) => <option key={m.value} value={m.value}>{m.label}</option>)}
</select>
</FormField>
<FormField label="比较运算符">
<select style={inputStyle} value={form.compareOp2} disabled={!form.linkedMetric2} onChange={(e) => setForm((f) => ({ ...f, compareOp2: e.target.value }))}>
{OP_OPTIONS.map((op) => <option key={op} value={op}>{op}</option>)}
</select>
</FormField>
<FormField label={isLevelMetric(form.linkedMetric2) ? '等级阈值(1-5' : form.linkedMetric2 === 'avgOverdueDays' ? '阈值(天)' : '阈值(百分数)'}>
<input style={inputStyle} value={form.threshold2} disabled={!form.linkedMetric2} inputMode="decimal" onChange={(e) => setForm((f) => ({ ...f, threshold2: e.target.value }))} placeholder="如 0" />
</FormField>
</div>
</div>
)}
</div>
<div style={{ display: 'flex', gap: `${space(2)}px`, marginTop: `${space(3)}px` }}>
<button onClick={handleSave} style={{ padding: `${space(2)}px ${space(5)}px`, borderRadius: `${RADIUS.md}px`, border: 'none', backgroundColor: colorVar('color.brand.primary'), color: '#fff', cursor: 'pointer', fontWeight: 600 }}>{editing ? '保存修改' : '添加'}</button>
{editing && <button onClick={() => { setForm(EMPTY_FORM); setEditing(false); setShowForm(false); }} style={{ padding: `${space(2)}px ${space(4)}px`, borderRadius: `${RADIUS.md}px`, border: `1px solid ${colorVar('color.border.default')}`, background: 'transparent', cursor: 'pointer' }}></button>}
</div>
</Card>
)}
{/* 筛选 */}
<div style={{ display: 'flex', gap: `${space(3)}px`, marginTop: `${space(3)}px`, marginBottom: `${space(3)}px`, flexWrap: 'wrap', alignItems: 'center' }}>
<select style={{ ...inputStyle, width: 'auto', minWidth: 130 }} value={filterRegion} onChange={(e) => setFilterRegion(e.target.value)}>
<option value=""></option>
{REGIONS.filter(Boolean).map((r) => <option key={r} value={r}>{r}</option>)}
</select>
<select style={{ ...inputStyle, width: 'auto', minWidth: 140 }} value={filterBiz} onChange={(e) => setFilterBiz(e.target.value)}>
<option value=""></option>
{BIZ_TYPES.filter(Boolean).map((b) => <option key={b} value={b}>{b}</option>)}
</select>
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>
{filtered.length} {filterRegion ? ` · ${filterRegion}` : ''}{filterBiz ? ` · ${filterBiz}` : ''}
</span>
</div>
{/* 规则卡片 */}
{loading ? <p style={{ color: colorVar('color.text.secondary') }}></p> : filtered.length === 0 ? (
<div style={{ padding: `${space(5)}px`, textAlign: 'center', color: colorVar('color.text.secondary') }}>
线线
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(380px, 1fr))', gap: `${space(3)}px` }}>
{filtered.map((r) => {
const sev = inferSeverity(r.consequence);
return (
<div key={r.id} onClick={() => setDetail(r)} style={{ padding: `${space(4)}px`, backgroundColor: colorVar('color.bg.elevated'), borderRadius: `${RADIUS.lg}px`, border: `1px solid ${colorVar('color.border.default')}`, borderLeft: `4px solid ${sev.color}`, cursor: 'pointer' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: `${space(2)}px` }}>
<span style={{ ...typographyStyle('title'), fontWeight: 700, color: colorVar('color.text.primary') }}>{r.title}</span>
<span style={{ padding: '2px 10px', borderRadius: '999px', backgroundColor: sev.bg, color: sev.color, ...typographyStyle('caption'), fontWeight: 700 }}>{sev.value}</span>
</div>
<div style={{ ...typographyStyle('body'), color: colorVar('color.text.secondary'), marginBottom: `${space(2)}px` }}>
<strong></strong>{r.triggerCondition}
</div>
<div style={{ ...typographyStyle('caption'), color: sev.color, marginBottom: `${space(2)}px` }}>
<strong></strong>{r.consequence}
</div>
<div style={{ display: 'flex', gap: `${space(2)}px`, flexWrap: 'wrap' }}>
<Tag>{r.region ?? '全国'}</Tag>
<Tag>{r.businessType ?? '全部业务'}</Tag>
{r.linkedMetric ? (
<span style={{ ...typographyStyle('caption'), color: colorVar('color.brand.primary'), fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<Icon name="settings" size={13} /> {metricLabelOf(r.linkedMetric)} {r.compareOp} {r.threshold}{r.linkedMetric2 ? `${metricLabelOf(r.linkedMetric2)} ${r.compareOp2} ${r.threshold2}` : ''}
</span>
) : (
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), display: 'inline-flex', alignItems: 'center', gap: 4 }}><Icon name="user" size={13} /> </span>
)}
<span style={{ ...typographyStyle('caption'), color: r.enabled ? '#15803D' : colorVar('color.text.secondary'), display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: r.enabled ? '#15803D' : colorVar('color.text.secondary'), display: 'inline-block' }} />
{r.enabled ? '启用(驱动评估)' : '停用'}
</span>
</div>
</div>
);
})}
</div>
)}
{/* 详情弹窗 */}
{detail !== null && (
<div style={{ position: 'fixed', inset: 0, backgroundColor: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }} onClick={() => setDetail(null)}>
<div style={{ backgroundColor: colorVar('color.bg.elevated'), borderRadius: `${RADIUS.lg}px`, padding: `${space(5)}px`, maxWidth: 600, width: '90%', boxShadow: SHADOW.lg }} onClick={(e) => e.stopPropagation()}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: `${space(4)}px` }}>
<h2 style={{ ...typographyStyle('heading'), margin: 0, color: colorVar('color.text.primary') }}>{detail.title}</h2>
<span style={{ padding: '3px 12px', borderRadius: '999px', backgroundColor: inferSeverity(detail.consequence).bg, color: inferSeverity(detail.consequence).color, fontWeight: 700, ...typographyStyle('caption') }}>{inferSeverity(detail.consequence).value}</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: `${space(3)}px`, marginBottom: `${space(3)}px` }}>
<DetailField label="规则ID" value={detail.id} />
<DetailField label="状态" value={detail.enabled ? '已启用(参与评估)' : '已停用'} />
<DetailField label="适用地域" value={detail.region ?? '全国通用'} />
<DetailField label="适用业务" value={detail.businessType ?? '全部业务类型'} />
</div>
<DetailField label="触发条件" value={detail.triggerCondition} full />
<DetailField label="触发后果" value={detail.consequence} full highlight />
<DetailField
label="自动判定条件"
value={detail.linkedMetric ? `${metricLabelOf(detail.linkedMetric)} ${detail.compareOp} ${detail.threshold}${detail.linkedMetric2 ? `${metricLabelOf(detail.linkedMetric2)} ${detail.compareOp2} ${detail.threshold2}` : ''} 时自动命中` : '人工核实(无可计算条件,评估时标「待核实」)'}
full
/>
{detail.regulationRef && <DetailField label="关联法规" value={detail.regulationRef} full />}
<div style={{ padding: `${space(3)}px`, backgroundColor: colorVar('color.bg.surface'), borderRadius: `${RADIUS.md}px`, margin: `${space(3)}px 0`, ...typographyStyle('caption'), color: colorVar('color.text.secondary'), display: 'flex', alignItems: 'flex-start', gap: 6 }}>
<Icon name="lightbulb" size={14} color={colorVar('color.brand.primary')} />
<span>线/线线,()</span>
</div>
<div style={{ display: 'flex', gap: `${space(2)}px`, justifyContent: 'flex-end' }}>
<button onClick={() => { handleEdit(detail); }} style={{ padding: `${space(2)}px ${space(4)}px`, borderRadius: `${RADIUS.md}px`, border: `1px solid ${colorVar('color.brand.primary')}`, background: 'transparent', color: colorVar('color.brand.primary'), cursor: 'pointer', fontWeight: 600 }}></button>
<button onClick={() => { deleteRedlineRuleApi(detail.id).then(load); setDetail(null); }} style={{ padding: `${space(2)}px ${space(4)}px`, borderRadius: `${RADIUS.md}px`, border: 'none', backgroundColor: '#BE123C', color: '#fff', cursor: 'pointer', fontWeight: 600 }}></button>
<button onClick={() => setDetail(null)} style={{ padding: `${space(2)}px ${space(4)}px`, borderRadius: `${RADIUS.md}px`, border: `1px solid ${colorVar('color.border.default')}`, background: 'transparent', cursor: 'pointer' }}></button>
</div>
</div>
</div>
)}
</div>
);
}
function FormField({ label, children }: { label: string; children: React.ReactNode }): JSX.Element {
return (
<div>
<label style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), display: 'block', marginBottom: 4 }}>{label}</label>
{children}
</div>
);
}
function DetailField({ label, value, full, highlight }: { label: string; value: string; full?: boolean; highlight?: boolean }): JSX.Element {
return (
<div style={{ ...(full ? { gridColumn: '1 / -1' } : {}), marginBottom: full ? `${space(2)}px` : 0 }}>
<div style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), marginBottom: 2 }}>{label}</div>
<div style={{ ...typographyStyle('body'), color: highlight ? '#BE123C' : colorVar('color.text.primary'), fontWeight: highlight ? 600 : 400 }}>{value}</div>
</div>
);
}
function Tag({ children }: { children: React.ReactNode }): JSX.Element {
return (
<span style={{ padding: '1px 8px', borderRadius: '999px', backgroundColor: colorVar('color.bg.surface'), color: colorVar('color.text.secondary'), ...typographyStyle('caption') }}>
{children}
</span>
);
}
+58
View File
@@ -0,0 +1,58 @@
/**
* 方案对比区:在盈利分析下方并列展示已保存的多套报价方案。
*/
import { useCallback, useEffect, useState } from 'react';
import { colorVar, FONT_FAMILY, RADIUS, space, typographyStyle } from '../design-system/components/styles.js';
import { fetchScenarios, type ScenarioItem } from '../api/client.js';
export function ScenarioCompare({ assessmentId }: { readonly assessmentId: string }): JSX.Element | null {
const [scenarios, setScenarios] = useState<ScenarioItem[]>([]);
const load = useCallback(() => {
fetchScenarios(assessmentId).then(setScenarios).catch(() => setScenarios([]));
}, [assessmentId]);
useEffect(() => { load(); }, [load]);
if (scenarios.length === 0) return null;
const pct = (v: number): string => `${(v * 100).toFixed(1)}%`;
const money = (v: number): string => `${v.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} 元`;
return (
<div style={{ marginTop: `${space(3)}px`, padding: `${space(3)}px`, backgroundColor: colorVar('color.bg.elevated'), borderRadius: `${RADIUS.lg}px`, border: `1px solid ${colorVar('color.border.default')}` }}>
<div style={{ ...typographyStyle('title'), fontWeight: 700, color: colorVar('color.text.primary'), marginBottom: `${space(2)}px` }}>
{scenarios.length}
</div>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', ...typographyStyle('caption'), fontFamily: FONT_FAMILY }}>
<thead>
<tr>
{['方案', '报价模式', '总人数', '月收入', '月总成本', '月净利', '净利率', '峰值垫资'].map((h) => (
<th key={h} style={{ textAlign: 'left', padding: `${space(1)}px ${space(2)}px`, borderBottom: `1px solid ${colorVar('color.border.default')}`, color: colorVar('color.text.secondary'), whiteSpace: 'nowrap' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{scenarios.map((s) => {
const m = s.result.monthly;
const netColor = m.netMargin >= 0.05 ? colorVar('color.risk.low') : m.netMargin >= 0 ? colorVar('color.risk.high') : colorVar('color.risk.critical');
return (
<tr key={s.id}>
<td style={{ padding: `${space(1)}px ${space(2)}px`, borderBottom: `1px solid ${colorVar('color.border.default')}`, fontWeight: 600 }}>{s.label}</td>
<td style={{ padding: `${space(1)}px ${space(2)}px`, borderBottom: `1px solid ${colorVar('color.border.default')}` }}>{s.result.pricingModel}</td>
<td style={{ padding: `${space(1)}px ${space(2)}px`, borderBottom: `1px solid ${colorVar('color.border.default')}` }}>{s.result.totalHeadcount}</td>
<td style={{ padding: `${space(1)}px ${space(2)}px`, borderBottom: `1px solid ${colorVar('color.border.default')}` }}>{money(m.revenueNet)}</td>
<td style={{ padding: `${space(1)}px ${space(2)}px`, borderBottom: `1px solid ${colorVar('color.border.default')}` }}>{money(m.totalCost)}</td>
<td style={{ padding: `${space(1)}px ${space(2)}px`, borderBottom: `1px solid ${colorVar('color.border.default')}`, color: netColor, fontWeight: 600 }}>{money(m.netProfit)}</td>
<td style={{ padding: `${space(1)}px ${space(2)}px`, borderBottom: `1px solid ${colorVar('color.border.default')}`, color: netColor, fontWeight: 600 }}>{pct(m.netMargin)}</td>
<td style={{ padding: `${space(1)}px ${space(2)}px`, borderBottom: `1px solid ${colorVar('color.border.default')}` }}>{money(s.result.cashflow?.maxAdvance ?? 0)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
+145
View File
@@ -0,0 +1,145 @@
/**
* DefaultView — 角色化默认首屏视图(task 23.3Req 25.125.5)。
*
* 给定登录用户角色,本组件渲染与该角色匹配的默认视图首屏,并将角色匹配的功能入口
* 呈现于无需额外导航即可见的位置(Req 25.4)。目标视图复用任务 15 的角色化视图
* renderView/renderPortfolio)作为导航目标;此处呈现进入这些视图的功能入口
* (而非领域侧的完整视图实现)。
*
* 路由目标由纯函数 `resolveDefaultView` 决定(见 routing.tsProperty 81):
* 商务/销售 → SalesView、风控 → RiskView、管理层 → ManagementDashboard。
* 对 `无角色` 渲染「需分配角色」提示视图,且不呈现任何评估数据(Req 25.5)。
*
* 可访问性:以 `<section>` 区域 + 标题层级表达结构;功能入口以 `Nav` 语义化导航
* 暴露(landmark),便于辅助技术直接定位(Req 25.4)。视觉值统一取自 Design Tokens。
*/
import type { CSSProperties } from 'react';
import { Card, Nav } from '../design-system/index.js';
import type { NavItem } from '../design-system/index.js';
import { colorVar, FONT_FAMILY, space, typographyStyle } from '../design-system/components/styles.js';
import { resolveDefaultView } from './routing.js';
import type { Role, Route } from './routing.js';
/** 单条功能入口定义(角色匹配,呈现为导航目标)。 */
interface EntryPoint {
/** 唯一键(亦作为导航目标标识)。 */
readonly key: string;
/** 显示文本。 */
readonly label: string;
/** 进入目标视图的路由片段(占位链接,领域侧视图于任务 15 实现)。 */
readonly href: string;
}
/** 每个角色默认视图的标题与可见功能入口(Req 25.4)。 */
interface ViewDescriptor {
/** 默认视图标题。 */
readonly heading: string;
/** 功能入口导航的无障碍标签。 */
readonly navLabel: string;
/** 角色匹配的功能入口集合(首屏可见,无需额外导航)。 */
readonly entryPoints: readonly EntryPoint[];
}
/**
* 路由 → 默认视图描述符。功能入口对应任务 15 角色化视图的关键能力区块:
* - SalesView:结论 / 接受条件 / 风险调整后报价(Req 13.1)。
* - RiskView:评分明细 / 红线检查 / 缺口尽调(Req 13.2)。
* - ManagementDashboard:风险热力图 / TopN 风险 / 利润 vs 风险看板(Req 13.3)。
* `AssignRolePrompt` 不在此表内——其不呈现任何评估数据(Req 25.5)。
*/
const VIEW_DESCRIPTORS: Readonly<Record<Exclude<Route, 'AssignRolePrompt'>, ViewDescriptor>> = {
SalesView: {
heading: '商务/销售视图',
navLabel: '商务/销售功能入口',
entryPoints: [
{ key: 'sales-conclusion', label: '评估结论', href: '#/sales/conclusion' },
{ key: 'sales-acceptance', label: '接受条件', href: '#/sales/acceptance' },
{ key: 'sales-quote', label: '风险调整后报价', href: '#/sales/quote' },
],
},
RiskView: {
heading: '风控视图',
navLabel: '风控功能入口',
entryPoints: [
{ key: 'risk-scoring', label: '评分明细', href: '#/risk/scoring' },
{ key: 'risk-redline', label: '红线检查', href: '#/risk/redline' },
{ key: 'risk-gap', label: '缺口尽调', href: '#/risk/gap' },
],
},
ManagementDashboard: {
heading: '管理层看板',
navLabel: '管理层功能入口',
entryPoints: [
{ key: 'mgmt-heatmap', label: '风险热力图', href: '#/management/heatmap' },
{ key: 'mgmt-topn', label: 'TopN 风险', href: '#/management/topn' },
{ key: 'mgmt-profit-risk', label: '利润 vs 风险看板', href: '#/management/profit-risk' },
],
},
} as const;
/** `DefaultView` 组件属性。 */
export interface DefaultViewProps {
/** 登录用户角色。 */
readonly role: Role;
}
/**
* 渲染角色匹配的默认首屏视图。
*
* - 已分配业务角色:渲染对应视图标题与首屏可见的功能入口导航(Req 25.1–25.4)。
* - 无角色:渲染「需分配角色」提示,且不呈现任何评估数据(Req 25.5)。
*/
export function DefaultView({ role }: DefaultViewProps): JSX.Element {
const { route, showsAssessmentData } = resolveDefaultView(role);
const containerStyle: CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: `${space(4)}px`,
fontFamily: FONT_FAMILY,
color: colorVar('color.text.primary'),
};
if (route === 'AssignRolePrompt') {
// Req 25.5:未分配业务角色 → 提示分配角色,且不呈现任何评估数据。
return (
<section
data-default-view="true"
data-route={route}
data-shows-assessment-data={showsAssessmentData}
aria-label="需分配角色"
style={containerStyle}
>
<h1 style={{ ...typographyStyle('heading'), margin: 0 }}></h1>
<Card title="暂无可访问的评估视图">
<p style={{ ...typographyStyle('body'), margin: 0 }}>
/
</p>
</Card>
</section>
);
}
const descriptor = VIEW_DESCRIPTORS[route];
const navItems: readonly NavItem[] = descriptor.entryPoints.map((entry) => ({
key: entry.key,
label: entry.label,
href: entry.href,
}));
return (
<section
data-default-view="true"
data-route={route}
data-shows-assessment-data={showsAssessmentData}
aria-label={descriptor.heading}
style={containerStyle}
>
<h1 style={{ ...typographyStyle('heading'), margin: 0 }}>{descriptor.heading}</h1>
{/* Req 25.4:角色匹配的功能入口呈现于无需额外导航即可见的位置。 */}
<Nav items={navItems} ariaLabel={descriptor.navLabel} />
</section>
);
}
@@ -0,0 +1,105 @@
/**
* DefaultView 角色化默认首屏组件测试(task 23.5Req 25.4 / 25.5)。
*
* RTL 组件测试(非属性测试)。验证:对每个已分配业务角色,默认视图首屏在无需
* 额外导航即可见的位置呈现与该角色匹配的功能入口(Req 25.4):
* - 商务/销售 → SalesView 入口;
* - 风控 → RiskView 入口;
* - 管理层 → ManagementDashboard 入口。
* 对未分配角色(无角色)呈现「需分配角色」提示且不呈现任何评估数据(Req 25.5)。
*
* 「首屏可见、无需额外导航」以如下方式断言:入口呈现于该视图根 `<section>` 的
* 语义化导航 landmark 中,且在初始渲染(无任何点击/展开等交互)后即可由角色查询
* 直接定位到对应链接。
*/
import { describe, it, expect } from 'vitest';
import { render, screen, within } from '@testing-library/react';
import { DefaultView } from '../index.js';
import type { Role } from '../index.js';
/** 各业务角色默认视图:标题、功能入口导航标签与角色匹配的入口文本(Req 25.1–25.4)。 */
const ROLE_VIEWS: ReadonlyArray<{
readonly role: Exclude<Role, '无角色'>;
readonly route: string;
readonly heading: string;
readonly navLabel: string;
readonly entries: readonly string[];
}> = [
{
role: '商务/销售',
route: 'SalesView',
heading: '商务/销售视图',
navLabel: '商务/销售功能入口',
entries: ['评估结论', '接受条件', '风险调整后报价'],
},
{
role: '风控',
route: 'RiskView',
heading: '风控视图',
navLabel: '风控功能入口',
entries: ['评分明细', '红线检查', '缺口尽调'],
},
{
role: '管理层',
route: 'ManagementDashboard',
heading: '管理层看板',
navLabel: '管理层功能入口',
entries: ['风险热力图', 'TopN 风险', '利润 vs 风险看板'],
},
];
describe('DefaultView 角色匹配功能入口首屏可见(Req 25.4)', () => {
for (const view of ROLE_VIEWS) {
it(`${view.role} 默认视图首屏在导航 landmark 中呈现角色匹配的功能入口`, () => {
render(<DefaultView role={view.role} />);
// 默认视图根区域以视图标题作为可访问名暴露,且路由解析为该角色对应视图。
const region = screen.getByRole('region', { name: view.heading });
expect(region).toHaveAttribute('data-route', view.route);
// 已分配业务角色 → 允许呈现评估数据。
expect(region).toHaveAttribute('data-shows-assessment-data', 'true');
// 功能入口呈现于首屏可见的语义化导航(landmark),无需额外导航。
const nav = within(region).getByRole('navigation', { name: view.navLabel });
// 每个角色匹配的功能入口在初始渲染后即可见(呈现为可定位的链接入口)。
for (const label of view.entries) {
const entry = within(nav).getByRole('link', { name: label });
expect(entry).toBeInTheDocument();
expect(entry).toBeVisible();
}
});
}
it('不同角色仅呈现各自匹配的入口,不串入其他角色入口', () => {
render(<DefaultView role="风控" />);
const nav = screen.getByRole('navigation', { name: '风控功能入口' });
// 风控入口存在。
expect(within(nav).getByRole('link', { name: '评分明细' })).toBeInTheDocument();
// 其他角色的入口不应出现。
expect(screen.queryByRole('link', { name: '评估结论' })).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: '风险热力图' })).not.toBeInTheDocument();
});
});
describe('DefaultView 未分配角色提示(Req 25.5', () => {
it('无角色默认视图呈现「需分配角色」提示且不呈现评估数据', () => {
render(<DefaultView role="无角色" />);
const region = screen.getByRole('region', { name: '需分配角色' });
expect(region).toHaveAttribute('data-route', 'AssignRolePrompt');
// Req 25.5:不呈现任何评估数据。
expect(region).toHaveAttribute('data-shows-assessment-data', 'false');
// 呈现分配角色提示。
expect(
within(region).getByRole('heading', { name: '需分配角色' }),
).toBeInTheDocument();
// 不呈现任何角色的功能入口导航。
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,76 @@
/**
* Property 81: 角色默认路由确定性 的属性化测试(角色化默认视图,Req 25.125.3 / 25.5)。
*
* 属性陈述:`defaultRoute(role)` 是一个确定性映射 —— 对任意角色恒得唯一确定的路由,
* 与调用次数/顺序无关,且穷尽覆盖全部角色取值:
* 商务/销售 → SalesView Req 25.1
* 风控 → RiskView Req 25.2
* 管理层 → ManagementDashboard Req 25.3
* 无角色 → AssignRolePrompt (不呈现评估数据,Req 25.5)
*
* Feature: outsourcing-risk-assessment, Property 81: 角色默认路由确定性
* Validates: Requirements 25.1, 25.2, 25.3, 25.5
*/
import { describe, expect, it } from 'vitest';
import fc from 'fast-check';
import { defaultRoute, resolveDefaultView } from '../index.js';
import type { Role, Route } from '../index.js';
// ----------------------------------------------------------------------------
// 生成器
// ----------------------------------------------------------------------------
/** 全部合法角色取值(含「无角色」),用于穷尽覆盖。 */
const ALL_ROLES: readonly Role[] = ['商务/销售', '风控', '管理层', '无角色'];
/** 任意角色生成器(覆盖全部取值空间)。 */
const roleArb: fc.Arbitrary<Role> = fc.constantFrom<Role>(...ALL_ROLES);
/** 角色 → 期望路由的独立期望表(与实现解耦,作为属性的真值来源)。 */
const EXPECTED_ROUTE: Readonly<Record<Role, Route>> = {
'商务/销售': 'SalesView',
: 'RiskView',
: 'ManagementDashboard',
: 'AssignRolePrompt',
};
describe('Property 81: 角色默认路由确定性', () => {
it('对任意角色返回符合 Req 25.125.3/25.5 的确定路由', () => {
fc.assert(
fc.property(roleArb, (role) => {
expect(defaultRoute(role)).toBe(EXPECTED_ROUTE[role]);
}),
{ numRuns: 100 },
);
});
it('确定性:同一角色多次调用恒得相同路由(与调用次数/顺序无关)', () => {
fc.assert(
fc.property(roleArb, fc.integer({ min: 2, max: 10 }), (role, times) => {
const first = defaultRoute(role);
for (let i = 0; i < times; i += 1) {
expect(defaultRoute(role)).toBe(first);
}
}),
{ numRuns: 100 },
);
});
it('穷尽覆盖:每个角色取值均有定义且互不冲突', () => {
for (const role of ALL_ROLES) {
expect(defaultRoute(role)).toBe(EXPECTED_ROUTE[role]);
}
});
it('无角色恒不呈现评估数据,其余角色均呈现(Req 25.5)', () => {
fc.assert(
fc.property(roleArb, (role) => {
const resolution = resolveDefaultView(role);
expect(resolution.route).toBe(EXPECTED_ROUTE[role]);
expect(resolution.showsAssessmentData).toBe(role !== '无角色');
}),
{ numRuns: 100 },
);
});
});
+13
View File
@@ -0,0 +1,13 @@
/**
* Routing 公共入口(barreltask 23.3)。
*
* 暴露角色化默认路由的纯逻辑(`defaultRoute` / `resolveDefaultView`,供 Property 81
* 验证,task 23.4)、相关类型,以及 `DefaultView` 默认首屏展示组件
* task 23.5 组件测试)。
*/
export type { Role, Route, DefaultViewResolution } from './routing.js';
export { ROUTE_MAP, defaultRoute, resolveDefaultView } from './routing.js';
export { DefaultView } from './DefaultView.js';
export type { DefaultViewProps } from './DefaultView.js';
+87
View File
@@ -0,0 +1,87 @@
/**
* routing.ts — 角色化默认路由的纯逻辑(task 23.3Req 25.125.3 / 25.5)。
*
* 本模块仅承载「角色 → 默认视图」的确定性映射与 RBAC 路由守卫的纯函数实现,
* 不含任何 React / DOM 依赖,便于以属性测试验证其确定性(Property 81task 23.4)。
*
* 角色取值在前端层本地镜像(web 不直接依赖领域层 `../src` 的 RBAC 类型):
* '商务/销售' | '风控' | '管理层' | '无角色'
*
* 映射关系(Req 25.125.3 / 25.5):
* 商务/销售 → SalesView
* 风控 → RiskView
* 管理层 → ManagementDashboard
* 无角色 → AssignRolePrompt(不呈现评估数据,Req 25.5
*/
/** 登录用户角色(前端本地镜像;与领域层 RBAC 角色取值保持一致)。 */
export type Role = '商务/销售' | '风控' | '管理层' | '无角色';
/**
* 默认视图/路由标识。
* - `SalesView`:面向商务/销售的视图(Req 25.1)。
* - `RiskView`:面向风控的视图(Req 25.2)。
* - `ManagementDashboard`:面向管理层的高层看板(Req 25.3)。
* - `AssignRolePrompt`:需分配角色的提示视图,不呈现评估数据(Req 25.5)。
*/
export type Route = 'SalesView' | 'RiskView' | 'ManagementDashboard' | 'AssignRolePrompt';
/**
* 角色 → 默认路由的单一事实来源映射表(Req 25 / design RouteMap)。
*
* 以 `Record<Role, Route>` 形式表达,确保覆盖每一个角色取值(编译期完整性保证),
* 从而支撑 `defaultRoute` 的确定性与全覆盖(Property 81)。
*/
export const ROUTE_MAP: Readonly<Record<Role, Route>> = {
'商务/销售': 'SalesView',
: 'RiskView',
: 'ManagementDashboard',
: 'AssignRolePrompt',
} as const;
/**
* 角色化默认路由:对任意角色返回唯一确定的默认视图(纯函数,Req 25.125.3 / 25.5)。
*
* 确定性:相同输入恒得相同输出,且与调用次数/顺序无关(Property 81task 23.4)。
* 无角色 → `AssignRolePrompt`,由表现层据此拒绝展示任何评估数据(Req 25.5)。
*
* @param role 登录用户角色。
* @returns 与角色匹配的默认路由标识。
*/
export function defaultRoute(role: Role): Route {
return ROUTE_MAP[role];
}
/**
* 默认视图解析结果:除目标路由外,附带「是否允许呈现评估数据」标记,
* 供表现层与 RBAC 路由守卫据此决定是否暴露评估数据(Req 25.4 / 25.5)。
*/
export interface DefaultViewResolution {
/** 当前角色。 */
readonly role: Role;
/** 角色匹配的目标路由(确定性,见 `defaultRoute`)。 */
readonly route: Route;
/**
* 是否允许在该视图中呈现评估数据。
* 仅当角色为已分配的业务角色时为 `true`;`无角色` 恒为 `false`Req 25.5)。
*/
readonly showsAssessmentData: boolean;
}
/**
* RBAC 路由守卫(纯函数):将角色解析为默认视图目标与数据可见性策略。
*
* 对 `无角色` 返回 `AssignRolePrompt` 且 `showsAssessmentData=false`,确保不暴露
* 任何评估数据(Req 25.5);对其余角色返回各自的默认视图并允许呈现评估数据。
*
* @param role 登录用户角色。
* @returns 默认视图解析结果(目标路由 + 数据可见性)。
*/
export function resolveDefaultView(role: Role): DefaultViewResolution {
const route = defaultRoute(role);
return {
role,
route,
showsAssessmentData: route !== 'AssignRolePrompt',
};
}
+89
View File
@@ -0,0 +1,89 @@
/**
* 评估流程全局状态管理(Zustand):驱动端到端评估交互状态机。
*
* 状态按评估流程步骤逐步推进,每个步骤持有当前步骤所需数据与状态。
*/
import { create } from 'zustand';
import type {
ClassificationResult,
RunAssessmentResult,
AssessmentListItem,
} from '../api/client.js';
/** 评估流程步骤。 */
export type AssessmentStep =
| 'idle'
| 'describing'
| 'classifying'
| 'confirming'
| 'running'
| 'completed'
| 'error';
/** 评估状态切片。 */
export interface AssessmentState {
/* ---------- 流程状态 ---------- */
readonly step: AssessmentStep;
readonly error: string | null;
/* ---------- 步骤 1:项目描述 ---------- */
readonly projectDescription: string;
/* ---------- 步骤 2:分类与确认 ---------- */
readonly classification: ClassificationResult | null;
readonly confirmedBusinessType: string | null;
readonly confirmedIndustry: string | null;
/* ---------- 步骤 3:运行评估 ---------- */
readonly runResult: RunAssessmentResult | null;
/* ---------- 历史记录 ---------- */
readonly assessmentList: AssessmentListItem[];
readonly currentAssessmentId: string | null;
/* ---------- Actions ---------- */
setStep(step: AssessmentStep): void;
setError(error: string | null): void;
setProjectDescription(description: string): void;
setClassification(result: ClassificationResult | null): void;
confirmClassification(businessType: string, industry: string): void;
setRunResult(result: RunAssessmentResult | null): void;
setAssessmentList(list: AssessmentListItem[]): void;
setCurrentAssessmentId(id: string | null): void;
reset(): void;
}
/** 初始状态。 */
const initialState = {
step: 'idle' as AssessmentStep,
error: null,
projectDescription: '',
classification: null as ClassificationResult | null,
confirmedBusinessType: null as string | null,
confirmedIndustry: null as string | null,
runResult: null as RunAssessmentResult | null,
assessmentList: [] as AssessmentListItem[],
currentAssessmentId: null as string | null,
};
export const useAssessmentStore = create<AssessmentState>((set) => ({
...initialState,
setStep: (step) => set({ step }),
setError: (error) => set({ error }),
setProjectDescription: (projectDescription) => set({ projectDescription }),
setClassification: (classification) =>
set({ classification, step: classification !== null ? 'confirming' : 'idle' }),
confirmClassification: (confirmedBusinessType, confirmedIndustry) =>
set({ confirmedBusinessType, confirmedIndustry }),
setRunResult: (runResult) =>
set({
runResult,
step: runResult !== null ? 'completed' : 'idle',
currentAssessmentId: runResult?.assessmentId ?? null,
}),
setAssessmentList: (assessmentList) => set({ assessmentList }),
setCurrentAssessmentId: (currentAssessmentId) => set({ currentAssessmentId }),
reset: () => set(initialState),
}));
+98
View File
@@ -0,0 +1,98 @@
/**
* 认证与角色状态管理(Zustand)。
*
* - 支持 3 种角色登录:商务/销售、风控、管理层。
* - 登录态持久化到 localStorage(仅演示,生产环境应走 OAuth/JWT)。
*/
import { create } from 'zustand';
import { API_BASE } from '../api/client.js';
/** 登录用户角色。 */
export type AuthRole = '商务/销售' | '风控' | '管理层';
/** 测试账号。 */
export const TEST_ACCOUNTS: readonly {
readonly username: string;
readonly password: string;
readonly role: AuthRole;
readonly aliases: readonly string[];
}[] = [
{ username: '销售账号', password: '123456', role: '商务/销售', aliases: ['sales'] },
{ username: '风控账号', password: '123456', role: '风控', aliases: ['risk'] },
{ username: '管理账号', password: '123456', role: '管理层', aliases: ['mgmt'] },
];
/** 认证状态。 */
export interface AuthState {
readonly isAuthenticated: boolean;
readonly user: { username: string; role: AuthRole } | null;
readonly error: string | null;
login(username: string, password: string): boolean;
logout(): void;
clearError(): void;
}
const STORAGE_KEY = 'risk-agent-auth';
function loadFromStorage(): { isAuthenticated: boolean; user: { username: string; role: AuthRole } | null } {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === null) return { isAuthenticated: false, user: null };
const parsed = JSON.parse(raw);
return { isAuthenticated: true, user: parsed.user };
} catch {
return { isAuthenticated: false, user: null };
}
}
function saveToStorage(user: { username: string; role: AuthRole } | null): void {
if (user === null) {
localStorage.removeItem(STORAGE_KEY);
} else {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ user }));
}
}
const initial = loadFromStorage();
export const useAuthStore = create<AuthState>((set) => ({
isAuthenticated: initial.isAuthenticated,
user: initial.user,
error: null,
login: (username, password) => {
const account = TEST_ACCOUNTS.find(
(a) => (a.username === username || a.aliases.includes(username)) && a.password === password,
);
if (account === undefined) {
set({ error: '用户名或密码错误' });
return false;
}
const user = { username: account.username, role: account.role };
saveToStorage(user);
// 生产 RBAC:向后端换取 JWT 并保存(演示模式后端返回空 token,不影响本地登录)。
void fetch(`${API_BASE}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: account.username, role: account.role }),
})
.then((r) => r.json())
.then((d: { token?: string }) => {
if (typeof d.token === 'string' && d.token !== '') localStorage.setItem('risk-agent-token', d.token);
else localStorage.removeItem('risk-agent-token');
})
.catch(() => undefined);
set({ isAuthenticated: true, user, error: null });
return true;
},
logout: () => {
saveToStorage(null);
try { localStorage.removeItem('risk-agent-token'); } catch { /* ignore */ }
set({ isAuthenticated: false, user: null, error: null });
},
clearError: () => set({ error: null }),
}));
+110
View File
@@ -0,0 +1,110 @@
/*
* 全局基础样式(Liner 风格基底)。
*
* 配色一律引用 ThemeProvider 注入的 Color_Token CSS 变量(--color-*),
* 因此明暗主题自动适配。悬停/焦点等交互仅使用 filter / box-shadow / transform /
* background / outline 等「组件内联样式未设置」的属性,避免与内联样式冲突。
*/
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
}
body {
background-color: var(--color-bg-canvas);
color: var(--color-text-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
font-feature-settings: 'cv11', 'ss01';
}
/* 文本选区配色(品牌色低透明度)。 */
::selection {
background-color: rgba(79, 70, 229, 0.18);
}
/* 精致滚动条。 */
* {
scrollbar-width: thin;
scrollbar-color: var(--color-border-default) transparent;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: var(--color-border-default);
border-radius: 8px;
border: 2px solid transparent;
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--color-text-secondary);
background-clip: content-box;
}
/* 键盘焦点可见环(鼠标点击不显示,保证可访问性与美观兼得)。 */
:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
border-radius: 4px;
}
:focus:not(:focus-visible) {
outline: none;
}
/* 按钮:统一悬停/按下反馈(filter 不与内联背景冲突)。 */
button {
transition: filter 150ms ease, box-shadow 150ms ease, transform 100ms ease;
}
button:not(:disabled):hover {
filter: brightness(1.05);
box-shadow: 0 1px 3px rgba(16, 24, 40, 0.12);
}
button:not(:disabled):active {
transform: translateY(1px);
filter: brightness(0.97);
}
/* 顶部导航链接:悬停浮起填充(无内联背景,可直接生效)。 */
[data-nav-link] {
transition: background-color 150ms ease, color 150ms ease;
border-radius: 8px;
}
[data-nav-link]:hover {
background-color: var(--color-bg-surface);
}
/* 表格行悬停高亮(td 无内联背景,可直接生效)。 */
tbody tr {
transition: background-color 120ms ease;
}
tbody tr:hover td {
background-color: var(--color-bg-surface);
}
/* 卡片悬停轻微抬升(box-shadow 非内联属性,可叠加)。 */
[data-card]:hover {
box-shadow: 0 4px 12px rgba(16, 24, 40, 0.08), 0 2px 4px rgba(16, 24, 40, 0.04);
}
/* 输入控件聚焦时的品牌色描边(box-shadow 非内联属性,可叠加)。 */
input:focus,
textarea:focus,
select:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.18);
border-color: var(--color-brand-primary) !important;
}
@@ -0,0 +1,134 @@
/**
* Property 76: 断点布局映射确定性 的属性化测试(响应式布局服务,Req 22.1–22.3)。
*
* 属性陈述:selectLayout(viewportWidth) 为确定性纯函数,将视口宽度唯一映射到布局:
* - width ≥ 1280 → DesktopLayout
* - 768 ≤ width ≤ 1279 → CompactLayout
* - width < 768 → MobileLayout
* 且同一输入恒产出同一布局(无状态、无副作用)。
*
* 本测试以智能生成器跨三段区间构造任意整数/浮点视口宽度:
* - 桌面段 [1280, ∞)、紧凑段 [768, 1279]、移动段 (-∞, 767],覆盖整数与浮点;
* - 显式纳入边界值 767 / 768 / 1279 / 1280,精确检验闭/开区间处理;
* - 任意宽度(含负值与极端值)下断言映射落入三种布局之一。
*
* Feature: outsourcing-risk-assessment, Property 76: 断点布局映射确定性
* Validates: Requirements 22.1, 22.2, 22.3
*/
import { describe, expect, it } from 'vitest';
import fc from 'fast-check';
import { BREAKPOINTS, selectLayout, type Layout } from '../index.js';
const { COMPACT_MIN_WIDTH, DESKTOP_MIN_WIDTH } = BREAKPOINTS;
/** 期望布局:以断点常量直接复算(与实现解耦的独立判定)。 */
function expectedLayout(width: number): Layout {
if (width >= DESKTOP_MIN_WIDTH) {
return 'DesktopLayout';
}
if (width >= COMPACT_MIN_WIDTH) {
return 'CompactLayout';
}
return 'MobileLayout';
}
/** 桌面段宽度:整数与浮点,下界含 1280。 */
const desktopWidthArb: fc.Arbitrary<number> = fc.oneof(
fc.integer({ min: DESKTOP_MIN_WIDTH, max: 100_000 }),
fc.double({ min: DESKTOP_MIN_WIDTH, max: 100_000, noNaN: true, noDefaultInfinity: true }),
);
/**
* 紧凑段宽度:整数与浮点,闭区间 [768, 1279]。
* 注意:浮点上界须严格小于 1280,且因 1280 处的浮点 ULP(≈2.27e-13)远大于
* Number.EPSILON(≈2.22e-16),`1280 - Number.EPSILON` 会舍回 1280,故改用
* `DESKTOP_MIN_WIDTH - 1` 作为浮点上界,确保不会越界生成 1280。
*/
const compactWidthArb: fc.Arbitrary<number> = fc.oneof(
fc.integer({ min: COMPACT_MIN_WIDTH, max: DESKTOP_MIN_WIDTH - 1 }),
fc.double({
min: COMPACT_MIN_WIDTH,
max: DESKTOP_MIN_WIDTH - 1,
noNaN: true,
noDefaultInfinity: true,
}),
);
/**
* 移动段宽度:整数与浮点,开区间 (-∞, 768),含负值。
* 浮点上界同理改用 `COMPACT_MIN_WIDTH - 1`,避免 `768 - Number.EPSILON` 舍回 768。
*/
const mobileWidthArb: fc.Arbitrary<number> = fc.oneof(
fc.integer({ min: -10_000, max: COMPACT_MIN_WIDTH - 1 }),
fc.double({
min: -10_000,
max: COMPACT_MIN_WIDTH - 1,
noNaN: true,
noDefaultInfinity: true,
}),
);
/** 任意宽度:跨全域整数/浮点,并显式纳入断点边界值。 */
const anyWidthArb: fc.Arbitrary<number> = fc.oneof(
fc.integer({ min: -10_000, max: 100_000 }),
fc.double({ min: -10_000, max: 100_000, noNaN: true, noDefaultInfinity: true }),
fc.constantFrom(767, 768, 1279, 1280, 0, -1),
);
describe('Property 76: 断点布局映射确定性 (Req 22.1, 22.2, 22.3)', () => {
it('width ≥ 1280 恒映射 DesktopLayout', () => {
fc.assert(
fc.property(desktopWidthArb, (width) => {
expect(selectLayout(width)).toBe('DesktopLayout');
}),
{ numRuns: 100 },
);
});
it('768 ≤ width ≤ 1279 恒映射 CompactLayout', () => {
fc.assert(
fc.property(compactWidthArb, (width) => {
expect(selectLayout(width)).toBe('CompactLayout');
}),
{ numRuns: 100 },
);
});
it('width < 768 恒映射 MobileLayout', () => {
fc.assert(
fc.property(mobileWidthArb, (width) => {
expect(selectLayout(width)).toBe('MobileLayout');
}),
{ numRuns: 100 },
);
});
it('任意宽度的映射与独立判定一致,且仅落入三种布局之一', () => {
const layouts: readonly Layout[] = ['DesktopLayout', 'CompactLayout', 'MobileLayout'];
fc.assert(
fc.property(anyWidthArb, (width) => {
const layout = selectLayout(width);
expect(layouts).toContain(layout);
expect(layout).toBe(expectedLayout(width));
}),
{ numRuns: 100 },
);
});
it('同一输入恒产出同一布局(确定性 / 无副作用)', () => {
fc.assert(
fc.property(anyWidthArb, (width) => {
expect(selectLayout(width)).toBe(selectLayout(width));
}),
{ numRuns: 100 },
);
});
it('边界值精确处理:767→Mobile, 768→Compact, 1279→Compact, 1280→Desktop', () => {
expect(selectLayout(767)).toBe('MobileLayout');
expect(selectLayout(768)).toBe('CompactLayout');
expect(selectLayout(1279)).toBe('CompactLayout');
expect(selectLayout(1280)).toBe('DesktopLayout');
});
});
+16
View File
@@ -0,0 +1,16 @@
/**
* 响应式布局服务公共入口(barreltask 21.1)。
*
* 暴露断点映射与视口变更的纯函数及类型,供表现层与属性测试(task 21.2 /
* Property 76、task 21.3 / Property 67)复用。
*/
export type { Layout, DashboardSection, UIState } from './viewport.js';
export {
BREAKPOINTS,
MOBILE_RETAINED_SECTIONS,
ALL_DASHBOARD_SECTIONS,
selectLayout,
retainedSections,
onViewportChange,
} from './viewport.js';
+129
View File
@@ -0,0 +1,129 @@
/**
* 响应式布局服务 — 纯断点映射与视口变更(task 21.1Req 22.122.4)。
*
* 以确定性纯函数承载「视口宽度 → 布局」的映射及跨断点切换逻辑,供表现层渲染
* 与属性测试(task 21.2 / Property 76、task 21.3 / Property 67)共同复用:
* - `selectLayout(width)`:将 Viewport 宽度映射到唯一确定的 `Layout`Req 22.122.3)。
* - `retainedSections(layout)`:声明各布局在看板视图中保留的区块集合;移动布局
* 必保留 `Risk_Score`、`Risk_Grade` 与 `Top_N`Req 22.3),使该约束显式可测。
* - `onViewportChange(prevState, newWidth)`:依据新宽度重算布局并应用,且不丢失/
* 不篡改 `prevState.enteredData`Req 22.4 / Property 67,验证见 task 21.3)。
*
* 设计要点(保证确定性与数据保留):
* - 全部函数无副作用、不修改入参,返回新值或原样引用。
* - 断点阈值集中于 `BREAKPOINTS`,比较以闭/开区间精确处理边界 767/768/1279/1280。
* - `enteredData` 视为不透明的泛型记录,`onViewportChange` 仅替换 `layout` 字段,
* 原样保留 `enteredData` 引用,故无任何键丢失或篡改。
*/
/** 三种响应式布局之一(Req 22.122.3)。 */
export type Layout = 'DesktopLayout' | 'CompactLayout' | 'MobileLayout';
/**
* 断点阈值常量(CSS 像素,Req 22.122.3):
* - `COMPACT_MIN_WIDTH`:进入紧凑布局的最小宽度(含),低于此值为移动布局。
* - `DESKTOP_MIN_WIDTH`:进入桌面布局的最小宽度(含)。
*
* 映射区间:`width ≥ 1280` → 桌面;`768 ≤ width ≤ 1279` → 紧凑;`width < 768` → 移动。
*/
export const BREAKPOINTS = {
/** 紧凑布局下界(含):768 CSS 像素。 */
COMPACT_MIN_WIDTH: 768,
/** 桌面布局下界(含):1280 CSS 像素。 */
DESKTOP_MIN_WIDTH: 1280,
} as const;
/**
* 看板视图中的可呈现区块标识。
*
* 移动布局所必保留的最小集合为 `Risk_Score` / `Risk_Grade` / `Top_N`Req 22.3);
* 其余区块在桌面与紧凑布局下完整呈现(Req 22.1、22.2)。
*/
export type DashboardSection =
| 'Risk_Score'
| 'Risk_Grade'
| 'Top_N'
| 'Risk_Heatmap'
| 'Cost_Breakdown'
| 'Quote_Compare'
| 'Portfolio_Compare';
/** 移动布局看板视图必保留的最小区块集合(Req 22.3)。 */
export const MOBILE_RETAINED_SECTIONS: readonly DashboardSection[] = [
'Risk_Score',
'Risk_Grade',
'Top_N',
] as const;
/** 桌面/紧凑布局下完整呈现的全部看板区块(Req 22.1、22.2)。 */
export const ALL_DASHBOARD_SECTIONS: readonly DashboardSection[] = [
'Risk_Score',
'Risk_Grade',
'Top_N',
'Risk_Heatmap',
'Cost_Breakdown',
'Quote_Compare',
'Portfolio_Compare',
] as const;
/**
* 将 Viewport 宽度映射到唯一确定的布局(Req 22.122.3 / Property 76)。
*
* 确定性区间映射(边界 767/768/1279/1280 精确处理):
* - `width ≥ 1280` → `DesktopLayout`(全部功能)
* - `768 ≤ width ≤ 1279` → `CompactLayout`(无横向滚动条,内容完整可见)
* - `width < 768` → `MobileLayout`(看板保留 Risk_Score/Risk_Grade/Top N
*
* 同一宽度恒映射到同一布局(无状态、无副作用)。
*/
export function selectLayout(viewportWidth: number): Layout {
if (viewportWidth >= BREAKPOINTS.DESKTOP_MIN_WIDTH) {
return 'DesktopLayout';
}
if (viewportWidth >= BREAKPOINTS.COMPACT_MIN_WIDTH) {
return 'CompactLayout';
}
return 'MobileLayout';
}
/**
* 返回给定布局在看板视图中保留的区块集合(Req 22.1–22.3)。
* - `MobileLayout` → `MOBILE_RETAINED_SECTIONS`Risk_Score/Risk_Grade/Top N)。
* - `CompactLayout` / `DesktopLayout` → `ALL_DASHBOARD_SECTIONS`(完整呈现)。
*
* 返回只读数组,使「移动布局必保留最小集合」这一约束显式且可测。
*/
export function retainedSections(layout: Layout): readonly DashboardSection[] {
return layout === 'MobileLayout' ? MOBILE_RETAINED_SECTIONS : ALL_DASHBOARD_SECTIONS;
}
/**
* UI 状态模型(携带当前布局与不透明的已录入数据)。
*
* `enteredData` 以泛型不透明记录表达,默认 `Readonly<Record<string, unknown>>`
* 以便与后续向导(task 20.x)录入数据组合而无需在此处约束其形状。
*/
export interface UIState<TData = Readonly<Record<string, unknown>>> {
/** 当前应用的布局(由最近一次视口宽度决定)。 */
readonly layout: Layout;
/** 当前页面已录入数据(不透明,跨布局切换恒保持不变)。 */
readonly enteredData: TData;
}
/**
* 处理视口宽度变化(Req 22.4 / Property 67)。
*
* 依据 `newWidth` 重算布局并应用;当跨越布局断点而布局改变时返回带新 `layout`
* 的新状态,否则原样返回 `prevState`。两种情况下均**原样保留** `prevState.enteredData`
* 引用——既不丢失任何键也不篡改其值(纯函数,不修改入参)。
*/
export function onViewportChange<TData>(
prevState: UIState<TData>,
newWidth: number,
): UIState<TData> {
const layout = selectLayout(newWidth);
if (layout === prevState.layout) {
return prevState;
}
return { ...prevState, layout };
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+19
View File
@@ -0,0 +1,19 @@
/**
* Type augmentation registering the vitest-axe `toHaveNoViolations` matcher on
* Vitest's `expect`. vitest-axe 0.1.0 only augments the legacy `Vi` namespace,
* which Vitest 2.x no longer maps onto `expect`, so we declare it against the
* `vitest` module directly. The matcher itself is registered at runtime in
* web/src/__tests__/setup.ts via `expect.extend`.
*/
import 'vitest';
interface AxeMatchers<R = unknown> {
toHaveNoViolations(): R;
}
declare module 'vitest' {
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface Assertion<T = unknown> extends AxeMatchers<T> {}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface AsymmetricMatchersContaining extends AxeMatchers {}
}
+197
View File
@@ -0,0 +1,197 @@
/**
* GapPanel — 信息缺口提示面板(task 20.3Req 21.3 / Property 74)。
*
* 当本次 Assessment 仍存在信息缺口或待补充项时,以**区别于常规文本的醒目样式**
* (警示配色 + 警示图标 + 独立警示背景与强调边框,类 Toast 警示外观)集中呈现
* **全部**该类项;并为**每一项**提供定位至其对应录入位置的入口(Req 21.3)。
*
* 定位入口:
* - 当注入 `onLocate` 回调时,点击调用 `onLocate(locateAnchor)`(同时保留可跳转的
* `href="#${locateAnchor}"` 作为渐进增强与无 JS 回退)。
* - 未注入回调时,作为纯锚点链接跳转至 `#${locateAnchor}`。
* - 每个缺口项均通过 `data-locate-anchor` 暴露其定位目标,且其定位控件为可聚焦、
* 可操作的链接(原生 `<a>`,键盘可达)。
*
* 可访问性:
* - 面板以 `role="region"` + `aria-labelledby` 关联可见标题暴露为命名地标区域。
* - 缺口项以 `<ul>`/`<li>` 表达集合关系;标签作为可见 DOM 文本节点呈现。
* - 视觉值统一取自 Design Tokens(颜色经 CSS 变量引用,随 ThemeProvider 切换),
* 与基础组件库一致(Req 19.1 / 19.6)。
*
* 纯缺口派生逻辑位于 `gaps.ts``listGaps` / `hasGaps`,供 Property 74 验证),本组件
* 仅负责将给定 `GapItem[]` 渲染为醒目且可定位的可访问标记。
*/
import type { CSSProperties } from 'react';
import {
colorVar,
FONT_FAMILY,
RADIUS,
space,
typographyStyle,
} from '../design-system/components/styles.js';
import { Icon } from '../design-system/index.js';
import type { GapItem } from './gaps.js';
/** `GapPanel` 组件属性。 */
export interface GapPanelProps {
/** 待呈现的全部缺口项(通常由 `listGaps` 派生)。 */
readonly gaps: readonly GapItem[];
/**
* 定位回调:点击某缺口项的定位入口时以其 `locateAnchor` 调用。
* 缺省时定位入口退化为锚点跳转 `#${locateAnchor}`。
*/
readonly onLocate?: (locateAnchor: string) => void;
/** 可选标题,缺省为「信息缺口 / 待补充项」。 */
readonly title?: string;
/** 定位入口的可读文案,缺省为「定位」。 */
readonly locateLabel?: string;
}
/** 面板标题元素的稳定 id(供 `aria-labelledby` 关联)。 */
const HEADING_ID = 'gap-panel-heading';
/**
* 信息缺口提示面板:醒目呈现全部缺口项并为每项提供定位入口(Req 21.3)。
*
* 当 `gaps` 为空时返回 `null`(无缺口则不渲染面板,符合「WHILE 存在缺口」语义)。
*/
export function GapPanel({
gaps,
onLocate,
title,
locateLabel = '定位',
}: GapPanelProps): JSX.Element | null {
if (gaps.length === 0) {
return null;
}
const heading = title ?? '信息缺口 / 待补充项';
const accent = colorVar('color.risk.high');
// 醒目样式:警示强调色 + 独立警示背景 + 左侧强调边框(明显区别于常规正文)。
const containerStyle: CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: `${space(3)}px`,
padding: `${space(4)}px`,
fontFamily: FONT_FAMILY,
color: colorVar('color.text.primary'),
backgroundColor: colorVar('color.bg.elevated'),
border: `1px solid ${accent}`,
borderInlineStart: `${space(1)}px solid ${accent}`,
borderRadius: `${RADIUS.md}px`,
};
const headerStyle: CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: `${space(2)}px`,
color: accent,
};
const headingStyle: CSSProperties = {
...typographyStyle('title'),
fontWeight: 700,
color: accent,
margin: 0,
};
const listStyle: CSSProperties = {
listStyle: 'none',
margin: 0,
padding: 0,
display: 'flex',
flexDirection: 'column',
gap: `${space(2)}px`,
};
const itemStyle: CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: `${space(3)}px`,
padding: `${space(2)}px ${space(3)}px`,
backgroundColor: colorVar('color.bg.surface'),
borderRadius: `${RADIUS.sm}px`,
};
const labelStyle: CSSProperties = {
...typographyStyle('body'),
flex: 1,
fontWeight: 600,
color: colorVar('color.text.primary'),
};
const locateStyle: CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
gap: `${space(1)}px`,
marginInlineStart: 'auto',
padding: `${space(1)}px ${space(3)}px`,
...typographyStyle('caption'),
fontWeight: 600,
color: colorVar('color.text.onAccent'),
backgroundColor: accent,
border: `1px solid ${accent}`,
borderRadius: `${RADIUS.sm}px`,
cursor: 'pointer',
textDecoration: 'none',
whiteSpace: 'nowrap',
};
return (
<section
role="region"
aria-labelledby={HEADING_ID}
data-gap-panel="true"
data-gap-count={gaps.length}
style={containerStyle}
>
<header style={headerStyle}>
<Icon name="warning" size={22} color={accent} title="信息缺口" />
<h2 id={HEADING_ID} style={headingStyle} data-gap-panel-heading="true">
{heading}
</h2>
</header>
<ul data-gap-list="true" style={listStyle}>
{gaps.map((gap) => {
const anchorHref = `#${gap.locateAnchor}`;
const handleLocate =
onLocate === undefined
? undefined
: (event: { preventDefault: () => void }): void => {
event.preventDefault();
onLocate(gap.locateAnchor);
};
return (
<li
key={`${gap.dimensionId}:${gap.indicatorId}:${gap.locateAnchor}`}
data-gap-item="true"
data-indicator-id={gap.indicatorId}
data-dimension-id={gap.dimensionId}
data-locate-anchor={gap.locateAnchor}
style={itemStyle}
>
<Icon name="warning" size={16} color={accent} />
<span data-gap-label="true" style={labelStyle}>
{gap.label}
</span>
<a
href={anchorHref}
data-locate-anchor={gap.locateAnchor}
data-gap-locate="true"
aria-label={`${locateLabel}${gap.label}`}
onClick={handleLocate}
style={locateStyle}
>
<Icon name="search" size={14} color={colorVar('color.text.onAccent')} />
<span>{locateLabel}</span>
</a>
</li>
);
})}
</ul>
</section>
);
}
+100
View File
@@ -0,0 +1,100 @@
/**
* QuickActions — Wizard 一键操作入口(task 20.2Req 21.4)。
*
* 为「提交评估」「保存草稿」「导出报告」三项操作各提供一个一键触发入口(Req 21.4),
* 全部以基础组件库的 `Button` 渲染(保证全 UI 外观/交互一致,Req 19.1),并暴露
* 清晰可读的可访问标签(task 20.8 组件测试将断言其存在)。
*
* 纯描述符与展示组件分离:
* - `quickActions()` 返回三项操作的纯描述符 `{ submit, saveDraft, exportReport }`
* (键、可见标签与按钮变体),便于测试与复用、不依赖 React;
* - `QuickActions` 组件按描述符渲染按钮,并将点击事件转接到注入的回调。
*/
import { Button } from '../design-system/index.js';
/** 一键操作的稳定标识。 */
export type QuickActionKey = 'submit' | 'saveDraft' | 'exportReport';
/** 单个一键操作的纯描述符。 */
export interface QuickActionDescriptor {
/** 操作稳定标识。 */
readonly key: QuickActionKey;
/** 可见标签(无障碍可读,Req 21.4)。 */
readonly label: string;
/** 按钮外观变体(提交为主操作,其余为次操作)。 */
readonly variant: 'primary' | 'secondary';
}
/** 三项一键操作的描述符集合。 */
export interface QuickActionSet {
/** 提交评估(主操作)。 */
readonly submit: QuickActionDescriptor;
/** 保存草稿。 */
readonly saveDraft: QuickActionDescriptor;
/** 导出报告。 */
readonly exportReport: QuickActionDescriptor;
}
/**
* 返回「提交评估 / 保存草稿 / 导出报告」三项一键操作的纯描述符(Req 21.4)。
* 不含任何副作用,供组件渲染与测试复用。
*/
export function quickActions(): QuickActionSet {
return {
submit: { key: 'submit', label: '提交评估', variant: 'primary' },
saveDraft: { key: 'saveDraft', label: '保存草稿', variant: 'secondary' },
exportReport: { key: 'exportReport', label: '导出报告', variant: 'secondary' },
};
}
/** `QuickActions` 组件属性:三项操作各注入一个回调。 */
export interface QuickActionsProps {
/** 提交评估回调。 */
readonly onSubmit: () => void;
/** 保存草稿回调。 */
readonly onSaveDraft: () => void;
/** 导出报告回调。 */
readonly onExportReport: () => void;
/** 是否禁用全部入口(如表单校验未通过时)。 */
readonly disabled?: boolean;
}
/**
* 渲染三项一键操作入口(Req 21.4):提交评估、保存草稿、导出报告。
* 每个入口为一个可访问的 `Button`,点击时调用对应注入回调。
*/
export function QuickActions({
onSubmit,
onSaveDraft,
onExportReport,
disabled = false,
}: QuickActionsProps): JSX.Element {
const actions = quickActions();
const handlers: Record<QuickActionKey, () => void> = {
submit: onSubmit,
saveDraft: onSaveDraft,
exportReport: onExportReport,
};
return (
<div
data-quick-actions="true"
role="group"
aria-label="一键操作"
style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}
>
{[actions.submit, actions.saveDraft, actions.exportReport].map((action) => (
<Button
key={action.key}
variant={action.variant}
disabled={disabled}
ariaLabel={action.label}
onClick={handlers[action.key]}
>
{action.label}
</Button>
))}
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
/**
* UnsavedLeaveDialog — 未保存修改离开确认对话框(task 20.4Req 21.7)。
*
* 当评估者在存在未保存修改时请求离开当前评估流程,UI 须提示「存在未保存修改」并
* 请求确认(Req 21.7)。本组件基于 Design_System 的 `Dialog``role="dialog"` +
* `aria-modal`Req 19.1)呈现该确认提示,并以 `Button` 提供两个明确动作:
* - 「离开并放弃修改」:触发 `onConfirmLeave`(确认离开)。
* - 「继续编辑」:触发 `onCancel`(取消离开,回到流程)。
*
* 是否需要弹出由纯逻辑 `confirmLeaveIfDirty(isDirty)` 决定(见 `draft.ts`);本组件
* 仅负责将 `open` 受控呈现为可访问的确认 UI(组件测试见 task 20.8)。
*/
import { Button } from '../design-system/index.js';
import { Dialog } from '../design-system/index.js';
/** `UnsavedLeaveDialog` 组件属性。 */
export interface UnsavedLeaveDialogProps {
/** 是否显示确认对话框(通常由 `confirmLeaveIfDirty(...).shouldPrompt` 驱动)。 */
readonly open: boolean;
/** 确认离开(放弃未保存修改)的回调。 */
readonly onConfirmLeave: () => void;
/** 取消离开(继续编辑)的回调。 */
readonly onCancel: () => void;
/** 可选标题,缺省为「存在未保存的修改」。 */
readonly title?: string;
/** 可选提示正文,缺省为标准未保存提示文案。 */
readonly message?: string;
}
/**
* 未保存修改离开确认对话框(Req 21.7)。显隐受控;确认离开与继续编辑各一动作。
*/
export function UnsavedLeaveDialog({
open,
onConfirmLeave,
onCancel,
title = '存在未保存的修改',
message = '当前评估流程存在尚未保存的修改,若现在离开将会丢失这些修改。确定要离开吗?',
}: UnsavedLeaveDialogProps): JSX.Element | null {
return (
<Dialog
open={open}
onClose={onCancel}
title={title}
closeLabel="继续编辑"
footer={
<>
<Button variant="secondary" onClick={onCancel}>
</Button>
<Button variant="primary" onClick={onConfirmLeave}>
</Button>
</>
}
>
<p data-unsaved-leave-message="true" style={{ margin: 0 }}>
{message}
</p>
</Dialog>
);
}
+196
View File
@@ -0,0 +1,196 @@
/**
* Wizard — 向导式分步评估流程组件(task 20.1Req 21.1)。
*
* 以有序步骤呈现评估流程,并在每一步显示进度指示:当前步骤序号(1 基)、步骤总数
* 与已完成步骤数(Req 21.1)。视觉值统一取自 Design Tokens(颜色经 CSS 变量引用,
* 随 ThemeProvider 切换),与基础组件库保持一致(Req 19.1 / 19.6)。
*
* 可访问性:
* - 进度以 `role="progressbar"` 暴露,并附 `aria-valuemin` / `aria-valuemax` /
* `aria-valuenow`(取值为已完成步骤数)与可读 `aria-valuetext`。
* - 步骤列表以 `<ol>` 表达有序关系;当前步骤项标注 `aria-current="step"`。
* - 关键数字以可见 DOM 文本节点呈现(如「步骤 2 / 5」、「已完成 1 步」),
* 便于无障碍读取与组件测试(task 20.8)。
*
* 纯状态与进度派生逻辑位于 `wizard-state.ts`(供 Property 73 验证),本组件仅负责
* 将给定 `WizardState` 渲染为可访问标记。
*/
import type { CSSProperties } from 'react';
import { colorTokenToCssVarName, spacing, typography } from '../design-system/index.js';
import type { ColorToken } from '../design-system/index.js';
import { progress } from './wizard-state.js';
import type { WizardState } from './wizard-state.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` };
}
/* ------------------------------------------------------------------ *
* Wizard 组件
* ------------------------------------------------------------------ */
/** `Wizard` 组件属性。 */
export interface WizardProps {
/** 待呈现的 Wizard 状态(有序步骤 + 当前步骤 + 完成进度)。 */
readonly state: WizardState;
/** 可选标题,缺省为「评估流程」。 */
readonly title?: string;
}
/**
* 向导式分步评估流程:呈现有序步骤与进度指示(Req 21.1)。
*/
export function Wizard({ state, title }: WizardProps): JSX.Element {
const { current, total, completedCount } = progress(state);
const containerStyle: CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: `${space(3)}px`,
fontFamily: FONT_FAMILY,
color: colorVar('color.text.primary'),
};
const headerStyle: CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: `${space(1)}px`,
};
const stepCounterText = total === 0 ? '暂无步骤' : `步骤 ${current} / ${total}`;
const completedText = `已完成 ${completedCount} / ${total}`;
const valueText = total === 0
? '暂无步骤'
: `当前第 ${current} 步,共 ${total} 步,已完成 ${completedCount}`;
return (
<section data-wizard="true" style={containerStyle} aria-label={title ?? '评估流程'}>
<header style={headerStyle}>
<span
style={{ ...typographyStyle('title'), fontWeight: 700 }}
data-wizard-title="true"
>
{title ?? '评估流程'}
</span>
{/* 当前步骤序号与步骤总数(可见文本,Req 21.1)。 */}
<span
data-wizard-step-counter="true"
data-current-step={current}
data-total-steps={total}
style={{ ...typographyStyle('body'), color: colorVar('color.text.secondary') }}
>
{stepCounterText}
</span>
</header>
{/* 已完成步骤进度指示(Req 21.1),以 progressbar 角色暴露。 */}
<div
role="progressbar"
aria-valuemin={0}
aria-valuemax={total}
aria-valuenow={completedCount}
aria-valuetext={valueText}
data-wizard-progress="true"
data-completed-count={completedCount}
style={{ display: 'flex', flexDirection: 'column', gap: `${space(1)}px` }}
>
<div
aria-hidden={true}
style={{
position: 'relative',
height: `${space(2)}px`,
borderRadius: `${space(1)}px`,
backgroundColor: colorVar('color.border.default'),
overflow: 'hidden',
}}
>
<div
style={{
position: 'absolute',
insetInlineStart: 0,
top: 0,
bottom: 0,
width: total === 0 ? '0%' : `${(completedCount / total) * 100}%`,
backgroundColor: colorVar('color.brand.primary'),
}}
/>
</div>
<span
data-wizard-completed="true"
style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}
>
{completedText}
</span>
</div>
{/* 有序步骤列表;当前步骤标注 aria-current="step"。 */}
{total > 0 ? (
<ol
data-wizard-steps="true"
style={{
listStyle: 'none',
margin: 0,
padding: 0,
display: 'flex',
flexDirection: 'column',
gap: `${space(1)}px`,
}}
>
{state.steps.map((step, index) => {
const isCurrent = index === current - 1;
const isCompleted = state.completed[index] === true;
const stateLabel = isCompleted ? '已完成' : isCurrent ? '进行中' : '未开始';
return (
<li
key={step.id}
data-wizard-step={step.id}
data-step-index={index}
data-step-completed={isCompleted}
{...(isCurrent ? { 'aria-current': 'step' as const } : {})}
style={{
display: 'flex',
alignItems: 'center',
gap: `${space(2)}px`,
...typographyStyle('body'),
fontWeight: isCurrent ? 700 : 400,
color: isCurrent
? colorVar('color.brand.primary')
: colorVar('color.text.primary'),
}}
>
<span aria-hidden={true}>{index + 1}.</span>
<span>{step.title}</span>
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>
{stateLabel}
</span>
</li>
);
})}
</ol>
) : null}
</section>
);
}

Some files were not shown because too many files have changed in this diff Show More