外包风险评估系统:领域引擎+前端+服务端持久化与生产部署
- 确定性领域引擎(分类/评分/分级/红线/费用/裁决)+LLM(通义千问)语言理解 - 6步评估向导、服务端草稿持久化(跨设备/编辑草稿保护) - 工作流(草稿→风控→管理层)、RBAC、报告导出、校准、客户/费率/红线/最低工资管理 - 专业图标体系替换全部emoji、看板美化 - 生产化:API_BASE可配置(同源反代)、auth密钥惰性读取修复RBAC - 444单测+204前端测试+51 e2e
This commit is contained in:
@@ -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
@@ -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.2,Req 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 秒内对当前页面生效且不丢失已录入数据', () => {
|
||||
// 隔离的承载元素,避免污染全局 :root;ThemeProvider 将 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.4:30 秒内给出终态。
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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. 切换 Theme(Light ↔ Dark,design-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.1,Req 19.1 / 20.3)。
|
||||
*
|
||||
* 目标:对关键页面与全套图表建立**基线快照**,覆盖 Light/Dark 双主题与三档断点
|
||||
* (≥1280 桌面 / 768–1279 紧凑 / <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 / 768–1279 / <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.1–22.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();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}` };
|
||||
},
|
||||
);
|
||||
|
||||
/** 任意 Theme(Light 或 Dark)。 */
|
||||
const themeArb: fc.Arbitrary<Theme> = fc.constantFrom(...THEME_VALUES);
|
||||
|
||||
/**
|
||||
* Design_System 中用于正文渲染的「文本令牌 × 背景令牌」对。
|
||||
* 取自 design.md:text.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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 78(task 22.4,Req 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 78(Req 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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* WCAG 2.1 对比度计算(Req 23.3 / task 22.2)。
|
||||
*
|
||||
* 提供纯函数 `contrastRatio(fg, bg)` 计算两个颜色的相对对比度,供文本可读性
|
||||
* 达标校验使用:正文(normal)≥ 4.5:1、大号文本(large)≥ 3:1(Property 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:1;false → 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;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 键盘可达与可见焦点指示(Req 23.1 / 23.2,task 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 };
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* 表单可访问标注与无障碍错误(Req 23.4 / 23.5,task 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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* 可访问性(a11y)公共入口(barrel)。
|
||||
*
|
||||
* 暴露 WCAG 对比度计算 API(task 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';
|
||||
@@ -0,0 +1,882 @@
|
||||
/**
|
||||
* HTTP API 客户端:封装 fetch 调用后端 REST API(Req 16.2)。
|
||||
*
|
||||
* 所有 API 返回 Promise<T>,错误时抛出 ApiError(含 status 与 message)。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 后端 API 基址。
|
||||
* - 开发模式:默认 http://localhost:3005(Vite 代理或直连本地后端)。
|
||||
* - 生产模式:默认空串 → 走相对路径 /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();
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* AppShell — 应用外壳:顶部栏(标题 / 导航 / 用户信息 / 主题切换)+ 角色化内容区。
|
||||
*
|
||||
* - 登录后按角色显示导航:商务/销售可见「新建评估」,风控可见「待办审核」,管理层可见「待办审批」。
|
||||
* - 顶部栏展示当前登录用户的用户名与角色标签,并提供退出登录。
|
||||
* - 主题切换复用 Design_System 的 ThemeProvider(Req 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* 角色化视图:商务/销售、风控、管理层(Req 13.1–13.3),复用全套图表与基础组件。
|
||||
*
|
||||
* - SalesView:可接受性结论 + 接受条件清单 + 风险调整后报价(含费用拆解/报价对比图)。
|
||||
* - RiskView:评分项明细(Risk_Level/依据/Provenance/Confidence)+ 红线校验 + 缺口尽调。
|
||||
* - ManagementView:Risk_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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: '用工结构调整成本约 8–12 万元',
|
||||
},
|
||||
{
|
||||
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: '极高' },
|
||||
];
|
||||
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* ChartContainer — 通用 Chart 容器(task 19.1)。
|
||||
*
|
||||
* 实现 `renderChart(spec)` 的通用契约,由具体图表(task 19.2–19.5)包裹其图形内容
|
||||
* (recharts)后复用:
|
||||
* - status `loading` → 呈现 Loading_State(role="status" + aria-busy,Req 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.md:renderChart)。
|
||||
* 与 `<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;
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* CostBreakdownChart — 费用拆解图(task 19.4,Req 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_State,Req 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* PortfolioCompareChart — 跨项目组合对比图(task 19.5,Req 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_Score(0–100)。 */
|
||||
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_State,Req 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_Score(0–100)' },
|
||||
...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_State,Req 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* QuoteCompareChart — 基准 vs 风险调整后报价对比图(task 19.4,Req 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_State,Req 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* RiskBadge — Risk_Grade 徽章(task 19.2,Req 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* RiskHeatmap — 风险热力图(task 19.2,Req 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.2–20.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_State,Req 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* ScoreGauge — 风险总分仪表盘(task 19.3,Req 20.1 / 20.6)。
|
||||
*
|
||||
* 同时呈现 Risk_Score 数值与其对应的 Risk_Grade 文本标签(Req 20.6):
|
||||
* - Risk_Grade 由本地 `classifyGrade(score)` 派生,逐字镜像领域层分级规则,
|
||||
* 因此仪表盘展示的等级恒等于该分值的分类结果(task 19.9 / Property 71)。
|
||||
* - 等级配色经 `riskGradeColorToken(grade)` 取得稳定 Color_Token,最终取值由
|
||||
* ThemeProvider 解析为 CSS 变量,不在图表层硬编码具体颜色(Req 19.6)。
|
||||
*
|
||||
* 通过通用 `ChartContainer` 取得一致的框架(标题 / 三态 / 标签),仪表盘图形以
|
||||
* 确定性 SVG 半圆弧表达,分值与等级以可见文本节点呈现(便于无障碍与测试)。
|
||||
* loading / empty 三态由容器统一处理(Req 20.4 / 20.5)。
|
||||
*/
|
||||
|
||||
import type { CSSProperties } from 'react';
|
||||
import {
|
||||
colorTokenToCssVarName,
|
||||
riskGradeColorToken,
|
||||
spacing,
|
||||
typography,
|
||||
} from '../design-system/index.js';
|
||||
import type { ColorToken } from '../design-system/index.js';
|
||||
import { ChartContainer } from './ChartContainer.js';
|
||||
import type { ChartSpec, Label, Series } from './chart-types.js';
|
||||
import { classifyGrade } from './riskGrade.js';
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 局部样式原语(取自 Design Tokens,颜色经 CSS 变量引用,与 ChartContainer 一致)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/** 将 Color_Token 映射为 CSS 变量引用。 */
|
||||
function colorVar(token: ColorToken): string {
|
||||
return `var(${colorTokenToCssVarName(token)})`;
|
||||
}
|
||||
|
||||
/** 取间距标度第 `step` 档(px);越界回退为 0。 */
|
||||
function space(step: number): number {
|
||||
return spacing[step] ?? 0;
|
||||
}
|
||||
|
||||
const FONT_FAMILY =
|
||||
"'Inter', 'PingFang SC', 'Microsoft YaHei', system-ui, -apple-system, sans-serif";
|
||||
|
||||
/** 取具名排版层级的字体样式(找不到则回退到 body 近似值)。 */
|
||||
function typographyStyle(name: string): CSSProperties {
|
||||
const level = typography.find((t) => t.name === name);
|
||||
if (level === undefined) {
|
||||
return { fontSize: '14px', lineHeight: '22px' };
|
||||
}
|
||||
return { fontSize: `${level.fontSize}px`, lineHeight: `${level.lineHeight}px` };
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Risk_Score 取值域
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/** Risk_Score 下界(Req:0 至 100)。 */
|
||||
const SCORE_MIN = 0;
|
||||
/** Risk_Score 上界(Req:0 至 100)。 */
|
||||
const SCORE_MAX = 100;
|
||||
|
||||
/** 将任意输入夹取到 [0, 100] 取值域内,保证弧形渲染稳健。 */
|
||||
function clampScore(score: number): number {
|
||||
if (Number.isNaN(score)) {
|
||||
return SCORE_MIN;
|
||||
}
|
||||
if (score < SCORE_MIN) {
|
||||
return SCORE_MIN;
|
||||
}
|
||||
if (score > SCORE_MAX) {
|
||||
return SCORE_MAX;
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 半圆弧几何(确定性,无外部依赖)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
const GAUGE_WIDTH = 220;
|
||||
const GAUGE_HEIGHT = 120;
|
||||
const GAUGE_CX = GAUGE_WIDTH / 2;
|
||||
const GAUGE_CY = GAUGE_HEIGHT - 10;
|
||||
const GAUGE_RADIUS = 90;
|
||||
const GAUGE_STROKE = 16;
|
||||
|
||||
/** 极坐标 → 笛卡尔坐标(角度以度计,0°=正左,180°=正右,沿上半圆)。 */
|
||||
function polar(angleDeg: number): { readonly x: number; readonly y: number } {
|
||||
const rad = (Math.PI * (180 - angleDeg)) / 180;
|
||||
return {
|
||||
x: GAUGE_CX + GAUGE_RADIUS * Math.cos(rad),
|
||||
y: GAUGE_CY - GAUGE_RADIUS * Math.sin(rad),
|
||||
};
|
||||
}
|
||||
|
||||
/** 构造从 `startDeg` 到 `endDeg` 的半圆弧 path 数据(上半圆,0..180°)。 */
|
||||
function arcPath(startDeg: number, endDeg: number): string {
|
||||
const start = polar(startDeg);
|
||||
const end = polar(endDeg);
|
||||
const largeArc = endDeg - startDeg > 180 ? 1 : 0;
|
||||
return `M ${start.x} ${start.y} A ${GAUGE_RADIUS} ${GAUGE_RADIUS} 0 ${largeArc} 1 ${end.x} ${end.y}`;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 仪表盘图形(SVG)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function GaugeArc({
|
||||
score,
|
||||
gradeColorToken,
|
||||
}: {
|
||||
readonly score: number;
|
||||
readonly gradeColorToken: ColorToken;
|
||||
}): JSX.Element {
|
||||
// 分值在 [0,100] 线性映射到半圆 [0°,180°]。
|
||||
const sweep = (score - SCORE_MIN) / (SCORE_MAX - SCORE_MIN) * 180;
|
||||
return (
|
||||
<svg
|
||||
width={GAUGE_WIDTH}
|
||||
height={GAUGE_HEIGHT}
|
||||
viewBox={`0 0 ${GAUGE_WIDTH} ${GAUGE_HEIGHT}`}
|
||||
role="img"
|
||||
aria-hidden={true}
|
||||
focusable={false}
|
||||
data-gauge-arc="true"
|
||||
>
|
||||
{/* 轨道:完整半圆底色。 */}
|
||||
<path
|
||||
d={arcPath(0, 180)}
|
||||
fill="none"
|
||||
stroke={colorVar('color.border.default')}
|
||||
strokeWidth={GAUGE_STROKE}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* 进度弧:长度对应分值,配色取自 Risk_Grade 的 Color_Token。 */}
|
||||
{sweep > 0 ? (
|
||||
<path
|
||||
d={arcPath(0, sweep)}
|
||||
fill="none"
|
||||
stroke={colorVar(gradeColorToken)}
|
||||
strokeWidth={GAUGE_STROKE}
|
||||
strokeLinecap="round"
|
||||
data-gauge-progress="true"
|
||||
/>
|
||||
) : null}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* ScoreGauge
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/** `ScoreGauge` 组件属性。 */
|
||||
export interface ScoreGaugeProps {
|
||||
/** Risk_Score 数值(期望 0 至 100;越界将被夹取以稳健渲染)。 */
|
||||
readonly score: number;
|
||||
/** 数据是否正在计算/请求中(true → 由容器呈现 Loading_State,Req 20.5)。 */
|
||||
readonly loading?: boolean;
|
||||
/** 可选标题,缺省为「风险总分」。 */
|
||||
readonly title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 风险总分仪表盘:同时呈现 Risk_Score 数值与其对应 Risk_Grade(Req 20.6)。
|
||||
*
|
||||
* 等级由 `classifyGrade(score)` 派生(与领域分级规则一致),等级配色取自
|
||||
* `riskGradeColorToken(grade)`。分值与等级文本均以可见 DOM 文本节点呈现,
|
||||
* 并附 `data-risk-score` / `data-risk-grade` 便于测试与无障碍读取。
|
||||
*/
|
||||
export function ScoreGauge({
|
||||
score,
|
||||
loading,
|
||||
title,
|
||||
}: ScoreGaugeProps): JSX.Element {
|
||||
const safeScore = clampScore(score);
|
||||
const grade = classifyGrade(safeScore);
|
||||
const gradeColorToken = riskGradeColorToken(grade);
|
||||
|
||||
// 单一系列的仪表盘:以一个数据点承载分值,使容器进入就绪态并渲染图形。
|
||||
const series: Series[] = loading === true
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: 'risk-score',
|
||||
label: '风险总分',
|
||||
encoding: {
|
||||
colorToken: gradeColorToken,
|
||||
textLabel: `风险总分(${grade})`,
|
||||
pattern: 'solid',
|
||||
},
|
||||
points: [{ label: `Risk_Score:${safeScore}`, value: safeScore }],
|
||||
},
|
||||
];
|
||||
|
||||
const labels: Label[] = [
|
||||
{ kind: 'axis', text: 'Risk_Score(0–100)' },
|
||||
{ kind: 'partition', text: `Risk_Grade:${grade}` },
|
||||
];
|
||||
|
||||
const spec: ChartSpec = {
|
||||
type: 'ScoreGauge',
|
||||
status: loading === true ? 'loading' : 'ready',
|
||||
series,
|
||||
labels,
|
||||
title: title ?? '风险总分',
|
||||
emptyMessage: '暂无可展示的风险总分',
|
||||
loadingMessage: '正在计算风险总分…',
|
||||
};
|
||||
|
||||
return (
|
||||
<ChartContainer spec={spec}>
|
||||
<div
|
||||
data-score-gauge="true"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: `${space(2)}px`,
|
||||
fontFamily: FONT_FAMILY,
|
||||
}}
|
||||
>
|
||||
<GaugeArc score={safeScore} gradeColorToken={gradeColorToken} />
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: `${space(1)}px`,
|
||||
}}
|
||||
>
|
||||
{/* Risk_Score 数值(Req 20.6)。 */}
|
||||
<span
|
||||
data-risk-score={safeScore}
|
||||
aria-label={`风险总分 ${safeScore}`}
|
||||
style={{
|
||||
...typographyStyle('display'),
|
||||
fontWeight: 700,
|
||||
color: colorVar(gradeColorToken),
|
||||
}}
|
||||
>
|
||||
{safeScore}
|
||||
</span>
|
||||
{/* Risk_Grade 文本标签(Req 20.6)。 */}
|
||||
<span
|
||||
data-risk-grade={grade}
|
||||
aria-label={`风险分级 ${grade}`}
|
||||
style={{
|
||||
...typographyStyle('title'),
|
||||
fontWeight: 700,
|
||||
color: colorVar(gradeColorToken),
|
||||
}}
|
||||
>
|
||||
{grade}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</ChartContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* TopNRiskChart — Top N 关键风险条形图(task 19.2,Req 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.6–19.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_State,Req 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.5,Req 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_State(Req 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_State(Req 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_State(Req 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.3,Req 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_Grade(Req 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_State(Req 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.12,Req 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.6–19.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.12,Req 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 ');
|
||||
|
||||
/** 风险等级(1–5)。 */
|
||||
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);
|
||||
|
||||
/** 风险等级(1–5)。 */
|
||||
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' 下贴近真实形态。
|
||||
*
|
||||
* 断言:
|
||||
* - 系列 ≥2:deriveLegend(series) 的标签集合 == seriesLabels(series) 的集合
|
||||
* (labelSetsEqual);且以派生图例构造的 spec 满足 legendMatchesData === true。
|
||||
* - 系列 ≥2 且无 legend:legendMatchesData === false(图例必需)。
|
||||
* - 系列 <2:无论是否提供 legend,legendMatchesData === 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' 的 ChartSpec(legend 可选传入)。 */
|
||||
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 71(task 19.9,Req 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:
|
||||
* - 变体 A:status === 'empty'(series 任意,含可非空系列)。
|
||||
* - 变体 B:status === '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,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* 任意「加载中」ChartSpec:status === '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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Charts — 视图模型类型(view-model types,task 19.1)。
|
||||
*
|
||||
* 纯类型,无 React、无运行时值。描述通用 Chart 容器 `renderChart(spec)` 所消费的
|
||||
* `ChartSpec` 形态,使具体图表(task 19.2–19.5)与属性测试(task 19.6–19.11)
|
||||
* 都围绕同一份契约工作:
|
||||
* - status:驱动 Empty_State / Loading_State(Req 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.md:categoryEncoding)。
|
||||
* 颜色(`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.md:ChartViewModel / 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 的产物)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/** 风险等级(1–5),镜像领域层 `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;
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* Charts — 纯派生函数(task 19.1)。
|
||||
*
|
||||
* 全部为确定性纯函数(无 React、无 DOM、无可变全局状态),承载通用 Chart 契约的
|
||||
* 可验证逻辑,供 `ChartContainer` 渲染与属性测试(task 19.6–19.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 输出 → ChartSpec(task 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: '暂无可展示的关键风险数据',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Charts 公共入口(barrel,task 19)。
|
||||
*
|
||||
* 暴露通用 Chart 容器(`renderChart` / `ChartContainer` / `Chart`)、视图模型类型
|
||||
* (`ChartSpec` 等)与纯派生函数(图例/标签/状态/非颜色编码、Scoring_Engine 输出适配)。
|
||||
* 具体图表(task 19.2–19.5)与属性测试(task 19.6–19.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.3,Req 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.2,Req 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.5,Req 20.1) */
|
||||
export { PortfolioCompareChart, buildPortfolioCompareSpec } from './PortfolioCompareChart.js';
|
||||
export type {
|
||||
PortfolioCompareChartProps,
|
||||
PortfolioCompareRow,
|
||||
PortfolioCompareKeyRisk,
|
||||
PortfolioCompareSpecOptions,
|
||||
} from './PortfolioCompareChart.js';
|
||||
|
||||
/* 费用拆解图与报价对比图(task 19.4,Req 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';
|
||||
@@ -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_Grade(Req 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_Token,Req 19.6)。
|
||||
*
|
||||
* 属性陈述:对任意 Color_Token 与任意 Theme(Light 或 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[]),
|
||||
);
|
||||
|
||||
/** 任意 Theme(Light 或 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_Token,Req 19.3)。
|
||||
*
|
||||
* 属性陈述:对任意 Risk_Grade,UI 在 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 Spacing,Req 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 Typography,Req 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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Button — 令牌驱动的按钮组件(task 18.8,Req 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Card — 令牌驱动的内容卡片容器(task 18.8,Req 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 Tokens(Req 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Dialog — 令牌驱动的模态对话框(task 18.8,Req 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Icon — 单一来源图标组件(task 18.8,Req 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Input — 令牌驱动的文本输入控件(task 18.8,Req 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Nav — 令牌驱动的导航组件(task 18.8,Req 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Table — 令牌驱动的数据表格(task 18.8,Req 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Toast — 令牌驱动的提示组件(task 18.8,Req 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.9,Req 19.1 / 19.5)。
|
||||
*
|
||||
* 这是 RTL 组件测试(非属性测试)。验证两件事:
|
||||
* - Req 19.1:同类组件(同 variant/size)在不同「页面」上下文渲染时外观与交互
|
||||
* 行为一致 —— 相同的解析后内联样式、相同的语义角色与交互回调。
|
||||
* - Req 19.5:UI 全部图标只能经 `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→status,warning/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→check,warning→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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 基础组件库公共入口(barrel,task 18.8)。
|
||||
*
|
||||
* 全 UI 从此处统一引用按钮、表单控件、表格、卡片、对话框、导航与提示组件,
|
||||
* 保证同类组件在不同页面的外观与交互行为一致(Req 19.1);图标统一经 `Icon`
|
||||
* 取自单一来源图标集(Req 19.5)。组件视觉值均源自 Design Tokens(styles.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.8,Req 19.1)。
|
||||
*
|
||||
* 所有基础组件**仅**从此模块取得「排版 / 间距 / 配色」的样式来源,从而保证
|
||||
* 同类组件在全 UI 的外观与交互行为一致(Req 19.1)。配色一律以 CSS 自定义属性
|
||||
* 引用(`var(--color-...)`),由 ThemeProvider 在切换 Theme 时仅替换取值
|
||||
* (theme-provider.tsx / Req 19.6–19.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';
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Design_System 公共入口(barrel)。
|
||||
*
|
||||
* 暴露 Design Tokens 的原始数据、类型与令牌名映射,作为排版/间距/图标/配色的
|
||||
* 单一事实来源(Req 19.2–19.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.6,Req 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.8,Req 19.1 / 19.5)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
export * from './components/index.js';
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Theme Provider / Context / `useTheme`(Req 19.6 / 19.7,task 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;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* 主题取色与 CSS 变量映射(Req 19.6 / 19.7,task 18.6)。
|
||||
*
|
||||
* 本模块为**纯函数**实现(无 React、无 DOM 副作用),供:
|
||||
* - `resolveColorToken(token, theme)`:确定性返回令牌在某主题下的取值
|
||||
* (design.md / Property 66)。
|
||||
* - `colorTokenToCssVarName(token)`:将令牌名确定性映射为 CSS 自定义属性名
|
||||
* (如 `color.risk.low` → `--color-risk-low`)。
|
||||
* - `buildThemeCssVariables(theme)`:构造某主题下全部令牌的 CSS 变量名→取值表,
|
||||
* 由 Theme Provider(theme.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;
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* Design Tokens — 排版 / 间距 / 图标 / Color_Token(Req 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;
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Typography(Req 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;
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Spacing(Req 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_Token(Req 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.5;Req 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 AA(Property 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' },
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* IconSet(Req 19.5):单一来源图标集,全 UI 图标从该集合引用。
|
||||
* 24×24 viewBox,path 为 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.md:DesignTokens)。 */
|
||||
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];
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Design_System token type vocabulary (Req 19.2–19.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.md:DesignTokens)。
|
||||
* 这是排版/间距/图标/配色的单一事实来源,供后续任务消费。
|
||||
*/
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* OperationFeedbackView — 操作反馈展示组件(task 23.1,Req 24.1–24.3)。
|
||||
*
|
||||
* 将 `OperationFeedback` 渲染为可访问的 UI 反馈,全部组件取自 Design_System(Req 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.1,Req 24.1–24.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('OperationFeedbackView(Req 24.1–24.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_State(loading 反馈,含非空进行中文本)(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_State(Req 24.1),随后呈现 success(Req 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_State(Req 24.1),随后呈现 error(Req 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.1–24.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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* 操作反馈纯逻辑单元测试(task 23.1,Req 24.1–24.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.1–24.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.1–24.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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* 操作反馈 — 纯状态模型、确定性映射与可测的状态转移逻辑(task 23.1,Req 24.1–24.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.1–24.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.1–24.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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 异步状态转移 helper(idle → 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Feedback 公共入口(barrel,task 23.1)。
|
||||
*
|
||||
* 暴露「操作反馈与状态提示」的纯映射器与状态转移 helper(供 Property 80 / task 23.2
|
||||
* 验证)以及展示组件:
|
||||
* - `feedbackFor`:纯且确定性的 OperationState → OperationFeedback 映射(Req 24.1–24.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';
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} 天 > 30 天警戒 → 建议:提高坏账准备金 / 要求预付款 / 缩短账期</div>}
|
||||
{concHigh && cc !== undefined && <div style={{ ...typographyStyle('caption'), color: '#BE123C' }}>• 集中度 {(cc.concentration * 100).toFixed(1)}% > 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* DefaultView — 角色化默认首屏视图(task 23.3,Req 25.1–25.5)。
|
||||
*
|
||||
* 给定登录用户角色,本组件渲染与该角色匹配的默认视图首屏,并将角色匹配的功能入口
|
||||
* 呈现于无需额外导航即可见的位置(Req 25.4)。目标视图复用任务 15 的角色化视图
|
||||
* (renderView/renderPortfolio)作为导航目标;此处呈现进入这些视图的功能入口
|
||||
* (而非领域侧的完整视图实现)。
|
||||
*
|
||||
* 路由目标由纯函数 `resolveDefaultView` 决定(见 routing.ts,Property 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.5,Req 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.1–25.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.1–25.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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Routing 公共入口(barrel,task 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';
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* routing.ts — 角色化默认路由的纯逻辑(task 23.3,Req 25.1–25.3 / 25.5)。
|
||||
*
|
||||
* 本模块仅承载「角色 → 默认视图」的确定性映射与 RBAC 路由守卫的纯函数实现,
|
||||
* 不含任何 React / DOM 依赖,便于以属性测试验证其确定性(Property 81,task 23.4)。
|
||||
*
|
||||
* 角色取值在前端层本地镜像(web 不直接依赖领域层 `../src` 的 RBAC 类型):
|
||||
* '商务/销售' | '风控' | '管理层' | '无角色'
|
||||
*
|
||||
* 映射关系(Req 25.1–25.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.1–25.3 / 25.5)。
|
||||
*
|
||||
* 确定性:相同输入恒得相同输出,且与调用次数/顺序无关(Property 81,task 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',
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
}));
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* 响应式布局服务公共入口(barrel,task 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';
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* 响应式布局服务 — 纯断点映射与视口变更(task 21.1,Req 22.1–22.4)。
|
||||
*
|
||||
* 以确定性纯函数承载「视口宽度 → 布局」的映射及跨断点切换逻辑,供表现层渲染
|
||||
* 与属性测试(task 21.2 / Property 76、task 21.3 / Property 67)共同复用:
|
||||
* - `selectLayout(width)`:将 Viewport 宽度映射到唯一确定的 `Layout`(Req 22.1–22.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.1–22.3)。 */
|
||||
export type Layout = 'DesktopLayout' | 'CompactLayout' | 'MobileLayout';
|
||||
|
||||
/**
|
||||
* 断点阈值常量(CSS 像素,Req 22.1–22.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.1–22.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 };
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Vendored
+19
@@ -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 {}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* GapPanel — 信息缺口提示面板(task 20.3,Req 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* QuickActions — Wizard 一键操作入口(task 20.2,Req 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* UnsavedLeaveDialog — 未保存修改离开确认对话框(task 20.4,Req 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Wizard — 向导式分步评估流程组件(task 20.1,Req 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
Reference in New Issue
Block a user