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