All checks were successful
Deploy Admin Frontend / build-and-deploy (push) Successful in 13s
## 类型安全 - 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>
224 lines
12 KiB
TypeScript
224 lines
12 KiB
TypeScript
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>
|
||
)
|
||
}
|