feat(self-operated): add charts and continuous collection status

This commit is contained in:
2026-10-13 20:44:31 +08:00
parent 698ca34529
commit 4c4a3db1eb
33 changed files with 780 additions and 211 deletions
@@ -11,7 +11,10 @@ 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]) ?? [])]
return [...new Map(all.filter((t): t is Task => Boolean(t && activeStates.has(t.taskStatus))).map(t => [t.taskNo, t])).values()]
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)
@@ -24,10 +27,22 @@ const upcoming = computed(() => {
}
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 (['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 '发布结果'
@@ -46,7 +61,7 @@ const idleReason = computed(() => {
})
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'] }])
] : [{ 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'
@@ -67,7 +82,7 @@ function nodeState(phases: string[]) {
<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><small>仅已知活动摘要;完整队列数量及下一任务顺序尚未提供。</small></aside></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>
@@ -94,10 +109,11 @@ footer { justify-content: space-between; margin-top: var(--admin-space-5); paddi
.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); }
.account-header { background: var(--el-bg-color); padding: 16px 24px; }
.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); }
.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; }
@@ -106,7 +122,7 @@ footer { justify-content: space-between; margin-top: var(--admin-space-5); paddi
.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); font-size: var(--admin-font-caption); line-height: 1.8; }
.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; }
@@ -118,21 +134,32 @@ footer { justify-content: space-between; margin-top: var(--admin-space-5); paddi
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); padding-top: 12px; display: grid; grid-template-columns: 28px 1fr; gap: 8px; align-items: center; }
.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); }
.account-flow li[data-state="done"] { border-color: var(--el-color-success); }
.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-radius: 6px; }
.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(.ring-value) { transition: stroke-dashoffset .5s ease; }
.account-monitor :deep(.collection-count-grid dd) { font-size: 36px; }
.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; } .account-monitor :deep(.ring-value) { transition: none; } }
@media (prefers-reduced-motion: reduce) { [data-running="true"] .account-live i { animation: none; } }
</style>