refactor(self-operated): simplify collection account monitor
This commit is contained in:
@@ -12,6 +12,15 @@ function overview(account: Task | null = source): Overview {
|
||||
}
|
||||
const options = { global: { stubs: { 'el-button': true } } }
|
||||
describe('single account collection monitor', () => {
|
||||
it('does not claim idle or an empty queue when the first status read fails', () => {
|
||||
const w = mount(CollectionAccountMonitor, { ...options, props: { overview: null, stale: true } })
|
||||
expect(w.text()).toContain('暂未取得账号状态')
|
||||
expect(w.text()).toContain('暂未取得安排')
|
||||
expect(w.text()).not.toContain('待机')
|
||||
expect(w.text()).not.toContain('保留最后一次')
|
||||
expect(w.text()).not.toContain('暂无其他待处理项')
|
||||
w.unmount()
|
||||
})
|
||||
it('shows account work once despite two waiting warehouses and keeps the phase counts', () => {
|
||||
const w = mount(CollectionAccountMonitor, { ...options, props: { overview: overview() } })
|
||||
expect(w.findAllComponents(CollectionTaskProgress)).toHaveLength(1)
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
<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 } from '../state'
|
||||
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.sharedUpstream]) ?? [])]
|
||||
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 running = computed(() => tasks.value.filter(t => ['RUNNING', 'APPLYING'].includes(t.taskStatus)))
|
||||
@@ -34,32 +35,53 @@ const nextStep = computed(() => {
|
||||
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="领星账号运行进度">
|
||||
<header class="account-header"><div><h2>数据采集</h2><span>领星账号</span></div><el-button :loading="loading" @click="$emit('refresh')">刷新状态</el-button></header>
|
||||
<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">状态更新失败,以下保留最后一次读取的进度。</p>
|
||||
<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" />
|
||||
<div class="account-next"><strong>后续处理</strong><span>{{ nextStep }}<small>流程参考,实际切换由调度确认。</small></span></div>
|
||||
<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"><h3>{{ loading ? '正在读取采集状态…' : overview ? '当前没有正在运行的采集' : '尚未读取到采集状态' }}</h3><p>{{ overview?.collectionMode === 'OFF' ? '采集已关闭,已有报表仍可查看。' : '运行任务会在这里自动更新。' }}</p></div>
|
||||
<div v-if="overview" class="account-pending"><strong>已知待处理</strong><span v-for="group in upcoming" :key="group.name">{{ group.name }} <b>{{ group.count }}</b> 项</span><span v-if="!upcoming.length">当前摘要中暂无其他待处理项</span><small>仅当前活动摘要;完整队列数量及下一任务顺序尚未提供。</small></div>
|
||||
<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 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 { background: var(--admin-surface, #fff); border: 1px solid var(--el-border-color-light); border-radius: var(--admin-radius); overflow: hidden; }
|
||||
.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 { max-width: 1180px; margin-inline: auto; padding: var(--admin-space-6); }
|
||||
.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); }
|
||||
@@ -69,5 +91,48 @@ h3 { font-size: 24px; margin: var(--admin-space-2) 0; }
|
||||
.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-empty { padding-block: var(--admin-space-6); }
|
||||
.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-header h2 { margin-bottom: 4px; }
|
||||
.account-live { color: var(--el-text-color-secondary); }
|
||||
.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); 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); padding-top: 12px; 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); }
|
||||
.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-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-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; } }
|
||||
</style>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
| 页面 | 操作 | 依据与承载结论 | 布局与验证 |
|
||||
| --- | --- | --- | --- |
|
||||
| /self-operated/collection | 单账号运行首屏 | 2026-10-13 最新实际页面及原型截图:首屏改为账号活动优先,环形进度、总量/完成/剩余及阶段预计时间;历史任务定位不替换首屏,原筛选、配置、历史与诊断默认折叠 | 沿用浅色Admin样式与既有详情弹框;后续流程不冒充调度顺序,有界待处理摘要不冒充完整队列;待人工1920×1080、100%截图确认 |
|
||||
| /self-operated/collection | 单账号运行首屏 | 2026-10-13 最新实际页面及原型截图:首屏改为账号活动优先,环形进度、总量/完成/剩余及阶段预计时间;移除原筛选、配置、历史、详情和恢复表单,只读账号概览;待机保留阶段、最近发布及各仓结果 | 按用户原型排版,使用现有 Admin 白色卡片、主题强调色及统一按钮,无详情弹框;本行覆盖下方旧采集页面承载记录;后续流程不冒充调度顺序,有界待处理摘要不冒充完整队列;待人工1920×1080、100%截图确认 |
|
||||
| 销量/库存 | 默认仓库与重置 | 2026-10-13 用户确认进入页面即按仓库显示:无传入仓库时自动选中接口返回的首个可用仓库并查询;链接带入仓库优先;重置保留当前仓库并重新查询 | 保留切仓与迟到响应隔离;没有可用仓库不发无仓库查询;自动回归覆盖两页首次进入、链接进入、缓存页返回及重置 |
|
||||
| /self-operated/sales | 自定义销量日期 | 2026-10-13 用户截图 `codex-clipboard-fac03bb1-6703-467d-b357-5731ea8cfb32.png`:两个单日框分散换行,未完整选择时残留逆序错误和旧结果。原筛选区改为公共QueryToolbar单一日期范围控件,明确开始/结束日期,支持同日;不新增浮层或改变统计口径 | 日期范围整体换行,校验完整范围后查询;草稿修改不改变已查询列表的分页/趋势条件,提交失败不冒充新范围结果;补充自动回归,待人工1920×1080、100%截图确认 |
|
||||
| /self-operated/overview | 查询、刷新与六项指标 | `docs/自营业务/经营总览图表设计.md`“菜单与页面布局”“指标带”;单仓+趋势日期QueryToolbar、紧凑指标带,整体固定版本 | 不新增货主控件;未知/过期独立提示;待人工1920×1080截图确认 |
|
||||
|
||||
@@ -1,122 +1,24 @@
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import CollectionView from './CollectionView.vue'
|
||||
import type { Overview, Task, WarehouseOverview } from '../types'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import CollectionAccountMonitor from '../components/CollectionAccountMonitor.vue'
|
||||
import type { Overview, WarehouseOverview } from '../types'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ overview: vi.fn(), tasks: vi.fn(), runs: vi.fn(), task: vi.fn(), retry: vi.fn(), push: vi.fn(), warehouse: '' }))
|
||||
vi.mock('../api/analytics', () => ({ analyticsApi: mocks }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => ({ query: { warehouseCode: mocks.warehouse } }), useRouter: () => ({ push: mocks.push }) }))
|
||||
const pass = defineComponent({ setup(_, { slots }) { return () => h('div', slots.default?.()) } })
|
||||
const button = defineComponent({ props: ['disabled'], emits: ['click'], setup(props, { emit, slots }) { return () => h('button', { disabled: props.disabled, onClick: () => emit('click') }, slots.default?.()) } })
|
||||
const dialog = defineComponent({ props: ['modelValue', 'title', 'showConfirm'], emits: ['update:modelValue'], setup(props, { emit, slots }) {
|
||||
return () => props.modelValue ? h('section', { 'data-test': 'configuration-dialog' }, [h('h2', props.title), slots.default?.(), h('button', { onClick: () => emit('update:modelValue', false) }, '关闭')]) : null
|
||||
} })
|
||||
const table = defineComponent({ props: ['data', 'columns', 'emptyText'], setup(props, { slots }) { return () => h('div', [
|
||||
h('div', { 'data-test': 'toolbar' }, slots['toolbar-actions']?.()), h('div', { 'data-test': 'filters' }, slots.filters?.()), h('div', { 'data-test': 'notices' }, slots.notices?.()),
|
||||
...(props.data ?? []).map((row: object) => h('div', [slots.configuration?.({ row }), slots.warehouse?.({ row }), slots.family?.({ row }), slots.enabled?.({ row }), slots.execution?.({ row }), slots.currentProgress?.({ row }), slots.completion?.({ row }), slots.publication?.({ row }), slots.coverage?.({ row }), slots.freshness?.({ row }), slots.dailySamples?.({ row })])),
|
||||
!(props.data ?? []).length ? props.emptyText : null,
|
||||
]) } })
|
||||
const setup = { global: { stubs: { AdminPage: pass, ListPageLayout: pass, QueryToolbar: true, DataTable: table, AppDialog: dialog,
|
||||
LoadErrorState: true, ApiErrorMeta: true, 'el-button': button, 'el-descriptions': pass, 'el-descriptions-item': pass, 'el-input': true } } }
|
||||
const stock: WarehouseOverview = {
|
||||
warehouseCode: 'SELFOP-TEST-A', warehouseName: '合成验收仓A', dataFamily: 'INVENTORY', executionEnabled: false, publicationEnabled: false,
|
||||
disabledReason: 'COLLECTION_OFF', nextScheduledAt: null, observedAt: '2026-10-12T13:00:00Z', initialFrom: null, initialUntil: null,
|
||||
initialCoverageStatus: 'COMPLETE', salesCoveredFrom: null, salesCoveredUntil: null, lastSuccessfulTaskNo: null, lastSucceededAt: null,
|
||||
lastPublishedAt: '2026-10-12T12:47:00Z', inventoryObservedFrom: '2026-10-12T12:40:00Z', inventoryObservedUntil: '2026-10-12T12:45:00Z',
|
||||
dataFreshness: 'VALID', rebuildRequired: false, revisionReviewStatus: 'CURRENT',
|
||||
protection: { accountCooldownUntil: null, accountPauseReason: null, dailyRequestBudget: 0, reservedHttpRequests: null, confirmedHttpRequests: null, budgetDate: null },
|
||||
inventoryDailySamples: { windowFrom: '2026-09-12', windowUntil: '2026-10-12', expectedDays: 30, sampleCount: 2, missingDates: ['2026-10-10'], accumulationStartedOn: '2026-10-09' },
|
||||
}
|
||||
const overview: Overview = { sourceAccount: 'lingxing-primary', environmentLabel: 'test', collectionMode: 'OFF', observedAt: stock.observedAt,
|
||||
warehouses: [{ ...stock, dataFamily: 'OUTBOUND', inventoryDailySamples: null, salesCoveredUntil: stock.inventoryObservedUntil }, stock] }
|
||||
const wrappers: ReturnType<typeof mount>[] = []
|
||||
beforeEach(() => { vi.clearAllMocks(); mocks.warehouse = ''; mocks.overview.mockResolvedValue(overview); mocks.tasks.mockResolvedValue({ records: [], total: 0, listUpperCreatedAt: null, listUpperId: null }) })
|
||||
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()) })
|
||||
async function page() { const wrapper = mount(CollectionView, setup); wrappers.push(wrapper); await flushPromises(); return wrapper }
|
||||
describe('collection status list and per-row configuration dialog', () => {
|
||||
it('shows preparation progress separately from a warehouse waiting to start', async () => {
|
||||
const waiting: Task = { taskNo: 'LOCAL-A', taskType: 'BACKFILL', dataFamily: 'OUTBOUND', warehouseCode: stock.warehouseCode, taskStatus: 'WAITING_SOURCE', applyState: 'NOT_STARTED', queryFrom: null, queryUntil: null, attemptCount: 0, latestAttemptNo: null, resultPublicationState: 'NOT_PUBLISHED', nextRetryAt: null, allowedActions: [], actionBlockReason: null, progress: null }
|
||||
waiting.dependencyState = 'WAITING_CONFIRMED'; waiting.progressSourceTaskNo = 'PREPARE-A'
|
||||
const shared: Task = { ...waiting, taskNo: 'PREPARE-A', taskStatus: 'RUNNING', attemptCount: 1, progress: { phase: 'DETAIL', progressScope: 'ACCOUNT_SHARED', countBasis: 'TASK_CUMULATIVE', completed: 792, total: 2014, totalKnown: true, unit: 'ORDER' } }
|
||||
mocks.task.mockResolvedValue({ task: waiting, sharedUpstream: shared, protection: stock.protection, observedAt: stock.observedAt })
|
||||
mocks.overview.mockResolvedValue({ ...overview, warehouses: [{ ...stock, dataFamily: 'OUTBOUND', currentTask: waiting, activeTasks: [waiting], sharedUpstream: shared }] })
|
||||
const wrapper = await page()
|
||||
expect(wrapper.text()).toContain('等待数据准备')
|
||||
expect(wrapper.find('[role="progressbar"]').attributes('aria-valuenow')).toBe('39.3')
|
||||
expect(wrapper.find('[aria-label="当前阶段数量"]').text()).toContain('已完成792 单')
|
||||
expect(wrapper.text()).toContain('不能当作本仓出库量或整个同步完成率')
|
||||
expect(wrapper.text()).toContain('整个任务完成时间尚未估算')
|
||||
expect(wrapper.text()).toContain('当前报表:使用')
|
||||
})
|
||||
it('shows one preparation task only once across two warehouses and keeps their progress independent', async () => {
|
||||
const waiting: Task = { taskNo: 'LOCAL-A', taskType: 'BACKFILL', dataFamily: 'OUTBOUND', warehouseCode: 'US01', taskStatus: 'WAITING_SOURCE', applyState: 'NOT_STARTED', queryFrom: null, queryUntil: null, attemptCount: 0, latestAttemptNo: null, resultPublicationState: 'NOT_PUBLISHED', nextRetryAt: null, allowedActions: [], actionBlockReason: null, progress: null }
|
||||
waiting.dependencyState = 'WAITING_CONFIRMED'; waiting.progressSourceTaskNo = 'PREPARE-A'
|
||||
const shared: Task = { ...waiting, taskNo: 'PREPARE-A', taskStatus: 'RUNNING', attemptCount: 1, progress: { phase: 'DETAIL', progressScope: 'ACCOUNT_SHARED', countBasis: 'TASK_CUMULATIVE', completed: 1218, total: 2014, totalKnown: true, unit: 'ORDER' } }
|
||||
const second = { ...waiting, warehouseCode: 'US02', taskNo: 'LOCAL-B' }
|
||||
mocks.task.mockResolvedValue({ task: waiting, sharedUpstream: shared, protection: stock.protection, observedAt: stock.observedAt })
|
||||
mocks.overview.mockResolvedValue({ ...overview, warehouses: [{ ...stock, warehouseCode: 'US01', dataFamily: 'OUTBOUND', currentTask: waiting, activeTasks: [waiting], sharedUpstream: shared }, { ...stock, warehouseCode: 'US02', dataFamily: 'OUTBOUND', currentTask: second, activeTasks: [second], sharedUpstream: shared }] })
|
||||
const wrapper = await page()
|
||||
const monitor = wrapper.find('[aria-label="领星账号运行进度"]')
|
||||
expect(monitor.findAll('[role="progressbar"]')).toHaveLength(1)
|
||||
expect(monitor.text()).toContain('US01 · 出库销量')
|
||||
expect(monitor.text()).toContain('US02 · 出库销量')
|
||||
expect(monitor.find('[aria-label="当前阶段数量"]').text()).toContain('已完成1218 单')
|
||||
expect(wrapper.find('.collection-secondary-tools').attributes('open')).toBeUndefined()
|
||||
expect(wrapper.text().match(/尚未开始,等待数据准备/g)).toHaveLength(2)
|
||||
})
|
||||
it('compares all warehouses in one compact table and distinguishes OFF from retained results', async () => {
|
||||
const wrapper = await page()
|
||||
expect(wrapper.findAllComponents(table)[0]?.props('data')).toHaveLength(2)
|
||||
expect(wrapper.findAllComponents(table)[0]?.props('columns')[0]).toMatchObject({ label: '操作', fixed: 'left', slotName: 'configuration' })
|
||||
expect(wrapper.findAllComponents(table)[0]?.props('columns')).toHaveLength(6)
|
||||
expect(wrapper.findAll('.collection-secondary').filter(item => item.text().includes('采集已关闭'))).toHaveLength(2)
|
||||
expect(wrapper.text()).toContain('自动采集已关闭,当前仅查看已留存结果')
|
||||
expect(wrapper.text()).toContain('包含合成测试样本')
|
||||
expect(wrapper.text()).toContain('已留存 2 / 30 个日样本')
|
||||
expect(wrapper.text()).not.toContain('账号日预算')
|
||||
expect(wrapper.find('[data-test="configuration-dialog"]').exists()).toBe(false)
|
||||
expect(mocks.tasks).not.toHaveBeenCalled()
|
||||
const notices = wrapper.find('[data-test="notices"]')
|
||||
expect(notices.text()).toContain('自动采集已关闭')
|
||||
expect(notices.text()).toContain('包含合成测试样本')
|
||||
expect(notices.find('.collection-observed').text()).toContain('北京时间')
|
||||
const links = wrapper.find('[data-test="toolbar"]').findAll('button')
|
||||
expect(links.map(item => item.text())).toEqual(['查看销量', '查看库存'])
|
||||
expect(links.every(item => item.attributes('disabled') !== undefined)).toBe(true)
|
||||
})
|
||||
it('opens only the chosen warehouse family and keeps gaps folded, with no configuration write', async () => {
|
||||
const wrapper = await page()
|
||||
const buttons = wrapper.findAll('button').filter(item => item.text() === '查看配置')
|
||||
await buttons[1]?.trigger('click'); await flushPromises()
|
||||
const modal = wrapper.find('[data-test="configuration-dialog"]')
|
||||
expect(modal.text()).toContain('合成验收仓A · 产品库存配置')
|
||||
expect(modal.text()).toContain('账号日预算 0')
|
||||
expect(modal.text()).toContain('数据积累起点 2026-10-09')
|
||||
expect(modal.find('details').attributes('open')).toBeUndefined()
|
||||
expect(wrapper.findComponent(dialog).props('showConfirm')).toBe(false)
|
||||
await modal.findAll('button').find(item => item.text() === '关闭')?.trigger('click')
|
||||
expect(wrapper.find('[data-test="configuration-dialog"]').exists()).toBe(false)
|
||||
expect(mocks.retry).not.toHaveBeenCalled()
|
||||
expect(mocks.overview).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('preserves zero daily samples and an unknown accumulation date in the dialog', async () => {
|
||||
mocks.overview.mockResolvedValue({ ...overview, warehouses: [{ ...stock, inventoryDailySamples: { ...stock.inventoryDailySamples, sampleCount: 0, accumulationStartedOn: null } }] })
|
||||
const wrapper = await page()
|
||||
expect(wrapper.text()).toContain('已留存 0 / 30 个日样本')
|
||||
await wrapper.findAll('button').find(item => item.text() === '查看配置')?.trigger('click'); await flushPromises()
|
||||
expect(wrapper.find('[data-test="configuration-dialog"]').text()).toContain('数据积累起点 尚无日样本')
|
||||
expect(wrapper.find('[data-test="configuration-dialog"]').text()).not.toContain('数据积累起点 2026-10-09')
|
||||
})
|
||||
it('navigates to the corresponding analysis with only the selected warehouse and makes no collection command', async () => {
|
||||
mocks.warehouse = 'SELFOP-TEST-A'
|
||||
const wrapper = await page()
|
||||
const toolbar = wrapper.find('[data-test="toolbar"]')
|
||||
await toolbar.findAll('button').find(item => item.text() === '查看库存')?.trigger('click')
|
||||
expect(mocks.push).toHaveBeenCalledWith({ path: '/self-operated/inventory', query: { warehouseCode: 'SELFOP-TEST-A' } })
|
||||
await toolbar.findAll('button').find(item => item.text() === '查看销量')?.trigger('click')
|
||||
expect(mocks.push).toHaveBeenCalledWith({ path: '/self-operated/sales', query: { warehouseCode: 'SELFOP-TEST-A' } })
|
||||
expect(mocks.retry).not.toHaveBeenCalled()
|
||||
describe('prototype collection monitor layout', () => {
|
||||
it('keeps the process and independent warehouse results visible even while idle', () => {
|
||||
const overview: Overview = { sourceAccount: 'lingxing-primary', environmentLabel: 'TEST', collectionMode: 'BUSINESS', observedAt: '2026-10-13T08:00:00Z', warehouses: [
|
||||
{ warehouseCode: 'US01', dataFamily: 'OUTBOUND', lastPublishedAt: null, protection: {}, currentTask: { taskStatus: 'DEAD' } },
|
||||
{ warehouseCode: 'US01', dataFamily: 'INVENTORY', lastPublishedAt: '2026-10-13T07:00:00Z', dataFreshness: 'FRESH', protection: {} },
|
||||
{ warehouseCode: 'US02', dataFamily: 'INVENTORY', lastPublishedAt: '2026-10-13T06:00:00Z', dataFreshness: 'STALE', protection: {} },
|
||||
] as WarehouseOverview[] }
|
||||
const w = mount(CollectionAccountMonitor, { props: { overview }, global: { stubs: { 'el-button': true } } })
|
||||
expect(w.find('.standby-ring').exists()).toBe(true)
|
||||
expect(w.find('[role="progressbar"]').exists()).toBe(false)
|
||||
expect(w.findAll('.account-flow li')).toHaveLength(5)
|
||||
expect(w.findAll('.warehouse-results article')).toHaveLength(2)
|
||||
expect(w.text()).toContain('最近有任务停止')
|
||||
expect(w.text()).toContain('最近发布结果')
|
||||
expect(w.findAll('.warehouse-result').map(r => r.text())).toEqual(expect.arrayContaining([expect.stringContaining('尚未发布'), expect.stringContaining('已有结果')]))
|
||||
expect(w.text()).not.toContain('全部完成'); expect(w.text()).not.toContain('100%')
|
||||
w.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,71 +2,52 @@ import { defineComponent, h, reactive } from 'vue'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import CollectionView from './CollectionView.vue'
|
||||
import CollectionAccountMonitor from '../components/CollectionAccountMonitor.vue'
|
||||
import type { Task, WarehouseOverview } from '../types'
|
||||
const mocks = vi.hoisted(() => ({ overview: vi.fn(), tasks: vi.fn(), task: vi.fn(), runs: vi.fn(), retry: vi.fn(), push: vi.fn(), route: { path: '/self-operated/collection', query: {} as Record<string, string> } }))
|
||||
import type { Overview, Task } from '../types'
|
||||
const mocks = vi.hoisted(() => ({ overview: vi.fn(), task: vi.fn(), tasks: vi.fn(), runs: vi.fn(), retry: vi.fn(), allowed: true, route: { query: {} } }))
|
||||
vi.mock('../api/analytics', () => ({ analyticsApi: mocks }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => mocks.route, useRouter: () => ({ push: mocks.push }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
const task: Task = { taskNo: 'TASK-A-FULL-NUMBER', warehouseCode: 'WH-A', dataFamily: 'OUTBOUND', taskType: 'BACKFILL', taskStatus: 'WAITING_SOURCE', dependencyState: 'WAITING_UNCONFIRMED', queryFrom: null, queryUntil: null, applyState: null, attemptCount: 0, latestAttemptNo: null, nextRetryAt: null, allowedActions: [], actionBlockReason: null, progress: null, resultPublicationState: 'NOT_PUBLISHED' }
|
||||
vi.mock('vue-router', () => ({ useRoute: () => mocks.route }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => mocks.allowed }) }))
|
||||
const shell = defineComponent({ setup(_, { slots }) { return () => h('div', slots.default?.()) } })
|
||||
const table = defineComponent({ props: ['data', 'columns', 'total'], emits: ['update:page'], setup(props, { slots }) { return () => h('div', [slots.filters?.(), slots.notices?.(), ...(props.data ?? []).map((row: unknown) => h('div', [(props.columns ?? []).map((c: { slotName?: string }) => c.slotName ? slots[c.slotName]?.({ row }) : null)]))]) } })
|
||||
const query = defineComponent({ props: ['modelValue', 'fields'], emits: ['search', 'update:modelValue'], setup() { return () => h('div') } })
|
||||
const dialog = defineComponent({ props: ['modelValue', 'title'], setup(props, { slots }) { return () => props.modelValue ? h('section', { 'data-dialog': props.title }, slots.default?.()) : null } })
|
||||
const button = defineComponent({ props: ['disabled'], emits: ['click'], setup(props, { slots, emit }) { return () => h('button', { disabled: props.disabled, onClick: () => emit('click') }, slots.default?.()) } })
|
||||
const options = { global: { stubs: { AdminPage: shell, ListPageLayout: shell, DataTable: table, QueryToolbar: query, AppDialog: dialog, 'el-button': button, 'el-input': true, 'el-descriptions': shell, 'el-descriptions-item': shell } } }
|
||||
const button = defineComponent({ emits: ['click'], setup(_, { slots, emit }) { return () => h('button', { onClick: () => emit('click') }, slots.default?.()) } })
|
||||
const task: Task = { taskNo: 'ACTIVE', warehouseCode: '', dataFamily: 'OUTBOUND', taskType: 'BACKFILL', taskStatus: 'RUNNING', queryFrom: null, queryUntil: null, applyState: null, attemptCount: 1, latestAttemptNo: 1, nextRetryAt: null, allowedActions: [], actionBlockReason: null, progress: { phase: 'DETAIL', progressScope: 'ACCOUNT_SHARED', countBasis: 'TASK_CUMULATIVE', completed: 339, total: 2787, totalKnown: true, unit: 'ORDER' }, resultPublicationState: 'NOT_PUBLISHED' }
|
||||
const overview: Overview = { sourceAccount: 'lingxing-primary', environmentLabel: 'TEST', collectionMode: 'BUSINESS', observedAt: '2026-10-13T08:00:00Z', accountActivity: task, warehouses: [] }
|
||||
const wrappers: ReturnType<typeof mount>[] = []
|
||||
function mountPage() { const w = mount(CollectionView, options); wrappers.push(w); return w }
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); mocks.route = reactive({ path: '/self-operated/collection', query: { warehouseCode: 'WH-A' } })
|
||||
mocks.overview.mockResolvedValue({ sourceAccount: 'lingxing-primary', environmentLabel: 'TEST', collectionMode: 'BUSINESS', observedAt: '2026-10-13T01:02:00Z', warehouses: [{ warehouseCode: 'WH-A', dataFamily: 'OUTBOUND', activeTasks: [task], currentTask: task, protection: {} } as WarehouseOverview] })
|
||||
mocks.tasks.mockResolvedValue({ records: [task], total: 21, current: 1, size: 10, listUpperCreatedAt: '2026-10-13T01:00:00Z', listUpperId: '10' })
|
||||
mocks.task.mockImplementation(async (_, taskNo: string) => ({ task: { ...task, taskNo }, sharedUpstream: null, protection: {}, observedAt: '2026-10-13T01:02:00Z' }))
|
||||
mocks.runs.mockResolvedValue({ records: [], total: 0 })
|
||||
})
|
||||
function page() { const w = mount(CollectionView, { global: { stubs: { AdminPage: shell, 'el-button': button, ApiErrorMeta: true } } }); wrappers.push(w); return w }
|
||||
beforeEach(() => { vi.clearAllMocks(); mocks.allowed = true; mocks.route = reactive({ query: { warehouseCode: 'US01', taskNo: 'OLD-HISTORY' } }); mocks.overview.mockResolvedValue(overview) })
|
||||
afterEach(() => { wrappers.splice(0).forEach(w => w.unmount()); vi.useRealTimers(); Object.defineProperty(document, 'hidden', { configurable: true, value: false }) })
|
||||
describe('collection focus and one runtime polling scheduler', () => {
|
||||
it('loads a deep-linked full task number without paging for it and retains focus when activity changes', async () => {
|
||||
mocks.route.query.taskNo = 'HISTORICAL-FULL-NUMBER'
|
||||
const w = mountPage(); await flushPromises()
|
||||
expect(mocks.task.mock.calls[0]?.[1]).toBe('HISTORICAL-FULL-NUMBER')
|
||||
expect(w.text()).toContain('HISTORICAL-FULL-NUMBER'); expect(w.text()).toContain('手动选择/历史记录')
|
||||
expect(w.findAllComponents(table)[1]?.props('columns')).toHaveLength(6)
|
||||
expect(mocks.runs).not.toHaveBeenCalled(); expect(w.find('form').exists()).toBe(false)
|
||||
expect(w.find('.collection-secondary-tools').attributes('open')).toBeUndefined()
|
||||
expect(w.findComponent(CollectionAccountMonitor).text()).not.toContain('HISTORICAL-FULL-NUMBER')
|
||||
describe('account-only collection page', () => {
|
||||
it('ignores old warehouse/history links and only reads the account overview', async () => {
|
||||
const w = page(); await flushPromises()
|
||||
expect(mocks.overview.mock.calls[0]?.slice(0, 2)).toEqual(['lingxing-primary', undefined])
|
||||
expect(w.text()).toContain('12.1%'); expect(w.text()).toContain('2448')
|
||||
expect(w.text()).not.toContain('OLD-HISTORY')
|
||||
for (const name of ['task', 'tasks', 'runs', 'retry'] as const) expect(mocks[name]).not.toHaveBeenCalled()
|
||||
for (const text of ['查看配置', '查看详情', '历史记录与排查', '核对恢复请求', '任务号定位']) expect(w.text()).not.toContain(text)
|
||||
expect(w.find('form').exists()).toBe(false); expect(w.find('details').exists()).toBe(false)
|
||||
})
|
||||
it('polls only runtime summaries at ten seconds, preserves history pages, and pauses while hidden', async () => {
|
||||
it('uses one poller and aborts hidden requests before resuming', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
const w = mountPage(); await flushPromises()
|
||||
expect(mocks.tasks).toHaveBeenCalledTimes(1)
|
||||
w.findAllComponents(table)[1]!.vm.$emit('update:page', 2); await flushPromises()
|
||||
expect(mocks.tasks.mock.calls.at(-1)?.[0].current).toBe(2)
|
||||
const pages = mocks.tasks.mock.calls.length
|
||||
await vi.advanceTimersByTimeAsync(10000); await flushPromises()
|
||||
expect(mocks.tasks).toHaveBeenCalledTimes(pages); expect(mocks.task.mock.calls.length).toBeGreaterThan(1)
|
||||
Object.defineProperty(document, 'hidden', { configurable: true, value: true }); document.dispatchEvent(new Event('visibilitychange')); await flushPromises()
|
||||
const reads = mocks.overview.mock.calls.length
|
||||
await vi.advanceTimersByTimeAsync(60000); expect(mocks.overview).toHaveBeenCalledTimes(reads)
|
||||
page(); await flushPromises(); expect(mocks.overview).toHaveBeenCalledTimes(1)
|
||||
let finish!: (value: Overview) => void
|
||||
mocks.overview.mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
|
||||
await vi.advanceTimersByTimeAsync(10000)
|
||||
const signal = mocks.overview.mock.calls.at(-1)?.[2] as AbortSignal
|
||||
Object.defineProperty(document, 'hidden', { configurable: true, value: true }); document.dispatchEvent(new Event('visibilitychange'))
|
||||
expect(signal.aborted).toBe(true)
|
||||
finish({ ...overview, accountActivity: null }); await flushPromises()
|
||||
const count = mocks.overview.mock.calls.length
|
||||
await vi.advanceTimersByTimeAsync(60000); expect(mocks.overview).toHaveBeenCalledTimes(count)
|
||||
Object.defineProperty(document, 'hidden', { configurable: true, value: false }); document.dispatchEvent(new Event('visibilitychange')); await flushPromises()
|
||||
expect(mocks.overview.mock.calls.length).toBeGreaterThan(reads); expect(mocks.tasks).toHaveBeenCalledTimes(pages)
|
||||
expect(mocks.overview).toHaveBeenCalledTimes(count + 1)
|
||||
})
|
||||
it('does not fetch empty attempts or show a retry form for waiting tasks', async () => {
|
||||
const w = mountPage(); await flushPromises()
|
||||
const open = w.findAll('button').find(b => b.text() === '查看关注任务详情')!; await open.trigger('click'); await flushPromises()
|
||||
expect(w.text()).toContain('本仓尚未开始执行;来源准备进度见上方')
|
||||
expect(mocks.runs).not.toHaveBeenCalled(); expect(w.find('form').exists()).toBe(false)
|
||||
expect(w.findAllComponents(table)).toHaveLength(2)
|
||||
it('retains last counts after a failed refresh and never turns failure into idle', async () => {
|
||||
const w = page(); await flushPromises(); mocks.overview.mockRejectedValue(new Error('offline'))
|
||||
await w.findAll('button').find(b => b.text().includes('刷新状态'))!.trigger('click'); await flushPromises()
|
||||
expect(w.text()).toContain('状态更新失败'); expect(w.text()).toContain('339')
|
||||
expect(w.text()).not.toContain('等待下一次采集')
|
||||
})
|
||||
it('does not replace task selection with a late detail response and shows lookup failure explicitly', async () => {
|
||||
let resolveOld!: (value: unknown) => void
|
||||
mocks.task.mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve }))
|
||||
const w = mountPage(); await flushPromises()
|
||||
const q = w.findComponent(query); q.vm.$emit('update:modelValue', { warehouseCode: 'WH-A', taskNo: 'TASK-B', dataFamily: '' }); q.vm.$emit('search'); await flushPromises()
|
||||
resolveOld({ task, sharedUpstream: null, protection: {}, observedAt: '2026-10-13T01:02:00Z' }); await flushPromises()
|
||||
expect(w.find('.collection-focus')?.text()).toContain('TASK-B')
|
||||
mocks.task.mockRejectedValue(new Error('指定仓库下找不到此任务'))
|
||||
q.vm.$emit('update:modelValue', { warehouseCode: 'WH-A', taskNo: 'MISSING', dataFamily: '' }); q.vm.$emit('search'); await flushPromises()
|
||||
expect(w.text()).toContain('任务首次读取失败,请核对仓库和完整任务号后重试'); expect(w.find('.collection-focus')?.text()).not.toContain('TASK-B')
|
||||
it('does not request account data without list permission', async () => {
|
||||
mocks.allowed = false; const w = page(); await flushPromises()
|
||||
expect(w.text()).toContain('没有采集状态查看权限'); expect(mocks.overview).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,227 +1,45 @@
|
||||
<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 { computed, onActivated, onDeactivated, onMounted, onUnmounted, ref } from 'vue'
|
||||
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 { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { analyticsApi } from '../api/analytics'
|
||||
import CollectionTaskProgress from '../components/CollectionTaskProgress.vue'
|
||||
import type { Overview } from '../types'
|
||||
import { RequestFence, RuntimePoller, hasActiveTask } from '../state'
|
||||
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 permitted = computed(() => access.can('platform:self-operated-analytics:list'))
|
||||
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 error = 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 visible = ref(!document.hidden)
|
||||
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)
|
||||
function busy() {
|
||||
const tasks = [overview.value?.accountActivity, ...(overview.value?.warehouses.flatMap(w => [...(w.activeTasks ?? []), w.currentTask, w.sharedUpstream]) ?? [])].filter(t => t != null)
|
||||
return hasActiveTask(tasks) || tasks.some(t => ['READY', 'APPLYING', 'WAITING', 'QUEUED'].includes(t.taskStatus))
|
||||
}
|
||||
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
|
||||
async function read() {
|
||||
if (!active || !visible.value || !permitted.value) return false
|
||||
const request = fence.begin()
|
||||
loading.value = !overview.value
|
||||
loading.value = true
|
||||
try {
|
||||
const nextOverview = await analyticsApi.overview(sourceAccount, undefined, request.signal)
|
||||
const result = await analyticsApi.overview('lingxing-primary', 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 }
|
||||
overview.value = result; error.value = null
|
||||
} catch (cause) {
|
||||
if (request.current()) error.value = getApiErrorDetails(cause, '采集状态更新失败,请重试')
|
||||
} finally { if (request.current()) loading.value = false }
|
||||
return busy()
|
||||
}
|
||||
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() })
|
||||
const poller = new RuntimePoller(read, () => fence.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 }
|
||||
function suspend() { active = false; visible.value = false; poller.stop(); loading.value = false }
|
||||
onMounted(() => { resume(); document.addEventListener('visibilitychange', visibility) })
|
||||
onActivated(resume)
|
||||
onDeactivated(suspend)
|
||||
@@ -229,124 +47,10 @@ onUnmounted(() => { suspend(); document.removeEventListener('visibilitychange',
|
||||
</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>
|
||||
<p v-if="!permitted" role="alert">没有采集状态查看权限。</p>
|
||||
<template v-else>
|
||||
<LoadErrorState v-if="error" title="采集状态更新失败" :message="error.message" :loading="loading" compact @retry="read"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<CollectionAccountMonitor :overview="overview" :loading="loading" :stale="Boolean(error)" :visible="visible" @refresh="read" />
|
||||
</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>
|
||||
|
||||
@@ -171,14 +171,14 @@ describe('self-operated actual workbench request state', () => {
|
||||
expect(mocks.api.inventory).toHaveBeenCalledTimes(calls)
|
||||
})
|
||||
|
||||
it('resumes a cached collection page with the new deep-linked warehouse and stops hidden polling and late requests', async () => {
|
||||
it('resumes the account overview without warehouse filtering and stops hidden polling and late requests', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
const route = reactive({ path: '/self-operated/collection', query: { warehouseCode: 'WH-A' } })
|
||||
mocks.routeState = route
|
||||
const visible = ref(true)
|
||||
const host = defineComponent({ setup: () => () => h(KeepAlive, null, { default: () => visible.value ? h(CollectionView) : null }) })
|
||||
const wrapper = mount(host, options); wrappers.push(wrapper); await flushPromises()
|
||||
expect(mocks.api.tasks.mock.calls.at(-1)?.[0].warehouseCode).toBe('WH-A')
|
||||
expect(mocks.api.overview.mock.calls.at(-1)?.[1]).toBeUndefined()
|
||||
let resolveOld!: (value: Overview) => void
|
||||
mocks.api.overview.mockImplementationOnce(() => new Promise<Overview>(resolve => { resolveOld = resolve }))
|
||||
await vi.advanceTimersByTimeAsync(60000)
|
||||
@@ -195,9 +195,9 @@ describe('self-operated actual workbench request state', () => {
|
||||
route.path = '/self-operated/collection'; route.query = { warehouseCode: 'WH-B' }
|
||||
mocks.api.tasks.mockResolvedValue({ records: [{ ...task, taskNo: 'COL-B', warehouseCode: 'WH-B' }], total: 1 })
|
||||
visible.value = true; await flushPromises()
|
||||
expect(wrapper.findComponent(queryStub).props('modelValue').warehouseCode).toBe('WH-B')
|
||||
expect(mocks.api.tasks.mock.calls.at(-1)?.[0].warehouseCode).toBe('WH-B')
|
||||
expect(wrapper.text()).toContain('COL-B'); expect(wrapper.text()).not.toContain('COL-A')
|
||||
expect(wrapper.findComponent(queryStub).exists()).toBe(false)
|
||||
expect(mocks.api.overview.mock.calls.length).toBeGreaterThan(readsBefore)
|
||||
expect(mocks.api.tasks).not.toHaveBeenCalled()
|
||||
})
|
||||
it.each([
|
||||
{ coverageSelection: 'FINITE_POSITIVE', sortBy: 'COVERAGE_DAYS_DESC' },
|
||||
@@ -273,8 +273,8 @@ describe('self-operated actual workbench request state', () => {
|
||||
expect(wrapper.get('[data-testid="inventory-observation"]').text()).toContain('— 至 —')
|
||||
expect(wrapper.get('[data-testid="inventory-observation"]').text()).toContain('尚无完整库存观测时段')
|
||||
})
|
||||
it.each(['sales', 'inventory', 'collection'] as const)('uses the public integrated filter region and layout settings for %s', async mode => {
|
||||
const wrapper = mode === 'collection' ? mount(CollectionView, options) : mount(AnalyticsView, { ...options, props: { mode } })
|
||||
it.each(['sales', 'inventory'] as const)('uses the public integrated filter region and layout settings for %s', async mode => {
|
||||
const wrapper = mount(AnalyticsView, { ...options, props: { mode } })
|
||||
wrappers.push(wrapper)
|
||||
await flushPromises()
|
||||
const table = wrapper.findComponent(tableStub)
|
||||
@@ -358,15 +358,6 @@ describe('self-operated actual workbench request state', () => {
|
||||
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).toMatchObject({ warehouseCode: 'WH-A', current: 1, attention: 'ALL' })
|
||||
expect(wrapper.findComponent(tableStub).props('data')).toEqual([metric])
|
||||
})
|
||||
it('shows actual inventory daily sample gaps without inventing an accumulation date', async () => {
|
||||
mocks.api.overview.mockResolvedValue({ ...overview, warehouses: [{ warehouseCode: 'WH-A', dataFamily: 'INVENTORY', protection: {}, inventoryDailySamples: { windowFrom: '2026-09-12', windowUntil: '2026-10-12', expectedDays: 30, sampleCount: 0, missingDates: ['2026-09-12'], accumulationStartedOn: null } } as WarehouseOverview] })
|
||||
const wrapper = mount(CollectionView, options); wrappers.push(wrapper)
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('已留存 0 / 30 个日样本')
|
||||
await findButton(wrapper, '查看配置').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('数据积累起点 尚无日样本')
|
||||
expect(wrapper.text()).toContain('缺口日期:2026-09-12')
|
||||
})
|
||||
it('pins versions and date across range and ranking switches, ranking omits chart parameters', async () => {
|
||||
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'sales' } }); wrappers.push(wrapper)
|
||||
await flushPromises()
|
||||
@@ -409,31 +400,15 @@ describe('self-operated actual workbench request state', () => {
|
||||
expect(wrapper.text()).toContain('状态更新失败')
|
||||
expect(wrapper.text()).toContain('2026/10/12 09:00:00')
|
||||
})
|
||||
it('requires confirmation inside the task dialog and preserves same key on ambiguous retry failure', async () => {
|
||||
mocks.api.retry.mockRejectedValueOnce(new Error('timeout')).mockResolvedValue({ taskNo: task.taskNo, status: 'QUEUED' })
|
||||
it('keeps collection read-only without configuration, history or retry controls', async () => {
|
||||
const wrapper = mount(CollectionView, options); wrappers.push(wrapper)
|
||||
await flushPromises()
|
||||
await findButton(wrapper, '查看详情').trigger('click'); await flushPromises()
|
||||
expect(wrapper.find('[data-test="detail-dialog"]').exists()).toBe(true)
|
||||
expect(wrapper.findAllComponents(dialog).find(item => item.props('modelValue'))?.props('title')).toContain('COL-A')
|
||||
await wrapper.find('textarea').setValue('已修复,恢复原任务')
|
||||
await findButton(wrapper, '核对恢复请求').trigger('click')
|
||||
expect(mocks.api.retry).not.toHaveBeenCalled()
|
||||
await wrapper.find('form').trigger('submit'); await flushPromises()
|
||||
const first = mocks.api.retry.mock.calls[0]?.[0]
|
||||
await wrapper.find('form').trigger('submit'); await flushPromises()
|
||||
expect(mocks.api.retry.mock.calls[1]?.[0]).toEqual(first)
|
||||
expect(first).toMatchObject({ action: 'RETRY', taskNo: 'COL-A', warehouseCode: 'WH-A', reason: '已修复,恢复原任务' })
|
||||
expect(first).not.toHaveProperty('taskId')
|
||||
expect(wrapper.text()).toContain('恢复请求已排队受理,尚未完成采集或发布')
|
||||
})
|
||||
it('read-only permission allows task details but never creates retry form', async () => {
|
||||
mocks.canSync = false
|
||||
const wrapper = mount(CollectionView, options); wrappers.push(wrapper)
|
||||
await flushPromises()
|
||||
await findButton(wrapper, '查看详情').trigger('click'); await flushPromises()
|
||||
expect(wrapper.find('form').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('没有任务恢复权限')
|
||||
expect(wrapper.findComponent(queryStub).exists()).toBe(false)
|
||||
expect(wrapper.text()).not.toContain('查看配置')
|
||||
expect(wrapper.text()).not.toContain('查看详情')
|
||||
expect(mocks.api.tasks).not.toHaveBeenCalled()
|
||||
expect(mocks.api.retry).not.toHaveBeenCalled()
|
||||
})
|
||||
it.each(['sales', 'inventory'] as const)('opens %s daily details in a dialog and discards a closed pending response', async mode => {
|
||||
let resolveTrend!: (value: object) => void
|
||||
|
||||
@@ -90,7 +90,7 @@ const notificationConsumers = [
|
||||
'notifications/components/TemplateEditorDialog.vue',
|
||||
]
|
||||
const systemConsumers = ['iam/pages/InternalUsersView.vue', 'iam/pages/RolesView.vue', 'iam/pages/PermissionsView.vue']
|
||||
const selfOperatedPages = ['self-operated/components/AnalyticsView.vue', 'self-operated/pages/CollectionView.vue']
|
||||
const selfOperatedPages = ['self-operated/components/AnalyticsView.vue']
|
||||
const selfOperatedConsumers = [...selfOperatedPages, 'self-operated/components/SkuTrend.vue', 'self-operated/pages/OverviewView.vue']
|
||||
const consumers = [
|
||||
...selfOperatedConsumers,
|
||||
@@ -466,6 +466,16 @@ describe('approved Admin density ownership', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the collection monitor in the Admin theme without legacy list controls', () => {
|
||||
const page = read('modules/self-operated/pages/CollectionView.vue')
|
||||
const monitor = read('modules/self-operated/components/CollectionAccountMonitor.vue')
|
||||
expect(page).toContain('class="admin-density"')
|
||||
expect(page).toContain('@/styles/adminDensity.css')
|
||||
expect(page).toContain('<CollectionAccountMonitor')
|
||||
expect(page).not.toContain('<DataTable')
|
||||
expect(monitor).toContain('background: var(--el-bg-color)')
|
||||
expect(monitor).not.toMatch(/--el-[\w-]+\s*:/)
|
||||
})
|
||||
it('opts the confirmed self-operated pages into integrated text actions without changing older outlined consumers', () => {
|
||||
expect(getNavigationLeaves(adminNavigation).map(node => node.path)).toEqual(expect.arrayContaining([
|
||||
'/self-operated/sales', '/self-operated/inventory', '/self-operated/collection',
|
||||
|
||||
Reference in New Issue
Block a user