release: bump version to 0.64.0
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled

This commit is contained in:
linkong
2026-05-21 03:46:02 +08:00
parent a37d4b6289
commit 37e92e7572
21 changed files with 1418 additions and 65 deletions

View File

@@ -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: '国界精度',

View File

@@ -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));
}