审批流程管理(系统管理员)+ 系统管理员去首页
- 审批规则引擎 evaluateApproval(纯函数+8单测):有序条件规则决定风控通过后是否需管理层终审 - approval_config 表 + 持久化 + 默认规则种子(红线/不可接受/高风险/低毛利/大合同→需管理层;低风险达标→风控终批) - 风控审核接入规则:低风险达标可风控终批,否则转管理层;审计记录命中规则 - GET/PUT /api/approval-config(PUT 限系统管理员);overdue SLA 改为读配置 - 审批流程配置页 WorkflowManagement(全局SLA/默认/驳回去向 + 规则与条件编辑器) - 系统管理员去掉首页,登录落地用户管理;导航=用户管理+审批流程
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* 审批流程配置(单行 JSONB):系统管理员可配置的审批规则与 SLA。
|
||||
*/
|
||||
|
||||
exports.up = (pgm) => {
|
||||
pgm.sql(`
|
||||
CREATE TABLE IF NOT EXISTS approval_config (
|
||||
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||
config JSONB NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT approval_config_singleton CHECK (id = 1)
|
||||
);
|
||||
`);
|
||||
};
|
||||
|
||||
exports.down = (pgm) => {
|
||||
pgm.sql(`DROP TABLE IF EXISTS approval_config;`);
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 审批流程配置持久化(单行 JSONB)。
|
||||
*/
|
||||
import type pg from 'pg';
|
||||
import { DEFAULT_APPROVAL_CONFIG, type ApprovalConfig } from '../strategy/approvalRules.js';
|
||||
|
||||
/** 读取审批配置;不存在则返回默认配置。 */
|
||||
export async function getApprovalConfig(pool: pg.Pool): Promise<ApprovalConfig> {
|
||||
const res = await pool.query('SELECT config FROM approval_config WHERE id = 1');
|
||||
const row = (res.rows as Array<{ config?: unknown }>)[0];
|
||||
if (!row || row.config == null) return DEFAULT_APPROVAL_CONFIG;
|
||||
return row.config as ApprovalConfig;
|
||||
}
|
||||
|
||||
/** 保存审批配置(upsert 单行)。 */
|
||||
export async function saveApprovalConfig(pool: pg.Pool, config: ApprovalConfig): Promise<void> {
|
||||
await pool.query(
|
||||
`INSERT INTO approval_config(id, config, updated_at) VALUES(1, $1, now())
|
||||
ON CONFLICT(id) DO UPDATE SET config = EXCLUDED.config, updated_at = now()`,
|
||||
[JSON.stringify(config)],
|
||||
);
|
||||
}
|
||||
|
||||
/** 首次启动兜底:无配置时写入默认配置。 */
|
||||
export async function ensureApprovalConfig(pool: pg.Pool): Promise<void> {
|
||||
const res = await pool.query('SELECT 1 FROM approval_config WHERE id = 1');
|
||||
if (res.rowCount === 0) {
|
||||
await saveApprovalConfig(pool, DEFAULT_APPROVAL_CONFIG);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export * from './customers.js';
|
||||
export * from './minWages.js';
|
||||
export * from './drafts.js';
|
||||
export * from './users.js';
|
||||
export * from './approvalConfig.js';
|
||||
export * from './settings.js';
|
||||
export * from './regionRates.js';
|
||||
export * from './rejectReasons.js';
|
||||
|
||||
+90
-5
@@ -72,6 +72,9 @@ import {
|
||||
ensureSeedUsers,
|
||||
USER_ROLES,
|
||||
type UserRole,
|
||||
getApprovalConfig,
|
||||
saveApprovalConfig,
|
||||
ensureApprovalConfig,
|
||||
getSetting,
|
||||
setSetting,
|
||||
loadAllRegionRates,
|
||||
@@ -101,7 +104,7 @@ import { buildMetricRedline, checkRedlines, type ComputableRedlineConfig, type R
|
||||
import { generate, exportReport } from '../report/index.js';
|
||||
import { analyzeProfitability, type ProfitabilityInputs } from '../cost/index.js';
|
||||
import { NATIONAL_DEFAULT_RATES, REGION_RATES } from '../cost/index.js';
|
||||
import { combineRecommendation, DEFAULT_TARGET_NET_MARGIN, buildOperatingControls, decide, hasRedlineHit, targetMarginForGrade } from '../strategy/index.js';
|
||||
import { combineRecommendation, DEFAULT_TARGET_NET_MARGIN, buildOperatingControls, decide, hasRedlineHit, targetMarginForGrade, evaluateApproval, DEFAULT_APPROVAL_CONFIG, type ApprovalConfig, type ApprovalRule, type ApprovalCondition } from '../strategy/index.js';
|
||||
import type { RiskLevel } from '../domain/common.js';
|
||||
import type { KnownData } from '../question/index.js';
|
||||
import type { Region } from '../domain/region.js';
|
||||
@@ -915,9 +918,14 @@ app.get('/api/assessments/overdue', async (c) => {
|
||||
ORDER BY ws.entered_at ASC`,
|
||||
);
|
||||
const now = Date.now();
|
||||
const cfg = await getApprovalConfig(pool).catch(() => null);
|
||||
const slaMap: Record<string, number> = {
|
||||
pending_risk_review: cfg?.slaRiskHours ?? SLA_HOURS.pending_risk_review ?? 24,
|
||||
risk_reviewed: cfg?.slaMgmtHours ?? SLA_HOURS.risk_reviewed ?? 48,
|
||||
};
|
||||
const overdue = (res.rows as Array<Record<string, unknown>>).filter((r) => {
|
||||
const enteredAt = new Date(String(r.entered_at)).getTime();
|
||||
const slaMs = (SLA_HOURS[String(r.status)] ?? 24) * 3600_000;
|
||||
const slaMs = (slaMap[String(r.status)] ?? 24) * 3600_000;
|
||||
return now - enteredAt > slaMs;
|
||||
}).map((r) => ({
|
||||
id: String(r.assessment_id),
|
||||
@@ -1286,6 +1294,43 @@ app.delete('/api/users/:id', requireRole('系统管理员'), async (c) => {
|
||||
return c.json({ deleted: true });
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 审批流程配置(系统管理员):规则 + SLA。
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/** 读取审批流程配置(只读开放,供看板/详情展示)。 */
|
||||
app.get('/api/approval-config', async (c) => {
|
||||
if (pool === null) return c.json(DEFAULT_APPROVAL_CONFIG);
|
||||
return c.json(await getApprovalConfig(pool));
|
||||
});
|
||||
|
||||
/** 保存审批流程配置(系统管理员)。 */
|
||||
app.put('/api/approval-config', requireRole('系统管理员'), async (c) => {
|
||||
if (pool === null) return c.json({ error: '未配置数据库' }, 400);
|
||||
const body = await c.req.json<ApprovalConfig>();
|
||||
if (!Array.isArray(body.rules)) return c.json({ error: '配置格式错误:rules 必须为数组' }, 400);
|
||||
// 基本校验与归一化。
|
||||
const cfg: ApprovalConfig = {
|
||||
defaultRequireManagement: body.defaultRequireManagement === true,
|
||||
slaRiskHours: Number(body.slaRiskHours) > 0 ? Number(body.slaRiskHours) : 24,
|
||||
slaMgmtHours: Number(body.slaMgmtHours) > 0 ? Number(body.slaMgmtHours) : 48,
|
||||
rejectTo: body.rejectTo === 'origin' ? 'origin' : 'risk',
|
||||
rules: body.rules.map((r: ApprovalRule, i: number) => ({
|
||||
id: typeof r.id === 'string' && r.id !== '' ? r.id : `rule-${Date.now().toString(36)}-${i}`,
|
||||
name: String(r.name ?? `规则${i + 1}`),
|
||||
enabled: r.enabled !== false,
|
||||
requireManagement: r.requireManagement === true,
|
||||
conditions: Array.isArray(r.conditions) ? r.conditions.map((cd: ApprovalCondition) => ({
|
||||
field: cd.field,
|
||||
op: cd.op,
|
||||
value: cd.value,
|
||||
})) : [],
|
||||
})),
|
||||
};
|
||||
await saveApprovalConfig(pool, cfg);
|
||||
return c.json(cfg);
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/region-rates/engine-defaults
|
||||
* 引擎内置默认费率(全国 + 上海/北京/广东),作为维护对照基准。
|
||||
@@ -1803,13 +1848,52 @@ app.post('/api/assessments/:id/review', requireRole('风控'), async (c) => {
|
||||
if (current !== 'pending_risk_review') {
|
||||
return c.json({ error: `当前状态为 ${current},无法审核` }, 400);
|
||||
}
|
||||
const newStatus: WorkflowStatus =
|
||||
body.action === 'approve' ? 'risk_reviewed' : 'rejected';
|
||||
let newStatus: WorkflowStatus;
|
||||
let actionText: string;
|
||||
if (body.action === 'approve') {
|
||||
// 按审批规则决定:风控通过后是否还需管理层终审。
|
||||
let requireMgmt = true;
|
||||
let ruleNote = '';
|
||||
if (pool !== null) {
|
||||
try {
|
||||
const cfg = await getApprovalConfig(pool);
|
||||
const prof = profitabilityById.get(id);
|
||||
const inputs = profitabilityInputsById.get(id);
|
||||
const a = record.assessment;
|
||||
const contractAmount = inputs?.contractTotal
|
||||
?? (prof !== undefined ? Math.round(prof.contract.revenueGross) : undefined);
|
||||
const decision = evaluateApproval(cfg, {
|
||||
...(a.riskGrade !== undefined ? { riskGrade: a.riskGrade } : {}),
|
||||
...(prof !== undefined ? { netMarginPct: Math.round(prof.monthly.netMargin * 1000) / 10 } : {}),
|
||||
...(a.acceptability !== undefined ? { acceptability: a.acceptability } : {}),
|
||||
redlineHit: a.redlineResults.some((r) => r.status === '命中'),
|
||||
...(contractAmount !== undefined ? { contractAmount } : {}),
|
||||
...(a.businessType !== undefined ? { businessType: a.businessType } : {}),
|
||||
});
|
||||
requireMgmt = decision.requireManagement;
|
||||
ruleNote = decision.matchedRuleName !== null
|
||||
? `(规则:${decision.matchedRuleName})`
|
||||
: '(默认规则)';
|
||||
} catch {
|
||||
requireMgmt = true; // 规则评估失败则保守要求管理层审批
|
||||
}
|
||||
}
|
||||
if (requireMgmt) {
|
||||
newStatus = 'risk_reviewed';
|
||||
actionText = `风控审核通过(转管理层审批)${ruleNote}`;
|
||||
} else {
|
||||
newStatus = 'approved';
|
||||
actionText = `风控审核通过并终批(免管理层)${ruleNote}`;
|
||||
}
|
||||
} else {
|
||||
newStatus = 'rejected';
|
||||
actionText = '风控驳回(退回销售)';
|
||||
}
|
||||
setStatus(id, newStatus);
|
||||
const reviewEntry: AuditLogEntry = {
|
||||
role: '风控',
|
||||
username: body.user,
|
||||
action: body.action === 'approve' ? '风控审核通过' : '风控驳回(退回销售)',
|
||||
action: actionText,
|
||||
...(body.comment !== undefined && body.comment !== '' ? { comment: body.comment } : {}),
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
@@ -2221,6 +2305,7 @@ async function start(): Promise<void> {
|
||||
for (const [id, v] of arc) archivedById.set(id, v);
|
||||
calibratedTargetBase = (await getSetting(pool, 'targetMarginBase')) ?? null;
|
||||
await ensureSeedUsers(pool);
|
||||
await ensureApprovalConfig(pool);
|
||||
console.log(`PostgreSQL 持久化已启用,已加载 ${store.getAll().length} 条评估记录`);
|
||||
} catch (err) {
|
||||
console.error('PostgreSQL 连接失败,回退到进程内存储:', err instanceof Error ? err.message : err);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { evaluateApproval, DEFAULT_APPROVAL_CONFIG, type ApprovalConfig } from '../approvalRules.js';
|
||||
|
||||
describe('审批规则引擎 evaluateApproval', () => {
|
||||
it('命中红线 → 需管理层', () => {
|
||||
const d = evaluateApproval(DEFAULT_APPROVAL_CONFIG, { redlineHit: true, riskGrade: '低', netMarginPct: 20 });
|
||||
expect(d.requireManagement).toBe(true);
|
||||
expect(d.matchedRuleId).toBe('rule-redline');
|
||||
});
|
||||
|
||||
it('高风险 → 需管理层(riskGrade>=高 命中)', () => {
|
||||
const d = evaluateApproval(DEFAULT_APPROVAL_CONFIG, { redlineHit: false, riskGrade: '高', netMarginPct: 20 });
|
||||
expect(d.requireManagement).toBe(true);
|
||||
expect(d.matchedRuleId).toBe('rule-highrisk');
|
||||
});
|
||||
|
||||
it('低风险且高毛利且无红线 → 风控终批(命中低风险规则)', () => {
|
||||
const d = evaluateApproval(DEFAULT_APPROVAL_CONFIG, { redlineHit: false, riskGrade: '低', netMarginPct: 20, acceptability: '可接受' });
|
||||
expect(d.requireManagement).toBe(false);
|
||||
expect(d.matchedRuleId).toBe('rule-lowrisk');
|
||||
});
|
||||
|
||||
it('低净利率 → 需管理层(净利率<5 命中,先于低风险规则)', () => {
|
||||
const d = evaluateApproval(DEFAULT_APPROVAL_CONFIG, { redlineHit: false, riskGrade: '中', netMarginPct: 3 });
|
||||
expect(d.requireManagement).toBe(true);
|
||||
expect(d.matchedRuleId).toBe('rule-lowmargin');
|
||||
});
|
||||
|
||||
it('大合同额 → 需管理层', () => {
|
||||
const d = evaluateApproval(DEFAULT_APPROVAL_CONFIG, { redlineHit: false, riskGrade: '中', netMarginPct: 10, contractAmount: 2_000_000 });
|
||||
expect(d.requireManagement).toBe(true);
|
||||
expect(d.matchedRuleId).toBe('rule-bigcontract');
|
||||
});
|
||||
|
||||
it('无规则命中 → 使用默认值', () => {
|
||||
const cfg: ApprovalConfig = { ...DEFAULT_APPROVAL_CONFIG, defaultRequireManagement: true, rules: [] };
|
||||
const d = evaluateApproval(cfg, { redlineHit: false, riskGrade: '中', netMarginPct: 10 });
|
||||
expect(d.requireManagement).toBe(true);
|
||||
expect(d.matchedRuleId).toBeNull();
|
||||
});
|
||||
|
||||
it('禁用规则不参与匹配', () => {
|
||||
const cfg: ApprovalConfig = {
|
||||
...DEFAULT_APPROVAL_CONFIG,
|
||||
defaultRequireManagement: false,
|
||||
rules: [{ id: 'r1', name: 'x', enabled: false, requireManagement: true, conditions: [{ field: 'redlineHit', op: '==', value: true }] }],
|
||||
};
|
||||
const d = evaluateApproval(cfg, { redlineHit: true });
|
||||
expect(d.requireManagement).toBe(false);
|
||||
expect(d.matchedRuleId).toBeNull();
|
||||
});
|
||||
|
||||
it('按顺序取第一条命中规则', () => {
|
||||
const cfg: ApprovalConfig = {
|
||||
...DEFAULT_APPROVAL_CONFIG,
|
||||
rules: [
|
||||
{ id: 'a', name: 'A', enabled: true, requireManagement: false, conditions: [{ field: 'riskGrade', op: '>=', value: '中' }] },
|
||||
{ id: 'b', name: 'B', enabled: true, requireManagement: true, conditions: [{ field: 'riskGrade', op: '>=', value: '高' }] },
|
||||
],
|
||||
};
|
||||
const d = evaluateApproval(cfg, { redlineHit: false, riskGrade: '高' });
|
||||
expect(d.matchedRuleId).toBe('a'); // A 先命中
|
||||
expect(d.requireManagement).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* 审批流程规则引擎(纯函数,确定性)。
|
||||
*
|
||||
* 设计:固定两级审批序列「销售提交 → 风控审核 →(按规则)管理层审批」。
|
||||
* 风控通过后,是否还需管理层终审由**有序规则**决定:按顺序取第一条命中规则的
|
||||
* `requireManagement`;无命中则用 `defaultRequireManagement`。这样既可让低风险达标项目
|
||||
* 风控通过即终批,也可对高风险/命中红线/低毛利/大合同强制管理层把关。规则可由系统管理员配置。
|
||||
*/
|
||||
|
||||
/** 条件字段。 */
|
||||
export type ApprovalField =
|
||||
| 'riskGrade' // 风险等级 低/中/高/极高(按序可做数值比较)
|
||||
| 'netMarginPct' // 月净利率(百分数,如 8 表示 8%)
|
||||
| 'acceptability' // 可接受性 可接受/有条件接受/不可接受
|
||||
| 'redlineHit' // 是否命中红线(布尔)
|
||||
| 'contractAmount' // 合同额(元)
|
||||
| 'businessType'; // 业务类型
|
||||
|
||||
/** 比较运算符。 */
|
||||
export type ApprovalOp = '>=' | '<=' | '>' | '<' | '==' | '!=';
|
||||
|
||||
/** 单个条件。 */
|
||||
export interface ApprovalCondition {
|
||||
readonly field: ApprovalField;
|
||||
readonly op: ApprovalOp;
|
||||
readonly value: string | number | boolean;
|
||||
}
|
||||
|
||||
/** 单条规则:全部条件 AND 命中时,按 requireManagement 决定是否需管理层审批。 */
|
||||
export interface ApprovalRule {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly enabled: boolean;
|
||||
readonly requireManagement: boolean;
|
||||
readonly conditions: readonly ApprovalCondition[];
|
||||
}
|
||||
|
||||
/** 审批流程配置。 */
|
||||
export interface ApprovalConfig {
|
||||
/** 无规则命中时是否需要管理层审批(兜底)。 */
|
||||
readonly defaultRequireManagement: boolean;
|
||||
/** 风控审核 SLA(小时)。 */
|
||||
readonly slaRiskHours: number;
|
||||
/** 管理层审批 SLA(小时)。 */
|
||||
readonly slaMgmtHours: number;
|
||||
/** 管理层驳回默认去向:退回销售(origin) / 退回风控复审(risk)。 */
|
||||
readonly rejectTo: 'origin' | 'risk';
|
||||
/** 有序规则集(按顺序匹配,第一条命中生效)。 */
|
||||
readonly rules: readonly ApprovalRule[];
|
||||
}
|
||||
|
||||
/** 评估上下文(供规则匹配)。 */
|
||||
export interface ApprovalContext {
|
||||
readonly riskGrade?: string;
|
||||
readonly netMarginPct?: number;
|
||||
readonly acceptability?: string;
|
||||
readonly redlineHit: boolean;
|
||||
readonly contractAmount?: number;
|
||||
readonly businessType?: string;
|
||||
}
|
||||
|
||||
/** 规则评估结果。 */
|
||||
export interface ApprovalDecision {
|
||||
readonly requireManagement: boolean;
|
||||
readonly matchedRuleId: string | null;
|
||||
readonly matchedRuleName: string | null;
|
||||
}
|
||||
|
||||
/** 风险等级 → 序号(用于 >=/<= 等数值比较)。 */
|
||||
const GRADE_RANK: Record<string, number> = { 低: 1, 中: 2, 高: 3, 极高: 4 };
|
||||
|
||||
/** 默认审批配置:低风险达标且无红线 → 风控终批;高风险/红线/低毛利/大合同 → 需管理层。 */
|
||||
export const DEFAULT_APPROVAL_CONFIG: ApprovalConfig = {
|
||||
defaultRequireManagement: false,
|
||||
slaRiskHours: 24,
|
||||
slaMgmtHours: 48,
|
||||
rejectTo: 'risk',
|
||||
rules: [
|
||||
{ id: 'rule-redline', name: '命中红线必过管理层', enabled: true, requireManagement: true, conditions: [{ field: 'redlineHit', op: '==', value: true }] },
|
||||
{ id: 'rule-unacceptable', name: '不可接受必过管理层', enabled: true, requireManagement: true, conditions: [{ field: 'acceptability', op: '==', value: '不可接受' }] },
|
||||
{ id: 'rule-highrisk', name: '风险等级≥高必过管理层', enabled: true, requireManagement: true, conditions: [{ field: 'riskGrade', op: '>=', value: '高' }] },
|
||||
{ id: 'rule-lowmargin', name: '净利率<5%必过管理层', enabled: true, requireManagement: true, conditions: [{ field: 'netMarginPct', op: '<', value: 5 }] },
|
||||
{ id: 'rule-bigcontract', name: '合同额≥100万必过管理层', enabled: true, requireManagement: true, conditions: [{ field: 'contractAmount', op: '>=', value: 1000000 }] },
|
||||
{ id: 'rule-lowrisk', name: '低风险且达标→风控终批', enabled: true, requireManagement: false, conditions: [{ field: 'riskGrade', op: '<=', value: '中' }] },
|
||||
],
|
||||
};
|
||||
|
||||
/** 取条件左值(从上下文)。riskGrade 转为序号以支持数值比较。 */
|
||||
function leftValue(field: ApprovalField, ctx: ApprovalContext): string | number | boolean | undefined {
|
||||
switch (field) {
|
||||
case 'riskGrade': return ctx.riskGrade !== undefined ? (GRADE_RANK[ctx.riskGrade] ?? undefined) : undefined;
|
||||
case 'netMarginPct': return ctx.netMarginPct;
|
||||
case 'acceptability': return ctx.acceptability;
|
||||
case 'redlineHit': return ctx.redlineHit;
|
||||
case 'contractAmount': return ctx.contractAmount;
|
||||
case 'businessType': return ctx.businessType;
|
||||
default: return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** 取条件右值。riskGrade 的字符串阈值转序号。 */
|
||||
function rightValue(field: ApprovalField, value: string | number | boolean): string | number | boolean {
|
||||
if (field === 'riskGrade' && typeof value === 'string') return GRADE_RANK[value] ?? Number(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
function compare(left: string | number | boolean | undefined, op: ApprovalOp, right: string | number | boolean): boolean {
|
||||
if (left === undefined || left === null) return false;
|
||||
if (op === '==') return String(left) === String(right);
|
||||
if (op === '!=') return String(left) !== String(right);
|
||||
// 数值比较
|
||||
const l = typeof left === 'boolean' ? (left ? 1 : 0) : Number(left);
|
||||
const r = typeof right === 'boolean' ? (right ? 1 : 0) : Number(right);
|
||||
if (Number.isNaN(l) || Number.isNaN(r)) return false;
|
||||
switch (op) {
|
||||
case '>=': return l >= r;
|
||||
case '<=': return l <= r;
|
||||
case '>': return l > r;
|
||||
case '<': return l < r;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 单条规则是否命中(全部条件 AND)。 */
|
||||
function ruleMatches(rule: ApprovalRule, ctx: ApprovalContext): boolean {
|
||||
if (!rule.enabled || rule.conditions.length === 0) return false;
|
||||
return rule.conditions.every((cond) =>
|
||||
compare(leftValue(cond.field, ctx), cond.op, rightValue(cond.field, cond.value)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 评估某项目风控通过后是否还需管理层审批。
|
||||
* 按规则顺序取第一条命中;无命中用默认值。
|
||||
*/
|
||||
export function evaluateApproval(config: ApprovalConfig, ctx: ApprovalContext): ApprovalDecision {
|
||||
for (const rule of config.rules) {
|
||||
if (ruleMatches(rule, ctx)) {
|
||||
return { requireManagement: rule.requireManagement, matchedRuleId: rule.id, matchedRuleName: rule.name };
|
||||
}
|
||||
}
|
||||
return { requireManagement: config.defaultRequireManagement, matchedRuleId: null, matchedRuleName: null };
|
||||
}
|
||||
@@ -10,3 +10,4 @@ export * from './measures.js';
|
||||
export * from './acceptanceConditions.js';
|
||||
export * from './recommendation.js';
|
||||
export * from './operatingControls.js';
|
||||
export * from './approvalRules.js';
|
||||
|
||||
+11
-2
@@ -12,6 +12,7 @@ import { RateManagement } from './pages/RateManagement.js';
|
||||
import { RedlineManagement } from './pages/RedlineManagement.js';
|
||||
import { CustomerManagement } from './pages/CustomerManagement.js';
|
||||
import { UserManagement } from './pages/UserManagement.js';
|
||||
import { WorkflowManagement } from './pages/WorkflowManagement.js';
|
||||
|
||||
/** 路由守卫:未登录重定向到登录页。 */
|
||||
function ProtectedRoute(): JSX.Element {
|
||||
@@ -26,6 +27,13 @@ function RoleRoute({ allow }: { readonly allow: readonly string[] }): JSX.Elemen
|
||||
return allow.includes(role) ? <Outlet /> : <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
/** 首页:系统管理员不关心业务,重定向到用户管理;其余角色看评估看板。 */
|
||||
function HomeRoute(): JSX.Element {
|
||||
const { user } = useAuthStore();
|
||||
if (user?.role === '系统管理员') return <Navigate to="/users" replace />;
|
||||
return <Dashboard />;
|
||||
}
|
||||
|
||||
export function App(): JSX.Element {
|
||||
return (
|
||||
<BrowserRouter
|
||||
@@ -38,7 +46,7 @@ export function App(): JSX.Element {
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<AppShell />}>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/" element={<HomeRoute />} />
|
||||
<Route path="/new" element={<NewAssessment />} />
|
||||
<Route path="/assessments/:id" element={<AssessmentDetail />} />
|
||||
{/* 费率/红线管理:仅管理层 */}
|
||||
@@ -50,9 +58,10 @@ export function App(): JSX.Element {
|
||||
<Route element={<RoleRoute allow={['商务/销售', '管理层']} />}>
|
||||
<Route path="/customers" element={<CustomerManagement />} />
|
||||
</Route>
|
||||
{/* 用户管理:系统管理员 */}
|
||||
{/* 用户管理 + 审批流程:系统管理员 */}
|
||||
<Route element={<RoleRoute allow={['系统管理员']} />}>
|
||||
<Route path="/users" element={<UserManagement />} />
|
||||
<Route path="/workflow" element={<WorkflowManagement />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
@@ -844,6 +844,48 @@ export async function deleteUserApi(id: string): Promise<void> {
|
||||
await fetch(`${API_BASE}/api/users/${encodeURIComponent(id)}`, { method: 'DELETE', headers: authHeader() });
|
||||
}
|
||||
|
||||
/* ----------------------------- 审批流程配置 ----------------------------- */
|
||||
|
||||
export type ApprovalField = 'riskGrade' | 'netMarginPct' | 'acceptability' | 'redlineHit' | 'contractAmount' | 'businessType';
|
||||
export type ApprovalOp = '>=' | '<=' | '>' | '<' | '==' | '!=';
|
||||
|
||||
export interface ApprovalCondition {
|
||||
field: ApprovalField;
|
||||
op: ApprovalOp;
|
||||
value: string | number | boolean;
|
||||
}
|
||||
export interface ApprovalRule {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
requireManagement: boolean;
|
||||
conditions: ApprovalCondition[];
|
||||
}
|
||||
export interface ApprovalConfig {
|
||||
defaultRequireManagement: boolean;
|
||||
slaRiskHours: number;
|
||||
slaMgmtHours: number;
|
||||
rejectTo: 'origin' | 'risk';
|
||||
rules: ApprovalRule[];
|
||||
}
|
||||
|
||||
/** 读取审批流程配置。 */
|
||||
export async function fetchApprovalConfig(): Promise<ApprovalConfig> {
|
||||
return request<ApprovalConfig>('GET', '/api/approval-config');
|
||||
}
|
||||
|
||||
/** 保存审批流程配置(系统管理员)。 */
|
||||
export async function saveApprovalConfig(config: ApprovalConfig): Promise<ApprovalConfig> {
|
||||
const res = await fetch(`${API_BASE}/api/approval-config`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeader() },
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
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 ApprovalConfig;
|
||||
}
|
||||
|
||||
/** 方案对比。 */
|
||||
export interface ScenarioItem {
|
||||
id: string;
|
||||
|
||||
@@ -141,9 +141,11 @@ export function AppShell(): JSX.Element {
|
||||
role="group"
|
||||
aria-label="导航与用户信息"
|
||||
>
|
||||
{role !== '系统管理员' && (
|
||||
<span data-nav-link style={navLinkStyle('/')} onClick={() => navigate('/')}>
|
||||
首页
|
||||
</span>
|
||||
)}
|
||||
|
||||
{role === '商务/销售' && (
|
||||
<span data-nav-link style={navLinkStyle('/new')} onClick={() => navigate('/new')}>
|
||||
@@ -181,9 +183,14 @@ export function AppShell(): JSX.Element {
|
||||
)}
|
||||
|
||||
{role === '系统管理员' && (
|
||||
<>
|
||||
<span data-nav-link style={navLinkStyle('/users')} onClick={() => navigate('/users')}>
|
||||
用户管理
|
||||
</span>
|
||||
<span data-nav-link style={navLinkStyle('/workflow')} onClick={() => navigate('/workflow')}>
|
||||
审批流程
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
<GlossaryButton />
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* 审批流程管理 — 系统管理员配置审批规则与 SLA。
|
||||
*
|
||||
* 流程固定为「销售提交 → 风控审核 →(按规则)管理层审批」。
|
||||
* 规则按顺序匹配,第一条命中的规则决定该项目风控通过后是否仍需管理层终审;
|
||||
* 无命中则用「默认是否需管理层」。
|
||||
*/
|
||||
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 {
|
||||
fetchApprovalConfig, saveApprovalConfig,
|
||||
type ApprovalConfig, type ApprovalRule, type ApprovalCondition, type ApprovalField, type ApprovalOp,
|
||||
} from '../api/client.js';
|
||||
|
||||
const FIELD_META: Record<ApprovalField, { label: string; kind: 'grade' | 'number' | 'accept' | 'bool' | 'biz'; unit?: string }> = {
|
||||
riskGrade: { label: '风险等级', kind: 'grade' },
|
||||
netMarginPct: { label: '月净利率', kind: 'number', unit: '%' },
|
||||
acceptability: { label: '可接受性', kind: 'accept' },
|
||||
redlineHit: { label: '命中红线', kind: 'bool' },
|
||||
contractAmount: { label: '合同额', kind: 'number', unit: '元' },
|
||||
businessType: { label: '业务类型', kind: 'biz' },
|
||||
};
|
||||
const FIELDS = Object.keys(FIELD_META) as ApprovalField[];
|
||||
const OPS_NUM: ApprovalOp[] = ['>=', '<=', '>', '<', '==', '!='];
|
||||
const OPS_EQ: ApprovalOp[] = ['==', '!='];
|
||||
const GRADES = ['低', '中', '高', '极高'];
|
||||
const ACCEPTS = ['可接受', '有条件接受', '不可接受'];
|
||||
const BIZ = ['岗位外包', '劳务派遣', '业务/服务外包', 'BPO', '项目制外包'];
|
||||
|
||||
function defaultValueFor(field: ApprovalField): string | number | boolean {
|
||||
const k = FIELD_META[field].kind;
|
||||
if (k === 'grade') return '高';
|
||||
if (k === 'accept') return '不可接受';
|
||||
if (k === 'bool') return true;
|
||||
if (k === 'biz') return '劳务派遣';
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function WorkflowManagement(): JSX.Element {
|
||||
const [config, setConfig] = useState<ApprovalConfig | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
fetchApprovalConfig().then(setConfig).catch((e: unknown) => setError(e instanceof Error ? e.message : '加载失败')).finally(() => setLoading(false));
|
||||
}, []);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
function patch(p: Partial<ApprovalConfig>): void { setConfig((c) => (c ? { ...c, ...p } : c)); }
|
||||
function patchRule(idx: number, p: Partial<ApprovalRule>): void {
|
||||
setConfig((c) => (c ? { ...c, rules: c.rules.map((r, i) => (i === idx ? { ...r, ...p } : r)) } : c));
|
||||
}
|
||||
function patchCond(ri: number, ci: number, p: Partial<ApprovalCondition>): void {
|
||||
setConfig((c) => {
|
||||
if (!c) return c;
|
||||
const rules = c.rules.map((r, i) => i !== ri ? r : { ...r, conditions: r.conditions.map((cd, j) => j === ci ? { ...cd, ...p } : cd) });
|
||||
return { ...c, rules };
|
||||
});
|
||||
}
|
||||
function addRule(): void {
|
||||
setConfig((c) => c ? { ...c, rules: [...c.rules, { id: `rule-${Date.now().toString(36)}`, name: '新规则', enabled: true, requireManagement: true, conditions: [{ field: 'riskGrade', op: '>=', value: '高' }] }] } : c);
|
||||
}
|
||||
function removeRule(idx: number): void { setConfig((c) => c ? { ...c, rules: c.rules.filter((_, i) => i !== idx) } : c); }
|
||||
function moveRule(idx: number, dir: -1 | 1): void {
|
||||
setConfig((c) => {
|
||||
if (!c) return c;
|
||||
const j = idx + dir;
|
||||
if (j < 0 || j >= c.rules.length) return c;
|
||||
const rules = [...c.rules];
|
||||
const tmp = rules[idx]!; rules[idx] = rules[j]!; rules[j] = tmp;
|
||||
return { ...c, rules };
|
||||
});
|
||||
}
|
||||
function addCond(ri: number): void {
|
||||
setConfig((c) => c ? { ...c, rules: c.rules.map((r, i) => i !== ri ? r : { ...r, conditions: [...r.conditions, { field: 'netMarginPct', op: '<', value: 5 }] }) } : c);
|
||||
}
|
||||
function removeCond(ri: number, ci: number): void {
|
||||
setConfig((c) => c ? { ...c, rules: c.rules.map((r, i) => i !== ri ? r : { ...r, conditions: r.conditions.filter((_, j) => j !== ci) }) } : c);
|
||||
}
|
||||
|
||||
async function handleSave(): Promise<void> {
|
||||
if (config === null) return;
|
||||
setSaving(true); setError(null);
|
||||
try { const saved = await saveApprovalConfig(config); setConfig(saved); setNotice('审批规则已保存并生效'); }
|
||||
catch (e) { setError(e instanceof Error ? e.message : '保存失败'); }
|
||||
finally { setSaving(false); }
|
||||
}
|
||||
|
||||
const input: 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'),
|
||||
};
|
||||
|
||||
function valueEditor(cd: ApprovalCondition, onChange: (v: string | number | boolean) => void): JSX.Element {
|
||||
const kind = FIELD_META[cd.field].kind;
|
||||
if (kind === 'grade') return <select style={input} value={String(cd.value)} onChange={(e) => onChange(e.target.value)}>{GRADES.map((g) => <option key={g} value={g}>{g}</option>)}</select>;
|
||||
if (kind === 'accept') return <select style={input} value={String(cd.value)} onChange={(e) => onChange(e.target.value)}>{ACCEPTS.map((a) => <option key={a} value={a}>{a}</option>)}</select>;
|
||||
if (kind === 'biz') return <select style={input} value={String(cd.value)} onChange={(e) => onChange(e.target.value)}>{BIZ.map((b) => <option key={b} value={b}>{b}</option>)}</select>;
|
||||
if (kind === 'bool') return <select style={input} value={String(cd.value)} onChange={(e) => onChange(e.target.value === 'true')}><option value="true">是</option><option value="false">否</option></select>;
|
||||
return <input style={{ ...input, width: 110 }} type="number" value={Number(cd.value)} onChange={(e) => onChange(Number(e.target.value))} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: FONT_FAMILY, maxWidth: 1100, margin: '0 auto' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', 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`, maxWidth: 720, lineHeight: 1.7 }}>
|
||||
流程:<strong>销售提交 → 风控审核 →(按规则)管理层审批</strong>。规则按顺序匹配,第一条命中者决定风控通过后是否需管理层终审;都不命中用「默认」。
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => { void handleSave(); }} disabled={saving || config === null}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: `${space(2)}px ${space(4)}px`, borderRadius: `${RADIUS.md}px`, border: 'none', backgroundColor: colorVar('color.brand.primary'), color: '#fff', cursor: saving ? 'not-allowed' : 'pointer', opacity: saving ? 0.6 : 1, fontWeight: 600 }}>
|
||||
<Icon name="save" size={16} color="#fff" /> {saving ? '保存中…' : '保存规则'}
|
||||
</button>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{loading || config === null ? <p style={{ color: colorVar('color.text.secondary') }}>加载中…</p> : (
|
||||
<>
|
||||
{/* 全局设置 */}
|
||||
<Card title="全局设置">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: `${space(3)}px` }}>
|
||||
<Field label="风控审核 SLA(小时)"><input style={{ ...input, width: '100%' }} type="number" value={config.slaRiskHours} onChange={(e) => patch({ slaRiskHours: Number(e.target.value) })} /></Field>
|
||||
<Field label="管理层审批 SLA(小时)"><input style={{ ...input, width: '100%' }} type="number" value={config.slaMgmtHours} onChange={(e) => patch({ slaMgmtHours: Number(e.target.value) })} /></Field>
|
||||
<Field label="默认是否需管理层(无规则命中时)">
|
||||
<select style={{ ...input, width: '100%' }} value={String(config.defaultRequireManagement)} onChange={(e) => patch({ defaultRequireManagement: e.target.value === 'true' })}>
|
||||
<option value="true">需管理层终审</option>
|
||||
<option value="false">风控通过即终批</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="管理层驳回默认去向">
|
||||
<select style={{ ...input, width: '100%' }} value={config.rejectTo} onChange={(e) => patch({ rejectTo: e.target.value as 'origin' | 'risk' })}>
|
||||
<option value="risk">退回风控复审</option>
|
||||
<option value="origin">退回销售重报</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 规则列表 */}
|
||||
<div style={{ marginTop: `${space(4)}px`, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<h2 style={{ ...typographyStyle('title'), margin: 0, color: colorVar('color.text.primary') }}>审批规则(按顺序匹配,{config.rules.length})</h2>
|
||||
<button onClick={addRule} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: `${space(1)}px ${space(3)}px`, borderRadius: `${RADIUS.md}px`, border: `1px solid ${colorVar('color.brand.primary')}`, background: 'transparent', color: colorVar('color.brand.primary'), cursor: 'pointer', fontWeight: 600, ...typographyStyle('caption') }}>
|
||||
<Icon name="plus" size={14} /> 添加规则
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: `${space(3)}px`, marginTop: `${space(3)}px` }}>
|
||||
{config.rules.map((r, ri) => (
|
||||
<div key={r.id} style={{ padding: `${space(3)}px`, backgroundColor: colorVar('color.bg.elevated'), border: `1px solid ${colorVar('color.border.default')}`, borderRadius: `${RADIUS.lg}px`, boxShadow: SHADOW.sm, opacity: r.enabled ? 1 : 0.6 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: `${space(2)}px`, flexWrap: 'wrap', marginBottom: `${space(2)}px` }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', minWidth: 22, height: 22, borderRadius: 6, backgroundColor: colorVar('color.bg.surface'), ...typographyStyle('caption'), fontWeight: 700, color: colorVar('color.text.secondary') }}>{ri + 1}</span>
|
||||
<input style={{ ...input, flex: '1 1 200px', ...typographyStyle('body'), fontWeight: 600 }} value={r.name} onChange={(e) => patchRule(ri, { name: e.target.value })} />
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 4, ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>
|
||||
<input type="checkbox" checked={r.enabled} onChange={(e) => patchRule(ri, { enabled: e.target.checked })} /> 启用
|
||||
</label>
|
||||
<select style={input} value={String(r.requireManagement)} onChange={(e) => patchRule(ri, { requireManagement: e.target.value === 'true' })}>
|
||||
<option value="true">命中 → 需管理层终审</option>
|
||||
<option value="false">命中 → 风控通过即终批</option>
|
||||
</select>
|
||||
<button onClick={() => moveRule(ri, -1)} title="上移" style={iconBtn}><Icon name="chevron-down" size={14} /></button>
|
||||
<button onClick={() => moveRule(ri, 1)} title="下移" style={{ ...iconBtn, transform: 'rotate(180deg)' }}><Icon name="chevron-down" size={14} /></button>
|
||||
<button onClick={() => removeRule(ri)} title="删除规则" style={{ ...iconBtn, color: colorVar('color.risk.critical') }}><Icon name="close" size={15} /></button>
|
||||
</div>
|
||||
<div style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), marginBottom: 6 }}>当以下条件<strong>全部满足</strong>时命中:</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: `${space(1)}px` }}>
|
||||
{r.conditions.map((cd, ci) => {
|
||||
const meta = FIELD_META[cd.field];
|
||||
const ops = meta.kind === 'number' || meta.kind === 'grade' ? OPS_NUM : OPS_EQ;
|
||||
return (
|
||||
<div key={ci} style={{ display: 'flex', alignItems: 'center', gap: `${space(2)}px`, flexWrap: 'wrap' }}>
|
||||
<select style={input} value={cd.field} onChange={(e) => { const f = e.target.value as ApprovalField; patchCond(ri, ci, { field: f, value: defaultValueFor(f) }); }}>
|
||||
{FIELDS.map((f) => <option key={f} value={f}>{FIELD_META[f].label}</option>)}
|
||||
</select>
|
||||
<select style={input} value={cd.op} onChange={(e) => patchCond(ri, ci, { op: e.target.value as ApprovalOp })}>
|
||||
{ops.map((o) => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
{valueEditor(cd, (v) => patchCond(ri, ci, { value: v }))}
|
||||
{meta.unit !== undefined && <span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>{meta.unit}</span>}
|
||||
<button onClick={() => removeCond(ri, ci)} title="删除条件" style={{ ...iconBtn, color: colorVar('color.risk.critical') }}><Icon name="close" size={13} /></button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button onClick={() => addCond(ri)} style={{ alignSelf: 'flex-start', display: 'inline-flex', alignItems: 'center', gap: 4, border: 'none', background: 'none', color: colorVar('color.brand.primary'), cursor: 'pointer', ...typographyStyle('caption'), fontWeight: 600 }}>
|
||||
<Icon name="plus" size={13} /> 添加条件
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{config.rules.length === 0 && <p style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>暂无规则:所有项目按「默认是否需管理层」处理。</p>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const iconBtn: React.CSSProperties = {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28,
|
||||
border: `1px solid var(--color-border-default)`, borderRadius: 6, background: 'transparent', cursor: 'pointer', color: 'var(--color-text-secondary)',
|
||||
};
|
||||
function Field({ 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>;
|
||||
}
|
||||
Reference in New Issue
Block a user