fix(selfop): distinguish preparation progress from warehouse tasks

This commit is contained in:
2026-10-13 12:50:29 +08:00
parent 9dcd991ddb
commit 6bdaff3fdf
7 changed files with 186 additions and 25 deletions
@@ -2,7 +2,7 @@ import { defineComponent, h } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import CollectionView from './CollectionView.vue'
import type { Overview, WarehouseOverview } from '../types'
import type { Overview, Task, WarehouseOverview } from '../types'
const mocks = vi.hoisted(() => ({ overview: vi.fn(), tasks: vi.fn(), runs: vi.fn(), task: vi.fn(), retry: vi.fn(), push: vi.fn(), warehouse: '' }))
vi.mock('../api/analytics', () => ({ analyticsApi: mocks }))
@@ -15,7 +15,7 @@ const dialog = defineComponent({ props: ['modelValue', 'title', 'showConfirm'],
} })
const table = defineComponent({ props: ['data', 'columns', 'emptyText'], setup(props, { slots }) { return () => h('div', [
h('div', { 'data-test': 'toolbar' }, slots['toolbar-actions']?.()), h('div', { 'data-test': 'filters' }, slots.filters?.()), h('div', { 'data-test': 'notices' }, slots.notices?.()),
...(props.data ?? []).map((row: object) => h('div', [slots.configuration?.({ row }), slots.warehouse?.({ row }), slots.family?.({ row }), slots.enabled?.({ row }), slots.execution?.({ row }), slots.publication?.({ row }), slots.coverage?.({ row }), slots.freshness?.({ row }), slots.dailySamples?.({ row })])),
...(props.data ?? []).map((row: object) => h('div', [slots.configuration?.({ row }), slots.warehouse?.({ row }), slots.family?.({ row }), slots.enabled?.({ row }), slots.execution?.({ row }), slots.currentProgress?.({ row }), slots.completion?.({ row }), slots.publication?.({ row }), slots.coverage?.({ row }), slots.freshness?.({ row }), slots.dailySamples?.({ row })])),
!(props.data ?? []).length ? props.emptyText : null,
]) } })
const setup = { global: { stubs: { AdminPage: pass, ListPageLayout: pass, QueryToolbar: true, DataTable: table, AppDialog: dialog,
@@ -36,10 +36,31 @@ beforeEach(() => { vi.clearAllMocks(); mocks.warehouse = ''; mocks.overview.mock
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()) })
async function page() { const wrapper = mount(CollectionView, setup); wrappers.push(wrapper); await flushPromises(); return wrapper }
describe('collection status list and per-row configuration dialog', () => {
it('shows preparation progress separately from a warehouse waiting to start', async () => {
const waiting: Task = { taskNo: 'LOCAL-A', taskType: 'BACKFILL', dataFamily: 'OUTBOUND', warehouseCode: stock.warehouseCode, taskStatus: 'WAITING_SOURCE', applyState: 'NOT_STARTED', queryFrom: null, queryUntil: null, attemptCount: 0, latestAttemptNo: null, resultPublicationState: 'NOT_PUBLISHED', nextRetryAt: null, allowedActions: [], actionBlockReason: null, progress: null }
mocks.overview.mockResolvedValue({ ...overview, warehouses: [{ ...stock, dataFamily: 'OUTBOUND', currentTask: waiting, sharedUpstream: { ...waiting, taskNo: 'PREPARE-A', taskStatus: 'RUNNING', attemptCount: 1, progress: { phase: 'DETAIL', completed: 792, total: 2014, totalKnown: true, unit: 'ORDER' } } }] })
const wrapper = await page()
expect(wrapper.text()).toContain('等待数据准备')
expect(wrapper.text()).toContain('792 / 2014单(39.3%,当前阶段)')
expect(wrapper.text()).toContain('不是各仓出库量,也不是各仓完成率')
expect(wrapper.text()).toContain('完成时间暂无法预计')
expect(wrapper.text()).toContain('上次结果:')
})
it('shows one preparation task only once across two warehouses and keeps their progress independent', async () => {
const waiting: Task = { taskNo: 'LOCAL-A', taskType: 'BACKFILL', dataFamily: 'OUTBOUND', warehouseCode: 'US01', taskStatus: 'WAITING_SOURCE', applyState: 'NOT_STARTED', queryFrom: null, queryUntil: null, attemptCount: 0, latestAttemptNo: null, resultPublicationState: 'NOT_PUBLISHED', nextRetryAt: null, allowedActions: [], actionBlockReason: null, progress: null }
const shared = { ...waiting, taskNo: 'PREPARE-A', taskStatus: 'RUNNING', attemptCount: 1, progress: { phase: 'DETAIL', completed: 1218, total: 2014, totalKnown: true, unit: 'ORDER' } }
mocks.overview.mockResolvedValue({ ...overview, warehouses: [{ ...stock, warehouseCode: 'US01', dataFamily: 'OUTBOUND', currentTask: waiting, sharedUpstream: shared }, { ...stock, warehouseCode: 'US02', dataFamily: 'OUTBOUND', currentTask: { ...waiting, warehouseCode: 'US02', taskNo: 'LOCAL-B' }, sharedUpstream: shared }] })
const wrapper = await page()
expect(wrapper.findAll('[aria-label="来源数据准备任务"]')).toHaveLength(1)
expect(wrapper.find('[aria-label="来源数据准备任务"]').text()).toContain('US01、US02')
expect(wrapper.text().match(/1218 \/ 2014/g)).toHaveLength(1)
expect(wrapper.text().match(/尚未开始,等待数据准备/g)).toHaveLength(2)
})
it('compares all warehouses in one compact table and distinguishes OFF from retained results', async () => {
const wrapper = await page()
expect(wrapper.findAllComponents(table)[0]?.props('data')).toHaveLength(2)
expect(wrapper.findAllComponents(table)[0]?.props('columns')[0]).toMatchObject({ label: '操作', fixed: 'left', slotName: 'configuration' })
expect(wrapper.findAllComponents(table)[0]?.props('columns').find((column: { key: string }) => column.key === 'enabled')).toMatchObject({ label: '采集开关' })
expect(wrapper.text()).toContain('自动采集已关闭,当前仅查看已留存结果')
expect(wrapper.text()).toContain('包含合成测试样本')
expect(wrapper.text()).toContain('已留存 2 / 30 个日样本')
@@ -12,8 +12,10 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import { getApiErrorMessage, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { analyticsApi } from '../api/analytics'
import TaskExecutionFacts from '../components/TaskExecutionFacts.vue'
import { taskStateText, taskProgressText, taskCompletionText } from '../collectionPresentation'
import type { Overview, Run, Task, TaskDetail, Validation, WarehouseOverview } from '../types'
import { RequestFence, RetryIntent, RuntimePoller, hasActiveTask, label, beijingTime, reasonsText, unitText, progressText, quantityText, taskRetryAllowed } from '../state'
import { RequestFence, RetryIntent, RuntimePoller, hasActiveTask, label, beijingTime, reasonsText, progressText, quantityText, taskRetryAllowed } from '../state'
const access = useAccessPreviewStore()
const route = useRoute()
@@ -56,14 +58,28 @@ const actionFence = new RequestFence()
const retryIntent = new RetryIntent()
let active = false
const warehouses = computed(() => overview.value?.warehouses.filter(row => !warehouseCode.value || row.warehouseCode === warehouseCode.value) ?? [])
const preparationTasks = computed(() => {
const visible = new Set(warehouses.value.map(item => item.sharedUpstream?.taskNo).filter(Boolean))
const tasks = new Map<string, { task: Task; warehouseCodes: string[] }>()
for (const item of overview.value?.warehouses ?? []) {
const task = item.sharedUpstream
if (!task?.taskNo || !visible.has(task.taskNo)) continue
let entry = tasks.get(task.taskNo)
if (!entry) { entry = { task, warehouseCodes: [] }; tasks.set(task.taskNo, entry) }
if (!entry.warehouseCodes.includes(item.warehouseCode)) entry.warehouseCodes.push(item.warehouseCode)
}
return [...tasks.values()]
})
const fields = computed<QueryField[]>(() => [{ key: 'warehouseCode', label: '仓库', type: 'select', width: 230, placeholder: '全部仓库采集状态', options: [...new Map((overview.value?.warehouses ?? []).map(row => [row.warehouseCode, { label: row.warehouseName || row.warehouseCode, value: row.warehouseCode }])).values()] }])
const hasSyntheticSamples = computed(() => warehouses.value.some(item => item.warehouseCode.startsWith('SELFOP-TEST-')))
const overviewColumns: DataTableColumn[] = [
{ key: 'configuration', label: '操作', width: 110, fixed: 'left', slotName: 'configuration' },
{ prop: 'warehouseName', label: '仓库', minWidth: 160, slotName: 'warehouse' },
{ key: 'family', label: '数据类型', width: 110, slotName: 'family' },
{ key: 'enabled', label: '自动采集', width: 100, slotName: 'enabled' },
{ key: 'execution', label: '最近执行', width: 110, slotName: 'execution' },
{ key: 'enabled', label: '采集开关', width: 100, slotName: 'enabled' },
{ key: 'execution', label: '当前状态', minWidth: 150, slotName: 'execution' },
{ key: 'currentProgress', label: '本仓处理进度', minWidth: 300, slotName: 'currentProgress' },
{ key: 'completion', label: '完成情况', minWidth: 170, slotName: 'completion' },
{ key: 'publication', label: '最近发布', minWidth: 180, slotName: 'publication' },
{ key: 'coverage', label: '数据覆盖截至', minWidth: 180, slotName: 'coverage' },
{ key: 'freshness', label: '数据时效', width: 100, slotName: 'freshness' },
@@ -74,14 +90,13 @@ const retryAllowed = computed(() => taskRetryAllowed(selected.value, canSync.val
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
{ prop: 'taskNo', label: '任务号', minWidth: 230 },
{ key: 'family', label: '任务类型 / 用途', minWidth: 160, slotName: 'family' },
{ key: 'family', label: '采集内容', minWidth: 160, slotName: 'family' },
{ prop: 'warehouseCode', label: '仓库', minWidth: 120 },
{ key: 'range', label: '目标范围(北京时间)', minWidth: 280, slotName: 'range' },
{ key: 'status', label: '执行结果', minWidth: 130, slotName: 'status' },
{ key: 'range', label: '本任务数据时间段(北京时间)', minWidth: 280, slotName: 'range', showOverflowTooltip: false },
{ key: 'status', label: '任务状态', minWidth: 150, slotName: 'status' },
{ key: 'publication', label: '发布结果', minWidth: 150, slotName: 'publication' },
{ key: 'progress', label: '阶段 / 可靠计数', minWidth: 310, slotName: 'progress' },
{ key: 'health', label: '最近进展 / 心跳', minWidth: 220, slotName: 'health' },
{ key: 'block', label: '阻断原因', minWidth: 210, slotName: 'block' },
{ key: 'progress', label: '当前阶段与进度', minWidth: 310, slotName: 'progress' },
{ key: 'completion', label: '完成情况', minWidth: 170, slotName: 'completion' },
]
const runColumns: DataTableColumn[] = [
{ prop: 'attemptNo', label: '尝试序号', width: 100 },
@@ -182,6 +197,7 @@ onUnmounted(() => { suspend(); document.removeEventListener('visibilitychange',
<ListPageLayout v-else>
<div class="collection-workspace">
<DataTable appearance="integrated" :data="warehouses" :columns="overviewColumns" :loading="loading" empty-text="暂无仓库采集状态">
<template #toolbar-start><h2>当前采集状态</h2></template>
<template #toolbar-actions><el-button :disabled="!warehouseCode" @click="openAnalysis('/self-operated/sales')">查看销量</el-button><el-button :disabled="!warehouseCode" @click="openAnalysis('/self-operated/inventory')">查看库存</el-button></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="采集仓库筛选" preference-key="self-operated-collection-query" :loading="loading" @search="refresh" @reset="refresh" /></template>
<template #notices>
@@ -192,38 +208,49 @@ onUnmounted(() => { suspend(); document.removeEventListener('visibilitychange',
<span v-if="hasSyntheticSamples">包含合成测试样本,不代表真实领星采集覆盖。</span>
<span class="collection-observed">最近更新:{{ beijingTime(observedAt) }}(北京时间)</span>
</div>
<div v-for="entry in preparationTasks" :key="entry.task.taskNo" class="collection-preparation" role="status" aria-label="来源数据准备任务">
<strong>来源数据准备</strong><span>{{ entry.warehouseCodes.join('、') }} 使用这批数据</span><span>{{ taskStateText(entry.task) }}</span><span>{{ taskProgressText(entry.task) }}</span>
<small>任务 {{ entry.task.taskNo }};这里的单数是来源账号的数据准备进度,不是各仓出库量,也不是各仓完成率。</small>
</div>
</template>
<template #configuration="{ row }"><el-button class="density-action" @click="openConfiguration(asWarehouse(row))">查看配置</el-button></template>
<template #warehouse="{ row }">{{ asWarehouse(row).warehouseName || asWarehouse(row).warehouseCode }}</template>
<template #family="{ row }">{{ family(asWarehouse(row).dataFamily) }}</template>
<template #enabled="{ row }">{{ asWarehouse(row).executionEnabled ? '已启用' : '已关闭' }}</template>
<template #execution="{ row }">{{ label(asWarehouse(row).currentTask?.taskStatus) }}</template>
<template #publication="{ row }">{{ label(asWarehouse(row).currentTask?.resultPublicationState) }}<small class="collection-secondary">{{ asWarehouse(row).lastPublishedAt ? beijingTime(asWarehouse(row).lastPublishedAt) : '尚无可用结果' }}</small></template>
<template #execution="{ row }">{{ taskStateText(asWarehouse(row).currentTask) }}</template>
<template #currentProgress="{ row }">{{ taskProgressText(asWarehouse(row).currentTask) }}</template>
<template #completion="{ row }">{{ taskCompletionText(asWarehouse(row).currentTask) }}</template>
<template #publication="{ row }">{{ label(asWarehouse(row).currentTask?.resultPublicationState) }}<small class="collection-secondary">{{ asWarehouse(row).lastPublishedAt ? `上次结果:${beijingTime(asWarehouse(row).lastPublishedAt)}` : '尚无可用结果' }}</small></template>
<template #coverage="{ row }">{{ beijingTime(asWarehouse(row).dataFamily === 'INVENTORY' ? asWarehouse(row).inventoryObservedUntil : asWarehouse(row).salesCoveredUntil) }}</template>
<template #freshness="{ row }">{{ label(asWarehouse(row).dataFreshness) }}</template>
<template #dailySamples="{ row }"><template v-if="asWarehouse(row).dataFamily === 'INVENTORY' && asWarehouse(row).inventoryDailySamples">已留存 {{ asWarehouse(row).inventoryDailySamples?.sampleCount }} / {{ asWarehouse(row).inventoryDailySamples?.expectedDays }} 个日样本</template><template v-else>—</template></template>
</DataTable>
<DataTable appearance="integrated" v-model:page="current" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" :empty-text="!warehouseCode ? '选择仓库后查看采集任务与尝试历史' : error && !rows.length ? '任务读取失败,请重试' : '暂无采集任务'">
<template #toolbar-start><h2>采集任务记录</h2></template>
<template #notices><p class="collection-history-note">这里包含当前任务和历史任务。每行对应一个任务及数据时间段;同一仓库出现多条出库销量记录,不代表这些任务同时执行。当前运行情况请看上方“当前采集状态”。</p></template>
<template #actions="{ row }"><el-button link :disabled="!asTask(row).taskNo" @click="selectTask(asTask(row))">详情 / 恢复</el-button></template>
<template #family="{ row }">{{ family(asTask(row).dataFamily) }} / {{ label(asTask(row).executionPurpose) }}</template>
<template #range="{ row }">{{ beijingTime(asTask(row).queryFrom) }} 至 {{ beijingTime(asTask(row).queryUntil) }}</template>
<template #status="{ row }">{{ label(asTask(row).taskStatus) }}</template>
<template #family="{ row }">{{ family(asTask(row).dataFamily) }}<small class="collection-secondary">{{ asTask(row).taskType === 'BACKFILL' ? '补采历史数据' : asTask(row).taskType === 'SCHEDULED' ? '定时更新' : label(asTask(row).executionPurpose) }}</small></template>
<template #range="{ row }">{{ beijingTime(asTask(row).queryFrom) }}<br />至 {{ beijingTime(asTask(row).queryUntil) }}</template>
<template #status="{ row }">{{ taskStateText(asTask(row)) }}</template>
<template #publication="{ row }">{{ label(asTask(row).resultPublicationState) }}</template>
<template #progress="{ row }">{{ progressText(asTask(row).progress) }}</template>
<template #health="{ row }">{{ beijingTime(asTask(row).latestAttempt?.progressUpdatedAt) }} / {{ label(asTask(row).latestAttempt?.executionHealth) }}</template>
<template #block="{ row }">{{ blockReason(asTask(row).actionBlockReason) }}</template>
<template #progress="{ row }">{{ taskProgressText(asTask(row)) }}</template>
<template #completion="{ row }">{{ taskCompletionText(asTask(row)) }}</template>
</DataTable>
<AppDialog v-model="taskOpen" class="admin-density-dialog" :title="`采集任务详情 · ${selected?.taskNo || '—'}`" width="1200px" :loading="detailLoading" :confirm-loading="submitting" :show-confirm="false" cancel-text="关闭" fullscreenable>
<section v-if="selected" class="collection-details admin-density" aria-label="选中任务详情">
<h3>任务 {{ selected.taskNo || '—' }}</h3>
<p v-if="detailLoading">读取任务详情…</p><p v-if="detailError" role="alert">{{ detailError }}</p>
<p>执行:{{ label(selected.taskStatus) }};发布:{{ label(selected.resultPublicationState) }};尝试 {{ selected.attemptCount }} 次;{{ progressText(selected.progress) }}</p>
<p>状态:{{ taskStateText(selected) }};结果:{{ label(selected.resultPublicationState) }};已执行 {{ selected.attemptCount }} 次。</p><p>进度:{{ taskProgressText(selected) }};{{ taskCompletionText(selected) }}。</p>
<p>目标 {{ beijingTime(selected.queryFrom) }} 至 {{ beijingTime(selected.queryUntil) }};下次重试 {{ beijingTime(selected.nextRetryAt) }};{{ selected.actionBlockReason ? blockReason(selected.actionBlockReason) : (selected.latestAttempt?.failureSummary ?? '') }}</p>
<p>尝试开始 {{ beijingTime(selected.latestAttempt?.startedAt) }};结束 {{ beijingTime(selected.latestAttempt?.finishedAt) }};耗时 {{ quantityText(selected.latestAttempt?.elapsedSeconds) }} 秒;心跳 {{ beijingTime(selected.latestAttempt?.heartbeatAt) }} · {{ label(selected.latestAttempt?.executionHealth) }}。</p>
<p>数据质量:{{ label(selected.progress?.validationState) }};异常 {{ quantityText(selected.progress?.invalidCount) }},未知 {{ quantityText(selected.progress?.unknownCount) }},待核对 {{ quantityText(selected.progress?.pendingReviewCount) }}({{ unitText(selected.progress?.countUnit) }});摘要时间 {{ beijingTime(selected.progress?.summaryAsOf) }}。未计算的数量不默认为零。</p>
<p v-for="(stage, index) in selected.progress?.stages" :key="index">{{ progressText(stage) }}</p>
<p v-if="selectedDetail?.sharedUpstream">账号共享上游:{{ selectedDetail.sharedUpstream.taskNo }} · {{ label(selectedDetail.sharedUpstream.taskStatus) }} · {{ progressText(selectedDetail.sharedUpstream.progress) }}</p>
<TaskExecutionFacts :task="selected" scope-label="本仓任务" />
<details v-if="selected.progress?.stages?.length"><summary>分阶段记录</summary><p v-for="(stage, index) in selected.progress.stages" :key="index">{{ progressText(stage) }}</p></details>
<section v-if="selectedDetail?.sharedUpstream" class="collection-upstream" aria-label="数据准备任务详情">
<h3>数据准备任务:{{ selectedDetail.sharedUpstream.taskNo }}</h3>
<p>{{ taskStateText(selectedDetail.sharedUpstream) }};{{ taskProgressText(selectedDetail.sharedUpstream) }};{{ taskCompletionText(selectedDetail.sharedUpstream) }}。</p>
<TaskExecutionFacts :task="selectedDetail.sharedUpstream" scope-label="数据准备任务" />
<p>系统先读取来源账号的数据,再按仓库分别校验、计算和发布。以上是前面的数据准备进度,可能包含其他仓库;本仓是否成功以结果发布为准,不把该进度计入本仓尝试次数。</p>
</section>
<DataTable appearance="integrated" v-model:page="attemptsPage" v-model:page-size="attemptsSize" :data="attempts" :columns="runColumns" :total="attemptsTotal" :loading="detailLoading" empty-text="尚无执行尝试">
<template #status="{ row }">{{ label(asRun(row).status) }}</template><template #progress="{ row }">{{ progressText(asRun(row).progress) }}</template><template #startedAt="{ row }">{{ beijingTime(asRun(row).startedAt) }}</template><template #finishedAt="{ row }">{{ beijingTime(asRun(row).finishedAt) }}</template>
</DataTable>
@@ -269,7 +296,7 @@ onUnmounted(() => { suspend(); document.removeEventListener('visibilitychange',
</template>
<p v-else-if="configItem.dataFamily === 'INVENTORY'">每日库存观测积累状态尚无可用资料。</p>
<p v-if="configItem.currentTask?.taskStatus === 'FAILED' || configItem.currentTask?.taskStatus === 'DEAD'">本次失败,{{ configItem.lastPublishedAt ? '仍展示上次成功数据;请核对数据时效。' : '尚无可用数据。' }}</p>
<p v-if="configItem.sharedUpstream">账号共享阶段:{{ configItem.sharedUpstream.taskNo || '—' }} · {{ label(configItem.sharedUpstream.taskStatus) }} · {{ progressText(configItem.sharedUpstream.progress) }}。共享采集完成不代表本仓发布成功。</p>
<p v-if="configItem.sharedUpstream">数据准备任务:{{ configItem.sharedUpstream.taskNo || '—' }} · {{ taskStateText(configItem.sharedUpstream) }} · {{ taskProgressText(configItem.sharedUpstream) }}。来源数据取完后,本仓还需校验、计算和发布。</p>
<p>账号日预算 {{ quantityText(configItem.protection.dailyRequestBudget) }};已保留请求 {{ quantityText(configItem.protection.reservedHttpRequests) }};已确认 HTTP {{ quantityText(configItem.protection.confirmedHttpRequests) }};额度日期 {{ configItem.protection.budgetDate ?? '—' }}。</p>
<p>验证会话预算(含保守预留):<span v-for="(used, key) in validation(configItem.currentTask)?.budgetConsumed" :key="key">{{ budgetName(String(key)) }} {{ budgetValue(used) }} / {{ budgetValue(limitFor(String(key), validation(configItem.currentTask))) }}; </span><span v-if="!validation(configItem.currentTask)?.budgetConsumed">尚无预算观测</span></p>
<p v-if="validation(configItem.currentTask)">会话限制:<span v-for="(limit, key) in validation(configItem.currentTask)?.budgetLimits" :key="key">{{ budgetName(String(key)) }} {{ budgetValue(limit) }}; </span>最多 1 个在途,请求起始间隔至少 5 秒。验证样本不会发布正式结果。</p>
@@ -283,8 +310,12 @@ onUnmounted(() => { suspend(); document.removeEventListener('visibilitychange',
.collection-summary { display: flex; flex-wrap: wrap; align-items: center; gap: var(--admin-space-5); font-size: var(--admin-font-caption); }
.collection-observed { margin-left: auto; color: var(--el-text-color-secondary); }
.collection-secondary { display: block; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
.collection-history-note { margin: 0; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
.collection-preparation { display: flex; flex-wrap: wrap; align-items: center; gap: var(--admin-space-3); padding-top: var(--admin-space-3); margin-top: var(--admin-space-3); border-top: 1px solid var(--el-border-color); }
.collection-preparation small { flex-basis: 100%; }
.collection-details summary, .collection-gaps summary { cursor: pointer; font-size: var(--admin-font-caption); }
.collection-details h3 { font-size: var(--admin-font-section); margin: var(--admin-space-5) 0; }
.collection-details p { margin: var(--admin-space-3) 0; overflow-wrap: anywhere; }
.collection-details section + section { margin-top: var(--admin-space-5); }
.collection-upstream { border-block: 1px solid var(--el-border-color); padding-block: var(--admin-space-3); }
</style>