Files
Profeto/frontend/src/admin/pages/DataPipeline.tsx
T
shangfangjian f71f29b4cb fix(frontend): 按审计报告修复全部17项问题,数据层迁至 src/api/
P0(4): 假数据清除(avg_latency_ms:2400→null,无端点字段改null)、假进度条改诚实的不确定态、
  公共页不再反向依赖 admin(api/public.ts)、PredictProgress 重写
P1(6): 23处 any 归零(对齐后端 Pydantic 契约新增 PredictionMatchRef/HealthProbe 等)、
  a11y(aria-live 0→4,htmlFor 1→17,aria-describedby/invalid 补齐)、App.tsx 抽 SiteLayout、
  路由级 lazy+代码分割(首屏 315KB→238KB)、index.html 补 SEO/favicon/OG、死代码清理(AdminIcon 抽出)
P2(7): Login/index.css 裸色值令牌化、groupByDate useMemo、滚动监听统一、原生控件基元化、
  useLeagues 静默失败补告警、路由级 ErrorBoundary

另修复审计未列问题:
- Monitoring todos 过滤器 t!==false 放行 null 导致整页崩溃 → Boolean(t) 真值过滤
- Collection 渲染期 Date.now()(react-hooks/purity 捕获)→ 计时器 effect
- bg-press-wash/60 透明度修饰符静默失效 → RGB 三元组 + 构建期令牌守卫(下个提交接入)

工程化:数据层 dal/api/types(1047行)git mv 至 src/api/,admin 留 @deprecated 兼容壳,
  21 个引用方直指新路径;tsconfig 开启 noUnusedLocals/noUnusedParameters(清理9处存量)
2026-09-22 23:45:06 +08:00

201 lines
8.0 KiB
TypeScript

/**
* Admin 后台 - 数据管线管理页(报刊风)
*
* 功能:
* - 采集失败记录列表(可重试)
* - 数据质量检查结果
* - 手动触发质量检查
*/
import { useEffect, useState, useCallback } from 'react'
import {
fetchDataQuality,
runDataQualityCheck,
fetchIngestFailures,
retryIngestFailure,
} from '../../api/dal'
import type { IngestFailureItem, DataQualityCheckItem } from '../../api/dal'
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
import { Button } from '../../components/ui'
export default function DataPipelinePage() {
const [quality, setQuality] = useState<{ failures: IngestFailureItem[]; checks: DataQualityCheckItem[] } | null>(null)
const [failures, setFailures] = useState<IngestFailureItem[]>([])
const [loading, setLoading] = useState(true)
const [running, setRunning] = useState(false)
const [error, setError] = useState<string | null>(null)
const [notice, setNotice] = useState<{ ok: boolean; text: string } | null>(null)
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [q, f] = await Promise.all([fetchDataQuality(), fetchIngestFailures()])
setQuality(q)
setFailures(f)
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
const handleRunCheck = async () => {
setRunning(true)
setNotice(null)
try {
const res = await runDataQualityCheck()
const failed = res.checks.filter(c => !c.passed)
setNotice({
ok: failed.length === 0,
text: failed.length === 0
? '数据质量检查通过'
: `检查完成: ${failed.length} 项未通过`,
})
await load()
} catch {
setNotice({ ok: false, text: '质量检查执行失败' })
} finally {
setRunning(false)
}
}
const handleRetry = async (id: number) => {
setNotice(null)
try {
const res = await retryIngestFailure(id)
setNotice({ ok: true, text: res.message })
await load()
} catch {
setNotice({ ok: false, text: '重试操作失败' })
}
}
const pendingFailures = failures.filter(f => f.status === 'pending' || f.status === 'retrying')
return (
<div className="space-y-6">
<SectionHeader
title="数据管线"
description="采集失败重试、数据质量检查与监控"
action={
<Button size="sm" onClick={handleRunCheck} disabled={running}>
{running ? <><Spinner /> 检查中</> : '运行质量检查'}
</Button>
}
/>
{notice && (
<Alert kind={notice.ok ? 'ok' : 'error'} title={notice.text} onClose={() => setNotice(null)} />
)}
{error && <Alert kind="error" title="加载失败" message={error} onClose={() => setError(null)} />}
{loading && (
<div className="flex justify-center py-12"><Spinner /></div>
)}
{!loading && (
<>
{/* 采集失败记录 */}
<Card>
<CardHeader
title="采集失败记录"
description={pendingFailures.length > 0 ? `${pendingFailures.length} 条待处理` : '暂无待处理失败记录'}
/>
<CardBody className="px-0 sm:px-0">
{failures.length === 0 ? (
<p className="py-8 text-center text-xs text-ink-400">暂无采集失败记录</p>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[640px] text-sm">
<thead>
<tr className="border-b border-ink-200 text-left text-ink-500">
<th className="px-4 py-2 font-medium">来源</th>
<th className="px-4 py-2 font-medium">实体类型</th>
<th className="px-4 py-2 font-medium">错误类型</th>
<th className="px-4 py-2 font-medium">重试次数</th>
<th className="px-4 py-2 font-medium">状态</th>
<th className="px-4 py-2 font-medium">操作</th>
</tr>
</thead>
<tbody>
{failures.map(f => (
<tr key={f.id} className="border-b border-ink-100 hover:bg-paper-100">
<td className="px-4 py-2 text-xs text-ink-700">{f.source}</td>
<td className="px-4 py-2 text-xs text-ink-600">{f.entity_type}</td>
<td className="px-4 py-2 text-xs text-ink-600">{f.error_type}</td>
<td className="px-4 py-2 text-xs tabular-nums text-ink-500">{f.retry_count}</td>
<td className="px-4 py-2">
<Badge status={f.status === 'resolved' ? 'success' : f.status === 'pending' ? 'warning' : 'info'}>
{f.status}
</Badge>
</td>
<td className="px-4 py-2">
{(f.status === 'pending' || f.status === 'retrying') && (
<Button size="sm" onClick={() => handleRetry(f.id)}>
重试
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardBody>
</Card>
{/* 数据质量检查 */}
<Card>
<CardHeader title="数据质量检查" description="最近 20 条检查结果" />
<CardBody className="px-0 sm:px-0">
{quality?.checks.length === 0 ? (
<p className="py-8 text-center text-xs text-ink-400">暂无质量检查记录,点击右上角「运行质量检查」触发</p>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[560px] text-sm">
<thead>
<tr className="border-b border-ink-200 text-left text-ink-500">
<th className="px-4 py-2 font-medium">检查项</th>
<th className="px-4 py-2 font-medium">实体</th>
<th className="px-4 py-2 font-medium">结果</th>
<th className="px-4 py-2 font-medium">严重度</th>
<th className="px-4 py-2 font-medium">时间</th>
</tr>
</thead>
<tbody>
{quality?.checks.map(c => (
<tr key={c.id} className="border-b border-ink-100 hover:bg-paper-100">
<td className="px-4 py-2 text-xs text-ink-700">{c.check_name}</td>
<td className="px-4 py-2 text-xs text-ink-600">{c.entity_type}</td>
<td className="px-4 py-2">
<Badge status={c.passed ? 'success' : 'error'}>
{c.passed ? '通过' : '未通过'}
</Badge>
</td>
<td className="px-4 py-2">
<Badge status={c.severity === 'warning' ? 'warning' : c.severity === 'error' ? 'error' : 'info'}>
{c.severity}
</Badge>
</td>
<td className="px-4 py-2 text-2xs text-ink-400">
{c.checked_at ? new Date(c.checked_at).toLocaleString('zh-CN', { hour12: false }) : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardBody>
</Card>
</>
)}
</div>
)
}