admin-projects/src/pages/ImportMonitor.tsx
wangdl 91698a2f3b
All checks were successful
Deploy Admin Frontend / build-and-deploy (push) Successful in 13s
refactor: TypeScript 类型体系重构 + 状态管理 + 环境配置
## 类型安全
- tsconfig: 开启 strict: true(豁免 noImplicitAny + strictNullChecks,待后续收紧)
- 新建 15 个领域类型文件 (src/types/),共 ~80 个类型定义
- 统一 barrel export (src/types/index.ts)
- 消除 9 个 [key: string]: any 接口
- 消除 22 个 API data: any 参数 → 替换为具体 DTO
- 消除 36 个 Promise<any> → 使用具体泛型
- 消除重复 PaginatedResponse,统一使用 PaginatedResult
- API 内联类型全部迁至 types/

## 状态管理
- 引入 Zustand (zustand + persist)
- 新建 src/stores/ui-store.ts — 侧边栏折叠状态持久化
- AdminLayout 接入 useUIStore

## 环境配置
- 新建 .env.example — 环境变量模板
- 新建 .env.production — 生产架构文档
- 新建 src/vite-env.d.ts — IDE 类型声明

## 通用 UI 类型
- 新建 src/types/ui.ts — TypedColumn<T>, SortState, FilterState 等

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-04 11:02:52 +08:00

224 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 type { TablePaginationConfig } from 'antd'
import type { FilterValue, SorterResult } from 'antd/es/table/interface'
import type { KnowledgeBase, ImportJob, ImportJobDetail, ImportStepLog } from '@/types'
import dayjs from 'dayjs'
const { Title, Text } = Typography
function formatKbLabel(kb: KnowledgeBase) {
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<ImportJobDetail | null>(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>>(undefined)
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: () => {
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: () => importsAdminAPI.list(queryParams.toString()),
})
const tableData = showDeleted
? (data?.items || [])
: (data?.items || []).filter((r: ImportJob) => !r.sourceDeleted)
const { data: detail } = useQuery({
queryKey: ['admin-import-detail', selectedJob?.id],
queryFn: () => 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: unknown) {
const e = err as { response?: { data?: { message?: string } }; message?: string }
message.error(`重试失败: ${e?.response?.data?.message || e?.message || '未知错误'}`)
}
}
const handleBatchRetry = async () => {
try {
const ids = selectedRowKeys as string[]
const res = await importsAdminAPI.batchRetry(ids)
const { updated, skipped, skippedCount } = res as { updated: number; skipped: string[]; skippedCount: number }
let msg = `已重新入队 ${updated} 个任务`
if (skippedCount > 0) {
msg += `${skippedCount} 个跳过(源已删除: ${skipped.slice(0, 3).join(', ')}${skipped.length > 3 ? '...' : ''}`
}
message.success(msg)
setSelectedRowKeys([])
refreshList()
} catch (err: unknown) {
const e = err as { response?: { data?: { message?: string } }; message?: string }
message.error(e?.response?.data?.message || e?.message || '批量重试失败')
}
}
const handleRetryAll = async () => {
try {
const allItems = data?.items || []
const retryableIds = allItems
.filter((r: ImportJob) => r.status?.startsWith('FAILED') || r.status === 'CLAIMED' || r.status === 'QUEUED')
.map((r: ImportJob) => r.id)
if (retryableIds.length === 0) {
message.warning('没有可重新解析的任务')
return
}
const res = await importsAdminAPI.batchRetry(retryableIds)
const { updated, skipped, skippedCount } = res as { updated: number; skipped: string[]; skippedCount: number }
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: unknown) {
const e = err as { response?: { data?: { message?: string } }; message?: string }
message.error(e?.response?.data?.message || e?.message || '全部重试失败')
}
}
const statusColor: Record<string, string> = {
COMPLETED: 'green', QUEUED: 'default', CLAIMED: 'blue',
DOWNLOADING: 'processing', PARSING: 'processing', CHUNKING: 'processing',
EMBEDDING: 'processing', INDEXING: 'processing', GENERATING_CANDIDATES: 'processing',
FAILED: 'red', FAILED_RETRYABLE: 'orange', CANCELLED: 'default',
}
const columns = [
{ title: '名称', dataIndex: 'sourceName', width: 180, ellipsis: true, sorter: true, sortOrder: sortBy === 'sourceName' ? (sortOrder as 'ascend' | 'descend') : undefined,
render: (v: string, r: ImportJob) => (
<a onClick={() => { setSelectedJob(r); setDrawerOpen(true) }} style={{ fontSize: 12 }}>{v || '—'}</a>
) },
{ title: '类型', dataIndex: 'sourceType', width: 80, sorter: true, sortOrder: sortBy === 'sourceType' ? (sortOrder as 'ascend' | 'descend') : undefined, render: (s: string) => (s === 'file' ? '文件' : s === 'url' ? '链接' : s === 'text' ? '文本' : s) },
{ title: '状态', dataIndex: 'status', width: 100, sorter: true, sortOrder: sortBy === 'status' ? (sortOrder as 'ascend' | 'descend') : 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 'ascend' | 'descend') : undefined, render: (v: number) => `${v || 0}%` },
{ title: '重试', dataIndex: 'retryCount', width: 60, align: 'center' as const, sorter: true, sortOrder: sortBy === 'retryCount' ? (sortOrder as 'ascend' | 'descend') : 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 'ascend' | 'descend') : undefined, render: (d: string) => dayjs(d).format('MM-DD HH:mm') },
{ title: '操作', width: 160,
render: (_: unknown, r: ImportJob) => (
<Space size={0}>
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => { setSelectedJob(r); setDrawerOpen(true) }}></Button>
<Button type="link" size="small" icon={<RetweetOutlined />} disabled={r.sourceDeleted} onClick={() => handleRetry(r.id, r.sourceDeleted)}></Button>
</Space>
) },
]
const stepColumns = [
{ 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: KnowledgeBase) => ({
value: kb.id,
label: formatKbLabel(kb),
}))
return (
<div style={{ padding: '0 0 24px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Title level={5} style={{ margin: 0 }}><ImportOutlined /> </Title>
<Space>
<Button icon={<ReloadOutlined />} onClick={refreshList} size="small"></Button>
</Space>
</div>
<Row gutter={[8, 8]} style={{ marginBottom: 16 }}>
<Col>
<Select allowClear showSearch placeholder="知识库" value={kbFilter} onChange={setKbFilter} onSearch={handleKbSearch} filterOption={false} options={kbOptions} loading={kbLoading} style={{ width: 280 }} notFoundContent={kbSearch ? '未找到' : '输入关键词搜索'} />
</Col>
<Col><Checkbox checked={showDeleted} onChange={e => setShowDeleted(e.target.checked)}></Checkbox></Col>
<Col><Popconfirm title="确认重试所有可重试任务?" open={retryAllOpen} onOpenChange={setRetryAllOpen} onConfirm={handleRetryAll}><Button size="small" icon={<RetweetOutlined />}></Button></Popconfirm></Col>
{selectedRowKeys.length > 0 && <Col><Button size="small" type="primary" onClick={handleBatchRetry}> ({selectedRowKeys.length})</Button></Col>}
</Row>
<Table
rowKey="id"
columns={columns}
dataSource={tableData}
loading={isLoading}
rowSelection={{ selectedRowKeys, onChange: keys => setSelectedRowKeys(keys) }}
pagination={false}
size="small"
onChange={(_pagination: TablePaginationConfig, _filters: Record<string, FilterValue>, sorter: SorterResult<ImportJob> | SorterResult<ImportJob>[]) => {
const s = Array.isArray(sorter) ? sorter[0] : sorter
if (s.columnKey) setSortBy(String(s.columnKey))
setSortOrder(s.order || 'descend')
}}
/>
<Drawer title={selectedJob?.sourceName || '导入详情'} open={drawerOpen} onClose={() => { setDrawerOpen(false); setSelectedJob(null) }} width={700}>
{detail ? (
<>
<Space wrap style={{ marginBottom: 16 }}>
<Tag color={statusColor[detail.status] || 'default'}>{importStatusLabels[detail.status] || detail.status}</Tag>
<Text type="secondary">: {detail.progress || 0}%</Text>
<Text type="secondary">: {detail.retryCount}</Text>
<Text type="secondary">: {detail.kbName || detail.kbId?.slice(0, 8)}</Text>
<Text type="secondary">: {dayjs(detail.createdAt).format('YYYY-MM-DD HH:mm:ss')}</Text>
</Space>
{detail.errorMessage && <Text type="danger" style={{ display: 'block', marginBottom: 12 }}>: {detail.errorMessage}</Text>}
<Title level={5}></Title>
<Table rowKey="id" columns={stepColumns} dataSource={(detail.steps || []) as ImportStepLog[]} pagination={false} size="small" />
</>
) : <Text type="secondary">...</Text>}
</Drawer>
</div>
)
}