fix(selfop): preserve warehouse navigation and refine overview presentation

This commit is contained in:
2026-10-13 11:06:20 +08:00
parent b73dc0d410
commit 58df0ed9e3
9 changed files with 296 additions and 57 deletions
@@ -3,30 +3,41 @@ import { computed, ref } from 'vue'
import type { Quantity } from '../types'
import { quantityText } from '../state'
export interface BarRow { key: string; label: string; secondary?: string; value: Quantity; detail: string }
const props = defineProps<{ rows: BarRow[]; unit: string; disabled?: boolean; title: string }>()
const props = defineProps<{ rows: BarRow[]; unit: string; disabled?: boolean; title: string; ranked?: boolean }>()
const emit = defineEmits<{ select: [key: string] }>()
const focused = ref('')
const maximum = computed(() => Math.max(1, ...props.rows.map(row => Number(row.value ?? 0))))
const palette = ['#5575ed', '#3da9ba', '#e8a54b', '#b083dc', '#df8397', '#8a9ab4', '#a2adbf']
function color(index: number) { return props.ranked ? palette[0] : palette[index % palette.length] }
</script>
<template>
<div :aria-label="title" class="dashboard-bars">
<p v-if="!rows.length">暂无符合条件的 SKU;请核对覆盖与待评估资料。</p>
<button v-for="row in rows" :key="row.key" type="button" class="bar-row" :aria-disabled="disabled || row.value == null" :aria-label="`${row.label},${row.secondary ?? ''},${quantityText(row.value)} ${unit};${row.detail}`" :title="`${row.label} ${row.secondary ?? ''};${row.detail}`" @focus="focused = row.key" @mouseenter="focused = row.key" @click="!disabled && row.value != null && emit('select', row.key)">
<span class="bar-heading"><span class="bar-label">{{ row.label }}</span><strong>{{ quantityText(row.value) }} {{ unit }}</strong></span>
<div v-if="!rows.length" class="bar-empty"><span aria-hidden="true">—</span><p>暂无符合条件的 SKU;请核对覆盖与待评估资料。</p></div>
<button v-for="(row, index) in rows" :key="row.key" type="button" class="bar-row" :style="{ '--bar-color': color(index), '--bar-delay': `${index * 45}ms` }" :aria-disabled="disabled || row.value == null" :aria-label="`${row.label},${row.secondary ?? ''},${quantityText(row.value)} ${unit};${row.detail}`" :title="`${row.label} ${row.secondary ?? ''};${row.detail}`" @focus="focused = row.key" @mouseenter="focused = row.key" @click="!disabled && row.value != null && emit('select', row.key)">
<span class="bar-heading"><span class="bar-label"><span v-if="ranked" class="bar-rank" :class="{ 'is-leading': index < 3 }">{{ String(index + 1).padStart(2, '0') }}</span>{{ row.label }}</span><strong>{{ quantityText(row.value) }} <small>{{ unit }}</small></strong></span>
<span v-if="row.secondary" class="bar-secondary">{{ row.secondary }}</span>
<svg viewBox="0 0 500 15" aria-hidden="true"><line x1="0" x2="500" y1="14" y2="14" stroke="var(--el-border-color)" /><rect v-if="row.value != null" x="0" y="1" :width="Number(row.value) / maximum * 500" height="11" rx="2" fill="var(--el-color-primary)" /></svg>
<span class="bar-track" aria-hidden="true"><span v-if="row.value != null" class="bar-fill" :style="{ width: `${Number(row.value) / maximum * 100}%` }" /></span>
</button>
<p v-if="focused" class="bar-detail" role="status">{{ rows.find(row => row.key === focused)?.detail }}</p>
</div>
</template>
<style scoped>
.dashboard-bars { display: grid; gap: var(--admin-space-2); }
.bar-row { appearance: none; border: 0; padding: var(--admin-space-2) 0; background: transparent; text-align: left; color: inherit; cursor: pointer; width: 100%; font: inherit; }
.bar-row { appearance: none; border: 0; padding: 7px 8px; border-radius: 6px; background: transparent; text-align: left; color: inherit; cursor: pointer; width: 100%; font: inherit; transition: background .2s; }
.bar-row:hover, .bar-row:focus-visible { background: #f5f7fc; }
.bar-row:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
.bar-row[aria-disabled="true"] { cursor: default; }
.bar-heading { display: flex; gap: var(--admin-space-2); justify-content: space-between; align-items: baseline; }
.bar-label { overflow-wrap: anywhere; }
.bar-rank { display: inline-block; width: 28px; color: #929db3; font-size: var(--admin-font-caption); font-weight: 700; font-variant-numeric: tabular-nums; }
.bar-rank.is-leading { color: var(--bar-color); }
.bar-heading strong { white-space: nowrap; }
.bar-heading small { font-weight: 400; color: var(--el-text-color-secondary); }
.bar-secondary, .bar-detail { display: block; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); overflow-wrap: anywhere; }
svg { display: block; width: 100%; height: 15px; }
.bar-track { display: block; height: 10px; margin-top: 7px; border-radius: 3px; overflow: hidden; background: #f0f3f9; }
.bar-fill { display: block; height: 100%; background: var(--bar-color); border-radius: 3px; transform-origin: left; animation: bar-reveal .7s cubic-bezier(.2,.7,.3,1) both; animation-delay: var(--bar-delay); transition: width .5s; }
.bar-empty { min-height: 150px; display: grid; align-content: center; justify-items: center; color: var(--el-text-color-secondary); text-align: center; }
.bar-empty > span { font-size: 38px; color: #a9b7d8; }
@keyframes bar-reveal { from { transform: scaleX(0); } to { transform: scaleX(1); } }
@media (prefers-reduced-motion: reduce) { .bar-fill { animation: none; transition: none; } .bar-row { transition: none; } }
</style>
@@ -0,0 +1,41 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import DashboardTrend from './DashboardTrend.vue'
import DashboardBars from './DashboardBars.vue'
import type { DailyPoint } from '../types'
function point(date: string, value: number | null, dataStatus = 'COMPLETE'): DailyPoint {
return { businessDate: date, outboundQuantity: dataStatus === 'COMPLETE' ? value : null, observedQuantity: value, dataStatus, reasonCodes: [], quantityUnit: 'PCS' }
}
describe('dashboard chart integrity and interaction', () => {
it('fills only contiguous known segments and keeps unknown days unavailable for drilldown', async () => {
const wrapper = mount(DashboardTrend, { props: { points: [point('2026-10-01', 2), point('2026-10-02', 4), point('2026-10-03', null, 'UNKNOWN'), point('2026-10-04', 1, 'PARTIAL')] } })
const areas = wrapper.findAll('.trend-area').filter(path => path.attributes('d'))
expect(areas).toHaveLength(1)
expect(areas[0]!.attributes('d')).not.toContain('770')
const days = wrapper.findAll('g')
await days[2]!.trigger('click')
expect(wrapper.emitted('select')).toBeUndefined()
await days[3]!.trigger('keydown', { key: 'Enter' })
expect(wrapper.emitted('select')).toEqual([['2026-10-04']])
expect(days[3]!.find('.trend-point').attributes('fill')).toBe('var(--el-bg-color)')
await wrapper.setProps({ points: [point('2026-10-05', 7)] })
expect(wrapper.find('.trend-tooltip').exists()).toBe(false)
wrapper.unmount()
})
it('preserves zero and unknown bars, exact relative widths and disabled selection', async () => {
const wrapper = mount(DashboardBars, { props: { title: '分布', unit: 'SKU', rows: [
{ key: 'a', label: 'A', value: 20, detail: '' }, { key: 'b', label: 'B', value: 5, detail: '' },
{ key: 'zero', label: '零值', value: 0, detail: '' }, { key: 'unknown', label: '未知', value: null, detail: '' },
] } })
expect(wrapper.findAll('.bar-fill').map(bar => bar.attributes('style'))).toEqual(['width: 100%;', 'width: 25%;', 'width: 0%;'])
await wrapper.findAll('button')[3]!.trigger('click')
expect(wrapper.emitted('select')).toBeUndefined()
await wrapper.findAll('button')[2]!.trigger('click')
expect(wrapper.emitted('select')).toEqual([['zero']])
await wrapper.setProps({ disabled: true })
await wrapper.findAll('button')[0]!.trigger('click')
expect(wrapper.emitted('select')).toHaveLength(1)
wrapper.unmount()
})
})
@@ -1,38 +1,66 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, ref, useId, watch } from 'vue'
import type { DailyPoint } from '../types'
import { label, quantityText, reasonsText, shownQuantity, trendSegments } from '../state'
const props = defineProps<{ points: DailyPoint[]; disabled?: boolean }>()
const emit = defineEmits<{ select: [date: string] }>()
const focused = ref<DailyPoint | null>(null)
const gradientId = `trend-area-${useId()}`
const chartKey = computed(() => props.points.map(point => `${point.businessDate}:${shownQuantity(point)}:${point.dataStatus}`).join('|'))
watch(() => props.points, () => { focused.value = null })
const segments = computed(() => trendSegments(props.points))
const maximum = computed(() => Math.max(1, ...props.points.map(point => Number(shownQuantity(point) ?? 0))))
function x(index: number) { return props.points.length === 1 ? 400 : 30 + index / (props.points.length - 1) * 740 }
function y(point: DailyPoint) { return 175 - Number(shownQuantity(point) ?? 0) / maximum.value * 145 }
function text(point: DailyPoint) { return `${point.businessDate}:${quantityText(shownQuantity(point))} 件,${label(point.dataStatus)};${reasonsText(point.reasonCodes)}` }
function select(point: DailyPoint) { focused.value = point; if (!props.disabled && point.dataStatus !== 'UNKNOWN' && shownQuantity(point) != null) emit('select', point.businessDate) }
function area(path: string) {
const coordinates = [...path.matchAll(/[ML]\s*([\d.]+),([\d.]+)/g)]
if (coordinates.length < 2) return ''
return `${path} L${coordinates.at(-1)![1]},175 L${coordinates[0]![1]},175 Z`
}
</script>
<template>
<div>
<p>缺失日期断线;空心点为未完整日;× 表示未知。今日为已采集累计。</p>
<svg viewBox="0 0 800 220" class="dashboard-trend" role="group" aria-label="全仓每日出库趋势,点击已知日点查看当日销量">
<line x1="30" y1="175" x2="770" y2="175" stroke="currentColor" /><text x="10" y="190">0</text><text x="30" y="20">{{ maximum }} 件</text>
<path v-for="(segment, index) in segments" :key="index" :d="segment.path" fill="none" stroke="var(--el-color-primary)" stroke-width="2" :stroke-dasharray="segment.partial ? '5 4' : undefined" />
<div class="trend-legend"><span><i class="legend-dot" />完整日</span><span><i class="legend-dot is-partial" />未完整日</span><span>× 未知 · 缺失日期断线</span><span class="trend-today">今日为已采集累计</span></div>
<svg :key="chartKey" viewBox="0 0 800 220" class="dashboard-trend" role="group" aria-label="全仓每日出库趋势,点击已知日点查看当日销量">
<defs><linearGradient :id="gradientId" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#5575ed" stop-opacity=".22" /><stop offset="100%" stop-color="#5575ed" stop-opacity=".01" /></linearGradient></defs>
<line v-for="level in [0, 1, 2, 3, 4]" :key="level" x1="30" :y1="175 - level * 36.25" x2="770" :y2="175 - level * 36.25" stroke="#e7edf6" :stroke-dasharray="level ? '4 5' : undefined" />
<text x="10" y="190">0</text><text x="30" y="20">{{ quantityText(maximum) }} 件</text>
<path v-for="(segment, index) in segments" :key="`area-${index}`" :d="area(segment.path)" :fill="`url(#${gradientId})`" class="trend-area" />
<path v-for="(segment, index) in segments" :key="index" :d="segment.path" fill="none" stroke="#5575ed" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" :stroke-dasharray="segment.partial ? '5 4' : undefined" :class="{ 'trend-line': !segment.partial }" :pathLength="segment.partial ? undefined : 1" />
<line v-if="focused" :x1="x(points.indexOf(focused))" :x2="x(points.indexOf(focused))" y1="30" y2="175" stroke="#b4c2e5" stroke-dasharray="3 4" />
<g v-for="(point, index) in points" :key="point.businessDate" tabindex="0" role="button" :aria-label="text(point)" :aria-disabled="disabled || point.dataStatus === 'UNKNOWN' || shownQuantity(point) == null" @focus="focused = point" @mouseenter="focused = point" @click="select(point)" @keydown.enter.prevent="select(point)" @keydown.space.prevent="select(point)">
<title>{{ text(point) }}</title>
<circle v-if="point.dataStatus !== 'UNKNOWN' && shownQuantity(point) != null" :cx="x(index)" :cy="y(point)" r="5" :fill="point.dataStatus === 'COMPLETE' ? 'var(--el-color-primary)' : 'var(--el-bg-color)'" stroke="var(--el-color-primary)" stroke-width="2" />
<circle class="trend-hit" :cx="x(index)" :cy="point.dataStatus === 'UNKNOWN' ? 175 : y(point)" r="12" fill="transparent" />
<circle v-if="point.dataStatus !== 'UNKNOWN' && shownQuantity(point) != null" class="trend-point" :cx="x(index)" :cy="y(point)" r="5" :fill="point.dataStatus === 'COMPLETE' ? '#5575ed' : 'var(--el-bg-color)'" stroke="#5575ed" stroke-width="2" />
<text v-else :x="x(index)" y="174" text-anchor="middle">×</text>
<text v-if="index % Math.max(1, Math.ceil(points.length / 8)) === 0 || index === points.length - 1" :x="x(index)" y="208" text-anchor="middle">{{ point.businessDate.slice(5) }}</text>
</g>
</svg>
<p v-if="focused" role="status">{{ text(focused) }}</p>
<p v-if="focused" class="trend-tooltip" role="status">{{ text(focused) }}</p>
<p v-if="!segments.length">所选日期暂无可靠出库资料,未知不按零显示。</p>
<details><summary>查看每日数值与覆盖原因</summary><table class="density-table"><thead><tr><th>日期</th><th>正式量</th><th>已采集量</th><th>状态</th><th>原因</th></tr></thead><tbody><tr v-for="point in points" :key="point.businessDate"><td>{{ point.businessDate }}</td><td>{{ quantityText(point.outboundQuantity) }}</td><td>{{ quantityText(point.observedQuantity) }}</td><td>{{ label(point.dataStatus) }}</td><td>{{ reasonsText(point.reasonCodes) }}</td></tr></tbody></table></details>
</div>
</template>
<style scoped>
.dashboard-trend { width: 100%; height: 300px; }
text { font-size: var(--admin-font-caption); }
.dashboard-trend { width: 100%; height: 300px; overflow: visible; }
text { font-size: var(--admin-font-caption); fill: #76849c; }
.trend-legend { display: flex; flex-wrap: wrap; gap: 16px; color: #76849c; font-size: var(--admin-font-caption); align-items: center; }
.trend-legend span { display: inline-flex; gap: 6px; align-items: center; }
.legend-dot { width: 7px; height: 7px; border-radius: 50%; background: #5575ed; border: 1px solid #5575ed; }
.legend-dot.is-partial { background: white; }
.trend-today { margin-left: auto; }
.trend-line { stroke-dasharray: 1; stroke-dashoffset: 0; animation: trend-draw 1.1s ease-out; }
.trend-area { animation: area-reveal 1.1s ease-out; }
.trend-point { animation: area-reveal .8s ease-out; }
.trend-tooltip { padding: 8px 12px; background: #f2f5ff; border-left: 3px solid #5575ed; border-radius: 4px; }
g { cursor: pointer; }
g[aria-disabled="true"] { cursor: default; }
g:focus { outline: none; }
g:focus circle { stroke: var(--el-text-color-primary); stroke-width: 3px; }
g:focus .trend-point, g:hover .trend-point { stroke: #304aa3; stroke-width: 3px; }
g:focus .trend-hit { stroke: #5575ed; stroke-width: 1px; }
@keyframes trend-draw { from { stroke-dashoffset: 1; } to { stroke-dashoffset: 0; } }
@keyframes area-reveal { from { opacity: 0; } to { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .trend-line, .trend-area, .trend-point { animation: none; } }
</style>