166 lines
19 KiB
Vue
166 lines
19 KiB
Vue
<script setup lang="ts">
|
||
import { computed } from 'vue'
|
||
import { Activity, Check, Clock3, Database, Pause, RefreshCw } from '@lucide/vue'
|
||
import type { Overview, Task } from '../types'
|
||
import CollectionTaskProgress from './CollectionTaskProgress.vue'
|
||
import { beijingTime, phaseText, label } from '../state'
|
||
import { taskStateText } from '../collectionPresentation'
|
||
|
||
const props = defineProps<{ overview: Overview | null; loading?: boolean; stale?: boolean; visible?: boolean }>()
|
||
defineEmits<{ refresh: [] }>()
|
||
const activeStates = new Set(['RUNNING', 'APPLYING', 'READY', 'QUEUED', 'WAITING', 'WAITING_SOURCE', 'WAITING_UPSTREAM', 'RETRY_WAIT', 'YIELDED'])
|
||
const tasks = computed(() => {
|
||
const all = [props.overview?.accountActivity, ...(props.overview?.warehouses.flatMap(w => [...(w.activeTasks ?? []), w.currentTask, w.sharedUpstream]) ?? [])]
|
||
const unique = new Map<string, Task>()
|
||
// Account activity is authoritative; warehouse references must not overwrite it.
|
||
for (const task of all) if (task && !unique.has(task.taskNo)) unique.set(task.taskNo, task)
|
||
return [...unique.values()].filter(t => activeStates.has(t.taskStatus))
|
||
})
|
||
const running = computed(() => tasks.value.filter(t => ['RUNNING', 'APPLYING'].includes(t.taskStatus)))
|
||
const current = computed(() => running.value[0] ?? tasks.value[0] ?? null)
|
||
const waiting = computed(() => tasks.value.filter(t => t.taskNo !== current.value?.taskNo && !['RUNNING', 'APPLYING'].includes(t.taskStatus)))
|
||
const upcoming = computed(() => {
|
||
const groups = new Map<string, number>()
|
||
for (const task of waiting.value) {
|
||
const name = `${task.warehouseCode || '账号'} · ${task.dataFamily === 'INVENTORY' ? '产品库存' : '出库销量'}`
|
||
groups.set(name, (groups.get(name) ?? 0) + 1)
|
||
}
|
||
return [...groups].map(([name, count]) => ({ name, count }))
|
||
})
|
||
const schedules = computed(() => {
|
||
if (props.overview?.collectionMode === 'OFF') return []
|
||
const groups = new Map<string, { family: string; at: string; warehouses: string[] }>()
|
||
for (const row of props.overview?.warehouses ?? []) {
|
||
if (!row.executionEnabled || !row.nextScheduledAt || !Number.isFinite(Date.parse(row.nextScheduledAt))) continue
|
||
const key = `${row.dataFamily}:${row.nextScheduledAt}`
|
||
const group = groups.get(key) ?? { family: row.dataFamily === 'INVENTORY' ? '产品库存' : '出库销量', at: row.nextScheduledAt, warehouses: [] }
|
||
if (!group.warehouses.includes(row.warehouseCode)) group.warehouses.push(row.warehouseCode)
|
||
groups.set(key, group)
|
||
}
|
||
return [...groups.values()].sort((a, b) => Date.parse(a.at) - Date.parse(b.at))
|
||
})
|
||
const nextStep = computed(() => {
|
||
const phase = current.value?.progress?.phase
|
||
if (['DIRECTORY', 'DISCOVERY'].includes(phase ?? '')) return '获取出库详情 → 更新各仓出库数据 → 统计计算 → 发布结果'
|
||
if (['DETAIL', 'DETAILS'].includes(phase ?? '')) return '更新各仓出库数据 → 统计计算 → 发布结果'
|
||
if (['INVENTORY', 'FETCH'].includes(phase ?? '')) return '库存完整性校验 → 统计计算 → 发布结果'
|
||
if (['VALIDATE', 'VALIDATION', 'MERGE'].includes(phase ?? '')) return '统计计算 → 发布结果'
|
||
if (['BUILD', 'BUILDING', 'BUILT'].includes(phase ?? '')) return '发布结果'
|
||
if (['PUBLISH', 'PUBLISHED'].includes(phase ?? '')) return '本次发布完成后,由调度继续安排后续任务'
|
||
return '等待调度确认下一步'
|
||
})
|
||
const scopeText = computed(() => current.value?.taskNo === props.overview?.accountActivity?.taskNo || current.value?.progress?.progressScope === 'ACCOUNT_SHARED' ? '领星账号 · 出库数据' : `${current.value?.warehouseCode || '领星账号'} · ${current.value?.dataFamily === 'INVENTORY' ? '产品库存' : '出库销量'}`)
|
||
const warehouseGroups = computed(() => [...new Set(props.overview?.warehouses.map(w => w.warehouseCode) ?? [])].map(code => ({ code, rows: props.overview!.warehouses.filter(w => w.warehouseCode === code) })))
|
||
const latestPublication = computed(() => (props.overview?.warehouses ?? []).filter(w => w.lastPublishedAt).sort((a, b) => Date.parse(b.lastPublishedAt!) - Date.parse(a.lastPublishedAt!))[0])
|
||
const idleReason = computed(() => {
|
||
if (!props.overview) return props.loading ? '正在连接,读取账号状态' : '暂未取得账号状态'
|
||
if (props.overview.collectionMode === 'OFF') return '采集已关闭'
|
||
if (props.overview.warehouses.some(w => w.protection?.accountPauseReason)) return '账号已暂停'
|
||
if (props.overview.warehouses.some(w => ['DEAD', 'FAILED', 'ABORTED'].includes(w.currentTask?.taskStatus ?? ''))) return '暂无活动任务,最近有任务停止'
|
||
return '等待下一次采集'
|
||
})
|
||
const flow = computed(() => current.value?.dataFamily === 'INVENTORY' ? [
|
||
{ name: '读取库存', phases: ['INVENTORY', 'FETCH'] }, { name: '完整性校验', phases: ['VALIDATE', 'VALIDATION'] }, { name: '统计计算', phases: ['BUILD', 'BUILDING'] }, { name: '发布结果', phases: ['PUBLISH', 'PUBLISHED'] },
|
||
] : [{ name: '获取出库目录', phases: ['DIRECTORY', 'DISCOVERY'] }, { name: '获取出库详情', phases: ['DETAIL', 'DETAILS'] }, { name: '更新各仓出库数据', phases: ['MERGE', 'VALIDATE', 'VALIDATION'] }, { name: '统计计算', phases: ['BUILD', 'BUILDING'] }, { name: '发布结果', phases: ['PUBLISH', 'PUBLISHED'] }])
|
||
function nodeState(phases: string[]) {
|
||
const p = current.value?.progress
|
||
if (!p) return 'idle'
|
||
const evidence = [p, ...(p.stages ?? [])].find(s => phases.includes(s.phase ?? '') && s.attemptNo != null && s.attemptNo === p.attemptNo && s.scanRound === p.scanRound && s.sliceFrom === p.sliceFrom && s.sliceUntil === p.sliceUntil && s.progressScope === p.progressScope && s.stageFinishedAt)
|
||
if (evidence) return 'done'
|
||
return phases.includes(p.phase ?? '') && running.value.includes(current.value!) ? 'active' : 'idle'
|
||
}
|
||
</script>
|
||
<template>
|
||
<section class="account-monitor" aria-label="领星账号运行进度" :data-running="running.length > 0 && !stale">
|
||
<header class="account-header"><div class="account-brand"><Database :size="22" /><div><h2>数据采集</h2><span>领星账号 · 运行看板</span></div></div><div class="account-controls"><span class="account-live"><i />{{ stale ? '更新中断' : !overview ? '连接中' : running.length ? '采集中' : '待机' }}</span><el-button :loading="loading" @click="$emit('refresh')"><RefreshCw :size="15" />刷新状态</el-button></div></header>
|
||
<div class="account-body">
|
||
<p v-if="stale" role="status">{{ overview ? '状态更新失败,以下保留最后一次读取的进度。' : '暂未取得账号状态,请稍后刷新。' }}</p>
|
||
<div class="account-main"><section class="account-primary">
|
||
<template v-if="current">
|
||
<div class="account-now"><div><span class="account-eyebrow">{{ running.length ? '正在进行' : '当前等待' }}</span><h3>{{ current.progress?.phase ? phaseText(current.progress.phase) : taskStateText(current) }}</h3><p>{{ scopeText }}<template v-if="current.queryFrom"> · {{ beijingTime(current.queryFrom) }} 至 {{ beijingTime(current.queryUntil) }}(结束时刻不含)</template></p></div><span class="account-status">{{ taskStateText(current) }}</span></div>
|
||
<CollectionTaskProgress :key="current.taskNo" :task="current" compact :stale="stale" :visible="visible" :observed-at="overview?.observedAt" />
|
||
<p v-if="running.length > 1">同时运行:<span v-for="task in running.slice(1)" :key="task.taskNo">{{ task.warehouseCode || '账号' }} · {{ phaseText(task.progress?.phase) }};</span></p>
|
||
</template>
|
||
<div v-else class="account-empty"><div class="standby-ring"><Pause :size="38" /><span>{{ loading ? '读取中' : !overview ? '未连接' : '待机' }}</span></div><div><span class="account-eyebrow">{{ overview ? '当前没有正在运行的采集' : '连接状态' }}</span><h3>{{ idleReason }}</h3><p>有新任务时自动更新进度。</p><div class="last-result"><Check :size="18" v-if="latestPublication" /><Clock3 :size="18" v-else /><div><strong>{{ latestPublication ? '最近发布结果' : '最近发布结果尚未提供' }}</strong><span v-if="latestPublication">{{ latestPublication.warehouseCode }} · {{ latestPublication.dataFamily === 'INVENTORY' ? '产品库存' : '出库销量' }}<br />{{ beijingTime(latestPublication.lastPublishedAt) }}</span></div></div></div></div>
|
||
</section><aside class="account-upnext"><span class="account-eyebrow">后续安排</span><h3>{{ !overview ? '暂未取得安排' : upcoming.length ? '等待处理' : '等待新任务' }}</h3><ul v-if="upcoming.length"><li v-for="group in upcoming" :key="group.name"><span>{{ group.name }}</span><b>{{ group.count }} 项</b></li></ul><p v-else>{{ overview ? '当前摘要中暂无其他待处理项' : '连接恢复后更新后续任务' }}</p><div v-if="current" class="account-next"><strong>本阶段之后</strong><span>{{ nextStep }}</span></div><div v-if="schedules.length" class="account-next account-schedules"><strong>{{ stale ? '上次自动采集计划' : '下次自动采集计划' }}</strong><span v-for="schedule in schedules" :key="schedule.family + schedule.at">{{ schedule.family }} · {{ schedule.warehouses.join(' / ') }}<br />{{ beijingTime(schedule.at) }}(北京时间)</span><small>计划时间不代表当前任务完成时间。</small></div><small>仅已知活动摘要;完整队列数量及下一任务顺序尚未提供。</small></aside></div>
|
||
<section class="account-flow"><div class="account-section-title"><h4><Activity :size="16" />处理阶段</h4><span>只标记已确认的推进状态</span></div><ol :style="{ gridTemplateColumns: `repeat(${flow.length}, minmax(0, 1fr))` }"><li v-for="(node, index) in flow" :key="node.name" :data-state="nodeState(node.phases)"><span class="step-index"><Check v-if="nodeState(node.phases) === 'done'" :size="16" /><template v-else>{{ index + 1 }}</template></span><strong>{{ node.name }}</strong><small>{{ nodeState(node.phases) === 'done' ? '已确认完成' : nodeState(node.phases) === 'active' ? '正在处理' : current || !overview ? '等待状态确认' : '待机' }}</small></li></ol></section>
|
||
<section class="account-results" v-if="warehouseGroups.length"><div class="account-section-title"><h4><Database :size="16" />各仓最新数据</h4><span>采集进度与报表发布分别确认</span></div><div class="warehouse-results"><article v-for="group in warehouseGroups" :key="group.code"><h4>{{ group.code }}</h4><div v-for="row in group.rows" :key="row.dataFamily" class="warehouse-result"><span>{{ row.dataFamily === 'INVENTORY' ? '产品库存' : '出库销量' }}<small>{{ row.lastPublishedAt ? beijingTime(row.lastPublishedAt) : '暂无发布结果' }}</small></span><span :class="{ 'result-ready': Boolean(row.lastPublishedAt) }">{{ row.lastPublishedAt ? '已有结果' : '尚未发布' }}<small>{{ row.lastPublishedAt ? label(row.dataFreshness) : '—' }}</small></span></div></article></div></section>
|
||
<footer><span>最近推进 {{ beijingTime(current?.progressUpdatedAt) }}</span><span>状态更新 {{ beijingTime(overview?.observedAt) }} · 北京时间</span></footer>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
<style scoped>
|
||
.account-monitor { color: var(--el-text-color-primary); background: var(--el-bg-color); border: 1px solid var(--el-border-color-light); border-radius: var(--admin-radius-lg, 12px); overflow: hidden; }
|
||
.account-header, .account-header > div, .account-now, .account-next, .account-pending, footer { display: flex; align-items: center; gap: var(--admin-space-4); }
|
||
.account-header { justify-content: space-between; padding: var(--admin-space-5) var(--admin-space-6); border-bottom: 1px solid var(--el-border-color-light); }
|
||
h2, h3, p { margin: 0; }
|
||
h2 { font-size: var(--admin-font-section); }
|
||
h3 { font-size: 24px; margin: var(--admin-space-2) 0; }
|
||
.account-body { padding: 24px; }
|
||
.account-header span, p, footer, small { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
|
||
.account-now { justify-content: space-between; align-items: flex-start; }
|
||
.account-eyebrow { color: var(--el-color-primary); font-size: var(--admin-font-caption); }
|
||
.account-status { color: var(--el-color-primary); background: var(--el-color-primary-light-9); padding: var(--admin-space-2) var(--admin-space-3); border-radius: var(--admin-radius); white-space: nowrap; }
|
||
.account-next { padding: var(--admin-space-4); background: var(--el-fill-color-light); border-left: 3px solid var(--el-color-primary); }
|
||
.account-next small { display: block; margin-top: var(--admin-space-2); }
|
||
.account-pending { flex-wrap: wrap; margin-top: var(--admin-space-5); }
|
||
.account-pending small { flex-basis: 100%; }
|
||
footer { justify-content: space-between; margin-top: var(--admin-space-5); padding-top: var(--admin-space-4); border-top: 1px solid var(--el-border-color-light); }
|
||
.account-main { display: grid; grid-template-columns: minmax(0, 1fr) 290px; gap: 32px; }
|
||
.account-primary { min-width: 0; }
|
||
.account-brand, .account-controls, .account-live, .account-section-title, h4 { display: flex; align-items: center; gap: 10px; }
|
||
.account-brand > svg { color: var(--el-color-primary); background: var(--el-color-primary-light-9); padding: var(--admin-space-3); box-sizing: content-box; border-radius: var(--admin-radius-lg); }
|
||
.account-header { background: linear-gradient(105deg, var(--el-color-primary-light-9), var(--el-bg-color) 65%); padding: 16px 24px; border-top: 3px solid var(--el-color-primary); }
|
||
.account-header h2 { margin-bottom: 4px; }
|
||
.account-live { color: var(--el-text-color-secondary); background: var(--el-fill-color-light); padding: var(--admin-space-2) var(--admin-space-3); border-radius: var(--admin-radius-lg); }
|
||
[data-running="true"] .account-live { color: var(--el-color-success-dark-2); background: var(--el-color-success-light-9); }
|
||
.account-live i { width: 7px; height: 7px; border-radius: 50%; background: var(--el-text-color-secondary); }
|
||
[data-running="true"] .account-live i { background: var(--el-color-success); animation: signal 2s ease-in-out infinite; }
|
||
.account-controls :deep(.el-button) { gap: 8px; }
|
||
.account-upnext { border-left: 1px solid var(--el-border-color-light); padding-left: 24px; }
|
||
.account-upnext h3 { font-size: 20px; margin: 8px 0 18px; }
|
||
.account-upnext ul { padding: 0; margin: 0; list-style: none; }
|
||
.account-upnext li { display: flex; justify-content: space-between; padding: 12px 0; gap: 8px; border-bottom: 1px solid var(--el-border-color-light); font-size: var(--admin-font-caption); }
|
||
.account-upnext > small { display: block; margin-top: 18px; line-height: 1.7; }
|
||
.account-next { display: grid; padding: 14px; margin-top: 20px; border-left-color: var(--el-color-primary); background: var(--el-color-primary-light-9); border-radius: var(--admin-radius); font-size: var(--admin-font-caption); line-height: 1.8; }
|
||
.account-empty { display: grid; grid-template-columns: 180px minmax(0, 1fr); align-items: center; gap: 32px; min-height: 220px; }
|
||
.standby-ring { width: 168px; height: 168px; border: 10px solid var(--el-border-color-light); border-radius: 50%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; color: var(--el-text-color-secondary); box-sizing: border-box; }
|
||
.last-result { display: flex; gap: 12px; padding: 16px; margin-top: 24px; background: var(--el-fill-color-light); border-radius: 6px; }
|
||
.last-result > svg { color: var(--el-color-success); }
|
||
.last-result strong, .last-result span { display: block; font-size: var(--admin-font-caption); line-height: 1.8; }
|
||
.last-result span { color: var(--el-text-color-secondary); }
|
||
.account-flow, .account-results { margin-top: 26px; padding-top: 22px; border-top: 1px solid var(--el-border-color-light); }
|
||
.account-section-title { justify-content: space-between; margin-bottom: 20px; }
|
||
h4 { margin: 0; font-size: var(--admin-font-body); }
|
||
.account-section-title > span { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
|
||
.account-flow ol { display: grid; gap: 16px; list-style: none; margin: 0; padding: 0; }
|
||
.account-flow li { border-top: 2px solid var(--el-border-color-light); border-radius: var(--admin-radius); background: var(--el-fill-color-lighter); padding: var(--admin-space-3); display: grid; grid-template-columns: 28px 1fr; gap: 8px; align-items: center; }
|
||
.account-flow li small { grid-column: 2; }
|
||
.account-flow li strong { font-size: var(--admin-font-body); }
|
||
.step-index { display: grid; place-items: center; border-radius: 50%; width: 26px; height: 26px; background: var(--el-fill-color); font-size: var(--admin-font-caption); }
|
||
.account-flow li[data-state="active"] { border-color: var(--el-color-primary); color: var(--el-color-primary); background: var(--el-color-primary-light-9); }
|
||
.account-flow li[data-state="active"] .step-index { background: var(--el-color-primary); color: white; }
|
||
.account-flow li[data-state="done"] { border-color: var(--el-color-success); background: var(--el-color-success-light-9); }
|
||
.account-flow li[data-state="done"] .step-index { background: var(--el-color-success); color: white; }
|
||
.warehouse-results { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; }
|
||
.warehouse-results article { background: var(--el-fill-color-lighter); padding: 16px 20px; border: 1px solid var(--el-border-color-light); border-top: 3px solid var(--el-color-primary); border-radius: var(--admin-radius-lg); }
|
||
.warehouse-results article:nth-child(2n) { border-top-color: var(--el-color-success); }
|
||
.warehouse-results article h4 { color: var(--el-color-primary); }
|
||
.warehouse-results article:nth-child(2n) h4 { color: var(--el-color-success-dark-2); }
|
||
.warehouse-result { display: flex; justify-content: space-between; gap: 12px; padding-top: 14px; font-size: var(--admin-font-body); }
|
||
.warehouse-result small { display: block; margin-top: 4px; }
|
||
.warehouse-result > span:last-child { text-align: right; }
|
||
.result-ready { color: var(--el-color-success); }
|
||
.account-monitor :deep(.collection-count-grid > div) { --metric-color: var(--el-color-primary); --metric-tint: var(--el-color-primary-light-9); position: relative; overflow: hidden; padding: var(--admin-space-4); border: 1px solid var(--metric-tint); border-radius: var(--admin-radius-lg); background: linear-gradient(135deg, var(--el-bg-color), var(--metric-tint)); }
|
||
.account-monitor :deep(.collection-count-grid > div:nth-child(2)) { --metric-color: var(--el-color-success-dark-2); --metric-tint: var(--el-color-success-light-9); }
|
||
.account-monitor :deep(.collection-count-grid > div:nth-child(3)) { --metric-color: var(--el-color-warning-dark-2); --metric-tint: var(--el-color-warning-light-9); }
|
||
.account-monitor :deep(.collection-count-grid > div::before) { content: ''; position: absolute; right: -18px; top: -24px; width: 86px; height: 86px; border-radius: 50%; background: var(--metric-color); opacity: .1; pointer-events: none; }
|
||
.account-monitor :deep(.collection-count-grid dt), .account-monitor :deep(.collection-count-grid dd) { position: relative; }
|
||
.account-monitor :deep(.collection-count-grid dd) { font-size: 36px; color: var(--metric-color); white-space: nowrap; }
|
||
.account-monitor :deep(.collection-ring) { background: var(--el-color-primary-light-9); border-radius: 50%; }
|
||
.account-monitor :deep(.ring-label strong) { color: var(--el-color-primary); }
|
||
.account-monitor :deep(.collection-estimate) { padding: 16px; border-radius: 6px; }
|
||
@keyframes signal { 50% { opacity: .45; } }
|
||
@media (prefers-reduced-motion: reduce) { [data-running="true"] .account-live i { animation: none; } }
|
||
</style>
|