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

- 审批规则引擎 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
+11 -2
View File
@@ -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>
+42
View File
@@ -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;
+13 -6
View File
@@ -141,9 +141,11 @@ export function AppShell(): JSX.Element {
role="group"
aria-label="导航与用户信息"
>
<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('/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('/users')} onClick={() => navigate('/users')}>
</span>
<span data-nav-link style={navLinkStyle('/workflow')} onClick={() => navigate('/workflow')}>
</span>
</>
)}
<GlossaryButton />
+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>;
}