/** * 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 = { draft: '草稿待申报', pending_risk_review: '待风控审核', risk_reviewed: '风控已审核', pending_management_approval: '待管理层审批', approved: '已通过', rejected: '已驳回', abandoned: '已放弃', }; const STATUS_STYLE: Record = { 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 = { '商务/销售': 'rejected', '风控': 'pending_risk_review', '管理层': 'risk_reviewed', }; /** 承接建议等级配色。 */ const REC_BG: Record = { 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 = { 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(null); const [notice, setNotice] = useState(null); const [loading, setLoading] = useState(false); // 历史列表(服务端分页) const [items, setItems] = useState([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(10); const [statusFilter, setStatusFilter] = useState('all'); const [archivedView, setArchivedView] = useState<'active' | 'archived'>('active'); const [searchInput, setSearchInput] = useState(''); const [search, setSearch] = useState(''); // 待办列表 const [todoItems, setTodoItems] = useState([]); // 审批人指派(assessmentId → 记录);待办软过滤用。 const [assignments, setAssignments] = useState>({}); const [onlyMine, setOnlyMine] = useState(true); // 草稿箱(销售:未运行/未提交的向导进度,服务端持久化) const [drafts, setDrafts] = useState([]); // 统计 const [summary, setSummary] = useState<{ total: number; byStatus: Record; archived?: number }>({ total: 0, byStatus: {} }); // 告警 const [expiring, setExpiring] = useState>([]); const [overdue, setOverdue] = useState>([]); const [rejectStats, setRejectStats] = useState>([]); 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(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 => { 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> = [ { 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 (
{display} 发起人:{r.assessorName ?? r.assessorId}
); }, }, { key: 'type', header: '业务类型', render: (r) => {r.businessType} }, { key: 'industry', header: '行业', render: (r) => {r.industry} }, { key: 'score', header: '风险分', align: 'right', render: (r) => (r.riskScore !== undefined ? r.riskScore.toFixed(1) : '-') }, { key: 'grade', header: '风险分级', render: (r) => (r.riskGrade !== undefined ? : '-'), }, { key: 'status', header: '状态', render: (r) => ( {STATUS_LABEL[r.status]} ), }, { key: 'acceptability', header: '可接受性', render: (r) => r.acceptability ?? '-' }, { key: 'recommendation', header: '承接建议', render: (r) => r.recommendation ? ( {r.recommendation.title} ) : ( - ), }, { key: 'latest', header: '最近处理', render: (r) => { const latest = r.auditLog.at(-1); return latest === undefined ? ( 待处理 ) : (
{latest.action.length > 10 ? latest.action.slice(0, 10) + '…' : latest.action}
{latest.role} · {formatDateTime(latest.timestamp)}
); }, }, { key: 'created', header: '创建时间', render: (r) => formatDateTime(r.createdAt) }, { key: 'action', header: '操作', render: (r) => { const canArchive = r.status === 'approved' || r.status === 'abandoned'; return (
{archivedView === 'archived' ? ( ) : ( )}
); }, }, ]; const todoCount = summary.byStatus[TODO_STATUS[role] ?? ''] ?? 0; const isSales = role === '商务/销售'; // 销售首页:为自己的项目显示审批进度(阶段 + 当前审批人)。 const approvalProgressCol: TableColumn = { 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 (
{stage.text} {stage.sub && {stage.sub}}
); }, }; // 销售视图:去掉与「审批进度」重复的列(承接建议/最近处理),插入审批进度,保留操作列。 // 风控/管理层视图:去掉冗余的「可接受性」(与承接建议重复),精简列宽避免溢出。 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 (
{role === '商务/销售' && ( 你是 商务/销售。主线三步:① 点右上角 + 新建评估 录入项目 → ② 在详情页点 申报 报送风控 → ③ 被驳回的项目可编辑后重报。下方为你的评估历史与被退回事项。 )} {role === '风控' && ( 你是 风控。下方 待处理 是需要你审核的项目;打开后审阅风险与红线,给出审核通过 / 驳回结论。红线"需人工核实"项可在详情页人工裁定。 )} {role === '管理层' && ( 你是 管理层。下方 待处理 待你终审(通过/驳回/放弃);顶部导航可维护 费率 / 红线 / 客户档案。看板含到期、超时与预测准确度提醒。 )}
{role} 工作台

{pageTitle}

{roleDescription}

{role === '商务/销售' && ( )}
{summaryItems.map((item) => (
{item.label} {item.value}
))}
{/* 告警卡片 */} {(expiring.length > 0 || overdue.length > 0 || rejectStats.length > 0 || accuracy.count > 0) && (
{accuracy.count > 0 && accuracy.bias !== null && (() => { const consistent = accuracy.bias === '基本一致'; const accent = consistent ? '#15803D' : '#B45309'; const dev = accuracy.avgDeviationPct ?? 0; return (
预测准确度 基于 {accuracy.count} 个已回填项目
预测净利率
{accuracy.avgPredictedPct}%
实际净利率
{accuracy.avgActualPct}%
系统性偏差 {dev > 0 ? '+' : ''}{accuracy.avgDeviationPct} pp · {accuracy.bias}
{!consistent && (
{accuracy.bias === '预测偏乐观' ? '建议上调目标净利率基准以补偿系统性乐观偏差。' : '预测偏保守,可适当下调目标净利率基准。'} {calibration !== null && ( 当前基准 {(calibration.currentBase * 100).toFixed(1)}% {!calibration.calibrated && <>,建议调整为 {(calibration.suggestedBase * 100).toFixed(1)}%}。 )}
)} {role === '管理层' && !consistent && calibration !== null && ( calibration.calibrated ? (
已按当前偏差校准:目标净利率基准 {(calibration.currentBase * 100).toFixed(1)}%
) : ( ) )} {role === '管理层' && !consistent && (
注:上方偏差来自已回填项目的历史预测与实际对比,属既成事实,不会因校准而改变;校准仅调整后续评估的目标净利率基准(影响承接建议阈值)。
)}
); })()} {expiring.length > 0 && (
即将到期({expiring.length})
{expiring.slice(0, 3).map((e) => (
{e.project.slice(0, 25)}
))}
)} {overdue.length > 0 && (
审批超时({overdue.length})
{overdue.slice(0, 3).map((e) => (
{e.project.slice(0, 20)} · 超时 {e.overdueHours}h
))}
)} {rejectStats.length > 0 && (() => { const maxCount = Math.max(...rejectStats.map((s) => s.count), 1); return (
驳回 Top 原因
{rejectStats.slice(0, 4).map((s) => (
{s.reasonType} {s.count} 次
))}
); })()}
)} {error !== null && (
{error}
)} {notice !== null && (
{notice}
)} {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 = { 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 未指派; const mine = aid === myId; return {name}{mine ? '(我)' : ''}; }, }; // 待处理列表精简列(参考销售列表):去掉「可接受性」(与承接建议重复)与「最近处理」 //(待审项最近处理即提交动作,对裁决无意义),插入「指派审批人」,保留操作列,避免溢出。 const todoBase = columns.filter((col) => col.key !== 'acceptability' && col.key !== 'latest'); const todoColumns = [...todoBase.slice(0, -1), assignCol, todoBase[todoBase.length - 1]!]; return (
待处理 ({shown.length})
}> row.id} caption={`${role} 待处理列表`} emptyMessage={onlyMine ? '没有分给你的待处理项(可取消勾选查看全部)' : '当前没有需要你处理的评估'} /> ); })()} {role === '商务/销售' && drafts.length > 0 && (

未运行/未提交的填写进度(服务端保存,跨设备)。「继续」回到向导接着填,运行评估后自动清除。

{drafts.map((d) => (
{d.sourceAssessmentId !== null ? '编辑草稿' : '新建草稿'}:{d.projectName ?? '未命名项目'} 更新于 {formatDateTime(d.updatedAt)}
))}
)} {isSales ? '我的申报 · 审批进度' : '评估历史'} 服务端分页 · 共 {total} 条 } >
{(['active', 'archived'] as const).map((v) => { const active = archivedView === v; return ( ); })}
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'), }} />
{/* 状态图例 */}
{(['draft', 'pending_risk_review', 'risk_reviewed', 'approved', 'rejected', 'abandoned'] as WorkflowStatus[]).map((s) => ( {STATUS_LABEL[s]} ))}
{loading ? (
加载中…
) : ( <>
row.id} caption="全部评估记录列表" emptyMessage={total === 0 ? (archivedView === 'archived' ? '暂无已归档的评估' : (role === '商务/销售' ? '还没有评估。点击右上角「+ 新建评估」开始第一单。' : '暂无评估记录')) : '没有匹配筛选条件的评估'} /> { setPageSize(s); setPage(1); }} /> )} ); } /** 服务端分页器。 */ 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 => ( ); return (
共 {total} 条,第 {page} / {totalPages} 页
每页 {btn('首页', 1, page <= 1)} {btn('上一页', page - 1, page <= 1)} {btn('下一页', page + 1, page >= totalPages)} {btn('末页', totalPages, page >= totalPages)}
); }