fix(admin): use ECharts pies and compact trip statistics with supplier menu restored
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import type { EChartsOption, LegendComponentOption, PieSeriesOption } from 'echarts'
|
||||
import TripOperationsCharts from './TripOperationsCharts.vue'
|
||||
import type { TripOperationsCharts as Charts, TripOperationsSummary } from '../api/tripOperations'
|
||||
|
||||
vi.mock('vue-echarts', () => ({ default: { name: 'VChart', props: ['option', 'initOptions', 'updateOptions', 'autoresize'], template: '<div class="echarts-pie-stub"/>' } }))
|
||||
const wrappers: VueWrapper[] = []
|
||||
const emptyCharts = (): Charts => ({ physicalPallets: [], destinationTrips: [], externalAgreedAmounts: [], externalPalletRates: [] })
|
||||
const summary = (): TripOperationsSummary => ({ trips: 20, inTransit: 5, completed: 15, physicalPallets: 90, palletSpaces: 100, ownPackages: 50, externalPackages: 20, externalAgreedAmounts: [{ currency: 'CNY', amount: 390 }, { currency: 'USD', amount: 100 }], costs: { visible: false, unrecordedTrips: null, unallocatedTrips: null, totals: [] } })
|
||||
function render(charts: Charts, totals = summary()) {
|
||||
const wrapper = mount(TripOperationsCharts, { props: { charts, summary: totals }, global: { stubs: {
|
||||
ElButton: { template: '<button><slot/></button>' },
|
||||
} } })
|
||||
wrappers.push(wrapper)
|
||||
return wrapper
|
||||
}
|
||||
const option = (wrapper: VueWrapper, selector: string) => wrapper.find(selector).findComponent({ name: 'VChart' }).props('option') as EChartsOption
|
||||
const series = (options: EChartsOption) => (options.series as PieSeriesOption[])[0]!
|
||||
afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount()))
|
||||
|
||||
it('uses four ECharts pies with compact native legends, accurate server totals and daily rates', () => {
|
||||
const charts = emptyCharts()
|
||||
charts.physicalPallets = [{ label: '每日实运托盘', unit: '托', points: [{ label: '2026-10-02', value: 10 }] }]
|
||||
charts.destinationTrips = [{ label: '目的地涉及车次', unit: '次', points: [{ label: 'ONT8', value: 3 }, { label: 'ABE2', value: 8 }] }]
|
||||
charts.externalAgreedAmounts = [{ label: 'CNY', unit: 'CNY', points: [{ label: '2026-10-02', value: 390 }] }]
|
||||
charts.externalPalletRates = [{ label: 'CNY', unit: 'CNY/标准托位', points: [{ label: '2026-10-07', value: 30 }, { label: '2026-10-02', value: 90 }] }]
|
||||
const original = structuredClone(charts)
|
||||
const wrapper = render(charts)
|
||||
expect(wrapper.findAllComponents({ name: 'VChart' })).toHaveLength(4)
|
||||
for (const chart of wrapper.findAllComponents({ name: 'VChart' })) {
|
||||
expect(series(chart.props('option'))).toMatchObject({ type: 'pie', label: { show: false } })
|
||||
expect(chart.props('initOptions')).toEqual({ renderer: 'canvas' })
|
||||
expect(chart.props('updateOptions')).toEqual({ notMerge: true })
|
||||
}
|
||||
expect(wrapper.find('svg').exists()).toBe(false)
|
||||
expect(wrapper.find('.series-caption').exists()).toBe(false)
|
||||
expect(wrapper.find('.chart > .chart-note').exists()).toBe(false)
|
||||
expect(wrapper.find('.chart--pallets .chart-metric').text()).toBe('90 托')
|
||||
expect(wrapper.find('.chart--rates .chart-metric').text()).toContain('CNY/标准托位 30.00')
|
||||
expect(wrapper.find('.chart--rates .chart-metric').attributes('title')).toContain('最近统计日:2026-10-07')
|
||||
const legend = option(wrapper, '.chart--pallets').legend as LegendComponentOption
|
||||
expect(legend.icon).toBe('circle')
|
||||
expect(typeof legend.formatter === 'function' && legend.formatter('2026-10-02')).toBe('10-02')
|
||||
expect(option(wrapper, '.chart--pallets').tooltip).toMatchObject({ trigger: 'item', confine: true, renderMode: 'richText' })
|
||||
expect(series(option(wrapper, '.chart--rates')).data).toEqual([
|
||||
{ name: '2026-10-02', value: 90, itemStyle: { color: '#3b82f6' } },
|
||||
{ name: '2026-10-07', value: 30, itemStyle: { color: '#14b8a6' } },
|
||||
])
|
||||
expect(charts).toEqual(original)
|
||||
})
|
||||
|
||||
it('switches currency without combining monetary amounts or leaving stale ECharts slices', async () => {
|
||||
const charts = emptyCharts()
|
||||
charts.externalAgreedAmounts = [{ label: 'CNY', unit: 'CNY', points: [{ label: '2026-10-02', value: 390 }] }, { label: 'USD', unit: 'USD', points: [{ label: '2026-10-03', value: 100 }] }]
|
||||
const wrapper = render(charts)
|
||||
expect(wrapper.find('.chart--amounts .chart-metric').text()).toBe('CNY 390.00')
|
||||
expect(wrapper.find('.chart--amounts').findAllComponents({ name: 'VChart' })).toHaveLength(1)
|
||||
const usd = wrapper.findAll('.chart--amounts .series-selector button').find(button => button.text() === 'USD')!
|
||||
await usd.trigger('click')
|
||||
expect(usd.attributes('aria-pressed')).toBe('true')
|
||||
expect(wrapper.find('.chart--amounts .chart-metric').text()).toBe('USD 100.00')
|
||||
expect(series(option(wrapper, '.chart--amounts')).data).toEqual([{ name: '2026-10-03', value: 100, itemStyle: { color: '#3b82f6' } }])
|
||||
await wrapper.setProps({ charts: { ...charts, externalAgreedAmounts: [charts.externalAgreedAmounts[0]!] } })
|
||||
expect(wrapper.find('.chart--amounts .chart-metric').text()).toBe('CNY 390.00')
|
||||
})
|
||||
|
||||
it('keeps every destination in its pie and exposes full legend data only on demand', async () => {
|
||||
const charts = emptyCharts()
|
||||
charts.destinationTrips = [{ label: '目的地涉及车次', unit: '次', points: Array.from({ length: 7 }, (_, index) => ({ label: `FBA-${index}`, value: index + 1 })) }]
|
||||
const wrapper = render(charts)
|
||||
expect(wrapper.find('.chart--destinations .chart-metric').text()).toBe('7 个目的地')
|
||||
const options = option(wrapper, '.chart--destinations')
|
||||
expect(series(options).data).toHaveLength(7)
|
||||
expect((options.legend as LegendComponentOption).data).toHaveLength(5)
|
||||
expect(wrapper.find('.chart-details').exists()).toBe(false)
|
||||
await wrapper.find('.chart-more').trigger('click')
|
||||
expect(wrapper.emitted('show-details')).toHaveLength(1)
|
||||
await wrapper.setProps({ detailsVisible: true })
|
||||
expect(wrapper.find('[aria-label="派送目的地明细"]').findAll('tbody tr')).toHaveLength(7)
|
||||
expect(wrapper.find('[aria-label="派送目的地明细"]').text()).toContain('FBA-0')
|
||||
})
|
||||
|
||||
it('preserves known zeros and does not draw equal positive shares for an empty sum', () => {
|
||||
const charts = emptyCharts()
|
||||
charts.physicalPallets = [{ label: '托盘', unit: '托', points: [{ label: '2026-10-01', value: 0 }, { label: '2026-10-02', value: 2 }] }]
|
||||
charts.externalPalletRates = [{ label: 'USD', unit: 'USD/标准托位', points: [{ label: '2026-10-01', value: 0 }] }]
|
||||
const wrapper = render(charts)
|
||||
expect(series(option(wrapper, '.chart--rates'))).toMatchObject({ stillShowZeroSum: false, showEmptyCircle: true, data: [{ name: '2026-10-01', value: 0 }] })
|
||||
expect(option(wrapper, '.chart--rates').graphic).toMatchObject([{ style: { text: '暂无正值数据' } }])
|
||||
expect(wrapper.find('.chart--rates .chart-metric').text()).toContain('0.00')
|
||||
expect(series(option(wrapper, '.chart--pallets')).data).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('shows missing amounts and rates as unknown and omits empty chart widgets', () => {
|
||||
const wrapper = render(emptyCharts(), { ...summary(), physicalPallets: 0, externalAgreedAmounts: [] })
|
||||
expect(wrapper.findAll('.chart-empty')).toHaveLength(4)
|
||||
expect(wrapper.find('.chart--amounts .chart-metric').text()).toBe('—')
|
||||
expect(wrapper.find('.chart--rates .chart-metric').text()).toBe('—')
|
||||
expect(wrapper.findComponent({ name: 'VChart' }).exists()).toBe(false)
|
||||
})
|
||||
@@ -1,28 +1,140 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { TripOperationsCharts, TripOperationsSeries } from '../api/tripOperations'
|
||||
const props = defineProps<{ charts: TripOperationsCharts }>()
|
||||
import { computed, ref } from 'vue'
|
||||
import VChart from 'vue-echarts'
|
||||
import { use } from 'echarts/core'
|
||||
import type { EChartsOption } from 'echarts'
|
||||
import { PieChart } from 'echarts/charts'
|
||||
import { AriaComponent, GraphicComponent, LegendComponent, TooltipComponent } from 'echarts/components'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import type { TripOperationsCharts, TripOperationsSeries, TripOperationsSummary } from '../api/tripOperations'
|
||||
|
||||
use([PieChart, LegendComponent, TooltipComponent, GraphicComponent, AriaComponent, CanvasRenderer])
|
||||
const props = withDefaults(defineProps<{ charts: TripOperationsCharts; summary: TripOperationsSummary; detailsVisible?: boolean }>(), { detailsVisible: false })
|
||||
const emit = defineEmits<{ 'show-details': [] }>()
|
||||
const selectedSeries = ref<Record<string, string>>({})
|
||||
const palette = ['#3b82f6', '#14b8a6', '#f59e0b', '#8b5cf6', '#f46565', '#06b6d4', '#ec4899', '#84cc16', '#6366f1', '#f97316']
|
||||
const sections = computed(() => [
|
||||
{ title: '实运托盘', note: '按实际发车日(UTC)汇总,物理托盘去重', series: props.charts.physicalPallets },
|
||||
{ title: '派送目的地', note: '各目的地涉及的车次,同车多站可分别出现', series: props.charts.destinationTrips },
|
||||
{ title: '外接约定金额', note: '按币种分别展示,不代表财务应收或实收', series: props.charts.externalAgreedAmounts },
|
||||
{ title: '外接按托报价均价', note: '按确认计价托位加权,排除整单报价', series: props.charts.externalPalletRates },
|
||||
])
|
||||
function width(value: number, series: TripOperationsSeries) { const max = Math.max(...series.points.map(p => p.value), 0); return max ? value / max * 100 : 0 }
|
||||
{ key: 'pallets', title: '实运托盘', note: '按实际发车日(UTC)分布,物理托盘去重', series: props.charts.physicalPallets, money: false },
|
||||
{ key: 'destinations', title: '派送目的地', note: '按目的地涉及车次分布,同车多站可分别计入', series: props.charts.destinationTrips, money: false },
|
||||
{ key: 'amounts', title: '外接约定金额', note: '按日期分布,各币种独立展示,不代表应收或实收', series: props.charts.externalAgreedAmounts, money: true },
|
||||
{ key: 'rates', title: '外接按托报价均价', note: '按日均价数值对比,顶部为最近统计日均价', series: props.charts.externalPalletRates, money: true },
|
||||
].map(section => {
|
||||
const series = section.series.filter(series => series.points.length).map(series => ({
|
||||
...series,
|
||||
points: [...series.points].sort((a, b) => section.key === 'destinations' ? b.value - a.value || a.label.localeCompare(b.label) : a.label.localeCompare(b.label)),
|
||||
}))
|
||||
return { ...section, series, active: series.find(series => series.label === selectedSeries.value[section.key]) ?? series[0] ?? null }
|
||||
}))
|
||||
const destinationCount = computed(() => new Set(props.charts.destinationTrips.flatMap(series => series.points.map(point => point.label))).size)
|
||||
const hasPoints = computed(() => sections.value.some(section => section.series.length))
|
||||
const number = (value: number, money = false) => value.toLocaleString('zh-CN', { minimumFractionDigits: money ? 2 : 0, maximumFractionDigits: 2 })
|
||||
const color = (index: number) => palette[index % palette.length]!
|
||||
const seriesTitle = (series: TripOperationsSeries) => series.label === series.unit ? series.label : `${series.label}(${series.unit})`
|
||||
const amount = (currency: string) => props.summary.externalAgreedAmounts.find(amount => amount.currency === currency)
|
||||
const legendLabel = (label: string) => /^\d{4}-\d{2}-\d{2}$/.test(label) ? label.slice(5) : label
|
||||
|
||||
function pieOption(series: TripOperationsSeries, money: boolean): EChartsOption {
|
||||
const hasPositive = series.points.some(point => point.value > 0)
|
||||
return {
|
||||
animation: false,
|
||||
aria: { enabled: true, description: `${seriesTitle(series)},完整日期、图例和数值可在统计详情读取` },
|
||||
tooltip: {
|
||||
trigger: 'item', confine: true, renderMode: 'richText',
|
||||
formatter: params => {
|
||||
if (Array.isArray(params)) return ''
|
||||
return `${params.name}\n${typeof params.value === 'number' ? number(params.value, money) : '—'} ${series.unit}`
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
type: 'plain', left: 'center', bottom: 4, width: series.points.length > 5 ? '82%' : '96%',
|
||||
icon: 'circle', itemWidth: 9, itemHeight: 9, itemGap: 10, selectedMode: false,
|
||||
textStyle: { color: '#8492a6', fontSize: 12 },
|
||||
data: series.points.slice(0, 5).map(point => point.label), formatter: legendLabel,
|
||||
tooltip: { show: true, confine: true },
|
||||
},
|
||||
series: [{
|
||||
name: seriesTitle(series), type: 'pie', radius: 64, center: ['50%', 72],
|
||||
label: { show: false }, labelLine: { show: false },
|
||||
itemStyle: { borderColor: '#fff', borderWidth: 2 },
|
||||
emphasis: { scale: false, focus: 'self' },
|
||||
stillShowZeroSum: false, showEmptyCircle: true,
|
||||
emptyCircleStyle: { color: '#f3f4f6', borderWidth: 0 },
|
||||
data: series.points.map((point, index) => ({ name: point.label, value: point.value, itemStyle: { color: color(index) } })),
|
||||
}],
|
||||
graphic: hasPositive ? [] : [{ type: 'text', left: 'center', top: 66, style: { text: '暂无正值数据', fill: '#8492a6', fontSize: 12 } }],
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template><section class="operations-charts" aria-label="出车数据统计"><article v-for="section in sections" :key="section.title" class="chart">
|
||||
<strong>{{ section.title }}</strong><p>{{ section.note }}</p>
|
||||
<p v-if="!section.series.some(s => s.points.length)">暂无对应统计</p>
|
||||
<div class="chart-values"><template v-for="(series, si) in section.series" :key="`${series.label}-${si}`"><div v-if="series.points.length"><b>{{ series.label }}({{ series.unit }})</b>
|
||||
<div v-for="(point, pi) in series.points" :key="pi" class="point"><span>{{ point.label }}</span><div class="bar-track" aria-hidden="true"><div class="bar" :style="{ width: width(point.value, series) + '%' }" /></div><span>{{ point.value }}</span></div>
|
||||
</div></template></div>
|
||||
</article></section></template>
|
||||
|
||||
<template>
|
||||
<div class="operations-visualizations">
|
||||
<section class="operations-charts" aria-label="出车统计饼图">
|
||||
<article v-for="section in sections" :key="section.key" class="chart" :class="`chart--${section.key}`" :aria-label="section.title">
|
||||
<h3 :title="section.note">{{ section.title }}</h3>
|
||||
<div class="chart-metric" :title="section.key === 'rates' && section.active ? `最近统计日:${section.active.points[section.active.points.length - 1]!.label};${section.note}` : section.note">
|
||||
<strong v-if="section.key === 'pallets'">{{ number(summary.physicalPallets) }} <small>托</small></strong>
|
||||
<strong v-else-if="section.key === 'destinations'">{{ number(destinationCount) }} <small>个目的地</small></strong>
|
||||
<template v-else-if="section.key === 'amounts'">
|
||||
<strong v-if="section.active && amount(section.active.label)"><small>{{ section.active.label }}</small> {{ number(amount(section.active.label)!.amount, true) }}</strong>
|
||||
<strong v-else>—</strong>
|
||||
</template>
|
||||
<template v-else>
|
||||
<strong v-if="section.active"><small>{{ section.active.unit }}</small> {{ number(section.active.points[section.active.points.length - 1]!.value, true) }}</strong>
|
||||
<strong v-else>—</strong>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="section.series.length > 1" class="series-selector" role="group" :aria-label="`${section.title}币种或系列`">
|
||||
<el-button v-for="series in section.series" :key="series.label" size="small" :type="section.active?.label === series.label ? 'primary' : 'default'" :aria-pressed="section.active?.label === series.label" @click="selectedSeries[section.key] = series.label">{{ series.label }}</el-button>
|
||||
</div>
|
||||
<template v-if="section.active">
|
||||
<div class="chart-visualization">
|
||||
<VChart class="pie-chart" :option="pieOption(section.active, section.money)" :init-options="{ renderer: 'canvas' }" :update-options="{ notMerge: true }" autoresize :aria-label="`${section.title},完整图例和数值见统计详情`" />
|
||||
<el-button v-if="section.active.points.length > 5" class="chart-more" link :aria-label="`查看其余 ${section.active.points.length - 5} 项图例及数值`" @click="emit('show-details')">+{{ section.active.points.length - 5 }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="chart-empty">暂无对应统计</p>
|
||||
</article>
|
||||
</section>
|
||||
<section v-if="detailsVisible && hasPoints" class="chart-details" aria-label="完整统计明细">
|
||||
<div class="chart-details-grid">
|
||||
<section v-for="section in sections" :key="section.key" :aria-label="`${section.title}明细`">
|
||||
<h3>{{ section.title }}</h3>
|
||||
<p class="chart-note">{{ section.note }}</p>
|
||||
<p v-if="section.key === 'rates'" class="chart-note">各日均价按确认计价托位加权,排除整单报价;不将每日均价相加或简单平均为期间均价。</p>
|
||||
<p v-if="!section.series.length" class="chart-note">暂无对应统计</p>
|
||||
<table v-for="(series, seriesIndex) in section.series" :key="`${series.label}-${seriesIndex}`" class="density-table">
|
||||
<caption>{{ seriesTitle(series) }}</caption>
|
||||
<thead><tr><th scope="col">{{ section.key === 'destinations' ? '目的地' : '日期(UTC)' }}</th><th scope="col">数值</th></tr></thead>
|
||||
<tbody><tr v-for="(point, pointIndex) in series.points" :key="point.label"><td><span class="legend-dot" :style="{ background: color(pointIndex) }" aria-hidden="true" /> {{ point.label }}</td><td>{{ number(point.value, section.money) }}</td></tr></tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.operations-charts { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:var(--admin-space-3); }
|
||||
.chart { display:grid; align-content:start; gap:var(--admin-space-2); min-width:0; padding:var(--admin-space-3); border:1px solid var(--cc-border); border-radius:var(--admin-radius); background:var(--admin-surface); }
|
||||
.chart > strong { font-size:var(--admin-font-section); }
|
||||
.chart p { margin:0; line-height:1.6; color:var(--cc-muted); font-size:var(--admin-font-caption); }
|
||||
.chart-values { max-height:220px; overflow:auto; }
|
||||
.point { display:grid; grid-template-columns:minmax(95px,1fr) minmax(45px,1fr) minmax(40px,auto); gap:var(--admin-space-2); align-items:center; margin:var(--admin-space-2) 0; }
|
||||
.bar-track { height:10px; background:var(--el-fill-color-light); }.bar { height:100%; background:var(--el-color-primary); }
|
||||
.operations-visualizations { display: grid; gap: var(--admin-space-3); min-width: 0; }
|
||||
.operations-charts, .chart-details-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--admin-space-6); }
|
||||
.chart { min-width: 0; padding-right: var(--admin-space-6); border-right: 1px solid var(--cc-border); color: var(--cc-text); text-align: center; }
|
||||
.chart:last-child { padding-right: 0; border-right: 0; }
|
||||
.chart h3, .chart-details h3 { margin: 0 0 var(--admin-space-2); font-size: var(--admin-font-section); }
|
||||
.chart-metric { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: center; gap: var(--admin-space-1) var(--admin-space-5); margin-bottom: var(--admin-space-1); font-variant-numeric: tabular-nums; }
|
||||
.chart-metric > strong { font-size: calc(var(--admin-font-title) * 1.25); overflow-wrap: anywhere; }
|
||||
.chart-metric small { font-size: var(--admin-font-caption); font-weight: normal; color: var(--cc-muted); }
|
||||
.chart-note, .chart-empty { margin: 0; font-size: var(--admin-font-caption); color: var(--cc-muted); line-height: 1.6; }
|
||||
.chart-empty { display: grid; min-height: 136px; place-content: center; }
|
||||
.series-selector { display: flex; justify-content: center; flex-wrap: wrap; gap: var(--admin-space-2); margin-top: var(--admin-space-2); }
|
||||
.series-selector :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.chart-visualization { position: relative; min-width: 0; }
|
||||
.pie-chart { display: block; width: 100%; height: 190px; margin: var(--admin-space-2) auto 0; }
|
||||
.chart-more { position: absolute; right: 0; bottom: var(--admin-space-1); height: auto; padding: 0; font-size: var(--admin-font-caption); }
|
||||
.legend-dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; flex: 0 0 9px; }
|
||||
.chart-details-grid { margin-top: var(--admin-space-5); }
|
||||
.chart-details-grid > section { min-width: 0; }
|
||||
.chart-details table { width: 100%; table-layout: fixed; }
|
||||
.chart-details caption { text-align: left; padding: var(--admin-space-2) 0; color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
.chart-details td { overflow-wrap: anywhere; }
|
||||
@media (max-width: 1100px) { .operations-charts, .chart-details-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .chart:nth-child(2n) { padding-right: 0; border-right: 0; } }
|
||||
@media (max-width: 720px) { .operations-charts, .chart-details-grid { grid-template-columns: minmax(0, 1fr); } .chart { padding-right: 0; padding-bottom: var(--admin-space-5); border-right: 0; border-bottom: 1px solid var(--cc-border); } .chart:last-child { padding-bottom: 0; border-bottom: 0; } }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user