8bac14ef44
根因:预测准确度卡的偏差来自历史回填项目的预测vs实际,属既成事实, 不会因校准改变;且原 apply 公式 next=current+dev 会累加,反复点越推越高。 修复: - 校准建议/应用均基于未校准原始基准(env/默认)计算,保证幂等 - GET /api/calibration 返回 uncalibratedBase 与 calibrated 标志 - 卡片:已校准时显示「已按当前偏差校准:基准X%」绿色状态,不再出现按钮; 未校准时按钮明示「X% → Y%」 - 补充说明:历史偏差不会因校准改变,校准仅调整后续承接建议阈值
844 lines
44 KiB
TypeScript
844 lines
44 KiB
TypeScript
/**
|
||
* 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>
|
||
);
|
||
}
|