admin-projects/src/pages/ImportMonitor.tsx
wangdl 8663ceba66
Some checks failed
Deploy Admin Frontend / build-and-deploy (push) Failing after 10s
feat: HTTP 层重构 + API 抽离 + 状态管理优化 + 路由修复
- HTTP 层:安装 axios,创建 lib/api-client.ts 统一拦截器
- API 层:创建 33 个 api/*.ts 文件,所有页面迁移完成
- DataPages.tsx 拆分为 10 个独立文件
- message 导入统一为 App.useApp()
- 新增 StaticAntdProvider 全局消息/通知
- 全局 HTTP 错误/成功提示(notification.error/success)
- learning 路由 /learning → /learning/dashboard 修复选中 bug
- 学习会话页面优化(移除 ID 列、加批量删除、后端排序)
- 文档导入页面重构(知识库筛选、批量重新解析)
- 删除旧 service 文件 10 个(admin-api、billing-api 等)
- antd App 包裹根组件

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-01 22:44:51 +08:00

286 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useCallback, useRef } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Table, Tag, Typography, Button, App, Drawer, Space, Select, Popconfirm, Row, Col, Checkbox } from 'antd'
import { ReloadOutlined, RetweetOutlined, ImportOutlined, EyeOutlined } from '@ant-design/icons'
import { importsAdminAPI, knowledgeAPI } from '@/api'
import { importStatusLabels, importStepLabels } from '@/constants/labels'
import dayjs from 'dayjs'
const { Title, Text } = Typography
// 格式化知识库选项:用户 - 名称 (id前8位)
function formatKbLabel(kb: any) {
const user = kb.user?.email || kb.user?.nickname || kb.userId || '?'
const name = kb.title || '未命名'
const shortId = kb.id?.slice(0, 8) || kb.id
return `${user} - ${name} (${shortId})`
}
export default function ImportMonitorPage() {
const { message } = App.useApp()
const qc = useQueryClient()
const [drawerOpen, setDrawerOpen] = useState(false)
const [selectedJob, setSelectedJob] = useState<any>(null)
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([])
const [kbFilter, setKbFilter] = useState<string | undefined>(undefined)
const [retryAllOpen, setRetryAllOpen] = useState(false)
const [showDeleted, setShowDeleted] = useState(false)
const [sortBy, setSortBy] = useState<string>('createdAt')
const [sortOrder, setSortOrder] = useState<string>('descend')
// 知识库远程搜索(防抖)
const [kbSearch, setKbSearch] = useState('')
const kbSearchTimer = useRef<ReturnType<typeof setTimeout>>()
const handleKbSearch = useCallback((val: string) => {
clearTimeout(kbSearchTimer.current)
kbSearchTimer.current = setTimeout(() => setKbSearch(val), 300)
}, [])
// 知识库列表
const { data: kbData, isLoading: kbLoading } = useQuery({
queryKey: ['admin-knowledge-bases', kbSearch],
queryFn: (): Promise<any> => {
const params = new URLSearchParams({ limit: '50' })
if (kbSearch) params.set('search', kbSearch)
return knowledgeAPI.search(params.toString())
},
staleTime: 30_000,
})
const queryParams = new URLSearchParams()
queryParams.set('limit', '200')
if (kbFilter) queryParams.set('kbId', kbFilter)
queryParams.set('sortBy', sortBy)
queryParams.set('sortOrder', sortOrder)
const { data, isLoading } = useQuery({
queryKey: ['admin-imports', 'list', kbFilter, sortBy, sortOrder],
queryFn: (): Promise<any> => importsAdminAPI.list(queryParams.toString()),
})
// 默认过滤已删除 source 的记录
const tableData = showDeleted
? (data?.items || [])
: (data?.items || []).filter((r: any) => !r.sourceDeleted)
const { data: detail } = useQuery({
queryKey: ['admin-import-detail', selectedJob?.id],
queryFn: (): Promise<any> => importsAdminAPI.detail(selectedJob?.id || ''),
enabled: !!selectedJob?.id,
})
const refreshList = () => {
qc.invalidateQueries({ queryKey: ['admin-imports', 'list'] })
}
const handleRetry = async (id: string, sourceDeleted?: boolean) => {
if (sourceDeleted) {
message.warning('关联的资料来源已删除,无法重新解析。请删除此导入记录后重新上传文件。')
return
}
try {
await importsAdminAPI.retry(id)
message.success('已重新入队,等待 Worker 处理')
refreshList()
} catch (err: any) {
const msg = err?.response?.data?.message || err?.message || '重试失败'
message.error(`重试失败: ${msg}`)
}
}
const handleBatchRetry = async () => {
try {
const ids = selectedRowKeys as string[]
const res = await importsAdminAPI.batchRetry(ids as string[])
const { updated, skipped, skippedCount } = res
let msg = `已重新入队 ${updated} 个任务`
if (skippedCount > 0) {
msg += `${skippedCount} 个跳过(源已删除: ${skipped.slice(0, 3).join(', ')}${skipped.length > 3 ? '...' : ''}`
}
message.success(msg)
setSelectedRowKeys([])
refreshList()
} catch (err: any) {
const msg = err?.response?.data?.message || err?.message || '批量重试失败'
message.error(msg)
}
}
const handleRetryAll = async () => {
try {
const allItems = data?.items || []
const retryableIds = allItems
.filter((r: any) => r.status?.startsWith('FAILED') || r.status === 'CLAIMED' || r.status === 'QUEUED')
.map((r: any) => r.id)
if (retryableIds.length === 0) {
message.warning('没有可重新解析的任务')
return
}
const res = await importsAdminAPI.batchRetry(retryableIds)
const { updated, skipped, skippedCount } = res
let msg = `已重新入队 ${updated} 个任务`
if (skippedCount > 0) {
msg += `${skippedCount} 个跳过(源已删除: ${skipped.slice(0, 3).join(', ')}${skipped.length > 3 ? '...' : ''}`
}
message.success(msg)
setSelectedRowKeys([])
setRetryAllOpen(false)
refreshList()
} catch (err: any) {
const msg = err?.response?.data?.message || err?.message || '全部重试失败'
message.error(msg)
setRetryAllOpen(false)
}
}
const statusColor: Record<string, string> = {
QUEUED: 'blue',
CLAIMED: 'cyan',
DOWNLOADING: 'processing',
PARSING: 'processing',
OCR_PROCESSING: 'processing',
VISION_PROCESSING: 'processing',
CLEANING: 'processing',
CHUNKING: 'processing',
EMBEDDING: 'processing',
INDEXING: 'processing',
GENERATING_CANDIDATES: 'processing',
COMPLETED: 'green',
FAILED: 'red',
FAILED_RETRYABLE: 'orange',
FAILED_FINAL: 'red',
}
const columns = [
{ title: '名称', dataIndex: 'sourceName', width: 180, ellipsis: true, sorter: true, sortOrder: sortBy === 'sourceName' ? (sortOrder as any) : undefined,
render: (v: string, r: any) => (
<Space size={4}>
<Text ellipsis style={{ maxWidth: 140 }}>{v}</Text>
{r.sourceDeleted && <Tag color="error" style={{ fontSize: 10 }}></Tag>}
</Space>
),
},
{ title: '类型', dataIndex: 'sourceType', width: 80, sorter: true, sortOrder: sortBy === 'sourceType' ? (sortOrder as any) : undefined, render: (s: string) => (s === 'file' ? '文件' : s === 'url' ? '链接' : s === 'text' ? '文本' : s) },
{ title: '状态', dataIndex: 'status', width: 100, sorter: true, sortOrder: sortBy === 'status' ? (sortOrder as any) : undefined, render: (s: string) => <Tag color={statusColor[s] || 'default'}>{importStatusLabels[s] || s}</Tag> },
{ title: '进度', dataIndex: 'progress', width: 70, sorter: true, sortOrder: sortBy === 'progress' ? (sortOrder as any) : undefined, render: (v: number) => `${v || 0}%` },
{ title: '重试', dataIndex: 'retryCount', width: 60, align: 'center' as const, sorter: true, sortOrder: sortBy === 'retryCount' ? (sortOrder as any) : undefined },
{ title: '错误', dataIndex: 'errorMessage', width: 180, ellipsis: true, render: (v: string) => v ? <Text type="danger" style={{ fontSize: 12 }}>{v}</Text> : '-' },
{ title: '时间', dataIndex: 'createdAt', width: 130, sorter: true, sortOrder: sortBy === 'createdAt' ? (sortOrder as any) : undefined, render: (d: string) => dayjs(d).format('MM-DD HH:mm') },
{
title: '操作', width: 100,
render: (_: any, r: any) => (
<Space size="small">
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => { setSelectedJob(r); setDrawerOpen(true) }}></Button>
{(r.status?.startsWith('FAILED') || r.status === 'CLAIMED' || r.status === 'QUEUED') && <Button type="link" size="small" icon={<RetweetOutlined />} onClick={() => handleRetry(r.id, r.sourceDeleted)}></Button>}
</Space>
),
},
]
const stepCols = [
{ title: '步骤', dataIndex: 'step', width: 120, render: (s: string) => importStepLabels[s] || importStatusLabels[s] || s },
{ title: '状态', dataIndex: 'status', width: 80, render: (s: string) => <Tag color={s === 'COMPLETED' || s === 'completed' ? 'green' : s?.includes('FAILED') || s === 'failed' ? 'red' : 'blue'}>{importStepLabels[s] || importStatusLabels[s] || s}</Tag> },
{ title: '详情', dataIndex: 'detail', ellipsis: true },
{ title: '开始', dataIndex: 'startedAt', width: 130, render: (d: string) => d ? dayjs(d).format('HH:mm:ss') : '-' },
{ title: '完成', dataIndex: 'completedAt', width: 130, render: (d: string) => d ? dayjs(d).format('HH:mm:ss') : '-' },
]
const kbOptions = (kbData?.items || []).map((kb: any) => ({
value: kb.id,
label: formatKbLabel(kb),
}))
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Title level={5} style={{ margin: 0 }}><ImportOutlined /> </Title>
<Space>
<Button icon={<ReloadOutlined />} onClick={() => qc.invalidateQueries({ queryKey: ['admin-imports', 'list'] })}></Button>
</Space>
</div>
<Row style={{ marginBottom: 12 }} gutter={12}>
<Col>
<Select
allowClear
showSearch
filterOption={false}
placeholder="搜索知识库(名称/ID/用户)"
style={{ width: 320 }}
value={kbFilter}
loading={kbLoading}
onChange={(val) => { setKbFilter(val); setSelectedRowKeys([]) }}
onSearch={handleKbSearch}
options={kbOptions}
notFoundContent={kbLoading ? '搜索中...' : '无匹配知识库'}
/>
</Col>
<Col>
<Checkbox checked={showDeleted} onChange={(e) => setShowDeleted(e.target.checked)}>
</Checkbox>
</Col>
<Col>
<Button
disabled={selectedRowKeys.length === 0}
icon={<RetweetOutlined />}
onClick={handleBatchRetry}
>
({selectedRowKeys.length})
</Button>
</Col>
<Col>
<Popconfirm
title="确定重新解析当前筛选的所有任务?"
description="将把所有 FAILED / QUEUED / CLAIMED 任务重新入队"
open={retryAllOpen}
onOpenChange={setRetryAllOpen}
onConfirm={handleRetryAll}
okText="确定"
cancelText="取消"
>
<Button icon={<RetweetOutlined />}></Button>
</Popconfirm>
</Col>
</Row>
<Table
dataSource={tableData}
columns={columns}
rowKey="id"
loading={isLoading}
pagination={{ pageSize: 30 }}
size="small"
rowSelection={{
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys),
}}
onChange={(_pagination, _filters, sorter: any) => {
if (sorter.order) {
setSortBy(sorter.field || 'createdAt')
setSortOrder(sorter.order)
} else {
setSortBy('createdAt')
setSortOrder('descend')
}
}}
/>
<Drawer title={selectedJob?.sourceName || '导入详情'} open={drawerOpen} onClose={() => setDrawerOpen(false)} width={700}>
{detail && (
<>
<Title level={5} style={{ fontSize: 14 }}>Job: {detail.job?.id}</Title>
<Text>: <Tag color={statusColor[detail.job?.status]}>{importStatusLabels[detail.job?.status] || detail.job?.status}</Tag></Text>
<Text style={{ marginLeft: 16 }}>: {detail.job?.progress || 0}%</Text>
<Text style={{ marginLeft: 16 }}>: {detail.job?.retryCount}/{detail.job?.maxRetries}</Text>
{detail.job?.errorMessage && <div style={{ marginTop: 8 }}><Text type="danger">{detail.job?.errorMessage}</Text></div>}
<Title level={5} style={{ fontSize: 14, marginTop: 16 }}> ({detail.steps?.length || 0})</Title>
<Table dataSource={detail.steps || []} columns={stepCols} rowKey="id" pagination={false} size="small" />
</>
)}
</Drawer>
</div>
)
}