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

353 lines
38 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 CollectionTaskProgress from '../components/CollectionTaskProgress.vue'
import CollectionAccountMonitor from '../components/CollectionAccountMonitor.vue'
import TaskExecutionFacts from '../components/TaskExecutionFacts.vue'
import { taskStateText, taskProgressText } 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 : '', dataFamily: '', taskNo: typeof route.query.taskNo === 'string' ? route.query.taskNo : '' })
const dataFamily = computed(() => (filters.value.dataFamily || undefined) as 'OUTBOUND' | 'INVENTORY' | undefined)
const focusNo = ref(typeof route.query.taskNo === 'string' ? route.query.taskNo : '')
const manualFocus = ref(Boolean(focusNo.value))
const historyLoaded = ref(false)
const historyOpen = ref(false)
const visible = ref(!document.hidden)
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(10)
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 detailErrorDetails = ref<ApiErrorDetails | null>(null)
const reason = ref('')
const confirming = ref(false)
const submitting = ref(false)
const actionFeedback = ref('')
const fence = new RequestFence()
const detailFence = new RequestFence()
const historyFence = 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 activityTasks = computed(() => warehouses.value.flatMap(item => item.activeTasks ?? []).filter(task => !dataFamily.value || task.dataFamily === dataFamily.value))
const focusWarehouse = computed(() => overview.value?.warehouses.find(item => item.warehouseCode === selected.value?.warehouseCode && item.dataFamily === selected.value?.dataFamily) ?? null)
const sharedSources = computed(() => {
const sources = new Map<string, { task: Task; warehouses: string[] }>()
for (const row of warehouses.value) {
if (dataFamily.value && row.dataFamily !== dataFamily.value) continue
const source = row.sharedUpstream
if (!source || !['WAITING_CONFIRMED', 'SATISFIED', 'FAILED'].includes(row.currentTask?.dependencyState ?? '')) continue
const item = sources.get(source.taskNo) ?? { task: source, warehouses: [] }
if (!item.warehouses.includes(row.warehouseCode)) item.warehouses.push(row.warehouseCode)
sources.set(source.taskNo, item)
}
return [...sources.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()] },
{ key: 'dataFamily', label: '采集内容', type: 'select', width: 160, options: [{ value: '', label: '全部采集内容' }, { value: 'OUTBOUND', label: '出库销量' }, { value: 'INVENTORY', label: '产品库存' }] },
{ key: 'taskNo', label: '任务号定位', type: 'input', width: 320, placeholder: '粘贴完整任务号;先选择仓库' },
])
const hasSyntheticSamples = computed(() => warehouses.value.some(item => item.warehouseCode.startsWith('SELFOP-TEST-')))
const overviewColumns: DataTableColumn[] = [
{ key: 'configuration', label: '操作', width: 100, fixed: 'left', slotName: 'configuration' },
{ key: 'warehouse', label: '仓库/采集内容', minWidth: 160, slotName: 'warehouse' },
{ key: 'execution', label: '当前活动', minWidth: 220, slotName: 'execution', showOverflowTooltip: false },
{ key: 'publication', label: '最近可用数据', minWidth: 220, slotName: 'publication', showOverflowTooltip: false },
{ key: 'coverage', label: '覆盖/新鲜度', minWidth: 240, slotName: 'coverage', showOverflowTooltip: false },
{ key: 'dailySamples', label: '库存日样本', minWidth: 150, slotName: 'dailySamples' },
]
const scope = computed(() => ({ sourceAccount, warehouseCode: selected.value?.warehouseCode || warehouseCode.value }))
const retryEligible = computed(() => Boolean(canSync.value && selected.value?.allowedActions.includes('RETRY') && !selected.value.actionBlockReason))
const retryAllowed = computed(() => taskRetryAllowed(selected.value, canSync.value, scope.value, reason.value))
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' },
{ key: 'identity', label: '任务/范围', minWidth: 265, slotName: 'identity', showOverflowTooltip: false },
{ key: 'range', label: '数据时间段', minWidth: 170, slotName: 'range', showOverflowTooltip: false },
{ key: 'progress', label: '当前阶段与进度', minWidth: 220, slotName: 'progress', showOverflowTooltip: false },
{ key: 'advance', label: '最近进展', minWidth: 180, slotName: 'advance', showOverflowTooltip: false },
{ key: 'result', label: '执行/发布结果', minWidth: 170, slotName: 'result', showOverflowTooltip: false },
]
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 shortDate(value: string | null) { return value ? beijingTime(value).split(' ')[0] : '—' }
function advanceAge(task: Task) { const at = task.progressUpdatedAt || task.progress?.progressUpdatedAt; if (!at || !observedAt.value) return ''; const minutes = Math.floor((Date.parse(observedAt.value) - Date.parse(at)) / 60000); return Number.isFinite(minutes) && minutes >= 0 ? (minutes < 1 ? '本次读取前1分钟内推进' : `本次读取前${minutes}分钟推进`) : '' }
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(refreshHistory = false) {
if (!active || !canList.value || !visible.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
overview.value = nextOverview; observedAt.value = nextOverview.observedAt
if (warehouseCode.value && (refreshHistory || !historyLoaded.value)) {
const tasks = await analyticsApi.tasks({ sourceAccount, warehouseCode: warehouseCode.value, dataFamily: dataFamily.value, current: current.value, size: size.value, ...upperBound.value }, request.signal)
if (!request.current()) return false
rows.value = tasks.records; total.value = tasks.total; historyLoaded.value = true
upperBound.value = { listUpperCreatedAt: tasks.listUpperCreatedAt, listUpperId: tasks.listUpperId }
}
error.value = ''; errorDetails.value = null
if (!manualFocus.value && activityTasks.value[0] && focusNo.value !== activityTasks.value[0].taskNo) { resetFocusState(); selected.value = activityTasks.value[0]; selectedDetail.value = null; focusNo.value = selected.value.taskNo }
if (focusNo.value && !submitting.value) await readFocus()
return isBusy()
} catch (cause) { if (request.current()) { errorDetails.value = getApiErrorDetails(cause, '状态更新失败,保留最后读取数据'); error.value = errorDetails.value.message }; return isBusy() }
finally { if (request.current()) loading.value = false }
}
function isBusy() { const tasks = [...activityTasks.value, ...(overview.value?.accountActivity ? [overview.value.accountActivity] : []), ...(selected.value ? [selected.value] : []), ...(selectedDetail.value?.sharedUpstream ? [selectedDetail.value.sharedUpstream] : [])]; return hasActiveTask(tasks) || tasks.some(t => ['READY', 'APPLYING'].includes(t.taskStatus)) }
async function readFocus() {
const taskNo = focusNo.value, warehouse = selected.value?.warehouseCode || warehouseCode.value
if (!active || !visible.value || !taskNo || !warehouse) return
const request = detailFence.begin(); detailLoading.value = true
try {
const detail = await analyticsApi.task({ sourceAccount, warehouseCode: warehouse }, taskNo, request.signal)
if (!request.current() || taskNo !== focusNo.value) return
if (detail.task.taskNo !== taskNo || detail.task.warehouseCode !== warehouse) throw new Error('任务归属不匹配,请核对所选仓库')
selected.value = detail.task; selectedDetail.value = detail; detailError.value = ''; detailErrorDetails.value = null
} catch (cause) { if (request.current()) { detailErrorDetails.value = getApiErrorDetails(cause, selectedDetail.value ? '关注任务状态更新失败,保留最后有效观测' : '任务首次读取失败,请核对仓库和完整任务号后重试'); detailError.value = detailErrorDetails.value.message } }
finally { if (request.current()) detailLoading.value = false }
}
async function readAttempts() {
if (!active || !visible.value || !selected.value || !historyOpen.value || !Number(selected.value.attemptCount)) return
const taskNo = selected.value.taskNo, request = historyFence.begin()
try {
const history = await analyticsApi.runs({ ...scope.value, taskNo, current: attemptsPage.value, size: attemptsSize.value, ...attemptsUpperBound.value }, request.signal)
if (!request.current() || taskNo !== selected.value?.taskNo) return
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, '执行记录读取失败') }
}
async function selectTask(task: Task, reset = true) {
if (!active || !task.taskNo || (warehouseCode.value && task.warehouseCode !== warehouseCode.value)) return
if (reset) { manualFocus.value = true; selected.value = task; focusNo.value = task.taskNo; selectedDetail.value = null; resetFocusState(); taskOpen.value = true; syncFocusRoute(task.warehouseCode, task.taskNo) }
await readFocus()
}
function resetFocusState() { detailFence.cancel(); historyFence.cancel(); actionFence.cancel(); detailError.value = ''; detailErrorDetails.value = null; submitting.value = false; attempts.value = []; attemptsTotal.value = 0; attemptsPage.value = 1; attemptsUpperBound.value = null; historyOpen.value = false; reason.value = ''; confirming.value = false; actionFeedback.value = '' }
function syncFocusRoute(warehouse: string, taskNo: string) { if (route.query.warehouseCode !== warehouse || route.query.taskNo !== taskNo) void router.push({ path: '/self-operated/collection', query: { warehouseCode: warehouse, ...(taskNo ? { taskNo } : {}) } }) }
async function locateTask() {
const taskNo = String(filters.value.taskNo ?? '').trim()
if (!taskNo) return
if (!warehouseCode.value) { detailError.value = '请先选择仓库,再定位完整任务号'; return }
manualFocus.value = true; focusNo.value = taskNo; selected.value = null; selectedDetail.value = null; resetFocusState(); syncFocusRoute(warehouseCode.value, taskNo); await readFocus()
}
async function returnActivity() { manualFocus.value = false; focusNo.value = ''; selected.value = null; selectedDetail.value = null; filters.value.taskNo = ''; resetFocusState(); syncFocusRoute(warehouseCode.value, ''); await read() }
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, target.warehouseCode, 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(() => read(), () => { fence.cancel(); detailFence.cancel(); historyFence.cancel() })
function visibility() { visible.value = !document.hidden; poller.setVisible(visible.value) }
function refresh() { upperBound.value = null; current.value = 1; historyLoaded.value = false; if (String(filters.value.taskNo ?? '').trim()) void locateTask(); void read(true) }
function openAnalysis(path: string) { void router.push({ path, query: { warehouseCode: warehouseCode.value || selected.value?.warehouseCode } }) }
watch([() => route.path, () => route.query], () => {
if (route.path && route.path !== '/self-operated/collection') return
const warehouse = typeof route.query.warehouseCode === 'string' ? route.query.warehouseCode : ''
const taskNo = typeof route.query.taskNo === 'string' ? route.query.taskNo : ''
if (warehouse === warehouseCode.value && taskNo === focusNo.value) return
filters.value.warehouseCode = warehouse; filters.value.taskNo = taskNo
selected.value = null; selectedDetail.value = null; focusNo.value = taskNo; manualFocus.value = Boolean(taskNo); resetFocusState(); void read()
})
watch([warehouseCode, dataFamily], () => { configOpen.value = false; taskOpen.value = false; fence.cancel(); resetFocusState(); selected.value = null; selectedDetail.value = null; focusNo.value = String(filters.value.taskNo ?? '').trim(); manualFocus.value = Boolean(focusNo.value); rows.value = []; total.value = 0; upperBound.value = null; historyLoaded.value = false; current.value = 1; void read() })
watch(taskOpen, open => { if (!open) { historyFence.cancel(); historyOpen.value = false; confirming.value = false; reason.value = ''; actionFeedback.value = '' } })
watch([current, size], () => { historyLoaded.value = false; void read(true) })
watch([attemptsPage, attemptsSize], () => { void readAttempts() })
watch(historyOpen, open => { if (open) void readAttempts(); else historyFence.cancel() })
watch(reason, () => { confirming.value = false })
function resume() { if (active) return; active = true; visibility(); poller.start() }
function suspend() { active = false; visible.value = 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>
<CollectionAccountMonitor :overview="overview" :loading="loading" :stale="Boolean(error)" :visible="visible" @refresh="read()" />
<details class="collection-secondary-tools"><summary>历史记录与排查</summary>
<div class="collection-workspace">
<DataTable appearance="integrated" :data="warehouses.filter(row => !dataFamily || row.dataFamily === dataFamily)" :columns="overviewColumns" :loading="loading" empty-text="暂无仓库采集状态">
<template #toolbar-start><h2>各仓数据可用情况</h2></template>
<template #toolbar-actions><el-button :disabled="!warehouseCode && !selected" @click="openAnalysis('/self-operated/sales')">查看销量</el-button><el-button :disabled="!warehouseCode && !selected" @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>{{ overview?.collectionMode === 'OFF' ? '自动采集已关闭,当前仅查看已留存结果。' : `采集模式:${label(overview?.collectionMode)}` }}</span><span v-if="hasSyntheticSamples">包含合成测试样本,不代表真实采集覆盖。</span><span class="collection-observed">状态读取 {{ beijingTime(observedAt) }}(北京时间)</span></div>
<section class="collection-focus" aria-label="当前关注任务">
<div class="density-actions"><h2>{{ manualFocus ? '当前关注任务 · 手动选择/历史记录' : '当前关注任务' }}</h2><el-button v-if="selected" @click="taskOpen = true">查看关注任务详情</el-button><el-button v-if="manualFocus" @click="returnActivity">返回当前活动</el-button></div>
<p v-if="focusNo" class="collection-task-no">{{ focusNo }}</p>
<div v-if="activityTasks.length > 1" class="density-actions"><span>本范围 {{ activityTasks.length }} 个活动/等待任务</span><el-button v-for="task in activityTasks" :key="task.taskNo" :aria-label="`关注 ${task.taskNo}`" @click="selectTask(task)">{{ task.taskNo }}</el-button></div>
<p v-if="detailLoading && !selectedDetail">正在读取关注任务…</p><p v-if="detailError" role="alert">{{ detailError }}<ApiErrorMeta v-if="detailErrorDetails" :details="detailErrorDetails" /></p>
<CollectionTaskProgress v-if="selected" :task="selected" :detail="selectedDetail" :warehouse="focusWarehouse" :stale="Boolean(error || detailError)" :visible="visible" :observed-at="observedAt" />
<p v-else-if="!detailLoading && !detailError">{{ overview ? '暂无已确认的活动任务;可选择仓库后定位完整任务号或查看历史记录。' : '尚未读取采集任务。' }}</p>
</section>
<section v-for="source in sharedSources" :key="source.task.taskNo" aria-label="来源数据准备任务" class="collection-preparation">
<template v-if="source.task.taskNo === selectedDetail?.sharedUpstream?.taskNo">关注任务的关联来源</template><template v-else>关联来源 {{ source.task.taskNo }} · 账号共享 · {{ taskStateText(source.task) }} · {{ taskProgressText(source.task) }}</template>;关联仓库 {{ source.warehouses.join('、') }}。共享数量只记一次,不计入各仓完成量。
</section>
<p v-if="overview?.accountActivity && overview.accountActivity.taskNo !== selectedDetail?.sharedUpstream?.taskNo && !sharedSources.some(source => source.task.taskNo === overview?.accountActivity?.taskNo)" class="collection-preparation">账号正在执行其他采集:{{ overview.accountActivity.taskNo }} · {{ taskStateText(overview.accountActivity) }}。与当前任务的依赖尚未确认,不作为本任务进度。</p>
</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 }}<small class="collection-secondary">{{ family(asWarehouse(row).dataFamily) }} · {{ asWarehouse(row).executionEnabled ? '采集已启用' : '采集已关闭' }}</small></template>
<template #execution="{ row }">{{ asWarehouse(row).activeTasks?.length ? `${asWarehouse(row).activeTasks!.length}个活动/等待任务` : '暂无已确认活动' }}<small v-if="asWarehouse(row).currentTask" class="collection-secondary">{{ taskStateText(asWarehouse(row).currentTask) }} · {{ taskProgressText(asWarehouse(row).currentTask) }}</small></template>
<template #publication="{ row }">{{ asWarehouse(row).lastPublishedAt ? beijingTime(asWarehouse(row).lastPublishedAt) : '尚无可用结果' }}<small class="collection-secondary">本次任务:{{ label(asWarehouse(row).currentTask?.resultPublicationState) }}</small></template>
<template #coverage="{ row }">{{ asWarehouse(row).dataFamily === 'INVENTORY' ? '库存观测截至' : '销量覆盖截至(不含)' }} {{ beijingTime(asWarehouse(row).dataFamily === 'INVENTORY' ? asWarehouse(row).inventoryObservedUntil : asWarehouse(row).salesCoveredUntil) }}<small class="collection-secondary">{{ label(asWarehouse(row).dataFreshness) }}</small></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 #identity="{ row }"><span class="collection-task-no">{{ asTask(row).taskNo }}</span><small class="collection-secondary">{{ asTask(row).warehouseCode }} · {{ family(asTask(row).dataFamily) }} · {{ label(asTask(row).executionPurpose) }}</small></template>
<template #range="{ row }">{{ shortDate(asTask(row).queryFrom) }} 至 {{ shortDate(asTask(row).queryUntil) }}<small class="collection-secondary">结束时刻不含;精确范围见详情</small></template>
<template #progress="{ row }">{{ taskStateText(asTask(row)) }}<small class="collection-secondary">{{ taskProgressText(asTask(row)) }}</small></template>
<template #advance="{ row }">{{ asTask(row).attemptCount ? beijingTime(asTask(row).progressUpdatedAt || asTask(row).progress?.progressUpdatedAt) : '本仓尚未开始执行' }}<small class="collection-secondary">{{ advanceAge(asTask(row)) }}</small></template>
<template #result="{ row }">{{ taskStateText(asTask(row)) }} · {{ label(asTask(row).resultPublicationState) }}</template>
</DataTable>
<AppDialog v-model="taskOpen" class="admin-density-dialog" :title="`采集任务详情 · ${selected?.taskNo || focusNo || '—'}`" width="1200px" :loading="detailLoading" :confirm-loading="submitting" :show-confirm="false" cancel-text="关闭" fullscreenable>
<section v-if="selected" class="collection-details admin-density" aria-label="选中任务详情">
<p v-if="detailError" role="alert">{{ detailError }}<ApiErrorMeta v-if="detailErrorDetails" :details="detailErrorDetails" /></p>
<CollectionTaskProgress :task="selected" :detail="selectedDetail" :warehouse="focusWarehouse" :stale="Boolean(error || detailError)" :visible="visible && taskOpen" :observed-at="observedAt" />
<p v-if="!Number(selected.attemptCount)">本仓尚未开始执行;来源准备进度见上方。</p>
<details v-else :open="historyOpen" @toggle="historyOpen = ($event.target as HTMLDetailsElement).open"><summary>执行记录({{ selected.attemptCount }}次)</summary>
<DataTable v-if="historyOpen" appearance="integrated" v-model:page="attemptsPage" v-model:page-size="attemptsSize" :data="attempts" :columns="runColumns" :total="attemptsTotal" 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>
</details>
<details><summary>校验与诊断</summary><TaskExecutionFacts :task="selected" scope-label="本仓任务" /><p>操作资格:{{ selected.actionBlockReason ? blockReason(selected.actionBlockReason) : '以服务端允许操作为准' }};下次重试 {{ beijingTime(selected.nextRetryAt) }}。</p><p>账号冷却 {{ beijingTime(selectedDetail?.protection.accountCooldownUntil) }};{{ selectedDetail?.protection.accountPauseReason ?? '未提供暂停原因' }}</p><p v-for="(stage, index) in selected.progress?.stages ?? []" :key="index">{{ progressText(stage) }}</p><TaskExecutionFacts v-if="selectedDetail?.sharedUpstream && selected.progressSourceTaskNo === selectedDetail.sharedUpstream.taskNo" :task="selectedDetail.sharedUpstream" scope-label="账号共享来源" /></details>
<form v-if="retryEligible" 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" />
<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-if="!canSync">没有任务恢复权限。</p><p v-if="actionFeedback" role="status">{{ actionFeedback }}</p>
</section>
</AppDialog>
</div>
</details>
</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-secondary-tools > summary { cursor: pointer; color: var(--el-text-color-secondary); padding: var(--admin-space-3) 0; font-size: var(--admin-font-caption); }
.collection-task-no { overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
.collection-focus { border-block: 1px solid var(--el-border-color); padding-block: var(--admin-space-4); margin-block: var(--admin-space-4); }
.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>