import { useCallback, useEffect, useRef, useState } from 'react' import { useCollapsedActions } from '../../hooks' import { TableActions, actionCellProps } from '../../components/TableActions/TableActions' import { Table, Tag, Space, Button, Form, Input, Select, Progress, Checkbox, message, Modal, Drawer, Tabs, Empty, Tooltip, Popconfirm, Collapse, InputNumber, Row, Col, Card } from 'antd' import { PlayCircleOutlined, PauseCircleOutlined, PlusOutlined, EditOutlined, DeleteOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined, ExperimentOutlined, SyncOutlined, ClearOutlined, CopyOutlined } from '@ant-design/icons' import axios, { type AxiosResponse } from 'axios' import AppLayout from '../../components/AppLayout/AppLayout' import ScrollbarOverlay from '../../components/Scrollbar/ScrollbarOverlay' import { formatDateTimeZhCN } from '../../utils/datetime' import { useWebSocket } from '../../hooks/useWebSocket' interface BuiltInDataSource { id: number source: string name: string module: string priority: string frequency: string endpoint?: string is_active: boolean collector_class: string last_run: string | null last_run_at?: string | null last_status?: string | null last_records_processed?: number | null data_count?: number is_running: boolean task_id: number | null progress: number | null phase?: string | null records_processed: number | null total_records: number | null } interface TaskTrackerState { task_id: number | null is_running: boolean progress: number phase: string | null status?: string | null records_processed?: number | null total_records?: number | null error_message?: string | null } interface BulkProgressItem { task_id: number | null progress: number status: string | null phase: string | null is_running: boolean } interface BulkProgressBatch { sourceIds: number[] items: Record } type TriggerDatasourceConflict = { reason?: string message?: string progress?: number | null phase?: string | null records_processed?: number | null total_records?: number | null } type TriggerDatasourceResult = | { ok: true; response: AxiosResponse } | { ok: false; status: number; detail?: string | TriggerDatasourceConflict } type DatasourceTaskStatus = { is_running: boolean task_id?: number | null progress?: number | null phase?: string | null records_processed?: number | null total_records?: number | null status?: string | null } const phaseProgressRanges: Record = { queued: [0, 0], fetching: [0, 30], transforming: [30, 50], saving: [50, 100], completed: [100, 100], success: [100, 100], failed: [0, 0], cancelled: [0, 0], } function mapPhaseProgressToOverall( phase?: string | null, progress?: number | null, status?: string | null, ): number { if (status && status !== 'running') { return status === 'success' ? 100 : 0 } const normalizedPhase = phase || 'queued' const [start, end] = phaseProgressRanges[normalizedPhase] ?? [0, 100] if (start === end) return start const boundedProgress = Math.max(0, Math.min(100, progress ?? 0)) return start + ((end - start) * boundedProgress) / 100 } function finalizeBulkProgressBatch(batch: BulkProgressBatch | null): BulkProgressBatch | null { if (!batch || batch.sourceIds.length === 0) { return null } return batch } function resolveTerminalBatchItem( sourceId: number, batch: BulkProgressBatch, builtInSources: BuiltInDataSource[], taskProgress: Record, ): BulkProgressItem | null { const currentItem = batch.items[sourceId] const source = builtInSources.find((item) => item.id === sourceId) const trackedTask = taskProgress[sourceId] const isRunning = trackedTask?.is_running ?? source?.is_running ?? currentItem?.is_running ?? false if (isRunning) { return null } const status = trackedTask?.status ?? source?.last_status ?? currentItem?.status ?? null if (!status || status === 'running') { return null } return { task_id: trackedTask?.task_id ?? currentItem?.task_id ?? source?.task_id ?? null, progress: status === 'success' ? 100 : trackedTask?.progress ?? source?.progress ?? currentItem?.progress ?? 0, is_running: false, phase: trackedTask?.phase ?? source?.phase ?? currentItem?.phase ?? null, status, } } interface WebSocketTaskMessage { type: string channel?: string payload?: { datasource_id?: number task_id?: number | null progress?: number | null phase?: string | null status?: string | null records_processed?: number | null total_records?: number | null error_message?: string | null } } interface CustomDataSource { id: number name: string description: string | null source_type: string endpoint: string auth_type: string is_active: boolean created_at: string updated_at: string | null } interface EditableDataSourceConfig { id: number name: string description: string | null source_type: string endpoint: string auth_type: string auth_config: Record headers: Record config: Record is_active?: boolean } interface ViewDataSource { id: number name: string description: string | null source_type: string endpoint: string auth_type: string headers: Record config: Record collector_class: string module: string priority: string frequency: string } function DataSources() { const [messageApi, contextHolder] = message.useMessage() const [modal, modalContextHolder] = Modal.useModal() const [activeTab, setActiveTab] = useState('builtin') const [builtInSources, setBuiltInSources] = useState([]) const [customSources, setCustomSources] = useState([]) const [loading, setLoading] = useState(false) const [drawerVisible, setDrawerVisible] = useState(false) const [viewDrawerVisible, setViewDrawerVisible] = useState(false) const [editingConfig, setEditingConfig] = useState(null) const [builtinEditingSource, setBuiltinEditingSource] = useState(null) const [viewingSource, setViewingSource] = useState(null) const [recordCount, setRecordCount] = useState(0) const [testing, setTesting] = useState(false) const [triggerAllLoading, setTriggerAllLoading] = useState(false) const [forceTriggerAll, setForceTriggerAll] = useState(false) const [testResult, setTestResult] = useState(null) const builtinTableRegionRef = useRef(null) const customTableRegionRef = useRef(null) const [builtinTableHeight, setBuiltinTableHeight] = useState(360) const [customTableHeight, setCustomTableHeight] = useState(360) const [builtinActionsCollapsed, builtinContainerRef] = useCollapsedActions() const [customActionsCollapsed, customContainerRef] = useCollapsedActions() const [form] = Form.useForm() const headersMapToList = useCallback((headers?: Record | null) => { return Object.entries(headers || {}) .filter(([key, value]) => key && value !== undefined && value !== null && String(value).trim() !== '') .map(([key, value]) => ({ key, value })) }, []) const headersListToMap = useCallback((headers?: Array<{ key?: string; value?: string }> | Record) => { if (!headers) return {} if (!Array.isArray(headers)) return headers return headers.reduce>((acc, item) => { const key = item?.key?.trim() const value = item?.value?.trim() if (!key || value === undefined) return acc acc[key] = value return acc }, {}) }, []) const applyConfigToForm = useCallback((config?: Partial | null) => { form.setFieldsValue({ name: config?.name || '', description: config?.description || '', source_type: config?.source_type || 'http', endpoint: config?.endpoint || '', auth_type: config?.auth_type || 'none', auth_config: config?.auth_config || {}, headers: headersMapToList(config?.headers || {}), config: config?.config || { timeout: 30, retry: 3 }, }) }, [form, headersMapToList]) const loadConfigDetail = useCallback(async (configId: number) => { const res = await axios.get(`/api/v1/datasources/configs/${configId}`) return res.data }, []) const createDefaultConfigDraft = useCallback((overrides?: Partial) => ({ source_type: 'http', auth_type: 'none', headers: {}, config: { timeout: 30, retry: 3 }, ...overrides, }), []) const getBuiltinOverrideDescription = useCallback( (source?: Pick | null) => source ? `Built-in datasource override for ${source.name}` : undefined, [], ) const createFormPayload = useCallback((values: any) => ({ ...values, name: builtinEditingSource ? builtinEditingSource.source : values.name, description: values.description || getBuiltinOverrideDescription(builtinEditingSource), source_type: builtinEditingSource ? 'http' : values.source_type, headers: headersListToMap(values.headers), }), [builtinEditingSource, getBuiltinOverrideDescription, headersListToMap]) const closeDrawerAfterLoadError = useCallback(( errorMessage: string, options?: { clearBuiltin?: boolean; clearEditingConfig?: boolean }, ) => { messageApi.error(errorMessage) setDrawerVisible(false) if (options?.clearBuiltin) { setBuiltinEditingSource(null) } if (options?.clearEditingConfig) { setEditingConfig(null) } }, [messageApi]) const fetchData = useCallback(async () => { setLoading(true) try { const [builtinRes, customRes] = await Promise.all([ axios.get('/api/v1/datasources'), axios.get('/api/v1/datasources/configs') ]) setBuiltInSources(builtinRes.data.data || []) setCustomSources(customRes.data.data || []) } catch (error) { console.error('Failed to fetch data:', error) } finally { setLoading(false) } }, []) const [taskProgress, setTaskProgress] = useState>({}) const [bulkProgressBatch, setBulkProgressBatch] = useState(null) const activeBuiltInCount = builtInSources.filter((source) => source.is_active).length const runningBuiltInCount = builtInSources.filter((source) => { const trackedTask = taskProgress[source.id] return trackedTask?.is_running || source.is_running }).length const runningBuiltInSources = builtInSources.filter((source) => { const trackedTask = taskProgress[source.id] return trackedTask?.is_running || source.is_running }) const aggregateProgress = bulkProgressBatch && bulkProgressBatch.sourceIds.length > 0 ? Math.round( bulkProgressBatch.sourceIds.reduce((sum, sourceId) => { const item = bulkProgressBatch.items[sourceId] if (!item) return sum return sum + mapPhaseProgressToOverall(item.phase, item.progress, item.status) }, 0) / bulkProgressBatch.sourceIds.length ) : runningBuiltInSources.length > 0 ? Math.round( runningBuiltInSources.reduce((sum, source) => { const trackedTask = taskProgress[source.id] return sum + mapPhaseProgressToOverall( trackedTask?.phase ?? source.phase ?? 'queued', trackedTask?.progress ?? source.progress ?? 0, trackedTask?.status ?? (trackedTask?.is_running || source.is_running ? 'running' : source.last_status ?? null), ) }, 0) / runningBuiltInSources.length ) : 0 const bulkBatchRunningCount = bulkProgressBatch ? bulkProgressBatch.sourceIds.filter((sourceId) => bulkProgressBatch.items[sourceId]?.is_running).length : 0 const bulkBatchSuccessCount = bulkProgressBatch ? bulkProgressBatch.sourceIds.filter((sourceId) => { const status = bulkProgressBatch.items[sourceId]?.status return status === 'success' }).length : 0 const bulkBatchFailedCount = bulkProgressBatch ? bulkProgressBatch.sourceIds.filter((sourceId) => { const status = bulkProgressBatch.items[sourceId]?.status return Boolean(status && status !== 'running' && status !== 'success') }).length : 0 const handleTaskSocketMessage = useCallback((message: WebSocketTaskMessage) => { if (message.type !== 'data_frame' || message.channel !== 'datasource_tasks' || !message.payload?.datasource_id) { return } const payload = message.payload const sourceId = payload.datasource_id if (typeof sourceId !== 'number') { return } const nextState: TaskTrackerState = { task_id: payload.task_id ?? null, progress: payload.progress ?? 0, is_running: payload.status === 'running', phase: payload.phase ?? null, status: payload.status ?? null, records_processed: payload.records_processed ?? null, total_records: payload.total_records ?? null, error_message: payload.error_message ?? null, } setTaskProgress((prev) => { const next = { ...prev, [sourceId]: nextState, } if (!nextState.is_running && nextState.status !== 'running') { delete next[sourceId] } return next }) setBulkProgressBatch((prev) => { if (!prev || !prev.sourceIds.includes(sourceId)) { return prev } const nextItems = { ...prev.items, [sourceId]: { task_id: payload.task_id ?? prev.items[sourceId]?.task_id ?? null, progress: payload.progress ?? prev.items[sourceId]?.progress ?? 0, is_running: payload.status === 'running', phase: payload.phase ?? prev.items[sourceId]?.phase ?? null, status: payload.status ?? prev.items[sourceId]?.status ?? null, }, } return finalizeBulkProgressBatch({ ...prev, items: nextItems, }) }) if (payload.status && payload.status !== 'running') { void fetchData() } }, [fetchData]) const { connected: taskSocketConnected } = useWebSocket({ autoConnect: true, autoSubscribe: ['datasource_tasks'], onMessage: handleTaskSocketMessage, }) useEffect(() => { fetchData() }, [fetchData]) useEffect(() => { const updateHeights = () => { const builtinRegionHeight = builtinTableRegionRef.current?.offsetHeight || 0 const customRegionHeight = customTableRegionRef.current?.offsetHeight || 0 setBuiltinTableHeight(Math.max(220, builtinRegionHeight - 56)) setCustomTableHeight(Math.max(220, customRegionHeight - 56)) } updateHeights() if (typeof ResizeObserver === 'undefined') { return undefined } const observer = new ResizeObserver(updateHeights) if (builtinTableRegionRef.current) observer.observe(builtinTableRegionRef.current) if (customTableRegionRef.current) observer.observe(customTableRegionRef.current) return () => observer.disconnect() }, [activeTab, builtInSources.length, customSources.length]) useEffect(() => { if (taskSocketConnected) return const trackedSources = builtInSources.filter((source) => { const trackedTask = taskProgress[source.id] return Boolean((trackedTask?.task_id ?? source.task_id) && (trackedTask?.is_running ?? source.is_running)) }) if (trackedSources.length === 0) return const interval = setInterval(async () => { const updates: Record = {} await Promise.all( trackedSources.map(async (source) => { const trackedTaskId = taskProgress[source.id]?.task_id ?? source.task_id if (!trackedTaskId) return try { const res = await axios.get(`/api/v1/datasources/${source.id}/task-status`, { params: { task_id: trackedTaskId }, }) updates[source.id] = { task_id: res.data.task_id ?? trackedTaskId, progress: res.data.progress || 0, is_running: !!res.data.is_running, phase: res.data.phase || null, status: res.data.status || null, records_processed: res.data.records_processed, total_records: res.data.total_records, } } catch { updates[source.id] = { task_id: trackedTaskId, progress: 0, is_running: false, phase: 'failed', status: 'failed', } } }) ) setTaskProgress((prev) => { const next = { ...prev, ...updates } for (const [sourceId, state] of Object.entries(updates)) { if (!state.is_running && state.status !== 'running') { delete next[Number(sourceId)] } } return next }) setBulkProgressBatch((prev) => { if (!prev) return prev const nextItems = { ...prev.items } for (const [sourceId, state] of Object.entries(updates)) { const numericSourceId = Number(sourceId) if (!prev.sourceIds.includes(numericSourceId)) continue nextItems[numericSourceId] = { task_id: state.task_id, progress: state.progress, is_running: state.is_running, phase: state.phase ?? null, status: state.status ?? null, } } return finalizeBulkProgressBatch({ ...prev, items: nextItems, }) }) if (Object.values(updates).some((state) => !state.is_running)) { fetchData() } }, 2000) return () => clearInterval(interval) }, [builtInSources, taskProgress, taskSocketConnected, fetchData]) useEffect(() => { if (!bulkProgressBatch) return let changed = false const nextItems = { ...bulkProgressBatch.items } for (const sourceId of bulkProgressBatch.sourceIds) { const nextItem = resolveTerminalBatchItem(sourceId, bulkProgressBatch, builtInSources, taskProgress) if (!nextItem) continue const previousItem = bulkProgressBatch.items[sourceId] if ( previousItem?.status === nextItem.status && previousItem?.is_running === nextItem.is_running && previousItem?.progress === nextItem.progress && previousItem?.phase === nextItem.phase ) { continue } nextItems[sourceId] = nextItem changed = true } if (!changed) return setBulkProgressBatch((prev) => { if (!prev) return prev return finalizeBulkProgressBatch({ ...prev, items: nextItems, }) }) }, [bulkProgressBatch, builtInSources, taskProgress]) const triggerDatasource = async (id: number, options?: { force?: boolean }) => { const force = options?.force ?? false const res = await axios.post(`/api/v1/datasources/${id}/trigger`, null, { params: { force }, validateStatus: (status) => status < 500, }) if (res.status >= 400) { return { ok: false, status: res.status, detail: res.data?.detail, } satisfies TriggerDatasourceResult } if (res.data.task_id) { setTaskProgress(prev => ({ ...prev, [id]: { task_id: res.data.task_id, progress: 0, is_running: true, phase: 'queued', status: 'running', }, })) } else { window.setTimeout(() => { fetchData() }, 800) } fetchData() return { ok: true, response: res, } satisfies TriggerDatasourceResult } const fetchDatasourceTaskStatus = async (id: number) => { const res = await axios.get(`/api/v1/datasources/${id}/task-status`) return res.data } const confirmForceTrigger = (id: number, taskInfo?: { progress?: number | null phase?: string | null records_processed?: number | null total_records?: number | null message?: string }) => { const progressText = typeof taskInfo?.progress === 'number' ? `${Math.round(taskInfo.progress)}%` : '未知' const phaseText = taskInfo?.phase || 'running' const processedText = typeof taskInfo?.records_processed === 'number' ? `${taskInfo.records_processed}${typeof taskInfo?.total_records === 'number' && taskInfo.total_records > 0 ? ` / ${taskInfo.total_records}` : ''}` : '未知' modal.confirm({ title: '当前任务未完成', content: (

{taskInfo?.message || '当前采集任务仍在运行,重新触发会丢失本次未完成进度。'}

当前阶段: {phaseText}

当前进度: {progressText}

已处理记录: {processedText}

确认后会强制取消当前采集,并回滚未完成写入,然后重新开始采集。

), okText: '强制重新采集', cancelText: '取消', okButtonProps: { danger: true }, onOk: async () => { const result = await triggerDatasource(id, { force: true }) if (result.ok) { messageApi.success('已强制重新触发,未完成采集将回滚') return } const detail = result.detail messageApi.error(typeof detail === 'string' ? detail : detail?.message || '强制重新采集失败') }, }) } const triggerDatasourceWithPrecheck = async ( id: number, options?: { successMessage: string onSuccess?: () => void errorMessage?: string }, ) => { const taskStatus = await fetchDatasourceTaskStatus(id) if (taskStatus.is_running) { confirmForceTrigger(id, { message: '当前采集任务尚未完成,重新触发会丢失本次未完成进度。是否强制重新采集?', progress: taskStatus.progress ?? null, phase: taskStatus.phase ?? null, records_processed: taskStatus.records_processed ?? null, total_records: taskStatus.total_records ?? null, }) return } const result = await triggerDatasource(id) if (result.ok) { messageApi.success(options?.successMessage || '任务已触发') options?.onSuccess?.() return } const detail = result.detail if (result.status === 409 && typeof detail === 'object' && detail?.reason === 'running_task_in_progress') { confirmForceTrigger(id, detail) return } messageApi.error(typeof detail === 'string' ? detail : detail?.message || options?.errorMessage || '触发失败') } const handleTrigger = async (id: number) => { await triggerDatasourceWithPrecheck(id, { successMessage: '任务已触发', errorMessage: '触发失败', }) } const handleTriggerAll = async () => { try { setTriggerAllLoading(true) const res = await axios.post('/api/v1/datasources/trigger-all', null, { params: { force: forceTriggerAll }, }) const triggered = res.data.triggered || [] const skipped = res.data.skipped || [] const failed = res.data.failed || [] const skippedInWindow = skipped.filter((item: { reason?: string }) => item.reason === 'within_frequency_window') const skippedOther = skipped.filter((item: { reason?: string }) => item.reason !== 'within_frequency_window') if (triggered.length > 0) { setBulkProgressBatch({ sourceIds: triggered.map((item: { id: number }) => item.id), items: Object.fromEntries( triggered.map((item: { id: number; task_id?: number | null }) => [ item.id, { task_id: item.task_id ?? null, progress: 0, is_running: true, phase: 'queued', status: 'running', } satisfies BulkProgressItem, ]) ), }) setTaskProgress((prev) => { const next = { ...prev } for (const item of triggered) { if (!item.task_id) continue next[item.id] = { task_id: item.task_id, progress: 0, is_running: true, phase: 'queued', status: 'running', } } return next }) } const summaryParts = [ `已触发 ${triggered.length} 个`, skippedInWindow.length > 0 ? `周期内跳过 ${skippedInWindow.length} 个` : null, skippedOther.length > 0 ? `其他跳过 ${skippedOther.length} 个` : null, failed.length > 0 ? `失败 ${failed.length} 个` : null, ].filter(Boolean) messageApi.success(summaryParts.join(',')) fetchData() } catch (error: unknown) { const err = error as { response?: { data?: { detail?: string } } } messageApi.error(err.response?.data?.detail || '全触发失败') } finally { setTriggerAllLoading(false) } } const handleToggle = async (id: number, current: boolean) => { const endpoint = current ? 'disable' : 'enable' try { await axios.post(`/api/v1/datasources/${id}/${endpoint}`) messageApi.success(`${current ? '已禁用' : '已启用'}`) fetchData() } catch (error: unknown) { const err = error as { response?: { data?: { detail?: string } } } messageApi.error(err.response?.data?.detail || '操作失败') } } const handleClearDataFromDrawer = async () => { if (!viewingSource) return try { const res = await axios.delete(`/api/v1/datasources/${viewingSource.id}/data`) messageApi.success(res.data.message || '数据已删除') setViewDrawerVisible(false) fetchData() } catch (error: unknown) { const err = error as { response?: { data?: { detail?: string } } } messageApi.error(err.response?.data?.detail || '删除数据失败') } } const handleViewSource = async (source: BuiltInDataSource) => { try { const existingOverride = customSources.find((item) => item.name === source.source) const [res, statsRes, overrideDetail] = await Promise.all([ axios.get(`/api/v1/datasources/${source.id}`), axios.get(`/api/v1/datasources/${source.id}/stats`), existingOverride ? loadConfigDetail(existingOverride.id) : Promise.resolve(null), ]) const data = res.data setViewingSource({ id: data.id, name: data.name, description: null, source_type: data.collector_class, endpoint: overrideDetail?.endpoint || data.endpoint || '', auth_type: overrideDetail?.auth_type || 'none', headers: overrideDetail?.headers || {}, config: overrideDetail?.config || {}, collector_class: data.collector_class, module: data.module, priority: data.priority, frequency: data.frequency, }) setRecordCount(statsRes.data.total_records || 0) setViewDrawerVisible(true) } catch (error) { messageApi.error('获取数据源信息失败') } } const handleUpdateSource = async () => { if (!viewingSource) return await triggerDatasourceWithPrecheck(viewingSource.id, { successMessage: '已触发更新', errorMessage: '更新失败', onSuccess: () => { setViewDrawerVisible(false) }, }) } const handleTest = async () => { try { const values = await form.validateFields() setTesting(true) setTestResult(null) const payload = createFormPayload(values) const res = await axios.post('/api/v1/datasources/configs/test', payload) setTestResult(res.data) if (res.data.success) { messageApi.success('连接测试成功') } else { messageApi.error('连接测试失败') } } catch (error: unknown) { const err = error as { response?: { data?: { detail?: string; message?: string } } } messageApi.error(err.response?.data?.message || err.response?.data?.detail || '测试失败') } finally { setTesting(false) } } const handleSave = async () => { try { const values = await form.validateFields() const payload = createFormPayload(values) if (editingConfig) { await axios.put(`/api/v1/datasources/configs/${editingConfig.id}`, payload) messageApi.success('配置已更新') } else { await axios.post('/api/v1/datasources/configs', payload) messageApi.success('配置已创建') } setDrawerVisible(false) form.resetFields() setEditingConfig(null) setBuiltinEditingSource(null) setTestResult(null) fetchData() } catch (error: unknown) { const err = error as { response?: { data?: { detail?: string; message?: string } } } messageApi.error(err.response?.data?.message || err.response?.data?.detail || '保存失败') } } const handleDelete = async (id: number) => { try { await axios.delete(`/api/v1/datasources/configs/${id}`) messageApi.success('配置已删除') fetchData() } catch (error: unknown) { const err = error as { response?: { data?: { detail?: string } } } messageApi.error(err.response?.data?.detail || '删除失败') } } const handleResetBuiltinOverride = async () => { if (!builtinEditingSource || !editingConfig) return try { await axios.delete(`/api/v1/datasources/configs/${editingConfig.id}`) messageApi.success(`已恢复 ${builtinEditingSource.name} 的默认配置`) setDrawerVisible(false) form.resetFields() setEditingConfig(null) setBuiltinEditingSource(null) setTestResult(null) fetchData() } catch (error: unknown) { const err = error as { response?: { data?: { detail?: string } } } messageApi.error(err.response?.data?.detail || '恢复默认失败') } } const handleToggleCustom = async (id: number, current: boolean) => { try { await axios.put(`/api/v1/datasources/configs/${id}`, { is_active: !current }) messageApi.success(`${current ? '已禁用' : '已启用'}`) fetchData() } catch (error: unknown) { const err = error as { response?: { data?: { detail?: string } } } messageApi.error(err.response?.data?.detail || '操作失败') } } const openDrawer = async (config?: CustomDataSource) => { setBuiltinEditingSource(null) setEditingConfig(config || null) setTestResult(null) setDrawerVisible(true) if (config) { try { const detail = await loadConfigDetail(config.id) applyConfigToForm(detail) } catch { closeDrawerAfterLoadError('获取配置详情失败', { clearEditingConfig: true }) } return } form.resetFields() applyConfigToForm(createDefaultConfigDraft()) } const openBuiltinConfigDrawer = async (source: BuiltInDataSource) => { setBuiltinEditingSource(source) setTestResult(null) setDrawerVisible(true) const existingOverride = customSources.find((item) => item.name === source.source) setEditingConfig(existingOverride || null) if (existingOverride) { try { const detail = await loadConfigDetail(existingOverride.id) applyConfigToForm(detail) } catch { closeDrawerAfterLoadError('获取内置数据源配置失败', { clearBuiltin: true, clearEditingConfig: true, }) } return } form.resetFields() applyConfigToForm(createDefaultConfigDraft({ name: source.source, description: getBuiltinOverrideDescription(source), endpoint: source.endpoint || '', })) } const handleCopyLink = async (value: string, successText: string) => { try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(value) } else { const textArea = document.createElement('textarea') textArea.value = value textArea.style.position = 'fixed' textArea.style.opacity = '0' document.body.appendChild(textArea) textArea.focus() textArea.select() document.execCommand('copy') document.body.removeChild(textArea) } messageApi.success(successText) } catch { messageApi.error('复制失败,请手动复制') } } const builtinColumns = [ { title: 'ID', dataIndex: 'id', key: 'id', width: 60, fixed: 'left' as const }, { title: '名称', dataIndex: 'name', key: 'name', width: 180, ellipsis: true, render: (name: string, record: BuiltInDataSource) => ( ), }, { title: '模块', dataIndex: 'module', key: 'module', width: 80 }, { title: '优先级', dataIndex: 'priority', key: 'priority', width: 80, render: (p: string) => {p}, }, { title: '频率', dataIndex: 'frequency', key: 'frequency', width: 80 }, { title: '最近采集', dataIndex: 'last_run', key: 'last_run', width: 180, render: (_: string | null, record: BuiltInDataSource) => { const label = formatDateTimeZhCN(record.last_run_at || record.last_run) if (!label || label === '-') return '-' if ((record.data_count || 0) === 0 && record.last_status === 'success') { return `${label} (0条)` } return label }, }, { title: '状态', dataIndex: 'is_active', key: 'is_active', width: 180, render: (_: unknown, record: BuiltInDataSource) => { const taskState = taskProgress[record.id] const isTaskRunning = taskState?.is_running || record.is_running const phaseLabelMap: Record = { queued: '排队中', fetching: '抓取中', transforming: '处理中', saving: '保存中', completed: '已完成', failed: '失败', } if (isTaskRunning) { const pct = taskState?.progress ?? record.progress ?? 0 const phase = taskState?.phase || record.phase || 'queued' return ( {phaseLabelMap[phase] || phase} {pct > 0 ? ` ${Math.round(pct)}%` : ''} ) } const lastStatusColor = record.last_status === 'success' ? 'success' : record.last_status === 'failed' ? 'error' : 'default' return ( {record.last_status ? ( {record.last_status === 'success' ? '采集成功' : record.last_status === 'failed' ? '采集失败' : record.last_status} ) : null} ) }, }, { title: '操作', key: 'action', fixed: 'right' as const, width: builtinActionsCollapsed ? 40 : 228, onCell: () => actionCellProps, render: (_: unknown, record: BuiltInDataSource) => ( , onClick: () => { void openBuiltinConfigDrawer(record) }, }, { key: 'trigger', label: '触发', icon: , disabled: !record.is_active, onClick: () => handleTrigger(record.id), }, { key: 'toggle', label: record.is_active ? '禁用' : '启用', icon: record.is_active ? : , danger: record.is_active, onClick: () => handleToggle(record.id, record.is_active), }, ]} > ), }, ] const customColumns = [ { title: 'ID', dataIndex: 'id', key: 'id', width: 60, fixed: 'left' as const }, { title: '名称', dataIndex: 'name', key: 'name', width: 150, ellipsis: true }, { title: '类型', dataIndex: 'source_type', key: 'source_type', width: 100 }, { title: 'API链接', dataIndex: 'endpoint', key: 'endpoint', width: 280, ellipsis: true, render: (endpoint: string) => ( endpoint ? ( {endpoint} ) : '-' ), }, { title: '状态', dataIndex: 'is_active', key: 'is_active', width: 80, render: (active: boolean) => ( {active ? '启用' : '禁用'} ), }, { title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 160 }, { title: '操作', key: 'action', fixed: 'right' as const, width: customActionsCollapsed ? 40 : 228, onCell: () => actionCellProps, render: (_: unknown, record: CustomDataSource) => ( , onClick: () => { void openDrawer(record) }, }, { key: 'toggle', label: record.is_active ? '禁用' : '启用', icon: record.is_active ? : , danger: record.is_active, onClick: () => handleToggleCustom(record.id, record.is_active), }, { type: 'divider' }, { key: 'delete', label: '删除', icon: , danger: true, onClick: () => { Modal.confirm({ title: '确定删除此配置?', onOk: () => handleDelete(record.id), }) }, }, ]} > handleDelete(record.id)}> ), }, ] const tabItems = [ { key: 'builtin', label: '内置数据源', children: (
采集实时进度
总体进度 {aggregateProgress}%
0 ? 'active' : 'normal'} showInfo={false} strokeColor="#1677ff" />
内置 {builtInSources.length}
已启用 {activeBuiltInCount}
执行中 {bulkProgressBatch ? bulkBatchRunningCount : runningBuiltInCount}
{bulkProgressBatch ? (
成功 {bulkBatchSuccessCount}/{bulkProgressBatch.sourceIds.length}
) : null} {bulkProgressBatch ? (
失败 {bulkBatchFailedCount}
) : null}
setForceTriggerAll(event.target.checked)} > 强制全部采集
), }, { key: 'custom', label: ( 自定义数据源 ), children: (
{customSources.length === 0 ? (
) : (
)} ), }, ] return ( {contextHolder} {modalContextHolder}

数据源管理

{ setDrawerVisible(false) form.resetFields() setEditingConfig(null) setBuiltinEditingSource(null) setTestResult(null) }} footer={
{builtinEditingSource && editingConfig ? ( ) : null}
} >
{builtinEditingSource ? (
内置数据源
Collector Key
) : ( )} {builtinEditingSource ? null : ( )}
auth_type === 'bearer'}> {({ getFieldValue }) => { if (getFieldValue('auth_type') === 'bearer') { return ( ) } return null }} auth_type === 'api_key'}> {({ getFieldValue }) => { if (getFieldValue('auth_type') === 'api_key') { return ( <> ) } return null }} auth_type === 'basic'}> {({ getFieldValue }) => { if (getFieldValue('auth_type') === 'basic') { return ( <> ) } return null }}
), }, ]} /> {(fields, { add, remove }) => ( <> {fields.map(({ key, name, ...restField }) => ( ))} )} ), }, ]} /> ), }, ]} /> {testResult && (
{testResult.success ? ( ) : ( )} {testResult.success ? '连接成功' : '连接失败'}
{testResult.status_code &&
状态码: {testResult.status_code}
} {testResult.response_time_ms &&
响应时间: {testResult.response_time_ms.toFixed(0)}ms
} {testResult.error &&
错误: {testResult.error}
} {testResult.data_preview && (
预览: {testResult.data_preview}
)}
)} { setViewDrawerVisible(false) setViewingSource(null) }} footer={
} > {viewingSource && (
名称
模块
优先级
频率
数据量
采集器