外包风险评估系统:领域引擎+前端+服务端持久化与生产部署
- 确定性领域引擎(分类/评分/分级/红线/费用/裁决)+LLM(通义千问)语言理解 - 6步评估向导、服务端草稿持久化(跨设备/编辑草稿保护) - 工作流(草稿→风控→管理层)、RBAC、报告导出、校准、客户/费率/红线/最低工资管理 - 专业图标体系替换全部emoji、看板美化 - 生产化:API_BASE可配置(同源反代)、auth密钥惰性读取修复RBAC - 444单测+204前端测试+51 e2e
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user