2026-10-12 20:33:39 +08:00
< script setup lang = "ts" >
import '@/styles/adminDensity.css'
2026-10-13 11:06:20 +08:00
import { computed , onActivated , onDeactivated , onMounted , onUnmounted , ref , watch } from 'vue'
2026-10-12 22:06:45 +08:00
import { useRoute , useRouter } from 'vue-router'
2026-10-12 20:33:39 +08:00
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'
2026-10-12 22:06:45 +08:00
import AppDialog from '@/shared/components/AppDialog.vue'
2026-10-12 20:33:39 +08:00
import { getApiErrorMessage , getApiErrorDetails , type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { analyticsApi } from '../api/analytics'
2026-10-13 16:52:09 +08:00
import CollectionTaskProgress from '../components/CollectionTaskProgress.vue'
import CollectionAccountMonitor from '../components/CollectionAccountMonitor.vue'
2026-10-13 12:50:29 +08:00
import TaskExecutionFacts from '../components/TaskExecutionFacts.vue'
2026-10-13 16:52:09 +08:00
import { taskStateText , taskProgressText } from '../collectionPresentation'
2026-10-12 22:06:45 +08:00
import type { Overview , Run , Task , TaskDetail , Validation , WarehouseOverview } from '../types'
2026-10-13 12:50:29 +08:00
import { RequestFence , RetryIntent , RuntimePoller , hasActiveTask , label , beijingTime , reasonsText , progressText , quantityText , taskRetryAllowed } from '../state'
2026-10-12 20:33:39 +08:00
const access = useAccessPreviewStore ()
const route = useRoute ()
2026-10-12 22:06:45 +08:00
const router = useRouter ()
2026-10-12 20:33:39 +08:00
const canList = computed (() => access . can ( 'platform:self-operated-analytics:list' ))
const canSync = computed (() => access . can ( 'platform:self-operated-analytics:sync' ))
const sourceAccount = 'lingxing-primary'
2026-10-13 16:52:09 +08:00
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 )
2026-10-12 20:33:39 +08:00
const warehouseCode = computed (() => String ( filters . value . warehouseCode ?? '' ))
const overview = ref < Overview | null >( null )
2026-10-12 22:06:45 +08:00
const configOpen = ref ( false )
const configKey = ref ( '' )
const configItem = computed (() => overview . value ? . warehouses . find ( item => ` ${ item . warehouseCode } : ${ item . dataFamily } ` === configKey . value ) ?? null )
2026-10-12 20:33:39 +08:00
const rows = ref < Task [] >([])
const current = ref ( 1 )
2026-10-13 16:52:09 +08:00
const size = ref ( 10 )
2026-10-12 20:33:39 +08:00
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 )
2026-10-12 22:06:45 +08:00
const taskOpen = ref ( false )
2026-10-12 20:33:39 +08:00
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 ( '' )
2026-10-13 16:52:09 +08:00
const detailErrorDetails = ref < ApiErrorDetails | null >( null )
2026-10-12 20:33:39 +08:00
const reason = ref ( '' )
const confirming = ref ( false )
const submitting = ref ( false )
const actionFeedback = ref ( '' )
const fence = new RequestFence ()
const detailFence = new RequestFence ()
2026-10-13 16:52:09 +08:00
const historyFence = new RequestFence ()
2026-10-12 20:33:39 +08:00
const actionFence = new RequestFence ()
const retryIntent = new RetryIntent ()
2026-10-13 11:06:20 +08:00
let active = false
2026-10-12 20:33:39 +08:00
const warehouses = computed (() => overview . value ? . warehouses . filter ( row => ! warehouseCode . value || row . warehouseCode === warehouseCode . value ) ?? [])
2026-10-13 16:52:09 +08:00
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 )
2026-10-13 12:50:29 +08:00
}
2026-10-13 16:52:09 +08:00
return [... sources . values ()]
2026-10-13 12:50:29 +08:00
})
2026-10-13 16:52:09 +08:00
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 : '粘贴完整任务号;先选择仓库' },
])
2026-10-12 22:06:45 +08:00
const hasSyntheticSamples = computed (() => warehouses . value . some ( item => item . warehouseCode . startsWith ( 'SELFOP-TEST-' )))
const overviewColumns : DataTableColumn [] = [
2026-10-13 16:52:09 +08:00
{ 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 },
2026-10-12 22:06:45 +08:00
{ key : 'dailySamples' , label : '库存日样本' , minWidth : 150 , slotName : 'dailySamples' },
]
2026-10-13 16:52:09 +08:00
const scope = computed (() => ({ sourceAccount , warehouseCode : selected . value ? . warehouseCode || warehouseCode . value }))
const retryEligible = computed (() => Boolean ( canSync . value && selected . value ? . allowedActions . includes ( 'RETRY' ) && ! selected . value . actionBlockReason ))
2026-10-12 20:33:39 +08:00
const retryAllowed = computed (() => taskRetryAllowed ( selected . value , canSync . value , scope . value , reason . value ))
const columns : DataTableColumn [] = [
2026-10-13 16:52:09 +08:00
{ 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 },
2026-10-12 20:33:39 +08:00
]
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 }
2026-10-12 22:06:45 +08:00
function asWarehouse ( row : unknown ) { return row as WarehouseOverview }
function openConfiguration ( item : WarehouseOverview ) { configKey . value = ` ${ item . warehouseCode } : ${ item . dataFamily } ` ; configOpen . value = true }
2026-10-13 16:52:09 +08:00
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 } 分钟推进` ) : '' }
2026-10-12 20:33:39 +08:00
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 }
2026-10-13 16:52:09 +08:00
async function read ( refreshHistory = false ) {
if ( ! active || ! canList . value || ! visible . value ) return false
2026-10-12 20:33:39 +08:00
const request = fence . begin ()
loading . value = ! overview . value
try {
const nextOverview = await analyticsApi . overview ( sourceAccount , undefined , request . signal )
2026-10-13 11:06:20 +08:00
if ( ! request . current ()) return false
2026-10-13 16:52:09 +08:00
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 () }
2026-10-12 20:33:39 +08:00
finally { if ( request . current ()) loading . value = false }
}
2026-10-13 16:52:09 +08:00
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
2026-10-12 20:33:39 +08:00
try {
2026-10-13 16:52:09 +08:00
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 } }
2026-10-12 20:33:39 +08:00
finally { if ( request . current ()) detailLoading . value = false }
}
2026-10-13 16:52:09 +08:00
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 () }
2026-10-12 20:33:39 +08:00
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 {
2026-10-13 16:52:09 +08:00
const accepted = await analyticsApi . retry ( retryIntent . create ( target . taskNo , target . warehouseCode , reason . value ), request . signal )
2026-10-12 20:33:39 +08:00
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 }
}
2026-10-13 16:52:09 +08:00
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 } }) }
2026-10-13 11:06:20 +08:00
watch ([() => route . path , () => route . query ], () => {
if ( route . path && route . path !== '/self-operated/collection' ) return
2026-10-13 16:52:09 +08:00
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 ()
2026-10-13 11:06:20 +08:00
})
2026-10-13 16:52:09 +08:00
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 () })
2026-10-12 20:33:39 +08:00
watch ( reason , () => { confirming . value = false })
2026-10-13 11:06:20 +08:00
function resume () { if ( active ) return ; active = true ; visibility (); poller . start () }
2026-10-13 16:52:09 +08:00
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 }
2026-10-13 11:06:20 +08:00
onMounted (() => { resume (); document . addEventListener ( 'visibilitychange' , visibility ) })
onActivated ( resume )
onDeactivated ( suspend )
onUnmounted (() => { suspend (); document . removeEventListener ( 'visibilitychange' , visibility ) })
2026-10-12 20:33:39 +08:00
</ script >
< template >
< AdminPage class = "admin-density" >
< p v-if = "!canList" role="alert" > 没有采集状态查看权限 。 </ p >
< ListPageLayout v-else >
2026-10-13 16:52:09 +08:00
< CollectionAccountMonitor :overview = "overview" :loading = "loading" :stale = "Boolean(error)" :visible = "visible" @refresh ="read()" />
< details class = "collection-secondary-tools" >< summary > 历史记录与排查 </ summary >
2026-10-12 22:06:45 +08:00
< div class = "collection-workspace" >
2026-10-13 16:52:09 +08:00
< 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 >
2026-10-12 20:33:39 +08:00
</ DataTable >
2026-10-13 16:52:09 +08:00
< 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 >
2026-10-12 22:06:45 +08:00
</ div >
2026-10-13 16:52:09 +08:00
</ details >
2026-10-12 20:33:39 +08:00
</ ListPageLayout >
2026-10-12 22:06:45 +08:00
< 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 >
2026-10-13 16:52:09 +08:00
< el-descriptions-item label = "首采目标范围" >{{ beijingTime ( configItem . initialFrom ) }} 至 {{ beijingTime ( configItem . initialUntil ) }}</ el-descriptions-item >
2026-10-12 22:06:45 +08:00
< 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 >
2026-10-13 12:50:29 +08:00
< p v-if = "configItem.sharedUpstream" > 数据准备任务 : {{ configItem.sharedUpstream.taskNo || '—' }} · {{ taskStateText ( configItem.sharedUpstream ) }} · {{ taskProgressText ( configItem.sharedUpstream ) }} 。 来源数据取完后 , 本仓还需校验 、 计算和发布 。 </ p >
2026-10-12 22:06:45 +08:00
< 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 >
2026-10-12 20:33:39 +08:00
</ AdminPage >
</ template >
2026-10-12 22:06:45 +08:00
< style scoped >
2026-10-13 16:52:09 +08:00
. 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 : 1 px solid var ( -- el - border - color ); padding - block : var ( -- admin - space - 4 ); margin - block : var ( -- admin - space - 4 ); }
2026-10-12 22:06:45 +08:00
. 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 ); }
2026-10-13 12:50:29 +08:00
. 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 : 1 px solid var ( -- el - border - color ); }
. collection - preparation small { flex - basis : 100 % ; }
2026-10-12 22:06:45 +08:00
. 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 ); }
2026-10-13 12:50:29 +08:00
. collection - upstream { border - block : 1 px solid var ( -- el - border - color ); padding - block : var ( -- admin - space - 3 ); }
2026-10-12 22:06:45 +08:00
</ style >