209 lines
14 KiB
TypeScript
209 lines
14 KiB
TypeScript
import { IconRefresh, IconSearch } from '@douyinfe/semi-icons';
|
||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||
import { useDeferredValue, useEffect, useMemo, useState } from 'react';
|
||
import { api } from '../../api/client';
|
||
import type { ReconciliationIssue } from '../../api/types';
|
||
import { InlineError } from '../shared/AsyncState';
|
||
import { QUERY_MEMORY } from '../queryPolicy';
|
||
|
||
const PAGE_SIZE = 50;
|
||
const activeStatuses = new Set(['pending', 'confirmed_source_a', 'confirmed_source_b']);
|
||
const reviewStatuses = [
|
||
{ value: 'pending', label: '待处理' },
|
||
{ value: 'confirmed_source_a', label: '确认来源 A' },
|
||
{ value: 'confirmed_source_b', label: '确认来源 B' },
|
||
{ value: 'no_action', label: '无需处理' },
|
||
{ value: 'fixed', label: '已修复' }
|
||
];
|
||
|
||
function statusLabel(status: string) {
|
||
return {
|
||
pending: '待处理',
|
||
confirmed_source_a: '确认来源 A',
|
||
confirmed_source_b: '确认来源 B',
|
||
no_action: '无需处理',
|
||
fixed: '已修复',
|
||
recovered: '已恢复'
|
||
}[status] ?? status;
|
||
}
|
||
|
||
function severityLabel(severity: string) {
|
||
return { critical: '严重', major: '重要', minor: '一般' }[severity] ?? severity;
|
||
}
|
||
|
||
function ruleLabel(rule: string) {
|
||
return {
|
||
DUPLICATE_PLATE: '重复车牌',
|
||
DUPLICATE_PHONE: '重复终端',
|
||
UNBOUND_SOURCE: '来源未绑定',
|
||
SOURCE_MISSING: '主车无来源',
|
||
POSITION_DRIFT: '位置漂移',
|
||
MILEAGE_REVERSE: '里程倒退',
|
||
MILEAGE_JUMP: '里程跳变',
|
||
MILEAGE_SOURCE_DIVERGENCE: '里程来源差异',
|
||
FLEET_COUNT_MISMATCH: '车辆总数不一致',
|
||
BUSINESS_SCOPE_UNBOUND: '业务车辆未绑定',
|
||
AUTH_SCOPE_BUSINESS_MISMATCH: '授权与业务范围不一致'
|
||
}[rule] ?? rule;
|
||
}
|
||
|
||
function fmt(value?: string) {
|
||
if (!value) return '—';
|
||
const parsed = new Date(value.replace(' ', 'T'));
|
||
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString('zh-CN', { hour12: false });
|
||
}
|
||
|
||
function evidenceValue(value: unknown) {
|
||
if (value == null || value === '') return '—';
|
||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||
return JSON.stringify(value, null, 2);
|
||
}
|
||
|
||
function ReconciliationDetail({ issue, onClose }: { issue: ReconciliationIssue; onClose: () => void }) {
|
||
const queryClient = useQueryClient();
|
||
const [status, setStatus] = useState(issue.status === 'recovered' ? 'pending' : issue.status);
|
||
const [note, setNote] = useState(issue.resolutionNote ?? '');
|
||
useEffect(() => {
|
||
setStatus(issue.status === 'recovered' ? 'pending' : issue.status);
|
||
setNote(issue.resolutionNote ?? '');
|
||
}, [issue.id, issue.resolutionNote, issue.status]);
|
||
const save = useMutation({
|
||
mutationFn: () => api.updateReconciliationIssue(issue.id, { version: issue.version, status, note: note.trim() }),
|
||
onSuccess: async (updated) => {
|
||
queryClient.setQueryData(['reconciliation-detail', issue.id], updated);
|
||
await Promise.all([
|
||
queryClient.invalidateQueries({ queryKey: ['reconciliation-summary'] }),
|
||
queryClient.invalidateQueries({ queryKey: ['reconciliation-issues'] })
|
||
]);
|
||
}
|
||
});
|
||
const requiresNote = status !== 'pending';
|
||
return <aside className="v2-reconcile-detail" aria-label="差异证据与处置">
|
||
<header>
|
||
<div><span className={`is-${issue.severity}`}>{severityLabel(issue.severity)}</span><strong>{issue.title}</strong><small>{ruleLabel(issue.ruleCode)}</small></div>
|
||
<button type="button" onClick={onClose} aria-label="关闭差异详情">×</button>
|
||
</header>
|
||
<div className="v2-reconcile-detail-body">
|
||
<section className="v2-reconcile-identity">
|
||
<div><small>车辆</small><strong>{issue.plate || '未登记车牌'}</strong><span>{issue.vin || '非单车差异'}</span></div>
|
||
<div><small>来源</small><strong>{[issue.protocolA, issue.protocolB].filter(Boolean).join(' ↔ ') || '平台口径'}</strong><span>累计发现 {issue.occurrenceCount.toLocaleString('zh-CN')} 次</span></div>
|
||
<div><small>时间</small><strong>{fmt(issue.lastSeenAt)}</strong><span>首次 {fmt(issue.firstSeenAt)}</span></div>
|
||
</section>
|
||
<p className="v2-reconcile-summary">{issue.summary}</p>
|
||
<section className="v2-reconcile-evidence">
|
||
<header><strong>规则证据</strong><span>保留来源值、时间与影响对象;未知来源不自动判对错</span></header>
|
||
<dl>{Object.entries(issue.evidence ?? {}).map(([key, value]) => <div key={key}><dt>{key}</dt><dd><pre>{evidenceValue(value)}</pre></dd></div>)}</dl>
|
||
</section>
|
||
<section className="v2-reconcile-review">
|
||
<header><strong>复核结论</strong><span>版本 v{issue.version}</span></header>
|
||
<label><span>处置状态</span><select value={status} onChange={(event) => setStatus(event.target.value)}>{reviewStatuses.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></label>
|
||
<label><span>说明{requiresNote ? '(必填)' : '(可选)'}</span><textarea value={note} maxLength={500} onChange={(event) => setNote(event.target.value)} placeholder="记录核对来源、原始值、责任人或修复结果" /></label>
|
||
<button type="button" disabled={save.isPending || (requiresNote && !note.trim())} onClick={() => save.mutate()}>{save.isPending ? '正在保存…' : '保存复核结论'}</button>
|
||
{save.isError ? <p role="alert">{save.error instanceof Error ? save.error.message : '保存失败'}</p> : null}
|
||
</section>
|
||
<section className="v2-reconcile-actions">
|
||
<header><strong>处理履历</strong><span>{issue.actions?.length ?? 0} 条</span></header>
|
||
{issue.actions?.length ? <ol>{issue.actions.map((action) => <li key={action.id}><i /><div><strong>{statusLabel(action.toStatus)}</strong><p>{action.note || action.action}</p><span>{action.actor} · {fmt(action.createdAt)}</span></div></li>)}</ol> : <p>暂无处理记录。</p>}
|
||
</section>
|
||
</div>
|
||
</aside>;
|
||
}
|
||
|
||
export default function ReconciliationCenter() {
|
||
const [keyword, setKeyword] = useState('');
|
||
const deferredKeyword = useDeferredValue(keyword.trim());
|
||
const [status, setStatus] = useState('active');
|
||
const [severity, setSeverity] = useState('all');
|
||
const [ruleCode, setRuleCode] = useState('all');
|
||
const [offset, setOffset] = useState(0);
|
||
const [selectedID, setSelectedID] = useState('');
|
||
useEffect(() => setOffset(0), [deferredKeyword, ruleCode, severity, status]);
|
||
|
||
const summary = useQuery({
|
||
queryKey: ['reconciliation-summary', 30],
|
||
queryFn: ({ signal }) => api.reconciliationSummary(30, signal),
|
||
staleTime: 60_000,
|
||
gcTime: QUERY_MEMORY.summaryGcTime
|
||
});
|
||
const issues = useQuery({
|
||
queryKey: ['reconciliation-issues', deferredKeyword, ruleCode, severity, status, offset],
|
||
queryFn: ({ signal }) => api.reconciliationIssues({
|
||
keyword: deferredKeyword, ruleCode, severity, status, limit: PAGE_SIZE, offset
|
||
}, signal),
|
||
staleTime: 20_000,
|
||
gcTime: QUERY_MEMORY.highVolumeGcTime
|
||
});
|
||
const detail = useQuery({
|
||
queryKey: ['reconciliation-detail', selectedID],
|
||
queryFn: ({ signal }) => api.reconciliationIssue(selectedID, signal),
|
||
enabled: Boolean(selectedID),
|
||
staleTime: 10_000,
|
||
gcTime: QUERY_MEMORY.optionGcTime
|
||
});
|
||
const maxTrend = useMemo(() => {
|
||
let result = 1;
|
||
for (const item of summary.data?.trend ?? []) result = Math.max(result, item.active, item.new, item.recovered);
|
||
return result;
|
||
}, [summary.data?.trend]);
|
||
const refresh = () => Promise.all([summary.refetch(), issues.refetch(), selectedID ? detail.refetch() : Promise.resolve()]);
|
||
const data = summary.data;
|
||
const page = issues.data;
|
||
const activeCount = page?.items.filter((item) => activeStatuses.has(item.status)).length ?? 0;
|
||
|
||
return <section className="v2-reconcile-center">
|
||
<header className="v2-reconcile-heading">
|
||
<div><small>每日自动对账</small><strong>数据差异中心</strong><span>自动发现、相同问题去重、恢复后闭环;复核人员只确认证据,不凭空修改原始数据。</span></div>
|
||
<button type="button" onClick={refresh} disabled={summary.isFetching || issues.isFetching}><IconRefresh />刷新结果</button>
|
||
</header>
|
||
{summary.isError ? <InlineError message={summary.error.message} onRetry={() => summary.refetch()} /> : null}
|
||
<div className="v2-reconcile-kpis">
|
||
<article className="is-active"><small>活跃差异</small><strong>{data?.active.toLocaleString('zh-CN') ?? '—'}</strong><span>待处理与已确认来源</span></article>
|
||
<article><small>待复核</small><strong>{data?.pending.toLocaleString('zh-CN') ?? '—'}</strong><span>尚未形成结论</span></article>
|
||
<article><small>已确认来源</small><strong>{data?.confirmed.toLocaleString('zh-CN') ?? '—'}</strong><span>保留为活跃差异</span></article>
|
||
<article><small>自动恢复</small><strong>{data?.recovered.toLocaleString('zh-CN') ?? '—'}</strong><span>检测不再命中</span></article>
|
||
<article className={data?.overSla ? 'is-overdue' : ''}><small>超过 24 小时</small><strong>{data?.overSla.toLocaleString('zh-CN') ?? '—'}</strong><span>需要优先跟进</span></article>
|
||
</div>
|
||
<div className="v2-reconcile-layout">
|
||
<div className="v2-reconcile-main">
|
||
<div className="v2-reconcile-toolbar">
|
||
<label className="v2-reconcile-search"><IconSearch /><input aria-label="搜索差异车辆或规则" value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="车牌、VIN、标题或说明" /></label>
|
||
<select aria-label="筛选差异状态" value={status} onChange={(event) => setStatus(event.target.value)}>
|
||
<option value="active">活跃差异</option><option value="pending">待处理</option><option value="confirmed_source_a">确认来源 A</option><option value="confirmed_source_b">确认来源 B</option><option value="recovered">已恢复</option><option value="fixed">已修复</option><option value="no_action">无需处理</option><option value="all">全部状态</option>
|
||
</select>
|
||
<select aria-label="筛选严重程度" value={severity} onChange={(event) => setSeverity(event.target.value)}>
|
||
<option value="all">全部等级</option><option value="critical">严重</option><option value="major">重要</option><option value="minor">一般</option>
|
||
</select>
|
||
<select aria-label="筛选差异规则" value={ruleCode} onChange={(event) => setRuleCode(event.target.value)}>
|
||
<option value="all">全部规则</option>{data?.byRule.map((item) => <option key={item.name} value={item.name}>{ruleLabel(item.name)} · {item.count}</option>)}
|
||
</select>
|
||
</div>
|
||
{issues.isError ? <InlineError message={issues.error.message} onRetry={() => issues.refetch()} /> : null}
|
||
<div className="v2-reconcile-table-wrap">
|
||
<table className="v2-reconcile-table"><thead><tr><th>等级</th><th>差异 / 规则</th><th>车辆</th><th>来源</th><th>最近发现</th><th>状态</th></tr></thead>
|
||
<tbody>{page?.items.map((item) => <tr key={item.id} role="button" tabIndex={0} className={selectedID === item.id ? 'is-selected' : ''} onClick={() => setSelectedID(item.id)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') setSelectedID(item.id); }}>
|
||
<td><span className={`v2-reconcile-severity is-${item.severity}`}>{severityLabel(item.severity)}</span></td>
|
||
<td><strong>{item.title}</strong><span>{ruleLabel(item.ruleCode)} · 命中 {item.occurrenceCount.toLocaleString('zh-CN')} 次</span></td>
|
||
<td><strong>{item.plate || '非单车差异'}</strong><span>{item.vin || '平台级口径'}</span></td>
|
||
<td><strong>{[item.protocolA, item.protocolB].filter(Boolean).join(' / ') || '平台口径'}</strong><span>{item.category}</span></td>
|
||
<td><strong>{fmt(item.lastSeenAt)}</strong><span>首次 {fmt(item.firstSeenAt)}</span></td>
|
||
<td><span className={`v2-reconcile-status is-${item.status}`}>{statusLabel(item.status)}</span></td>
|
||
</tr>)}</tbody>
|
||
</table>
|
||
{issues.isPending ? <p className="v2-reconcile-empty">正在读取差异队列…</p> : null}
|
||
{!issues.isPending && !page?.items.length ? <p className="v2-reconcile-empty">当前筛选条件下没有差异。</p> : null}
|
||
</div>
|
||
<footer className="v2-reconcile-pagination"><span>共 {(page?.total ?? 0).toLocaleString('zh-CN')} 条 · 本页 {activeCount} 条活跃</span><div><button type="button" disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}>上一页</button><button type="button" disabled={offset + PAGE_SIZE >= (page?.total ?? 0)} onClick={() => setOffset(offset + PAGE_SIZE)}>下一页</button></div></footer>
|
||
</div>
|
||
<aside className="v2-reconcile-trend">
|
||
<header><strong>近 30 天趋势</strong><span>最近运行 {fmt(data?.lastRunAt)}</span></header>
|
||
<div>{data?.trend.slice(-14).map((item) => <article key={item.date}><time>{item.date.slice(5)}</time><div title={`存量 ${item.active},新增 ${item.new},恢复 ${item.recovered}`}><i className="is-active" style={{ width: `${Math.max(2, item.active / maxTrend * 100)}%` }} /><i className="is-new" style={{ width: `${Math.max(0, item.new / maxTrend * 100)}%` }} /><i className="is-recovered" style={{ width: `${Math.max(0, item.recovered / maxTrend * 100)}%` }} /></div><b>{item.active}</b></article>)}</div>
|
||
{!data?.trend.length ? <p>完成首次每日检测后显示趋势。</p> : null}
|
||
<footer><span><i className="is-active" />存量</span><span><i className="is-new" />新增</span><span><i className="is-recovered" />恢复</span></footer>
|
||
</aside>
|
||
{selectedID && detail.isPending ? <aside className="v2-reconcile-detail"><div className="v2-reconcile-empty">正在读取证据…</div></aside> : null}
|
||
{selectedID && detail.isError ? <aside className="v2-reconcile-detail"><InlineError message={detail.error.message} onRetry={() => detail.refetch()} /></aside> : null}
|
||
{detail.data ? <ReconciliationDetail issue={detail.data} onClose={() => setSelectedID('')} /> : null}
|
||
</div>
|
||
</section>;
|
||
}
|