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

- 确定性领域引擎(分类/评分/分级/红线/费用/裁决)+LLM(通义千问)语言理解
- 6步评估向导、服务端草稿持久化(跨设备/编辑草稿保护)
- 工作流(草稿→风控→管理层)、RBAC、报告导出、校准、客户/费率/红线/最低工资管理
- 专业图标体系替换全部emoji、看板美化
- 生产化:API_BASE可配置(同源反代)、auth密钥惰性读取修复RBAC
- 444单测+204前端测试+51 e2e
This commit is contained in:
freedakgmail
2026-06-13 01:06:39 +08:00
commit c670b9e454
404 changed files with 61820 additions and 0 deletions
+732
View File
@@ -0,0 +1,732 @@
/**
* Dashboard — 评估历史列表与按角色的待办工作台。
*
* 列表与待办均走**服务端 SQL 分页**(page/pageSize/status/q),统计卡走 summary 聚合接口,
* 不再把全量数据载入前端,支持大数据量。
*/
import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button, Card, Table } from '../design-system/index.js';
import type { TableColumn } from '../design-system/index.js';
import { Icon } from '../design-system/index.js';
import {
colorVar,
FONT_FAMILY,
RADIUS,
space,
typographyStyle,
} from '../design-system/components/styles.js';
import { fetchAssessmentsPage, fetchSummary, archiveAssessment, applyCalibration, listDrafts, deleteDraftApi, API_BASE } from '../api/client.js';
import type { AssessmentListItem, WorkflowStatus, DraftItem } from '../api/client.js';
import { useAuthStore } from '../stores/authStore.js';
import { GuideBanner } from '../app/Guidance.js';
import { RiskBadge } from '../charts/index.js';
const STATUS_LABEL: Record<WorkflowStatus, string> = {
draft: '草稿待申报',
pending_risk_review: '待风控审核',
risk_reviewed: '风控已审核',
pending_management_approval: '待管理层审批',
approved: '已通过',
rejected: '已驳回',
abandoned: '已放弃',
};
const STATUS_STYLE: Record<WorkflowStatus, { bg: string; fg: string }> = {
draft: { bg: 'rgba(100, 116, 139, 0.12)', fg: '#64748B' },
pending_risk_review: { bg: 'rgba(180, 83, 9, 0.12)', fg: '#B45309' },
risk_reviewed: { bg: 'rgba(37, 99, 235, 0.12)', fg: '#2563EB' },
pending_management_approval: { bg: 'rgba(124, 58, 237, 0.12)', fg: '#7C3AED' },
approved: { bg: 'rgba(16, 128, 61, 0.12)', fg: '#15803D' },
rejected: { bg: 'rgba(190, 18, 60, 0.12)', fg: '#BE123C' },
abandoned: { bg: 'rgba(100, 116, 139, 0.14)', fg: '#475569' },
};
type StatusFilter = 'all' | WorkflowStatus;
const STATUS_FILTER_OPTIONS: ReadonlyArray<{ value: StatusFilter; label: string }> = [
{ value: 'all', label: '全部状态' },
{ value: 'draft', label: STATUS_LABEL.draft },
{ value: 'pending_risk_review', label: STATUS_LABEL.pending_risk_review },
{ value: 'risk_reviewed', label: STATUS_LABEL.risk_reviewed },
{ value: 'pending_management_approval', label: STATUS_LABEL.pending_management_approval },
{ value: 'approved', label: STATUS_LABEL.approved },
{ value: 'rejected', label: STATUS_LABEL.rejected },
{ value: 'abandoned', label: STATUS_LABEL.abandoned },
];
/** 角色 → 待办状态。 */
const TODO_STATUS: Record<string, WorkflowStatus> = {
'商务/销售': 'rejected',
'风控': 'pending_risk_review',
'管理层': 'risk_reviewed',
};
/** 承接建议等级配色。 */
const REC_BG: Record<string, string> = {
accept: 'rgba(16,128,61,0.12)',
conditional: 'rgba(194,65,12,0.12)',
caution: 'rgba(194,65,12,0.12)',
reject: 'rgba(190,18,60,0.12)',
};
const REC_FG: Record<string, string> = {
accept: '#15803D',
conditional: '#C2410C',
caution: '#C2410C',
reject: '#BE123C',
};
function formatDateTime(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
}
export function Dashboard(): JSX.Element {
const navigate = useNavigate();
const { user } = useAuthStore();
const role = user?.role ?? '商务/销售';
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
// 历史列表(服务端分页)
const [items, setItems] = useState<AssessmentListItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
const [archivedView, setArchivedView] = useState<'active' | 'archived'>('active');
const [searchInput, setSearchInput] = useState('');
const [search, setSearch] = useState('');
// 待办列表
const [todoItems, setTodoItems] = useState<AssessmentListItem[]>([]);
// 草稿箱(销售:未运行/未提交的向导进度,服务端持久化)
const [drafts, setDrafts] = useState<DraftItem[]>([]);
// 统计
const [summary, setSummary] = useState<{ total: number; byStatus: Record<string, number>; archived?: number }>({ total: 0, byStatus: {} });
// 告警
const [expiring, setExpiring] = useState<Array<{ id: string; project: string }>>([]);
const [overdue, setOverdue] = useState<Array<{ id: string; project: string; overdueHours: number }>>([]);
const [rejectStats, setRejectStats] = useState<Array<{ reasonType: string; count: number }>>([]);
const [accuracy, setAccuracy] = useState<{ count: number; avgPredictedPct: number | null; avgActualPct: number | null; avgDeviationPct: number | null; bias: string | null }>({ count: 0, avgPredictedPct: null, avgActualPct: null, avgDeviationPct: null, bias: null });
// 搜索防抖
useEffect(() => {
const t = setTimeout(() => {
setSearch(searchInput);
setPage(1);
}, 350);
return () => clearTimeout(t);
}, [searchInput]);
const loadList = useCallback(() => {
setLoading(true);
fetchAssessmentsPage({ page, pageSize, status: statusFilter, q: search, archived: archivedView })
.then((res) => {
setItems(res.items);
setTotal(res.total);
setError(null);
})
.catch((err: unknown) => setError(err instanceof Error ? err.message : '加载失败'))
.finally(() => setLoading(false));
}, [page, pageSize, statusFilter, search, archivedView]);
useEffect(() => {
loadList();
}, [loadList]);
const loadAux = useCallback(() => {
fetchSummary().then(setSummary).catch(() => undefined);
const todoStatus = TODO_STATUS[role];
if (todoStatus !== undefined) {
fetchAssessmentsPage({ page: 1, pageSize: 50, status: todoStatus })
.then((res) => setTodoItems(res.items))
.catch(() => setTodoItems([]));
} else {
setTodoItems([]);
}
// 告警数据
fetch(`${API_BASE}/api/assessments/expiring`).then((r) => r.json()).then(setExpiring).catch(() => setExpiring([]));
fetch(`${API_BASE}/api/assessments/overdue`).then((r) => r.json()).then(setOverdue).catch(() => setOverdue([]));
fetch(`${API_BASE}/api/reject-reason-stats`).then((r) => r.json()).then(setRejectStats).catch(() => setRejectStats([]));
fetch(`${API_BASE}/api/accuracy`).then((r) => r.json()).then(setAccuracy).catch(() => undefined);
// 草稿箱(仅销售展示):列出当前用户的向导草稿。
if (role === '商务/销售') {
listDrafts(user?.username ?? undefined).then(setDrafts).catch(() => setDrafts([]));
} else {
setDrafts([]);
}
}, [role, user?.username]);
/** 删除一条草稿并刷新草稿箱。 */
const removeDraft = useCallback(async (id: string): Promise<void> => {
try { await deleteDraftApi(id); } catch { /* ignore */ }
setDrafts((ds) => ds.filter((d) => d.id !== id));
}, []);
useEffect(() => {
loadAux();
}, [loadAux]);
const handleArchive = useCallback(
(id: string, archived: boolean) => {
archiveAssessment(id, archived, user?.username)
.then(() => {
loadList();
loadAux();
})
.catch((err: unknown) => setError(err instanceof Error ? err.message : '操作失败'));
},
[loadList, loadAux, user],
);
const columns: ReadonlyArray<TableColumn<AssessmentListItem>> = [
{
key: 'project',
header: '项目描述',
render: (r) => {
// 提取【项目】后的名称,去掉【客户】部分
const nameMatch = r.projectDescription.match(/【项目】([^|\n]+)/);
const name = nameMatch !== null && nameMatch[1] !== undefined ? nameMatch[1].trim() : r.projectDescription.slice(0, 20);
const display = name.length > 18 ? `${name.slice(0, 18)}` : name;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px', maxWidth: 200 }}>
<span style={{ fontWeight: 700, color: colorVar('color.text.primary'), whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{display}
</span>
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), whiteSpace: 'nowrap' }}>{r.assessorId}</span>
</div>
);
},
},
{ key: 'type', header: '业务类型', field: 'businessType' },
{ key: 'industry', header: '行业', field: 'industry' },
{ key: 'score', header: '风险分', align: 'right', render: (r) => (r.riskScore !== undefined ? r.riskScore.toFixed(1) : '-') },
{
key: 'grade',
header: '风险分级',
render: (r) => (r.riskGrade !== undefined ? <RiskBadge grade={r.riskGrade as '低' | '中' | '高' | '极高'} /> : '-'),
},
{
key: 'status',
header: '状态',
render: (r) => (
<span
style={{
display: 'inline-block',
padding: '2px 10px',
borderRadius: '999px',
backgroundColor: STATUS_STYLE[r.status].bg,
color: STATUS_STYLE[r.status].fg,
...typographyStyle('caption'),
fontWeight: 600,
whiteSpace: 'nowrap',
}}
>
{STATUS_LABEL[r.status]}
</span>
),
},
{ key: 'acceptability', header: '可接受性', render: (r) => r.acceptability ?? '-' },
{
key: 'recommendation',
header: '承接建议',
render: (r) =>
r.recommendation ? (
<span
style={{
display: 'inline-block',
padding: '2px 10px',
borderRadius: '999px',
...typographyStyle('caption'),
fontWeight: 600,
whiteSpace: 'nowrap',
backgroundColor: REC_BG[r.recommendation.level] ?? 'transparent',
color: REC_FG[r.recommendation.level] ?? colorVar('color.text.secondary'),
}}
>
{r.recommendation.title}
</span>
) : (
<span style={{ color: colorVar('color.text.secondary') }}>-</span>
),
},
{
key: 'latest',
header: '最近处理',
render: (r) => {
const latest = r.auditLog.at(-1);
return latest === undefined ? (
<span style={{ color: colorVar('color.text.secondary'), whiteSpace: 'nowrap' }}></span>
) : (
<div style={{ whiteSpace: 'nowrap', maxWidth: 140, overflow: 'hidden', textOverflow: 'ellipsis' }}>
<span style={{ ...typographyStyle('caption') }}>{latest.action.length > 10 ? latest.action.slice(0, 10) + '…' : latest.action}</span>
<div style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>
{latest.role} · {formatDateTime(latest.timestamp)}
</div>
</div>
);
},
},
{ key: 'created', header: '创建时间', render: (r) => formatDateTime(r.createdAt) },
{
key: 'action',
header: '操作',
render: (r) => {
const canArchive = r.status === 'approved' || r.status === 'abandoned';
return (
<div style={{ display: 'flex', gap: `${space(1)}px`, whiteSpace: 'nowrap' }}>
<Button size="sm" variant="ghost" onClick={() => navigate(`/assessments/${r.id}`)}>
</Button>
{archivedView === 'archived' ? (
<Button size="sm" variant="ghost" onClick={() => handleArchive(r.id, false)}>
</Button>
) : (
<span title={canArchive ? '归档' : '仅「最终通过」或「已放弃」可归档'} style={{ display: 'inline-flex' }}>
<Button
size="sm"
variant="ghost"
disabled={!canArchive}
onClick={() => handleArchive(r.id, true)}
>
</Button>
</span>
)}
</div>
);
},
},
];
const todoCount = summary.byStatus[TODO_STATUS[role] ?? ''] ?? 0;
const summaryItems = [
{ label: '全部评估', value: summary.total, tone: colorVar('color.brand.primary') },
{ label: '我的待办', value: todoCount, tone: colorVar('color.risk.high') },
{ label: '待风控', value: summary.byStatus.pending_risk_review ?? 0, tone: colorVar('color.risk.medium') },
{ label: '已通过', value: summary.byStatus.approved ?? 0, tone: colorVar('color.risk.low') },
];
const pageTitle = role === '风控' ? '待办审核' : role === '管理层' ? '待办审批' : '评估历史';
const roleDescription =
role === '风控'
? '聚焦待复核项目,补充风险判断并留下处理痕迹。'
: role === '管理层'
? '查看全部历史与高风险事项,完成最终审批决策。'
: '发起新评估,跟踪退回事项,并查看全量历史记录。';
const totalPages = Math.max(1, Math.ceil(total / pageSize));
return (
<div style={{ fontFamily: FONT_FAMILY }}>
{role === '商务/销售' && (
<GuideBanner id="role-sales" tone="brand">
<strong> /</strong>线 <strong>+ </strong> <strong></strong> <strong></strong>退
</GuideBanner>
)}
{role === '风控' && (
<GuideBanner id="role-risk" tone="brand">
<strong> </strong> <strong></strong> 线<strong> / </strong>线"需人工核实"
</GuideBanner>
)}
{role === '管理层' && (
<GuideBanner id="role-mgmt" tone="brand">
<strong> </strong> <strong></strong> // <strong> / 线 / </strong>
</GuideBanner>
)}
<div
style={{
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'space-between',
alignItems: 'center',
gap: `${space(3)}px`,
marginBottom: `${space(5)}px`,
padding: `${space(5)}px`,
background: `linear-gradient(135deg, ${colorVar('color.bg.elevated')} 0%, ${colorVar('color.bg.surface')} 100%)`,
border: `1px solid ${colorVar('color.border.default')}`,
borderRadius: `${RADIUS.lg}px`,
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: `${space(2)}px` }}>
<span style={{ ...typographyStyle('caption'), color: colorVar('color.brand.primary'), fontWeight: 700 }}>{role} </span>
<h1 style={{ ...typographyStyle('heading'), margin: 0, color: colorVar('color.text.primary') }}>{pageTitle}</h1>
<p style={{ margin: 0, color: colorVar('color.text.secondary'), maxWidth: 620 }}>{roleDescription}</p>
</div>
{role === '商务/销售' && (
<Button onClick={() => navigate('/new')} size="lg"><span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}><Icon name="plus" size={16} color="currentColor" /> </span></Button>
)}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: `${space(3)}px`, marginBottom: `${space(4)}px` }}>
{summaryItems.map((item) => (
<div
key={item.label}
style={{
padding: `${space(4)}px`,
backgroundColor: colorVar('color.bg.elevated'),
border: `1px solid ${colorVar('color.border.default')}`,
borderRadius: `${RADIUS.lg}px`,
display: 'flex',
flexDirection: 'column',
gap: `${space(2)}px`,
}}
>
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>{item.label}</span>
<span style={{ ...typographyStyle('heading'), color: item.tone, fontWeight: 800 }}>{item.value}</span>
</div>
))}
</div>
{/* 告警卡片 */}
{(expiring.length > 0 || overdue.length > 0 || rejectStats.length > 0 || accuracy.count > 0) && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: `${space(3)}px`, marginBottom: `${space(4)}px` }}>
{accuracy.count > 0 && accuracy.bias !== null && (() => {
const consistent = accuracy.bias === '基本一致';
const accent = consistent ? '#15803D' : '#B45309';
const dev = accuracy.avgDeviationPct ?? 0;
return (
<div style={{ padding: `${space(4)}px`, backgroundColor: colorVar('color.bg.elevated'), border: `1px solid ${colorVar('color.border.default')}`, borderRadius: `${RADIUS.lg}px`, boxShadow: '0 1px 2px rgba(16,24,40,0.04)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: `${space(2)}px`, marginBottom: `${space(3)}px` }}>
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 32, height: 32, borderRadius: 8, backgroundColor: `${accent}1A`, color: accent }}>
<Icon name="trending-up" size={18} />
</span>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span style={{ ...typographyStyle('body'), fontWeight: 700, color: colorVar('color.text.primary') }}></span>
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}> {accuracy.count} </span>
</div>
</div>
<div style={{ display: 'flex', gap: `${space(3)}px`, marginBottom: `${space(3)}px` }}>
<div style={{ flex: 1, padding: `${space(2)}px ${space(3)}px`, backgroundColor: colorVar('color.bg.surface'), borderRadius: `${RADIUS.md}px` }}>
<div style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}></div>
<div style={{ ...typographyStyle('body'), fontWeight: 800, color: colorVar('color.text.primary') }}>{accuracy.avgPredictedPct}%</div>
</div>
<div style={{ flex: 1, padding: `${space(2)}px ${space(3)}px`, backgroundColor: colorVar('color.bg.surface'), borderRadius: `${RADIUS.md}px` }}>
<div style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}></div>
<div style={{ ...typographyStyle('body'), fontWeight: 800, color: colorVar('color.text.primary') }}>{accuracy.avgActualPct}%</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: `${space(2)}px` }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: `2px ${space(2)}px`, borderRadius: '999px', backgroundColor: `${accent}1A`, color: accent, ...typographyStyle('caption'), fontWeight: 700 }}>
<Icon name={consistent ? 'check-circle' : 'alert'} size={13} />
{dev > 0 ? '+' : ''}{accuracy.avgDeviationPct} pp · {accuracy.bias}
</span>
</div>
{!consistent && (
<div style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), marginTop: `${space(2)}px`, lineHeight: 1.6 }}>
{accuracy.bias === '预测偏乐观' ? '建议下调报价预期或上调成本假设进行校准。' : '成本假设或偏保守,可适当下调目标基准。'}
</div>
)}
{role === '管理层' && !consistent && (
<button
type="button"
onClick={() => {
applyCalibration()
.then((r) => { setError(null); setNotice(`已应用校准:目标净利率基准 ${(r.previousBase * 100).toFixed(1)}% → ${(r.appliedBase * 100).toFixed(1)}%`); loadAux(); })
.catch((e: unknown) => setError(e instanceof Error ? e.message : '校准失败'));
}}
style={{ marginTop: `${space(3)}px`, display: 'inline-flex', alignItems: 'center', gap: 6, padding: `${space(1)}px ${space(3)}px`, borderRadius: `${RADIUS.md}px`, border: `1px solid ${accent}`, background: 'transparent', color: accent, cursor: 'pointer', fontFamily: FONT_FAMILY, ...typographyStyle('caption'), fontWeight: 600 }}
>
<Icon name="settings" size={14} />
</button>
)}
</div>
);
})()}
{expiring.length > 0 && (
<div style={{ padding: `${space(4)}px`, backgroundColor: colorVar('color.bg.elevated'), border: `1px solid ${colorVar('color.border.default')}`, borderRadius: `${RADIUS.lg}px`, boxShadow: '0 1px 2px rgba(16,24,40,0.04)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: `${space(2)}px`, marginBottom: `${space(2)}px` }}>
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 32, height: 32, borderRadius: 8, backgroundColor: 'rgba(180,83,9,0.1)', color: '#B45309' }}>
<Icon name="clock" size={18} />
</span>
<span style={{ ...typographyStyle('body'), fontWeight: 700, color: colorVar('color.text.primary') }}>{expiring.length}</span>
</div>
{expiring.slice(0, 3).map((e) => (
<div key={e.id} style={{ ...typographyStyle('caption'), color: colorVar('color.text.primary'), padding: '3px 0' }}>{e.project.slice(0, 25)}</div>
))}
</div>
)}
{overdue.length > 0 && (
<div style={{ padding: `${space(4)}px`, backgroundColor: colorVar('color.bg.elevated'), border: `1px solid ${colorVar('color.border.default')}`, borderRadius: `${RADIUS.lg}px`, boxShadow: '0 1px 2px rgba(16,24,40,0.04)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: `${space(2)}px`, marginBottom: `${space(2)}px` }}>
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 32, height: 32, borderRadius: 8, backgroundColor: 'rgba(190,18,60,0.1)', color: '#BE123C' }}>
<Icon name="alert" size={18} />
</span>
<span style={{ ...typographyStyle('body'), fontWeight: 700, color: colorVar('color.text.primary') }}>{overdue.length}</span>
</div>
{overdue.slice(0, 3).map((e) => (
<div key={e.id} style={{ ...typographyStyle('caption'), color: colorVar('color.text.primary'), padding: '3px 0' }}>{e.project.slice(0, 20)} · {e.overdueHours}h</div>
))}
</div>
)}
{rejectStats.length > 0 && (() => {
const maxCount = Math.max(...rejectStats.map((s) => s.count), 1);
return (
<div style={{ padding: `${space(4)}px`, backgroundColor: colorVar('color.bg.elevated'), border: `1px solid ${colorVar('color.border.default')}`, borderRadius: `${RADIUS.lg}px`, boxShadow: '0 1px 2px rgba(16,24,40,0.04)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: `${space(2)}px`, marginBottom: `${space(3)}px` }}>
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 32, height: 32, borderRadius: 8, backgroundColor: 'rgba(190,18,60,0.1)', color: '#BE123C' }}>
<Icon name="ban" size={18} />
</span>
<span style={{ ...typographyStyle('body'), fontWeight: 700, color: colorVar('color.text.primary') }}> Top </span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: `${space(2)}px` }}>
{rejectStats.slice(0, 4).map((s) => (
<div key={s.reasonType} style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', ...typographyStyle('caption'), color: colorVar('color.text.primary') }}>
<span>{s.reasonType}</span>
<span style={{ fontWeight: 700, color: '#BE123C' }}>{s.count} </span>
</div>
<div style={{ height: 6, borderRadius: 999, backgroundColor: colorVar('color.bg.surface'), overflow: 'hidden' }}>
<div style={{ width: `${(s.count / maxCount) * 100}%`, height: '100%', borderRadius: 999, backgroundColor: '#BE123C', opacity: 0.85 }} />
</div>
</div>
))}
</div>
</div>
);
})()}
</div>
)}
{error !== null && (
<div style={{ padding: `${space(3)}px`, backgroundColor: 'rgba(190,18,60,0.08)', color: colorVar('color.risk.critical'), borderRadius: `${RADIUS.md}px`, marginBottom: `${space(3)}px` }}>
{error}
</div>
)}
{notice !== null && (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: `${space(3)}px`, backgroundColor: 'rgba(16,128,61,0.08)', color: '#15803D', borderRadius: `${RADIUS.md}px`, marginBottom: `${space(3)}px`, fontWeight: 600 }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}><Icon name="check-circle" size={16} /> {notice}</span>
<button type="button" onClick={() => setNotice(null)} style={{ display: 'inline-flex', border: 'none', background: 'transparent', color: '#15803D', cursor: 'pointer' }} aria-label="关闭"><Icon name="close" size={16} /></button>
</div>
)}
{todoItems.length > 0 && (
<div style={{ marginBottom: `${space(4)}px` }}>
<Card title={`待处理 (${todoCount})`}>
<Table columns={columns} data={todoItems} getRowKey={(row) => row.id} caption={`${role} 待处理列表`} emptyMessage="当前没有需要你处理的评估" />
</Card>
</div>
)}
{role === '商务/销售' && drafts.length > 0 && (
<div style={{ marginBottom: `${space(4)}px` }}>
<Card title={`草稿箱 (${drafts.length})`}>
<p style={{ margin: `0 0 ${space(2)}px`, ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>
/
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: `${space(1)}px` }}>
{drafts.map((d) => (
<div key={d.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: `${space(2)}px`, padding: `${space(2)}px ${space(3)}px`, border: `1px solid ${colorVar('color.border.default')}`, borderRadius: `${RADIUS.md}px` }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }}>
<span style={{ ...typographyStyle('body'), color: colorVar('color.text.primary'), fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<Icon name={d.sourceAssessmentId !== null ? 'edit' : 'file'} size={15} color={colorVar('color.brand.primary')} />
{d.sourceAssessmentId !== null ? '编辑草稿' : '新建草稿'}{d.projectName ?? '未命名项目'}
</span>
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>
{formatDateTime(d.updatedAt)}
</span>
</div>
<div style={{ display: 'flex', gap: `${space(1)}px`, whiteSpace: 'nowrap' }}>
<Button size="sm" onClick={() => navigate(`/new?draft=${encodeURIComponent(d.id)}`)}></Button>
<Button size="sm" variant="ghost" onClick={() => { void removeDraft(d.id); }}></Button>
</div>
</div>
))}
</div>
</Card>
</div>
)}
<Card
title={
<div style={{ display: 'flex', flexDirection: 'column', gap: `${space(1)}px` }}>
<span></span>
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), fontWeight: 400 }}>
· {total}
</span>
</div>
}
>
<div style={{ display: 'flex', gap: `${space(2)}px`, marginBottom: `${space(3)}px`, borderBottom: `1px solid ${colorVar('color.border.default')}`, paddingBottom: `${space(2)}px` }}>
{(['active', 'archived'] as const).map((v) => {
const active = archivedView === v;
return (
<button
key={v}
type="button"
onClick={() => {
setArchivedView(v);
setPage(1);
}}
style={{
padding: `${space(1)}px ${space(3)}px`,
borderRadius: '999px',
border: `1px solid ${active ? colorVar('color.brand.primary') : colorVar('color.border.default')}`,
backgroundColor: active ? colorVar('color.brand.primary') : 'transparent',
color: active ? '#fff' : colorVar('color.text.secondary'),
cursor: 'pointer',
fontFamily: FONT_FAMILY,
...typographyStyle('caption'),
fontWeight: 600,
}}
>
{v === 'active' ? '进行中' : `已归档${summary.archived ? ` (${summary.archived})` : ''}`}
</button>
);
})}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'space-between', gap: `${space(3)}px`, marginBottom: `${space(3)}px` }}>
<input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="搜索项目、业务类型、行业或发起人"
style={{
minWidth: 280,
flex: '1 1 320px',
padding: `${space(2)}px ${space(3)}px`,
border: `1px solid ${colorVar('color.border.default')}`,
borderRadius: `${RADIUS.md}px`,
backgroundColor: colorVar('color.bg.canvas'),
color: colorVar('color.text.primary'),
fontFamily: FONT_FAMILY,
...typographyStyle('body'),
}}
/>
<select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value as StatusFilter);
setPage(1);
}}
style={{
minWidth: 160,
padding: `${space(2)}px ${space(3)}px`,
border: `1px solid ${colorVar('color.border.default')}`,
borderRadius: `${RADIUS.md}px`,
backgroundColor: colorVar('color.bg.canvas'),
color: colorVar('color.text.primary'),
fontFamily: FONT_FAMILY,
...typographyStyle('body'),
}}
>
{STATUS_FILTER_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
{/* 状态图例 */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: `${space(2)}px`, marginBottom: `${space(3)}px` }}>
{(['draft', 'pending_risk_review', 'risk_reviewed', 'approved', 'rejected', 'abandoned'] as WorkflowStatus[]).map((s) => (
<span key={s} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>
<span style={{ width: 10, height: 10, borderRadius: '50%', backgroundColor: STATUS_STYLE[s].fg, display: 'inline-block' }} />
{STATUS_LABEL[s]}
</span>
))}
</div>
{loading ? (
<div style={{ padding: `${space(4)}px`, color: colorVar('color.text.secondary') }}></div>
) : (
<>
<Table
columns={columns}
data={items}
getRowKey={(row) => row.id}
caption="全部评估记录列表"
emptyMessage={total === 0 ? (archivedView === 'archived' ? '暂无已归档的评估' : (role === '商务/销售' ? '还没有评估。点击右上角「+ 新建评估」开始第一单。' : '暂无评估记录')) : '没有匹配筛选条件的评估'}
/>
<Pager
page={page}
totalPages={totalPages}
total={total}
pageSize={pageSize}
onPage={setPage}
onPageSize={(s) => {
setPageSize(s);
setPage(1);
}}
/>
</>
)}
</Card>
</div>
);
}
/** 服务端分页器。 */
function Pager({
page,
totalPages,
total,
pageSize,
onPage,
onPageSize,
}: {
readonly page: number;
readonly totalPages: number;
readonly total: number;
readonly pageSize: number;
readonly onPage: (p: number) => void;
readonly onPageSize: (s: number) => void;
}): JSX.Element {
const btn = (label: string, to: number, disabled: boolean): JSX.Element => (
<button
type="button"
disabled={disabled}
onClick={() => onPage(to)}
style={{
padding: `${space(1)}px ${space(2)}px`,
borderRadius: `${RADIUS.sm}px`,
border: `1px solid ${colorVar('color.border.default')}`,
background: 'transparent',
color: disabled ? colorVar('color.text.secondary') : colorVar('color.text.primary'),
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.5 : 1,
fontFamily: FONT_FAMILY,
...typographyStyle('caption'),
}}
>
{label}
</button>
);
return (
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'space-between', gap: `${space(2)}px`, marginTop: `${space(3)}px` }}>
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}>
{total} {page} / {totalPages}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: `${space(2)}px` }}>
<span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary') }}></span>
<select
value={pageSize}
onChange={(e) => onPageSize(Number(e.target.value))}
style={{
padding: `${space(1)}px ${space(2)}px`,
borderRadius: `${RADIUS.sm}px`,
border: `1px solid ${colorVar('color.border.default')}`,
backgroundColor: colorVar('color.bg.canvas'),
color: colorVar('color.text.primary'),
fontFamily: FONT_FAMILY,
...typographyStyle('caption'),
}}
>
{[10, 20, 50].map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
{btn('首页', 1, page <= 1)}
{btn('上一页', page - 1, page <= 1)}
{btn('下一页', page + 1, page >= totalPages)}
{btn('末页', totalPages, page >= totalPages)}
</div>
</div>
);
}