Files
RiskAgent/web/src/pages/Dashboard.tsx
T
freedakgmail 8bac14ef44 fix(calibration): 校准幂等+已校准状态显示,解决重复提示
根因:预测准确度卡的偏差来自历史回填项目的预测vs实际,属既成事实,
不会因校准改变;且原 apply 公式 next=current+dev 会累加,反复点越推越高。

修复:
- 校准建议/应用均基于未校准原始基准(env/默认)计算,保证幂等
- GET /api/calibration 返回 uncalibratedBase 与 calibrated 标志
- 卡片:已校准时显示「已按当前偏差校准:基准X%」绿色状态,不再出现按钮;
  未校准时按钮明示「X% → Y%」
- 补充说明:历史偏差不会因校准改变,校准仅调整后续承接建议阈值
2026-06-14 11:01:55 +08:00

844 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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, fetchCalibration, listDrafts, deleteDraftApi, fetchAssignments, API_BASE } from '../api/client.js';
// 校准状态类型(目标净利率基准)。
type CalibrationState = { currentBase: number; suggestedBase: number; uncalibratedBase: number; calibrated: boolean; bias: string | null; deviationPct: number | null };
import type { AssessmentListItem, WorkflowStatus, DraftItem, AssignmentRecord } 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[]>([]);
// 审批人指派(assessmentId → 记录);待办软过滤用。
const [assignments, setAssignments] = useState<Record<string, AssignmentRecord>>({});
const [onlyMine, setOnlyMine] = useState(true);
// 草稿箱(销售:未运行/未提交的向导进度,服务端持久化)
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 });
// 校准状态(目标净利率基准)。
const [calibration, setCalibration] = useState<CalibrationState | null>(null);
// 搜索防抖
useEffect(() => {
const t = setTimeout(() => {
setSearch(searchInput);
setPage(1);
}, 350);
return () => clearTimeout(t);
}, [searchInput]);
// 销售仅可见本人发起的评估(行级数据隔离);风控/管理层可见全部。
const scopeAssessorId = role === '商务/销售' ? user?.id : undefined;
const loadList = useCallback(() => {
setLoading(true);
fetchAssessmentsPage({ page, pageSize, status: statusFilter, q: search, archived: archivedView, ...(scopeAssessorId !== undefined ? { assessorId: scopeAssessorId } : {}) })
.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, scopeAssessorId]);
useEffect(() => {
loadList();
}, [loadList]);
const loadAux = useCallback(() => {
fetchSummary(scopeAssessorId).then(setSummary).catch(() => undefined);
const todoStatus = TODO_STATUS[role];
if (todoStatus !== undefined) {
fetchAssessmentsPage({ page: 1, pageSize: 50, status: todoStatus, ...(scopeAssessorId !== undefined ? { assessorId: scopeAssessorId } : {}) })
.then((res) => setTodoItems(res.items))
.catch(() => setTodoItems([]));
} else {
setTodoItems([]);
}
// 组合分析(准确度/驳回Top/到期/超时)面向风控与管理层;销售视图不展示(避免跨人数据)。
if (role === '商务/销售') {
setExpiring([]); setOverdue([]); setRejectStats([]); setAccuracy({ count: 0, avgPredictedPct: null, avgActualPct: null, avgDeviationPct: null, bias: null }); setCalibration(null);
} else {
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);
fetchCalibration().then(setCalibration).catch(() => setCalibration(null));
}
// 草稿箱(仅销售展示):列出当前用户的向导草稿。
if (role === '商务/销售') {
listDrafts(user?.id ?? undefined).then(setDrafts).catch(() => setDrafts([]));
} else {
setDrafts([]);
}
// 审批人指派:风控/管理层用于待办过滤;销售用于查看自己项目的审批进度。
if (role === '风控' || role === '管理层' || role === '商务/销售') {
fetchAssignments().then(setAssignments).catch(() => setAssignments({}));
} else {
setAssignments({});
}
}, [role, user?.username, user?.id, scopeAssessorId]);
/** 删除一条草稿并刷新草稿箱。 */
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.assessorName ?? r.assessorId}</span>
</div>
);
},
},
{ key: 'type', header: '业务类型', render: (r) => <span style={{ display: 'inline-block', minWidth: 56, wordBreak: 'keep-all', whiteSpace: 'normal' }}>{r.businessType}</span> },
{ key: 'industry', header: '行业', render: (r) => <span style={{ display: 'inline-block', minWidth: 48, whiteSpace: 'nowrap' }}>{r.industry}</span> },
{ 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 isSales = role === '商务/销售';
// 销售首页:为自己的项目显示审批进度(阶段 + 当前审批人)。
const approvalProgressCol: TableColumn<AssessmentListItem> = {
key: 'progress',
header: '审批进度',
render: (r) => {
const a = assignments[r.id];
const stage = (() => {
switch (r.status) {
case 'draft': return { text: '待申报(草稿)', sub: '尚未报送,点「查看」进入后申报', color: '#64748B' };
case 'pending_risk_review': return { text: '风控审核中', sub: a?.riskReviewerName ? `审批人:${a.riskReviewerName}` : '待分配风控', color: '#B45309' };
case 'risk_reviewed':
case 'pending_management_approval': return { text: '管理层审批中', sub: a?.managerName ? `审批人:${a.managerName}` : '待分配管理层', color: '#4F46E5' };
case 'approved': return { text: '已通过 ✓', sub: '审批完成', color: '#15803D' };
case 'rejected': return { text: '已驳回', sub: '请修改后重新提交', color: '#BE123C' };
case 'abandoned': return { text: '已放弃', sub: '流程终止', color: '#475569' };
default: return { text: r.status, sub: '', color: colorVar('color.text.secondary') };
}
})();
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 120 }}>
<span style={{ ...typographyStyle('caption'), fontWeight: 700, color: stage.color }}>{stage.text}</span>
{stage.sub && <span style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), fontSize: '11px' }}>{stage.sub}</span>}
</div>
);
},
};
// 销售视图:去掉与「审批进度」重复的列(承接建议/最近处理),插入审批进度,保留操作列。
// 风控/管理层视图:去掉冗余的「可接受性」(与承接建议重复),精简列宽避免溢出。
const historyColumns = isSales
? (() => {
const base = columns.filter((col) => col.key !== 'recommendation' && col.key !== 'latest');
return [...base.slice(0, -1), approvalProgressCol, base[base.length - 1]!];
})()
: columns.filter((col) => col.key !== 'acceptability');
const summaryItems = [
{ label: isSales ? '我的评估' : '全部评估', value: summary.total, tone: colorVar('color.brand.primary') },
{ label: isSales ? '被驳回(待处理)' : '我的待办', value: todoCount, tone: colorVar('color.risk.high') },
{ label: isSales ? '我的待风控' : '待风控', value: summary.byStatus.pending_risk_review ?? 0, tone: colorVar('color.risk.medium') },
{ label: isSales ? '我的已通过' : '已通过', 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 === '预测偏乐观' ? '建议上调目标净利率基准以补偿系统性乐观偏差。' : '预测偏保守,可适当下调目标净利率基准。'}
{calibration !== null && (
<span> <strong>{(calibration.currentBase * 100).toFixed(1)}%</strong>
{!calibration.calibrated && <> <strong style={{ color: accent }}>{(calibration.suggestedBase * 100).toFixed(1)}%</strong></>}</span>
)}
</div>
)}
{role === '管理层' && !consistent && calibration !== null && (
calibration.calibrated ? (
<div style={{ marginTop: `${space(3)}px`, display: 'inline-flex', alignItems: 'center', gap: 6, padding: `${space(1)}px ${space(3)}px`, borderRadius: `${RADIUS.md}px`, backgroundColor: 'rgba(16,128,61,0.10)', color: '#15803D', ...typographyStyle('caption'), fontWeight: 600 }}>
<Icon name="check-circle" size={14} /> {(calibration.currentBase * 100).toFixed(1)}%
</div>
) : (
<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} /> {(calibration.currentBase * 100).toFixed(1)}% {(calibration.suggestedBase * 100).toFixed(1)}%
</button>
)
)}
{role === '管理层' && !consistent && (
<div style={{ ...typographyStyle('caption'), color: colorVar('color.text.secondary'), marginTop: `${space(2)}px`, fontSize: '11px', lineHeight: 1.6 }}>
</div>
)}
</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 && (() => {
const myId = user?.id;
const assignedToMe = (id: string): boolean => {
const a = assignments[id];
if (a === undefined) return false;
return role === '风控' ? a.riskReviewerId === myId : role === '管理层' ? a.managerId === myId : false;
};
const isAssigned = (id: string): boolean => {
const a = assignments[id];
if (a === undefined) return false;
return role === '风控' ? a.riskReviewerId !== null : role === '管理层' ? a.managerId !== null : false;
};
// 软约束:默认只看分给我的;未指派的也展示(避免遗漏)。
const shown = onlyMine ? todoItems.filter((t) => assignedToMe(t.id) || !isAssigned(t.id)) : todoItems;
const assignCol: TableColumn<AssessmentListItem> = {
key: 'assignee',
header: '指派审批人',
render: (r) => {
const a = assignments[r.id];
const name = a !== undefined ? (role === '管理层' ? a.managerName : a.riskReviewerName) : null;
const aid = a !== undefined ? (role === '管理层' ? a.managerId : a.riskReviewerId) : null;
if (name === null || name === undefined) return <span style={{ color: colorVar('color.text.secondary') }}></span>;
const mine = aid === myId;
return <span style={{ ...typographyStyle('caption'), fontWeight: 600, color: mine ? '#15803D' : colorVar('color.text.primary') }}>{name}{mine ? '(我)' : ''}</span>;
},
};
// 待处理列表精简列(参考销售列表):去掉「可接受性」(与承接建议重复)与「最近处理」
//(待审项最近处理即提交动作,对裁决无意义),插入「指派审批人」,保留操作列,避免溢出。
const todoBase = columns.filter((col) => col.key !== 'acceptability' && col.key !== 'latest');
const todoColumns = [...todoBase.slice(0, -1), assignCol, todoBase[todoBase.length - 1]!];
return (
<div style={{ marginBottom: `${space(4)}px` }}>
<Card title={
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: `${space(2)}px` }}>
<span> ({shown.length})</span>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, ...typographyStyle('caption'), color: colorVar('color.text.secondary'), fontWeight: 400 }}>
<input type="checkbox" checked={onlyMine} onChange={(e) => setOnlyMine(e.target.checked)} />
</label>
</div>
}>
<Table columns={todoColumns} data={shown} getRowKey={(row) => row.id} caption={`${role} 待处理列表`} emptyMessage={onlyMine ? '没有分给你的待处理项(可取消勾选查看全部)' : '当前没有需要你处理的评估'} />
</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>{isSales ? '我的申报 · 审批进度' : '评估历史'}</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={historyColumns}
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>
);
}