生产更新:同步已提交的海外仓分析及业务修复(2026-10-16) #20
@@ -11,7 +11,7 @@ describe('adminNavigation', () => {
|
||||
it('opens all self-operated read pages with list permission and keeps sync alone insufficient', () => {
|
||||
const read = filterAdminNavigation(adminNavigation, new Set(['platform:self-operated-analytics:list']))
|
||||
expect(getNavigationLeaves(read).map(item => item.path)).toEqual([
|
||||
'/dashboard', '/self-operated/sales', '/self-operated/inventory', '/self-operated/collection',
|
||||
'/dashboard', '/self-operated/overview', '/self-operated/sales', '/self-operated/inventory', '/self-operated/collection',
|
||||
])
|
||||
const sync = filterAdminNavigation(adminNavigation, new Set(['platform:self-operated-analytics:sync']))
|
||||
expect(getNavigationLeaves(sync).map(item => item.path)).toEqual(['/dashboard'])
|
||||
@@ -35,12 +35,12 @@ describe('adminNavigation', () => {
|
||||
{ title: '海外业务', children: ['采购订单', '本土卡车', '本土散板'] },
|
||||
{ title: '客户服务', children: ['异常订单'] },
|
||||
{ title: '财务管理', children: ['客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] },
|
||||
{ title: '自营业务', children: ['销量分析', '库存分析', '数据采集'] },
|
||||
{ title: '自营业务', children: ['经营总览', '销量分析', '库存分析', '数据采集'] },
|
||||
{ title: '通用', children: ['费用项目', '汇率管理', '仓库管理', '海外仓报价', '车队报价', '船期价格表', '下载中心'] },
|
||||
{ title: '系统管理', children: ['用户管理', '角色权限', '菜单权限'] },
|
||||
])
|
||||
expect(visible).toHaveLength(13)
|
||||
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(55)
|
||||
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(56)
|
||||
expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined()
|
||||
})
|
||||
|
||||
|
||||
@@ -3,30 +3,41 @@ import { computed, ref } from 'vue'
|
||||
import type { Quantity } from '../types'
|
||||
import { quantityText } from '../state'
|
||||
export interface BarRow { key: string; label: string; secondary?: string; value: Quantity; detail: string }
|
||||
const props = defineProps<{ rows: BarRow[]; unit: string; disabled?: boolean; title: string }>()
|
||||
const props = defineProps<{ rows: BarRow[]; unit: string; disabled?: boolean; title: string; ranked?: boolean }>()
|
||||
const emit = defineEmits<{ select: [key: string] }>()
|
||||
const focused = ref('')
|
||||
const maximum = computed(() => Math.max(1, ...props.rows.map(row => Number(row.value ?? 0))))
|
||||
const palette = ['#5575ed', '#3da9ba', '#e8a54b', '#b083dc', '#df8397', '#8a9ab4', '#a2adbf']
|
||||
function color(index: number) { return props.ranked ? palette[0] : palette[index % palette.length] }
|
||||
</script>
|
||||
<template>
|
||||
<div :aria-label="title" class="dashboard-bars">
|
||||
<p v-if="!rows.length">暂无符合条件的 SKU;请核对覆盖与待评估资料。</p>
|
||||
<button v-for="row in rows" :key="row.key" type="button" class="bar-row" :aria-disabled="disabled || row.value == null" :aria-label="`${row.label},${row.secondary ?? ''},${quantityText(row.value)} ${unit};${row.detail}`" :title="`${row.label} ${row.secondary ?? ''};${row.detail}`" @focus="focused = row.key" @mouseenter="focused = row.key" @click="!disabled && row.value != null && emit('select', row.key)">
|
||||
<span class="bar-heading"><span class="bar-label">{{ row.label }}</span><strong>{{ quantityText(row.value) }} {{ unit }}</strong></span>
|
||||
<div v-if="!rows.length" class="bar-empty"><span aria-hidden="true">—</span><p>暂无符合条件的 SKU;请核对覆盖与待评估资料。</p></div>
|
||||
<button v-for="(row, index) in rows" :key="row.key" type="button" class="bar-row" :style="{ '--bar-color': color(index), '--bar-delay': `${index * 45}ms` }" :aria-disabled="disabled || row.value == null" :aria-label="`${row.label},${row.secondary ?? ''},${quantityText(row.value)} ${unit};${row.detail}`" :title="`${row.label} ${row.secondary ?? ''};${row.detail}`" @focus="focused = row.key" @mouseenter="focused = row.key" @click="!disabled && row.value != null && emit('select', row.key)">
|
||||
<span class="bar-heading"><span class="bar-label"><span v-if="ranked" class="bar-rank" :class="{ 'is-leading': index < 3 }">{{ String(index + 1).padStart(2, '0') }}</span>{{ row.label }}</span><strong>{{ quantityText(row.value) }} <small>{{ unit }}</small></strong></span>
|
||||
<span v-if="row.secondary" class="bar-secondary">{{ row.secondary }}</span>
|
||||
<svg viewBox="0 0 500 15" aria-hidden="true"><line x1="0" x2="500" y1="14" y2="14" stroke="var(--el-border-color)" /><rect v-if="row.value != null" x="0" y="1" :width="Number(row.value) / maximum * 500" height="11" rx="2" fill="var(--el-color-primary)" /></svg>
|
||||
<span class="bar-track" aria-hidden="true"><span v-if="row.value != null" class="bar-fill" :style="{ width: `${Number(row.value) / maximum * 100}%` }" /></span>
|
||||
</button>
|
||||
<p v-if="focused" class="bar-detail" role="status">{{ rows.find(row => row.key === focused)?.detail }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.dashboard-bars { display: grid; gap: var(--admin-space-2); }
|
||||
.bar-row { appearance: none; border: 0; padding: var(--admin-space-2) 0; background: transparent; text-align: left; color: inherit; cursor: pointer; width: 100%; font: inherit; }
|
||||
.bar-row { appearance: none; border: 0; padding: 7px 8px; border-radius: 6px; background: transparent; text-align: left; color: inherit; cursor: pointer; width: 100%; font: inherit; transition: background .2s; }
|
||||
.bar-row:hover, .bar-row:focus-visible { background: #f5f7fc; }
|
||||
.bar-row:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
|
||||
.bar-row[aria-disabled="true"] { cursor: default; }
|
||||
.bar-heading { display: flex; gap: var(--admin-space-2); justify-content: space-between; align-items: baseline; }
|
||||
.bar-label { overflow-wrap: anywhere; }
|
||||
.bar-rank { display: inline-block; width: 28px; color: #929db3; font-size: var(--admin-font-caption); font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.bar-rank.is-leading { color: var(--bar-color); }
|
||||
.bar-heading strong { white-space: nowrap; }
|
||||
.bar-heading small { font-weight: 400; color: var(--el-text-color-secondary); }
|
||||
.bar-secondary, .bar-detail { display: block; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); overflow-wrap: anywhere; }
|
||||
svg { display: block; width: 100%; height: 15px; }
|
||||
.bar-track { display: block; height: 10px; margin-top: 7px; border-radius: 3px; overflow: hidden; background: #f0f3f9; }
|
||||
.bar-fill { display: block; height: 100%; background: var(--bar-color); border-radius: 3px; transform-origin: left; animation: bar-reveal .7s cubic-bezier(.2,.7,.3,1) both; animation-delay: var(--bar-delay); transition: width .5s; }
|
||||
.bar-empty { min-height: 150px; display: grid; align-content: center; justify-items: center; color: var(--el-text-color-secondary); text-align: center; }
|
||||
.bar-empty > span { font-size: 38px; color: #a9b7d8; }
|
||||
@keyframes bar-reveal { from { transform: scaleX(0); } to { transform: scaleX(1); } }
|
||||
@media (prefers-reduced-motion: reduce) { .bar-fill { animation: none; transition: none; } .bar-row { transition: none; } }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import DashboardTrend from './DashboardTrend.vue'
|
||||
import DashboardBars from './DashboardBars.vue'
|
||||
import type { DailyPoint } from '../types'
|
||||
|
||||
function point(date: string, value: number | null, dataStatus = 'COMPLETE'): DailyPoint {
|
||||
return { businessDate: date, outboundQuantity: dataStatus === 'COMPLETE' ? value : null, observedQuantity: value, dataStatus, reasonCodes: [], quantityUnit: 'PCS' }
|
||||
}
|
||||
describe('dashboard chart integrity and interaction', () => {
|
||||
it('fills only contiguous known segments and keeps unknown days unavailable for drilldown', async () => {
|
||||
const wrapper = mount(DashboardTrend, { props: { points: [point('2026-10-01', 2), point('2026-10-02', 4), point('2026-10-03', null, 'UNKNOWN'), point('2026-10-04', 1, 'PARTIAL')] } })
|
||||
const areas = wrapper.findAll('.trend-area').filter(path => path.attributes('d'))
|
||||
expect(areas).toHaveLength(1)
|
||||
expect(areas[0]!.attributes('d')).not.toContain('770')
|
||||
const days = wrapper.findAll('g')
|
||||
await days[2]!.trigger('click')
|
||||
expect(wrapper.emitted('select')).toBeUndefined()
|
||||
await days[3]!.trigger('keydown', { key: 'Enter' })
|
||||
expect(wrapper.emitted('select')).toEqual([['2026-10-04']])
|
||||
expect(days[3]!.find('.trend-point').attributes('fill')).toBe('var(--el-bg-color)')
|
||||
await wrapper.setProps({ points: [point('2026-10-05', 7)] })
|
||||
expect(wrapper.find('.trend-tooltip').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('preserves zero and unknown bars, exact relative widths and disabled selection', async () => {
|
||||
const wrapper = mount(DashboardBars, { props: { title: '分布', unit: 'SKU', rows: [
|
||||
{ key: 'a', label: 'A', value: 20, detail: '' }, { key: 'b', label: 'B', value: 5, detail: '' },
|
||||
{ key: 'zero', label: '零值', value: 0, detail: '' }, { key: 'unknown', label: '未知', value: null, detail: '' },
|
||||
] } })
|
||||
expect(wrapper.findAll('.bar-fill').map(bar => bar.attributes('style'))).toEqual(['width: 100%;', 'width: 25%;', 'width: 0%;'])
|
||||
await wrapper.findAll('button')[3]!.trigger('click')
|
||||
expect(wrapper.emitted('select')).toBeUndefined()
|
||||
await wrapper.findAll('button')[2]!.trigger('click')
|
||||
expect(wrapper.emitted('select')).toEqual([['zero']])
|
||||
await wrapper.setProps({ disabled: true })
|
||||
await wrapper.findAll('button')[0]!.trigger('click')
|
||||
expect(wrapper.emitted('select')).toHaveLength(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -1,38 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, useId, watch } from 'vue'
|
||||
import type { DailyPoint } from '../types'
|
||||
import { label, quantityText, reasonsText, shownQuantity, trendSegments } from '../state'
|
||||
const props = defineProps<{ points: DailyPoint[]; disabled?: boolean }>()
|
||||
const emit = defineEmits<{ select: [date: string] }>()
|
||||
const focused = ref<DailyPoint | null>(null)
|
||||
const gradientId = `trend-area-${useId()}`
|
||||
const chartKey = computed(() => props.points.map(point => `${point.businessDate}:${shownQuantity(point)}:${point.dataStatus}`).join('|'))
|
||||
watch(() => props.points, () => { focused.value = null })
|
||||
const segments = computed(() => trendSegments(props.points))
|
||||
const maximum = computed(() => Math.max(1, ...props.points.map(point => Number(shownQuantity(point) ?? 0))))
|
||||
function x(index: number) { return props.points.length === 1 ? 400 : 30 + index / (props.points.length - 1) * 740 }
|
||||
function y(point: DailyPoint) { return 175 - Number(shownQuantity(point) ?? 0) / maximum.value * 145 }
|
||||
function text(point: DailyPoint) { return `${point.businessDate}:${quantityText(shownQuantity(point))} 件,${label(point.dataStatus)};${reasonsText(point.reasonCodes)}` }
|
||||
function select(point: DailyPoint) { focused.value = point; if (!props.disabled && point.dataStatus !== 'UNKNOWN' && shownQuantity(point) != null) emit('select', point.businessDate) }
|
||||
function area(path: string) {
|
||||
const coordinates = [...path.matchAll(/[ML]\s*([\d.]+),([\d.]+)/g)]
|
||||
if (coordinates.length < 2) return ''
|
||||
return `${path} L${coordinates.at(-1)![1]},175 L${coordinates[0]![1]},175 Z`
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<p>缺失日期断线;空心点为未完整日;× 表示未知。今日为已采集累计。</p>
|
||||
<svg viewBox="0 0 800 220" class="dashboard-trend" role="group" aria-label="全仓每日出库趋势,点击已知日点查看当日销量">
|
||||
<line x1="30" y1="175" x2="770" y2="175" stroke="currentColor" /><text x="10" y="190">0</text><text x="30" y="20">{{ maximum }} 件</text>
|
||||
<path v-for="(segment, index) in segments" :key="index" :d="segment.path" fill="none" stroke="var(--el-color-primary)" stroke-width="2" :stroke-dasharray="segment.partial ? '5 4' : undefined" />
|
||||
<div class="trend-legend"><span><i class="legend-dot" />完整日</span><span><i class="legend-dot is-partial" />未完整日</span><span>× 未知 · 缺失日期断线</span><span class="trend-today">今日为已采集累计</span></div>
|
||||
<svg :key="chartKey" viewBox="0 0 800 220" class="dashboard-trend" role="group" aria-label="全仓每日出库趋势,点击已知日点查看当日销量">
|
||||
<defs><linearGradient :id="gradientId" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#5575ed" stop-opacity=".22" /><stop offset="100%" stop-color="#5575ed" stop-opacity=".01" /></linearGradient></defs>
|
||||
<line v-for="level in [0, 1, 2, 3, 4]" :key="level" x1="30" :y1="175 - level * 36.25" x2="770" :y2="175 - level * 36.25" stroke="#e7edf6" :stroke-dasharray="level ? '4 5' : undefined" />
|
||||
<text x="10" y="190">0</text><text x="30" y="20">{{ quantityText(maximum) }} 件</text>
|
||||
<path v-for="(segment, index) in segments" :key="`area-${index}`" :d="area(segment.path)" :fill="`url(#${gradientId})`" class="trend-area" />
|
||||
<path v-for="(segment, index) in segments" :key="index" :d="segment.path" fill="none" stroke="#5575ed" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" :stroke-dasharray="segment.partial ? '5 4' : undefined" :class="{ 'trend-line': !segment.partial }" :pathLength="segment.partial ? undefined : 1" />
|
||||
<line v-if="focused" :x1="x(points.indexOf(focused))" :x2="x(points.indexOf(focused))" y1="30" y2="175" stroke="#b4c2e5" stroke-dasharray="3 4" />
|
||||
<g v-for="(point, index) in points" :key="point.businessDate" tabindex="0" role="button" :aria-label="text(point)" :aria-disabled="disabled || point.dataStatus === 'UNKNOWN' || shownQuantity(point) == null" @focus="focused = point" @mouseenter="focused = point" @click="select(point)" @keydown.enter.prevent="select(point)" @keydown.space.prevent="select(point)">
|
||||
<title>{{ text(point) }}</title>
|
||||
<circle v-if="point.dataStatus !== 'UNKNOWN' && shownQuantity(point) != null" :cx="x(index)" :cy="y(point)" r="5" :fill="point.dataStatus === 'COMPLETE' ? 'var(--el-color-primary)' : 'var(--el-bg-color)'" stroke="var(--el-color-primary)" stroke-width="2" />
|
||||
<circle class="trend-hit" :cx="x(index)" :cy="point.dataStatus === 'UNKNOWN' ? 175 : y(point)" r="12" fill="transparent" />
|
||||
<circle v-if="point.dataStatus !== 'UNKNOWN' && shownQuantity(point) != null" class="trend-point" :cx="x(index)" :cy="y(point)" r="5" :fill="point.dataStatus === 'COMPLETE' ? '#5575ed' : 'var(--el-bg-color)'" stroke="#5575ed" stroke-width="2" />
|
||||
<text v-else :x="x(index)" y="174" text-anchor="middle">×</text>
|
||||
<text v-if="index % Math.max(1, Math.ceil(points.length / 8)) === 0 || index === points.length - 1" :x="x(index)" y="208" text-anchor="middle">{{ point.businessDate.slice(5) }}</text>
|
||||
</g>
|
||||
</svg>
|
||||
<p v-if="focused" role="status">{{ text(focused) }}</p>
|
||||
<p v-if="focused" class="trend-tooltip" role="status">{{ text(focused) }}</p>
|
||||
<p v-if="!segments.length">所选日期暂无可靠出库资料,未知不按零显示。</p>
|
||||
<details><summary>查看每日数值与覆盖原因</summary><table class="density-table"><thead><tr><th>日期</th><th>正式量</th><th>已采集量</th><th>状态</th><th>原因</th></tr></thead><tbody><tr v-for="point in points" :key="point.businessDate"><td>{{ point.businessDate }}</td><td>{{ quantityText(point.outboundQuantity) }}</td><td>{{ quantityText(point.observedQuantity) }}</td><td>{{ label(point.dataStatus) }}</td><td>{{ reasonsText(point.reasonCodes) }}</td></tr></tbody></table></details>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.dashboard-trend { width: 100%; height: 300px; }
|
||||
text { font-size: var(--admin-font-caption); }
|
||||
.dashboard-trend { width: 100%; height: 300px; overflow: visible; }
|
||||
text { font-size: var(--admin-font-caption); fill: #76849c; }
|
||||
.trend-legend { display: flex; flex-wrap: wrap; gap: 16px; color: #76849c; font-size: var(--admin-font-caption); align-items: center; }
|
||||
.trend-legend span { display: inline-flex; gap: 6px; align-items: center; }
|
||||
.legend-dot { width: 7px; height: 7px; border-radius: 50%; background: #5575ed; border: 1px solid #5575ed; }
|
||||
.legend-dot.is-partial { background: white; }
|
||||
.trend-today { margin-left: auto; }
|
||||
.trend-line { stroke-dasharray: 1; stroke-dashoffset: 0; animation: trend-draw 1.1s ease-out; }
|
||||
.trend-area { animation: area-reveal 1.1s ease-out; }
|
||||
.trend-point { animation: area-reveal .8s ease-out; }
|
||||
.trend-tooltip { padding: 8px 12px; background: #f2f5ff; border-left: 3px solid #5575ed; border-radius: 4px; }
|
||||
g { cursor: pointer; }
|
||||
g[aria-disabled="true"] { cursor: default; }
|
||||
g:focus { outline: none; }
|
||||
g:focus circle { stroke: var(--el-text-color-primary); stroke-width: 3px; }
|
||||
g:focus .trend-point, g:hover .trend-point { stroke: #304aa3; stroke-width: 3px; }
|
||||
g:focus .trend-hit { stroke: #5575ed; stroke-width: 1px; }
|
||||
@keyframes trend-draw { from { stroke-dashoffset: 1; } to { stroke-dashoffset: 0; } }
|
||||
@keyframes area-reveal { from { opacity: 0; } to { opacity: 1; } }
|
||||
@media (prefers-reduced-motion: reduce) { .trend-line, .trend-area, .trend-point { animation: none; } }
|
||||
</style>
|
||||
|
||||
@@ -24,6 +24,19 @@
|
||||
|
||||
自动测试与人工视觉验收分别记录;无真实截图不宣称视觉通过。
|
||||
|
||||
## 经营总览图形表现(2026-10-13 用户截图确认)
|
||||
|
||||
用户明确要求经营总览强化图表与动效。保留 `经营总览图表设计.md` 的五图、窗口、未知与下钻规则,以本轮截图为视觉修改依据。
|
||||
|
||||
| 操作/区域 | 当前问题与本轮承载结论 | 验证边界 |
|
||||
| --- | --- | --- |
|
||||
| 六项指标 | 普通文字带改为彩色细顶边指标带、大数字和辅助状态;保留已知小计与待核验数量 | 数值和未知口径不变;待人工截图确认 |
|
||||
| 全仓出库趋势 | 继续日折线;增加网格、分段面积、绘制动效、悬停/键盘指引 | 缺日断线,部分日虚线与空心点;不插值补齐;待人工截图确认 |
|
||||
| 库存分布/三榜 | 保留横向条形;分布分色,排行名次标记,柱条展开动效 | 不引入健康评级、风险率或虚假占位条;待人工截图确认 |
|
||||
| 首次加载/更新 | 骨架加载、指标和图形入场;减少动态效果偏好下禁用动画 | 原版本、关闭取消、过期提示及权限保持;待人工截图确认 |
|
||||
| 顶部区域/默认仓库 | 用户补充要求顶部提示紧凑且默认展示仓库;标题与操作合并一行,统计口径和采集时间默认折叠;首次自动加载接口首个仓库,深链指定仓库优先,重置保留仓库 | 保留过期/失败提示及切仓入口;不硬编码仓库;待人工截图确认 |
|
||||
| 统一搜索/切仓 | 2026-10-13 用户以客户账户截图要求统一标题和右侧操作栏;复用 integrated DataTable 的 toolbar-start/toolbar-actions/filters,关闭结果区,不增加空表;QueryToolbar 接入筛选布局设置。切仓取消旧请求、清旧图后自动加载新仓,重置仍保留当前仓 | 自动查询只读本地聚合接口;验证迟到响应不覆盖、加载与重置;待人工截图确认 |
|
||||
|
||||
## 数据采集样式统一(2026-10-12 当前截图确认)
|
||||
|
||||
用户确认销量、库存样式已完成,本轮以两页截图中的“顶部操作栏 → 灰底筛选带 → 提示区 → 表格”为样板,仅统一采集页。采集模式、合成样本声明及更新时间移入主表 notices,顶部保留查看销量/库存的跨页入口,未选仓库时禁用;概览与任务表之间保留单一标准间距。沿用既有配置弹框、任务内嵌详情和恢复确认,不修改采集接口、资格或状态机。修改后待人工1920×1080、100%截图确认。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, onActivated, onDeactivated, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
@@ -54,6 +54,7 @@ const fence = new RequestFence()
|
||||
const detailFence = 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 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()] }])
|
||||
const hasSyntheticSamples = computed(() => warehouses.value.some(item => item.warehouseCode.startsWith('SELFOP-TEST-')))
|
||||
@@ -100,11 +101,12 @@ function blockReason(value: string | null | undefined) { return value ? reasonsT
|
||||
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() {
|
||||
if (!canList.value) return false
|
||||
if (!active || !canList.value) return false
|
||||
const request = fence.begin()
|
||||
loading.value = !overview.value
|
||||
try {
|
||||
const nextOverview = await analyticsApi.overview(sourceAccount, undefined, request.signal)
|
||||
if (!request.current()) return false
|
||||
const tasks = warehouseCode.value ? await analyticsApi.tasks({ ...scope.value, current: current.value, size: size.value, ...upperBound.value }, request.signal) : null
|
||||
if (!request.current()) return false
|
||||
overview.value = nextOverview
|
||||
@@ -117,13 +119,14 @@ async function read() {
|
||||
finally { if (request.current()) loading.value = false }
|
||||
}
|
||||
async function selectTask(task: Task, reset = true) {
|
||||
if (!task.taskNo || task.warehouseCode !== warehouseCode.value) return
|
||||
if (!active || !task.taskNo || task.warehouseCode !== warehouseCode.value) return
|
||||
if (reset) { taskOpen.value = true; actionFence.cancel(); submitting.value = false; selected.value = task; selectedDetail.value = null; attempts.value = []; attemptsPage.value = 1; attemptsUpperBound.value = null; reason.value = ''; confirming.value = false; actionFeedback.value = '' }
|
||||
const request = detailFence.begin()
|
||||
detailLoading.value = true
|
||||
detailError.value = ''
|
||||
try {
|
||||
const detail = await analyticsApi.task(scope.value, task.taskNo, request.signal)
|
||||
if (!request.current()) return
|
||||
const history = await analyticsApi.runs({ ...scope.value, taskNo: task.taskNo, current: attemptsPage.value, size: attemptsSize.value, ...attemptsUpperBound.value }, request.signal)
|
||||
if (!request.current()) return
|
||||
if (detail.task.taskNo !== task.taskNo || detail.task.warehouseCode !== warehouseCode.value) throw new Error('任务归属不匹配')
|
||||
@@ -157,13 +160,21 @@ const poller = new RuntimePoller(async () => { const busy = await read(); if (se
|
||||
function visibility() { poller.setVisible(!document.hidden) }
|
||||
function refresh() { upperBound.value = null; current.value = 1; void read() }
|
||||
function openAnalysis(path: string) { void router.push({ path, query: { warehouseCode: warehouseCode.value } }) }
|
||||
watch([() => route.path, () => route.query], () => {
|
||||
if (route.path && route.path !== '/self-operated/collection') return
|
||||
filters.value.warehouseCode = typeof route.query.warehouseCode === 'string' ? route.query.warehouseCode : ''
|
||||
})
|
||||
watch(warehouseCode, () => { configOpen.value = false; taskOpen.value = false; fence.cancel(); detailFence.cancel(); actionFence.cancel(); selected.value = null; rows.value = []; total.value = 0; attempts.value = []; upperBound.value = null; confirming.value = false; submitting.value = false; reason.value = ''; actionFeedback.value = ''; current.value = 1; void read() })
|
||||
watch(taskOpen, open => { if (!open) { detailFence.cancel(); detailLoading.value = false; selected.value = null; selectedDetail.value = null; attempts.value = []; confirming.value = false; reason.value = ''; actionFeedback.value = '' } })
|
||||
watch([current, size], () => { void read() })
|
||||
watch([attemptsPage, attemptsSize], () => { if (selected.value) void selectTask(selected.value, false) })
|
||||
watch(reason, () => { confirming.value = false })
|
||||
onMounted(() => { visibility(); poller.start(); document.addEventListener('visibilitychange', visibility) })
|
||||
onUnmounted(() => { poller.stop(); actionFence.cancel(); document.removeEventListener('visibilitychange', visibility) })
|
||||
function resume() { if (active) return; active = true; visibility(); poller.start() }
|
||||
function suspend() { active = false; poller.stop(); actionFence.cancel(); configOpen.value = false; taskOpen.value = false; loading.value = false; detailLoading.value = false; submitting.value = false }
|
||||
onMounted(() => { resume(); document.addEventListener('visibilitychange', visibility) })
|
||||
onActivated(resume)
|
||||
onDeactivated(suspend)
|
||||
onUnmounted(() => { suspend(); document.removeEventListener('visibilitychange', visibility) })
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage class="admin-density">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import OverviewView from './OverviewView.vue'
|
||||
import DashboardBars from '../components/DashboardBars.vue'
|
||||
import DashboardTrend from '../components/DashboardTrend.vue'
|
||||
import DataTable from '@/shared/components/DataTable.vue'
|
||||
import { dashboardFixture } from './dashboard.fixture'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ dashboard: vi.fn(), overview: vi.fn(), daily: vi.fn(), push: vi.fn(), query: {} as Record<string, string>, permitted: true }))
|
||||
@@ -15,7 +16,7 @@ const query = defineComponent({ props: ['modelValue'], emits: ['update:modelValu
|
||||
const button = defineComponent({ props: ['disabled'], emits: ['click'], setup(props, { slots, emit }) { return () => h('button', { disabled: props.disabled, onClick: () => emit('click') }, slots.default?.()) } })
|
||||
const dialog = defineComponent({ props: ['modelValue', 'width'], emits: ['update:modelValue'], setup(props, { slots }) { return () => props.modelValue ? h('section', [h('button', { onClick: () => undefined }, '趋势弹框'), slots.default?.()]) : null } })
|
||||
const error = defineComponent({ props: ['message'], setup(props) { return () => h('p', props.message) } })
|
||||
const options = { global: { stubs: { AdminPage: passthrough, QueryToolbar: query, AppDialog: dialog, LoadErrorState: error, ApiErrorMeta: true, SkuTrend: true, 'el-button': button } } }
|
||||
const options = { global: { directives: { loading: () => undefined }, stubs: { AdminPage: passthrough, QueryToolbar: query, AppDialog: dialog, LoadErrorState: error, ApiErrorMeta: true, SkuTrend: true, 'el-button': button, 'el-popover': true, 'el-table-column': true, 'el-table': true, 'el-option': true, 'el-select': true } } }
|
||||
const wrappers: ReturnType<typeof mount>[] = []
|
||||
function page() { const wrapper = mount(OverviewView, options); wrappers.push(wrapper); return wrapper }
|
||||
function findButton(wrapper: ReturnType<typeof mount>, text: string) { return wrapper.findAll('button').find(node => node.text() === text)! }
|
||||
@@ -29,6 +30,46 @@ beforeEach(() => {
|
||||
})
|
||||
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()); vi.useRealTimers(); vi.restoreAllMocks() })
|
||||
describe('warehouse operating dashboard', () => {
|
||||
it('does not replace an invalid warehouse-less return with the default warehouse', async () => {
|
||||
mocks.query = { context: JSON.stringify(dashboardFixture.context), overviewReturn: JSON.stringify({ rangePreset: 'THIS_MONTH', scroll: 760 }) }
|
||||
mocks.overview.mockResolvedValue({ warehouses: [{ warehouseCode: 'WH-A', warehouseName: 'A仓' }], observedAt: '2026-10-13T00:00:00Z' })
|
||||
const wrapper = page(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('经营总览返回参数无效')
|
||||
expect(wrapper.findComponent(query).props('modelValue').warehouseCode).toBe('')
|
||||
expect(mocks.dashboard).not.toHaveBeenCalled()
|
||||
await choose(wrapper, { warehouseCode: 'WH-A' })
|
||||
expect(mocks.dashboard).toHaveBeenCalledOnce()
|
||||
expect(mocks.dashboard.mock.calls[0]?.[0]).not.toHaveProperty('queryAsOf')
|
||||
})
|
||||
it('uses the common title/actions/filter surface without an empty result table', async () => {
|
||||
const wrapper = page(); await flushPromises()
|
||||
expect(wrapper.findComponent(DataTable).props()).toMatchObject({ appearance: 'integrated', resultsVisible: false, columnSettingsMode: 'hide' })
|
||||
expect(wrapper.find('.data-table__toolbar-start h2').text()).toBe('经营总览')
|
||||
expect(wrapper.find('.data-table__toolbar-actions').text()).toContain('整体刷新')
|
||||
expect(wrapper.find('.data-table__filters').findComponent(query).exists()).toBe(true)
|
||||
expect(wrapper.findComponent(query).attributes('preference-key')).toBe('self-operated-overview-query')
|
||||
expect(wrapper.find('el-table-stub').exists()).toBe(false)
|
||||
})
|
||||
it('automatically loads the first warehouse and keeps it selected on reset', async () => {
|
||||
mocks.overview.mockResolvedValue({ warehouses: [{ warehouseCode: 'WH-A', warehouseName: '仓A' }, { warehouseCode: 'WH-A' }, { warehouseCode: 'WH-B' }] })
|
||||
const wrapper = page(); await flushPromises()
|
||||
expect(wrapper.findComponent(query).props('modelValue').warehouseCode).toBe('WH-A')
|
||||
expect(mocks.dashboard).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.dashboard.mock.calls[0]?.[0]).toMatchObject({ warehouseCode: 'WH-A', rangePreset: 'LAST_15_DAYS' })
|
||||
expect(wrapper.find('.dashboard-data-notes').attributes('open')).toBeUndefined()
|
||||
await choose(wrapper, { rangePreset: 'TODAY' })
|
||||
wrapper.findComponent(query).vm.$emit('reset'); await flushPromises()
|
||||
expect(wrapper.findComponent(query).props('modelValue')).toMatchObject({ warehouseCode: 'WH-A', rangePreset: 'LAST_15_DAYS' })
|
||||
expect(mocks.dashboard.mock.calls.at(-1)?.[0]).not.toHaveProperty('queryAsOf')
|
||||
})
|
||||
it('preserves a warehouse supplied by a direct link instead of replacing it with the default', async () => {
|
||||
mocks.query = { warehouseCode: 'WH-B' }
|
||||
mocks.overview.mockResolvedValue({ warehouses: [{ warehouseCode: 'WH-A' }, { warehouseCode: 'WH-B' }] })
|
||||
const wrapper = page(); await flushPromises()
|
||||
expect(mocks.dashboard).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.dashboard.mock.calls[0]?.[0].warehouseCode).toBe('WH-B')
|
||||
expect(wrapper.findComponent(query).props('modelValue').warehouseCode).toBe('WH-B')
|
||||
})
|
||||
it('restores the saved warehouse, range, fixed context and scroll when returning from a drilldown', async () => {
|
||||
const scroll = vi.spyOn(window, 'scrollTo').mockImplementation(() => undefined)
|
||||
mocks.query = { warehouseCode: 'WH-A', context: JSON.stringify(dashboardFixture.context), overviewReturn: JSON.stringify({ rangePreset: 'THIS_MONTH', scroll: 760 }) }
|
||||
@@ -48,7 +89,7 @@ describe('warehouse operating dashboard', () => {
|
||||
})
|
||||
it('keeps sales available when inventory is unavailable, with no fabricated distribution', async () => {
|
||||
mocks.dashboard.mockResolvedValue({ ...dashboardFixture, context: { ...dashboardFixture.context, inventoryDrilldownExpiresAt: null }, coverageDistribution: { ...dashboardFixture.coverageDistribution, candidateSkuCount: null, buckets: [], dataStatus: 'UNKNOWN', reasonCodes: ['INVENTORY_UNAVAILABLE'] } })
|
||||
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' }); await findButton(wrapper, '查询').trigger('click'); await flushPromises()
|
||||
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
|
||||
expect(wrapper.text()).toContain('无可用库存分布资料')
|
||||
expect(wrapper.text()).toContain('库存资料不可用')
|
||||
expect(wrapper.findComponent(DashboardTrend).exists()).toBe(true)
|
||||
@@ -56,7 +97,7 @@ describe('warehouse operating dashboard', () => {
|
||||
})
|
||||
it('keeps inventory present without published sales and never derives a daily average from partial sales', async () => {
|
||||
mocks.dashboard.mockResolvedValue({ ...dashboardFixture, context: { ...dashboardFixture.context, salesRunId: null, salesScopeHash: null }, kpis: { ...dashboardFixture.kpis, averageDailyOutboundQuantity30d: { ...dashboardFixture.kpis.averageDailyOutboundQuantity30d, value: null, knownSubtotal: null, dataStatus: 'UNKNOWN', reasonCodes: ['SALES_UNAVAILABLE'] }, physicalQuantity: { ...dashboardFixture.kpis.physicalQuantity, value: '128', dataStatus: 'COMPLETE' } }, salesTrend: { ...dashboardFixture.salesTrend, points: [] } })
|
||||
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' }); await findButton(wrapper, '查询').trigger('click'); await flushPromises()
|
||||
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
|
||||
expect(wrapper.findAll('.dashboard-kpis strong')[1]!.text()).toBe('—')
|
||||
expect(wrapper.findAll('.dashboard-kpis strong')[2]!.text()).toBe('128')
|
||||
expect(wrapper.text()).toContain('销量资料不可用')
|
||||
@@ -65,7 +106,7 @@ describe('warehouse operating dashboard', () => {
|
||||
it('does not request business data without a selected warehouse and renders six metrics/five charts from one snapshot', async () => {
|
||||
const wrapper = page(); await flushPromises()
|
||||
expect(mocks.dashboard).not.toHaveBeenCalled()
|
||||
await choose(wrapper, { warehouseCode: 'WH-A' }); await findButton(wrapper, '查询').trigger('click'); await flushPromises()
|
||||
await choose(wrapper, { warehouseCode: 'WH-A' })
|
||||
expect(mocks.dashboard).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.dashboard.mock.calls[0]?.[0]).toMatchObject({ warehouseCode: 'WH-A', rangePreset: 'LAST_15_DAYS' })
|
||||
expect(wrapper.findAll('.dashboard-kpis section')).toHaveLength(6)
|
||||
@@ -77,27 +118,46 @@ describe('warehouse operating dashboard', () => {
|
||||
expect(wrapper.text()).not.toContain('lingxing-primary')
|
||||
})
|
||||
it('changes only trend range while pinning versions, then explicitly refreshes the whole snapshot', async () => {
|
||||
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' }); await findButton(wrapper, '查询').trigger('click'); await flushPromises()
|
||||
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
|
||||
await choose(wrapper, { rangePreset: 'TODAY' }); await findButton(wrapper, '查询').trigger('click'); await flushPromises()
|
||||
expect(mocks.dashboard.mock.calls.at(-1)?.[0]).toMatchObject({ rangePreset: 'TODAY', salesRunId: '11', inventoryRunId: '12', queryAsOf: dashboardFixture.context.queryAsOf })
|
||||
await findButton(wrapper, '整体刷新').trigger('click'); await flushPromises()
|
||||
expect(mocks.dashboard.mock.calls.at(-1)?.[0]).not.toHaveProperty('salesRunId')
|
||||
})
|
||||
it('clears the old warehouse and ignores its late response', async () => {
|
||||
it('automatically loads the new warehouse, shows loading and ignores the old late response', async () => {
|
||||
let finish!: (value: unknown) => void
|
||||
let finishNew!: (value: unknown) => void
|
||||
mocks.dashboard.mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
|
||||
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' }); await findButton(wrapper, '查询').trigger('click'); await flushPromises()
|
||||
mocks.dashboard.mockImplementationOnce(() => new Promise(resolve => { finishNew = resolve }))
|
||||
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
|
||||
await choose(wrapper, { warehouseCode: 'WH-B' })
|
||||
expect(wrapper.find('.dashboard-kpis').exists()).toBe(false)
|
||||
await findButton(wrapper, '查询').trigger('click'); await flushPromises()
|
||||
expect(wrapper.find('.dashboard-placeholder').exists()).toBe(true)
|
||||
expect(mocks.dashboard).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.dashboard.mock.calls[1]?.[0]).toMatchObject({ warehouseCode: 'WH-B', rangePreset: 'LAST_15_DAYS' })
|
||||
expect(mocks.dashboard.mock.calls[1]?.[0]).not.toHaveProperty('queryAsOf')
|
||||
finishNew(dashboardFixture); await flushPromises()
|
||||
expect(wrapper.find('.dashboard-kpis').exists()).toBe(true)
|
||||
finish({ ...dashboardFixture, kpis: { ...dashboardFixture.kpis, physicalQuantity: { ...dashboardFixture.kpis.physicalQuantity, value: '999999' } } }); await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('999999')
|
||||
expect(mocks.dashboard.mock.calls[0]?.[1].aborted).toBe(true)
|
||||
})
|
||||
it('retains the switched warehouse when the query toolbar clears its fields on reset', async () => {
|
||||
mocks.overview.mockResolvedValue({ warehouses: [{ warehouseCode: 'WH-A' }, { warehouseCode: 'WH-B' }] })
|
||||
const wrapper = page(); await flushPromises()
|
||||
await choose(wrapper, { warehouseCode: 'WH-B' })
|
||||
const toolbar = wrapper.findComponent(query)
|
||||
toolbar.props('modelValue').warehouseCode = ''
|
||||
toolbar.props('modelValue').rangePreset = ''
|
||||
toolbar.vm.$emit('reset'); await flushPromises()
|
||||
expect(toolbar.props('modelValue')).toMatchObject({ warehouseCode: 'WH-B', rangePreset: 'LAST_15_DAYS' })
|
||||
expect(mocks.dashboard).toHaveBeenCalledTimes(3)
|
||||
expect(mocks.dashboard.mock.calls.at(-1)?.[0]).toMatchObject({ warehouseCode: 'WH-B', rangePreset: 'LAST_15_DAYS' })
|
||||
})
|
||||
it('preserves same-SKU owner identity in the 1120 dialog and cancels closed requests', async () => {
|
||||
let finish!: (value: unknown) => void
|
||||
mocks.daily.mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
|
||||
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' }); await findButton(wrapper, '查询').trigger('click'); await flushPromises()
|
||||
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
|
||||
wrapper.findAllComponents(DashboardBars)[1]!.vm.$emit('select', '1'); await flushPromises()
|
||||
expect(mocks.daily.mock.calls[0]?.[0]).toMatchObject({ customerCode: 'OWNER-B', sku: 'SKU-X', rangePreset: 'CUSTOM', startDate: '2026-09-13', endDate: '2026-10-12', salesRunId: '11' })
|
||||
expect(wrapper.findComponent(dialog).props('width')).toBe('1120px')
|
||||
@@ -107,7 +167,7 @@ describe('warehouse operating dashboard', () => {
|
||||
expect(wrapper.text()).not.toContain('LATE-SKU')
|
||||
})
|
||||
it('emits exact bucket/day/ranking navigation and keeps unknown day navigation disabled', async () => {
|
||||
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' }); await findButton(wrapper, '查询').trigger('click'); await flushPromises()
|
||||
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
|
||||
wrapper.findAllComponents(DashboardBars)[0]!.vm.$emit('select', 'DAYS_GT_90'); await flushPromises()
|
||||
expect(mocks.push.mock.calls.at(-1)?.[0]).toMatchObject({ path: '/self-operated/inventory', query: { coverageBucket: 'DAYS_GT_90', bucketRuleVersion: 'COVERAGE_DISTRIBUTION_V1', from: 'overview' } })
|
||||
const chart = wrapper.findComponent(DashboardTrend)
|
||||
@@ -123,7 +183,7 @@ describe('warehouse operating dashboard', () => {
|
||||
it('expires inventory before sales while retaining stock-independent SKU trends', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date', 'setInterval', 'clearInterval'] }); vi.setSystemTime(new Date('2026-10-13T00:00:00Z'))
|
||||
mocks.dashboard.mockResolvedValue({ ...dashboardFixture, context: { ...dashboardFixture.context, inventoryDrilldownExpiresAt: '2026-10-13T00:00:01Z' } })
|
||||
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' }); await findButton(wrapper, '查询').trigger('click'); await flushPromises()
|
||||
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
expect(wrapper.text()).toContain('原图库存已过期或不可用')
|
||||
expect(wrapper.findAllComponents(DashboardBars)[0]!.props('disabled')).toBe(true)
|
||||
@@ -134,7 +194,7 @@ describe('warehouse operating dashboard', () => {
|
||||
expect(mocks.push).not.toHaveBeenCalled()
|
||||
})
|
||||
it('retains prior figures on failed refresh and does not render unknown quantities as zero', async () => {
|
||||
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' }); await findButton(wrapper, '查询').trigger('click'); await flushPromises()
|
||||
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
|
||||
mocks.dashboard.mockRejectedValue(new Error('offline'))
|
||||
await findButton(wrapper, '整体刷新').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('展示上次查询')
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { Activity, ArrowUpRight, Boxes, Package, Layers, Clock3 } from '@lucide/vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import DataTable from '@/shared/components/DataTable.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
@@ -23,6 +25,7 @@ const access = useAccessPreviewStore()
|
||||
const permitted = computed(() => access.can('platform:self-operated-analytics:list'))
|
||||
const sourceAccount = 'lingxing-primary'
|
||||
const filters = ref<QueryModel>({ warehouseCode: typeof route.query.warehouseCode === 'string' ? route.query.warehouseCode : '', rangePreset: 'LAST_15_DAYS', startDate: '', endDate: '' })
|
||||
let selectedWarehouse = String(filters.value.warehouseCode)
|
||||
const context = ref<QueryContext | null>(null)
|
||||
const data = ref<Dashboard | null>(null)
|
||||
const runtime = ref<Overview | null>(null)
|
||||
@@ -35,6 +38,7 @@ const newData = ref(false)
|
||||
const now = ref(Date.now())
|
||||
let timer: ReturnType<typeof setInterval> | undefined
|
||||
let restoreScroll: number | null = null
|
||||
let initialWarehouseResolved = false
|
||||
let appliedRange: OverviewReturn = { rangePreset: 'LAST_15_DAYS', scroll: 0 }
|
||||
try {
|
||||
if (route.query.context !== undefined || route.query.overviewReturn !== undefined) {
|
||||
@@ -49,7 +53,7 @@ const warehouseCode = computed(() => String(filters.value.warehouseCode ?? ''))
|
||||
const contextExpired = computed(() => Boolean(context.value && expired(context.value, now.value)))
|
||||
const stockExpired = computed(() => Boolean(context.value && inventoryExpired(context.value, now.value)))
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'warehouseCode', type: 'select', label: '仓库', width: 220, placeholder: '请选择仓库(必选)', options: [...new Map((runtime.value?.warehouses ?? []).map(item => [item.warehouseCode, { value: item.warehouseCode, label: item.warehouseName || item.warehouseCode }])).values()] },
|
||||
{ key: 'warehouseCode', type: 'select', label: '仓库', width: 220, placeholder: '切换仓库', props: { clearable: false }, options: [...new Map((runtime.value?.warehouses ?? []).map(item => [item.warehouseCode, { value: item.warehouseCode, label: item.warehouseName || item.warehouseCode }])).values()] },
|
||||
{ key: 'rangePreset', type: 'select', label: '出库趋势范围', width: 180, props: { clearable: false }, options: rangeOptions },
|
||||
...(filters.value.rangePreset === 'CUSTOM' ? [{ key: 'startDate', type: 'date' as const, label: '开始日期', width: 160 }, { key: 'endDate', type: 'date' as const, label: '结束日期', width: 160 }] : []),
|
||||
])
|
||||
@@ -58,6 +62,8 @@ const metricNames: { key: keyof Dashboard['kpis']; label: string; inventory?: bo
|
||||
{ key: 'physicalQuantity', label: '实物库存', inventory: true }, { key: 'availableQuantity', label: '正品可售', inventory: true },
|
||||
{ key: 'physicalSkuCount', label: '有库存SKU', inventory: true }, { key: 'noOutboundSkuCount30d', label: '近30日无出库SKU', inventory: true },
|
||||
]
|
||||
const metricIcons = [ArrowUpRight, Activity, Boxes, Package, Layers, Clock3]
|
||||
const metricColors = ['#5575ed', '#3aa9ba', '#8871ce', '#35a987', '#c6923e', '#d67b91']
|
||||
const rankingNames = [{ key: 'hotSkus', label: '近30日热销Top10', unit: '件', all: '查看Top100' }, { key: 'longCoverageSkus', label: '预计消化时间最长Top10', unit: '天', all: '查看全部' }, { key: 'noOutboundSkus', label: '近30日无出库库存Top10', unit: '件', all: '查看全部' }] as const
|
||||
const bucketRows = computed<BarRow[]>(() => buckets.map(bucket => {
|
||||
const item = data.value?.coverageDistribution.buckets.find(row => row.bucketCode === bucket.code)
|
||||
@@ -101,6 +107,12 @@ async function readRuntime() {
|
||||
const result = await analyticsApi.overview(sourceAccount, undefined, request.signal)
|
||||
if (!request.current()) return false
|
||||
runtime.value = result; runtimeError.value = ''
|
||||
if (!initialWarehouseResolved && !routeError.value) {
|
||||
initialWarehouseResolved = true
|
||||
if (!warehouseCode.value) filters.value.warehouseCode = result.warehouses.find(item => item.warehouseCode)?.warehouseCode ?? ''
|
||||
await nextTick()
|
||||
if (warehouseCode.value && !routeError.value && !loading.value && !data.value) void load()
|
||||
}
|
||||
if (context.value && result.warehouses.some(item => item.warehouseCode === warehouseCode.value && item.lastPublishedAt && Date.parse(item.lastPublishedAt) > Date.parse(context.value!.queryAsOf))) newData.value = true
|
||||
} catch { if (request.current()) runtimeError.value = '采集状态更新失败,经营图表仍保留原查询版本。' }
|
||||
return false
|
||||
@@ -131,8 +143,16 @@ async function showTrend(item: DashboardItem) {
|
||||
} catch (cause) { if (request.current()) trendError.value = getApiErrorDetails(cause, 'SKU趋势读取失败').message }
|
||||
finally { if (request.current()) trendLoading.value = false }
|
||||
}
|
||||
function reset() { filters.value = { warehouseCode: '', rangePreset: 'LAST_15_DAYS', startDate: '', endDate: '' }; routeError.value = '' }
|
||||
watch(warehouseCode, () => { dataFence.cancel(); trendFence.cancel(); data.value = null; context.value = null; trendOpen.value = false; loading.value = false; error.value = ''; newData.value = false; routeError.value = ''; restoreScroll = null })
|
||||
async function reset() {
|
||||
filters.value = { warehouseCode: warehouseCode.value || selectedWarehouse || runtime.value?.warehouses.find(item => item.warehouseCode)?.warehouseCode || '', rangePreset: 'LAST_15_DAYS', startDate: '', endDate: '' }
|
||||
routeError.value = ''
|
||||
await nextTick()
|
||||
void load(true)
|
||||
}
|
||||
watch(warehouseCode, code => {
|
||||
dataFence.cancel(); trendFence.cancel(); data.value = null; context.value = null; trendOpen.value = false; loading.value = false; error.value = ''; errorDetails.value = null; newData.value = false; routeError.value = ''; restoreScroll = null
|
||||
if (code) { selectedWarehouse = code; void load(true) }
|
||||
})
|
||||
watch(trendOpen, open => { if (!open) { trendFence.cancel(); trend.value = null; trendLoading.value = false } })
|
||||
onMounted(() => { visibility(); poller.start(); timer = setInterval(() => { now.value = Date.now() }, 1000); document.addEventListener('visibilitychange', visibility); if (context.value && !routeError.value) void load() })
|
||||
onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clearInterval(timer); document.removeEventListener('visibilitychange', visibility) })
|
||||
@@ -141,9 +161,12 @@ onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clea
|
||||
<AdminPage class="admin-density dashboard-page">
|
||||
<p v-if="!permitted" role="alert">没有自营经营数据查看权限。</p>
|
||||
<template v-else>
|
||||
<div class="density-actions dashboard-actions"><el-button :disabled="!data || contextExpired" @click="drill('sales', { analysis: 'SKU', rangePreset: appliedRange.rangePreset, ...(appliedRange.startDate ? { startDate: appliedRange.startDate, endDate: appliedRange.endDate! } : {}) })">查看销量</el-button><el-button :disabled="!warehouseCode" @click="router.push({ path: '/self-operated/inventory', query: { warehouseCode } })">{{ stockExpired ? '普通库存诊断' : '查看库存' }}</el-button><el-button :disabled="!warehouseCode" @click="router.push({ path: '/self-operated/collection', query: { warehouseCode } })">数据采集</el-button></div>
|
||||
<QueryToolbar v-model="filters" :fields="fields" :loading="loading" :search-on-reset="false" ariaLabel="经营总览筛选" @search="load()" @reset="reset" />
|
||||
<div class="density-actions"><el-button :disabled="!warehouseCode || loading || Boolean(routeError)" @click="load(true)">整体刷新</el-button><span>趋势范围只影响区间出库与日趋势,其余指标使用近30个完整日。</span></div>
|
||||
<DataTable appearance="integrated" :data="[]" :columns="[]" :results-visible="false" column-settings-mode="hide">
|
||||
<template #toolbar-start><div class="dashboard-title"><h2>经营总览</h2><span class="dashboard-snapshot">{{ runtime?.warehouses.find(item => item.warehouseCode === warehouseCode)?.warehouseName || warehouseCode }}<i />{{ loading ? '正在更新' : data ? '固定版本 · 北京时间' : '正在准备数据' }}</span></div></template>
|
||||
<template #toolbar-actions><div class="density-actions"><el-button :disabled="!data || contextExpired" @click="drill('sales', { analysis: 'SKU', rangePreset: appliedRange.rangePreset, ...(appliedRange.startDate ? { startDate: appliedRange.startDate, endDate: appliedRange.endDate! } : {}) })">查看销量</el-button><el-button :disabled="!warehouseCode" @click="router.push({ path: '/self-operated/inventory', query: { warehouseCode } })">{{ stockExpired ? '普通库存诊断' : '查看库存' }}</el-button><el-button :disabled="!warehouseCode" @click="router.push({ path: '/self-operated/collection', query: { warehouseCode } })">数据采集</el-button><el-button :disabled="!warehouseCode || loading || Boolean(routeError)" @click="load(true)">整体刷新</el-button></div></template>
|
||||
<template #filters><QueryToolbar v-model="filters" :fields="fields" :loading="loading" :search-on-reset="false" preference-key="self-operated-overview-query" ariaLabel="经营总览筛选" @search="load()" @reset="reset" /></template>
|
||||
</DataTable>
|
||||
<details class="dashboard-data-notes"><summary>统计口径与采集时间</summary><p>趋势范围只影响区间出库与日趋势,其余指标使用近30个完整日。</p><p v-if="data">销量覆盖截至 {{ beijingTime(data.context.salesCoveredUntil) }};库存采集于 {{ beijingTime(data.context.inventoryObservedFrom) }} 至 {{ beijingTime(data.context.inventoryObservedUntil) }}(北京时间,本仓一轮完整分页观测)。</p></details>
|
||||
<p v-if="!warehouseCode">请先选择一个仓库并查询经营数据。</p>
|
||||
<p v-if="routeError" role="alert">{{ routeError }}。请返回正常入口重新选择仓库。</p>
|
||||
<LoadErrorState v-if="error" title="经营总览查询未完成" :message="error" compact :loading="loading" @retry="load(true)"><template #details><ApiErrorMeta v-if="errorDetails" :details="errorDetails" /></template></LoadErrorState>
|
||||
@@ -151,18 +174,18 @@ onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clea
|
||||
<p v-if="newData" role="status">有新数据,可整体刷新;当前图表仍使用原查询版本。</p>
|
||||
<p v-if="contextExpired" role="alert">查询上下文已过期,以下图表为历史结果,请整体刷新。</p>
|
||||
<p v-else-if="stockExpired && data" role="status">原图库存已过期或不可用,库存指标为历史/待刷新资料,严格库存下钻已停用;出库趋势与热销仍可查看。</p>
|
||||
<div v-if="loading && !data" class="dashboard-placeholder" role="status" aria-busy="true">正在读取六项指标、日趋势、库存分布和三个榜单…</div>
|
||||
<div v-if="loading && !data" class="dashboard-placeholder" role="status" aria-busy="true"><span>正在读取六项指标、日趋势、库存分布和三个榜单…</span><div class="dashboard-skeleton-kpis" aria-hidden="true"><i v-for="index in 6" :key="index" /></div><div class="dashboard-skeleton-charts" aria-hidden="true"><i /><i /></div></div>
|
||||
<template v-if="data">
|
||||
<p>销量覆盖截至 {{ beijingTime(data.context.salesCoveredUntil) }};库存采集于 {{ beijingTime(data.context.inventoryObservedFrom) }} 至 {{ beijingTime(data.context.inventoryObservedUntil) }}(北京时间,本仓一轮完整分页观测)。</p>
|
||||
|
||||
<div class="dashboard-kpis" aria-label="本仓经营指标">
|
||||
<section v-for="metric in metricNames" :key="metric.key"><h3>{{ metric.key === 'intervalOutboundQuantity' && data.kpis[metric.key].dataStatus !== 'COMPLETE' ? '区间已采集出库量' : metric.label }}</h3><strong>{{ quantityText(metric.key === 'intervalOutboundQuantity' && data.kpis[metric.key].dataStatus !== 'COMPLETE' ? data.kpis[metric.key].observedQuantity : data.kpis[metric.key].value) }}</strong><span> {{ metric.key === 'averageDailyOutboundQuantity30d' ? '件/日' : data.kpis[metric.key].unit === 'SKU' ? 'SKU' : '件' }}</span><p>{{ contextExpired || metric.inventory && stockExpired ? '历史/待刷新 · ' : '' }}{{ label(data.kpis[metric.key].dataStatus) }}</p><p v-if="data.kpis[metric.key].value == null && metric.key !== 'intervalOutboundQuantity'">已知小计 {{ quantityText(data.kpis[metric.key].knownSubtotal) }}</p><p v-if="data.kpis[metric.key].unknownSkuCount != null">另 {{ quantityText(data.kpis[metric.key].unknownSkuCount) }} SKU 待核验</p><small>{{ reasonsText(data.kpis[metric.key].reasonCodes) }}</small></section>
|
||||
<section v-for="(metric, index) in metricNames" :key="metric.key" :style="{ '--metric-color': metricColors[index], '--reveal-delay': `${index * 45}ms` }"><div class="dashboard-metric-heading"><h3>{{ metric.key === 'intervalOutboundQuantity' && data.kpis[metric.key].dataStatus !== 'COMPLETE' ? '区间已采集出库量' : metric.label }}</h3><component :is="metricIcons[index]" :size="18" aria-hidden="true" /></div><div class="dashboard-metric-value"><strong>{{ quantityText(metric.key === 'intervalOutboundQuantity' && data.kpis[metric.key].dataStatus !== 'COMPLETE' ? data.kpis[metric.key].observedQuantity : data.kpis[metric.key].value) }}</strong><span> {{ metric.key === 'averageDailyOutboundQuantity30d' ? '件/日' : data.kpis[metric.key].unit === 'SKU' ? 'SKU' : '件' }}</span></div><p class="dashboard-metric-status">{{ contextExpired || metric.inventory && stockExpired ? '历史/待刷新 · ' : '' }}{{ label(data.kpis[metric.key].dataStatus) }}</p><p v-if="data.kpis[metric.key].value == null && metric.key !== 'intervalOutboundQuantity'">已知小计 {{ quantityText(data.kpis[metric.key].knownSubtotal) }}</p><p v-if="data.kpis[metric.key].unknownSkuCount != null">另 {{ quantityText(data.kpis[metric.key].unknownSkuCount) }} SKU 待核验</p><small>{{ reasonsText(data.kpis[metric.key].reasonCodes) }}</small></section>
|
||||
</div>
|
||||
<div class="dashboard-main-charts">
|
||||
<section class="dashboard-chart"><h3>出库趋势</h3><p>{{ data.context.rangeStartDate }} 至 {{ data.context.rangeEndDateExclusive }}(截止日不含)</p><DashboardTrend :points="data.salesTrend.points" :disabled="contextExpired" @select="date => drill('sales', { analysis: 'SKU', rangePreset: 'CUSTOM', startDate: date, endDate: date })" /></section>
|
||||
<section class="dashboard-chart"><h3>库存消化天数分布</h3><p>有实物库存或库存待核验 SKU;{{ data.context.fixedWindowStartDate }} 至 {{ data.context.fixedWindowEndDateExclusive }}(截止日不含)。</p><p>候选 {{ quantityText(data.coverageDistribution.candidateSkuCount) }};已排除确定零实物 {{ quantityText(data.coverageDistribution.excludedZeroPhysicalSkuCount) }} SKU。{{ label(data.coverageDistribution.dataStatus) }} · {{ reasonsText(data.coverageDistribution.reasonCodes) }}</p><p v-if="data.coverageDistribution.candidateSkuCount == null">无可用库存分布资料;销售候选不作为库存总量。</p><DashboardBars v-else :rows="bucketRows" title="库存消化天数分布" unit="SKU" :disabled="stockExpired || contextExpired" @select="code => drill('inventory', { coverageBucket: code, bucketRuleVersion: 'COVERAGE_DISTRIBUTION_V1' })" /><details><summary>分组说明与待核验原因</summary><p>30/60/90 仅为图形分组,不是经营评级;无可售库存不代表周转良好。零出库不计无限天数。</p><p v-for="(count, reason) in data.coverageDistribution.unknownReasons" :key="reason">{{ reasonsText([String(reason)]) }}:{{ quantityText(count) }} SKU</p></details></section>
|
||||
</div>
|
||||
<div class="dashboard-rankings">
|
||||
<section v-for="ranking in rankingNames" :key="ranking.key" class="dashboard-chart"><div class="dashboard-ranking-heading"><h3>{{ ranking.label }}</h3><el-button :disabled="contextExpired || ranking.key !== 'hotSkus' && stockExpired" @click="allRanking(ranking.key)">{{ ranking.all }}</el-button></div><p>{{ data[ranking.key].windowStartDate }} 至 {{ data[ranking.key].windowEndDateExclusive }}(截止日不含);符合条件 {{ quantityText(data[ranking.key].eligibleCount) }} SKU,待评估 {{ quantityText(data[ranking.key].unassessableCount) }}。</p><p>{{ contextExpired || ranking.key !== 'hotSkus' && stockExpired ? '历史/待刷新 · ' : '' }}{{ label(data[ranking.key].dataStatus) }} · {{ reasonsText(data[ranking.key].reasonCodes) }}</p><DashboardBars :rows="rankingRows(data[ranking.key].items)" :title="ranking.label" :unit="ranking.unit" :disabled="contextExpired" @select="index => showTrend(data![ranking.key].items[Number(index)]!)" /></section>
|
||||
<section v-for="ranking in rankingNames" :key="ranking.key" class="dashboard-chart"><div class="dashboard-ranking-heading"><h3>{{ ranking.label }}</h3><el-button :disabled="contextExpired || ranking.key !== 'hotSkus' && stockExpired" @click="allRanking(ranking.key)">{{ ranking.all }}</el-button></div><p>{{ data[ranking.key].windowStartDate }} 至 {{ data[ranking.key].windowEndDateExclusive }}(截止日不含);符合条件 {{ quantityText(data[ranking.key].eligibleCount) }} SKU,待评估 {{ quantityText(data[ranking.key].unassessableCount) }}。</p><p>{{ contextExpired || ranking.key !== 'hotSkus' && stockExpired ? '历史/待刷新 · ' : '' }}{{ label(data[ranking.key].dataStatus) }} · {{ reasonsText(data[ranking.key].reasonCodes) }}</p><DashboardBars ranked :rows="rankingRows(data[ranking.key].items)" :title="ranking.label" :unit="ranking.unit" :disabled="contextExpired" @select="index => showTrend(data![ranking.key].items[Number(index)]!)" /></section>
|
||||
</div>
|
||||
</template>
|
||||
<AppDialog v-model="trendOpen" class="admin-density-dialog" title="SKU每日出库趋势" width="1120px" :loading="trendLoading" :show-confirm="false" cancel-text="关闭" fullscreenable><div class="admin-density"><p v-if="trendError" role="alert">{{ trendError }}</p><SkuTrend v-if="trend" :data="trend" /></div></AppDialog>
|
||||
@@ -171,15 +194,39 @@ onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clea
|
||||
</template>
|
||||
<style scoped>
|
||||
.dashboard-page { display: grid; gap: var(--admin-space-3); }
|
||||
.dashboard-actions { justify-content: flex-end; }
|
||||
.dashboard-kpis { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); padding: var(--admin-space-3); border-block: 1px solid var(--el-border-color); gap: var(--admin-space-3); }
|
||||
.dashboard-title { display: flex; align-items: center; gap: 16px; }
|
||||
.dashboard-data-notes { font-size: var(--admin-font-caption); color: #76849c; }
|
||||
.dashboard-data-notes summary { cursor: pointer; width: fit-content; }
|
||||
.dashboard-data-notes p { margin: 6px 0; }
|
||||
.dashboard-snapshot { display: inline-flex; align-items: center; gap: 8px; color: #65748d; font-size: var(--admin-font-caption); }
|
||||
.dashboard-snapshot i { width: 7px; height: 7px; border-radius: 50%; background: #5575ed; }
|
||||
.dashboard-kpis { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
|
||||
.dashboard-kpis section { min-width: 0; position: relative; padding: 16px; border: 1px solid #e5eaf4; border-radius: 8px; background: white; border-top: 3px solid var(--metric-color); animation: dashboard-reveal .5s ease-out both; animation-delay: var(--reveal-delay); }
|
||||
.dashboard-metric-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.dashboard-metric-heading svg { color: var(--metric-color); flex-shrink: 0; }
|
||||
.dashboard-kpis h3, .dashboard-chart h3 { margin: 0; font-size: var(--admin-font-section); }
|
||||
.dashboard-kpis strong { font-size: var(--admin-font-title); }
|
||||
.dashboard-kpis h3 { color: #65748d; font-size: var(--admin-font-caption); font-weight: 500; }
|
||||
.dashboard-metric-value { margin: 12px 0 8px; display: flex; flex-wrap: wrap; align-items: baseline; gap: 5px; }
|
||||
.dashboard-kpis strong { font-size: 28px; font-weight: 700; letter-spacing: -.7px; font-variant-numeric: tabular-nums; color: #24334d; overflow-wrap: anywhere; }
|
||||
.dashboard-metric-value > span { font-size: var(--admin-font-caption); color: #8290a6; }
|
||||
.dashboard-metric-status { color: var(--metric-color); }
|
||||
.dashboard-kpis p { margin: var(--admin-space-1) 0; }
|
||||
.dashboard-main-charts { display: grid; grid-template-columns: 2fr 1fr; gap: var(--admin-space-4); }
|
||||
.dashboard-rankings { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-4); }
|
||||
.dashboard-chart { min-width: 0; padding: var(--admin-space-3); background: var(--admin-surface); border: 1px solid var(--el-border-color); border-radius: var(--admin-radius); }
|
||||
.dashboard-chart { min-width: 0; padding: 18px; background: var(--admin-surface); border: 1px solid #e5eaf4; border-radius: 8px; animation: dashboard-reveal .6s ease-out both; }
|
||||
.dashboard-chart > h3, .dashboard-ranking-heading h3 { display: flex; align-items: center; gap: 8px; }
|
||||
.dashboard-chart > h3::before, .dashboard-ranking-heading h3::before { content: ''; width: 3px; height: 14px; border-radius: 2px; background: #5575ed; }
|
||||
.dashboard-chart > p { color: #76849c; font-size: var(--admin-font-caption); line-height: 1.7; }
|
||||
.dashboard-rankings .dashboard-chart:nth-child(2) { animation-delay: .08s; }
|
||||
.dashboard-rankings .dashboard-chart:nth-child(3) { animation-delay: .16s; }
|
||||
.dashboard-ranking-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-2); }
|
||||
.dashboard-placeholder { min-height: 320px; padding: var(--admin-space-4); background: var(--el-fill-color-light); }
|
||||
.dashboard-placeholder { padding: 12px 0; color: #76849c; }
|
||||
.dashboard-skeleton-kpis { display: grid; grid-template-columns: repeat(6, 1fr); gap: 12px; margin: 12px 0; }
|
||||
.dashboard-skeleton-charts { display: grid; grid-template-columns: 2fr 1fr; gap: 12px; }
|
||||
.dashboard-placeholder i { display: block; height: 130px; border-radius: 8px; background: linear-gradient(100deg, #eaf0f8 30%, #f8faff 50%, #eaf0f8 70%); background-size: 200% 100%; animation: skeleton-shimmer 1.5s infinite; }
|
||||
.dashboard-skeleton-charts i { height: 320px; }
|
||||
@keyframes dashboard-reveal { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes skeleton-shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }
|
||||
@media (prefers-reduced-motion: reduce) { .dashboard-kpis section, .dashboard-chart, .dashboard-placeholder i { animation: none; } }
|
||||
small { font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
|
||||
</style>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { defineComponent, h, KeepAlive, reactive, ref } from 'vue'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AnalyticsView from '../components/AnalyticsView.vue'
|
||||
import CollectionView from './CollectionView.vue'
|
||||
import type { Metric, Overview, OwnerOverview, QueryContext, SalesSummary, Task, WarehouseOverview } from '../types'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ api: { owners: vi.fn(), ownerOverview: vi.fn(), summary: vi.fn(), daily: vi.fn(), hot: vi.fn(), slow: vi.fn(), inventory: vi.fn(), overview: vi.fn(), tasks: vi.fn(), task: vi.fn(), runs: vi.fn(), retry: vi.fn() }, canSync: true, routeQuery: { warehouseCode: 'WH-A' } as Record<string, string>, push: vi.fn() }))
|
||||
const mocks = vi.hoisted(() => ({ api: { owners: vi.fn(), ownerOverview: vi.fn(), summary: vi.fn(), daily: vi.fn(), hot: vi.fn(), slow: vi.fn(), inventory: vi.fn(), overview: vi.fn(), tasks: vi.fn(), task: vi.fn(), runs: vi.fn(), retry: vi.fn() }, canSync: true, routeState: null as { path: string; query: Record<string, string> } | null, routeQuery: { warehouseCode: 'WH-A' } as Record<string, string>, push: vi.fn() }))
|
||||
vi.mock('../api/analytics', () => ({ analyticsApi: mocks.api }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => permission.endsWith(':list') || mocks.canSync }) }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => ({ query: mocks.routeQuery }), useRouter: () => ({ push: mocks.push }) }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => mocks.routeState ?? ({ query: mocks.routeQuery }), useRouter: () => ({ push: mocks.push }) }))
|
||||
|
||||
const context: QueryContext = { salesRunId: '11', inventoryRunId: '0', salesScopeHash: 'sales', inventoryScopeHash: null, queryAsOf: '2026-10-12T01:00:00Z', queryExpiresAt: '2026-10-12T01:30:00Z', businessTimezone: 'Asia/Shanghai', anchorDate: '2026-10-12', completeDayFrom: '2026-08-13', completeDayUntil: '2026-10-12', rangeStartDate: '2026-09-12', rangeEndDateExclusive: '2026-10-12' }
|
||||
const quantity = { overallTotal: null, knownSubtotal: '128', unknownSkuCount: 1, dataStatus: 'PARTIAL', reasonCodes: ['INVENTORY_UNKNOWN'] }
|
||||
@@ -34,7 +34,7 @@ function findButton(wrapper: ReturnType<typeof mount>, text: string) { const res
|
||||
async function setFilters(wrapper: ReturnType<typeof mount>, values: Record<string, string | number>) { const toolbar = wrapper.findComponent(queryStub); toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), ...values }); await flushPromises() }
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.canSync = true
|
||||
mocks.canSync = true; mocks.routeState = null
|
||||
mocks.routeQuery = { warehouseCode: 'WH-A' }
|
||||
mocks.api.overview.mockResolvedValue(overview)
|
||||
mocks.api.summary.mockResolvedValue({ context, records: [summary], total: 1 })
|
||||
@@ -48,8 +48,36 @@ beforeEach(() => {
|
||||
mocks.api.runs.mockResolvedValue({ records: [], total: 0, listUpperCreatedAt: '2026-10-12T01:00:00', listUpperId: '10' })
|
||||
mocks.api.retry.mockResolvedValue({ taskNo: task.taskNo, status: 'QUEUED' })
|
||||
})
|
||||
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()) })
|
||||
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()); vi.useRealTimers() })
|
||||
describe('self-operated actual workbench request state', () => {
|
||||
it('resumes a cached collection page with the new deep-linked warehouse 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')
|
||||
let resolveOld!: (value: Overview) => void
|
||||
mocks.api.overview.mockImplementationOnce(() => new Promise<Overview>(resolve => { resolveOld = resolve }))
|
||||
await vi.advanceTimersByTimeAsync(60000)
|
||||
const pendingSignal = mocks.api.overview.mock.calls.at(-1)?.[2] as AbortSignal
|
||||
visible.value = false; await flushPromises()
|
||||
expect(pendingSignal.aborted).toBe(true)
|
||||
const tasksBefore = mocks.api.tasks.mock.calls.length
|
||||
route.path = '/self-operated/overview'; route.query = { warehouseCode: 'WH-B' }
|
||||
resolveOld(overview); await flushPromises()
|
||||
const readsBefore = mocks.api.overview.mock.calls.length
|
||||
await vi.advanceTimersByTimeAsync(120000)
|
||||
expect(mocks.api.overview).toHaveBeenCalledTimes(readsBefore)
|
||||
expect(mocks.api.tasks).toHaveBeenCalledTimes(tasksBefore)
|
||||
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')
|
||||
})
|
||||
it.each([
|
||||
{ coverageSelection: 'FINITE_POSITIVE', sortBy: 'COVERAGE_DAYS_DESC' },
|
||||
{ attention: 'NO_OUTBOUND_30D', sortBy: 'PHYSICAL_DESC' },
|
||||
|
||||
Reference in New Issue
Block a user