release: bump version to 0.64.0
This commit is contained in:
@@ -98,6 +98,27 @@ type DatasourceFilters = {
|
||||
dataStatus: string
|
||||
}
|
||||
|
||||
type CollectionQueueStatus = 'queued' | 'running' | 'success' | 'failed' | 'skipped' | 'cancelled'
|
||||
|
||||
type CollectionQueueItem = {
|
||||
key: string
|
||||
sourceId: string
|
||||
source?: string
|
||||
name: string
|
||||
taskId?: number | string | null
|
||||
status: CollectionQueueStatus
|
||||
phase?: string
|
||||
phaseMessage?: string
|
||||
progress?: number | null
|
||||
recordsProcessed?: number | null
|
||||
totalRecords?: number | null
|
||||
reason?: string
|
||||
error?: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
completedAt?: number
|
||||
}
|
||||
|
||||
const DEFAULT_DATASOURCE_FILTERS: DatasourceFilters = {
|
||||
product: '',
|
||||
module: '',
|
||||
@@ -237,6 +258,61 @@ function datasourceStatus(record: AnyRecord) {
|
||||
return text(record.last_status || record.status, record.is_active === false ? 'disabled' : 'idle')
|
||||
}
|
||||
|
||||
function queueStatusFromTask(statusValue: unknown, isRunning?: unknown): CollectionQueueStatus {
|
||||
const status = text(statusValue, '').toLowerCase()
|
||||
if (status === 'success' || status === 'completed') return 'success'
|
||||
if (status === 'failed' || status === 'error') return 'failed'
|
||||
if (status === 'cancelled' || status === 'canceled') return 'cancelled'
|
||||
if (status === 'skipped') return 'skipped'
|
||||
if (status === 'queued' || status === 'pending') return 'queued'
|
||||
if (isRunning === true || status === 'running' || status === 'collecting') return 'running'
|
||||
return 'queued'
|
||||
}
|
||||
|
||||
function queueItemKey(item: AnyRecord) {
|
||||
const taskId = text(item.task_id || item.taskId, '')
|
||||
if (taskId) return `task:${taskId}`
|
||||
const sourceId = text(item.datasource_id || item.source_id || item.id || item.sourceId, '')
|
||||
if (sourceId) return `source:${sourceId}`
|
||||
const source = text(item.collector_name || item.source, '')
|
||||
return source ? `source-name:${source}` : `queue:${Date.now()}`
|
||||
}
|
||||
|
||||
function queueProgress(item: CollectionQueueItem) {
|
||||
if (typeof item.progress === 'number') return Math.max(0, Math.min(100, Math.round(item.progress)))
|
||||
if (item.status === 'success' || item.status === 'failed' || item.status === 'cancelled' || item.status === 'skipped') return 100
|
||||
return 0
|
||||
}
|
||||
|
||||
function queueStatusLabel(status: CollectionQueueStatus) {
|
||||
const labels: Record<CollectionQueueStatus, string> = {
|
||||
queued: '排队中',
|
||||
running: '运行中',
|
||||
success: '已完成',
|
||||
failed: '失败',
|
||||
skipped: '跳过',
|
||||
cancelled: '已取消',
|
||||
}
|
||||
return labels[status]
|
||||
}
|
||||
|
||||
function queueReasonLabel(reason = '') {
|
||||
const labels: Record<string, string> = {
|
||||
disabled: '已停用',
|
||||
already_running: '已有任务运行',
|
||||
within_frequency_window: '未到采集间隔',
|
||||
trigger_failed: '触发失败',
|
||||
}
|
||||
return labels[reason] || reason || '-'
|
||||
}
|
||||
|
||||
function formatDuration(startedAt: number, endedAt = Date.now()) {
|
||||
const seconds = Math.max(0, Math.round((endedAt - startedAt) / 1000))
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const rest = seconds % 60
|
||||
return `${String(minutes).padStart(2, '0')}:${String(rest).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function datasourceTableRow(row: AnyRecord) {
|
||||
return {
|
||||
...row,
|
||||
@@ -540,6 +616,8 @@ const fieldLabels: Record<string, string> = {
|
||||
key: '键',
|
||||
label: '标签',
|
||||
title: '标题',
|
||||
kicker: '眉标',
|
||||
version: '版本',
|
||||
default_source_id: '默认频道',
|
||||
auto_fallback: '自动回退',
|
||||
id: '标识',
|
||||
@@ -653,6 +731,10 @@ const fieldLabels: Record<string, string> = {
|
||||
use_ssl: '使用 SSL',
|
||||
from_email: '发件邮箱',
|
||||
from_name: '发件人名称',
|
||||
logo_alt: 'Logo 替代文本',
|
||||
meta: '信息条目',
|
||||
credits: '出品信息',
|
||||
links: '链接',
|
||||
}
|
||||
|
||||
function fieldLabel(key: string) {
|
||||
@@ -1941,6 +2023,8 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [actionLoading, setActionLoading] = useState(false)
|
||||
const [rowActionLoading, setRowActionLoading] = useState<Record<string, boolean>>({})
|
||||
const [collectionQueue, setCollectionQueue] = useState<CollectionQueueItem[]>([])
|
||||
const [collectionQueueOpen, setCollectionQueueOpen] = useState(false)
|
||||
const pendingDatasourceTasksRef = useRef<Record<string, { sourceId: string; source?: string; name: string; taskId?: number | string | null; completed?: boolean }>>({})
|
||||
const completedDatasourceTasksRef = useRef<Set<string>>(new Set())
|
||||
const datasourcePollTimersRef = useRef<Record<string, number>>({})
|
||||
@@ -2157,6 +2241,77 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
return Boolean(sourceId && rowId === sourceId) || Boolean(source && rowSource === source)
|
||||
}, [])
|
||||
|
||||
const upsertCollectionQueueItem = useCallback((item: CollectionQueueItem) => {
|
||||
setCollectionQueue((current) => {
|
||||
const index = current.findIndex((existing) => (
|
||||
existing.key === item.key
|
||||
|| (item.taskId && existing.taskId === item.taskId)
|
||||
|| (item.sourceId && existing.sourceId === item.sourceId)
|
||||
|| (item.source && existing.source === item.source)
|
||||
))
|
||||
if (index < 0) return [item, ...current]
|
||||
const next = [...current]
|
||||
next[index] = { ...next[index], ...item, key: next[index].key, createdAt: next[index].createdAt || item.createdAt }
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const patchCollectionQueueFromTask = useCallback((payload: AnyRecord) => {
|
||||
const sourceId = text(payload.datasource_id || payload.source_id || payload.id, '')
|
||||
const source = text(payload.collector_name || payload.source, '')
|
||||
const taskId = payload.task_id as number | string | null | undefined
|
||||
const status = queueStatusFromTask(payload.status || payload.phase, payload.is_running)
|
||||
setCollectionQueue((current) => current.map((item) => {
|
||||
const matched = (taskId && item.taskId === taskId) || (sourceId && item.sourceId === sourceId) || (source && item.source === source)
|
||||
if (!matched) return item
|
||||
const terminal = ['success', 'failed', 'cancelled', 'skipped'].includes(status)
|
||||
return {
|
||||
...item,
|
||||
taskId: taskId ?? item.taskId,
|
||||
sourceId: sourceId || item.sourceId,
|
||||
source: source || item.source,
|
||||
status,
|
||||
phase: text(payload.phase, item.phase || ''),
|
||||
phaseMessage: text(payload.phase_message, item.phaseMessage || ''),
|
||||
progress: typeof payload.progress === 'number' ? payload.progress : item.progress,
|
||||
recordsProcessed: typeof payload.records_processed === 'number' ? payload.records_processed : item.recordsProcessed,
|
||||
totalRecords: typeof payload.total_records === 'number' ? payload.total_records : item.totalRecords,
|
||||
error: text(payload.error_message, item.error || ''),
|
||||
updatedAt: Date.now(),
|
||||
completedAt: terminal ? Date.now() : item.completedAt,
|
||||
}
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const addBatchQueueResult = useCallback((payload: AnyRecord) => {
|
||||
const now = Date.now()
|
||||
const toItems = (items: unknown, status: CollectionQueueStatus): CollectionQueueItem[] => (
|
||||
Array.isArray(items) ? items.filter(isObjectRecord).map((item) => ({
|
||||
key: queueItemKey(item),
|
||||
sourceId: text(item.id || item.source_id || item.datasource_id, ''),
|
||||
source: text(item.source || item.collector_name, ''),
|
||||
name: text(item.name || item.source || item.collector_name, '数据源'),
|
||||
taskId: item.task_id as number | string | null | undefined,
|
||||
status,
|
||||
phase: status === 'queued' ? 'queued' : undefined,
|
||||
phaseMessage: status === 'queued' ? '等待任务创建' : queueReasonLabel(text(item.reason, '')),
|
||||
progress: status === 'queued' ? 0 : 100,
|
||||
reason: text(item.reason, ''),
|
||||
error: text(item.error || item.message, ''),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
completedAt: status === 'queued' || status === 'running' ? undefined : now,
|
||||
})) : []
|
||||
)
|
||||
const nextItems = [
|
||||
...toItems(payload.triggered, 'queued'),
|
||||
...toItems(payload.skipped, 'skipped'),
|
||||
...toItems(payload.failed, 'failed'),
|
||||
]
|
||||
nextItems.forEach(upsertCollectionQueueItem)
|
||||
if (nextItems.length) setCollectionQueueOpen(true)
|
||||
}, [upsertCollectionQueueItem])
|
||||
|
||||
const updateDatasourceRow = useCallback((row: AnyRecord, options: { removeIfFilteredOut?: boolean } = {}) => {
|
||||
if (config !== configs.datasources) return
|
||||
const normalized = normalizeDatasourceTableRecord(row)
|
||||
@@ -2180,8 +2335,8 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
})
|
||||
}, [config, isSameDatasourceRow, normalizeDatasourceTableRecord])
|
||||
|
||||
const mergeDatasourceTaskUpdate = useCallback((payload: AnyRecord) => {
|
||||
if (config !== configs.datasources) return
|
||||
const mergeDatasourceTaskUpdate = useCallback((payload: AnyRecord) => {
|
||||
if (config !== configs.datasources) return
|
||||
const sourceId = text(payload.datasource_id || payload.source_id, '')
|
||||
const source = text(payload.collector_name || payload.source, '')
|
||||
if (!sourceId && !source) return
|
||||
@@ -2216,11 +2371,12 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
}),
|
||||
}
|
||||
}))
|
||||
setSelected((current) => {
|
||||
if (!current || !isSameDatasourceRow(current, sourceId, source)) return current
|
||||
return normalizeDatasourceTableRecord({ ...current, ...rowPatch, id: current.id, source: current.source })
|
||||
})
|
||||
}, [config, isSameDatasourceRow, normalizeDatasourceTableRecord])
|
||||
setSelected((current) => {
|
||||
if (!current || !isSameDatasourceRow(current, sourceId, source)) return current
|
||||
return normalizeDatasourceTableRecord({ ...current, ...rowPatch, id: current.id, source: current.source })
|
||||
})
|
||||
patchCollectionQueueFromTask(payload)
|
||||
}, [config, isSameDatasourceRow, normalizeDatasourceTableRecord, patchCollectionQueueFromTask])
|
||||
|
||||
const finalizeDatasourceTask = useCallback(async (payload: AnyRecord) => {
|
||||
const sourceId = text(payload.datasource_id || payload.source_id, '')
|
||||
@@ -2305,6 +2461,16 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
const activeState = useMemo(() => states.find((state) => state.section.key === activeSection?.key), [activeSection?.key, states])
|
||||
const rows = activeState?.rows ?? []
|
||||
const summary = sectionSummary(states)
|
||||
const collectionQueueSummary = useMemo(() => {
|
||||
const running = collectionQueue.filter((item) => item.status === 'queued' || item.status === 'running').length
|
||||
const completed = collectionQueue.filter((item) => item.status === 'success').length
|
||||
const failed = collectionQueue.filter((item) => item.status === 'failed').length
|
||||
const skipped = collectionQueue.filter((item) => item.status === 'skipped' || item.status === 'cancelled').length
|
||||
const terminal = completed + failed + skipped
|
||||
const total = collectionQueue.length
|
||||
const progress = total ? Math.round((terminal / total) * 100) : 0
|
||||
return { total, running, completed, failed, skipped, progress }
|
||||
}, [collectionQueue])
|
||||
const isPlaygroundSection = config === configs.ai && activeSection?.key === 'playground'
|
||||
const isHierarchySection = config.viewMode === 'management' && !isPlaygroundSection
|
||||
const searchIntent = useMemo(() => {
|
||||
@@ -2486,6 +2652,12 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
source_ids: sourceIds,
|
||||
force: batchTriggerForce,
|
||||
})
|
||||
addBatchQueueResult(response.data)
|
||||
arrayAt(response.data, 'triggered').forEach((item) => {
|
||||
const sourceId = text(item.id || item.source_id || item.datasource_id, '')
|
||||
const record = rows.find((row) => text(row.id, '') === sourceId || text(row.source, '') === text(item.source, ''))
|
||||
if (record) scheduleDatasourceTaskPoll(record, item.task_id as number | string | null | undefined)
|
||||
})
|
||||
replaceSelectedWithPayload('trigger-batch', '批量触发结果', [{
|
||||
...response.data,
|
||||
__title: '批量触发结果',
|
||||
@@ -2502,6 +2674,25 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
}
|
||||
}
|
||||
|
||||
const triggerAllDatasources = async () => {
|
||||
setActionLoading(true)
|
||||
try {
|
||||
toast({ title: '正在提交采集队列', description: '触发全部会按后端调度规则跳过未到采集间隔的源。' })
|
||||
const response = await axios.post(apiPath('/datasources/trigger-all'))
|
||||
addBatchQueueResult(response.data)
|
||||
arrayAt(response.data, 'triggered').forEach((item) => {
|
||||
const sourceId = text(item.id || item.source_id || item.datasource_id, '')
|
||||
const record = rows.find((row) => text(row.id, '') === sourceId || text(row.source, '') === text(item.source, ''))
|
||||
if (record) scheduleDatasourceTaskPoll(record, item.task_id as number | string | null | undefined)
|
||||
})
|
||||
toast({ title: '采集队列已提交', description: `${arrayAt(response.data, 'triggered').length} 个任务已进入队列。`, tone: 'success' })
|
||||
} catch (error) {
|
||||
toast({ title: '触发全部失败', description: actionErrorMessage(error), tone: 'error' })
|
||||
} finally {
|
||||
setActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleDatasourceTaskPoll = (record: TableRecord, taskId?: number | string | null) => {
|
||||
const id = pick(record, ['id', 'source_id', 'key', 'name'], '')
|
||||
if (!id) return
|
||||
@@ -2535,6 +2726,33 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
datasourcePollTimersRef.current[pollKey] = window.setTimeout(poll, datasourceSocket.connected ? 3000 : 900)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (config !== configs.datasources) return
|
||||
const builtinState = states.find((state) => state.section.key === 'builtin')
|
||||
if (!builtinState?.rows.length) return
|
||||
builtinState.rows.forEach((row) => {
|
||||
const status = datasourceStatus(row)
|
||||
if (!['running', 'pending', 'queued'].includes(status)) return
|
||||
const sourceId = text(row.id || row.source_id, '')
|
||||
const source = text(row.source || row.collector_name, '')
|
||||
const taskId = row.task_id as number | string | null | undefined
|
||||
upsertCollectionQueueItem({
|
||||
key: queueItemKey({ id: sourceId, source, task_id: taskId }),
|
||||
sourceId,
|
||||
source,
|
||||
name: recordTitle(row),
|
||||
taskId,
|
||||
status: status === 'running' ? 'running' : 'queued',
|
||||
phase: status,
|
||||
phaseMessage: text(row.phase_message || row.last_status, '后端任务仍在运行'),
|
||||
progress: typeof row.progress === 'number' ? row.progress : 0,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
scheduleDatasourceTaskPoll(row, taskId)
|
||||
})
|
||||
}, [config, states, upsertCollectionQueueItem])
|
||||
|
||||
const refreshDatasourceRow = async (record: AnyRecord, options: { removeIfFilteredOut?: boolean } = {}) => {
|
||||
const id = pick(record, ['id', 'source_id', 'key', 'name'], '')
|
||||
if (!id) return
|
||||
@@ -2632,13 +2850,27 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
}
|
||||
const taskId = response.data?.task_id
|
||||
const pendingKey = text(taskId, id)
|
||||
pendingDatasourceTasksRef.current[pendingKey] = {
|
||||
sourceId: id,
|
||||
source: text(record.source || response.data?.collector_name, ''),
|
||||
name: recordTitle(record),
|
||||
taskId,
|
||||
}
|
||||
completedDatasourceTasksRef.current.delete(pendingKey)
|
||||
pendingDatasourceTasksRef.current[pendingKey] = {
|
||||
sourceId: id,
|
||||
source: text(record.source || response.data?.collector_name, ''),
|
||||
name: recordTitle(record),
|
||||
taskId,
|
||||
}
|
||||
upsertCollectionQueueItem({
|
||||
key: queueItemKey({ task_id: taskId, id, source: record.source }),
|
||||
sourceId: id,
|
||||
source: text(record.source || response.data?.collector_name, ''),
|
||||
name: recordTitle(record),
|
||||
taskId,
|
||||
status: 'queued',
|
||||
phase: 'queued',
|
||||
phaseMessage: '任务已提交',
|
||||
progress: 0,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
setCollectionQueueOpen(true)
|
||||
completedDatasourceTasksRef.current.delete(pendingKey)
|
||||
updateDatasourceRow({
|
||||
...record,
|
||||
id: record.id || Number(id) || id,
|
||||
@@ -2659,8 +2891,8 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
__status: '已提交',
|
||||
__metric: response.data?.task_id || '-',
|
||||
}])
|
||||
toast({ title: force ? '已强制重新触发' : '任务已触发', tone: 'success' })
|
||||
scheduleDatasourceTaskPoll(record, taskId)
|
||||
toast({ title: force ? '已强制重新触发' : '任务已触发', tone: 'success' })
|
||||
scheduleDatasourceTaskPoll(record, taskId)
|
||||
} catch (error) {
|
||||
toast({ title: '触发采集失败', description: actionErrorMessage(error), tone: 'error' })
|
||||
} finally {
|
||||
@@ -3957,6 +4189,10 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
await saveHierarchySettings('/earth/brand', payload, '保存品牌配置')
|
||||
return
|
||||
}
|
||||
if (activeSection.key === 'about') {
|
||||
await saveHierarchySettings('/earth/about', payload, '保存关于配置')
|
||||
return
|
||||
}
|
||||
if (activeGroup.key.includes('boundaries')) {
|
||||
await saveHierarchySettings('/earth/boundaries/config', { config: payload }, '保存边界配置')
|
||||
return
|
||||
@@ -4185,6 +4421,15 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
})} loading={actionLoading}><Trash2 size={15} /></Button>
|
||||
</>
|
||||
) : null}
|
||||
{config === configs.earthContent && activeSection.key === 'about' ? (
|
||||
<Button size="icon" variant="subtle" title="恢复默认关于信息" aria-label="恢复默认关于信息" onClick={() => setConfirmAction({
|
||||
title: '恢复默认关于信息',
|
||||
description: '确认恢复 Earth 关于卡片的默认内容?',
|
||||
danger: false,
|
||||
confirmLabel: '恢复',
|
||||
run: () => requestAction('恢复默认关于信息', 'delete', '/earth/about'),
|
||||
})} loading={actionLoading}><RefreshCw size={15} /></Button>
|
||||
) : null}
|
||||
{config === configs.earthContent && activeSection.key === 'earth_assets' ? (
|
||||
<Button variant="primary" icon="trigger" onClick={() => void requestAction('启动边界构建', 'post', '/earth/boundaries/build', undefined, { refresh: false, successDescription: '边界构建任务已提交,当前表单未保存。' })} loading={actionLoading}>构建</Button>
|
||||
) : null}
|
||||
@@ -4636,7 +4881,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
const actions: ReactNode[] = []
|
||||
if (config === configs.datasources) {
|
||||
actions.push(
|
||||
<Button key="trigger-all" variant="primary" icon="trigger" onClick={() => void requestAction('触发全部数据源', 'post', '/datasources/trigger-all', undefined, { refresh: false, successDescription: '批量触发任务已提交,不会修改配置。' })} loading={actionLoading} title="触发全部数据源">
|
||||
<Button key="trigger-all" variant="primary" icon="trigger" onClick={() => void triggerAllDatasources()} loading={actionLoading} title="触发全部数据源">
|
||||
触发全部
|
||||
</Button>,
|
||||
<Button key="trigger-batch" size="icon" variant="subtle" onClick={() => setBatchTriggerOpen(true)} loading={actionLoading} title="批量触发数据源" aria-label="批量触发数据源">
|
||||
@@ -4748,6 +4993,115 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
)
|
||||
}
|
||||
|
||||
const renderDatasourceTaskSummary = () => {
|
||||
if (config !== configs.datasources || !selected || selected.__endpointKey !== 'builtin') return null
|
||||
const sourceId = text(selected.id || selected.source_id, '')
|
||||
const source = text(selected.source || selected.collector_name, '')
|
||||
const queueItem = collectionQueue.find((item) => isSameDatasourceRow(selected, item.sourceId || '', item.source || ''))
|
||||
const status = queueItem?.status || datasourceStatus(selected)
|
||||
const taskId = queueItem?.taskId || selected.task_id
|
||||
return (
|
||||
<section className="an-task-summary">
|
||||
<div>
|
||||
<strong>采集任务</strong>
|
||||
<p>{queueItem?.phaseMessage || text(selected.last_status || selected.phase_message, '当前没有运行中的任务。')}</p>
|
||||
</div>
|
||||
<StatusText tone={statusTone(status)}>{queueStatusLabel(status as CollectionQueueStatus)}</StatusText>
|
||||
<dl>
|
||||
<dt>数据源</dt><dd>{source || sourceId || '-'}</dd>
|
||||
<dt>任务</dt><dd>{text(taskId, '-')}</dd>
|
||||
<dt>进度</dt><dd>{queueItem ? `${queueProgress(queueItem)}%` : '-'}</dd>
|
||||
<dt>更新时间</dt><dd>{queueItem?.updatedAt ? new Date(queueItem.updatedAt).toLocaleTimeString() : text(selected.last_run_at, '-')}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const jumpToQueueRecord = (item: CollectionQueueItem) => {
|
||||
const record = rows.find((row) => isSameDatasourceRow(row, item.sourceId || '', item.source || ''))
|
||||
if (!record) {
|
||||
toast({ title: '当前分区未找到该数据源', description: '可切回内置源或刷新后再查看。' })
|
||||
return
|
||||
}
|
||||
openResourceDetail(record)
|
||||
}
|
||||
|
||||
const retryQueueItem = (item: CollectionQueueItem) => {
|
||||
const record = rows.find((row) => isSameDatasourceRow(row, item.sourceId || '', item.source || ''))
|
||||
if (!record) {
|
||||
toast({ title: '无法重试', description: '当前列表中没有找到对应数据源。', tone: 'error' })
|
||||
return
|
||||
}
|
||||
void triggerDatasourceWithPrecheck(record)
|
||||
}
|
||||
|
||||
const renderCollectionQueue = () => {
|
||||
if (config !== configs.datasources || !collectionQueueSummary.total) return null
|
||||
const groups: Array<{ key: string; title: string; items: CollectionQueueItem[] }> = [
|
||||
{ key: 'running', title: '运行中', items: collectionQueue.filter((item) => item.status === 'queued' || item.status === 'running') },
|
||||
{ key: 'failed', title: '失败', items: collectionQueue.filter((item) => item.status === 'failed') },
|
||||
{ key: 'completed', title: '完成', items: collectionQueue.filter((item) => item.status === 'success') },
|
||||
{ key: 'skipped', title: '跳过', items: collectionQueue.filter((item) => item.status === 'skipped' || item.status === 'cancelled') },
|
||||
]
|
||||
return (
|
||||
<div className="an-collection-queue">
|
||||
<div className="an-collection-queue__bar">
|
||||
<div className="an-collection-queue__summary">
|
||||
<strong>采集队列</strong>
|
||||
<span>{collectionQueueSummary.progress}%</span>
|
||||
<span>运行 {collectionQueueSummary.running}</span>
|
||||
<span>完成 {collectionQueueSummary.completed}</span>
|
||||
<span>失败 {collectionQueueSummary.failed}</span>
|
||||
<span>跳过 {collectionQueueSummary.skipped}</span>
|
||||
</div>
|
||||
<div className="an-collection-queue__actions">
|
||||
<Button size="sm" variant="subtle" onClick={() => setCollectionQueueOpen((open) => !open)}>
|
||||
{collectionQueueOpen ? '收起队列' : '查看队列'}
|
||||
</Button>
|
||||
{collectionQueueSummary.running === 0 ? (
|
||||
<Button size="icon" variant="subtle" title="清空已结束队列项" aria-label="清空已结束队列项" onClick={() => setCollectionQueue((items) => items.filter((item) => item.status === 'queued' || item.status === 'running'))}>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="an-collection-queue__track" aria-hidden="true">
|
||||
<span style={{ width: `${collectionQueueSummary.progress}%` }} />
|
||||
</div>
|
||||
{collectionQueueOpen ? (
|
||||
<div className="an-collection-queue__panel">
|
||||
{groups.map((group) => (
|
||||
<section key={group.key} className="an-collection-queue__group">
|
||||
<h3>{group.title}<span>{group.items.length}</span></h3>
|
||||
{group.items.length ? (
|
||||
<div className="an-collection-queue__items">
|
||||
{group.items.map((item) => (
|
||||
<article key={item.key} className={`an-collection-queue__item is-${item.status}`}>
|
||||
<div>
|
||||
<strong>{item.name}</strong>
|
||||
<p>{item.phaseMessage || item.error || queueStatusLabel(item.status)}{item.taskId ? ` · task ${item.taskId}` : ''}{item.completedAt ? ` · ${formatDuration(item.createdAt, item.completedAt)}` : ''}</p>
|
||||
</div>
|
||||
<span>{queueProgress(item)}%</span>
|
||||
<div className="an-collection-queue__item-actions">
|
||||
<Button size="icon" variant="subtle" title="查看数据源" aria-label="查看数据源" onClick={() => jumpToQueueRecord(item)}>
|
||||
<Eye size={14} />
|
||||
</Button>
|
||||
{item.status === 'failed' ? (
|
||||
<Button size="icon" variant="subtle" icon="trigger" title="重试" aria-label="重试" onClick={() => retryQueueItem(item)} />
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : <p className="an-collection-queue__empty">暂无{group.title}任务</p>}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const moduleActions = renderModuleActions()
|
||||
const credentialGuideProviderName = credentialGuide ? text(credentialGuide.provider, 'barentswatch') : ''
|
||||
const credentialGuideMarkdown = credentialGuide
|
||||
@@ -4790,6 +5144,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
|
||||
<SectionTabs sections={config.sections} states={states} activeKey={activeSection?.key || ''} onChange={handleSectionChange} />
|
||||
{renderDatasourceFilters()}
|
||||
{renderCollectionQueue()}
|
||||
</div>
|
||||
|
||||
{isPlaygroundSection ? (
|
||||
@@ -4841,6 +5196,7 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
<StatusText tone={statusTone(recordStatus(selected))}>{recordStatus(selected)}</StatusText>
|
||||
</header>
|
||||
{renderRecordActions()}
|
||||
{renderDatasourceTaskSummary()}
|
||||
<DetailMarkdownDocument record={selected} />
|
||||
{renderStructuredEditor() || <DetailFields record={selected} />}
|
||||
<div className="an-code-section">
|
||||
@@ -5112,6 +5468,7 @@ const configs = {
|
||||
viewMode: 'management',
|
||||
sections: [
|
||||
{ key: 'brand', label: '品牌标识', url: '/earth/brand', map: (payload) => singleRow(payload, 'brand', { __title: '品牌配置', __module: '品牌' }).map((row) => ({ ...row, __title: '品牌配置', __module: '品牌', __status: '已读取' })) },
|
||||
{ key: 'about', label: '关于', url: '/earth/about', map: (payload) => singleRow(payload, 'about', { __title: '关于配置', __module: '关于' }).map((row) => ({ ...row, __title: '关于配置', __module: '关于', __status: '已读取' })) },
|
||||
{
|
||||
key: 'earth_assets',
|
||||
label: '国界精度',
|
||||
|
||||
@@ -29,6 +29,12 @@
|
||||
--tui-primary-hover: var(--an-accent-hover);
|
||||
--tui-primary-active: var(--an-accent-hover);
|
||||
--tui-danger: var(--an-danger);
|
||||
--d-segment-bg: #eef3f9;
|
||||
--d-segment-slider: #ffffff;
|
||||
--d-segment-shadow: 0 2px 8px rgba(15, 23, 42, 0.12);
|
||||
--d-lang-btn: #4a5568;
|
||||
--d-nav-hover: #0d4f9f;
|
||||
--d-nav-active: #0b5fc1;
|
||||
color: var(--an-text);
|
||||
}
|
||||
|
||||
@@ -49,6 +55,12 @@
|
||||
--an-info: #38bdf8;
|
||||
--an-row-hover: #1f2b3e;
|
||||
--an-shadow: none;
|
||||
--d-segment-bg: rgba(0, 0, 0, 0.28);
|
||||
--d-segment-slider: #202938;
|
||||
--d-segment-shadow: 0 2px 8px rgba(0, 0, 0, 0.45);
|
||||
--d-lang-btn: #8a9bb8;
|
||||
--d-nav-hover: #93c5fd;
|
||||
--d-nav-active: #5ba5ff;
|
||||
}
|
||||
|
||||
.an-dialog,
|
||||
@@ -301,6 +313,192 @@ body:has(.admin-next-theme-root[data-theme='dark']) .an-toast {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.an-collection-queue {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--an-border);
|
||||
border-radius: 8px;
|
||||
background: var(--an-surface);
|
||||
box-shadow: var(--an-shadow);
|
||||
}
|
||||
|
||||
.an-collection-queue__bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.an-collection-queue__summary,
|
||||
.an-collection-queue__actions,
|
||||
.an-collection-queue__item-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.an-collection-queue__summary {
|
||||
flex-wrap: wrap;
|
||||
color: var(--an-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.an-collection-queue__summary strong {
|
||||
color: var(--an-text);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.an-collection-queue__track {
|
||||
height: 6px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: var(--an-soft);
|
||||
}
|
||||
|
||||
.an-collection-queue__track span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, var(--an-info), var(--an-accent));
|
||||
transition: width 180ms ease;
|
||||
}
|
||||
|
||||
.an-collection-queue__panel {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.an-collection-queue__group {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--an-border);
|
||||
border-radius: 8px;
|
||||
background: var(--an-surface-alt);
|
||||
}
|
||||
|
||||
.an-collection-queue__group h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.an-collection-queue__group h3 span {
|
||||
color: var(--an-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.an-collection-queue__items {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.an-collection-queue__item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--an-border);
|
||||
border-radius: 7px;
|
||||
background: var(--an-surface);
|
||||
}
|
||||
|
||||
.an-collection-queue__item strong,
|
||||
.an-collection-queue__item p {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.an-collection-queue__item strong {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.an-collection-queue__item p,
|
||||
.an-collection-queue__empty {
|
||||
margin: 0;
|
||||
color: var(--an-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.an-collection-queue__item > span {
|
||||
min-width: 38px;
|
||||
color: var(--an-muted);
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.an-collection-queue__item.is-running,
|
||||
.an-collection-queue__item.is-queued {
|
||||
border-color: color-mix(in srgb, var(--an-info) 35%, var(--an-border));
|
||||
}
|
||||
|
||||
.an-collection-queue__item.is-success {
|
||||
border-color: color-mix(in srgb, var(--an-success) 35%, var(--an-border));
|
||||
}
|
||||
|
||||
.an-collection-queue__item.is-failed {
|
||||
border-color: color-mix(in srgb, var(--an-danger) 35%, var(--an-border));
|
||||
}
|
||||
|
||||
.an-task-summary {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--an-border);
|
||||
border-radius: 8px;
|
||||
background: var(--an-surface-alt);
|
||||
}
|
||||
|
||||
.an-task-summary p {
|
||||
margin: 3px 0 0;
|
||||
color: var(--an-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.an-task-summary dl {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
margin: 0;
|
||||
border: 1px solid var(--an-border);
|
||||
border-radius: 7px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.an-task-summary dt,
|
||||
.an-task-summary dd {
|
||||
margin: 0;
|
||||
padding: 7px 9px;
|
||||
border-bottom: 1px solid var(--an-border);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.an-task-summary dt {
|
||||
color: var(--an-muted);
|
||||
background: var(--an-surface);
|
||||
}
|
||||
|
||||
.an-task-summary dd {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-next__content {
|
||||
min-width: 0;
|
||||
height: 100vh;
|
||||
@@ -3572,6 +3770,20 @@ body:has(.admin-next-theme-root[data-theme='dark']) .an-toast {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.an-collection-queue__bar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.an-collection-queue__panel {
|
||||
grid-template-columns: 1fr;
|
||||
max-height: 46vh;
|
||||
}
|
||||
|
||||
.an-task-summary dl {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.an-logs-filters {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user