fix(selfop): preserve warehouse navigation and refine overview presentation
This commit is contained in:
@@ -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