Compare commits

...
18 changed files with 475 additions and 75 deletions
+5 -4
View File
@@ -36,11 +36,11 @@ describe('adminNavigation', () => {
{ title: '客户服务', children: ['异常订单'] },
{ title: '财务管理', children: ['客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] },
{ title: '海外仓', children: ['经营总览', '销量分析', '库存分析', '数据采集'] },
{ title: '通用', children: ['费用项目', '汇率管理', '仓库管理', '海外仓报价', '车队报价', '船期价格表', '下载中心'] },
{ title: '通用', children: ['费用项目', '汇率管理', '仓库管理', '海外仓报价', '车队报价', '船期价格表', '下载中心', '供应商列表'] },
{ title: '系统管理', children: ['用户管理', '角色权限', '菜单权限'] },
])
expect(visible).toHaveLength(13)
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(56)
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(57)
expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined()
})
@@ -196,12 +196,13 @@ describe('adminNavigation', () => {
expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual(['/dashboard', '/master-data/fee-items'])
})
it('retains the supplier route permission while omitting its entry from the latest sidebar', () => {
it('shows the restored supplier entry under general only with its list permission', () => {
const visible = filterAdminNavigation(adminNavigation, new Set(['platform:supplier:list']))
const missing = filterAdminNavigation(adminNavigation, new Set())
expect(getNavigationLeaves(adminNavigation).find(item => item.path === '/suppliers')?.permission).toBe('platform:supplier:list')
expect(getNavigationLeaves(visible).map((item) => item.path)).toEqual(['/dashboard'])
expect(getNavigationLeaves(visible).map((item) => item.path)).toEqual(['/dashboard', '/suppliers'])
expect(visible.find(item => item.title === '通用')?.children?.map(item => item.path)).toEqual(['/suppliers'])
expect(getNavigationLeaves(missing).map((item) => item.path)).toEqual(['/dashboard'])
})
+1 -1
View File
@@ -204,7 +204,7 @@ export const adminNavigation: AdminNavigationNode[] = [
{ key: 'fleet-quotes', title: '车队报价', icon: Truck, path: '/overseas-master-data/fleet-quotes', permission: 'platform:fleet:quote:list' },
{ key: 'fba-carrier-rates', title: '船期价格表', icon: CircleDollarSign, path: '/first-leg-pricing/carrier-rates', permission: 'firstleg:fba-pricing:rate:view' },
{ key: 'download-center', title: '下载中心', icon: Download, path: '/downloads', permission: 'platform:download:list' },
{ key: 'supplier-list', title: '供应商列表', icon: ContactRound, path: '/suppliers', permission: 'platform:supplier:list', hidden: true },
{ key: 'supplier-list', title: '供应商列表', icon: ContactRound, path: '/suppliers', permission: 'platform:supplier:list' },
{ key: 'express-companies', title: '快递公司', icon: Truck, path: '/master-data/express-companies', permission: 'platform:express:list', hidden: true },
{ key: 'counterparties', title: '往来单位', icon: ContactRound, path: '/master-data/counterparties', permission: 'platform:counterparty:list', hidden: true },
{ key: 'mojia-sku-sync', title: '称重机器', icon: Database, path: '/general/mojia-sku-sync', permission: 'platform:mojia-sku-sync:list', hidden: true },
@@ -32,7 +32,7 @@ type FilterModel = QueryModel & {
expectedArrivalRange: string[]
}
const props = defineProps<{ mode: InboundTaskMode }>()
const props = defineProps<{ mode: InboundTaskMode; title?: string }>()
const router = useRouter()
const access = useAccessPreviewStore()
const page = ref(1)
@@ -356,8 +356,9 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
:aria-label="`${taskLabel}待办列表`"
@selection-change="handleReviewSelectionChange"
>
<template #toolbar-start><h1>{{ isReview ? '测量数据审核' : '订单测量' }}</h1></template>
<template #toolbar-start><h1>{{ title || (isReview ? '测量数据审核' : '订单测量') }}</h1></template>
<template #filters>
<slot name="status-tabs" />
<QueryToolbar
v-model="filters"
:ariaLabel="`${taskLabel}待办筛选`"
+1
View File
@@ -3,6 +3,7 @@ export { createInboundHoldIdempotencyKey, downloadInboundCartonLabelsBatch, down
export { default as DropshipPalletPrintPanel } from './core/DropshipPalletPrintPanel.vue'
export { default as InboundMeasurementPanel } from './measurement/InboundMeasurementPanel.vue'
export { default as InboundMeasurementDialog } from './measurement/InboundMeasurementDialog.vue'
export { default as InboundTaskList } from './components/InboundTaskList.vue'
export { default as MeasurementEvidenceThumbnail } from './measurement/components/MeasurementEvidenceThumbnail.vue'
export { default as InboundFeePanel } from './fee/InboundFeePanel.vue'
export {
@@ -89,6 +89,21 @@
以上仅为代码接入依据,真实界面验收仍待执行。
## 2026-10-19 出车记录统计与列表优化
依据:当前对话图1(现状)、图2(原型);用户确认优先在前端优化。沿当前实际发车口径与接口,不恢复原型手填发车事实。
| 页面 | 操作 | 原型承载/布局 | 当前实现 | 本轮结论 |
|---|---|---|---|---|
| 出车记录 | 统计与查询 | 图2上方独立四栏饼图,下方记录与筛选 | 图1统计位于列表筛选和表格之间,条形明细内部滚动 | 独立四栏饼图及彩色图例;用户追加确认只改饼图、保留现有日期/目的地统计分组;列表一套日期/关键词/货源/状态查询,同一响应更新统计与记录;币种切换独立展示,顶部均价明确为最近统计日值,不相加或简单平均每日均价;长图例与完整统计按需展开 |
| 出车记录 | 列表、导出 | 图2列表独立卡片,操作第一列,导出在右上 | integrated列表、字段设置、XLSX下载任务 | 保留公共连续列表,整理默认列;完整字段仍可通过字段设置显示;导出沿原筛选及权限 |
| 出车记录 | 详情、实测编辑、凭证预览 | 图2仅能确认列表编辑入口;当前操作以既有确认矩阵为准 | 详情独立路由;实测820px居中及按需凭证预览 | 不改变承载、读写权限或请求方式 |
| 出车记录 | 新增、审核、删除 | 图2有新增记录,未定义审核/删除 | 当前列表无新增/审核/删除 | 不新增业务动作,实际发车事实仍由既有作业链产生 |
用户追加截图确认统计区文字过多,并要求改善图表质感:使用项目已有 ECharts / vue-echarts 替换手写SVG,统一原生饼图、圆点图例和悬停数值;默认仅保留指标标题、主数值、四个饼图及短图例,日期图例使用月日,完整值在统计详情读取;默认不显示各图说明、重复系列标题、整行成本和额外明细入口。页头“统计详情”统一按需展开统计口径、服务端汇总、成本及完整图例明细,原有数据与权限保留。
本轮只调整前端;自动检查与人工视觉验收分别记录,修改后1920×1080、100%截图待用户确认。
## 新版报价与出车记录接入
| 入口 | 原型依据 | 当前承载 | 核对边界 |
@@ -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>
@@ -2,6 +2,8 @@ import { afterEach, expect, it, vi } from 'vitest'
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
import FleetQuotesView from './FleetQuotesView.vue'
import TripOperationsView from './TripOperationsView.vue'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import TripOperationsCharts from '../components/TripOperationsCharts.vue'
import * as quotes from '../api/fleetQuotes'
import * as reports from '../api/tripOperations'
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (p: string) => p.endsWith(':list') }) }))
@@ -9,10 +11,10 @@ vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('../api/fleetQuotes', () => ({ listFleetQuotes: vi.fn(), setFleetQuoteStatus: vi.fn(), deleteFleetQuote: vi.fn(), exportFleetQuotes: vi.fn(), saveFleetCsv: vi.fn() }))
vi.mock('../api/tripOperations', () => ({ listTripOperations: vi.fn(), exportTripOperations: vi.fn() }))
const wrappers: VueWrapper[] = []
const layout = { template: '<div><slot/></div>' }
const layout = { template: '<div><slot name="metrics"/><slot name="state"/><slot/></div>' }
function mount(component: object) { const wrapper = shallowMount(component, { global: { stubs: { AdminPage: layout, ListPageLayout: layout, QueryToolbar: true,
DataTable: { name: 'DataTable', props: ['data', 'columns'], template: '<div><slot name="toolbar-start"/><slot name="toolbar-actions"/><slot name="filters"/><slot name="notices"/></div>' }, ElButton: { template: '<button><slot/></button>' },
LoadErrorState: true, FleetQuoteEditor: true, FleetQuoteImport: true, ApiErrorMeta: true } } }); wrappers.push(wrapper); return wrapper }
ElTooltip: true, LoadErrorState: true, FleetQuoteEditor: true, FleetQuoteImport: true, ApiErrorMeta: true } } }); wrappers.push(wrapper); return wrapper }
afterEach(() => { wrappers.splice(0).forEach(w => w.unmount()); vi.clearAllMocks() })
it('quotes read-only view fetches a page once and hides all write/export tools', async () => {
vi.mocked(quotes.listFleetQuotes).mockResolvedValue({ records: [], total: 0, page: 1, size: 20, pages: 0 })
@@ -23,5 +25,42 @@ it('quotes read-only view fetches a page once and hides all write/export tools',
it('operations uses server totals rather than displayed page size', async () => {
vi.mocked(reports.listTripOperations).mockResolvedValue({ page: { records: [], total: 21, page: 1, size: 20, pages: 2 }, summary: { trips: 21, inTransit: 12, completed: 9, physicalPallets: 40, palletSpaces: 40, ownPackages: 70, externalPackages: 10, externalAgreedAmounts: [], costs: { visible: false, unrecordedTrips: null, unallocatedTrips: null, totals: [] } }, charts: { physicalPallets: [], destinationTrips: [], externalAgreedAmounts: [], externalPalletRates: [] } })
const wrapper = mount(TripOperationsView); await flushPromises()
expect(reports.listTripOperations).toHaveBeenCalledTimes(1); expect(wrapper.text()).toContain('实际出车 21 次'); expect(wrapper.text()).not.toContain('导出CSV')
expect(reports.listTripOperations).toHaveBeenCalledTimes(1); expect(wrapper.text()).not.toContain('导出CSV')
expect(wrapper.find('#operations-statistics-details').exists()).toBe(false)
expect(wrapper.findComponent(TripOperationsCharts).props('detailsVisible')).toBe(false)
await wrapper.find('[aria-controls="operations-statistics-details"]').trigger('click')
expect(wrapper.text()).toContain('实际出车 21 次')
expect(wrapper.findComponent(TripOperationsCharts).props('detailsVisible')).toBe(true)
expect(reports.listTripOperations).toHaveBeenCalledTimes(1)
await wrapper.find('[aria-controls="operations-statistics-details"]').trigger('click')
expect(wrapper.find('#operations-statistics-details').exists()).toBe(false)
wrapper.findComponent(TripOperationsCharts).vm.$emit('show-details'); await flushPromises()
expect(wrapper.find('#operations-statistics-details').exists()).toBe(true)
expect(reports.listTripOperations).toHaveBeenCalledTimes(1)
expect(wrapper.findComponent(TripOperationsCharts).props('summary').physicalPallets).toBe(40)
expect(wrapper.find('.operations-overview').element.compareDocumentPosition(wrapper.findComponent({ name: 'DataTable' }).element) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(wrapper.findComponent({ name: 'DataTable' }).props('columns').find((column: { key: string }) => column.key === 'finance')).toMatchObject({ defaultVisible: false })
})
it('operations queries charts and records together and resets with only one request', async () => {
vi.mocked(reports.listTripOperations).mockResolvedValue({ page: { records: [], total: 0, page: 1, size: 20, pages: 0 }, summary: { trips: 0, inTransit: 0, completed: 0, physicalPallets: 0, palletSpaces: 0, ownPackages: 0, externalPackages: 0, externalAgreedAmounts: [], costs: { visible: false, unrecordedTrips: null, unallocatedTrips: null, totals: [] } }, charts: { physicalPallets: [], destinationTrips: [], externalAgreedAmounts: [], externalPalletRates: [] } })
const wrapper = mount(TripOperationsView); await flushPromises()
const initial = vi.mocked(reports.listTripOperations).mock.calls[0]![0]
const toolbar = wrapper.findComponent(QueryToolbar)
toolbar.vm.$emit('update:modelValue', { from: '2026-10-01', to: '2026-10-07', keyword: 'TRIP', sourceType: 'EXTERNAL', status: 'IN_TRANSIT' })
toolbar.vm.$emit('search'); await flushPromises()
expect(reports.listTripOperations).toHaveBeenLastCalledWith(expect.objectContaining({ from: '2026-10-01T00:00:00Z', to: '2026-10-08T00:00:00.000Z', keyword: 'TRIP', sourceType: 'EXTERNAL', status: 'IN_TRANSIT' }))
expect(wrapper.text()).toContain('2026-10-01 至 2026-10-07(UTC)')
toolbar.vm.$emit('update:modelValue', { from: '2026-09-01', to: '2026-09-07' }); await flushPromises()
expect(wrapper.text()).toContain('2026-10-01 至 2026-10-07(UTC)')
toolbar.vm.$emit('reset'); toolbar.vm.$emit('search'); await flushPromises()
expect(reports.listTripOperations).toHaveBeenCalledTimes(3)
expect(reports.listTripOperations).toHaveBeenLastCalledWith(initial)
expect(toolbar.props('modelValue')).toMatchObject({ keyword: '', sourceType: '', status: '' })
})
it('operations shows query failures without leaving stale statistics', async () => {
vi.mocked(reports.listTripOperations).mockRejectedValue(new Error('统计暂不可用'))
const wrapper = mount(TripOperationsView); await flushPromises()
expect(wrapper.findComponent(TripOperationsCharts).exists()).toBe(false)
expect(wrapper.findComponent({ name: 'LoadErrorState' }).attributes('message')).toBe('运营记录加载失败')
expect(wrapper.text()).not.toContain('实际出车 0 次')
})
@@ -19,49 +19,82 @@ const submitExport = useExportTask()
const router = useRouter(), access = useAccessPreviewStore()
const result = ref<TripOperationsResult | null>(null), page = ref(1), size = ref(20), loading = ref(false), exporting = ref(false), error = ref<ApiErrorDetails | null>(null)
const recordOpen = ref(false), selected = ref<TripOperationsRow | null>(null)
const statisticsOpen = ref(false)
const today = new Date().toISOString().slice(0, 10), monthAgo = new Date(Date.now() - 30 * 86400000).toISOString().slice(0, 10)
const filters = ref<QueryModel>({ from: monthAgo, to: today, keyword: '', sourceType: '', status: '' })
const defaultFilters = () => ({ from: monthAgo, to: today, keyword: '', sourceType: '', status: '' })
const filters = ref<QueryModel>(defaultFilters())
const displayedRange = ref('')
let applied: TripOperationsQuery = range(), sequence = 0
const fields: QueryField[] = [
{ key: 'from', label: '实际发车起日(UTC)', type: 'date', width: 185 }, { key: 'to', label: '实际发车止日(UTC)', type: 'date', width: 185 },
{ key: 'keyword', label: '关键词', type: 'input', width: 235, placeholder: '车次 / 发车车牌 / 司机 / 目的仓' },
{ key: 'sourceType', label: '货源', type: 'select', width: 145, options: [{ value: 'OWN', label: '含自有业务' }, { value: 'EXTERNAL', label: '含外接业务' }] },
{ key: 'status', label: '状态', type: 'select', width: 130, options: [{ value: 'IN_TRANSIT', label: '在途' }, { value: 'COMPLETED', label: '货物已结案' }] },
{ key: 'from', label: '实际发车起日(UTC)', placeholder: '发车起日(UTC)', type: 'date', width: 150, props: { 'aria-label': '实际发车起日(UTC)' } },
{ key: 'to', label: '实际发车止日(UTC)', placeholder: '发车止日(UTC)', type: 'date', width: 150, props: { 'aria-label': '实际发车止日(UTC)' } },
{ key: 'keyword', label: '关键词', type: 'input', width: 270, placeholder: '车次 / 车牌 / 司机 / 目的仓', props: { 'aria-label': '车次、车牌、司机或目的仓' } },
{ key: 'sourceType', label: '货源', placeholder: '全部货源', type: 'select', width: 140, props: { 'aria-label': '货源' }, options: [{ value: 'OWN', label: '含自有业务' }, { value: 'EXTERNAL', label: '含外接业务' }] },
{ key: 'status', label: '状态', placeholder: '全部状态', type: 'select', width: 140, props: { 'aria-label': '状态' }, options: [{ value: 'IN_TRANSIT', label: '在途' }, { value: 'COMPLETED', label: '货物已结案' }] },
]
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 185, fixed: 'left', slotName: 'actions' }, { key: 'tripNo', label: '车次号', minWidth: 220 },
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 140, fixed: 'left', slotName: 'actions' }, { key: 'tripNo', label: '车次号', minWidth: 220 },
{ key: 'status', label: '状态', width: 125, slotName: 'status' }, { key: 'vehiclePlate', label: '发车车牌', minWidth: 125 }, { key: 'driverName', label: '发车司机', minWidth: 100 },
{ key: 'warehouseName', label: '起运仓', minWidth: 150 }, { key: 'destinations', label: '目的地', minWidth: 180, slotName: 'destinations' },
{ key: 'departedAt', label: '实际发车(UTC)', minWidth: 190 }, { key: 'completedAt', label: '货物结案(UTC)', minWidth: 190 },
{ key: 'physicalPallets', label: '实物托盘', width: 100 }, { key: 'palletSpaces', label: '标准托位', width: 100 }, { key: 'ownPackages', label: '自有箱数', width: 100 }, { key: 'externalPackages', label: '外接箱数', width: 100 },
{ key: 'agreed', label: '外接约定金额', minWidth: 170, slotName: 'agreed' }, { key: 'distance', label: '实际里程(mile)', width: 140, slotName: 'distance' }, { key: 'fuel', label: '实际油耗(L)', width: 130, slotName: 'fuel' }, { key: 'finance', label: '应收 / 实收', minWidth: 260, slotName: 'finance' }, { key: 'cost', label: '已登记直接成本', minWidth: 200, slotName: 'cost' }, { key: 'allocated', label: '自有 / 外接分摊', minWidth: 240, slotName: 'allocated' }]
{ key: 'departedAt', label: '实际发车(UTC)', minWidth: 190 }, { key: 'physicalPallets', label: '实物托盘', width: 100 },
{ key: 'agreed', label: '外接约定金额', minWidth: 170, slotName: 'agreed' },
{ key: 'completedAt', label: '货物结案(UTC)', minWidth: 190, defaultVisible: false },
{ key: 'palletSpaces', label: '标准托位', width: 100, defaultVisible: false }, { key: 'ownPackages', label: '自有箱数', width: 100, defaultVisible: false }, { key: 'externalPackages', label: '外接箱数', width: 100, defaultVisible: false },
{ key: 'distance', label: '实际里程(mile)', width: 140, slotName: 'distance', defaultVisible: false }, { key: 'fuel', label: '实际油耗(L)', width: 130, slotName: 'fuel', defaultVisible: false },
{ key: 'finance', label: '应收 / 实收', minWidth: 260, slotName: 'finance', defaultVisible: false }, { key: 'cost', label: '已登记直接成本', minWidth: 200, slotName: 'cost', defaultVisible: false }, { key: 'allocated', label: '自有 / 外接分摊', minWidth: 240, slotName: 'allocated', defaultVisible: false }]
function range(): TripOperationsQuery {
const from = String(filters.value.from || monthAgo), to = String(filters.value.to || today)
if (!/^\d{4}-\d{2}-\d{2}$/.test(from) || !/^\d{4}-\d{2}-\d{2}$/.test(to) || Number.isNaN(Date.parse(from)) || Number.isNaN(Date.parse(to))) throw new Error('请输入有效日期 YYYY-MM-DD')
return { from: `${from}T00:00:00Z`, to: new Date(Date.parse(`${to}T00:00:00Z`) + 86400000).toISOString(), keyword: String(filters.value.keyword || '') || undefined, sourceType: (filters.value.sourceType || undefined) as TripOperationsQuery['sourceType'], status: String(filters.value.status || '') || undefined }
}
async function load() { const current = ++sequence; loading.value = true; error.value = null; try { const data = await listTripOperations({ ...applied, page: page.value, size: size.value }); if (current === sequence) result.value = data } catch (e) { if (current === sequence) { result.value = null; error.value = getApiErrorDetails(e, '运营记录加载失败') } } finally { if (current === sequence) loading.value = false } }
async function load() {
const current = ++sequence, query = { ...applied, page: page.value, size: size.value }
loading.value = true; error.value = null
try {
const data = await listTripOperations(query)
if (current === sequence) {
result.value = data
displayedRange.value = `${query.from.slice(0, 10)} 至 ${new Date(Date.parse(query.to) - 86400000).toISOString().slice(0, 10)}(UTC)`
}
} catch (e) {
if (current === sequence) { result.value = null; error.value = getApiErrorDetails(e, '运营记录加载失败') }
} finally { if (current === sequence) loading.value = false }
}
function search() { try { applied = range(); if (page.value !== 1) page.value = 1; else void load() } catch (e) { error.value = getApiErrorDetails(e, '日期范围无效') } }
function resetFilters() { filters.value = defaultFilters() }
function openRecord(tripNo: unknown) { const row = result.value?.page.records.find(item => item.tripNo === tripNo); if (row) { selected.value = row; recordOpen.value = true } }
async function download() { exporting.value = true; try { await submitExport('TRIP_OPERATIONS_LIST', '出车记录', applied) } catch (e) { error.value = getApiErrorDetails(e, '导出失败') } finally { exporting.value = false } }
const money = (rows: TripOperationsMoney[]) => rows.length ? rows.map(r => `${r.currency} ${r.amount.toFixed(2)}`).join(' / ') : '—'
watch([page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++ })
</script>
<template><AdminPage class="admin-density"><ListPageLayout aria-label="出车记录">
<template #metrics>
<section class="operations-overview" aria-labelledby="operations-statistics-title" :aria-busy="loading">
<header class="operations-heading">
<h2 id="operations-statistics-title">出车数据统计</h2>
<div class="operations-heading-tools">
<span class="operations-note">{{ loading ? '正在更新…' : displayedRange }}</span>
<el-button v-if="result" link :aria-expanded="statisticsOpen" aria-controls="operations-statistics-details" @click="statisticsOpen = !statisticsOpen">{{ statisticsOpen ? '收起详情' : '统计详情' }}</el-button>
</div>
</header>
<template v-if="result">
<TripOperationsCharts :charts="result.charts" :summary="result.summary" :details-visible="statisticsOpen" @show-details="statisticsOpen = true" />
<section v-if="statisticsOpen" id="operations-statistics-details" class="operations-detail-body" aria-label="统计汇总与口径">
<div class="operations-summary" aria-live="polite">
<span>实际出车 <strong>{{ result.summary.trips }}</strong> 次</span><span>在途 <strong>{{ result.summary.inTransit }}</strong> 次</span><span>货物结案 <strong>{{ result.summary.completed }}</strong> 次</span>
</div>
<p v-if="result.summary.costs.visible" class="operations-note">已登记直接成本:{{ result.summary.costs.totals.length ? result.summary.costs.totals.map(c => `${c.currency} ${c.directCost.toFixed(2)}`).join(' / ') : '—' }};未登记 {{ result.summary.costs.unrecordedTrips ?? '—' }} 趟,已登记但未能分摊 {{ result.summary.costs.unallocatedTrips ?? '—' }} 趟。</p>
<p class="operations-note">按实际发车时间查询,每趟车只计一次;货源筛选展示含该类货物的整趟车。物理托盘去重;同车多站可分别计入目的地。外接约定金额不代表应收或实收,结案不代表车辆与司机已释放。</p>
</section>
</template>
<p v-else class="operations-placeholder operations-note" role="status">{{ loading ? '正在加载出车统计…' : '暂无统计数据' }}</p>
</section>
</template>
<template #state>
<LoadErrorState v-if="error" title="查询或导出失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
</template>
<DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="true" preference-key="fleet-operations-table" v-model:page="page" v-model:page-size="size" :data="result?.page.records || []" :columns="columns" :total="result?.page.total || 0" :loading="loading" row-key="tripNo" empty-text="该范围暂无实际出车记录">
<template #toolbar-start><h1>出车记录</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="运营记录查询" preference-key="fleet-operations-query" :loading="loading" @search="search" @reset="search" /></template>
<template #notices>
<div class="operations-overview">
<LoadErrorState v-if="error" title="查询或导出失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<TripOperationsCharts v-if="result" :charts="result.charts" />
<div v-if="result" class="operations-summary" aria-live="polite">
<span>实际出车 <strong>{{ result.summary.trips }}</strong> 次</span><span>在途 <strong>{{ result.summary.inTransit }}</strong> 次</span><span>货物结案 <strong>{{ result.summary.completed }}</strong> 次</span>
<span>实物托盘 <strong>{{ result.summary.physicalPallets }}</strong> 托</span><span>外接约定 {{ money(result.summary.externalAgreedAmounts) }}</span>
</div>
<p v-if="result?.summary.costs.visible" class="operations-note">已登记直接成本:{{ result.summary.costs.totals.length ? result.summary.costs.totals.map(c => `${c.currency} ${c.directCost.toFixed(2)}`).join(' / ') : '—' }};未登记 {{ result.summary.costs.unrecordedTrips }} 趟,已登记但未能分摊 {{ result.summary.costs.unallocatedTrips }} 趟。</p>
<p class="operations-note">按实际发车时间查询,每趟车只计一次;货源筛选展示含该类货物的整趟车。约定金额不代表应收或实收,结案不代表车辆与司机已释放。</p>
</div>
</template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="运营记录查询" preference-key="fleet-operations-query" :loading="loading" @search="search" @reset="resetFilters" /></template>
<template #toolbar-actions><el-button v-if="access.can('platform:fleet:operations:export') && access.can('platform:download:list')" :loading="exporting" @click="download">导出</el-button></template>
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button v-if="access.can('platform:fleet:trip:list')" class="density-action" @click="router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: row.tripNo } })">详情</el-button><el-button class="density-action" @click="openRecord(row.tripNo)">实测记录</el-button></div></template>
<template #status="{ row }">{{ row.status === 'COMPLETED' ? '货物已结案' : '在途' }}</template><template #destinations="{ row }">{{ row.destinations.map((d: TripOperationsRow['destinations'][number]) => d.label).join(' / ') }}</template>
@@ -73,7 +106,11 @@ watch([page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++ }
<TripOperationsRecordDialog v-model="recordOpen" :row="selected" :readonly="!access.can('platform:fleet:operations:edit')" @saved="load" />
</ListPageLayout></AdminPage></template>
<style scoped>
.operations-overview { display: grid; gap: var(--admin-space-3); min-width: 0; }
.operations-overview { display: grid; gap: var(--admin-space-3); min-width: 0; padding: var(--admin-space-5); background: var(--admin-surface); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); }
.operations-heading, .operations-heading-tools { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--admin-space-3) var(--admin-space-6); }
.operations-heading h2 { margin: 0; font-size: var(--admin-font-title); }
.operations-detail-body { display: grid; gap: var(--admin-space-3); border-top: 1px solid var(--cc-border); padding-top: var(--admin-space-3); }
.operations-summary { display: flex; flex-wrap: wrap; gap: var(--admin-space-2) var(--admin-space-6); font-variant-numeric: tabular-nums; }
.operations-note { margin: 0; color: var(--cc-muted); font-size: var(--admin-font-caption); line-height: 1.6; }
.operations-placeholder { min-height: var(--admin-empty-height); display: grid; place-content: center; }
</style>
@@ -4,11 +4,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getOutbound } from '../api/outbounds'
import OutboundDetailView from './OutboundDetailView.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getPlatformWarehouse } from '@/modules/warehouses'
const canPermission = vi.hoisted(() => vi.fn((_code: string) => false))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canPermission }) }))
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { outboundNo: 'OUT/1' } }), useRouter: () => ({ push: vi.fn() }) }))
vi.mock('../api/outbounds', () => ({ getOutbound: vi.fn(), cancelOutbound: vi.fn() }))
vi.mock('@/modules/warehouses', () => ({ getPlatformWarehouse: vi.fn() }))
describe('OutboundDetailView', () => {
it.each([
@@ -25,6 +27,7 @@ describe('OutboundDetailView', () => {
})
beforeEach(() => {
vi.mocked(getPlatformWarehouse).mockReset().mockRejectedValue(new Error('Warehouse unavailable'))
canPermission.mockReset().mockReturnValue(false)
vi.mocked(getOutbound).mockReset().mockResolvedValue({
outbound: { id: '1', outboundNo: 'OUT/1', dispatchNo: 'PC-1', status: 'PICKED', palletCount: 2, packageCount: 20, createdAt: '2026-09-01T10:00:00' },
@@ -87,6 +90,24 @@ describe('OutboundDetailView', () => {
wrapper.unmount()
})
it('resolves the warehouse for a discard outbound without deliveries', async () => {
vi.mocked(getOutbound).mockResolvedValueOnce({
outbound: { id: '1', outboundNo: 'OB-WH-20261019-151681', outboundType: 'DISCARD_OUTBOUND', overseasWarehouseId: 'warehouse-internal-id', packageCount: 1 },
deliveries: [],
})
vi.mocked(getPlatformWarehouse).mockResolvedValueOnce({
warehouse: { id: 'warehouse-internal-id', tenantId: '0', warehouseCode: 'LAX', internalCode: null, warehouseName: '洛杉矶仓', warehouseType: 'OVERSEAS', countryCode: 'US', timezone: 'America/Los_Angeles', contactName: null, contactPhone: null, contactEmail: null, status: 'ACTIVE', remark: null },
addresses: [], capabilities: [],
})
const wrapper = mount(OutboundDetailView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(getPlatformWarehouse).toHaveBeenCalledWith('warehouse-internal-id')
const label = wrapper.findAll('.el-descriptions__label').find(item => item.text() === '海外仓')!
expect(label.element.nextElementSibling?.textContent?.trim()).toBe('洛杉矶仓')
expect(wrapper.text()).not.toContain('warehouse-internal-id')
wrapper.unmount()
})
it.each([
['warehouse-internal-id', '洛杉矶仓', '洛杉矶仓'],
['warehouse-internal-id', null, '—'],
@@ -13,6 +13,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getPlatformWarehouse } from '@/modules/warehouses'
import { cancelOutbound, getOutbound, type OutboundDetail } from '../api/outbounds'
import { formatOverseasDateTime } from '../lastMilePresentation'
import { overseasWarehousePermissions as permission } from '../permissions'
@@ -21,6 +22,7 @@ const route = useRoute(); const router = useRouter(); const access = useAccessPr
const outboundNo = computed(() => String(route.params.outboundNo || '').trim())
const detail = ref<OutboundDetail | null>(null); const loading = ref(false); const cancelling = ref(false)
const loadError = ref<ApiErrorDetails | null>(null); const mutationError = ref<ApiErrorDetails | null>(null)
const warehouseName = ref('')
const statusLabels: Record<string, string> = { WAIT_PICK: '待拣货', WAIT_OUTBOUND: '待出库', PICKING: '拣货中', PICKED: '已拣货', OUTBOUNDED: '已出库', CANCELLED: '已取消' }
const palletColumns: DataTableColumn[] = [
{ key: 'palletNo', label: '托盘码', width: 220 },
@@ -40,8 +42,23 @@ function show(value: unknown) { const text = String(value ?? '').trim(); return
// A warehouse ID is only a join key; never render it as a warehouse name.
const warehouseLabel = computed(() => {
const outboundWarehouseId = detail.value?.outbound.overseasWarehouseId
if (!outboundWarehouseId) return '—'
const delivery = detail.value?.deliveries?.find(item => item.overseasWarehouseId === outboundWarehouseId)
return outboundWarehouseId && delivery?.overseasWarehouseId === outboundWarehouseId ? show(delivery.overseasWarehouseName) : '—'
return show(delivery?.overseasWarehouseName?.trim() || warehouseName.value)
})
// 废弃等出库没有关联派送单,直接从仓库主档补充名称。
watch(() => detail.value, async (value, _previous, onCleanup) => {
warehouseName.value = ''
let current = true
onCleanup(() => { current = false })
const warehouseId = value?.outbound.overseasWarehouseId
if (!warehouseId || value?.deliveries?.some(item => item.overseasWarehouseId === warehouseId && item.overseasWarehouseName?.trim())) return
try {
const result = await getPlatformWarehouse(warehouseId)
if (current && String(result.warehouse.id) === String(warehouseId)) warehouseName.value = result.warehouse.warehouseName
} catch {
// 主档不可用时保留占位,出库详情仍可查看,不展示内部 ID。
}
})
function statusLabel(value: unknown) { const code = String(value || ''); return statusLabels[code] || show(code) }
function outboundTypeLabel(value?: string | null) { return value === 'RELABEL_OUTBOUND' ? '贴标出库' : value === 'DISCARD_OUTBOUND' ? '废弃出库' : value === 'WAREHOUSE_TRANSFER_OUTBOUND' ? '仓储转运出库' : value === 'ONE_CLICK_DROPSHIP' ? '一件代发出库' : value === 'RETURN_OUTBOUND' ? '退货出库' : value === 'DISPATCH_DELIVERY' ? '派送出库' : show(value) }
@@ -15,7 +15,7 @@ export type UnifiedOrderMainStatus =
export const unifiedOrderStatusOptions: Array<{ value: UnifiedOrderMainStatus; label: string }> = [
{ value: 'PENDING_REVIEW', label: '待审核' },
{ value: 'WAIT_MEASURE', label: '待测量数据' },
{ value: 'WAIT_PALLETIZE', label: '待一件代发组托' },
{ value: 'WAIT_PALLETIZE', label: '一件代发打托' },
{ value: 'WAIT_SPLIT', label: '待分柜' },
{ value: 'LOADING', label: '装柜中' },
{ value: 'LOADED', label: '装柜完成' },
@@ -14,7 +14,10 @@ vi.mock('vue-router', () => ({
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 }) }))
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn().mockResolvedValue([]) }))
vi.mock('@/modules/inbounds', () => ({ downloadWeightImage: vi.fn(), getInboundMeasurement: vi.fn() }))
vi.mock('@/modules/inbounds', () => ({
downloadWeightImage: vi.fn(), getInboundMeasurement: vi.fn(),
InboundTaskList: { name: 'InboundTaskList', props: ['mode', 'title'], template: '<section><slot name="status-tabs" /></section>' },
}))
vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() }))
vi.mock('@/shared/components/exportTaskCreatedDialog', () => ({ showExportTaskCreatedDialog: vi.fn() }))
vi.mock('@/modules/unified-orders/workflows', () => ({ UnifiedOrderWorkflowPanel: { template: '<div />' } }))
@@ -51,6 +54,7 @@ function mountView() {
directives: { loading: () => undefined },
stubs: {
AdminPage: { template: '<main><slot /></main>' },
InboundTaskList: false,
ListPageLayout: { template: '<section><slot name="toolbar" /><slot name="state" /><slot /></section>' },
QueryToolbar: { name: 'QueryToolbar', props: ['modelValue'], emits: ['update:modelValue', 'search', 'reset'], template: '<div data-testid="query-toolbar" />' },
DataTable: { name: 'DataTable', props: ['page', 'pageSize', 'data'], emits: ['update:page', 'update:pageSize', 'selection-change'], template: '<div data-testid="data-table"><slot name="toolbar-start" /><slot name="toolbar-actions" /><slot name="filters" /><slot name="notices" /><div v-for="row in data" :key="row.orderId" class="test-row"><slot name="estimatedVolume" :row="row" /><slot name="expectedArrival" :row="row" /></div></div>' },
@@ -94,6 +98,25 @@ describe('UnifiedOrdersView list request coordination', () => {
expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(5)
})
it('opens the existing measurement review list beside measurement and returns to order queries', async () => {
const wrapper = mountView()
await flushPromises()
const labels = wrapper.findAll('[role="tab"]').map((tab) => tab.text())
expect(labels.indexOf('测量数据审核')).toBe(labels.indexOf('待测量数据') + 1)
await wrapper.findAll('[role="tab"]').find((tab) => tab.text() === '测量数据审核')!.trigger('click')
await flushPromises()
expect(wrapper.getComponent({ name: 'InboundTaskList' }).props()).toMatchObject({ mode: 'review', title: '订单列表' })
expect(wrapper.get('[aria-selected="true"]').text()).toBe('测量数据审核')
expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(1)
await wrapper.findAll('[role="tab"]').find((tab) => tab.text() === '待测量数据')!.trigger('click')
await flushPromises()
expect(wrapper.findComponent({ name: 'InboundTaskList' }).exists()).toBe(false)
expect(listPlatformUnifiedOrders).toHaveBeenLastCalledWith(expect.objectContaining({ mainStatus: 'WAIT_MEASURE' }))
expect(listPlatformUnifiedOrders).toHaveBeenCalledTimes(2)
})
it('does not apply draft filters to pagination until query is submitted', async () => {
const wrapper = mountView()
await flushPromises()
@@ -62,7 +62,7 @@ describe('UnifiedOrdersView contract', () => {
it('exposes the product main statuses including dropship palletizing', () => {
expect(unifiedOrderStatusOptions.map((item) => item.label)).toEqual([
'待审核', '待测量数据', '待一件代发组托', '待分柜', '装柜中', '装柜完成', '派送完成', '已完成',
'待审核', '待测量数据', '一件代发打托', '待分柜', '装柜中', '装柜完成', '派送完成', '已完成',
])
for (const legacyLabel of ['待客户确认', '差异处理中', '派送中', '进入分柜']) {
expect(source).not.toContain(legacyLabel)
@@ -72,7 +72,7 @@ describe('UnifiedOrdersView contract', () => {
it('renders accessible current-status tabs with keyboard navigation', () => {
expect(source).toContain('role="tablist"')
expect(source).toContain('role="tab"')
expect(source).toContain(':aria-selected="selectedStatus === tab.value"')
expect(source).toContain(':aria-selected="selectedTab === tab.value"')
expect(source).toContain("event.key === 'ArrowRight'")
expect(source).toContain("event.key === 'ArrowLeft'")
expect(source).toContain("event.key === 'Home'")
@@ -237,7 +237,7 @@ describe('UnifiedOrdersView contract', () => {
})
it('keeps the title, search, errors and results in the integrated list', () => {
expect(source).toContain('<ListPageLayout ariaLabel="统一订单列表">')
expect(source).toContain('<ListPageLayout v-else ariaLabel="统一订单列表">')
expect(source).toContain('<template #filters>')
expect(source).toContain('<template #notices>')
expect(source).toContain('<template #default>')
@@ -82,6 +82,7 @@ import {
updateMeasurementOuterDimensions,
InboundFeePanel,
InboundMeasurementDialog,
InboundTaskList,
MeasurementEvidenceThumbnail,
DropshipPalletPrintPanel,
sendInboundFee,
@@ -322,6 +323,9 @@ const showAdvancedFilters = ref(false)
const filters = ref<FilterModel>(emptyFilters())
const appliedFilters = ref<FilterModel>(emptyFilters())
const selectedStatus = ref<UnifiedOrderMainStatus | ''>('')
const measurementReviewSelected = ref(false)
const selectedTab = computed(() => measurementReviewSelected.value ? 'MEASUREMENT_REVIEW' : selectedStatus.value)
const statusTabList = ref<HTMLElement | null>(null)
const businessInboundCode = computed(() => route.name === 'unified-order-detail-by-inbound-code' && typeof route.params.inboundCode === 'string'
? route.params.inboundCode.trim()
: '')
@@ -584,7 +588,9 @@ async function waitForSavedMeasurementVersion(tenantId: string, inboundId: strin
const statusTabs = computed(() => [
{ value: '' as const, label: '全部' },
...unifiedOrderStatusOptions,
...unifiedOrderStatusOptions.flatMap((tab) => tab.value === 'WAIT_MEASURE' && can('firstleg:inbound:measurement-review')
? [tab, { value: 'MEASUREMENT_REVIEW' as const, label: '测量数据审核' }]
: [tab]),
])
const warehouseOptions = computed(() => warehouses.value.map((warehouse) => ({
@@ -706,8 +712,11 @@ function optionalBoolean(value: string | null | undefined) {
return undefined
}
function selectStatus(status: UnifiedOrderMainStatus | '') {
if (selectedStatus.value === status) return
function selectStatus(status: UnifiedOrderMainStatus | '' | 'MEASUREMENT_REVIEW') {
if (selectedTab.value === status) return
measurementReviewSelected.value = status === 'MEASUREMENT_REVIEW'
if (measurementReviewSelected.value) selectedOrders.value = []
if (status === 'MEASUREMENT_REVIEW') return
selectedStatus.value = status
if (page.value === 1) void loadOrders()
else page.value = 1
@@ -724,8 +733,9 @@ function handleTabKeydown(event: KeyboardEvent, index: number) {
const tab = statusTabs.value[nextIndex]
if (!tab) return
selectStatus(tab.value)
const buttons = (event.currentTarget as HTMLElement).parentElement?.querySelectorAll<HTMLElement>('[role="tab"]')
buttons?.[nextIndex]?.focus()
void nextTick(() => {
statusTabList.value?.querySelectorAll<HTMLElement>('[role="tab"]')[nextIndex]?.focus()
})
}
function can(permission: string) {
@@ -1574,6 +1584,7 @@ async function loadWarehouses() {
}
async function loadOrders() {
if (measurementReviewSelected.value) return
selectedApprovalOrders.value = []
selectedOrders.value = []
loading.value = true
@@ -2691,7 +2702,27 @@ onBeforeUnmount(() => {
</template>
<template v-else>
<ListPageLayout ariaLabel="统一订单列表">
<div v-if="measurementReviewSelected" id="unified-orders-results" role="tabpanel">
<InboundTaskList mode="review" title="订单列表">
<template #status-tabs>
<div ref="statusTabList" class="status-tabs density-status-tabs" role="tablist" aria-label="订单主状态">
<button
v-for="(tab, index) in statusTabs"
:key="tab.value || 'ALL'"
type="button"
role="tab"
:aria-selected="selectedTab === tab.value"
aria-controls="unified-orders-results"
:tabindex="selectedTab === tab.value ? 0 : -1"
:class="['status-tab', { 'is-active': selectedTab === tab.value }]"
@click="selectStatus(tab.value)"
@keydown="handleTabKeydown($event, index)"
>{{ tab.label }}</button>
</div>
</template>
</InboundTaskList>
</div>
<ListPageLayout v-else ariaLabel="统一订单列表">
@@ -2701,16 +2732,16 @@ onBeforeUnmount(() => {
<template #toolbar-start><h1>订单列表</h1></template>
<template #filters>
<div class="orders-query-panel integrated-query-group">
<div class="status-tabs density-status-tabs" role="tablist" aria-label="订单主状态">
<div ref="statusTabList" class="status-tabs density-status-tabs" role="tablist" aria-label="订单主状态">
<button
v-for="(tab, index) in statusTabs"
:key="tab.value || 'ALL'"
type="button"
role="tab"
:aria-selected="selectedStatus === tab.value"
:aria-selected="selectedTab === tab.value"
aria-controls="unified-orders-results"
:tabindex="selectedStatus === tab.value ? 0 : -1"
:class="['status-tab', { 'is-active': selectedStatus === tab.value }]"
:tabindex="selectedTab === tab.value ? 0 : -1"
:class="['status-tab', { 'is-active': selectedTab === tab.value }]"
@click="selectStatus(tab.value)"
@keydown="handleTabKeydown($event, index)"
>{{ tab.label }}</button>
+1 -1
View File
@@ -10,7 +10,7 @@ import {
describe('unified order status labels', () => {
it('maps order, review and loading statuses to domain-specific Chinese', () => {
expect(unifiedOrderStatusLabel('WAIT_PALLETIZE')).toBe('待一件代发组托')
expect(unifiedOrderStatusLabel('WAIT_PALLETIZE')).toBe('一件代发打托')
expect(unifiedOrderStatusLabel('WAIT_SPLIT')).toBe('待分柜')
expect(unifiedOrderReviewStatusLabel('APPROVED')).toBe('审核通过')
expect(unifiedOrderLoadingStatusLabel('LOADED')).toBe('已配载')
+1 -1
View File
@@ -9,7 +9,7 @@ const orderStatusLabels: Record<string, string> = {
CANCELLED: '已取消',
PENDING_REVIEW: '待审核',
WAIT_MEASURE: '待测量数据',
WAIT_PALLETIZE: '待一件代发组托',
WAIT_PALLETIZE: '一件代发打托',
WAIT_SPLIT: '待分柜',
LOADING: '装柜中',
LOADED: '装柜完成',
+1
View File
@@ -71,6 +71,7 @@ const generalCatalogConsumers = [
'overseas-master-data/components/CapacityInquiryTable.vue',
'overseas-master-data/components/CapacityOfferSettingsDialog.vue',
'overseas-master-data/pages/TripOperationsView.vue',
'overseas-master-data/components/TripOperationsCharts.vue',
'overseas-master-data/pages/TripDetailView.vue',
'overseas-master-data/components/TripPlanDialog.vue',
'overseas-master-data/pages/DriverDetailView.vue',