141 lines
11 KiB
Vue
141 lines
11 KiB
Vue
<script setup lang="ts">
|
||
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(() => [
|
||
{ 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>
|
||
<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-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>
|