优化
This commit is contained in:
@@ -6,9 +6,9 @@ import { BarChart, GaugeChart, LineChart } from 'echarts/charts'
|
||||
import { AriaComponent, DataZoomComponent, GridComponent, TooltipComponent } from 'echarts/components'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
use([LineChart, BarChart, GaugeChart, GridComponent, TooltipComponent, DataZoomComponent, AriaComponent, CanvasRenderer])
|
||||
defineProps<{ option: EChartsOption }>()
|
||||
defineProps<{ option: EChartsOption; mergeUpdates?: boolean }>()
|
||||
defineEmits<{ select: [event: ECElementEvent] }>()
|
||||
</script>
|
||||
<template>
|
||||
<VChart :option="option" :init-options="{ renderer: 'canvas' }" :update-options="{ notMerge: true }" autoresize @click="$emit('select', $event)" />
|
||||
<VChart :option="option" :init-options="{ renderer: 'canvas' }" :update-options="{ notMerge: !mergeUpdates }" autoresize @click="$emit('select', $event)" />
|
||||
</template>
|
||||
|
||||
@@ -38,7 +38,6 @@ function initialFilters(): QueryModel { return { warehouseCode: typeof route.que
|
||||
const filters = ref<QueryModel>(initialFilters())
|
||||
const applied = ref({ sku: String(filters.value.sku) })
|
||||
const appliedWindow = ref({ salesWindowDays: 30 as 10 | 30, salesWindowEndDate: String(filters.value.salesWindowEndDate) })
|
||||
const defaultInventoryWindow = computed(() => Number(filters.value.salesWindowDays) === 30 && filters.value.salesWindowEndDate === previousDate(context.value?.anchorDate ?? beijingToday()))
|
||||
const displayedWindowDays = computed(() => context.value?.calculationWindowDays ?? appliedWindow.value.salesWindowDays)
|
||||
const overview = ref<Overview | null>(null)
|
||||
const context = ref<QueryContext | null>(drilldown.value?.context ?? null)
|
||||
@@ -82,11 +81,11 @@ const fields = computed<QueryField[]>(() => {
|
||||
{ key: 'sku', type: 'input', label: 'SKU', width: 230, placeholder: '输入SKU编码(精确查询)' },
|
||||
]
|
||||
if (props.mode === 'inventory') result.push(
|
||||
{ key: 'salesWindowDays', type: 'select', label: '销量窗口', width: 150, props: { clearable: false }, options: [{ value: 10, label: '10个完整自然日' }, { value: 30, label: '30个完整自然日' }] },
|
||||
{ key: 'salesWindowEndDate', type: 'date', label: '统计截止日(含)', width: 180, props: { clearable: false } },
|
||||
{ key: 'attention', type: 'select', label: '库存关注', width: 200, props: { 'aria-label': '库存关注' }, options: [{ value: 'ALL', label: '全部库存关注' }, ...(defaultInventoryWindow.value ? [{ value: 'NO_OUTBOUND_30D', label: '近30天无出库' }] : []), { value: 'COVERAGE_GT_THRESHOLD', label: '可售天数超过阈值' }, { value: 'FROZEN_STOCK', label: '冻结库存' }, { value: 'DEFECTIVE_STOCK', label: '次品库存' }, { value: 'UNASSESSABLE', label: '数据不足/待观察' }] },
|
||||
...(!drilldown.value ? [{ key: 'salesWindowDays', type: 'select' as const, label: '销量窗口', width: 150, props: { clearable: false }, options: [{ value: 10, label: '10个完整自然日' }, { value: 30, label: '30个完整自然日' }] },
|
||||
{ key: 'salesWindowEndDate', type: 'date' as const, label: '统计截止日(含)', width: 180, props: { clearable: false } }] : [{ key: 'customRange', type: 'daterange' as const, label: '原图统计区间', width: 300 }]),
|
||||
{ key: 'attention', type: 'select', label: '库存关注', width: 200, props: { 'aria-label': '库存关注' }, options: [{ value: 'ALL', label: '全部库存关注' }, { value: 'NO_OUTBOUND_IN_WINDOW', label: '所选时段无出库' }, { value: 'COVERAGE_GT_THRESHOLD', label: '可售天数超过阈值' }, { value: 'FROZEN_STOCK', label: '冻结库存' }, { value: 'DEFECTIVE_STOCK', label: '次品库存' }, { value: 'UNASSESSABLE', label: '数据不足/待观察' }] },
|
||||
{ key: 'threshold', type: 'input', label: '可售天数阈值', width: 180, placeholder: '天数阈值(天)', props: { 'aria-label': '可售天数阈值', inputmode: 'numeric' } },
|
||||
{ key: 'sortBy', type: 'select', label: '库存排序', width: 210, options: [{ value: 'AVAILABLE_DESC', label: '正品可售量从高到低' }, { value: 'COVERAGE_DAYS_DESC', label: '可售天数从高到低' }, { value: 'PHYSICAL_DESC', label: '全品质实物从高到低' }, ...(defaultInventoryWindow.value ? [{ value: 'OUTBOUND_30D_ASC', label: '近30天出库从低到高' }] : [])] },
|
||||
{ key: 'sortBy', type: 'select', label: '库存排序', width: 210, options: [{ value: 'AVAILABLE_DESC', label: '正品可售量从高到低' }, { value: 'COVERAGE_DAYS_DESC', label: '可售天数从高到低' }, { value: 'PHYSICAL_DESC', label: '全品质实物从高到低' }, { value: 'OUTBOUND_ASC', label: '区间出库从低到高' }] },
|
||||
)
|
||||
else {
|
||||
result.push({ key: 'analysis', type: 'select', label: '销量类别', width: 170, props: { clearable: false }, options: [{ value: 'summary', label: 'SKU销量' }, { value: 'hot', label: '热销Top100' }, { value: 'slow', label: '滞销Top100' }] })
|
||||
@@ -137,7 +136,11 @@ function query(): InventoryQuery {
|
||||
if (drilldown.value?.analysis === 'hot') Object.assign(result, { rangePreset: 'CUSTOM', startDate: drilldown.value.startDate, endDate: drilldown.value.endDate })
|
||||
else result.windowDays = windowDays.value
|
||||
}
|
||||
if (props.mode === 'inventory') Object.assign(result, appliedInventory.value, appliedWindow.value, drilldown.value?.inventory)
|
||||
if (props.mode === 'inventory') {
|
||||
Object.assign(result, appliedInventory.value, drilldown.value?.inventory)
|
||||
if (drilldown.value) Object.assign(result, { rangePreset: 'CUSTOM', startDate: drilldown.value.startDate, endDate: drilldown.value.endDate })
|
||||
else Object.assign(result, appliedWindow.value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
async function load(reset = false, keepContext = false) {
|
||||
@@ -214,16 +217,12 @@ function applySalesRange(anchor?: string) {
|
||||
function search() {
|
||||
if (summaryMode.value && !applySalesRange(origin.value?.context.anchorDate ?? beijingToday())) return
|
||||
let nextWindow = appliedWindow.value
|
||||
if (props.mode === 'inventory') {
|
||||
if (props.mode === 'inventory' && !drilldown.value) {
|
||||
const end = String(filters.value.salesWindowEndDate ?? '')
|
||||
const days = Number(filters.value.salesWindowDays)
|
||||
const problem = validateCustomRange(end, end, previousDate(origin.value?.context.anchorDate ?? beijingToday()))
|
||||
if (problem || ![10, 30].includes(days)) { error.value = problem || '请选择10天或30天销量窗口'; return }
|
||||
nextWindow = { salesWindowDays: days as 10 | 30, salesWindowEndDate: end }
|
||||
if (!defaultInventoryWindow.value) {
|
||||
if (attention.value === 'NO_OUTBOUND_30D') filters.value.attention = 'ALL'
|
||||
if (sortBy.value === 'OUTBOUND_30D_ASC') filters.value.sortBy = 'AVAILABLE_DESC'
|
||||
}
|
||||
}
|
||||
const rawThreshold = thresholdInput.value.trim()
|
||||
if (props.mode === 'inventory' && ((rawThreshold && (!/^\d+$/.test(rawThreshold) || !Number.isSafeInteger(Number(rawThreshold)) || Number(rawThreshold) <= 0)) || (attention.value === 'COVERAGE_GT_THRESHOLD' && !rawThreshold))) {
|
||||
@@ -303,7 +302,7 @@ onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); docu
|
||||
<p v-if="mode === 'inventory' && context" class="analytics-note" data-testid="inventory-window">销量计算区间(北京时间):{{ context.rangeStartDate ?? '—' }} 至 {{ context.rangeEndDateExclusive ? previousDate(context.rangeEndDateExclusive) : '—' }}(含截止日),{{ displayedWindowDays }} 天;销量状态:{{ label(context.salesStatus) }}。未采齐不推算日均及预计可售天数。</p>
|
||||
<div class="analytics-rules">
|
||||
|
||||
<p>{{ summaryMode ? '今日显示已采集量,完整日期显示出库量;未知数据不按零计算。' : mode === 'inventory' ? '按所选截止日及10/30个完整自然日计算;窗口未采齐时不以部分销量推算日均或可售天数。' : '使用截至昨日的完整自然日;日均和可售天数由已采集数据计算。' }}同一SKU属于不同货主时分别展示。</p>
|
||||
<p>{{ summaryMode ? '今日显示已采集量,完整日期显示出库量;未知数据不按零计算。' : mode === 'inventory' ? '按页面所示销量区间计算;窗口未采齐时不以部分销量推算日均或可售天数。' : '使用截至昨日的完整自然日;日均和可售天数由已采集数据计算。' }}同一SKU属于不同货主时分别展示。</p>
|
||||
<p v-if="mode === 'inventory'">预计可售天数=当前正品可售库存÷(完整窗口出库量÷窗口天数);窗口无出库时不推算有限天数。实物含全品质,可售仅正品;冻结、次品不重复计入实物,在途不计当前占仓。可售天数阈值只用于本次查询。</p>
|
||||
<p v-if="context">查询时间:{{ beijingTime(context.queryAsOf) }};有效至 {{ beijingTime(context.queryExpiresAt) }}。销量覆盖截至 {{ context.salesCoveredUntil ? beijingTime(context.salesCoveredUntil) : '尚无可用数据' }}。{{ context.rebuildRequired ? '数据修订待重建。' : '' }}</p>
|
||||
<p v-if="context">实际计算区间:{{ context.rangeStartDate ?? '—' }} 至 {{ context.rangeEndDateExclusive ?? '—' }}(截止日不含)<template v-if="context.calculationWindowDays != null">;{{ context.calculationWindowDays }} 天</template>。</p>
|
||||
|
||||
@@ -69,6 +69,7 @@ describe('single account collection monitor', () => {
|
||||
const w = mount(CollectionAccountMonitor, { ...options, props: { overview: overview(), stale: true } })
|
||||
expect(w.text()).toContain('339'); expect(w.text()).toContain('状态更新失败')
|
||||
expect(w.text()).toContain('已暂停估算')
|
||||
expect(w.find('.account-flow li[data-state="active"]').exists()).toBe(false)
|
||||
w.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -67,7 +67,7 @@ function nodeState(phases: string[]) {
|
||||
if (!p) return 'idle'
|
||||
const evidence = [p, ...(p.stages ?? [])].find(s => phases.includes(s.phase ?? '') && s.attemptNo != null && s.attemptNo === p.attemptNo && s.scanRound === p.scanRound && s.sliceFrom === p.sliceFrom && s.sliceUntil === p.sliceUntil && s.progressScope === p.progressScope && s.stageFinishedAt)
|
||||
if (evidence) return 'done'
|
||||
return phases.includes(p.phase ?? '') && running.value.includes(current.value!) ? 'active' : 'idle'
|
||||
return !props.stale && phases.includes(p.phase ?? '') && running.value.includes(current.value!) ? 'active' : 'idle'
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
@@ -82,22 +82,23 @@ function nodeState(phases: string[]) {
|
||||
<p v-if="running.length > 1">同时运行:<span v-for="task in running.slice(1)" :key="task.taskNo">{{ task.warehouseCode || '账号' }} · {{ phaseText(task.progress?.phase) }};</span></p>
|
||||
</template>
|
||||
<div v-else class="account-empty"><div class="standby-ring"><Pause :size="38" /><span>{{ loading ? '读取中' : !overview ? '未连接' : '待机' }}</span></div><div><span class="account-eyebrow">{{ overview ? '当前没有正在运行的采集' : '连接状态' }}</span><h3>{{ idleReason }}</h3><p>有新任务时自动更新进度。</p><div class="last-result"><Check :size="18" v-if="latestPublication" /><Clock3 :size="18" v-else /><div><strong>{{ latestPublication ? '最近发布结果' : '最近发布结果尚未提供' }}</strong><span v-if="latestPublication">{{ latestPublication.warehouseCode }} · {{ latestPublication.dataFamily === 'INVENTORY' ? '产品库存' : '出库销量' }}<br />{{ beijingTime(latestPublication.lastPublishedAt) }}</span></div></div></div></div>
|
||||
</section><aside class="account-upnext"><span class="account-eyebrow">后续安排</span><h3>{{ !overview ? '暂未取得安排' : upcoming.length ? '等待处理' : '等待新任务' }}</h3><ul v-if="upcoming.length"><li v-for="group in upcoming" :key="group.name"><span>{{ group.name }}</span><b>{{ group.count }} 项</b></li></ul><p v-else>{{ overview ? '当前摘要中暂无其他待处理项' : '连接恢复后更新后续任务' }}</p><div v-if="current" class="account-next"><strong>本阶段之后</strong><span>{{ nextStep }}</span></div><div v-if="schedules.length" class="account-next account-schedules"><strong>{{ stale ? '上次自动采集计划' : '下次自动采集计划' }}</strong><span v-for="schedule in schedules" :key="schedule.family + schedule.at">{{ schedule.family }} · {{ schedule.warehouses.join(' / ') }}<br />{{ beijingTime(schedule.at) }}(北京时间)</span><small>计划时间不代表当前任务完成时间。</small></div><small>仅已知活动摘要;完整队列数量及下一任务顺序尚未提供。</small></aside></div>
|
||||
<section class="account-flow"><div class="account-section-title"><h4><Activity :size="16" />处理阶段</h4><span>只标记已确认的推进状态</span></div><ol :style="{ gridTemplateColumns: `repeat(${flow.length}, minmax(0, 1fr))` }"><li v-for="(node, index) in flow" :key="node.name" :data-state="nodeState(node.phases)"><span class="step-index"><Check v-if="nodeState(node.phases) === 'done'" :size="16" /><template v-else>{{ index + 1 }}</template></span><strong>{{ node.name }}</strong><small>{{ nodeState(node.phases) === 'done' ? '已确认完成' : nodeState(node.phases) === 'active' ? '正在处理' : current || !overview ? '等待状态确认' : '待机' }}</small></li></ol></section>
|
||||
|
||||
<section class="account-results" v-if="warehouseGroups.length"><div class="account-section-title"><h4><Database :size="16" />各仓最新数据</h4><span>采集进度与报表发布分别确认</span></div><div class="warehouse-results"><article v-for="group in warehouseGroups" :key="group.code"><h4>{{ group.code }}</h4><div v-for="row in group.rows" :key="row.dataFamily" class="warehouse-result"><span>{{ row.dataFamily === 'INVENTORY' ? '产品库存' : '出库销量' }}<small>{{ row.lastPublishedAt ? beijingTime(row.lastPublishedAt) : '暂无发布结果' }}</small></span><span :class="{ 'result-ready': Boolean(row.lastPublishedAt) }">{{ row.lastPublishedAt ? '已有结果' : '尚未发布' }}<small>{{ row.lastPublishedAt ? label(row.dataFreshness) : '—' }}</small></span></div></article></div></section>
|
||||
</section><aside class="account-upnext"><span class="account-eyebrow">后续安排</span><h3>{{ !overview ? '暂未取得安排' : upcoming.length ? '等待处理' : '等待新任务' }}</h3><ul v-if="upcoming.length"><li v-for="group in upcoming" :key="group.name"><span>{{ group.name }}</span><b>{{ group.count }} 项</b></li></ul><p v-else>{{ overview ? '当前摘要中暂无其他待处理项' : '连接恢复后更新后续任务' }}</p><div v-if="current" class="account-next"><strong>本阶段之后</strong><span>{{ nextStep }}</span></div><div v-if="schedules.length" class="account-next account-schedules"><strong>{{ stale ? '上次自动采集计划' : '下次自动采集计划' }}</strong><span v-for="schedule in schedules" :key="schedule.family + schedule.at">{{ schedule.family }} · {{ schedule.warehouses.join(' / ') }}<br />{{ beijingTime(schedule.at) }}(北京时间)</span><small>计划时间不代表当前任务完成时间。</small></div><small>仅已知活动摘要;完整队列数量及下一任务顺序尚未提供。</small></aside></div>
|
||||
<footer><span>最近推进 {{ beijingTime(current?.progressUpdatedAt) }}</span><span>状态更新 {{ beijingTime(overview?.observedAt) }} · 北京时间</span></footer>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>
|
||||
.account-monitor { color: var(--el-text-color-primary); background: var(--el-bg-color); border: 1px solid var(--el-border-color-light); border-radius: var(--admin-radius-lg, 12px); overflow: hidden; }
|
||||
.account-monitor { color: var(--el-text-color-primary); background: var(--el-bg-color); border: 1px solid var(--el-border-color-light); border-radius: var(--admin-radius-lg); overflow: hidden; }
|
||||
.account-header, .account-header > div, .account-now, .account-next, .account-pending, footer { display: flex; align-items: center; gap: var(--admin-space-4); }
|
||||
.account-header { justify-content: space-between; padding: var(--admin-space-5) var(--admin-space-6); border-bottom: 1px solid var(--el-border-color-light); }
|
||||
h2, h3, p { margin: 0; }
|
||||
h2 { font-size: var(--admin-font-section); }
|
||||
h3 { font-size: 24px; margin: var(--admin-space-2) 0; }
|
||||
.account-body { padding: 24px; }
|
||||
h3 { font-size: 22px; margin: var(--admin-space-2) 0; }
|
||||
.account-body { padding: var(--admin-space-6); }
|
||||
.account-body > p[role="status"] { margin-bottom: var(--admin-space-5); color: var(--el-color-warning-dark-2); }
|
||||
.account-header span, p, footer, small { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
|
||||
.account-now { justify-content: space-between; align-items: flex-start; }
|
||||
.account-eyebrow { color: var(--el-color-primary); font-size: var(--admin-font-caption); }
|
||||
@@ -107,11 +108,11 @@ h3 { font-size: 24px; margin: var(--admin-space-2) 0; }
|
||||
.account-pending { flex-wrap: wrap; margin-top: var(--admin-space-5); }
|
||||
.account-pending small { flex-basis: 100%; }
|
||||
footer { justify-content: space-between; margin-top: var(--admin-space-5); padding-top: var(--admin-space-4); border-top: 1px solid var(--el-border-color-light); }
|
||||
.account-main { display: grid; grid-template-columns: minmax(0, 1fr) 290px; gap: 32px; }
|
||||
.account-main { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: var(--admin-space-6); align-items: start; }
|
||||
.account-primary { min-width: 0; }
|
||||
.account-brand, .account-controls, .account-live, .account-section-title, h4 { display: flex; align-items: center; gap: 10px; }
|
||||
.account-brand > svg { color: var(--el-color-primary); background: var(--el-color-primary-light-9); padding: var(--admin-space-3); box-sizing: content-box; border-radius: var(--admin-radius-lg); }
|
||||
.account-header { background: var(--el-bg-color); padding: var(--admin-space-5) var(--admin-space-6); }
|
||||
.account-header { background: var(--el-bg-color); }
|
||||
.account-header h2 { margin-bottom: 4px; }
|
||||
.account-live { color: var(--el-text-color-secondary); background: var(--el-fill-color-light); padding: var(--admin-space-2) var(--admin-space-3); border-radius: var(--admin-radius-lg); }
|
||||
[data-running="true"] .account-live { color: var(--el-color-success-dark-2); background: var(--el-color-success-light-9); }
|
||||
@@ -119,19 +120,19 @@ footer { justify-content: space-between; margin-top: var(--admin-space-5); paddi
|
||||
[data-running="true"] .account-live i { background: var(--el-color-success); animation: signal 2s ease-in-out infinite; }
|
||||
.account-controls :deep(.el-button) { gap: 8px; }
|
||||
.account-upnext { border-left: 1px solid var(--el-border-color-light); padding-left: 24px; }
|
||||
.account-upnext h3 { font-size: 20px; margin: 8px 0 18px; }
|
||||
.account-upnext h3 { font-size: 20px; margin: 8px 0 12px; }
|
||||
.account-upnext ul { padding: 0; margin: 0; list-style: none; }
|
||||
.account-upnext li { display: flex; justify-content: space-between; padding: 12px 0; gap: 8px; border-bottom: 1px solid var(--el-border-color-light); font-size: var(--admin-font-caption); }
|
||||
.account-upnext > small { display: block; margin-top: 18px; line-height: 1.7; }
|
||||
.account-next { display: grid; padding: 14px; margin-top: 20px; border-left-color: var(--el-color-primary); background: var(--el-color-primary-light-9); border-radius: var(--admin-radius); font-size: var(--admin-font-caption); line-height: 1.8; }
|
||||
.account-empty { display: grid; grid-template-columns: 180px minmax(0, 1fr); align-items: center; gap: 32px; min-height: 220px; }
|
||||
.account-next { display: grid; padding: 14px; margin-top: 12px; border-left-color: var(--el-color-primary); background: var(--el-color-primary-light-9); border-radius: var(--admin-radius); font-size: var(--admin-font-caption); line-height: 1.8; }
|
||||
.account-empty { display: grid; grid-template-columns: 180px minmax(0, 1fr); align-items: center; gap: var(--admin-space-6); min-height: 190px; }
|
||||
.standby-ring { width: 168px; height: 168px; border: 10px solid var(--el-border-color-light); border-radius: 50%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; color: var(--el-text-color-secondary); box-sizing: border-box; }
|
||||
.last-result { display: flex; gap: 12px; padding: 16px; margin-top: 24px; background: var(--el-fill-color-light); border-radius: 6px; }
|
||||
.last-result > svg { color: var(--el-color-success); }
|
||||
.last-result strong, .last-result span { display: block; font-size: var(--admin-font-caption); line-height: 1.8; }
|
||||
.last-result span { color: var(--el-text-color-secondary); }
|
||||
.account-flow, .account-results { margin-top: 26px; padding-top: 22px; border-top: 1px solid var(--el-border-color-light); }
|
||||
.account-section-title { justify-content: space-between; margin-bottom: 20px; }
|
||||
.account-flow, .account-results { margin-top: var(--admin-space-6); padding-top: var(--admin-space-5); border-top: 1px solid var(--el-border-color-light); }
|
||||
.account-section-title { justify-content: space-between; margin-bottom: var(--admin-space-5); }
|
||||
h4 { margin: 0; font-size: var(--admin-font-body); }
|
||||
.account-section-title > span { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
|
||||
.account-flow ol { display: grid; gap: 16px; list-style: none; margin: 0; padding: 0; }
|
||||
@@ -155,7 +156,8 @@ h4 { margin: 0; font-size: var(--admin-font-body); }
|
||||
.account-monitor :deep(.collection-count-grid > div) { padding-inline: var(--admin-space-5); }
|
||||
.account-monitor :deep(.collection-count-grid > div + div) { border-left: 1px solid var(--el-border-color-light); }
|
||||
.account-monitor :deep(.collection-count-grid dd) { font-size: 36px; font-weight: 600; color: var(--el-text-color-primary); overflow-wrap: anywhere; }
|
||||
.account-monitor :deep(.collection-count-grid > div:nth-child(2) dd) { color: var(--el-color-primary); }
|
||||
.account-monitor :deep(.collection-count-grid > div:nth-child(2) dd) { color: var(--el-color-success-dark-2); }
|
||||
.account-monitor :deep(.collection-count-grid > div:nth-child(3) dd) { color: var(--el-color-warning-dark-2); }
|
||||
.account-monitor :deep(.collection-count-grid dd small) { display: block; margin-top: var(--admin-space-2); font-weight: 400; }
|
||||
.account-monitor :deep(.collection-progress) { display: grid; grid-template-columns: 180px minmax(0, 1fr); gap: var(--admin-space-3) var(--admin-space-6); align-items: center; }
|
||||
.account-monitor :deep(.collection-stage-counts) { display: contents; }
|
||||
@@ -164,6 +166,8 @@ h4 { margin: 0; font-size: var(--admin-font-body); }
|
||||
.account-monitor :deep(.collection-progress > p) { grid-column: 1 / -1; }
|
||||
.account-monitor :deep(.ring-label strong) { color: var(--el-color-primary); }
|
||||
.account-monitor :deep(.collection-estimate) { grid-column: 2; margin: 0; padding: var(--admin-space-4) var(--admin-space-5); border-radius: var(--admin-radius); background: var(--el-color-primary-light-9); }
|
||||
.account-monitor :deep(.collection-estimate > strong) { width: 100%; color: var(--el-color-primary); }
|
||||
.account-monitor :deep(.collection-estimate > span) { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
|
||||
@keyframes signal { 50% { opacity: .45; } }
|
||||
@media (prefers-reduced-motion: reduce) { [data-running="true"] .account-live i { animation: none; } }
|
||||
</style>
|
||||
|
||||
@@ -4,7 +4,7 @@ import AnalyticsChart from './AnalyticsChart.vue'
|
||||
import { progressGaugeOption } from '../dashboardChartOptions'
|
||||
import type { Progress } from '../types'
|
||||
import { beijingTime, phaseText, quantityText, unitText } from '../state'
|
||||
const props = defineProps<{ progress: Partial<Progress> | null | undefined; taskNo: string; stale?: boolean; compact?: boolean }>()
|
||||
const props = defineProps<{ progress: Partial<Progress> | null | undefined; taskNo: string; stale?: boolean; compact?: boolean; running?: boolean }>()
|
||||
const identity = computed(() => JSON.stringify([props.taskNo, props.progress?.progressScope, props.progress?.countBasis, props.progress?.attemptNo, props.progress?.phase, props.progress?.scanRound, props.progress?.sliceFrom, props.progress?.sliceUntil, props.progress?.unit]))
|
||||
const changedTotal = ref(false)
|
||||
watch([identity, () => props.progress?.total], ([id, total], [oldId, oldTotal]) => { changedTotal.value = id === oldId ? changedTotal.value || (oldTotal != null && total != null && total !== oldTotal) : false })
|
||||
@@ -16,14 +16,15 @@ const counts = computed(() => {
|
||||
return { remaining: total - done, percent: Number(BigInt(done) * 1000n / BigInt(total)) / 10 }
|
||||
})
|
||||
const scope = computed(() => props.progress?.progressScope === 'ACCOUNT_SHARED' ? '账号共享' : props.progress?.progressScope === 'WAREHOUSE' ? '本仓' : '范围待确认')
|
||||
const option = computed(() => progressGaugeOption(counts.value?.percent ?? null))
|
||||
const indeterminate = computed(() => Boolean(props.running && !props.stale && !counts.value))
|
||||
const option = computed(() => progressGaugeOption(counts.value?.percent ?? null, indeterminate.value))
|
||||
const readingDirectory = computed(() => !props.progress?.totalKnown && ['DIRECTORY', 'DISCOVERY'].includes(props.progress?.phase ?? ''))
|
||||
</script>
|
||||
<template>
|
||||
<section class="collection-stage-counts" aria-label="当前阶段数量">
|
||||
<div class="collection-ring" :role="counts ? 'progressbar' : 'img'" :aria-label="`${scope} · ${phaseText(progress?.phase)} · ${counts ? '当前阶段' : '总量待确认'}`" :aria-valuenow="counts?.percent" :aria-valuemin="counts ? 0 : undefined" :aria-valuemax="counts ? 100 : undefined">
|
||||
<AnalyticsChart :option="option" class="ring-chart" aria-hidden="true" />
|
||||
<div class="ring-label"><strong>{{ counts ? `${counts.percent.toFixed(1)}%` : '—' }}</strong><span>{{ stale ? '上次阶段记录' : '当前阶段' }}</span></div>
|
||||
<AnalyticsChart :option="option" merge-updates class="ring-chart" :class="{ 'is-indeterminate': indeterminate }" aria-hidden="true" />
|
||||
<div class="ring-label"><strong>{{ counts ? `${counts.percent.toFixed(1)}%` : indeterminate ? '执行中' : '—' }}</strong><span>{{ stale ? '上次阶段记录' : '当前阶段' }}</span></div>
|
||||
</div>
|
||||
<div :class="{ 'compact-counts': compact }">
|
||||
<p v-if="!compact">{{ phaseText(progress?.phase) }} · {{ scope }}<span v-if="progress?.scanRound != null"> · 第 {{ progress.scanRound }} 轮</span></p>
|
||||
@@ -41,6 +42,9 @@ const readingDirectory = computed(() => !props.progress?.totalKnown && ['DIRECTO
|
||||
.collection-ring { width: 156px; height: 156px; position: relative; }
|
||||
.compact-counts { min-width: 0; }
|
||||
.ring-chart { width: 100%; height: 100%; }
|
||||
.ring-chart.is-indeterminate { animation: collection-spin 2.4s linear infinite; }
|
||||
@keyframes collection-spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) { .ring-chart.is-indeterminate { animation: none; } }
|
||||
.ring-label { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; }
|
||||
.ring-label strong { font-size: 28px; font-weight: 500; font-variant-numeric: tabular-nums; }
|
||||
.collection-count-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-4); margin: var(--admin-space-4) 0; }
|
||||
|
||||
@@ -11,6 +11,22 @@ const detail = { task, sharedUpstream: source, observedAt: '2026-10-13T01:02:00Z
|
||||
const wrappers: ReturnType<typeof mount>[] = []
|
||||
afterEach(() => { wrappers.splice(0).forEach(w => w.unmount()); vi.useRealTimers() })
|
||||
describe('collection task progress evidence', () => {
|
||||
it('animates unknown totals only during active visible execution and retains the chart across polls', async () => {
|
||||
const unknown = { ...source, progress: { ...progress, totalKnown: false, total: null } }
|
||||
const w = mount(CollectionTaskProgress, { props: { task: unknown, visible: true } }); wrappers.push(w)
|
||||
const chart = w.get('.ring-chart').element
|
||||
expect(w.get('.ring-chart').classes()).toContain('is-indeterminate')
|
||||
expect(w.get('.ring-label strong').text()).toBe('执行中')
|
||||
await w.setProps({ task: { ...unknown, progress: { ...unknown.progress, completed: 46 } } })
|
||||
expect(w.get('.ring-chart').element).toBe(chart)
|
||||
for (const state of [{ stale: true }, { stale: false, visible: false }, { visible: true, task: { ...unknown, taskStatus: 'RETRY_WAIT' } }]) {
|
||||
await w.setProps(state)
|
||||
expect(w.get('.ring-chart').classes()).not.toContain('is-indeterminate')
|
||||
}
|
||||
await w.setProps({ task: source, visible: true, stale: false })
|
||||
expect(w.get('.ring-label strong').text()).toBe('12.1%')
|
||||
expect(w.get('.ring-chart').classes()).not.toContain('is-indeterminate')
|
||||
})
|
||||
it('explains a confirmed normal batch continuation without hiding failures or protection blocks', async () => {
|
||||
const yielded: Task = { ...source, taskStatus: 'RETRY_WAIT', blockingReason: 'RETRY_SCHEDULED', latestAttemptNo: 2,
|
||||
latestAttempt: { attemptNo: 2, status: 'YIELDED', phase: 'DETAIL', startedAt: null, finishedAt: null, progressUpdatedAt: null, heartbeatAt: null, executionHealth: null, elapsedSeconds: 20, errorCode: null, failureSummary: null } }
|
||||
|
||||
@@ -73,7 +73,7 @@ function stageState(node: { title: string; scope: string; phases: string[] }) {
|
||||
<p v-if="confirmedSource">已确认关联来源:{{ confirmedSource.taskNo }} · 账号共享 · {{ taskStateText(confirmedSource) }};来源完成后,本仓仍需更新出库数据、计算与发布。</p>
|
||||
</template>
|
||||
<p v-if="!compact && (task.blockingReason || task.latestAttempt?.failureSummary)" role="status">{{ task.blockingReason ? blockingText : task.latestAttempt?.failureSummary }}</p>
|
||||
<CollectionProgressGauge :progress="progress" :task-no="workingTask.taskNo" :stale="stale" :compact="compact" />
|
||||
<CollectionProgressGauge :progress="progress" :task-no="workingTask.taskNo" :running="!stale && visible !== false && !progress?.stageFinishedAt && ['RUNNING', 'APPLYING'].includes(workingTask.taskStatus)" :stale="stale" :compact="compact" />
|
||||
<div class="collection-estimate"><strong>{{ compact && !stale && visible !== false && task.blockingReason ? blockingText : estimateText }}</strong><span v-if="compact && task.latestAttempt?.failureSummary" role="status">{{ task.latestAttempt.failureSummary }}</span><span v-if="estimate">最近速度 {{ quantityText(estimate.ratePerSecond) }} {{ unitText(progress?.unit) }}/秒;估算更新 {{ beijingTime(estimate.estimatedAt) }}</span><span>仅当前阶段;整个任务完成时间尚未估算。</span></div>
|
||||
<p>任务累计耗时 {{ taskElapsed == null ? '—' : `${Math.floor(taskElapsed / 60)} 分 ${taskElapsed % 60} 秒` }}<small>(含排队与重试)</small></p>
|
||||
<template v-if="!compact">
|
||||
|
||||
@@ -8,7 +8,7 @@ const explanations: Record<string, string> = {
|
||||
PHYSICAL_INVENTORY_UNKNOWN: '实物库存缺失、异常或已过期,需核对库存采集结果。',
|
||||
AVAILABLE_INVENTORY_UNKNOWN: '正品可售库存缺失、异常或已过期,暂不能计算消化天数。',
|
||||
UNIT_UNKNOWN_OR_CONFLICT: '销量或库存的数量单位未确认或不一致。',
|
||||
SALES_WINDOW_UNAVAILABLE: '近30个完整日的正式销量不可用,需核对覆盖范围、销量状态与修订核对结果。',
|
||||
SALES_WINDOW_UNAVAILABLE: '所选区间的完整销量不可用,需核对覆盖范围、销量状态与修订核对结果。',
|
||||
OBSERVATION_INSUFFICIENT: '缺少统计窗口起点及以前的本仓有效出库或库存观察依据;仅补齐窗口内销量不一定满足此条件。',
|
||||
METRIC_DEPENDENCY_UNKNOWN: '计算所需资料仍有未知项,需进入库存分析核对具体SKU。',
|
||||
}
|
||||
@@ -24,12 +24,12 @@ const causes = computed(() => Object.entries(props.data.coverageDistribution.unk
|
||||
<tr><th scope="row">榜单要求的销量区间</th><td>{{ data.longCoverageSkus.windowStartDate }} 至 {{ data.longCoverageSkus.windowEndDateExclusive }}(截止日不含)</td></tr>
|
||||
<tr><th scope="row">已发布销量覆盖</th><td>{{ beijingTime(data.context.salesCoveredFrom) }} 至 {{ beijingTime(data.context.salesCoveredUntil) }}(截止时刻不含)</td></tr>
|
||||
<tr><th scope="row">完整日覆盖</th><td v-if="data.context.completeDayFrom && data.context.completeDayUntil">{{ data.context.completeDayFrom }} 至 {{ data.context.completeDayUntil }}(截止日不含)</td><td v-else>尚无可确认的完整日覆盖范围</td></tr>
|
||||
<tr><th scope="row">销量状态</th><td>{{ label(data.context.salesStatus) }};近30日日均:{{ label(data.kpis.averageDailyOutboundQuantity30d.dataStatus) }} · {{ reasonsText(data.kpis.averageDailyOutboundQuantity30d.reasonCodes) }}</td></tr>
|
||||
<tr><th scope="row">销量状态</th><td>{{ label(data.context.salesStatus) }};所选时段日均:{{ label(data.kpis.averageDailyOutboundQuantity.dataStatus) }} · {{ reasonsText(data.kpis.averageDailyOutboundQuantity.reasonCodes) }}</td></tr>
|
||||
<tr><th scope="row">库存采集时间</th><td>{{ beijingTime(data.context.inventoryObservedFrom) }} 至 {{ beijingTime(data.context.inventoryObservedUntil) }}</td></tr>
|
||||
<tr><th scope="row">库存状态/有效期</th><td>{{ label(data.context.inventoryStatus) }};有效至 {{ beijingTime(data.context.inventoryFreshUntil) }}</td></tr>
|
||||
<tr><th scope="row">本次查询提示</th><td>{{ reasonsText(data.context.reasonCodes) }}</td></tr>
|
||||
<tr><th scope="row">预计消化时间最长</th><td>符合条件 {{ quantityText(data.longCoverageSkus.eligibleCount) }} SKU;待评估 {{ quantityText(data.longCoverageSkus.unassessableCount) }} SKU</td></tr>
|
||||
<tr><th scope="row">近30日无出库库存</th><td>符合条件 {{ quantityText(data.noOutboundSkus.eligibleCount) }} SKU;待评估 {{ quantityText(data.noOutboundSkus.unassessableCount) }} SKU</td></tr>
|
||||
<tr><th scope="row">所选时段无出库库存</th><td>符合条件 {{ quantityText(data.noOutboundSkus.eligibleCount) }} SKU;待评估 {{ quantityText(data.noOutboundSkus.unassessableCount) }} SKU</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h4>全仓库存分布的待核验原因</h4>
|
||||
|
||||
@@ -8,7 +8,7 @@ vi.mock('vue-router', () => ({ useRoute: () => mocks.route, useRouter: () => ({
|
||||
vi.mock('../api/analytics', () => ({ analyticsApi: mocks }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => mocks.allowed }) }))
|
||||
const toolbar = defineComponent({ name: 'QueryToolbar', props: ['modelValue', 'fields'], emits: ['update:modelValue', 'search', 'reset'], setup: () => () => h('div') })
|
||||
const table = defineComponent({ name: 'DataTable', props: ['data', 'columns', 'total', 'page'], emits: ['update:page', 'update:pageSize'], setup(p, { slots }) { return () => h('div', [slots.filters?.(), slots['toolbar-start']?.(), slots['toolbar-actions']?.(), slots['before-results']?.(), ...(p.data ?? []).flatMap((row: TurnoverRow) => (p.columns ?? []).map((column: { slotName?: string; prop: keyof TurnoverRow }) => column.slotName && slots[column.slotName] ? slots[column.slotName]?.({ row }) : String(row[column.prop] ?? '')))]) } })
|
||||
const table = defineComponent({ name: 'DataTable', props: ['data', 'columns', 'total', 'page'], emits: ['update:page', 'update:pageSize'], setup(p, { slots }) { return () => h('div', [slots.filters?.(), slots['toolbar-start']?.(), slots['toolbar-actions']?.(), slots['before-results']?.(), ...(p.columns ?? []).map((column: { headerSlotName?: string; prop: string; label: string }) => column.headerSlotName ? slots[column.headerSlotName]?.({ column: { property: column.prop, label: column.label } }) : null), ...(p.data ?? []).flatMap((row: TurnoverRow) => (p.columns ?? []).map((column: { slotName?: string; prop: keyof TurnoverRow }) => column.slotName && slots[column.slotName] ? slots[column.slotName]?.({ row }) : String(row[column.prop] ?? '')))]) } })
|
||||
const button = defineComponent({ emits: ['click'], setup(_, { slots, emit }) { return () => h('button', { onClick: () => emit('click') }, slots.default?.()) } })
|
||||
const shell = defineComponent({ setup(_, { slots }) { return () => h('div', slots.default?.()) } })
|
||||
const total = { value: 100, knownSubtotal: 100, unknownSkuCount: 0, reasonCodes: [] }
|
||||
@@ -26,6 +26,94 @@ async function change(w: ReturnType<typeof mount>, patch: object, search = false
|
||||
beforeEach(() => { vi.clearAllMocks(); mocks.allowed = true; mocks.route = reactive({ query: { endDate: '2026-10-12' } }); mocks.overview.mockResolvedValue({ warehouses: [{ warehouseCode: 'A' }, { warehouseCode: 'B' }] }); mocks.turnover.mockImplementation(async (q: TurnoverQuery) => response(q)); sessionStorage.clear() })
|
||||
afterEach(() => wrappers.splice(0).forEach(w => w.unmount()))
|
||||
describe('inventory turnover page fixed snapshot behavior', () => {
|
||||
it('sorts every numeric header on the server in both directions with the same snapshot', async () => {
|
||||
const w = page(); await flushPromises()
|
||||
await w.findAll('button').find(b => b.text() === '扩展列')!.trigger('click')
|
||||
const metrics = { physicalQuantity: 'PHYSICAL', availableQuantity: 'AVAILABLE', outbound7: 'OUTBOUND_7', outbound15: 'OUTBOUND_15', outbound30: 'OUTBOUND_30', averageDailyOutboundQuantity: 'AVERAGE_DAILY', inventoryCoverageDays: 'COVERAGE', frozenQuantity: 'FROZEN', defectiveQuantity: 'DEFECTIVE', averagePhysicalQuantity: 'AVERAGE_PHYSICAL', quantityTurnoverCount: 'TURNOVER' }
|
||||
for (const [prop, metric] of Object.entries(metrics)) {
|
||||
// Start on a later page and with another sort to prove that a header resets pagination.
|
||||
await change(w, { sortBy: metric === 'PHYSICAL' ? 'AVAILABLE_DESC' : 'PHYSICAL_DESC' }, true)
|
||||
w.findComponent(table).vm.$emit('update:page', 2); await flushPromises()
|
||||
const header = w.get(`button[data-column="${prop}"]`)
|
||||
for (const direction of ['DESC', 'ASC']) {
|
||||
await header.trigger('click'); await flushPromises()
|
||||
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ sortBy: `${metric}_${direction}`, current: 1, snapshotContext: 'token-A', includeOverview: false })
|
||||
expect(w.findComponent(toolbar).props('modelValue').sortBy).toBe(`${metric}_${direction}`)
|
||||
expect(header.attributes('aria-label')).toContain(direction === 'ASC' ? '当前升序' : '当前降序')
|
||||
}
|
||||
}
|
||||
await change(w, { baseWindowDays: 7 })
|
||||
expect(w.get('button[data-column="averageDailyOutboundQuantity"]').text()).toContain('7日日均')
|
||||
await w.get('button[data-column="averageDailyOutboundQuantity"]').trigger('click'); await flushPromises()
|
||||
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ baseWindowDays: 7, sortBy: 'AVERAGE_DAILY_DESC' })
|
||||
})
|
||||
it('keeps the successful sort marker and page when a header query fails', async () => {
|
||||
const w = page(); await flushPromises()
|
||||
w.findComponent(table).vm.$emit('update:page', 2); await flushPromises()
|
||||
mocks.turnover.mockRejectedValueOnce(new Error('offline'))
|
||||
await w.get('button[data-column="outbound15"]').trigger('click'); await flushPromises()
|
||||
expect(w.findComponent(table).props('page')).toBe(2)
|
||||
expect(w.get('button[data-column="physicalQuantity"]').attributes('aria-label')).toContain('当前降序')
|
||||
expect(w.get('button[data-column="outbound15"]').attributes('aria-label')).not.toContain('当前')
|
||||
expect(w.findComponent(table).props('data')).toHaveLength(1)
|
||||
})
|
||||
it('supports owner numeric sorting and restores the default header on reset', async () => {
|
||||
const w = page(); await flushPromises()
|
||||
await change(w, { groupBy: 'OWNER' })
|
||||
expect(w.find('button[data-column="inventoryCoverageDays"]').exists()).toBe(false)
|
||||
await w.get('button[data-column="averageDailyOutboundQuantity"]').trigger('click'); await flushPromises()
|
||||
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ groupBy: 'OWNER', sortBy: 'AVERAGE_DAILY_DESC' })
|
||||
w.findComponent(toolbar).vm.$emit('reset'); await flushPromises()
|
||||
expect(w.get('button[data-column="physicalQuantity"]').attributes('aria-label')).toContain('当前降序')
|
||||
})
|
||||
it('keeps published historical averages visible while explaining pending rebuild separately', async () => {
|
||||
mocks.turnover.mockImplementation(async (q: TurnoverQuery) => {
|
||||
const result = response(q)
|
||||
result.context.analyticsContext.rebuildRequired = true
|
||||
result.context.analyticsContext.salesPublishedAt = '2026-10-14T01:40:24Z'
|
||||
result.charts!.averageDaily = row.windows.map(window => ({ ...window,
|
||||
averageDailyOutboundQuantity: window.days === 7 ? 1234.567143 : window.days === 15 ? 2100 : null,
|
||||
outboundQuantity: window.days === 30 ? null : window.outboundQuantity,
|
||||
completeDayCount: Math.min(window.days, 18), unknownDayCount: window.days === 30 ? 12 : 0,
|
||||
dataStatus: window.days === 30 ? 'PARTIAL' : 'COMPLETE',
|
||||
}))
|
||||
return result
|
||||
})
|
||||
const w = page(); await flushPromises()
|
||||
expect(w.text()).toContain('1,234.57 件/日')
|
||||
expect(w.text()).toContain('2,100 件/日')
|
||||
expect(w.text()).toContain('完整 18 / 30 日')
|
||||
expect(w.text()).toContain('已发布销量版本计算')
|
||||
expect(w.text()).toContain('新采集数据待重算')
|
||||
expect(w.findAllComponents({ name: 'AnalyticsChart' }).length).toBeGreaterThan(0)
|
||||
mocks.turnover.mockImplementation(async (q: TurnoverQuery) => response(q))
|
||||
await w.findAll('button').find(b => b.text() === '整体刷新')!.trigger('click'); await flushPromises()
|
||||
expect(w.text()).not.toContain('新采集数据待重算')
|
||||
})
|
||||
it('queries fast and slow tabs with a visible default threshold, preserving custom values', async () => {
|
||||
const w = page(); await flushPromises()
|
||||
const select = async (label: string) => { await w.findAll('.turnover-focus button').find(b => b.text() === label)!.trigger('click'); await flushPromises() }
|
||||
await select('消化较慢')
|
||||
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ focus: 'COVERAGE_GT', coverageDaysThreshold: 30, sortBy: 'COVERAGE_DESC', current: 1 })
|
||||
expect(w.find('.turnover-focus button[aria-pressed="true"]').text()).toBe('消化较慢')
|
||||
await w.get('input[aria-label="快慢阈值天数"]').setValue('60')
|
||||
await select('消化较快')
|
||||
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ focus: 'COVERAGE_LE', coverageDaysThreshold: 60, sortBy: 'COVERAGE_ASC' })
|
||||
const calls = mocks.turnover.mock.calls.length
|
||||
await w.get('input[aria-label="快慢阈值天数"]').setValue('0')
|
||||
await select('消化较慢')
|
||||
expect(mocks.turnover).toHaveBeenCalledTimes(calls)
|
||||
expect(w.get('.turnover-filter-feedback').text()).toContain('正整数')
|
||||
expect(w.find('.turnover-focus button[aria-pressed="true"]').text()).toBe('消化较快')
|
||||
})
|
||||
it('queries the remaining focus tabs and uses owner-compatible sorting', async () => {
|
||||
const w = page(); await flushPromises()
|
||||
await change(w, { groupBy: 'OWNER' })
|
||||
for (const [label, focus, sortBy] of [['近30日无出库', 'NO_OUTBOUND_30D', 'NO_OUTBOUND_PHYSICAL_DESC'], ['待评估', 'UNASSESSABLE', 'PHYSICAL_DESC'], ['消化较慢', 'COVERAGE_GT', 'PHYSICAL_DESC'], ['消化较快', 'COVERAGE_LE', 'PHYSICAL_DESC'], ['全部', 'ALL', 'PHYSICAL_DESC']]) {
|
||||
await w.findAll('.turnover-focus button').find(b => b.text() === label)!.trigger('click'); await flushPromises()
|
||||
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ groupBy: 'OWNER', focus, sortBy, current: 1 })
|
||||
}
|
||||
})
|
||||
it('shows stale inventory and failed collection without presenting unknown totals as zero', async () => {
|
||||
mocks.overview.mockResolvedValue({ warehouses: [{ warehouseCode: 'A', dataFamily: 'INVENTORY', observedAt: '2026-10-14T00:48:00Z', currentTask: { taskNo: 'LXCOL-TEST-STOCK', taskStatus: 'DEAD', latestAttempt: { status: 'FAILED', errorCode: 'SELFOP_STOCK_DUPLICATE' } } }] })
|
||||
mocks.turnover.mockImplementation(async (q: TurnoverQuery) => {
|
||||
|
||||
@@ -15,7 +15,8 @@ import { analyticsApi } from '../api/analytics'
|
||||
import { RequestFence, beijingTime, contextParams } from '../state'
|
||||
import type { DailyResponse, Overview } from '../types'
|
||||
import type { TurnoverQuery, TurnoverResponse, TurnoverRow, TurnoverSort, TurnoverWindow } from '../turnoverTypes'
|
||||
import { turnoverRoute, validateTurnover, yesterdayBeijing, turnoverSorts } from '../turnoverNavigation'
|
||||
import { turnoverRoute, validateTurnover, yesterdayBeijing } from '../turnoverNavigation'
|
||||
import { turnoverSortColumns, turnoverSortOptions } from '../turnoverSorting'
|
||||
import { totalText, turnoverTotalNote, inventoryCollectionNotice, turnoverChart, turnoverNumber, windowText, turnoverReasonsText as reasonsText } from '../turnoverPresentation'
|
||||
import AnalyticsChart from './AnalyticsChart.vue'
|
||||
import ChartDetailsDialog from './ChartDetailsDialog.vue'
|
||||
@@ -37,7 +38,6 @@ let linkedSnapshot: string | undefined
|
||||
let ownerReturn: { query: TurnoverQuery; result: TurnoverResponse } | null = null
|
||||
let initialized = false, active = true
|
||||
const warehouses = computed(() => [...new Map((runtime.value?.warehouses ?? []).map(w => [w.warehouseCode, { value: w.warehouseCode, label: w.warehouseName || w.warehouseCode }])).values()])
|
||||
const sortLabels: Record<TurnoverSort, string> = { PHYSICAL_DESC: '实物件数最多', AVAILABLE_DESC: '可售件数最多', OUTBOUND_7_DESC: '完整7日出库最多', OUTBOUND_15_DESC: '完整15日出库最多', OUTBOUND_30_DESC: '完整30日出库最多', COVERAGE_ASC: '可售天数从少到多', COVERAGE_DESC: '可售天数从多到少', TURNOVER_DESC: '期间件数周转次数最多', NO_OUTBOUND_PHYSICAL_DESC: '已确认无出库实物最多' }
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'warehouseCode', label: '仓库', type: 'select', width: 170, options: warehouses.value, props: { clearable: false } },
|
||||
{ key: 'endDate', label: '截止日', type: 'date', width: 150, props: { valueFormat: 'YYYY-MM-DD', clearable: false, disabledDate: (date: Date) => date.toLocaleDateString('en-CA') > yesterdayBeijing() } },
|
||||
@@ -47,7 +47,7 @@ const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'focus', label: '关注条件', type: 'select', width: 165, props: { clearable: false }, options: [{ value: 'ALL', label: '全部' }, { value: 'NO_OUTBOUND_30D', label: '近30日无出库' }, { value: 'COVERAGE_GT', label: '消化较慢' }, { value: 'COVERAGE_LE', label: '消化较快' }, { value: 'FROZEN', label: '有冻结库存' }, { value: 'DEFECTIVE', label: '有次品库存' }, { value: 'UNASSESSABLE', label: '待评估' }] },
|
||||
{ key: 'coverageDaysThreshold', label: '快慢阈值(天)', type: 'input', width: 145, placeholder: '正整数,可选' },
|
||||
{ key: 'turnoverStatus', label: '周转样本', type: 'select', width: 160, options: [{ value: '', label: '全部样本状态' }, { value: 'INSUFFICIENT', label: '周转样本不足' }] },
|
||||
{ key: 'sortBy', label: '排序', type: 'select', width: 190, props: { clearable: false }, options: turnoverSorts.filter(s => filters.value.groupBy === 'OWNER' ? !['COVERAGE_ASC', 'COVERAGE_DESC'].includes(s) : s !== 'NO_OUTBOUND_PHYSICAL_DESC').map(value => ({ value, label: sortLabels[value] })) },
|
||||
{ key: 'sortBy', label: '排序', type: 'select', width: 190, props: { clearable: false }, options: turnoverSortOptions.filter(({ value }) => filters.value.groupBy === 'OWNER' ? !value.startsWith('COVERAGE_') : value !== 'NO_OUTBOUND_PHYSICAL_DESC') },
|
||||
])
|
||||
const ownerMode = computed(() => applied.value?.groupBy === 'OWNER')
|
||||
const columns = computed<DataTableColumn[]>(() => [
|
||||
@@ -60,7 +60,19 @@ const columns = computed<DataTableColumn[]>(() => [
|
||||
...(ownerMode.value ? [{ prop: 'noOutbound30d', label: '无出库SKU / 实物', slotName: 'noOutbound', minWidth: 190 }, { prop: 'slowCoverage', label: '慢消化SKU / 待判断', slotName: 'slow', minWidth: 175 }] : [{ prop: 'inventoryCoverageDays', label: '预计可售天数', slotName: 'coverage', minWidth: 150 }]),
|
||||
{ prop: 'attentionReasons', label: '状态与样本', slotName: 'status', minWidth: 180 },
|
||||
...(expanded.value ? [{ prop: 'frozenQuantity', label: '冻结件数', slotName: 'frozenQuantity', minWidth: 125 }, { prop: 'defectiveQuantity', label: '次品实物', slotName: 'defectiveQuantity', minWidth: 125 }, { prop: 'averagePhysicalQuantity', label: '期间平均实物', slotName: 'averageStock', minWidth: 145 }, { prop: 'quantityTurnoverCount', label: '期间件数周转', slotName: 'turnover', minWidth: 145 }, { prop: 'lastObservedOutboundDate', label: '最近已观测出库日', slotName: 'lastDate', minWidth: 180 }] : []),
|
||||
])
|
||||
].map(column => ({ ...column, headerSlotName: turnoverSortColumns[column.prop] ? 'sortHeader' : undefined })))
|
||||
function columnSortDirection(prop: string) {
|
||||
const metric = turnoverSortColumns[prop]
|
||||
if (!metric) return undefined
|
||||
return applied.value?.sortBy === `${metric}_ASC` ? 'ASC' : applied.value?.sortBy === `${metric}_DESC` ? 'DESC' : undefined
|
||||
}
|
||||
async function sortColumn(prop: string) {
|
||||
const metric = turnoverSortColumns[prop]
|
||||
if (!metric || loading.value) return
|
||||
const sortBy: TurnoverSort = `${metric}_${columnSortDirection(prop) === 'DESC' ? 'ASC' : 'DESC'}`
|
||||
filters.value = { ...filters.value, sortBy }
|
||||
await search()
|
||||
}
|
||||
const cards = computed(() => data.value?.summary ? [
|
||||
{ name: '实物件数', value: data.value.summary.physicalQuantity }, { name: '正品可售件数', value: data.value.summary.availableQuantity },
|
||||
{ name: '近30日无出库SKU', value: data.value.summary.noOutboundSkuCount }, { name: '待评估SKU', value: data.value.summary.unassessableSkuCount },
|
||||
@@ -84,7 +96,9 @@ function restoreThreshold(warehouse: string) {
|
||||
return ''
|
||||
}
|
||||
function quickFocus(focus: TurnoverQuery['focus'], sortBy: TurnoverSort) {
|
||||
filters.value = { ...filters.value, focus, sortBy }; void search()
|
||||
const needsThreshold = ['COVERAGE_GT', 'COVERAGE_LE'].includes(focus)
|
||||
const threshold = filters.value.coverageDaysThreshold
|
||||
filters.value = { ...filters.value, focus, sortBy, coverageDaysThreshold: needsThreshold && (threshold == null || String(threshold).trim() === '') ? 30 : threshold }; void search()
|
||||
}
|
||||
function query(): TurnoverQuery {
|
||||
return { sourceAccount: 'lingxing-primary', warehouseCode: String(filters.value.warehouseCode || ''), endDate: String(filters.value.endDate || ''), baseWindowDays: Number(filters.value.baseWindowDays) as TurnoverQuery['baseWindowDays'], groupBy: filters.value.groupBy as TurnoverQuery['groupBy'], customerCode: String(filters.value.customerCode || '') || undefined, sku: String(filters.value.sku || '') || undefined, focus: filters.value.focus as TurnoverQuery['focus'], coverageDaysThreshold: filters.value.coverageDaysThreshold === '' || filters.value.coverageDaysThreshold == null ? undefined : Number(filters.value.coverageDaysThreshold), turnoverStatus: (filters.value.turnoverStatus || undefined) as TurnoverQuery['turnoverStatus'], sortBy: filters.value.sortBy as TurnoverSort, current: page.value, size: size.value, includeOverview: true }
|
||||
@@ -198,6 +212,12 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
|
||||
<LoadErrorState v-if="error" title="库存周转读取失败" :message="error.message" compact @retry="runtime ? load() : initialize()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="inputError" role="alert">{{ inputError }}</p><p v-if="notice" role="status">{{ notice }}</p>
|
||||
<DataTable :data="data?.rows ?? []" :columns="columns" :loading="loading" :total="data?.total ?? 0" :page="page" :page-size="size" appearance="integrated" :empty-text="!warehouses.length ? '暂无可用仓库' : error ? '读取失败,请重试' : '暂无符合条件的数据'" @update:page="page = $event; load()" @update:page-size="size = $event; search()">
|
||||
<template #sortHeader="{ column }">
|
||||
<button type="button" class="turnover-sort" :data-column="column.property" :disabled="loading" :aria-label="`${column.label}:${columnSortDirection(column.property) === 'ASC' ? '当前升序,' : columnSortDirection(column.property) === 'DESC' ? '当前降序,' : ''}点击${columnSortDirection(column.property) === 'DESC' ? '升序' : '降序'}排列`" @click.stop="sortColumn(column.property)">
|
||||
{{ column.label }}
|
||||
<span class="turnover-sort-arrows" aria-hidden="true"><span :class="{ active: columnSortDirection(column.property) === 'ASC' }">▲</span><span :class="{ active: columnSortDirection(column.property) === 'DESC' }">▼</span></span>
|
||||
</button>
|
||||
</template>
|
||||
<template #toolbar-start><slot name="view-tabs"><h2 class="turnover-title">库存周转<span>销量 · 库存 · 周转效率</span></h2></slot></template>
|
||||
<template #toolbar-actions><el-button @click="router.push('/self-operated/collection')">数据采集</el-button><el-button @click="page = 1; load(true)">整体刷新</el-button><el-button @click="expanded = !expanded">{{ expanded ? '收起扩展列' : '扩展列' }}</el-button><el-popover title="统计说明" trigger="click" placement="bottom-end" :width="460" teleported><template #reference><el-button aria-label="库存周转统计说明" title="统计说明"><Info :size="16" /></el-button></template><div class="turnover-rules"><p>7/15/30天均为截至所选日期的完整滚动窗口。日均仅使用完整窗口;预计可售天数使用查询时固定的当前正品可售库存,不是库龄。</p><p>期间件数周转次数=同期完整出库÷每日首次合格计划库存的实物均值。缺日、缺商品或单位异常不补零;当前库存不代替历史平均库存。件数不是占用空间,快慢标签仅供人工复核。</p><p>货主视图按“含此类SKU的货主”筛选;未知数量保留已知小计,不平均SKU可售天数。库存过期不抹去有完整证据的历史销量与周转。</p></div></el-popover></template>
|
||||
<template #filters>
|
||||
@@ -216,12 +236,26 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
|
||||
<template v-if="data?.charts">
|
||||
<div class="turnover-context"><el-button link @click="chartsOpen = !chartsOpen">{{ chartsOpen ? '收起图表' : '展开图表' }}</el-button><span>图表与指标为{{ applied?.customerCode ? '本货主' : '本仓' }}整体,不随列表SKU或关注筛选改变</span></div>
|
||||
<div v-if="chartsOpen" class="turnover-charts">
|
||||
<section><h3>货主实物件数 Top10</h3><AnalyticsChart v-if="data.charts.ownerPhysicalTop.length" class="turnover-chart" :option="turnoverChart(data.charts, 'owners')" @select="event => { const owner = data?.charts?.ownerPhysicalTop[event.dataIndex]; if (owner) selectOwner(owner.customerCode) }" /><div v-else class="turnover-chart-empty"><strong>暂无可排名的库存数据</strong><span>库存数量尚未完整确认,确认后展示货主排名</span></div><p class="turnover-chart-note">{{ data.charts.unknownOwnerCount }} 个货主未能完整计算</p><ChartDetailsDialog title="货主实物件数明细" :reset-on="data.context.snapshotContext"><table><thead><tr><th>货主</th><th>实物件数</th></tr></thead><tbody><tr v-for="owner in data.charts.ownerPhysicalTop" :key="owner.customerCode"><td><el-button link @click="selectOwner(owner.customerCode)">{{ owner.customerName || owner.customerCode }}</el-button></td><td>{{ turnoverNumber(owner.physicalQuantity) }}</td></tr></tbody></table></ChartDetailsDialog></section>
|
||||
<section><h3>7/15/30 日日均出库</h3><AnalyticsChart class="turnover-chart" :option="turnoverChart(data.charts, 'daily')" /><div class="turnover-window-summary"><div v-for="window in data.charts.averageDaily" :key="window.days"><span>{{ window.days }}日日均</span><strong>{{ window.dataStatus === 'COMPLETE' ? `${turnoverNumber(window.averageDailyOutboundQuantity)} 件/日` : '覆盖不足' }}</strong><small v-if="window.dataStatus !== 'COMPLETE'">完整 {{ window.completeDayCount }} / {{ window.days }} 日</small></div></div><ChartDetailsDialog title="日均出库与覆盖明细" :reset-on="data.context.snapshotContext"><p v-for="window in data.charts.averageDaily" :key="window.days">{{ window.startDate }} 至 {{ window.endDateExclusive }}(不含):{{ windowText(window) }};{{ reasonsText(window.reasonCodes) }}</p></ChartDetailsDialog></section>
|
||||
<section><header class="turnover-chart-header"><h3>货主实物件数 Top10</h3><ChartDetailsDialog inline button-label="查看明细" title="货主实物件数明细" :reset-on="data.context.snapshotContext"><table><thead><tr><th>货主</th><th>实物件数</th></tr></thead><tbody><tr v-for="owner in data.charts.ownerPhysicalTop" :key="owner.customerCode"><td><el-button link @click="selectOwner(owner.customerCode)">{{ owner.customerName || owner.customerCode }}</el-button></td><td>{{ turnoverNumber(owner.physicalQuantity) }}</td></tr></tbody></table></ChartDetailsDialog></header><AnalyticsChart v-if="data.charts.ownerPhysicalTop.length" class="turnover-chart" :option="turnoverChart(data.charts, 'owners')" @select="event => { const owner = data?.charts?.ownerPhysicalTop[event.dataIndex]; if (owner) selectOwner(owner.customerCode) }" /><div v-else class="turnover-chart-empty"><strong>暂无可排名的库存数据</strong><span>库存数量尚未完整确认,确认后展示货主排名</span></div><p v-if="data.charts.unknownOwnerCount" class="turnover-chart-note">{{ data.charts.unknownOwnerCount }} 个货主未能完整计算</p></section>
|
||||
<section><header class="turnover-chart-header"><h3>7/15/30 日日均出库</h3><ChartDetailsDialog inline button-label="查看明细" title="日均出库与覆盖明细" :reset-on="data.context.snapshotContext"><p v-for="window in data.charts.averageDaily" :key="window.days">{{ window.startDate }} 至 {{ window.endDateExclusive }}(不含):{{ windowText(window) }};{{ reasonsText(window.reasonCodes) }}</p></ChartDetailsDialog></header><p v-if="data.context.analyticsContext.rebuildRequired" class="turnover-chart-note" role="status">日均出库按 {{ beijingTime(data.context.analyticsContext.salesPublishedAt) }} 已发布销量版本计算;新采集数据待重算,发布后请整体刷新。SKU库存评估仍等待重算完成。</p><AnalyticsChart v-if="data.charts.averageDaily.some(w => w.dataStatus === 'COMPLETE' && w.averageDailyOutboundQuantity != null)" class="turnover-chart" :option="turnoverChart(data.charts, 'daily')" /><p v-else class="turnover-chart-empty">暂无完整窗口的日均出库数据,覆盖情况如下。</p><div class="turnover-window-summary"><div v-for="window in data.charts.averageDaily" :key="window.days"><span>{{ window.days }}日日均</span><strong>{{ window.dataStatus === 'COMPLETE' ? `${turnoverNumber(window.averageDailyOutboundQuantity)} 件/日` : '覆盖不足' }}</strong><small v-if="window.dataStatus !== 'COMPLETE'">完整 {{ window.completeDayCount }} / {{ window.days }} 日</small></div></div></section>
|
||||
</div>
|
||||
</template>
|
||||
<nav class="turnover-context turnover-focus" aria-label="库存关注快捷筛选"><el-button :type="applied?.focus === 'ALL' ? 'primary' : 'default'" :plain="applied?.focus !== 'ALL'" @click="quickFocus('ALL', 'PHYSICAL_DESC')">全部/实物件数最多</el-button><el-button :type="applied?.focus === 'NO_OUTBOUND_30D' ? 'primary' : 'default'" :plain="applied?.focus !== 'NO_OUTBOUND_30D'" @click="quickFocus('NO_OUTBOUND_30D', ownerMode ? 'NO_OUTBOUND_PHYSICAL_DESC' : 'PHYSICAL_DESC')">近30日无出库</el-button><el-button :type="applied?.focus === 'COVERAGE_GT' ? 'primary' : 'default'" :plain="applied?.focus !== 'COVERAGE_GT'" @click="quickFocus('COVERAGE_GT', ownerMode ? 'PHYSICAL_DESC' : 'COVERAGE_DESC')">消化较慢</el-button><el-button :type="applied?.focus === 'COVERAGE_LE' ? 'primary' : 'default'" :plain="applied?.focus !== 'COVERAGE_LE'" @click="quickFocus('COVERAGE_LE', ownerMode ? 'PHYSICAL_DESC' : 'COVERAGE_ASC')">消化较快</el-button><el-button :type="applied?.focus === 'UNASSESSABLE' ? 'primary' : 'default'" :plain="applied?.focus !== 'UNASSESSABLE'" @click="quickFocus('UNASSESSABLE', 'PHYSICAL_DESC')">待评估</el-button></nav>
|
||||
<p class="turnover-context">筛选结果:{{ data?.total ?? 0 }} {{ ownerMode ? '个货主' : '条SKU' }}</p>
|
||||
<nav class="turnover-focus" aria-label="库存关注快捷筛选">
|
||||
<button type="button" :aria-pressed="applied?.focus === 'ALL'" @click="quickFocus('ALL', 'PHYSICAL_DESC')">全部</button>
|
||||
<button type="button" :aria-pressed="applied?.focus === 'NO_OUTBOUND_30D'" @click="quickFocus('NO_OUTBOUND_30D', ownerMode ? 'NO_OUTBOUND_PHYSICAL_DESC' : 'PHYSICAL_DESC')">近30日无出库</button>
|
||||
<button type="button" :aria-pressed="applied?.focus === 'COVERAGE_GT'" @click="quickFocus('COVERAGE_GT', ownerMode ? 'PHYSICAL_DESC' : 'COVERAGE_DESC')">消化较慢</button>
|
||||
<button type="button" :aria-pressed="applied?.focus === 'COVERAGE_LE'" @click="quickFocus('COVERAGE_LE', ownerMode ? 'PHYSICAL_DESC' : 'COVERAGE_ASC')">消化较快</button>
|
||||
<button type="button" :aria-pressed="applied?.focus === 'UNASSESSABLE'" @click="quickFocus('UNASSESSABLE', 'PHYSICAL_DESC')">待评估</button>
|
||||
</nav>
|
||||
<div v-if="['COVERAGE_GT', 'COVERAGE_LE'].includes(String(filters.focus))" class="turnover-threshold">
|
||||
<label>快慢阈值 <input v-model="filters.coverageDaysThreshold" type="number" min="1" step="1" aria-label="快慢阈值天数" @keydown.enter.prevent="search()" /> 天</label>
|
||||
<el-button @click="search()">应用</el-button>
|
||||
<span>预计可售天数大于阈值为较慢,不超过阈值为较快;首次使用默认30天,可修改。数据不足的商品请查看“待评估”。</span>
|
||||
</div>
|
||||
<p v-if="inputError" class="turnover-filter-feedback" role="alert">{{ inputError }}</p>
|
||||
<p v-else-if="loading" class="turnover-filter-feedback" role="status">正在更新筛选结果…</p>
|
||||
<p v-else-if="error" class="turnover-filter-feedback" role="alert">筛选未更新,当前保留上次结果。{{ error.message }}</p>
|
||||
|
||||
</template>
|
||||
<template #sku="{ row }"><el-button link type="primary" @click="showTrend(castRow(row))">{{ castRow(row).sku }}</el-button></template>
|
||||
<template #owner="{ row }"><el-button v-if="ownerMode" link type="primary" @click="selectOwner(castRow(row).customerCode)">{{ castRow(row).customerName || castRow(row).customerCode }}</el-button><span v-else>{{ castRow(row).customerName || castRow(row).customerCode }}</span></template>
|
||||
@@ -237,15 +271,23 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
|
||||
<template #lastDate="{ row }">{{ castRow(row).lastObservedOutboundDate || '已采集历史内未见出库' }}</template>
|
||||
</DataTable>
|
||||
<AppDialog v-model="trendOpen" class="admin-density-dialog" title="SKU每日出库趋势" width="1120px" :show-confirm="false" :loading="trendLoading" cancel-text="关闭" append-to-body fullscreenable><LoadErrorState v-if="trendError" title="趋势读取失败" :message="trendError.message" compact><template #details><ApiErrorMeta :details="trendError" /></template></LoadErrorState><SkuTrend v-if="daily" :data="daily" /></AppDialog>
|
||||
<AppDialog v-model="evidenceOpen" class="admin-density-dialog" title="周转样本与覆盖证据" width="1040px" :show-confirm="false" cancel-text="关闭" append-to-body fullscreenable><template v-if="evidence && data"><p>{{ evidence.customerCode }} · {{ evidence.sku || '货主汇总' }};合格日样本 {{ evidence.qualifiedSampleDays }}/{{ evidence.requiredSampleDays }};积累起点 {{ data.context.accumulationStartedOn || '尚无日样本' }}</p><p>缺口日期:{{ evidence.missingSampleDates.join('、') || '无日期缺口' }}</p><p>{{ reasonsText(evidence.sampleReasonCodes) }}</p><p v-for="window in evidence.windows" :key="window.days">{{ window.days }}日:{{ windowText(window) }};完整{{ window.completeDayCount }}日/部分{{ window.partialDayCount }}日/未知{{ window.unknownDayCount }}日</p><p>日代表规则:每天首次合格计划采集;仅纳入 {{ beijingTime(data.context.queryAsOf) }} 以前已发布代表。</p><p>日样本是每日库存观测,不是出库历史天数;补采和验证任务不计入。今天成功的计划库存,要在截止日包含今天时才进入窗口,历史缺日不能补零。</p></template></AppDialog>
|
||||
<AppDialog v-model="evidenceOpen" class="admin-density-dialog" title="周转样本与覆盖证据" width="1040px" :show-confirm="false" cancel-text="关闭" append-to-body fullscreenable><template v-if="evidence && data"><p>{{ evidence.customerCode }} · {{ evidence.sku || '货主汇总' }};合格日样本 {{ evidence.qualifiedSampleDays }}/{{ evidence.requiredSampleDays }};积累起点 {{ data.context.accumulationStartedOn || '尚无日样本' }}</p><p>缺口日期:{{ evidence.missingSampleDates.join('、') || '无日期缺口' }}</p><p>{{ reasonsText(evidence.sampleReasonCodes) }}</p><p v-for="window in evidence.windows" :key="window.days">{{ window.days }}日:{{ windowText(window) }};完整{{ window.completeDayCount }}日/部分{{ window.partialDayCount }}日/未知{{ window.unknownDayCount }}日</p><p>日代表规则:每天首次合格计划采集;仅纳入 {{ beijingTime(data.context.queryAsOf) }} 以前已发布代表。</p><p>日样本是每日库存观测,不是出库历史天数;补采和验证任务不计入。今天采集成功的合格计划库存样本,最早次日查询时才能纳入窗口;当天样本数仍为0不代表采集未成功,历史缺日不能补零。</p></template></AppDialog>
|
||||
</template>
|
||||
</AdminPage>
|
||||
</template>
|
||||
<style scoped>
|
||||
.turnover-sort { display: inline-flex; align-items: center; gap: 6px; padding: 0; border: 0; background: transparent; color: inherit; font: inherit; cursor: pointer; }
|
||||
.turnover-sort:disabled { cursor: wait; }
|
||||
.turnover-sort:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 3px; }
|
||||
.turnover-sort-arrows { display: inline-flex; flex-direction: column; color: var(--el-text-color-placeholder); font-size: 9px; line-height: 10px; }
|
||||
.turnover-sort-arrows .active { color: var(--el-color-primary); }
|
||||
.turnover-page :deep(.data-table--integrated > .data-table__summary) { padding: 12px 20px 0; }
|
||||
.turnover-page :deep(.data-table--integrated > .el-table) { width: calc(100% - 40px); margin-inline: 20px; }
|
||||
|
||||
.turnover-title { margin: 0; font-size: var(--admin-font-section); display: flex; align-items: center; gap: 16px; }
|
||||
.turnover-title span { font-size: var(--admin-font-caption); font-weight: 400; color: var(--el-text-color-secondary); }
|
||||
.turnover-context { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; padding: 10px 16px; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
|
||||
.turnover-cards { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 12px; padding: 4px 16px 12px; }
|
||||
.turnover-context { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; padding: 10px 0; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
|
||||
.turnover-cards { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 12px; padding: 4px 0 12px; }
|
||||
.turnover-cards article { position: relative; overflow: hidden; padding: 16px 20px; border: 1px solid var(--el-color-primary-light-8); background: linear-gradient(120deg, #fff, var(--el-color-primary-light-9)); border-radius: 10px; color: var(--el-color-primary); }
|
||||
.turnover-cards article:nth-child(2) { color: #248260; border-color: #dbeee5; background: linear-gradient(120deg, #fff, #edf8f1); }
|
||||
.turnover-cards article:nth-child(3) { color: #b37a22; border-color: #f4e7d3; background: linear-gradient(120deg, #fff, #fff6e8); }
|
||||
@@ -253,19 +295,30 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
|
||||
.turnover-cards article::after { content: ''; position: absolute; width: 88px; height: 88px; border-radius: 50%; right: -22px; top: -30px; background: currentColor; opacity: .07; pointer-events: none; }
|
||||
.turnover-cards article > span { color: var(--el-text-color-regular); }
|
||||
.turnover-cards strong { display: block; font-size: 28px; margin: 8px 0 4px; font-variant-numeric: tabular-nums; }
|
||||
.turnover-charts { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; padding: 0 16px 12px; }
|
||||
.turnover-charts section { background: var(--el-bg-color); border: 1px solid var(--el-border-color-light); padding: 16px; border-radius: 10px; }
|
||||
.turnover-chart, .turnover-chart-empty { height: 220px; }
|
||||
.turnover-charts { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; padding: 0 0 12px; align-items: stretch; }
|
||||
.turnover-charts section { height: 360px; box-sizing: border-box; display: flex; flex-direction: column; min-width: 0; background: var(--el-bg-color); border: 1px solid var(--el-border-color-light); padding: 16px; border-radius: 10px; }
|
||||
.turnover-chart { flex: 1 1 0; height: 0; min-height: 120px; width: 100%; }
|
||||
.turnover-chart-empty { flex: 1 1 0; min-height: 0; padding: 16px; text-align: center; }
|
||||
.turnover-chart-header { flex-shrink: 0; display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
|
||||
.turnover-chart-header h3 { margin: 0; }
|
||||
.turnover-chart-header :deep(.chart-disclosure) { flex-shrink: 0; }
|
||||
.turnover-chart-empty { display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 12px; color: var(--el-text-color-secondary); background: var(--el-fill-color-lighter); border-radius: 8px; margin-top: 12px; }
|
||||
.turnover-chart-empty strong { color: var(--el-text-color-regular); }
|
||||
.turnover-chart-note { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
|
||||
.turnover-window-summary { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin: 8px 0 12px; }
|
||||
.turnover-chart-note { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); margin: 8px 0 0; }
|
||||
.turnover-window-summary { flex-shrink: 0; display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin: 12px 0 0; }
|
||||
.turnover-window-summary > div { background: var(--el-fill-color-lighter); padding: 8px 12px; border-radius: 6px; }
|
||||
.turnover-window-summary span, .turnover-window-summary strong { display: block; }
|
||||
.turnover-window-summary span { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); margin-bottom: 4px; }
|
||||
.turnover-window-summary strong { font-variant-numeric: tabular-nums; }
|
||||
.turnover-focus { border-top: 1px solid var(--el-border-color-lighter); padding-top: 16px; gap: 8px; }
|
||||
.turnover-focus :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.turnover-focus { display: flex; flex-wrap: wrap; gap: 24px; margin: 4px 0 0; border-bottom: 1px solid var(--el-border-color-light); }
|
||||
.turnover-focus button { border: 0; border-bottom: 2px solid transparent; background: transparent; padding: 12px 4px; font: inherit; color: var(--el-text-color-regular); cursor: pointer; }
|
||||
.turnover-focus button[aria-pressed="true"] { color: var(--el-color-primary); border-bottom-color: var(--el-color-primary); font-weight: 600; }
|
||||
.turnover-focus button:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
|
||||
.turnover-threshold { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 10px 0; font-size: var(--admin-font-caption); }
|
||||
.turnover-threshold label { display: flex; align-items: center; gap: 8px; }
|
||||
.turnover-threshold input { width: 76px; padding: 6px 8px; border: 1px solid var(--el-border-color); border-radius: var(--admin-radius); font: inherit; color: var(--el-text-color-primary); }
|
||||
.turnover-threshold > span { color: var(--el-text-color-secondary); }
|
||||
.turnover-filter-feedback { margin: 8px 0; color: var(--el-color-warning-dark-2); font-size: var(--admin-font-caption); }
|
||||
h3 { margin: 0 0 12px; font-size: var(--admin-font-body); border-left: 3px solid var(--el-color-primary); padding-left: 10px; }
|
||||
small { display: block; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
|
||||
.turnover-rules { font-size: var(--admin-font-caption); line-height: 1.7; }
|
||||
|
||||
Reference in New Issue
Block a user