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(null) const [selectedRowKeys, setSelectedRowKeys] = useState([]) const [kbFilter, setKbFilter] = useState(undefined) const [retryAllOpen, setRetryAllOpen] = useState(false) const [showDeleted, setShowDeleted] = useState(false) const [sortBy, setSortBy] = useState('createdAt') const [sortOrder, setSortOrder] = useState('descend') // 知识库远程搜索(防抖) const [kbSearch, setKbSearch] = useState('') const kbSearchTimer = useRef>() 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 => { 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 => 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 => 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 = { 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) => ( {v} {r.sourceDeleted && 源已删} ), }, { 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) => {importStatusLabels[s] || s} }, { 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 ? {v} : '-' }, { 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) => ( {(r.status?.startsWith('FAILED') || r.status === 'CLAIMED' || r.status === 'QUEUED') && } ), }, ] const stepCols = [ { title: '步骤', dataIndex: 'step', width: 120, render: (s: string) => importStepLabels[s] || importStatusLabels[s] || s }, { title: '状态', dataIndex: 'status', width: 80, render: (s: string) => {importStepLabels[s] || importStatusLabels[s] || s} }, { 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 (
<ImportOutlined /> 文档导入