审批流程管理(系统管理员)+ 系统管理员去首页

- 审批规则引擎 evaluateApproval(纯函数+8单测):有序条件规则决定风控通过后是否需管理层终审
- approval_config 表 + 持久化 + 默认规则种子(红线/不可接受/高风险/低毛利/大合同→需管理层;低风险达标→风控终批)
- 风控审核接入规则:低风险达标可风控终批,否则转管理层;审计记录命中规则
- GET/PUT /api/approval-config(PUT 限系统管理员);overdue SLA 改为读配置
- 审批流程配置页 WorkflowManagement(全局SLA/默认/驳回去向 + 规则与条件编辑器)
- 系统管理员去掉首页,登录落地用户管理;导航=用户管理+审批流程
This commit is contained in:
freedakgmail
2026-06-13 17:55:28 +08:00
parent 6562208b13
commit 757b9c4a69
11 changed files with 632 additions and 13 deletions
+217
View File
@@ -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>;
}