Files
admin-web/src/modules/self-operated/pages/CollectionView.vue
T

322 lines
31 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onActivated, onDeactivated, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AdminPage from '@/shared/components/AdminPage.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
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, progressText, quantityText, taskRetryAllowed } from '../state'
const access = useAccessPreviewStore()
const route = useRoute()
const router = useRouter()
const canList = computed(() => access.can('platform:self-operated-analytics:list'))
const canSync = computed(() => access.can('platform:self-operated-analytics:sync'))
const sourceAccount = 'lingxing-primary'
const filters = ref<QueryModel>({ warehouseCode: typeof route.query.warehouseCode === 'string' ? route.query.warehouseCode : '' })
const warehouseCode = computed(() => String(filters.value.warehouseCode ?? ''))
const overview = ref<Overview | null>(null)
const configOpen = ref(false)
const configKey = ref('')
const configItem = computed(() => overview.value?.warehouses.find(item => `${item.warehouseCode}:${item.dataFamily}` === configKey.value) ?? null)
const rows = ref<Task[]>([])
const current = ref(1)
const size = ref(20)
const total = ref(0)
const upperBound = ref<{ listUpperCreatedAt: string | null; listUpperId: string | null } | null>(null)
const observedAt = ref<string | null>(null)
const error = ref('')
const errorDetails = ref<ApiErrorDetails | null>(null)
const loading = ref(false)
const selected = ref<Task | null>(null)
const taskOpen = ref(false)
const selectedDetail = ref<TaskDetail | null>(null)
const attempts = ref<Run[]>([])
const attemptsPage = ref(1)
const attemptsSize = ref(20)
const attemptsTotal = ref(0)
const attemptsUpperBound = ref<{ listUpperCreatedAt: string | null; listUpperId: string | null } | null>(null)
const detailLoading = ref(false)
const detailError = ref('')
const reason = ref('')
const confirming = ref(false)
const submitting = ref(false)
const actionFeedback = ref('')
const fence = new RequestFence()
const detailFence = new RequestFence()
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: '当前状态', 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' },
{ key: 'dailySamples', label: '库存日样本', minWidth: 150, slotName: 'dailySamples' },
]
const scope = computed(() => ({ sourceAccount, warehouseCode: warehouseCode.value }))
const retryAllowed = computed(() => taskRetryAllowed(selected.value, canSync.value, scope.value, reason.value))
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
{ prop: 'taskNo', label: '任务号', minWidth: 230 },
{ key: 'family', label: '采集内容', minWidth: 160, slotName: 'family' },
{ prop: 'warehouseCode', label: '仓库', minWidth: 120 },
{ 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: 'completion', label: '完成情况', minWidth: 170, slotName: 'completion' },
]
const runColumns: DataTableColumn[] = [
{ prop: 'attemptNo', label: '尝试序号', width: 100 },
{ key: 'status', label: '执行结果', width: 120, slotName: 'status' },
{ key: 'startedAt', label: '开始(北京时间)', minWidth: 180, slotName: 'startedAt' }, { key: 'finishedAt', label: '结束(北京时间)', minWidth: 180, slotName: 'finishedAt' },
{ key: 'progress', label: '冻结阶段摘要', minWidth: 290, slotName: 'progress' },
{ prop: 'failureSummary', label: '失败摘要', minWidth: 210 },
]
function asTask(row: unknown) { return row as Task }
function asRun(row: unknown) { return row as Run }
function asWarehouse(row: unknown) { return row as WarehouseOverview }
function openConfiguration(item: WarehouseOverview) { configKey.value = `${item.warehouseCode}:${item.dataFamily}`; configOpen.value = true }
function family(value: string) { return value === 'INVENTORY' ? '产品库存' : value === 'OUTBOUND' || value === 'SALES' ? '出库销量' : '任务类型待核对' }
function validation(task: Task | null | undefined): Validation | null { return task?.validation ?? null }
function budgetValue(value: unknown) { return typeof value === 'number' || typeof value === 'string' ? String(value) : '—' }
function blockReason(value: string | null | undefined) { return value ? reasonsText([value]) : '—' }
function budgetName(key: string) { return ({ pages: '分页HTTP', detailHttp: '详情HTTP', http: '全部HTTP保留额度', confirmedHttp: '已确认真实HTTP', distinctDetails: '不同详情对象', maxPages: '分页HTTP上限', maxPageSize: '每页数量上限', maxDistinctDetails: '不同详情对象上限', maxDetailHttp: '详情HTTP上限', maxInventoryPagesPerWarehouse: '每仓库存页上限', maxHttp: '全部HTTP上限' } as Record<string, string>)[key] ?? '其他保护额度' }
function limitFor(used: string, value: Validation | null) { const key = ({ pages: 'maxPages', detailHttp: 'maxDetailHttp', http: 'maxHttp', confirmedHttp: 'maxHttp', distinctDetails: 'maxDistinctDetails' } as Record<string, string>)[used]; return key ? value?.budgetLimits?.[key] : null }
async function read() {
if (!active || !canList.value) return false
const request = fence.begin()
loading.value = !overview.value
try {
const nextOverview = await analyticsApi.overview(sourceAccount, undefined, request.signal)
if (!request.current()) return false
const tasks = warehouseCode.value ? await analyticsApi.tasks({ ...scope.value, current: current.value, size: size.value, ...upperBound.value }, request.signal) : null
if (!request.current()) return false
overview.value = nextOverview
observedAt.value = nextOverview.observedAt
if (tasks) { rows.value = tasks.records; total.value = tasks.total; upperBound.value = { listUpperCreatedAt: tasks.listUpperCreatedAt, listUpperId: tasks.listUpperId } }
error.value = ''
errorDetails.value = null
return hasActiveTask([...rows.value, ...nextOverview.warehouses.flatMap(row => [row.currentTask, row.sharedUpstream].filter((task): task is Task => Boolean(task)))])
} catch (cause) { if (request.current()) { errorDetails.value = getApiErrorDetails(cause, '状态更新失败,保留最后读取数据'); error.value = errorDetails.value.message }; return hasActiveTask(rows.value) }
finally { if (request.current()) loading.value = false }
}
async function selectTask(task: Task, reset = true) {
if (!active || !task.taskNo || task.warehouseCode !== warehouseCode.value) return
if (reset) { taskOpen.value = true; actionFence.cancel(); submitting.value = false; selected.value = task; selectedDetail.value = null; attempts.value = []; attemptsPage.value = 1; attemptsUpperBound.value = null; reason.value = ''; confirming.value = false; actionFeedback.value = '' }
const request = detailFence.begin()
detailLoading.value = true
detailError.value = ''
try {
const detail = await analyticsApi.task(scope.value, task.taskNo, request.signal)
if (!request.current()) return
const history = await analyticsApi.runs({ ...scope.value, taskNo: task.taskNo, current: attemptsPage.value, size: attemptsSize.value, ...attemptsUpperBound.value }, request.signal)
if (!request.current()) return
if (detail.task.taskNo !== task.taskNo || detail.task.warehouseCode !== warehouseCode.value) throw new Error('任务归属不匹配')
selected.value = detail.task
selectedDetail.value = detail
attempts.value = history.records
attemptsTotal.value = history.total
attemptsUpperBound.value = { listUpperCreatedAt: history.listUpperCreatedAt, listUpperId: history.listUpperId }
} catch (cause) { if (request.current()) detailError.value = getApiErrorMessage(cause, '任务详情更新失败,保留上次详情') }
finally { if (request.current()) detailLoading.value = false }
}
async function submitRetry() {
if (!confirming.value || !retryAllowed.value || submitting.value || !selected.value) return
const target = selected.value
const request = actionFence.begin()
submitting.value = true
actionFeedback.value = ''
try {
const accepted = await analyticsApi.retry(retryIntent.create(target.taskNo, warehouseCode.value, reason.value), request.signal)
if (!request.current()) return
if (accepted.taskNo !== target.taskNo) throw new Error('受理任务号不匹配')
retryIntent.accepted()
confirming.value = false
actionFeedback.value = '恢复请求已排队受理,尚未完成采集或发布。'
await read()
await selectTask(target, false)
} catch (cause) { if (request.current()) actionFeedback.value = getApiErrorMessage(cause, '恢复请求失败;再次提交相同内容将沿用原幂等键') }
finally { if (request.current()) submitting.value = false }
}
const poller = new RuntimePoller(async () => { const busy = await read(); if (selected.value && !submitting.value) await selectTask(selected.value, false); return busy }, () => { fence.cancel(); detailFence.cancel() })
function visibility() { poller.setVisible(!document.hidden) }
function refresh() { upperBound.value = null; current.value = 1; void read() }
function openAnalysis(path: string) { void router.push({ path, query: { warehouseCode: warehouseCode.value } }) }
watch([() => route.path, () => route.query], () => {
if (route.path && route.path !== '/self-operated/collection') return
filters.value.warehouseCode = typeof route.query.warehouseCode === 'string' ? route.query.warehouseCode : ''
})
watch(warehouseCode, () => { configOpen.value = false; taskOpen.value = false; fence.cancel(); detailFence.cancel(); actionFence.cancel(); selected.value = null; rows.value = []; total.value = 0; attempts.value = []; upperBound.value = null; confirming.value = false; submitting.value = false; reason.value = ''; actionFeedback.value = ''; current.value = 1; void read() })
watch(taskOpen, open => { if (!open) { detailFence.cancel(); detailLoading.value = false; selected.value = null; selectedDetail.value = null; attempts.value = []; confirming.value = false; reason.value = ''; actionFeedback.value = '' } })
watch([current, size], () => { void read() })
watch([attemptsPage, attemptsSize], () => { if (selected.value) void selectTask(selected.value, false) })
watch(reason, () => { confirming.value = false })
function resume() { if (active) return; active = true; visibility(); poller.start() }
function suspend() { active = false; poller.stop(); actionFence.cancel(); configOpen.value = false; taskOpen.value = false; loading.value = false; detailLoading.value = false; submitting.value = false }
onMounted(() => { resume(); document.addEventListener('visibilitychange', visibility) })
onActivated(resume)
onDeactivated(suspend)
onUnmounted(() => { suspend(); document.removeEventListener('visibilitychange', visibility) })
</script>
<template>
<AdminPage class="admin-density">
<p v-if="!canList" role="alert">没有采集状态查看权限。</p>
<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>
<LoadErrorState v-if="error" title="采集状态更新失败" :message="error" :loading="loading" compact @retry="read"><template #details><p>状态观测时间 {{ beijingTime(observedAt) }}。任务运行失败与本页读取失败分别判断。</p><ApiErrorMeta v-if="errorDetails" :details="errorDetails" /></template></LoadErrorState>
<div class="collection-summary" role="status">
<span v-if="overview?.collectionMode === 'OFF'">自动采集已关闭,当前仅查看已留存结果。</span>
<span v-else>采集模式:{{ label(overview?.collectionMode) }}</span>
<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 }">{{ 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) }}<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 }">{{ 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>状态:{{ 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>
<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>
<form v-if="canSync" class="density-form" @submit.prevent="submitRetry">
<label for="retry-reason">恢复原因</label><el-input id="retry-reason" v-model="reason" type="textarea" placeholder="请说明已完成的修复及恢复原任务的原因" :maxlength="500" :disabled="submitting" />
<p v-if="!selected.allowedActions.includes('RETRY') || selected.actionBlockReason">{{ selected.actionBlockReason ? blockReason(selected.actionBlockReason) : '当前任务不允许恢复' }}</p>
<el-button v-if="!confirming" :disabled="!retryAllowed || submitting" @click="confirming = true">核对恢复请求</el-button>
<template v-else><p>确认恢复任务 {{ selected.taskNo }}(仓库 {{ selected.warehouseCode }}),范围保持不变,原因:{{ reason }}。受理后仍须等待执行与发布。</p><el-button :disabled="!retryAllowed || submitting" :loading="submitting" native-type="submit" type="primary">确认提交恢复请求</el-button><el-button :disabled="submitting" @click="confirming = false">返回修改</el-button></template>
</form>
<p v-else>没有任务恢复权限。</p><p v-if="actionFeedback" role="status">{{ actionFeedback }}</p>
</section>
</AppDialog>
</div>
</ListPageLayout>
<AppDialog v-model="configOpen" class="admin-density-dialog" :title="`${configItem?.warehouseName || configItem?.warehouseCode || '仓库'} · ${family(configItem?.dataFamily || '')}配置`" width="1120px" :show-confirm="false" cancel-text="关闭" fullscreenable>
<div class="admin-density">
<section v-if="configItem" class="collection-details">
<h3>{{ configItem.warehouseName || configItem.warehouseCode }} · {{ family(configItem.dataFamily) }}</h3>
<el-descriptions class="density-descriptions" :column="3" border>
<el-descriptions-item label="环境 / 模式">{{ overview?.environmentLabel ?? '—' }} / {{ label(overview?.collectionMode) }}</el-descriptions-item>
<el-descriptions-item label="采集 / 发布">{{ configItem.executionEnabled ? '已启用' : '未启用' }} / {{ configItem.publicationEnabled ? '已启用' : '未启用' }}</el-descriptions-item>
<el-descriptions-item label="用途">{{ label(configItem.currentTask?.executionPurpose) }}</el-descriptions-item>
<el-descriptions-item label="停用原因">{{ blockReason(configItem.disabledReason) }}</el-descriptions-item>
<el-descriptions-item label="最近执行">{{ label(configItem.currentTask?.taskStatus) }}</el-descriptions-item>
<el-descriptions-item label="发布结果">{{ label(configItem.currentTask?.resultPublicationState) }}</el-descriptions-item>
<el-descriptions-item label="最近成功">{{ configItem.lastSucceededAt ? beijingTime(configItem.lastSucceededAt) : '尚无成功记录' }}</el-descriptions-item>
<el-descriptions-item label="结果发布时间">{{ configItem.lastPublishedAt ? beijingTime(configItem.lastPublishedAt) : '尚无可用数据' }}</el-descriptions-item>
<el-descriptions-item label="两个月目标">{{ beijingTime(configItem.initialFrom) }} 至 {{ beijingTime(configItem.initialUntil) }}</el-descriptions-item>
<el-descriptions-item label="首采完整覆盖">{{ label(configItem.initialCoverageStatus) }}</el-descriptions-item>
<el-descriptions-item label="连续覆盖截至">{{ beijingTime(configItem.salesCoveredUntil) }}</el-descriptions-item>
<el-descriptions-item label="数据新鲜度">{{ label(configItem.dataFreshness) }}</el-descriptions-item>
<el-descriptions-item label="库存采集区间" :span="2">{{ beijingTime(configItem.inventoryObservedFrom) }} 至 {{ beijingTime(configItem.inventoryObservedUntil) }}</el-descriptions-item>
<el-descriptions-item label="下次计划">{{ configItem.nextScheduledAt ? beijingTime(configItem.nextScheduledAt) : (configItem.executionEnabled ? '待确认' : '未启用') }}</el-descriptions-item>
<el-descriptions-item label="修订状态">{{ configItem.rebuildRequired ? '待重建' : label(configItem.revisionReviewStatus) }}</el-descriptions-item>
<el-descriptions-item label="账号冷却">{{ beijingTime(configItem.protection.accountCooldownUntil) }} {{ configItem.protection.accountPauseReason ?? '' }}</el-descriptions-item>
<el-descriptions-item label="验证会话">{{ validation(configItem.currentTask)?.validationSessionNo ?? '—' }}</el-descriptions-item>
<el-descriptions-item label="会话截止">{{ beijingTime(validation(configItem.currentTask)?.deadlineAt) }}</el-descriptions-item>
<el-descriptions-item label="停止原因">{{ validation(configItem.currentTask)?.stopReason ?? '—' }}</el-descriptions-item>
</el-descriptions>
<template v-if="configItem.dataFamily === 'INVENTORY' && configItem.inventoryDailySamples">
<p>每日库存观测:{{ configItem.inventoryDailySamples.windowFrom }} 至 {{ configItem.inventoryDailySamples.windowUntil }}(结束边界不含);数据积累起点 {{ configItem.inventoryDailySamples.accumulationStartedOn ?? '尚无日样本' }}。日样本只表示实际观测留存,不代表真实库龄。</p>
<details class="collection-gaps"><summary>库存日样本缺口({{ configItem.inventoryDailySamples.missingDates.length }} 天)</summary><p>缺口日期:{{ configItem.inventoryDailySamples.missingDates.length ? configItem.inventoryDailySamples.missingDates.join('、') : '无' }}</p></details>
</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 || '—' }} · {{ 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>
</section>
</div>
</AppDialog>
</AdminPage>
</template>
<style scoped>
.collection-workspace { display: grid; min-width: 0; gap: var(--admin-space-6); }
.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>