This commit is contained in:
2026-08-06 13:56:04 +08:00
parent 6736893128
commit a813426588
35 changed files with 2822 additions and 489 deletions
+1 -1
View File
@@ -49,7 +49,7 @@ describe('AdminSidebarMenu', () => {
expect(groupTitles).toContain('系统管理')
expect(groupTitles).not.toContain('财务管理')
expect(paths).toContain('/dashboard')
expect(paths).toContain('/inbounds')
expect(paths).toContain('/orders')
expect(paths).toContain('/master-data/express-companies')
expect(paths).not.toContain('/orders/first-leg')
expect(paths).not.toContain('/first-leg-pricing/customer-rules')
+2
View File
@@ -6,6 +6,7 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { downloadRoutes } from '@/modules/downloads/routes'
import { firstLegOrderRoutes } from '@/modules/first-leg-orders/routes'
import { inboundRoutes } from '@/modules/inbounds/routes'
import { unifiedOrderRoutes } from '@/modules/unified-orders/routes'
import { fbaPricingRoutes } from '@/modules/fba-pricing/routes'
import { iamRoutes } from '@/modules/iam/routes'
import { masterDataRoutes } from '@/modules/master-data/routes'
@@ -43,6 +44,7 @@ const router = createRouter({
...prototypeModuleRoutes,
...customerRoutes,
...warehouseRoutes,
...unifiedOrderRoutes,
...firstLegOrderRoutes,
...inboundRoutes,
...fbaPricingRoutes,
+5 -3
View File
@@ -29,8 +29,8 @@ describe('adminNavigation', () => {
'海外仓阶梯费用',
])
expect(adminNavigation.find((item) => item.key === 'domestic-warehouse')?.children?.map((item) => item.title)).toEqual([
'入库单列表',
'入库单测量',
'订单列表',
'订单测量',
'测量数据审核',
'库位管理',
'托盘管理',
@@ -73,7 +73,9 @@ describe('adminNavigation', () => {
const permissions = leaves.flatMap((item) => item.permission ? [item.permission] : [])
expect(paths).toContain('/dashboard')
expect(paths).toContain('/orders')
expect(paths).not.toContain('/orders/first-leg')
expect(paths).not.toContain('/inbounds')
expect(paths).not.toContain('/first-leg-pricing/customer-rules')
expect(paths).toContain('/downloads')
expect(paths).toContain('/prototype/finance/profit-report')
@@ -110,7 +112,7 @@ describe('adminNavigation', () => {
expect(filtered.map((item) => item.title)).toEqual(['首页数据看板', '国内仓', '供应商中心', '通用'])
expect(paths).toEqual([
'/dashboard',
'/inbounds',
'/orders',
'/master-data/express-companies',
'/master-data/counterparties',
'/warehouses',
+2 -2
View File
@@ -53,8 +53,8 @@ export const adminNavigation: AdminNavigationNode[] = [
icon: WarehouseIcon,
iconColor: '#52c41a',
children: [
{ key: 'inbound-list', title: '入库单列表', icon: ClipboardList, path: '/inbounds', permission: 'platform:firstleg:inbound:list' },
{ key: 'inbound-dedup', title: '入库单测量', icon: ClipboardList, path: '/prototype/domestic-warehouse/inbound-dedup', hidden: true },
{ key: 'order-list', title: '订单列表', icon: ClipboardList, path: '/orders', permission: 'platform:order:list', permissionAnyOf: ['platform:firstleg:inbound:list'] },
{ key: 'inbound-dedup', title: '订单测量', icon: ClipboardList, path: '/prototype/domestic-warehouse/inbound-dedup', hidden: true },
{ key: 'inbound-measure', title: '测量数据审核', icon: FileCheck2, path: '/prototype/domestic-warehouse/inbound-measure', hidden: true },
{ key: 'locations', title: '库位管理', icon: WarehouseIcon, path: '/master-data/locations', permission: 'platform:location:list' },
{ key: 'pallets', title: '托盘管理', icon: WarehouseIcon, path: '/master-data/pallets', permission: 'platform:pallet:list' },
@@ -275,7 +275,7 @@ async function loadCustomerMasterData(customerId: string) {
masterDataLoading.value = true
try {
const [warehouseResult, partnerResult] = await Promise.all([
listPlatformWarehouses(customerId, 1, 200),
listPlatformWarehouses(1, 200),
listPlatformPartners(customerId, { page: 1, size: 200, type: 'CARRIER', status: 'ACTIVE' }),
])
if (sequence !== masterDataSequence) return false
+2 -2
View File
@@ -4,7 +4,7 @@ export const firstLegOrderRoutes = [
{
path: 'orders/first-leg',
name: 'first-leg-orders',
component: () => import('@/modules/first-leg-orders/pages/FirstLegOrdersView.vue'),
meta: { title: '头程订单', description: '创建、受理和跟进客户提交的头程运输订单', permission: 'platform:order:list' },
redirect: '/orders',
meta: { title: '订单列表', description: '旧头程订单入口已合并到统一订单列表', permission: 'platform:order:list' },
},
] satisfies RouteRecordRaw[]
@@ -17,12 +17,12 @@ describe('InboundWorkflowWorkspace', () => {
expect(source).toContain('label="基本信息"')
expect(source).toContain('label="货物与测量"')
expect(source).toContain('label="测量审核"')
expect(source).toContain('label="费用快照"')
expect(source).toContain('label="托盘与记录"')
expect(source).toContain('label="费用信息"')
expect(source).toContain('label="状态与其他"')
})
it('uses the established first-leg detail density without nested framed surfaces', () => {
expect(source).toContain('padding: 20px 24px')
expect(source).toContain('padding: 18px 24px 16px')
expect(source).toContain('border-bottom: 1px solid var(--cc-border)')
expect(source).not.toContain('border-radius: 10px')
expect(source).not.toContain('<el-tag')
@@ -54,7 +54,7 @@ describe('InboundWorkflowWorkspace', () => {
it('keeps pallet and location results read-only in the overview', () => {
expect(source).toContain(':pallet-locations="detail.palletLocations"')
expect(source).toContain('label="托盘与记录"')
expect(source).toContain('label="状态与其他"')
expect(source).not.toContain('reserveLocation')
expect(source).not.toContain('allocatePallet')
})
@@ -106,7 +106,7 @@ onBeforeUnmount(() => {
<header class="workspace-header">
<div>
<div class="workspace-title-row">
<h2>入仓码:{{ title }}</h2>
<h2>入库码:{{ title }}</h2>
<StatusBadge v-if="detail" :status="statusLabel" />
</div>
<p>按基本信息、货物测量、审核、费用和托盘库位顺序处理,所有结果以服务端最新状态为准。</p>
@@ -134,7 +134,7 @@ onBeforeUnmount(() => {
<el-tabs v-else v-model="activeTab" class="workflow-tabs">
<el-tab-pane label="基本信息" name="overview">
<InboundSummaryPanel :summary="detail.summary" />
<InboundSummaryPanel :summary="detail.summary" :public-remark="detail.publicRemark" />
</el-tab-pane>
<el-tab-pane label="货物与测量" name="measurement" lazy>
<div class="measurement-workflow">
@@ -169,7 +169,7 @@ onBeforeUnmount(() => {
show-icon
/>
</el-tab-pane>
<el-tab-pane label="费用快照" name="fee" lazy>
<el-tab-pane label="费用信息" name="fee" lazy>
<InboundFeePanel
:key="`fee-${revision}`"
:tenant-id="customerId"
@@ -177,13 +177,13 @@ onBeforeUnmount(() => {
@changed="handleDomainChanged"
/>
</el-tab-pane>
<el-tab-pane label="托盘与记录" name="records" lazy>
<el-tab-pane label="状态与其他" name="records" lazy>
<InboundHistoryPanel
:pallet-locations="detail.palletLocations"
:files="detail.files"
:internal-notes="detail.internalNotes"
:status-history="detail.statusHistory"
:public-remark="detail.publicRemark"
:public-remark="null"
/>
</el-tab-pane>
</el-tabs>
@@ -192,15 +192,18 @@ onBeforeUnmount(() => {
<style scoped>
.inbound-workspace { min-width: 0; }
.workspace-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding: 20px 24px; border-bottom: 1px solid var(--cc-border); }
.workspace-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding: 18px 24px 16px; border-bottom: 1px solid var(--cc-border); }
.workspace-title-row { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: 10px; }
.workspace-title-row h2 { margin: 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 18px; line-height: 1.35; }
.workspace-title-row h2 { margin: 0; overflow-wrap: anywhere; color: #172033; font-size: 16px; line-height: 1.35; }
.workspace-header p { margin: 5px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
.workspace-state { margin: 24px; }
.workspace-skeleton { padding: 0; }
.workflow-tabs { min-width: 0; padding: 0 24px 24px; }
.workflow-tabs :deep(.el-tabs__header) { margin-bottom: 16px; }
.workflow-tabs :deep(.el-tabs__item) { min-height: 48px; }
.workflow-tabs :deep(.el-tabs__header) { margin-bottom: 20px; }
.workflow-tabs :deep(.el-tabs__nav-wrap::after) { height: 1px; background: var(--cc-border); }
.workflow-tabs :deep(.el-tabs__active-bar) { height: 2px; background: var(--cc-primary); }
.workflow-tabs :deep(.el-tabs__item) { min-height: 44px; padding: 0 18px; color: #475467; font-size: 13px; font-weight: 500; }
.workflow-tabs :deep(.el-tabs__item.is-active) { color: var(--cc-primary); font-weight: 650; }
.measurement-workflow { display: grid; min-width: 0; gap: 28px; }
@media (max-width: 720px) {
.workspace-header { align-items: stretch; flex-direction: column; padding: 16px; }
@@ -51,26 +51,27 @@ function date(value: string | null) {
</template>
<style scoped>
.history-panel { display: grid; gap: 22px; min-width: 0; }
.history-section { min-width: 0; }
.section-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 10px; }
.section-heading h3 { margin: 0; color: var(--cc-text); font-size: 14px; }
.history-panel { display: grid; gap: 24px; min-width: 0; }
.history-section { min-width: 0; padding-top: 16px; border-top: 1px solid var(--cc-border); }
.history-section:first-child { padding-top: 0; border-top: 0; }
.section-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
.section-heading h3 { margin: 0; color: #172033; font-size: 13px; }
.section-heading span { color: var(--cc-muted); font-size: 12px; }
.allocation-list, .simple-list { overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; }
.allocation-row { display: grid; grid-template-columns: 1fr auto minmax(100px, 0.8fr); gap: 12px; align-items: center; padding: 11px 12px; border-bottom: 1px solid var(--cc-border); font-size: 12px; }
.allocation-list, .simple-list { overflow: hidden; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; }
.allocation-row { display: grid; grid-template-columns: 1fr auto minmax(100px, 0.8fr); gap: 12px; align-items: center; padding: 10px 12px; border-bottom: 1px solid #edf0f4; font-size: 12px; }
.allocation-row:last-child, .simple-list > div:last-child { border-bottom: 0; }
.allocation-row > div, .simple-list > div { min-width: 0; }
.allocation-row span, .simple-list span { display: block; margin-top: 3px; color: var(--cc-muted); }
.allocation-location { overflow-wrap: anywhere; color: var(--cc-primary); text-align: right; }
.simple-list > div { padding: 11px 12px; border-bottom: 1px solid var(--cc-border); font-size: 12px; }
.simple-list > div { padding: 10px 12px; border-bottom: 1px solid #edf0f4; font-size: 12px; }
.simple-list strong, .simple-list span { overflow-wrap: anywhere; }
.muted-empty { margin: 0; padding: 18px 12px; border: 1px dashed var(--cc-border); border-radius: 8px; color: var(--cc-muted); font-size: 12px; text-align: center; }
.muted-empty { margin: 0; padding: 18px 12px; border: 1px dashed var(--cc-border); border-radius: 6px; background: #f8fafc; color: var(--cc-muted); font-size: 12px; text-align: center; }
.timeline { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; }
.timeline li { display: grid; grid-template-columns: 16px 1fr; gap: 10px; min-width: 0; padding-bottom: 16px; }
.timeline-dot { width: 8px; height: 8px; margin-top: 5px; border: 2px solid var(--cc-primary); border-radius: 50%; background: #fff; box-shadow: 0 0 0 3px color-mix(in srgb, var(--cc-primary) 12%, transparent); }
.timeline li > div { min-width: 0; padding-bottom: 12px; border-bottom: 1px solid var(--cc-border); }
.timeline li > div { min-width: 0; padding-bottom: 12px; border-bottom: 1px solid #edf0f4; }
.timeline strong, .timeline span, .timeline small { display: block; overflow-wrap: anywhere; }
.timeline span, .timeline small { margin-top: 3px; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
.remark-section p { margin: 0; padding: 12px; border-radius: 8px; background: #f8fafc; color: var(--cc-text); font-size: 13px; line-height: 1.6; white-space: pre-wrap; }
.remark-section p { margin: 0; padding: 12px; border-radius: 6px; background: #f8fafc; color: var(--cc-text); font-size: 13px; line-height: 1.6; white-space: pre-wrap; }
@media (max-width: 480px) { .allocation-row { grid-template-columns: 1fr auto; } .allocation-location { grid-column: 1 / -1; text-align: left; } }
</style>
@@ -14,6 +14,9 @@ describe('InboundSummaryPanel', () => {
expect(source).toContain('summary.chargeableWeightKg')
expect(source).toContain('summary.locationCodes')
expect(source).toContain('summary.notificationStatus')
expect(source).toContain('publicRemark')
expect(source).toContain('买单 / 退税')
expect(source).toContain('计费毛重')
})
it('does not restore excluded receiving review or customer fee confirmation actions', () => {
@@ -2,7 +2,7 @@
import { Box, MapPin, Scale, Warehouse } from '@lucide/vue'
import type { InboundOrderSummary } from './types'
defineProps<{ summary: InboundOrderSummary }>()
withDefaults(defineProps<{ summary: InboundOrderSummary; publicRemark?: string | null }>(), { publicRemark: null })
const processLabels: Record<string, string> = {
WAIT_MEASURE: '待测量', MEASURING: '测量中', MEASURE_PENDING: '待审核', MEASURE_REJECTED: '测量驳回',
@@ -33,8 +33,9 @@ function label(value: string | null, labels: Record<string, string>) { return va
<template>
<section class="summary-panel" aria-label="入库单概览">
<div class="summary-heading">
<div>
<h3>{{ summary.inboundCode || `入库单 ${summary.id}` }}</h3>
<span class="summary-icon" aria-hidden="true"><Box :size="20" /></span>
<div class="summary-title">
<h3>入库码:{{ summary.inboundCode || `入库单 ${summary.id}` }}</h3>
<p class="summary-subtitle">订单 {{ value(summary.orderNo) }} · {{ value(summary.customerName) }}</p>
</div>
<el-tag :type="processType(summary.processStatus)" effect="light">{{ processLabel(summary.processStatus) }}</el-tag>
@@ -50,9 +51,10 @@ function label(value: string | null, labels: Record<string, string>) { return va
<div><dt>公司</dt><dd>{{ value(summary.companyName) }}</dd></div>
<div><dt>业务类型</dt><dd>{{ value(summary.businessType) }}</dd></div>
<div><dt>渠道</dt><dd>{{ value(summary.channelName || summary.channelCode) }}</dd></div>
<div><dt>计税模式</dt><dd>{{ value(summary.taxMode) }}</dd></div>
<div><dt>装载模式</dt><dd>{{ value(summary.containerMode) }}</dd></div>
<div><dt>买单 / 退税</dt><dd>{{ value(summary.taxMode) }}</dd></div>
<div><dt>是否整柜</dt><dd>{{ value(summary.containerMode) }}</dd></div>
<div><dt>商检验</dt><dd>{{ yesNo(summary.customsInspection) }}</dd></div>
<div><dt>备注</dt><dd>{{ value(publicRemark) }}</dd></div>
<div><dt>创建时间</dt><dd>{{ date(summary.createdAt) }}</dd></div>
</dl>
</section>
@@ -79,11 +81,11 @@ function label(value: string | null, labels: Record<string, string>) { return va
<h4 id="inbound-measure-heading">测量与计费</h4>
<dl class="summary-grid">
<div><dt><Box :size="14" />总箱数</dt><dd>{{ value(summary.totalPackageCount) }} 箱</dd></div>
<div><dt><Scale :size="14" />总毛重</dt><dd>{{ number(summary.actualGrossWeightKg) }} kg</dd></div>
<div><dt>总净重</dt><dd>{{ number(summary.actualNetWeightKg) }} kg</dd></div>
<div><dt><Scale :size="14" />总毛重 (T.K.GW)</dt><dd>{{ number(summary.actualGrossWeightKg) }} kg</dd></div>
<div><dt>总净重 (T.K.NW)</dt><dd>{{ number(summary.actualNetWeightKg) }} kg</dd></div>
<div><dt>总体积</dt><dd>{{ number(summary.actualVolumeCbm) }} CBM</dd></div>
<div><dt>总材积重</dt><dd>{{ number(summary.volumeWeightKg) }} kg</dd></div>
<div><dt>计费重量</dt><dd>{{ number(summary.chargeableWeightKg) }} kg</dd></div>
<div><dt>计费毛重</dt><dd>{{ number(summary.chargeableWeightKg) }} kg</dd></div>
<div><dt>计费体积</dt><dd>{{ number(summary.chargeableVolumeCbm) }} CBM</dd></div>
<div><dt>测量审核</dt><dd>{{ label(summary.measureReviewStatus, measureReviewLabels) }}</dd></div>
<div><dt>费用状态</dt><dd>{{ label(summary.feeStatus, feeLabels) }}</dd></div>
@@ -108,17 +110,21 @@ function label(value: string | null, labels: Record<string, string>) { return va
<style scoped>
.summary-panel { min-width: 0; }
.summary-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding-bottom: 18px; border-bottom: 1px solid var(--cc-border); }
h3 { margin: 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 18px; line-height: 1.3; }
.summary-heading { display: grid; grid-template-columns: 36px minmax(0, 1fr) auto; align-items: center; gap: 10px; padding-bottom: 18px; border-bottom: 1px solid var(--cc-border); }
.summary-icon { display: grid; width: 36px; height: 36px; place-items: center; border-radius: 8px; background: #172033; color: #fff; }
.summary-title { min-width: 0; }
h3 { margin: 0; overflow-wrap: anywhere; color: #172033; font-size: 16px; line-height: 1.35; }
.summary-subtitle { margin: 6px 0 0; color: var(--cc-muted); font-size: 12px; }
.summary-sections { display: grid; min-width: 0; gap: 22px; margin-top: 18px; }
.summary-sections section { min-width: 0; }
h4 { margin: 0 0 9px; color: var(--cc-text); font-size: 14px; line-height: 1.4; }
.summary-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 0; border-top: 1px solid var(--cc-border); border-left: 1px solid var(--cc-border); }
.summary-grid > div { min-width: 0; min-height: 66px; padding: 11px 12px; border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }
dt { display: flex; align-items: center; gap: 6px; color: var(--cc-muted); font-size: 12px; }
dd { margin: 5px 0 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 13px; line-height: 1.5; }
.hold-note { margin: 16px 0 0; padding: 10px 12px; border: 1px solid #f5c26b; border-radius: 8px; background: #fffaeb; color: #93370d; font-size: 12px; line-height: 1.5; }
.summary-sections { display: grid; min-width: 0; gap: 24px; margin-top: 18px; }
.summary-sections section { min-width: 0; padding-top: 16px; border-top: 1px solid var(--cc-border); }
.summary-sections section:first-child { padding-top: 0; border-top: 0; }
h4 { margin: 0 0 12px; color: #172033; font-size: 13px; line-height: 1.4; }
.summary-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px 24px; margin: 0; color: #475467; }
.summary-grid > div { display: grid; min-width: 0; grid-template-columns: max-content minmax(0, 1fr); align-items: baseline; gap: 4px; min-height: 26px; line-height: 1.7; }
dt { display: flex; align-items: center; gap: 6px; color: var(--cc-muted); font-size: 12px; white-space: nowrap; }
dt::after { content: ":"; }
dd { margin: 0; overflow-wrap: anywhere; color: #475467; font-size: 13px; line-height: 1.7; }
.hold-note { margin: 16px 0 0; padding: 10px 12px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fffaeb; color: #b54708; font-size: 12px; line-height: 1.5; }
@media (max-width: 900px) { .summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 480px) { .summary-heading { align-items: stretch; flex-direction: column; } .summary-grid { grid-template-columns: 1fr; } }
@media (max-width: 480px) { .summary-heading { grid-template-columns: 36px minmax(0, 1fr); align-items: start; } .summary-heading :deep(.el-tag) { grid-column: 2; justify-self: start; } .summary-grid { grid-template-columns: 1fr; } .summary-grid > div { grid-template-columns: 1fr; gap: 1px; } dt::after { content: ""; } }
</style>
@@ -14,8 +14,8 @@ describe('InboundOrderDetailView', () => {
expect(source).toContain('缺少所属企业信息,请从入库单列表重新打开')
expect(source).not.toContain('PlatformTenantSelector')
expect(source).not.toContain('租户')
expect(source).toContain('<h1>入库详情</h1>')
expect(source).toContain('货物测量、审核、费用及托盘库位记录')
expect(source).toContain('<h1>入库单详情</h1>')
expect(source).toContain('货品与测量、费用信息、状态历史及其他记录')
expect(source).toContain('class="surface-panel detail-page"')
expect(source).toContain('class="detail-page-actions"')
expect(source).toContain('返回入库单列表')
@@ -25,8 +25,8 @@ function backToList() {
<section class="surface-panel detail-page">
<header class="detail-page-header">
<div class="detail-page-heading">
<h1>入库详情</h1>
<p>查看基本信息、货物测量、审核、费用及托盘库位记录。</p>
<h1>入库单详情</h1>
<p>查看基本信息、货品与测量、费用信息、状态历史及其他记录。</p>
</div>
<div class="detail-page-actions">
<el-button @click="backToList"><ArrowLeft :size="16" />返回入库单列表</el-button>
@@ -101,7 +101,7 @@ describe('InboundOrdersView', () => {
await selectCustomer(wrapper)
expect(listPlatformInbounds).toHaveBeenCalledWith('10001', expect.objectContaining({ page: 1, size: 20 }))
expect(listPlatformWarehouses).toHaveBeenCalledWith('10001', 1, 200)
expect(listPlatformWarehouses).toHaveBeenCalledWith(1, 200)
expect(listPlatformTenants).toHaveBeenCalledWith({ page: 1, size: 200 })
expect(document.body.textContent).toContain('RK-001')
expect(document.body.textContent).toContain('查看')
@@ -185,8 +185,10 @@ describe('InboundOrdersView', () => {
expect(tabButtons).toHaveLength(5)
const toolbarRows = wrapper.find('.table-toolbar').element.children
expect(toolbarRows[0].classList.contains('table-tabs-row')).toBe(true)
expect(toolbarRows[1].classList.contains('table-action-row')).toBe(true)
expect(wrapper.find('.table-action-row').text()).toContain('共 1 条 · 所属企业 全部客户')
expect(toolbarRows[1].classList.contains('inbound-stats-row')).toBe(true)
expect(toolbarRows[2].classList.contains('table-action-row')).toBe(true)
expect(wrapper.find('.inbound-stats-row').text()).toContain('当前显示 1 条 · 共 1 条')
expect(wrapper.find('.table-action-row').text()).toContain('所属企业 全部客户')
expect(wrapper.find('.table-action-buttons').text()).toContain('刷新')
expect(wrapper.text()).not.toContain('全部入库单')
expect(tabButtons[0].attributes('aria-selected')).toBe('true')
@@ -214,7 +216,7 @@ describe('InboundOrdersView', () => {
toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), customerId: '10002' })
await flushPromises()
expect(listPlatformInbounds).toHaveBeenLastCalledWith('10002', expect.objectContaining({ page: 1 }))
expect(listPlatformWarehouses).toHaveBeenLastCalledWith('10002', 1, 200)
expect(listPlatformWarehouses).toHaveBeenLastCalledWith(1, 200)
})
it('resets to all customers without sending an empty backend tenant ID', async () => {
@@ -116,7 +116,7 @@ const columns: DataTableColumn[] = [
{ key: 'orderStatus', label: '订单 / 流程状态', minWidth: 156, slotName: 'orderStatus' },
{ key: 'measureReviewStatus', label: '测量数据审核状态', minWidth: 142, slotName: 'measureReviewStatus' },
{ key: 'expectedArrivalAt', label: '预计到达时间', minWidth: 164, slotName: 'expectedArrivalAt' },
{ key: 'feeStatus', label: '费用状态', minWidth: 124, slotName: 'feeStatus' },
{ key: 'feeStatus', label: '费用审核状态', minWidth: 132, slotName: 'feeStatus' },
{ key: 'customerName', label: '客户', minWidth: 142, slotName: 'customerName' },
{ key: 'warehouseName', label: '仓库', minWidth: 140, slotName: 'warehouseName' },
{ key: 'deliveryWarehouseName', label: '交货仓', minWidth: 140, slotName: 'deliveryWarehouseName' },
@@ -128,20 +128,20 @@ const columns: DataTableColumn[] = [
{ key: 'companyName', label: '公司', minWidth: 160, slotName: 'companyName' },
{ key: 'businessType', label: '业务类型', minWidth: 112, slotName: 'businessType' },
{ key: 'channelName', label: '渠道', minWidth: 138, slotName: 'channelName' },
{ key: 'taxMode', label: '计税模式', minWidth: 108, slotName: 'taxMode' },
{ key: 'containerMode', label: '装载模式', minWidth: 108, slotName: 'containerMode' },
{ key: 'taxMode', label: '买单/退税', minWidth: 108, slotName: 'taxMode' },
{ key: 'containerMode', label: '是否整柜', minWidth: 108, slotName: 'containerMode' },
{ key: 'customsInspection', label: '商检验', width: 92, slotName: 'customsInspection' },
{ key: 'sailingAt', label: '开船时间', minWidth: 164, slotName: 'sailingAt' },
{ key: 'portArrivalAt', label: '到港时间', minWidth: 164, slotName: 'portArrivalAt' },
{ key: 'totalPackageCount', label: '总箱数', width: 90, slotName: 'totalPackageCount' },
{ key: 'actualGrossWeightKg', label: '总毛重(kg)', minWidth: 118, slotName: 'actualGrossWeightKg' },
{ key: 'chargeableWeightKg', label: '计费重量(kg)', minWidth: 126, slotName: 'chargeableWeightKg' },
{ key: 'actualVolumeCbm', label: '总体积(CBM)', minWidth: 122, slotName: 'actualVolumeCbm' },
{ key: 'chargeableVolumeCbm', label: '计费体积(CBM)', minWidth: 132, slotName: 'chargeableVolumeCbm' },
{ key: 'actualGrossWeightKg', label: '总毛重(T.K.GW)', minWidth: 136, slotName: 'actualGrossWeightKg' },
{ key: 'chargeableWeightKg', label: '计费毛重', minWidth: 112, slotName: 'chargeableWeightKg' },
{ key: 'actualVolumeCbm', label: '总体积(m³)', minWidth: 118, slotName: 'actualVolumeCbm' },
{ key: 'chargeableVolumeCbm', label: '计费体积(m³)', minWidth: 128, slotName: 'chargeableVolumeCbm' },
{ key: 'palletCount', label: '总托盘数', width: 100, slotName: 'palletCount' },
{ key: 'locationCodes', label: '库位位置', minWidth: 124, slotName: 'locationCodes' },
{ key: 'volumeWeightKg', label: '总材积重(kg)', minWidth: 126, slotName: 'volumeWeightKg' },
{ key: 'actualNetWeightKg', label: '总净重(kg)', minWidth: 118, slotName: 'actualNetWeightKg' },
{ key: 'volumeWeightKg', label: '总材积重', minWidth: 110, slotName: 'volumeWeightKg' },
{ key: 'actualNetWeightKg', label: '总净重(T.K.NW)', minWidth: 136, slotName: 'actualNetWeightKg' },
{ key: 'destinationType', label: '目的仓类型', minWidth: 112, slotName: 'destinationType' },
{ key: 'destinationName', label: '目的仓', minWidth: 132, slotName: 'destinationName' },
{ key: 'measureCompletedAt', label: '测量完成时间', minWidth: 164, slotName: 'measureCompletedAt' },
@@ -156,6 +156,16 @@ const selectedCustomerName = computed(() => {
return customer?.legalName?.trim() || customer?.tenantName?.trim() || '全部客户'
})
const listEmptyText = '暂无符合条件的入库单'
const currentPageCount = computed(() => result.value.records.length)
const aggregateStats = computed(() => {
const records = result.value.records
return {
packages: records.reduce((total, item) => total + (item.totalPackageCount || 0), 0),
grossWeight: records.reduce((total, item) => total + (item.actualGrossWeightKg || 0), 0),
chargeableWeight: records.reduce((total, item) => total + (item.chargeableWeightKg || 0), 0),
volume: records.reduce((total, item) => total + (item.actualVolumeCbm || 0), 0),
}
})
function queryValue(value: string) { return value.trim() || undefined }
function handleTabKeydown(event: KeyboardEvent, currentIndex: number) {
@@ -186,7 +196,7 @@ async function loadWarehouseOptions() {
}
warehousesLoading.value = true
try {
const response = await listPlatformWarehouses(selectedCustomerId.value, 1, 200)
const response = await listPlatformWarehouses(1, 200)
if (disposed || requestId !== warehouseRequestId) return
warehouseOptions.value = response.records.map((warehouse) => ({
label: `${warehouse.warehouseName} · ${warehouse.warehouseCode}`,
@@ -297,6 +307,10 @@ function openWorkspace(row: InboundOrderSummary) {
void router.push({ name: 'inbound-order-detail', params: { inboundId: row.id }, query: { tenantId: row.tenantId } })
}
function formatNumber(value: number | null, digits = 3) { return value === null ? '—' : value.toFixed(digits) }
function formatAggregate(value: number, digits = 3) {
if (!Number.isFinite(value)) return '0'
return value.toLocaleString('zh-CN', { maximumFractionDigits: digits })
}
function formatDate(value: string | null) {
if (!value) return '—'
const date = new Date(value)
@@ -390,8 +404,20 @@ onBeforeUnmount(() => { disposed = true; loadRequestId += 1; detailRequestId +=
<button v-for="(item, index) in tabs" :key="item.value || 'all'" type="button" role="tab" class="inbound-tab" :class="{ 'is-active': tab === item.value }" :aria-selected="tab === item.value" :tabindex="tab === item.value ? 0 : -1" :title="item.description" @click="tab = item.value" @keydown="handleTabKeydown($event, index)">{{ item.label }}</button>
</nav>
</div>
<div class="inbound-stats-row" aria-label="入库单统计">
<span class="selected-info">当前显示 {{ currentPageCount }} 条 · 共 {{ result.total }} 条</span>
<span class="total-info">
<b>总箱数 {{ formatAggregate(aggregateStats.packages, 0) }}</b>
<i aria-hidden="true" />
<b>总毛重 {{ formatAggregate(aggregateStats.grossWeight) }} kg</b>
<i aria-hidden="true" />
<b>计费重量 {{ formatAggregate(aggregateStats.chargeableWeight) }} kg</b>
<i aria-hidden="true" />
<b>总体积 {{ formatAggregate(aggregateStats.volume) }} m³</b>
</span>
</div>
<div class="table-action-row">
<span class="result-summary">共 {{ result.total }} 条 · 所属企业 {{ selectedCustomerName }}</span>
<span class="result-summary">所属企业 {{ selectedCustomerName }}</span>
<div class="table-action-buttons">
<el-button :loading="loading" @click="loadInbounds"><RefreshCw :size="16" />刷新</el-button>
</div>
@@ -442,37 +468,50 @@ onBeforeUnmount(() => { disposed = true; loadRequestId += 1; detailRequestId +=
<AppDrawer v-model="detailOpen" :title="detail ? `入库单详情 · ${detail.summary.inboundCode || detail.summary.id}` : '入库单详情'" size="min(720px, 94vw)" :loading="detailLoading" :show-footer="false" destroy-on-close @closed="closeDetail">
<section v-if="detailError" class="detail-error" role="alert"><strong>入库单详情暂时无法加载</strong><p>{{ detailError.message }}</p><ApiErrorMeta :details="detailError" /><el-button type="primary" :disabled="!detailTarget" @click="detailTarget && openDetail(detailTarget)">重新加载</el-button></section>
<div v-else-if="detail" class="detail-content"><InboundSummaryPanel :summary="detail.summary" /><InboundHistoryPanel :pallet-locations="detail.palletLocations" :files="detail.files" :internal-notes="detail.internalNotes" :status-history="detail.statusHistory" :public-remark="detail.publicRemark" /></div>
<div v-else-if="detail" class="detail-content"><InboundSummaryPanel :summary="detail.summary" :public-remark="detail.publicRemark" /><InboundHistoryPanel :pallet-locations="detail.palletLocations" :files="detail.files" :internal-notes="detail.internalNotes" :status-history="detail.statusHistory" :public-remark="null" /></div>
</AppDrawer>
</div>
</template>
<style scoped>
.inbound-orders-page { min-width: 0; }
.inbound-tabs { display: flex; width: max-content; max-width: 100%; min-width: 0; overflow-x: auto; padding: 4px; border: 1px solid var(--cc-border); border-radius: 8px; background: #f7f9fc; scrollbar-width: none; }
.inbound-query-toolbar { align-items: stretch; border-radius: 8px; background: #f8fafc; }
.inbound-query-toolbar :deep(.query-toolbar__filters) { display: grid; flex: 1 1 auto; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px 16px; }
.inbound-query-toolbar :deep(.query-toolbar__filters .el-input),
.inbound-query-toolbar :deep(.query-toolbar__filters .el-select),
.inbound-query-toolbar :deep(.query-toolbar__filters .el-date-editor) { width: 100%; }
.inbound-query-toolbar :deep(.query-toolbar__actions) { align-content: flex-end; align-items: flex-end; }
.inbound-tabs { display: flex; width: max-content; max-width: 100%; min-width: 0; overflow-x: auto; border-bottom: 2px solid var(--cc-border); scrollbar-width: none; }
.inbound-tabs::-webkit-scrollbar { display: none; }
.inbound-tab { min-height: 44px; flex: 0 0 auto; padding: 8px 18px; border: 1px solid transparent; border-radius: 6px; background: transparent; color: var(--cc-muted); cursor: pointer; font-size: 13px; line-height: 1.4; white-space: nowrap; transition: background-color .16s ease, border-color .16s ease, color .16s ease, box-shadow .16s ease; }
.inbound-tab:hover { background: #fff; color: var(--cc-primary); }
.inbound-tab { min-height: 42px; flex: 0 0 auto; padding: 10px 22px; border: 0; border-bottom: 2px solid transparent; margin-bottom: -2px; background: transparent; color: #475467; cursor: pointer; font-size: 13px; line-height: 1.4; white-space: nowrap; transition: border-color .16s ease, color .16s ease, background-color .16s ease; }
.inbound-tab:hover { color: var(--cc-primary); background: #f8fbff; }
.inbound-tab:focus-visible { outline: 3px solid rgba(102, 126, 234, .2); outline-offset: 1px; }
.inbound-tab.is-active { border-color: #d8e0f0; background: #fff; color: var(--cc-primary); box-shadow: 0 1px 3px rgba(16, 24, 40, .08); font-weight: 650; }
.inbound-tab.is-active { border-bottom-color: var(--cc-primary); background: #fff; color: var(--cc-primary); font-weight: 650; }
.inbound-load-error { margin-top: 12px; }
.inbound-query-toolbar { border-radius: 8px; }
.row-actions { display: flex; align-items: center; gap: 2px; white-space: nowrap; }
.row-actions :deep(.el-button) { margin-left: 0; }
.table-toolbar { display: grid; min-width: 0; border-bottom: 1px solid var(--cc-border); }
.table-tabs-row { min-width: 0; padding: 10px 14px; }
.table-action-row { display: flex; min-width: 0; min-height: 56px; align-items: center; justify-content: space-between; gap: 16px; padding: 8px 14px; border-top: 1px solid var(--cc-border); background: #fbfcfe; }
.table-tabs-row { min-width: 0; padding: 0 14px; background: #fff; }
.inbound-stats-row { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 16px; padding: 10px 14px; background: var(--cc-primary); color: #fff; }
.inbound-stats-row .selected-info, .inbound-stats-row .total-info { min-width: 0; font-size: 12px; font-weight: 650; line-height: 1.5; }
.inbound-stats-row .total-info { display: flex; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.inbound-stats-row b { font-weight: 650; }
.inbound-stats-row i { width: 1px; height: 12px; background: rgba(255, 255, 255, .38); }
.table-action-row { display: flex; min-width: 0; min-height: 48px; align-items: center; justify-content: space-between; gap: 16px; padding: 7px 14px; border-top: 1px solid #dfe6f0; background: #fbfcfe; }
.table-action-buttons { display: flex; min-width: 0; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.result-summary { color: var(--cc-muted); font-size: 12px; white-space: nowrap; }
.table-action-buttons :deep(.el-button) { min-height: 40px; margin-left: 0; }
.table-action-buttons :deep(.el-button) { min-height: 34px; margin-left: 0; }
.table-panel :deep(.el-table th.el-table__cell) { border-right: 1px solid #edf0f4; }
.table-panel :deep(.el-table td.el-table__cell) { border-right: 1px solid #edf0f4; }
.primary-cell, .status-stack { display: grid; min-width: 0; justify-items: start; gap: 4px; }
.primary-cell strong, .primary-cell span, .status-stack span, .status-stack small { overflow-wrap: anywhere; }
.primary-cell span, .status-stack span, .status-stack small { color: var(--cc-muted); font-size: 12px; }
.detail-content { display: grid; gap: 24px; min-width: 0; }
.detail-error { display: flex; min-height: 240px; align-items: flex-start; justify-content: center; flex-direction: column; }
.detail-error strong { color: var(--cc-danger); font-size: 15px; }
.detail-error p { margin: 7px 0; color: var(--cc-text); font-size: 13px; }
.detail-error strong { color: #b42318; font-size: 16px; }
.detail-error p { margin: 7px 0; color: #172033; font-size: 13px; }
.detail-error :deep(.el-button) { margin-top: 14px; }
@media (max-width: 900px) { .inbound-tabs { width: 100%; } }
@media (max-width: 720px) { .inbound-tab { padding-inline: 14px; } .table-tabs-row { padding: 10px; } .table-action-row { min-height: 0; align-items: stretch; flex-direction: column; gap: 10px; padding: 10px; } .table-action-buttons { justify-content: flex-start; } .result-summary { min-width: 0; overflow: hidden; text-overflow: ellipsis; } .table-action-buttons :deep(.el-button) { min-height: 44px; } }
@media (max-width: 1180px) { .inbound-query-toolbar :deep(.query-toolbar__filters) { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@media (max-width: 900px) { .inbound-tabs { width: 100%; } .inbound-query-toolbar :deep(.query-toolbar__filters) { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 720px) { .inbound-query-toolbar :deep(.query-toolbar__filters) { grid-template-columns: 1fr; } .inbound-tab { padding-inline: 14px; } .table-tabs-row { padding: 0 10px; } .inbound-stats-row, .table-action-row { min-height: 0; align-items: stretch; flex-direction: column; gap: 10px; padding: 10px; } .inbound-stats-row .total-info { justify-content: flex-start; } .table-action-buttons { justify-content: flex-start; } .result-summary { min-width: 0; overflow: hidden; text-overflow: ellipsis; } .table-action-buttons :deep(.el-button) { min-height: 44px; } }
</style>
+3 -3
View File
@@ -4,10 +4,10 @@ export const inboundRoutes = [
{
path: 'inbounds',
name: 'inbound-orders',
component: () => import('./pages/InboundOrdersView.vue'),
redirect: '/orders',
meta: {
title: '入库单',
description: '查询头程入库单并跟进测量、审核、费用和入库进度',
title: '订单列表',
description: '旧入库列表入口已合并到统一订单列表',
permission: 'platform:firstleg:inbound:list',
},
},
@@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { getPlatformUnifiedOrder, listPlatformUnifiedOrders } from './unifiedOrders'
vi.mock('@/shared/api/http', () => ({
http: { get: vi.fn() },
unwrapApiResponse: <T>(response: { data: T }) => response.data,
}))
describe('unified orders API contract', () => {
beforeEach(() => vi.mocked(http.get).mockReset())
it('uses the C02 platform orders endpoint and contract filters', async () => {
vi.mocked(http.get).mockResolvedValue({ data: { records: [], page: '2', size: '20', total: '0', pages: '0' } })
await expect(listPlatformUnifiedOrders({
keyword: 'CC', orderNo: 'CC-1', mainStatus: 'WAIT_SPLIT', customerId: '30001',
receivingWarehouseId: '11', destinationWarehouseId: '22', page: 2, size: 20,
})).resolves.toMatchObject({ page: 2, size: 20, total: 0, pages: 0 })
expect(http.get).toHaveBeenCalledWith('/platform/v1/orders', {
params: {
keyword: 'CC', orderNo: 'CC-1', mainStatus: 'WAIT_SPLIT', customerId: '30001',
receivingWarehouseId: '11', destinationWarehouseId: '22', page: 2, size: 20,
},
})
})
it('normalizes C02 row IDs and keeps inboundId for measurement routing', async () => {
vi.mocked(http.get).mockResolvedValue({ data: {
records: [{ orderId: '90001', tenantId: '208', inboundId: '77', inboundVersion: 3, mainStatus: 'WAIT_MEASURE', measurement: null, preload: null, loading: null, delivery: null, transport: null }],
page: 1, size: 20, total: 1, pages: 1,
} })
await expect(listPlatformUnifiedOrders({ page: 1, size: 20 })).resolves.toMatchObject({
records: [{ orderId: '90001', tenantId: '208', inboundId: '77', inboundVersion: 3 }],
})
})
it('loads the C02 detail shape and passes tenantId only on the detail boundary', async () => {
vi.mocked(http.get).mockResolvedValue({ data: {
summary: { orderId: '90001', tenantId: '208', mainStatus: 'WAIT_SPLIT' },
order: { order: {}, cargoes: [], parties: [], internalNotes: [], statusHistory: [] },
timeline: [],
} })
await expect(getPlatformUnifiedOrder('208', '90001')).resolves.toMatchObject({
summary: { orderId: '90001' }, order: { internalNotes: [] }, timeline: [],
})
expect(http.get).toHaveBeenCalledWith('/platform/v1/orders/90001', { params: { tenantId: '208' } })
})
it('does not expose a guessed generic action endpoint', async () => {
expect(http).not.toHaveProperty('post')
})
})
@@ -0,0 +1,264 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export const unifiedOrdersApiPath = '/platform/v1/orders'
export type UnifiedOrderMainStatus =
| 'PENDING_REVIEW'
| 'WAIT_MEASURE'
| 'INBOUNDED'
| 'WAIT_SPLIT'
| 'LOADING'
| 'LOADED'
| 'DELIVERED'
export const unifiedOrderStatusOptions: Array<{ value: UnifiedOrderMainStatus; label: string }> = [
{ value: 'PENDING_REVIEW', label: '待审核' },
{ value: 'WAIT_MEASURE', label: '待测量数据' },
{ value: 'INBOUNDED', label: '已入库' },
{ value: 'WAIT_SPLIT', label: '待分柜' },
{ value: 'LOADING', label: '装柜中' },
{ value: 'LOADED', label: '装柜完成' },
{ value: 'DELIVERED', label: '派送完成' },
]
export interface UnifiedOrderMeasurementSummary {
reviewStatus: string | null
actualGrossWeightKg: number | null
actualVolumeCbm: number | null
chargeableWeightKg: number | null
completedAt: string | null
}
export interface UnifiedOrderPreloadSummary {
status: string | null
version: number | null
itemCount: number | null
updatedAt: string | null
}
export interface UnifiedOrderLoadingSummary {
status: string | null
loadingVersion: number | null
pickingVersion: number | null
expectedCount: number | null
pickedCount: number | null
evidenceCount: number | null
sealNo: string | null
updatedAt: string | null
}
export interface UnifiedOrderDeliverySummary {
status: string | null
completedAt: string | null
trackingNo: string | null
}
export interface UnifiedOrderTransportSummary {
loadingStatus: string | null
containerNo: string | null
cabinetNo: string | null
vesselName: string | null
voyageNo: string | null
sailingAt: string | null
portArrivalAt: string | null
}
export interface UnifiedOrderResource {
orderId: number | string
tenantId: number | string
orderNo: string | null
mainStatus: UnifiedOrderMainStatus
nextAction: string | null
internalOrderStatus: string | null
customerId: number | string | null
customerName: string | null
channelName: string | null
receivingWarehouseId: number | string | null
receivingWarehouseName: string | null
destinationWarehouseId: number | string | null
destinationWarehouseName: string | null
inboundId: number | string | null
inboundVersion: number | null
inboundCode: string | null
expectedShipDate: string | null
totalPackageCount: number | null
measurement: UnifiedOrderMeasurementSummary | null
preload: UnifiedOrderPreloadSummary | null
loading: UnifiedOrderLoadingSummary | null
delivery: UnifiedOrderDeliverySummary | null
transport: UnifiedOrderTransportSummary | null
createdAt: string | null
updatedAt: string | null
}
export interface UnifiedOrderTimelineEvent {
source: string | null
eventType: string | null
mainStatus: UnifiedOrderMainStatus | null
action: string | null
remark: string | null
operatorId: number | string | null
operatorName: string | null
occurredAt: string | null
}
export interface UnifiedOrderDetailOrderSnapshot {
id: number | string | null
tenantId: number | string | null
orderNo: string | null
customerId: number | string | null
customerName: string | null
transportMode: string | null
destinationCountryCode: string | null
destinationWarehouseId: number | string | null
destinationWarehouseName: string | null
expectedShipDate: string | null
receivingWarehouseId: number | string | null
receivingWarehouseName: string | null
channelName: string | null
createdAt: string | null
updatedAt: string | null
}
export interface UnifiedOrderDetailOrderResource {
order: UnifiedOrderDetailOrderSnapshot | null
cargoes: Array<Record<string, unknown>>
parties: Array<Record<string, unknown>>
internalNotes: UnifiedOrderInternalNoteResource[]
statusHistory: UnifiedOrderStatusHistoryResource[]
}
export interface UnifiedOrderInternalNoteResource {
id: number | string
content: string
createdBy: number | string | null
createdAt: string | null
}
export interface UnifiedOrderStatusHistoryResource {
id: number | string
fromStatus: string | null
toStatus: string | null
action: string | null
reason: string | null
operatorId: number | string | null
operatorName: string | null
occurredAt: string | null
}
export interface UnifiedOrderDetailResource {
summary: UnifiedOrderResource
order: UnifiedOrderDetailOrderResource
timeline: UnifiedOrderTimelineEvent[]
}
export interface UnifiedOrderPageResource {
records: UnifiedOrderResource[]
page: number
size: number
total: number
pages: number
}
export interface UnifiedOrderQuery {
tenantId?: string
keyword?: string
orderNo?: string
mainStatus?: UnifiedOrderMainStatus
customerId?: string
receivingWarehouseId?: string
destinationWarehouseId?: string
createdFrom?: string
createdTo?: string
page: number
size: number
}
type PagePayload = Omit<UnifiedOrderPageResource, 'page' | 'size' | 'total' | 'pages'> & {
page: number | string
size: number | string
total: number | string
pages: number | string
}
function integer(value: number | string | null | undefined, fallback: number) {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed >= 0 ? Math.trunc(parsed) : fallback
}
function nullableInteger(value: number | string | null | undefined) {
if (value === null || value === undefined || value === '') return null
return integer(value, 0)
}
function numberValue(value: number | string | null | undefined) {
if (value === null || value === undefined || value === '') return null
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
function id(value: number | string | null | undefined) {
return value === null || value === undefined || value === '' ? null : String(value)
}
function queryParams(query: UnifiedOrderQuery) {
return Object.fromEntries(Object.entries(query).filter(([, value]) => value !== undefined && value !== ''))
}
function normalizeRow(raw: UnifiedOrderResource): UnifiedOrderResource {
return {
...raw,
orderId: String(raw.orderId),
tenantId: String(raw.tenantId),
customerId: id(raw.customerId),
receivingWarehouseId: id(raw.receivingWarehouseId),
destinationWarehouseId: id(raw.destinationWarehouseId),
inboundId: id(raw.inboundId),
inboundVersion: nullableInteger(raw.inboundVersion),
totalPackageCount: nullableInteger(raw.totalPackageCount),
measurement: raw.measurement ? {
...raw.measurement,
actualGrossWeightKg: numberValue(raw.measurement.actualGrossWeightKg),
actualVolumeCbm: numberValue(raw.measurement.actualVolumeCbm),
chargeableWeightKg: numberValue(raw.measurement.chargeableWeightKg),
} : null,
preload: raw.preload ? { ...raw.preload, version: nullableInteger(raw.preload.version), itemCount: nullableInteger(raw.preload.itemCount) } : null,
loading: raw.loading ? {
...raw.loading,
loadingVersion: nullableInteger(raw.loading.loadingVersion),
pickingVersion: nullableInteger(raw.loading.pickingVersion),
expectedCount: nullableInteger(raw.loading.expectedCount),
pickedCount: nullableInteger(raw.loading.pickedCount),
evidenceCount: nullableInteger(raw.loading.evidenceCount),
} : null,
transport: raw.transport ? { ...raw.transport } : null,
}
}
export async function listPlatformUnifiedOrders(query: UnifiedOrderQuery) {
const response = await http.get<ApiEnvelope<PagePayload>, ApiEnvelope<PagePayload>>(unifiedOrdersApiPath, {
params: queryParams(query),
})
const result = unwrapApiResponse(response)
return {
...result,
records: (result.records || []).map(normalizeRow),
page: integer(result.page, query.page),
size: integer(result.size, query.size),
total: integer(result.total, 0),
pages: integer(result.pages, 0),
} satisfies UnifiedOrderPageResource
}
export async function getPlatformUnifiedOrder(tenantId: string, orderId: string) {
const response = await http.get<ApiEnvelope<UnifiedOrderDetailResource>, ApiEnvelope<UnifiedOrderDetailResource>>(
`${unifiedOrdersApiPath}/${orderId}`,
{ params: { tenantId } },
)
const result = unwrapApiResponse(response)
return { ...result, summary: normalizeRow(result.summary) }
}
export function unifiedOrderStatusLabel(status: UnifiedOrderMainStatus) {
return unifiedOrderStatusOptions.find((item) => item.value === status)?.label || status
}
@@ -0,0 +1,51 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { unifiedOrderStatusOptions } from '../api/unifiedOrders'
const source = readFileSync(resolve(process.cwd(), 'src/modules/unified-orders/pages/UnifiedOrdersView.vue'), 'utf8')
describe('UnifiedOrdersView contract', () => {
it('only exposes the seven product main statuses', () => {
expect(unifiedOrderStatusOptions.map((item) => item.label)).toEqual([
'待审核', '待测量数据', '已入库', '待分柜', '装柜中', '装柜完成', '派送完成',
])
for (const legacyLabel of ['待客户确认', '差异处理中', '派送中', '进入分柜']) {
expect(source).not.toContain(legacyLabel)
}
})
it('covers real list states and keeps operation column first and left-fixed', () => {
expect(source).toContain("{ key: 'actions', label: '操作', width: 180, fixed: 'left'")
expect(source).toContain('title="订单列表暂时无法加载"')
expect(source).toContain('empty-text="暂无符合条件的订单"')
expect(source).toContain(':loading="loading"')
expect(source).toContain('v-model:page="page"')
expect(source).toContain('v-model:page-size="pageSize"')
})
it('keeps business actions as explicit C01/C03/C04/C06 boundaries', () => {
expect(source).toContain('nextActionBoundary')
expect(source).toContain('待接入:${label}')
expect(source).toContain('CONFIRM_PRELOAD')
expect(source).toContain("can('platform:firstleg:inbound:view')")
expect(source).toContain('openMeasurement')
expect(source).not.toContain('updateMainStatus')
expect(source).not.toContain('人工进入分柜')
expect(source).not.toContain('performPlatformUnifiedOrderAction')
expect(source).not.toContain('/actions/')
})
it('provides unified detail, history and internal note states', () => {
expect(source).toContain('getPlatformUnifiedOrder')
expect(source).toContain('aria-label="状态历史"')
expect(source).toContain('aria-label="内部备注"')
expect(source).toContain('订单详情暂时无法加载')
})
it('exposes the C06 workflow entry only for inbound-backed downstream states', () => {
expect(source).toContain("name: 'unified-order-workflow'")
expect(source).toContain("['WAIT_SPLIT', 'LOADING', 'LOADED'].includes(row.mainStatus)")
expect(source).toContain('后半程作业')
})
})
@@ -0,0 +1,327 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ClipboardList, Eye, RefreshCw } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
import {
getPlatformUnifiedOrder,
listPlatformUnifiedOrders,
unifiedOrderStatusOptions,
type UnifiedOrderDetailResource,
type UnifiedOrderMainStatus,
type UnifiedOrderResource,
} from '@/modules/unified-orders/api/unifiedOrders'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
type FilterModel = QueryModel & {
tenantId: string
keyword: string
mainStatus: UnifiedOrderMainStatus | ''
createdRange: string[]
}
const route = useRoute()
const router = useRouter()
const access = useAccessPreviewStore()
const records = ref<UnifiedOrderResource[]>([])
const customers = ref<PlatformTenantResource[]>([])
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const customerLoading = ref(false)
const customerLoadError = ref<ApiErrorDetails | null>(null)
const detail = ref<UnifiedOrderDetailResource | null>(null)
const detailLoading = ref(false)
const detailError = ref<ApiErrorDetails | null>(null)
const detailOpen = ref(false)
const filters = ref<FilterModel>({ tenantId: '', keyword: '', mainStatus: '', createdRange: [] })
const orderId = computed(() => typeof route.params.orderId === 'string' ? route.params.orderId : '')
const queryFields = computed<QueryField[]>(() => [
{
key: 'tenantId',
type: 'select',
width: 220,
placeholder: customerLoadError.value ? '客户列表加载失败' : customerLoading.value ? '正在加载客户' : '全部客户',
options: customers.value.map((customer) => ({
value: String(customer.id),
label: `${customer.legalName?.trim() || customer.tenantName}(${customer.tenantCode || '无客户编码'})`,
})),
props: { 'aria-label': '客户', filterable: true, loading: customerLoading.value, disabled: Boolean(customerLoadError.value) },
},
{
key: 'mainStatus',
type: 'select',
width: 160,
placeholder: '全部主状态',
options: unifiedOrderStatusOptions,
props: { 'aria-label': '订单主状态' },
},
{ key: 'keyword', type: 'input', width: 260, placeholder: '订单号、入仓码或客户名称', props: { 'aria-label': '订单关键词' } },
{ key: 'createdRange', type: 'daterange', width: 300, placeholder: '创建日期范围', props: { 'aria-label': '创建日期范围' } },
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' },
{ key: 'order', label: '订单信息', minWidth: 220, slotName: 'order' },
{ key: 'mainStatus', label: '主状态', width: 132, slotName: 'mainStatus' },
{ key: 'customer', label: '客户', minWidth: 180, slotName: 'customer' },
{ key: 'receivingWarehouseName', label: '平台收货仓', minWidth: 160, slotName: 'receivingWarehouseName' },
{ key: 'destinationWarehouseName', label: '目的仓', minWidth: 160, slotName: 'destinationWarehouseName' },
{ key: 'transport', label: '运输/装柜', minWidth: 180, slotName: 'transport' },
{ key: 'cargo', label: '货物数据', minWidth: 180, slotName: 'cargo' },
{ key: 'expectedShipDate', label: '预计出运', minWidth: 150, slotName: 'expectedShipDate' },
{ key: 'updatedAt', label: '最近更新', minWidth: 150, slotName: 'updatedAt' },
]
function can(permission: string) {
return access.can(permission)
}
function customerName(row: UnifiedOrderResource) {
return row.customerName?.trim() || customers.value.find((item) => String(item.id) === String(row.tenantId))?.tenantName || `客户 ${row.tenantId}`
}
function formatDate(value: string | null | undefined) {
return value ? value.replace('T', ' ').slice(0, 16) : '—'
}
function formatNumber(value: number | null | undefined, digits = 2) {
return value === null || value === undefined ? '—' : value.toLocaleString('zh-CN', { maximumFractionDigits: digits })
}
function statusLabel(value: UnifiedOrderMainStatus | string | null) {
return unifiedOrderStatusOptions.find((item) => item.value === value)?.label || value || '状态更新'
}
const nextActionLabels: Record<string, string> = {
APPROVE_ORDER: '审核通过',
CONFIRM_MEASUREMENT: '完成测量',
SMART_SPLIT: '智能分柜',
CONFIRM_PRELOAD: '确认预装清单',
COMPLETE_LOADING: '确认装柜完成',
COMPLETE_DELIVERY: '确认派送完成',
}
function nextActionBoundary(row: UnifiedOrderResource) {
const label = row.nextAction ? nextActionLabels[row.nextAction] || row.nextAction : ''
return label ? `待接入:${label}` : ''
}
async function loadCustomers() {
customerLoading.value = true
customerLoadError.value = null
try {
const records: PlatformTenantResource[] = []
let currentPage = 1
let pages = 1
do {
const result = await listPlatformTenants({ page: currentPage, size: 200 })
records.push(...result.records)
pages = Math.max(1, result.pages)
currentPage += 1
} while (currentPage <= pages)
customers.value = records
} catch (error) {
customers.value = []
customerLoadError.value = getApiErrorDetails(error, '客户列表加载失败,暂时无法按客户筛选')
} finally {
customerLoading.value = false
}
}
async function loadOrders() {
loading.value = true
loadError.value = null
try {
const range = filters.value.createdRange
const result = await listPlatformUnifiedOrders({
tenantId: filters.value.tenantId || undefined,
keyword: filters.value.keyword.trim() || undefined,
mainStatus: filters.value.mainStatus || undefined,
createdFrom: range.length === 2 ? `${range[0]}T00:00:00` : undefined,
createdTo: range.length === 2 ? `${range[1]}T23:59:59` : undefined,
page: page.value,
size: pageSize.value,
})
records.value = result.records
total.value = result.total
} catch (error) {
records.value = []
total.value = 0
loadError.value = getApiErrorDetails(error, '统一订单列表加载失败,请检查 C02 统一查询接口和订单权限')
} finally {
loading.value = false
}
}
function search() {
if (page.value !== 1) {
page.value = 1
return
}
void loadOrders()
}
function reset() {
filters.value = { tenantId: '', keyword: '', mainStatus: '', createdRange: [] }
page.value = 1
void loadOrders()
}
function openDetail(row: UnifiedOrderResource) {
void router.push({ name: 'unified-order-detail', params: { orderId: String(row.orderId) }, query: { tenantId: String(row.tenantId) } })
}
function openMeasurement(row: UnifiedOrderResource) {
if (!row.inboundId || !can('platform:firstleg:inbound:view')) return
void router.push({ name: 'inbound-order-detail', params: { inboundId: String(row.inboundId) }, query: { tenantId: String(row.tenantId) } })
}
function workflowEligible(row: UnifiedOrderResource) {
return Boolean(row.inboundId && ['WAIT_SPLIT', 'LOADING', 'LOADED'].includes(row.mainStatus))
}
function openWorkflow(row: UnifiedOrderResource) {
if (!workflowEligible(row) || !can('platform:firstleg:inbound:view')) return
void router.push({
name: 'unified-order-workflow',
params: { orderId: String(row.orderId) },
query: { tenantId: String(row.tenantId) },
})
}
function closeDetail() {
detailOpen.value = false
detail.value = null
detailError.value = null
void router.push({ name: 'unified-orders' })
}
async function loadDetail() {
if (!orderId.value) return
const tenantId = typeof route.query.tenantId === 'string' ? route.query.tenantId : ''
if (!tenantId) {
detailError.value = { message: '订单详情缺少客户标识,请从订单列表重新打开' }
detailOpen.value = true
return
}
detailLoading.value = true
detailError.value = null
detailOpen.value = true
try {
detail.value = await getPlatformUnifiedOrder(tenantId, orderId.value)
} catch (error) {
detail.value = null
detailError.value = getApiErrorDetails(error, '统一订单详情加载失败,请检查 C02 统一详情接口和订单权限')
} finally {
detailLoading.value = false
}
}
watch([page, pageSize], () => { void loadOrders() })
watch(orderId, (value) => {
if (value) void loadDetail()
else detailOpen.value = false
}, { immediate: true })
onMounted(() => {
void loadCustomers()
void loadOrders()
})
</script>
<template>
<section class="unified-orders-page">
<div class="page-intro">
<div>
<h1>订单列表</h1>
<p>平台围绕同一订单主线跟进审核、测量、分柜、装柜和派送。</p>
</div>
<el-button :loading="loading" @click="loadOrders"><RefreshCw :size="16" />刷新</el-button>
</div>
<QueryToolbar v-model="filters" ariaLabel="订单列表筛选" :fields="queryFields" :search-on-reset="false" @search="search" @reset="reset" />
<LoadErrorState v-if="loadError" class="orders-load-error" title="订单列表暂时无法加载" :message="loadError.message" :loading="loading" @retry="loadOrders">
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
<div v-else class="table-card">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="records" :columns="columns" :loading="loading" :total="total" :page-sizes="[20, 50, 100]" row-key="orderId" empty-text="暂无符合条件的订单" aria-label="统一订单列表">
<template #actions="{ row }">
<div class="row-actions">
<el-button link type="primary" @click="openDetail(row as UnifiedOrderResource)"><Eye :size="15" />详情</el-button>
<el-button v-if="(row as UnifiedOrderResource).mainStatus === 'WAIT_MEASURE' && (row as UnifiedOrderResource).inboundId && can('platform:firstleg:inbound:view')" link type="primary" @click="openMeasurement(row as UnifiedOrderResource)">测量作业</el-button>
<el-button v-if="workflowEligible(row as UnifiedOrderResource) && can('platform:firstleg:inbound:view')" link type="primary" @click="openWorkflow(row as UnifiedOrderResource)"><ClipboardList :size="15" />后半程作业</el-button>
<span v-if="nextActionBoundary(row as UnifiedOrderResource)" class="action-boundary">{{ nextActionBoundary(row as UnifiedOrderResource) }}</span>
</div>
</template>
<template #order="{ row }"><div class="primary-cell"><strong>{{ (row as UnifiedOrderResource).orderNo || `订单 ${(row as UnifiedOrderResource).orderId}` }}</strong><span v-if="(row as UnifiedOrderResource).inboundCode">入仓码 {{ (row as UnifiedOrderResource).inboundCode }}</span></div></template>
<template #mainStatus="{ row }"><StatusBadge :status="statusLabel((row as UnifiedOrderResource).mainStatus)" /></template>
<template #customer="{ row }"><div class="primary-cell"><strong>{{ customerName(row as UnifiedOrderResource) }}</strong><span>客户 ID {{ (row as UnifiedOrderResource).tenantId }}</span></div></template>
<template #receivingWarehouseName="{ row }">{{ (row as UnifiedOrderResource).receivingWarehouseName || '—' }}</template>
<template #destinationWarehouseName="{ row }">{{ (row as UnifiedOrderResource).destinationWarehouseName || '—' }}</template>
<template #transport="{ row }"><div class="primary-cell"><strong>{{ (row as UnifiedOrderResource).transport?.containerNo || (row as UnifiedOrderResource).transport?.loadingStatus || '—' }}</strong><span v-if="(row as UnifiedOrderResource).transport?.vesselName">{{ (row as UnifiedOrderResource).transport?.vesselName }} · {{ (row as UnifiedOrderResource).transport?.voyageNo || '—' }}</span></div></template>
<template #cargo="{ row }"><span>{{ (row as UnifiedOrderResource).totalPackageCount ?? '—' }} 箱</span><span v-if="(row as UnifiedOrderResource).measurement?.actualGrossWeightKg !== null && (row as UnifiedOrderResource).measurement?.actualGrossWeightKg !== undefined"> · {{ formatNumber((row as UnifiedOrderResource).measurement?.actualGrossWeightKg) }} kg</span><span v-if="(row as UnifiedOrderResource).measurement?.actualVolumeCbm !== null && (row as UnifiedOrderResource).measurement?.actualVolumeCbm !== undefined"> · {{ formatNumber((row as UnifiedOrderResource).measurement?.actualVolumeCbm, 3) }} m³</span></template>
<template #expectedShipDate="{ row }">{{ formatDate((row as UnifiedOrderResource).expectedShipDate) }}</template>
<template #updatedAt="{ row }">{{ formatDate((row as UnifiedOrderResource).updatedAt) }}</template>
</DataTable>
</div>
<AppDrawer v-model="detailOpen" :title="detail ? `订单详情 · ${detail.summary.orderNo || detail.summary.orderId}` : '订单详情'" size="min(760px, 94vw)" :loading="detailLoading" :show-footer="false" destroy-on-close @closed="closeDetail">
<LoadErrorState v-if="detailError" title="订单详情暂时无法加载" :message="detailError.message" :loading="detailLoading" @retry="loadDetail"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<div v-else-if="detail" class="detail-content">
<section class="detail-summary" aria-label="订单概览">
<div class="detail-summary__header"><div><span class="eyebrow">订单主状态</span><h2>{{ detail.summary.orderNo || `订单 ${detail.summary.orderId}` }}</h2></div><div class="detail-summary__actions"><StatusBadge :status="statusLabel(detail.summary.mainStatus)" /><el-button v-if="workflowEligible(detail.summary) && can('platform:firstleg:inbound:view')" type="primary" plain @click="openWorkflow(detail.summary)"><ClipboardList :size="15" />后半程作业</el-button></div></div>
<dl class="detail-grid"><div><dt>客户</dt><dd>{{ customerName(detail.summary) }}</dd></div><div><dt>入仓码</dt><dd>{{ detail.summary.inboundCode || '—' }}</dd></div><div><dt>平台收货仓</dt><dd>{{ detail.summary.receivingWarehouseName || '—' }}</dd></div><div><dt>目的仓</dt><dd>{{ detail.summary.destinationWarehouseName || '—' }}</dd></div><div><dt>下一步</dt><dd>{{ nextActionBoundary(detail.summary) || '—' }}</dd></div><div><dt>最近更新</dt><dd>{{ formatDate(detail.summary.updatedAt) }}</dd></div></dl>
</section>
<section class="detail-section" aria-label="状态历史"><h3>状态历史</h3><el-empty v-if="detail.timeline.length === 0" description="暂无状态历史" :image-size="56" /><ol v-else class="history-list"><li v-for="(item, index) in detail.timeline" :key="`${item.source || 'event'}-${item.occurredAt || index}`"><StatusBadge :status="statusLabel(item.mainStatus)" /><div><strong>{{ item.action || item.eventType || '状态更新' }}</strong><p>{{ item.operatorName || '系统' }} · {{ formatDate(item.occurredAt) }}</p><p v-if="item.remark">{{ item.remark }}</p></div></li></ol></section>
<section class="detail-section" aria-label="内部备注"><h3>内部备注</h3><el-empty v-if="detail.order.internalNotes.length === 0" description="暂无内部备注" :image-size="56" /><ul v-else class="notes-list"><li v-for="item in detail.order.internalNotes" :key="String(item.id)"><p>{{ item.content }}</p><span>操作人 {{ item.createdBy || '系统' }} · {{ formatDate(item.createdAt) }}</span></li></ul></section>
</div>
</AppDrawer>
</section>
</template>
<style scoped>
.unified-orders-page { min-width: 0; }
.page-intro { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
.page-intro h1 { margin: 0; color: var(--cc-text); font-size: 22px; line-height: 1.35; }
.page-intro p { margin: 5px 0 0; color: var(--cc-muted); font-size: 13px; line-height: 1.6; }
.page-intro :deep(.el-button) { flex: 0 0 auto; }
.orders-load-error { margin-top: 12px; }
.table-card { margin-top: 16px; overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
.row-actions { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
.row-actions :deep(.el-button) { margin-left: 0; }
.primary-cell { display: grid; min-width: 0; gap: 4px; }
.primary-cell strong { overflow-wrap: anywhere; }
.primary-cell span { color: var(--cc-muted); font-size: 12px; overflow-wrap: anywhere; }
.detail-content { display: grid; gap: 22px; min-width: 0; }
.detail-summary, .detail-section { padding: 16px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
.detail-summary__header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.detail-summary__actions { display: flex; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.eyebrow { color: var(--cc-muted); font-size: 12px; }
.detail-summary h2 { margin: 4px 0 0; color: var(--cc-text); font-size: 18px; }
.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px 20px; margin: 20px 0 0; }
.detail-grid div { min-width: 0; }
.detail-grid dt { color: var(--cc-muted); font-size: 12px; }
.detail-grid dd { margin: 4px 0 0; color: var(--cc-text); overflow-wrap: anywhere; }
.detail-section h3 { margin: 0 0 12px; color: var(--cc-text); font-size: 15px; }
.history-list, .notes-list { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; }
.history-list li { display: flex; align-items: flex-start; gap: 10px; padding-bottom: 12px; border-bottom: 1px solid #edf0f4; }
.history-list li:last-child, .notes-list li:last-child { padding-bottom: 0; border-bottom: 0; }
.history-list strong { font-size: 13px; }
.history-list p, .notes-list p { margin: 3px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; }
.notes-list li { padding-bottom: 12px; border-bottom: 1px solid #edf0f4; }
.notes-list li > p { margin: 0; color: var(--cc-text); font-size: 13px; }
.notes-list span { display: block; margin-top: 5px; color: var(--cc-muted); font-size: 12px; }
@media (max-width: 720px) { .page-intro { align-items: stretch; flex-direction: column; } .page-intro :deep(.el-button) { width: 100%; min-height: 44px; } .detail-grid { grid-template-columns: 1fr; } .detail-summary__header { flex-direction: column; } .detail-summary__actions { justify-content: flex-start; } .row-actions { flex-wrap: wrap; } }
</style>
+37
View File
@@ -0,0 +1,37 @@
import type { RouteRecordRaw } from 'vue-router'
export const unifiedOrderRoutes = [
{
path: 'orders',
name: 'unified-orders',
component: () => import('./pages/UnifiedOrdersView.vue'),
meta: {
title: '订单列表',
description: '围绕同一订单主线跟进审核、测量、分柜、装柜和派送',
permission: 'platform:order:list',
permissionAnyOf: ['platform:firstleg:inbound:list'],
},
},
{
path: 'orders/:orderId/workflow',
name: 'unified-order-workflow',
component: () => import('./workflows/pages/UnifiedOrderWorkflowView.vue'),
meta: {
title: '订单后半程作业',
description: '处理分柜、预装、装柜和派送作业',
permission: 'platform:firstleg:inbound:view',
permissionAnyOf: ['platform:firstleg:inbound:list', 'platform:order:list'],
},
},
{
path: 'orders/:orderId',
name: 'unified-order-detail',
component: () => import('./pages/UnifiedOrdersView.vue'),
meta: {
title: '订单详情',
description: '查看订单主状态、状态历史和平台备注',
permission: 'platform:order:view',
permissionAnyOf: ['platform:order:list', 'platform:firstleg:inbound:view'],
},
},
] satisfies RouteRecordRaw[]
@@ -0,0 +1,16 @@
# C06 Admin 后半程作业
本目录只承载统一订单详情的后半程作业接入点,供 C05 统一订单列表在详情抽屉或作业页中装配。
真实接口均使用 `/api/platform/v1/inbounds/{inboundId}`:
- `POST /split/smart`:智能分柜/保存预装清单
- `GET /preload`、`POST /preload/confirming`、`POST /preload/confirm`:预装清单查询与确认
- `GET/POST /loading/picking`、`/loading/picking/start`、`/loading/picking/complete`:拣货
- `GET/POST /loading/evidence`:四步照片与封签证据元数据
- `POST /loading/complete`:完成装柜
- `POST /delivery/complete`:确认派送完成
所有写请求带 `Idempotency-Key` 和服务端要求的 `expectedVersion`。`loading/complete` 与 `delivery/complete` 使用入库单版本;拣货使用拣货任务版本。当前没有 File 服务上传接口,证据组件不会把本地路径伪装成已上传文件。
权限码、状态流转和请求字段以 Firstleg Controller/DTO 为准;本模块不新增“进入分柜”操作,也不定义通用 `/actions/{ACTION}` 接口。
@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import {
completeDelivery,
completeLoading,
confirmingPreload,
getPicking,
getPreload,
smartSplit,
} from './api'
vi.mock('@/shared/api/http', () => ({
http: { get: vi.fn(), post: vi.fn() },
unwrapApiResponse: <T>(response: { data: T }) => response.data,
}))
describe('unified order workflow API contract', () => {
beforeEach(() => {
vi.mocked(http.get).mockReset()
vi.mocked(http.post).mockReset()
})
it('uses the C03 preload endpoints and expected version', async () => {
vi.mocked(http.get).mockResolvedValue({ data: { id: 1, inboundId: 8, tenantId: 2, status: 'PENDING', version: 3, items: [] } })
vi.mocked(http.post).mockResolvedValue({ data: { mainStatus: 'WAIT_SPLIT', preloadPlan: null } })
await getPreload('2', '8')
await smartSplit('2', '8', { expectedVersion: 3, items: [] })
expect(http.get).toHaveBeenCalledWith('/platform/v1/inbounds/8/preload', { params: { tenantId: '2' } })
expect(http.post).toHaveBeenCalledWith(
'/platform/v1/inbounds/8/split/smart', { expectedVersion: 3, items: [] },
expect.objectContaining({ params: { tenantId: '2' }, headers: { 'Idempotency-Key': expect.any(String) } }),
)
})
it('uses C04 picking and loading paths without a generic action endpoint', async () => {
vi.mocked(http.get).mockResolvedValue({ data: { id: 1, inboundId: 8, status: 'PICKING', version: 4 } })
vi.mocked(http.post).mockResolvedValue({ data: { mainStatus: 'LOADED', evidences: null } })
await getPicking('2', '8')
await completeLoading('2', '8', { expectedVersion: 7 })
expect(http.get).toHaveBeenCalledWith('/platform/v1/inbounds/8/loading/picking', { params: { tenantId: '2' } })
expect(http.post).toHaveBeenCalledWith(
'/platform/v1/inbounds/8/loading/complete', { expectedVersion: 7 },
expect.objectContaining({ params: { tenantId: '2' }, headers: { 'Idempotency-Key': expect.any(String) } }),
)
expect(vi.mocked(http.post).mock.calls.some(([url]) => String(url).includes('/actions/'))).toBe(false)
})
it('uses the inbound version for delivery completion', async () => {
vi.mocked(http.post).mockResolvedValue({ data: { mainStatus: 'DELIVERED', evidences: null } })
await completeDelivery('2', '8', { expectedVersion: 9, remark: '已确认派送' })
expect(http.post).toHaveBeenCalledWith(
'/platform/v1/inbounds/8/delivery/complete', { expectedVersion: 9, remark: '已确认派送' },
expect.objectContaining({ params: { tenantId: '2' }, headers: { 'Idempotency-Key': expect.any(String) } }),
)
})
it('does not swallow non-success responses', async () => {
vi.mocked(http.get).mockRejectedValue(new Error('无权限'))
await expect(getPreload('2', '8')).rejects.toThrow('无权限')
})
it('supports the intermediate confirming preload action', async () => {
vi.mocked(http.post).mockResolvedValue({ data: { mainStatus: 'WAIT_SPLIT', preloadPlan: null } })
await confirmingPreload('2', '8', { expectedVersion: 1 })
expect(http.post).toHaveBeenCalledWith(
'/platform/v1/inbounds/8/preload/confirming', { expectedVersion: 1 },
expect.objectContaining({ params: { tenantId: '2' } }),
)
})
})
+229
View File
@@ -0,0 +1,229 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
const inboundBasePath = '/platform/v1/inbounds'
export type PreloadStatus = 'PENDING' | 'CONFIRMING' | 'CONFIRMED' | string
export type EvidenceType = 'PHOTO_STEP_1' | 'PHOTO_STEP_2' | 'PHOTO_STEP_3' | 'PHOTO_STEP_4' | 'SEAL'
export interface PreloadItem {
containerCode: string | null
cargoReference: string | null
packageCount: number | null
volumeCbm: number | null
remark: string | null
}
export interface PreloadPlan {
id: number | string
inboundId: number | string
tenantId: number | string
status: PreloadStatus
version: number
items: PreloadItem[]
remark: string | null
createdAt: string | null
updatedAt: string | null
}
export interface SplitActionResponse {
mainStatus: string
preloadPlan: PreloadPlan | null
}
export interface PickingTask {
id: number | string
inboundId: number | string
status: string
version: number
expectedCount: number | null
pickedCount: number | null
remark: string | null
}
export interface Evidence {
id: number | string
inboundId: number | string
tenantId: number | string
evidenceType: EvidenceType
fileId: number | string | null
fileName: string | null
sealNo: string | null
remark: string | null
version: number
createdAt: string | null
updatedAt: string | null
}
export interface EvidenceList {
inboundId: number | string
evidences: Evidence[]
}
export interface LoadingActionResponse {
mainStatus: string
evidences: EvidenceList | null
}
export interface PickingRequest {
expectedVersion: number
expectedCount: number
pickedCount: number
remark?: string
}
export interface SmartSplitRequest {
expectedVersion: number
items: PreloadItem[]
remark?: string
}
export interface ConfirmPreloadRequest {
expectedVersion: number
}
export interface UpsertEvidenceRequest {
expectedVersion: number
evidenceType: EvidenceType
fileId?: number | string
fileName?: string
sealNo?: string
remark?: string
}
export interface CompleteLoadingRequest {
expectedVersion: number
remark?: string
}
export interface CompleteDeliveryRequest {
expectedVersion: number
remark?: string
}
function query(tenantId: string | number) {
return { tenantId: String(tenantId) }
}
function idempotencyKey(operation: string, inboundId: string | number) {
return `admin-${operation}-${inboundId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
}
function writeOptions(operation: string, inboundId: string | number, tenantId: string | number) {
return { params: query(tenantId), headers: { 'Idempotency-Key': idempotencyKey(operation, inboundId) } }
}
export async function getPreload(tenantId: string | number, inboundId: string | number) {
const response = await http.get<ApiEnvelope<PreloadPlan>, ApiEnvelope<PreloadPlan>>(
`${inboundBasePath}/${inboundId}/preload`, { params: query(tenantId) },
)
return unwrapApiResponse(response)
}
export async function smartSplit(
tenantId: string | number,
inboundId: string | number,
request: SmartSplitRequest,
) {
const response = await http.post<ApiEnvelope<SplitActionResponse>, ApiEnvelope<SplitActionResponse>>(
`${inboundBasePath}/${inboundId}/split/smart`, request, writeOptions('smart-split', inboundId, tenantId),
)
return unwrapApiResponse(response)
}
export async function confirmingPreload(
tenantId: string | number,
inboundId: string | number,
request: ConfirmPreloadRequest,
) {
const response = await http.post<ApiEnvelope<SplitActionResponse>, ApiEnvelope<SplitActionResponse>>(
`${inboundBasePath}/${inboundId}/preload/confirming`, request,
writeOptions('preload-confirming', inboundId, tenantId),
)
return unwrapApiResponse(response)
}
export async function confirmPreload(
tenantId: string | number,
inboundId: string | number,
request: ConfirmPreloadRequest,
) {
const response = await http.post<ApiEnvelope<SplitActionResponse>, ApiEnvelope<SplitActionResponse>>(
`${inboundBasePath}/${inboundId}/preload/confirm`, request,
writeOptions('preload-confirm', inboundId, tenantId),
)
return unwrapApiResponse(response)
}
export async function getPicking(tenantId: string | number, inboundId: string | number) {
const response = await http.get<ApiEnvelope<PickingTask>, ApiEnvelope<PickingTask>>(
`${inboundBasePath}/${inboundId}/loading/picking`, { params: query(tenantId) },
)
return unwrapApiResponse(response)
}
export async function startPicking(
tenantId: string | number,
inboundId: string | number,
request: PickingRequest,
) {
const response = await http.post<ApiEnvelope<PickingTask>, ApiEnvelope<PickingTask>>(
`${inboundBasePath}/${inboundId}/loading/picking/start`, request,
writeOptions('picking-start', inboundId, tenantId),
)
return unwrapApiResponse(response)
}
export async function completePicking(
tenantId: string | number,
inboundId: string | number,
request: PickingRequest,
) {
const response = await http.post<ApiEnvelope<PickingTask>, ApiEnvelope<PickingTask>>(
`${inboundBasePath}/${inboundId}/loading/picking/complete`, request,
writeOptions('picking-complete', inboundId, tenantId),
)
return unwrapApiResponse(response)
}
export async function getEvidence(tenantId: string | number, inboundId: string | number) {
const response = await http.get<ApiEnvelope<EvidenceList>, ApiEnvelope<EvidenceList>>(
`${inboundBasePath}/${inboundId}/loading/evidence`, { params: query(tenantId) },
)
return unwrapApiResponse(response)
}
export async function upsertEvidence(
tenantId: string | number,
inboundId: string | number,
request: UpsertEvidenceRequest,
) {
const response = await http.post<ApiEnvelope<LoadingActionResponse>, ApiEnvelope<LoadingActionResponse>>(
`${inboundBasePath}/${inboundId}/loading/evidence`, request,
writeOptions(`evidence-${request.evidenceType}`, inboundId, tenantId),
)
return unwrapApiResponse(response)
}
export async function completeLoading(
tenantId: string | number,
inboundId: string | number,
request: CompleteLoadingRequest,
) {
const response = await http.post<ApiEnvelope<LoadingActionResponse>, ApiEnvelope<LoadingActionResponse>>(
`${inboundBasePath}/${inboundId}/loading/complete`, request,
writeOptions('loading-complete', inboundId, tenantId),
)
return unwrapApiResponse(response)
}
export async function completeDelivery(
tenantId: string | number,
inboundId: string | number,
request: CompleteDeliveryRequest,
) {
const response = await http.post<ApiEnvelope<LoadingActionResponse>, ApiEnvelope<LoadingActionResponse>>(
`${inboundBasePath}/${inboundId}/delivery/complete`, request,
writeOptions('delivery-complete', inboundId, tenantId),
)
return unwrapApiResponse(response)
}
@@ -0,0 +1,388 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { RefreshCw, Save, Check, Play, Camera, Truck } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { ApiResponseError } from '@/shared/api/http'
import {
completeDelivery,
completeLoading,
completePicking,
confirmingPreload,
confirmPreload,
getEvidence,
getPicking,
getPreload,
smartSplit,
startPicking,
upsertEvidence,
type Evidence,
type EvidenceType,
type PickingTask,
type PreloadItem,
type PreloadPlan,
} from '@/modules/unified-orders/workflows/api'
import type { UnifiedOrderMainStatus } from '@/modules/unified-orders/api/unifiedOrders'
const props = withDefaults(defineProps<{
tenantId: string
inboundId: string | number
mainStatus: UnifiedOrderMainStatus
inboundVersion?: number | null
pickingVersion?: number | null
deliveryTrackingNo?: string | null
}>(), {
inboundVersion: null,
pickingVersion: null,
deliveryTrackingNo: null,
})
const emit = defineEmits<{ updated: [] }>()
const access = useAccessPreviewStore()
const preload = ref<PreloadPlan | null>(null)
const preloadItems = ref<PreloadItem[]>([])
const picking = ref<PickingTask | null>(null)
const evidences = ref<Evidence[]>([])
const deliveryRemark = ref('')
const loadingRemark = ref('')
const splitRemark = ref('')
const busy = ref('')
const loading = ref(false)
const error = ref('')
const notice = ref('')
const evidenceTypes: Array<{ type: EvidenceType; label: string }> = [
{ type: 'PHOTO_STEP_1', label: '装柜照片 1' },
{ type: 'PHOTO_STEP_2', label: '装柜照片 2' },
{ type: 'PHOTO_STEP_3', label: '装柜照片 3' },
{ type: 'PHOTO_STEP_4', label: '装柜照片 4' },
{ type: 'SEAL', label: '封签' },
]
const isSplit = computed(() => props.mainStatus === 'WAIT_SPLIT')
const isLoading = computed(() => props.mainStatus === 'LOADING')
const isLoaded = computed(() => props.mainStatus === 'LOADED')
const preloadVersion = computed(() => preload.value?.version ?? 0)
const pickingExpectedVersion = computed(() => picking.value?.version ?? props.pickingVersion ?? null)
const inboundExpectedVersion = computed(() => props.inboundVersion)
const canSplit = computed(() => access.can('firstleg:inbound:split'))
const canPreload = computed(() => access.can('firstleg:inbound:preload'))
const canPreloadView = computed(() => access.can('firstleg:inbound:preload-view'))
const canPicking = computed(() => access.can('platform:firstleg:inbound:picking:edit'))
const canPickingView = computed(() => access.can('platform:firstleg:inbound:picking:view'))
const canEvidence = computed(() => access.can('platform:firstleg:inbound:loading:edit'))
const canEvidenceView = computed(() => access.can('platform:firstleg:inbound:loading:view'))
const canLoadingComplete = computed(() => access.can('platform:firstleg:inbound:loading:complete'))
const canDeliveryComplete = computed(() => access.can('platform:firstleg:inbound:delivery:complete'))
function itemTemplate(): PreloadItem {
return { containerCode: '', cargoReference: '', packageCount: null, volumeCbm: 0, remark: '' }
}
function addItem() {
preloadItems.value.push(itemTemplate())
}
function removeItem(index: number) {
preloadItems.value.splice(index, 1)
}
function evidence(type: EvidenceType) {
return evidences.value.find((item) => item.evidenceType === type)
}
function idempotentMessage(action: string) {
return busy.value === action ? '正在提交,请勿重复点击' : ''
}
async function run<T>(action: string, operation: () => Promise<T>, message: string) {
busy.value = action
error.value = ''
notice.value = ''
try {
const result = await operation()
notice.value = message
emit('updated')
return result
} catch (cause) {
error.value = cause instanceof Error ? cause.message : '操作失败,请重试'
return null
} finally {
busy.value = ''
}
}
async function loadPreload() {
if (!canPreloadView.value || !props.inboundId) return
loading.value = true
error.value = ''
try {
preload.value = await getPreload(props.tenantId, props.inboundId)
preloadItems.value = preload.value.items?.map((item) => ({ ...item })) || []
} catch (cause) {
preload.value = null
preloadItems.value = []
const missing = cause instanceof ApiResponseError
&& (cause.status === 404 || cause.code === 'A0404' || cause.code === 'NOT_FOUND')
error.value = missing ? '' : cause instanceof Error ? cause.message : '预装清单加载失败,请重试'
} finally {
loading.value = false
}
}
async function loadLoading() {
loading.value = true
error.value = ''
const messages: string[] = []
if (canPickingView.value) {
try {
picking.value = await getPicking(props.tenantId, props.inboundId)
} catch (cause) {
picking.value = null
messages.push(cause instanceof Error ? cause.message : '拣货任务加载失败')
}
}
if (canEvidenceView.value) {
try {
evidences.value = (await getEvidence(props.tenantId, props.inboundId)).evidences || []
} catch (cause) {
evidences.value = []
messages.push(cause instanceof Error ? cause.message : '装柜证据加载失败')
}
}
error.value = messages.join(';')
loading.value = false
}
async function refresh() {
if (isSplit.value) await loadPreload()
if (isLoading.value) await loadLoading()
}
async function runSmartSplit() {
const result = await run('smart-split', () => smartSplit(props.tenantId, props.inboundId, {
expectedVersion: preloadVersion.value,
items: preloadItems.value,
remark: splitRemark.value || undefined,
}), '智能分柜已提交')
if (result) {
preload.value = result.preloadPlan
preloadItems.value = result.preloadPlan?.items?.map((item) => ({ ...item })) || []
}
}
async function startPreloadConfirmation() {
const result = await run('preload-confirming', () => confirmingPreload(props.tenantId, props.inboundId, {
expectedVersion: preloadVersion.value,
}), '预装清单已进入确认中')
if (result?.preloadPlan) preload.value = result.preloadPlan
}
async function finishPreloadConfirmation() {
const result = await run('preload-confirm', () => confirmPreload(props.tenantId, props.inboundId, {
expectedVersion: preloadVersion.value,
}), '预装清单已确认,订单进入装柜中')
if (result?.preloadPlan) preload.value = result.preloadPlan
}
function pickingRequest(): { expectedVersion: number; expectedCount: number; pickedCount: number; remark?: string } | null {
if (pickingExpectedVersion.value === null) {
error.value = '拣货版本缺失,请刷新订单详情后重试'
return null
}
return {
expectedVersion: pickingExpectedVersion.value,
expectedCount: picking.value?.expectedCount ?? 0,
pickedCount: picking.value?.pickedCount ?? 0,
remark: loadingRemark.value || undefined,
}
}
async function startPickingTask() {
const request = pickingRequest()
if (!request) return
const result = await run('picking-start', () => startPicking(props.tenantId, props.inboundId, request), '拣货任务已开始')
if (result) picking.value = result
}
async function completePickingTask() {
const request = pickingRequest()
if (!request) return
const result = await run('picking-complete', () => completePicking(props.tenantId, props.inboundId, request), '拣货已完成')
if (result) picking.value = result
}
async function saveEvidence(type: EvidenceType) {
const current = evidence(type)
const result = await run(`evidence-${type}`, () => upsertEvidence(props.tenantId, props.inboundId, {
expectedVersion: current?.version ?? 0,
evidenceType: type,
fileId: current?.fileId || undefined,
fileName: current?.fileName || undefined,
sealNo: current?.sealNo || undefined,
remark: current?.remark || undefined,
}), `${type === 'SEAL' ? '封签' : '证据'}已保存`)
if (result?.evidences) evidences.value = result.evidences.evidences || []
}
async function finishLoading() {
const expectedVersion = inboundExpectedVersion.value
if (expectedVersion === null) {
error.value = '入库单版本缺失,刷新订单详情后才能确认装柜完成'
return
}
const result = await run('loading-complete', () => completeLoading(props.tenantId, props.inboundId, {
expectedVersion,
remark: loadingRemark.value || undefined,
}), '装柜完成已提交')
if (result) await refresh()
}
async function finishDelivery() {
const expectedVersion = inboundExpectedVersion.value
if (expectedVersion === null) {
error.value = '入库单版本缺失,刷新订单详情后才能确认派送完成'
return
}
await run('delivery-complete', () => completeDelivery(props.tenantId, props.inboundId, {
expectedVersion,
remark: deliveryRemark.value || undefined,
}), '派送完成已提交')
}
function updateEvidenceField(type: EvidenceType, field: 'fileName' | 'sealNo' | 'remark', value: string) {
const current = evidence(type)
if (current) {
current[field] = value || null
return
}
evidences.value.push({
id: `draft-${type}`,
inboundId: props.inboundId,
tenantId: props.tenantId,
evidenceType: type,
fileId: null,
fileName: field === 'fileName' ? value : null,
sealNo: field === 'sealNo' ? value : null,
remark: field === 'remark' ? value : null,
version: 0,
createdAt: null,
updatedAt: null,
})
}
watch(() => props.mainStatus, () => { void refresh() })
onMounted(() => { void refresh() })
</script>
<template>
<section class="workflow-panel" aria-label="订单后半程作业">
<div class="workflow-heading">
<div>
<h2>后半程作业</h2>
<p>订单按待分柜、装柜中、装柜完成和派送完成推进;资料可修改,客户确认不阻断。</p>
</div>
<el-button text :loading="loading" aria-label="刷新作业数据" @click="refresh"><RefreshCw :size="15" />刷新</el-button>
</div>
<el-alert v-if="error" class="workflow-alert" type="error" :closable="false" show-icon>
<template #title>{{ error }}</template>
<el-button link type="danger" @click="refresh">重试</el-button>
</el-alert>
<el-alert v-if="notice" class="workflow-alert" type="success" :closable="false" show-icon>{{ notice }}</el-alert>
<el-alert v-if="busy" class="workflow-alert" type="info" :closable="false">{{ idempotentMessage(busy) }}</el-alert>
<template v-if="isSplit">
<el-card shadow="never" class="workflow-card">
<template #header><div class="card-title"><span>预装清单</span><el-tag>{{ preload?.status || '待确认' }}</el-tag></div></template>
<el-empty v-if="!preload && !loading" description="暂无预装清单,可直接生成分柜方案" />
<div class="preload-items" role="group" aria-label="预装清单明细">
<div v-for="(item, index) in preloadItems" :key="index" class="preload-row">
<el-input v-model="item.containerCode" aria-label="柜号" placeholder="柜号" />
<el-input v-model="item.cargoReference" aria-label="货物引用" placeholder="货物引用" />
<el-input-number v-model="item.packageCount" aria-label="件数" :min="1" controls-position="right" />
<el-input-number v-model="item.volumeCbm" aria-label="体积" :min="0" :precision="3" controls-position="right" />
<el-button text type="danger" aria-label="删除预装项" @click="removeItem(index)">删除</el-button>
</div>
<el-button plain @click="addItem">新增预装项</el-button>
<el-input v-model="splitRemark" class="workflow-remark" type="textarea" :rows="2" aria-label="分柜备注" placeholder="分柜备注(可选)" />
</div>
<div class="workflow-actions">
<el-button :disabled="!canSplit || Boolean(busy)" :loading="busy === 'smart-split'" @click="runSmartSplit"><Save :size="15" />智能分柜/保存调整</el-button>
<el-button :disabled="!canPreload || !preload || Boolean(busy)" :loading="busy === 'preload-confirming'" @click="startPreloadConfirmation"><Play :size="15" />提交确认</el-button>
<el-button type="primary" :disabled="!canPreload || !preload || preload?.status !== 'CONFIRMING' || Boolean(busy)" :loading="busy === 'preload-confirm'" @click="finishPreloadConfirmation"><Check :size="15" />确认预装清单</el-button>
</div>
</el-card>
</template>
<template v-if="isLoading">
<el-card shadow="never" class="workflow-card">
<template #header><div class="card-title"><span>拣货与装柜</span><el-tag type="warning">装柜中</el-tag></div></template>
<el-descriptions v-if="picking" :column="2" border>
<el-descriptions-item label="拣货状态">{{ picking.status || '—' }}</el-descriptions-item>
<el-descriptions-item label="拣货版本">{{ picking.version }}</el-descriptions-item>
<el-descriptions-item label="应拣件数">{{ picking.expectedCount ?? '—' }}</el-descriptions-item>
<el-descriptions-item label="已拣件数">{{ picking.pickedCount ?? '—' }}</el-descriptions-item>
</el-descriptions>
<el-empty v-else-if="!loading" description="暂无拣货任务" />
<div class="workflow-actions">
<el-button :disabled="!canPicking || !picking || Boolean(busy)" :loading="busy === 'picking-start'" @click="startPickingTask"><Play :size="15" />开始拣货</el-button>
<el-button :disabled="!canPicking || !picking || Boolean(busy)" :loading="busy === 'picking-complete'" @click="completePickingTask"><Check :size="15" />完成拣货</el-button>
</div>
<el-input v-model="loadingRemark" class="workflow-remark" type="textarea" :rows="2" aria-label="装柜备注" placeholder="装柜备注(可选)" />
</el-card>
<el-card shadow="never" class="workflow-card">
<template #header><div class="card-title"><span>装柜证据与封签</span><el-tag type="info">四步照片</el-tag></div></template>
<div class="evidence-grid">
<div v-for="entry in evidenceTypes" :key="entry.type" class="evidence-item">
<strong><Camera :size="15" />{{ entry.label }}</strong>
<el-input v-if="entry.type === 'SEAL'" :model-value="evidence(entry.type)?.sealNo || ''" aria-label="封签号" placeholder="封签号" @update:model-value="updateEvidenceField(entry.type, 'sealNo', $event)" />
<el-input v-else :model-value="evidence(entry.type)?.fileName || ''" aria-label="照片文件名" placeholder="文件上传完成后填写文件名" @update:model-value="updateEvidenceField(entry.type, 'fileName', $event)" />
<el-button plain :disabled="!canEvidence || Boolean(busy)" :loading="busy === `evidence-${entry.type}`" @click="saveEvidence(entry.type)">保存</el-button>
</div>
</div>
<p class="workflow-hint">当前只保存证据元数据;真实文件上传需接 File 服务契约,不会把本地路径伪装成已上传文件。</p>
<div class="workflow-actions">
<el-button type="primary" :disabled="!canLoadingComplete || inboundExpectedVersion === null || Boolean(busy)" :loading="busy === 'loading-complete'" @click="finishLoading"><Check :size="15" />完成装柜</el-button>
<span v-if="inboundExpectedVersion === null" class="workflow-hint">缺少入库版本,刷新订单详情后可提交</span>
</div>
</el-card>
</template>
<el-card v-if="isLoaded" shadow="never" class="workflow-card">
<template #header><div class="card-title"><span>派送</span><el-tag type="success">装柜完成</el-tag></div></template>
<el-descriptions v-if="props.deliveryTrackingNo" :column="1" border>
<el-descriptions-item label="派送单号">{{ props.deliveryTrackingNo }}</el-descriptions-item>
</el-descriptions>
<el-input v-model="deliveryRemark" class="workflow-remark" type="textarea" :rows="3" aria-label="派送备注" placeholder="派送备注(可选)" />
<div class="workflow-actions">
<el-button type="primary" :disabled="!canDeliveryComplete || inboundExpectedVersion === null || Boolean(busy)" :loading="busy === 'delivery-complete'" @click="finishDelivery"><Truck :size="15" />确认派送完成</el-button>
<span v-if="inboundExpectedVersion === null" class="workflow-hint">缺少入库版本,刷新订单详情后可提交</span>
</div>
</el-card>
</section>
</template>
<style scoped>
.workflow-panel { display: grid; gap: 14px; }
.workflow-heading, .card-title, .workflow-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.workflow-heading { justify-content: space-between; }
.workflow-heading h2 { margin: 0; font-size: 17px; }
.workflow-heading p { margin: 4px 0 0; color: var(--cc-text-secondary, #64748b); font-size: 13px; }
.workflow-card { border-radius: 12px; }
.card-title { justify-content: space-between; }
.preload-items { display: grid; gap: 10px; }
.preload-row { display: grid; grid-template-columns: 1fr 1fr 120px 120px auto; gap: 8px; align-items: center; }
.workflow-actions { margin-top: 14px; }
.workflow-remark { margin-top: 10px; }
.evidence-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.evidence-item { display: grid; grid-template-columns: minmax(100px, auto) 1fr auto; gap: 8px; align-items: center; }
.evidence-item strong { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
.workflow-alert { margin-bottom: 0; }
.workflow-hint { color: var(--cc-text-secondary, #64748b); font-size: 12px; }
@media (max-width: 640px) {
.preload-row, .evidence-grid { grid-template-columns: 1fr; }
.evidence-item { grid-template-columns: 1fr; }
.workflow-actions > .el-button { flex: 1 1 150px; }
}
</style>
@@ -0,0 +1,3 @@
export { default as UnifiedOrderWorkflowPanel } from './components/UnifiedOrderWorkflowPanel.vue'
export { default as UnifiedOrderWorkflowView } from './pages/UnifiedOrderWorkflowView.vue'
export * from './api'
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { getPlatformUnifiedOrder, type UnifiedOrderDetailResource } from '@/modules/unified-orders/api/unifiedOrders'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import UnifiedOrderWorkflowPanel from '@/modules/unified-orders/workflows/components/UnifiedOrderWorkflowPanel.vue'
const route = useRoute()
const detail = ref<UnifiedOrderDetailResource | null>(null)
const loading = ref(false)
const error = ref<ApiErrorDetails | null>(null)
const orderId = computed(() => String(route.params.orderId || ''))
const tenantId = computed(() => typeof route.query.tenantId === 'string' ? route.query.tenantId : '')
const summary = computed(() => detail.value?.summary || null)
async function load() {
if (!orderId.value || !tenantId.value) {
error.value = { message: '订单作业上下文缺少订单号或客户标识' }
return
}
loading.value = true
error.value = null
try {
detail.value = await getPlatformUnifiedOrder(tenantId.value, orderId.value)
} catch (cause) {
detail.value = null
error.value = getApiErrorDetails(cause, '订单作业上下文加载失败')
} finally {
loading.value = false
}
}
onMounted(() => { void load() })
</script>
<template>
<section class="workflow-page">
<LoadErrorState v-if="error" title="后半程作业暂时无法加载" :message="error.message" :loading="loading" @retry="load">
<template #details><ApiErrorMeta :details="error" /></template>
</LoadErrorState>
<UnifiedOrderWorkflowPanel
v-else-if="summary && summary.inboundId"
:tenant-id="tenantId"
:inbound-id="summary.inboundId"
:main-status="summary.mainStatus"
:inbound-version="summary.inboundVersion"
:picking-version="summary.loading?.pickingVersion"
:delivery-tracking-no="summary.delivery?.trackingNo"
@updated="load"
/>
<el-empty v-else-if="!loading" description="该订单尚未生成入库作业,暂无后半程操作" />
<el-skeleton v-else :rows="5" animated />
</section>
</template>
<style scoped>
.workflow-page { display: grid; gap: 16px; }
</style>
@@ -9,13 +9,11 @@ import {
listPlatformLocations,
type PlatformLocationResource,
} from '../api/platformLocations'
import { listPlatformTenants, type PlatformTenantResource } from '../../../iam/api/platformIam'
import { listPlatformWarehouses, type PlatformWarehouseResource } from '../../../warehouses/api/platformWarehouses'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import DataTable from '@/shared/components/DataTable.vue'
import LocationsView from './LocationsView.vue'
vi.mock('../../../iam/api/platformIam', () => ({ listPlatformTenants: vi.fn() }))
vi.mock('../../../warehouses/api/platformWarehouses', () => ({ listPlatformWarehouses: vi.fn() }))
vi.mock('../api/platformLocations', () => ({
listPlatformLocations: vi.fn(),
@@ -44,32 +42,7 @@ function createMemoryStorage(): Storage {
}
}
const tenant = (id: number | string): PlatformTenantResource => ({
id,
tenantCode: String(id),
tenantName: '雪花企业',
status: 'ACTIVE',
legalName: '雪花企业',
legalRepresentative: null,
unifiedSocialCreditCode: null,
contactName: null,
contactPhone: null,
contactEmail: null,
servicePackage: null,
contactWechat: null,
salesOwnerName: null,
paymentTermDays: null,
enterpriseType: null,
establishedDate: null,
registeredCapital: null,
registeredCapitalCurrency: null,
registeredAddress: null,
internalRemark: null,
createdAt: null,
approvedAt: null,
})
const tenantId = '9223372036854775806'
const tenantId = '0'
const warehouseId = '9223372036854775805'
const warehouse: PlatformWarehouseResource = {
id: warehouseId,
@@ -151,9 +124,6 @@ describe('LocationsView', () => {
beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
document.body.innerHTML = ''
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({
records: [tenant(tenantId)], page: 1, size: 200, total: 1, pages: 1,
})
vi.mocked(listPlatformWarehouses).mockReset().mockResolvedValue({
records: [warehouse], page: 1, size: 200, total: 1, pages: 1,
})
@@ -170,14 +140,13 @@ describe('LocationsView', () => {
const toolbar = wrapper.findComponent(QueryToolbar)
const fields = toolbar.props('fields') as QueryField[]
expect(listPlatformTenants).toHaveBeenCalledWith({ page: 1, size: 200 })
expect(wrapper.find('[aria-label="所属企业"]').exists()).toBe(false)
expect(fields[0]?.placeholder).toBe('全部仓库')
expect(fields.map(({ key, width }) => [key, width])).toEqual([
['warehouseId', 300], ['zoneCode', 210],
['locationCode', 220], ['locationType', 170], ['status', 160],
])
expect(listPlatformWarehouses).toHaveBeenCalledWith(undefined, 1, 200)
expect(listPlatformWarehouses).toHaveBeenCalledWith(1, 200)
expect(listPlatformLocations).toHaveBeenCalledWith({
page: 1,
size: 20,
@@ -206,7 +175,7 @@ describe('LocationsView', () => {
expect(panelHeading.text()).toContain('共 2 个库位')
expect(panelHeading.text()).toContain('新增库位')
expect(wrapper.findComponent(QueryToolbar).text()).not.toContain('新增库位')
expect(listPlatformWarehouses).toHaveBeenCalledWith(undefined, 1, 200)
expect(listPlatformWarehouses).toHaveBeenCalledWith(1, 200)
expect(listPlatformLocations).toHaveBeenLastCalledWith({
page: 1,
size: 20,
@@ -259,7 +228,8 @@ describe('LocationsView', () => {
expect(getPlatformLocation).toHaveBeenCalledWith(tenantId, warehouseId, availableLocation.id)
expect(document.body.textContent).toContain('库位详情')
expect(document.body.textContent).toContain('义乌收货仓(CN-YW-01)')
expect(document.body.textContent).toContain(`所属企业雪花企业(${tenantId})`)
expect(document.body.textContent).toContain('资源作用域平台公共资源')
expect(document.body.textContent).not.toContain('所属企业')
expect(document.body.textContent).not.toContain('企业 ID')
expect(document.body.textContent).toContain('库位名称A1 区 01 位')
expect(document.body.textContent).toContain('v3')
@@ -2,7 +2,6 @@
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { Eye, MapPin, MoreHorizontal, Pencil, Plus, Power, Trash2 } from '@lucide/vue'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -43,9 +42,6 @@ type AppliedLocationFilters = {
}
const access = useAccessPreviewStore()
const customers = ref<PlatformTenantResource[]>([])
const customerLoading = ref(false)
const customerError = ref<ApiErrorDetails | null>(null)
const warehouses = ref<PlatformWarehouseResource[]>([])
const warehouseLoading = ref(false)
const warehouseError = ref<ApiErrorDetails | null>(null)
@@ -82,7 +78,7 @@ const queryFields = computed<QueryField[]>(() => [
},
options: warehouses.value.map((warehouse) => ({
value: String(warehouse.id),
label: `${warehouse.warehouseName}(${warehouse.warehouseCode}) · ${customerLabel(warehouse.tenantId)}`,
label: `${warehouse.warehouseName}(${warehouse.warehouseCode})`,
})),
},
{ key: 'zoneCode', type: 'input', width: 210, placeholder: '库区编码', props: { maxlength: 50, 'aria-label': '库区编码' } },
@@ -232,32 +228,13 @@ function recordScope(row: PlatformLocationResource) {
return { customerId: apiId(String(row.tenantId)), warehouseId: apiId(String(row.warehouseId)) }
}
function customerLabel(customerId: number | string) {
const customer = customers.value.find((item) => String(item.id) === String(customerId))
return customer ? `${customer.tenantName}(${customer.tenantCode || customer.id})` : String(customerId)
}
async function loadCustomers() {
customerLoading.value = true
customerError.value = null
try {
const result = await listPlatformTenants({ page: 1, size: 200 })
customers.value = result.records
} catch (error) {
customers.value = []
customerError.value = getApiErrorDetails(error, '所属企业列表加载失败,请稍后重试')
} finally {
customerLoading.value = false
}
}
async function loadWarehouses() {
const sequence = ++warehouseLoadSequence
warehouseLoading.value = true
warehouseError.value = null
warehouses.value = []
try {
const result = await listPlatformWarehouses(undefined, 1, 200)
const result = await listPlatformWarehouses(1, 200)
if (sequence !== warehouseLoadSequence) return
warehouses.value = result.records
} catch (error) {
@@ -482,7 +459,6 @@ watch(() => form.warehouseId, (warehouseId) => {
})
onMounted(() => {
void loadCustomers()
void loadWarehouses()
void loadLocations()
})
@@ -497,12 +473,6 @@ onMounted(() => {
@search="applyFilters"
/>
<el-alert v-if="customerError" type="warning" :closable="false" show-icon class="state-alert">
<template #title>所属企业列表加载失败</template>
<p>{{ customerError.message }}</p>
<ApiErrorMeta :details="customerError" />
<el-button size="small" @click="loadCustomers">重新加载所属企业</el-button>
</el-alert>
<el-alert v-if="warehouseError" type="error" :closable="false" show-icon class="state-alert">
<template #title>仓库列表加载失败</template>
<p>{{ warehouseError.message }}</p>
@@ -637,7 +607,7 @@ onMounted(() => {
<el-tag :type="statusMeta[detail.location.status].type">{{ statusMeta[detail.location.status].label }}</el-tag>
</section>
<el-descriptions :column="2" border class="detail-descriptions">
<el-descriptions-item label="所属企业">{{ customerLabel(detail.location.tenantId) }}</el-descriptions-item>
<el-descriptions-item label="资源作用域">平台公共资源</el-descriptions-item>
<el-descriptions-item label="所属仓库">{{ detail.warehouseName }}({{ detail.warehouseCode }})</el-descriptions-item>
<el-descriptions-item label="库位编码">{{ detail.location.locationCode }}</el-descriptions-item>
<el-descriptions-item label="库位名称">{{ detail.location.locationName || '—' }}</el-descriptions-item>
@@ -677,21 +647,21 @@ onMounted(() => {
>
<div v-if="drawerMode === 'edit'" class="editor-scope">
<MapPin :size="18" />
<div><strong>{{ detail?.warehouseName || selectedWarehouse?.warehouseName }}({{ detail?.warehouseCode || selectedWarehouse?.warehouseCode }})</strong><span>所属企业:{{ customerLabel(form.customerId) }} · 保存后不可跨仓移动</span></div>
<div><strong>{{ detail?.warehouseName || selectedWarehouse?.warehouseName }}({{ detail?.warehouseCode || selectedWarehouse?.warehouseCode }})</strong><span>平台公共资源 · 保存后不可跨仓移动</span></div>
</div>
<el-form-item v-if="drawerMode === 'create'" label="所属仓库" prop="warehouseId" required>
<el-select v-model="form.warehouseId" filterable placeholder="请选择所属仓库" :loading="warehouseLoading">
<el-option
v-for="warehouse in warehouses"
:key="String(warehouse.id)"
:label="`${warehouse.warehouseName}(${warehouse.warehouseCode}) · ${customerLabel(warehouse.tenantId)}`"
:label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`"
:value="String(warehouse.id)"
/>
</el-select>
</el-form-item>
<section class="form-section">
<h3>库位标识</h3>
<p class="section-description">库位编码在当前企业和仓库内唯一;编辑后不能移动到其他仓库。</p>
<p class="section-description">库位编码在当前仓库内唯一;编辑后不能移动到其他仓库。</p>
<div class="form-grid two-cols">
<el-form-item label="库位编码" prop="locationCode" required>
<el-input v-model.trim="form.locationCode" maxlength="50" placeholder="例如 A1-01" />
@@ -4,7 +4,6 @@ import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/t
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import { listPlatformTenants as listPlatformCustomers, type PlatformTenantResource as PlatformCustomerResource } from '@/modules/iam'
import { listPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
import DataTable from '@/shared/components/DataTable.vue'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
@@ -16,11 +15,6 @@ import {
} from '../api/platformPallets'
import PalletsView from './PalletsView.vue'
vi.mock('@/modules/iam', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/modules/iam')>()
const listPlatformTenants = vi.fn<typeof actual.listPlatformTenants>()
return { ...actual, listPlatformTenants }
})
vi.mock('@/modules/warehouses', () => ({ listPlatformWarehouses: vi.fn() }))
vi.mock('../api/platformPallets', () => ({
listPlatformPallets: vi.fn(),
@@ -48,23 +42,14 @@ function createMemoryStorage(): Storage {
}
}
const customer = (id: number | string, tenantName: string): PlatformCustomerResource => ({
id, tenantCode: String(id), tenantName, status: 'ACTIVE', legalName: tenantName,
legalRepresentative: null, unifiedSocialCreditCode: null, contactName: null,
contactPhone: null, contactEmail: null, servicePackage: null, contactWechat: null,
salesOwnerName: null, paymentTermDays: null, enterpriseType: null, establishedDate: null,
registeredCapital: null, registeredCapitalCurrency: null, registeredAddress: null,
internalRemark: null, createdAt: null, approvedAt: null,
})
const warehouse: PlatformWarehouseResource = {
id: '19001', tenantId: 10001, warehouseCode: 'CN-YW-01', warehouseName: '义乌收货仓',
id: '19001', tenantId: 0, warehouseCode: 'CN-YW-01', warehouseName: '义乌收货仓',
warehouseType: 'DOMESTIC', countryCode: 'CN', timezone: 'Asia/Shanghai', contactName: null,
contactPhone: null, contactEmail: null, status: 'ACTIVE', remark: null,
}
const pallet: PlatformPalletResource = {
id: '20001', tenantId: 10001, warehouseId: '19001', palletCode: 'TP-001', palletType: '木托盘',
id: '20001', tenantId: 0, warehouseId: '19001', palletCode: 'TP-001', palletType: '木托盘',
capacityCbm: '1.200000', remark: '标准川字托盘', createdAt: '2026-08-04T09:00:00', updatedAt: '2026-08-04T10:00:00',
}
@@ -110,9 +95,6 @@ describe('PalletsView', () => {
beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
document.body.innerHTML = ''
vi.mocked(listPlatformCustomers).mockReset().mockResolvedValue({
records: [customer(10001, '默认企业'), customer('9223372036854775806', '雪花企业')], page: 1, size: 200, total: 2, pages: 1,
})
vi.mocked(listPlatformWarehouses).mockReset().mockResolvedValue({ records: [warehouse], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(listPlatformPallets).mockReset().mockResolvedValue({ records: [pallet], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(getPlatformPallet).mockReset().mockResolvedValue(pallet)
@@ -132,7 +114,7 @@ describe('PalletsView', () => {
])
expect(wrapper.find('.pallet-filter-toolbar').exists()).toBe(true)
expect(fields.find((field) => field.key === 'warehouseId')?.placeholder).toBe('全部仓库')
expect(listPlatformWarehouses).toHaveBeenCalledWith(undefined, 1, 200)
expect(listPlatformWarehouses).toHaveBeenCalledWith(1, 200)
expect(listPlatformPallets).toHaveBeenCalledWith({
page: 1, size: 20,
})
@@ -143,7 +125,7 @@ describe('PalletsView', () => {
await loadRows(wrapper)
expect(listPlatformWarehouses).toHaveBeenCalledTimes(1)
expect(listPlatformWarehouses).toHaveBeenCalledWith(undefined, 1, 200)
expect(listPlatformWarehouses).toHaveBeenCalledWith(1, 200)
expect(listPlatformPallets).toHaveBeenCalledWith({
page: 1, size: 20,
})
@@ -234,7 +216,7 @@ describe('PalletsView', () => {
await saveButton?.trigger('click')
await flushPromises()
expect(createPlatformPallet).toHaveBeenCalledWith(10001, {
expect(createPlatformPallet).toHaveBeenCalledWith(0, {
warehouseId: 19001, palletCode: 'TP-002', palletType: '塑料托盘', capacityCbm: 1.5, remark: '蓝色标准托盘',
})
})
@@ -277,33 +259,33 @@ describe('PalletsView', () => {
expect(wrapper.text()).not.toContain('TP-001')
})
it('keeps platform warehouse and pallet queries available when customer names cannot load', async () => {
vi.mocked(listPlatformCustomers).mockRejectedValueOnce(new Error('Gateway unavailable'))
it('keeps platform warehouse and pallet queries independent from customer names', async () => {
const wrapper = await mountView()
expect(wrapper.text()).toContain('客户列表暂时无法加载')
expect(wrapper.text()).toContain('重新加载')
expect(listPlatformWarehouses).toHaveBeenCalledWith(undefined, 1, 200)
expect(wrapper.text()).not.toContain('客户列表暂时无法加载')
expect(wrapper.text()).not.toContain('所属企业')
expect(listPlatformWarehouses).toHaveBeenCalledWith(1, 200)
expect(listPlatformPallets).toHaveBeenCalledWith({
page: 1, size: 20,
})
})
it('shows only the pallet fields exposed by the backend contract', async () => {
const crossCustomerPallet = { ...pallet, tenantId: '9223372036854775806' }
vi.mocked(listPlatformPallets).mockResolvedValue({ records: [crossCustomerPallet], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(getPlatformPallet).mockResolvedValue(crossCustomerPallet)
const publicPallet = { ...pallet, tenantId: '0' }
vi.mocked(listPlatformPallets).mockResolvedValue({ records: [publicPallet], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(getPlatformPallet).mockResolvedValue(publicPallet)
const wrapper = await mountView()
await loadRows(wrapper)
expect(wrapper.findComponent(DataTable).props('data')).toEqual([crossCustomerPallet])
expect(wrapper.findComponent(DataTable).props('data')).toEqual([publicPallet])
const tableBody = wrapper.find('.el-table__body-wrapper')
const viewButton = tableBody.findAll('.pallet-row-actions button').find((button) => button.text().trim() === '查看')
await viewButton?.trigger('click')
await flushPromises()
expect(getPlatformPallet).toHaveBeenCalledWith('9223372036854775806', 19001, '20001')
expect(document.body.textContent).toContain('所属企业')
expect(document.body.textContent).toContain('雪花企业(9223372036854775806)')
expect(getPlatformPallet).toHaveBeenCalledWith(0, 19001, '20001')
expect(document.body.textContent).toContain('资源作用域')
expect(document.body.textContent).toContain('平台公共资源')
expect(document.body.textContent).not.toContain('所属企业')
expect(document.body.textContent).toContain('托盘编码')
expect(document.body.textContent).toContain('托盘类型')
expect(document.body.textContent).toContain('托盘容积')
@@ -4,7 +4,6 @@ import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'elem
import { Boxes, Eye, Pencil, Plus, Trash2, Warehouse as WarehouseIcon } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { listPlatformTenants as listPlatformCustomers, type PlatformTenantResource as PlatformCustomerResource } from '@/modules/iam'
import { listPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
@@ -21,9 +20,6 @@ import {
} from '../api/platformPallets'
const access = useAccessPreviewStore()
const customers = ref<PlatformCustomerResource[]>([])
const customerLoading = ref(false)
const customerError = ref<ApiErrorDetails | null>(null)
const warehouses = ref<PlatformWarehouseResource[]>([])
const warehouseLoading = ref(false)
const warehouseError = ref<ApiErrorDetails | null>(null)
@@ -140,44 +136,17 @@ function warehouseFor(warehouseId: number | string) {
return warehouses.value.find((item) => String(item.id) === String(warehouseId)) || null
}
function customerLabel(customerId: number | string) {
const customer = customers.value.find((item) => String(item.id) === String(customerId))
return customer ? `${customer.tenantName}(${customer.tenantCode || customer.id})` : '—'
}
function errorMessage(details: ApiErrorDetails) {
return details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message
}
async function loadCustomers() {
customerLoading.value = true
customerError.value = null
try {
const records: PlatformCustomerResource[] = []
let currentPage = 1
let pages = 1
do {
const result = await listPlatformCustomers({ page: currentPage, size: 200 })
records.push(...result.records)
pages = Math.max(1, result.pages)
currentPage += 1
} while (currentPage <= pages)
customers.value = records
} catch (error) {
customers.value = []
customerError.value = getApiErrorDetails(error, '所属企业列表加载失败,请稍后重试')
} finally {
customerLoading.value = false
}
}
async function loadWarehouses() {
const sequence = ++warehouseSequence
warehouses.value = []
warehouseError.value = null
warehouseLoading.value = true
try {
const result = await listPlatformWarehouses(undefined, 1, 200)
const result = await listPlatformWarehouses(1, 200)
if (sequence !== warehouseSequence) return
warehouses.value = result.records
} catch (error) {
@@ -343,7 +312,6 @@ watch(() => access.can(editorPermission.value), (canWrite) => {
if (!canWrite && editorOpen.value) editorOpen.value = false
}, { flush: 'sync' })
onMounted(() => {
void loadCustomers()
void loadWarehouses()
void loadPallets()
})
@@ -351,14 +319,6 @@ onMounted(() => {
<template>
<section class="pallet-page">
<LoadErrorState
v-if="customerError"
title="客户列表暂时无法加载"
:message="customerError.message"
:loading="customerLoading"
@retry="loadCustomers"
/>
<QueryToolbar
v-model="filters"
class="surface-panel pallet-filter-toolbar"
@@ -430,7 +390,7 @@ onMounted(() => {
<div class="detail-hero"><span><Boxes :size="22" /></span><div><h2>{{ detail.palletCode }}</h2><p>{{ detail.palletType }}</p></div></div>
<h3 class="detail-section-title">归属信息</h3>
<el-descriptions :column="1" border>
<el-descriptions-item label="所属企业">{{ customerLabel(detail.tenantId) }}</el-descriptions-item>
<el-descriptions-item label="资源作用域">平台公共资源</el-descriptions-item>
<el-descriptions-item label="所属仓库">{{ warehouseFor(detail.warehouseId)?.warehouseName || '—' }}({{ warehouseFor(detail.warehouseId)?.warehouseCode || '—' }})</el-descriptions-item>
</el-descriptions>
<h3 class="detail-section-title">托盘规格</h3>
@@ -467,7 +427,7 @@ onMounted(() => {
<div><h3 id="pallet-ownership-heading">归属仓库</h3><p>托盘创建后固定归属于所选仓库,不支持跨仓移动。</p></div>
</div>
<div v-if="form.id" class="editor-context">
<div><strong>{{ editorWarehouse?.warehouseName || '当前仓库' }}</strong><span>{{ editorWarehouse?.warehouseCode || form.warehouseId }} · 客户 {{ customerLabel(form.customerId) }}</span></div>
<div><strong>{{ editorWarehouse?.warehouseName || '当前仓库' }}</strong><span>{{ editorWarehouse?.warehouseCode || form.warehouseId }} · 平台公共资源</span></div>
</div>
<el-form-item v-else label="所属仓库" prop="warehouseId" required>
<el-select v-model="form.warehouseId" filterable placeholder="请选择所属仓库" :loading="warehouseLoading">
@@ -2,8 +2,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import {
changePlatformWarehouseStatus,
createFbaWarehouse,
createPlatformWarehouse,
getPlatformWarehouse,
importWarehouses,
listPlatformWarehouses,
updatePlatformWarehouse,
type SavePlatformWarehouseRequest,
@@ -15,12 +17,11 @@ vi.mock('@/shared/api/http', () => ({
}))
const payload: SavePlatformWarehouseRequest = {
tenantId: 10001,
warehouseCode: 'CN-YW-01',
warehouseName: '义乌收货仓',
warehouseType: 'DOMESTIC',
countryCode: 'CN',
timezone: 'Asia/Shanghai',
timezone: 'UTC+8',
contactName: '仓库客服',
contactPhone: '0579-12345678',
contactEmail: null,
@@ -55,20 +56,45 @@ describe('platform warehouse API', () => {
vi.mocked(http.patch).mockReset()
})
it('lists and loads warehouse details for the target tenant', async () => {
const page = { records: [], page: 1, size: 200, total: 0, pages: 0 }
it('lists server-paged platform warehouses and normalizes summary fields', async () => {
const page = {
records: [{
id: '1',
tenantId: 0,
warehouseCode: 'LAX9',
warehouseName: 'LAX9',
warehouseType: 'FBA',
countryCode: 'US',
timezone: 'UTC-8',
contactName: null,
contactPhone: null,
contactEmail: null,
status: 'ACTIVE',
remark: null,
}],
page: '1',
size: '20',
total: '1',
pages: '1',
}
const detail = { warehouse: {}, addresses: [], capabilities: [] }
vi.mocked(http.get)
.mockResolvedValueOnce({ code: '200', message: 'success', data: page })
.mockResolvedValueOnce({ code: '200', message: 'success', data: detail })
await expect(listPlatformWarehouses(10001)).resolves.toEqual(page)
await expect(getPlatformWarehouse(10001, '19001')).resolves.toEqual(detail)
await expect(listPlatformWarehouses({ page: 1, size: 20, keyword: 'LAX', warehouseType: 'FBA', status: 'ACTIVE' })).resolves.toEqual({
records: [{ ...page.records[0], primaryAddress: null, addressSummary: null }],
page: 1,
size: 20,
total: 1,
pages: 1,
})
await expect(getPlatformWarehouse('19001')).resolves.toEqual(detail)
expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/warehouse-admin', {
params: { tenantId: 10001, page: 1, size: 200 },
params: { page: 1, size: 20, keyword: 'LAX', warehouseType: 'FBA', status: 'ACTIVE' },
})
expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/warehouse-admin/19001', {
params: { tenantId: 10001 },
params: { tenantId: 0 },
})
})
@@ -89,15 +115,87 @@ describe('platform warehouse API', () => {
vi.mocked(http.patch).mockResolvedValue({ code: '200', message: 'success', data: null })
await expect(createPlatformWarehouse(payload)).resolves.toBe('19001')
await expect(updatePlatformWarehouse(10001, '19001', payload)).resolves.toBeNull()
await expect(changePlatformWarehouseStatus(10001, '19001', 'INACTIVE')).resolves.toBeNull()
await expect(updatePlatformWarehouse('19001', payload)).resolves.toBeNull()
await expect(changePlatformWarehouseStatus('19001', 'INACTIVE')).resolves.toBeNull()
expect(http.post).toHaveBeenCalledWith('/platform/v1/warehouse-admin', payload)
expect(http.put).toHaveBeenCalledWith('/platform/v1/warehouse-admin/19001', payload, {
params: { tenantId: 10001 },
expect(http.post).toHaveBeenCalledWith('/platform/v1/warehouse-admin', { ...payload, tenantId: 0 })
expect(http.put).toHaveBeenCalledWith('/platform/v1/warehouse-admin/19001', { ...payload, tenantId: 0 }, {
params: { tenantId: 0 },
})
expect(http.patch).toHaveBeenCalledWith('/platform/v1/warehouse-admin/19001/status', undefined, {
params: { tenantId: 10001, status: 'INACTIVE' },
params: { tenantId: 0, status: 'INACTIVE' },
})
})
it('uses the FBA fast-create defaults and implemented import routes without client-side success simulation', async () => {
vi.mocked(http.post)
.mockResolvedValueOnce({ code: '200', message: 'success', data: '19002' })
.mockResolvedValueOnce({
code: '200',
message: 'success',
data: {
fileName: 'fba.xlsx',
totalRows: '3',
createdRows: '1',
updatedRows: '1',
unchangedRows: '0',
success: false,
committed: false,
errors: [{ row: '3', field: '仓库编码', message: '编码重复' }],
},
})
await expect(createFbaWarehouse({
warehouseCode: 'LAX9',
stateProvince: 'CA',
city: 'Long Beach',
postalCode: '90802',
addressLine1: '2400 E Spring St',
})).resolves.toBe('19002')
const file = new File(['content'], 'warehouses.xlsx')
await expect(importWarehouses(file, true, 'CREATE_ONLY')).resolves.toMatchObject({
totalRows: 3,
validRows: 2,
createCount: 1,
updateCount: 1,
unchangedCount: 0,
errorCount: 1,
committed: false,
errors: [{ row: 3, field: '仓库编码', message: '编码重复' }],
})
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/warehouse-admin', {
warehouseCode: 'LAX9',
warehouseName: 'LAX9',
warehouseType: 'FBA',
countryCode: 'US',
timezone: '',
contactName: null,
contactPhone: null,
contactEmail: null,
status: 'ACTIVE',
remark: null,
address: {
addressType: 'OPERATING',
countryCode: 'US',
stateProvince: 'CA',
city: 'Long Beach',
district: null,
postalCode: '90802',
addressLine1: '2400 E Spring St',
addressLine2: null,
latitude: null,
longitude: null,
},
capabilities: [],
tenantId: 0,
})
expect(http.post).toHaveBeenNthCalledWith(
2,
'/platform/v1/warehouse-admin/import',
expect.any(FormData),
{ params: { tenantId: 0, dryRun: true, mode: 'CREATE_ONLY' } },
)
})
})
+172 -17
View File
@@ -54,14 +54,34 @@ export interface PlatformWarehouseDetailResource {
capabilities: PlatformWarehouseCapabilityResource[]
}
export interface PlatformWarehouseSummaryResource extends PlatformWarehouseResource {
primaryAddress?: PlatformWarehouseAddressResource | null
addressSummary?: string | null
}
export interface PlatformWarehousePage {
records: PlatformWarehouseResource[]
records: PlatformWarehouseSummaryResource[]
page: number
size: number
total: number
pages: number
}
type PlatformWarehousePagePayload = Omit<PlatformWarehousePage, 'page' | 'size' | 'total' | 'pages'> & {
page: number | string
size: number | string
total: number | string
pages: number | string
}
export interface PlatformWarehouseQuery {
page: number
size: number
keyword?: string
warehouseType?: WarehouseType
status?: PlatformWarehouseStatus
}
export interface PlatformWarehouseAddressRequest {
addressType: string
countryCode: string
@@ -84,7 +104,6 @@ export interface PlatformWarehouseCapabilityRequest {
}
export interface SavePlatformWarehouseRequest {
tenantId: number | string
warehouseCode: string
warehouseName: string
warehouseType: WarehouseType
@@ -99,42 +118,178 @@ export interface SavePlatformWarehouseRequest {
capabilities: PlatformWarehouseCapabilityRequest[]
}
const basePath = '/platform/v1/warehouse-admin'
export async function listPlatformWarehouses(tenantId?: number | string, page = 1, size = 200) {
const response = await http.get<ApiEnvelope<PlatformWarehousePage>, ApiEnvelope<PlatformWarehousePage>>(basePath, {
params: { ...(tenantId === undefined || tenantId === '' ? {} : { tenantId }), page, size },
})
return unwrapApiResponse(response)
export interface CreateFbaWarehouseRequest {
warehouseCode: string
stateProvince: string
city: string
postalCode: string
addressLine1: string
timezone?: string | null
}
export async function getPlatformWarehouse(tenantId: number | string, warehouseId: number | string) {
export type WarehouseImportMode = 'CREATE_ONLY' | 'UPSERT'
export interface WarehouseImportError {
row: number
field: string
code?: string | null
message: string
}
export interface WarehouseImportResult {
fileName: string
totalRows: number
validRows: number
createCount: number
updateCount: number
unchangedCount: number
errorCount: number
committed: boolean
errors: WarehouseImportError[]
}
const basePath = '/platform/v1/warehouse-admin'
const platformResourceScope = 0
type PlatformWarehouseApiSaveRequest = SavePlatformWarehouseRequest & { tenantId: number }
type WarehouseImportErrorPayload = Omit<WarehouseImportError, 'row'> & { row: number | string }
type WarehouseImportResultPayload = Partial<Omit<
WarehouseImportResult,
'totalRows' | 'validRows' | 'createCount' | 'updateCount' | 'unchangedCount' | 'errorCount' | 'committed' | 'errors'
>> & {
totalRows: number | string
validRows?: number | string
createCount?: number | string
createdRows?: number | string
updateCount?: number | string
updatedRows?: number | string
unchangedCount?: number | string
unchangedRows?: number | string
errorCount?: number | string
success?: boolean
committed?: boolean
errors?: WarehouseImportErrorPayload[] | null
}
function withPlatformScope(payload: SavePlatformWarehouseRequest): PlatformWarehouseApiSaveRequest {
return { ...payload, tenantId: platformResourceScope }
}
function toNumber(value: number | string | undefined) {
return Number(value ?? 0)
}
function normalizeWarehouseImportResult(result: WarehouseImportResultPayload, dryRun: boolean): WarehouseImportResult {
const errors = (result.errors || []).map((error) => ({ ...error, row: toNumber(error.row) }))
const errorCount = toNumber(result.errorCount) || errors.length
const totalRows = toNumber(result.totalRows)
return {
...result,
fileName: result.fileName || '',
totalRows,
validRows: toNumber(result.validRows) || Math.max(totalRows - errorCount, 0),
createCount: toNumber(result.createCount ?? result.createdRows),
updateCount: toNumber(result.updateCount ?? result.updatedRows),
unchangedCount: toNumber(result.unchangedCount ?? result.unchangedRows),
errorCount,
committed: result.committed ?? (!dryRun && Boolean(result.success) && errorCount === 0),
errors,
}
}
export async function listPlatformWarehouses(query?: PlatformWarehouseQuery): Promise<PlatformWarehousePage>
export async function listPlatformWarehouses(page?: number, size?: number): Promise<PlatformWarehousePage>
export async function listPlatformWarehouses(pageOrQuery: number | PlatformWarehouseQuery = 1, size = 200) {
const query = typeof pageOrQuery === 'number'
? { page: pageOrQuery, size }
: pageOrQuery
const response = await http.get<ApiEnvelope<PlatformWarehousePagePayload>, ApiEnvelope<PlatformWarehousePagePayload>>(basePath, {
params: query,
})
const result = unwrapApiResponse(response)
return {
...result,
records: result.records.map((warehouse) => ({
...warehouse,
primaryAddress: warehouse.primaryAddress ?? null,
addressSummary: warehouse.addressSummary ?? null,
})),
page: Number(result.page),
size: Number(result.size),
total: Number(result.total),
pages: Number(result.pages),
}
}
export async function getPlatformWarehouse(warehouseId: number | string) {
const response = await http.get<ApiEnvelope<PlatformWarehouseDetailResource>, ApiEnvelope<PlatformWarehouseDetailResource>>(
`${basePath}/${warehouseId}`,
{ params: { tenantId } },
{ params: { tenantId: platformResourceScope } },
)
return unwrapApiResponse(response)
}
export async function createPlatformWarehouse(payload: SavePlatformWarehouseRequest) {
const response = await http.post<ApiEnvelope<number | string>, ApiEnvelope<number | string>>(basePath, payload)
const response = await http.post<ApiEnvelope<number | string>, ApiEnvelope<number | string>>(basePath, withPlatformScope(payload))
return unwrapApiResponse(response)
}
export async function updatePlatformWarehouse(tenantId: number | string, warehouseId: number | string, payload: SavePlatformWarehouseRequest) {
const response = await http.put<ApiEnvelope<null>, ApiEnvelope<null>>(`${basePath}/${warehouseId}`, payload, {
params: { tenantId },
export async function createFbaWarehouse(payload: CreateFbaWarehouseRequest) {
return createPlatformWarehouse({
warehouseCode: payload.warehouseCode,
warehouseName: payload.warehouseCode,
warehouseType: 'FBA',
countryCode: 'US',
timezone: payload.timezone?.trim() || '',
contactName: null,
contactPhone: null,
contactEmail: null,
status: 'ACTIVE',
remark: null,
address: {
addressType: 'OPERATING',
countryCode: 'US',
stateProvince: payload.stateProvince,
city: payload.city,
district: null,
postalCode: payload.postalCode,
addressLine1: payload.addressLine1,
addressLine2: null,
latitude: null,
longitude: null,
},
capabilities: [],
})
}
export async function downloadWarehouseImportTemplate() {
return http.get<Blob, Blob>(`${basePath}/import-template`, { responseType: 'blob' })
}
export async function importWarehouses(file: File, dryRun: boolean, mode: WarehouseImportMode) {
const form = new FormData()
form.append('file', file)
const response = await http.post<ApiEnvelope<WarehouseImportResultPayload>, ApiEnvelope<WarehouseImportResultPayload>>(
`${basePath}/import`,
form,
{ params: { tenantId: platformResourceScope, dryRun, mode } },
)
return normalizeWarehouseImportResult(unwrapApiResponse(response), dryRun)
}
export async function updatePlatformWarehouse(warehouseId: number | string, payload: SavePlatformWarehouseRequest) {
const response = await http.put<ApiEnvelope<null>, ApiEnvelope<null>>(`${basePath}/${warehouseId}`, withPlatformScope(payload), {
params: { tenantId: platformResourceScope },
})
return unwrapApiResponse(response)
}
export async function changePlatformWarehouseStatus(
tenantId: number | string,
warehouseId: number | string,
status: PlatformWarehouseStatus,
) {
const response = await http.patch<ApiEnvelope<null>, ApiEnvelope<null>>(`${basePath}/${warehouseId}/status`, undefined, {
params: { tenantId, status },
params: { tenantId: platformResourceScope, status },
})
return unwrapApiResponse(response)
}
@@ -2,25 +2,32 @@ import ElementPlus from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import DataTable from '@/shared/components/DataTable.vue'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import {
createFbaWarehouse,
createPlatformWarehouse,
getPlatformWarehouse,
importWarehouses,
listPlatformWarehouses,
updatePlatformWarehouse,
type PlatformWarehouseDetailResource,
type PlatformWarehouseResource,
type PlatformWarehouseSummaryResource,
type WarehouseImportResult,
} from '@/modules/warehouses/api/platformWarehouses'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import WarehousesView from './WarehousesView.vue'
vi.mock('@/modules/warehouses/api/platformWarehouses', () => ({
listPlatformWarehouses: vi.fn(),
getPlatformWarehouse: vi.fn(),
createPlatformWarehouse: vi.fn(),
createFbaWarehouse: vi.fn(),
updatePlatformWarehouse: vi.fn(),
changePlatformWarehouseStatus: vi.fn(),
downloadWarehouseImportTemplate: vi.fn(),
importWarehouses: vi.fn(),
}))
class ResizeObserverStub implements ResizeObserver {
@@ -41,43 +48,109 @@ function createMemoryStorage(initial: Record<string, string> = {}): Storage {
}
}
const warehouse: PlatformWarehouseResource = {
const domesticAddress = {
id: '19002',
tenantId: 0,
warehouseId: '19001',
addressType: 'OPERATING',
countryCode: 'CN',
stateProvince: '浙江省',
city: '义乌市',
district: '北苑街道',
postalCode: '322000',
addressLine1: '真实物流园 1 号库',
addressLine2: 'A 栋',
latitude: '29.3',
longitude: '120.0',
}
const domesticWarehouse: PlatformWarehouseSummaryResource = {
id: '19001',
tenantId: 10001,
warehouseCode: 'CN-TEST-01',
warehouseName: '测试环境收货仓',
tenantId: 0,
warehouseCode: 'CN-YW-01',
warehouseName: '义乌收货仓',
warehouseType: 'DOMESTIC',
countryCode: 'CN',
timezone: 'Asia/Shanghai',
contactName: '真实联系人',
timezone: 'UTC+8',
contactName: '仓库客服',
contactPhone: '0579-12345678',
contactEmail: 'warehouse@example.com',
status: 'ACTIVE',
remark: '测试环境数据',
updatedAt: '2026-07-31T10:20:00',
addressSummary: '浙江省义乌市北苑街道 1 号库',
primaryAddress: domesticAddress,
}
const fbaAddress = {
id: '19012',
tenantId: 0,
warehouseId: '19011',
addressType: 'OPERATING',
countryCode: 'US',
stateProvince: 'OH',
city: 'Canton',
district: null,
postalCode: '44705',
addressLine1: '4747 Rebar Ave NE',
addressLine2: null,
latitude: null,
longitude: null,
}
const fbaWarehouse: PlatformWarehouseSummaryResource = {
id: '19011',
tenantId: 0,
warehouseCode: 'AKR1',
warehouseName: 'AKR1',
warehouseType: 'FBA',
countryCode: 'US',
timezone: 'UTC-5',
contactName: null,
contactPhone: null,
contactEmail: null,
status: 'ACTIVE',
remark: null,
updatedAt: '2026-07-31T10:20:00',
addressSummary: 'OH · Canton',
primaryAddress: fbaAddress,
}
const fbaDetail: PlatformWarehouseDetailResource = {
warehouse: fbaWarehouse,
addresses: [fbaAddress],
capabilities: [],
}
const detail: PlatformWarehouseDetailResource = {
warehouse,
addresses: [{
id: '19002', tenantId: 10001, warehouseId: '19001', addressType: 'OPERATING', countryCode: 'CN',
stateProvince: '浙江省', city: '义乌市', district: '北苑街道', postalCode: '322000',
addressLine1: '真实物流园 1 号库', addressLine2: 'A 栋', latitude: '29.3', longitude: '120.0',
}],
warehouse: domesticWarehouse,
addresses: [domesticAddress],
capabilities: [
{ id: '19003', tenantId: 10001, warehouseId: '19001', capabilityCode: 'RECEIVING', capabilityName: '收货服务', enabled: true, cutoffTime: '17:00:00', description: '周一至周六 08:30-18:00' },
{ id: '19004', tenantId: 10001, warehouseId: '19001', capabilityCode: 'STORAGE', capabilityName: '仓储', enabled: true, cutoffTime: null, description: '托盘仓储' },
{ id: '19003', tenantId: 0, warehouseId: '19001', capabilityCode: 'RECEIVING', capabilityName: '收货服务', enabled: true, cutoffTime: '17:00:00', description: '周一至周六 08:30-18:00' },
{ id: '19004', tenantId: 0, warehouseId: '19001', capabilityCode: 'STORAGE', capabilityName: '仓储', enabled: true, cutoffTime: null, description: '托盘仓储' },
],
}
async function mountView() {
const importPreview: WarehouseImportResult = {
fileName: 'warehouses.xlsx',
totalRows: 4,
validRows: 3,
createCount: 2,
updateCount: 1,
unchangedCount: 0,
errorCount: 1,
committed: false,
errors: [{ row: 4, field: '仓库编码', code: 'DUPLICATE_CODE', message: '文件内编码重复' }],
}
async function mountView(permissions = ['platform:warehouse:list', 'platform:warehouse:create', 'platform:warehouse:edit', 'platform:warehouse:status']) {
vi.stubGlobal('localStorage', createMemoryStorage())
vi.stubGlobal('sessionStorage', createMemoryStorage())
const pinia = createPinia()
setActivePinia(pinia)
const auth = useAuthStore()
auth.token = 'token'
auth.permissions = ['platform:warehouse:list', 'platform:warehouse:create', 'platform:warehouse:edit', 'platform:warehouse:status']
auth.permissions = permissions
useAccessPreviewStore().initializeAfterLogin()
const wrapper = mount(WarehousesView, {
attachTo: document.body,
@@ -87,72 +160,80 @@ async function mountView() {
return wrapper
}
function setField(selector: string, value: string) {
const field = document.querySelector<HTMLInputElement | HTMLTextAreaElement>(selector)
expect(field).not.toBeNull()
field!.value = value
field!.dispatchEvent(new Event('input', { bubbles: true }))
}
enableAutoUnmount(afterEach)
afterAll(() => vi.unstubAllGlobals())
describe('WarehousesView real API integration', () => {
describe('WarehousesView', () => {
beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
document.body.innerHTML = ''
vi.mocked(listPlatformWarehouses).mockReset().mockResolvedValue({ records: [warehouse], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(listPlatformWarehouses).mockReset().mockResolvedValue({ records: [domesticWarehouse], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(getPlatformWarehouse).mockReset().mockResolvedValue(detail)
vi.mocked(createPlatformWarehouse).mockReset().mockResolvedValue('19005')
vi.mocked(createFbaWarehouse).mockReset().mockResolvedValue('19006')
vi.mocked(updatePlatformWarehouse).mockReset().mockResolvedValue(null)
vi.mocked(importWarehouses).mockReset().mockResolvedValue(importPreview)
})
it('automatically loads the fixed tenant and renders address and receiving capability from details', async () => {
it('loads a server-paged warehouse list with backend address summaries and no row detail fan-out', async () => {
const wrapper = await mountView()
expect(listPlatformWarehouses).toHaveBeenCalledWith(10001, 1, 200)
expect(getPlatformWarehouse).toHaveBeenCalledWith(10001, '19001')
expect(wrapper.text()).toContain('测试环境收货仓')
expect(wrapper.text()).toContain('北苑街道 真实物流园 1 号库 A 栋')
expect(wrapper.text()).toContain('周一至周六 08:30-18:00')
expect(listPlatformWarehouses).toHaveBeenCalledWith({
page: 1,
size: 20,
keyword: undefined,
warehouseType: undefined,
status: undefined,
})
expect(getPlatformWarehouse).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('义乌收货仓')
expect(wrapper.text()).toContain('浙江省义乌市北苑街道 1 号库')
expect(wrapper.text()).toContain('国内收货仓')
expect(wrapper.text()).toContain('Asia/Shanghai')
expect(wrapper.text()).not.toContain('全部收货仓')
expect(wrapper.find('.query-toolbar').exists()).toBe(true)
expect(wrapper.text()).toContain('中国 CN')
expect(wrapper.text()).toContain('UTC+8')
expect(wrapper.text()).toContain('共 1 个公共仓库')
expect(wrapper.find('.data-table').exists()).toBe(true)
})
it('updates filters locally and only reloads after search', async () => {
const wrapper = await mountView()
const keywordInput = wrapper.find('input[placeholder="仓库名称、编码、城市或地址"]')
await keywordInput.setValue('义乌')
await flushPromises()
expect(listPlatformWarehouses).toHaveBeenCalledTimes(1)
const searchButton = wrapper.findAll('button').find((button) => button.text().trim() === '查询')
expect(searchButton).toBeDefined()
await searchButton?.trigger('click')
await flushPromises()
expect(listPlatformWarehouses).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('测试环境收货仓')
})
it('uses the fixed public tenant without rendering a tenant selector', async () => {
const wrapper = await mountView()
expect(wrapper.find('[aria-label="目标企业"]').exists()).toBe(false)
expect(listPlatformWarehouses).toHaveBeenCalledWith(10001, 1, 200)
expect(wrapper.text()).toContain('公共仓库')
})
it('filters the public warehouse table by warehouse type', async () => {
it('submits filters and pagination to the API instead of filtering rows locally', async () => {
vi.mocked(listPlatformWarehouses).mockResolvedValue({ records: [domesticWarehouse], page: 1, size: 20, total: 45, pages: 3 })
const wrapper = await mountView()
wrapper.findComponent(QueryToolbar).vm.$emit('search', {
keyword: '', warehouseType: 'FBA', status: '',
keyword: 'FBA',
warehouseType: 'FBA',
status: 'ACTIVE',
})
await flushPromises()
expect(wrapper.text()).not.toContain('测试环境收货仓')
expect(wrapper.text()).toContain('暂无符合筛选条件的仓库')
expect(listPlatformWarehouses).toHaveBeenLastCalledWith({
page: 1,
size: 20,
keyword: 'FBA',
warehouseType: 'FBA',
status: 'ACTIVE',
})
wrapper.findComponent(DataTable).vm.$emit('update:page', 2)
await flushPromises()
expect(listPlatformWarehouses).toHaveBeenLastCalledWith({
page: 2,
size: 20,
keyword: 'FBA',
warehouseType: 'FBA',
status: 'ACTIVE',
})
})
it('shows a recoverable real API error without falling back to demo warehouses', async () => {
it('shows a recoverable API error without falling back to demo warehouses', async () => {
vi.mocked(listPlatformWarehouses).mockRejectedValueOnce(new Error('Gateway unavailable'))
const wrapper = await mountView()
@@ -162,66 +243,219 @@ describe('WarehousesView real API integration', () => {
expect(wrapper.text()).not.toContain('演示物流园')
})
it('preserves backend-only address, email, capability and cutoff fields when updating', async () => {
it('keeps create actions behind permissions and preserves a read-only table state', async () => {
const wrapper = await mountView(['platform:warehouse:list'])
expect(wrapper.text()).not.toContain('新增仓库')
expect(wrapper.text()).not.toContain('导入仓库')
expect(wrapper.text()).toContain('仅查看')
})
it('creates an FBA address from the fast path with selectable state and timezone values', async () => {
const wrapper = await mountView()
const editButton = wrapper.findAll('button').find((button) => button.text().trim() === '编辑')
expect(editButton).toBeDefined()
await editButton?.trigger('click')
wrapper.findComponent({ name: 'ElDropdown' }).vm.$emit('command', 'FBA')
await flushPromises()
const typeFormItem = wrapper.findAll('.el-form-item').find((item) => item.text().includes('仓库类型'))
expect(typeFormItem).toBeDefined()
typeFormItem?.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'FBA')
await wrapper.find('input[placeholder="例如 CN、US"]').setValue('US')
await wrapper.find('input[placeholder="例如 Asia/Shanghai"]').setValue('America/Los_Angeles')
const drawerText = document.querySelector('.el-drawer')?.textContent || ''
expect(drawerText).toContain('新增FBA 地址')
expect(drawerText).toContain('国家或地区')
expect(drawerText).not.toContain('时区覆盖')
expect(drawerText).not.toContain('联系人')
expect(drawerText).not.toContain('收货时间')
const countrySelect = wrapper.findAllComponents({ name: 'ElSelect' })
.find((select) => select.props('placeholder') === '美国 US')
expect(countrySelect).toBeDefined()
expect(countrySelect!.props('modelValue')).toBe('US')
expect(countrySelect!.props('disabled')).toBe(true)
const saveButton = wrapper.findAll('button').find((button) => button.text().trim() === '保存仓库')
const stateSelect = wrapper.findAllComponents({ name: 'ElSelect' })
.find((select) => select.props('placeholder') === '请选择州')
expect(stateSelect).toBeDefined()
setField('input[placeholder="例如 LAX9"]', 'lax9')
stateSelect!.vm.$emit('update:modelValue', 'CA')
stateSelect!.vm.$emit('change', 'CA')
await flushPromises()
setField('input[placeholder="例如 Long Beach"]', 'Long Beach')
setField('input[placeholder="例如 90802"]', '90802')
setField('textarea[placeholder="请输入街道和门牌信息"]', '2400 E Spring St')
const saveButton = [...document.querySelectorAll('button')].find((button) => button.textContent?.trim() === '确认新增')
expect(saveButton).toBeDefined()
await saveButton?.trigger('click')
saveButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await flushPromises()
expect(createFbaWarehouse).toHaveBeenCalledWith({
warehouseCode: 'LAX9',
stateProvince: 'CA',
city: 'Long Beach',
postalCode: '90802',
addressLine1: '2400 E Spring St',
timezone: 'UTC-8',
})
expect(createPlatformWarehouse).not.toHaveBeenCalled()
})
it('hides domestic country, timezone and type inputs while submitting fixed CN and UTC+8 defaults', async () => {
const wrapper = await mountView()
wrapper.findComponent({ name: 'ElDropdown' }).vm.$emit('command', 'DOMESTIC')
await flushPromises()
const drawerText = document.querySelector('.el-drawer')?.textContent || ''
expect(drawerText).not.toContain('国家或地区')
expect(drawerText).not.toContain('时区')
expect(drawerText).not.toContain('仓库类型')
setField('input[placeholder="请输入仓库名称"]', '义乌新仓')
setField('input[placeholder="例如 浙江省"]', '浙江省')
setField('input[placeholder="例如 义乌市"]', '义乌市')
setField('textarea[placeholder="请输入街道、园区、楼栋及门牌信息"]', '稠州北路 100 号')
const saveButton = [...document.querySelectorAll('button')].find((button) => button.textContent?.trim() === '确认新增')
expect(saveButton).toBeDefined()
saveButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await flushPromises()
expect(createPlatformWarehouse).toHaveBeenCalledOnce()
expect(vi.mocked(createPlatformWarehouse).mock.calls[0][0]).toMatchObject({
warehouseName: '义乌新仓',
warehouseType: 'DOMESTIC',
countryCode: 'CN',
timezone: 'UTC+8',
address: expect.objectContaining({ countryCode: 'CN', city: '义乌市' }),
})
})
it('uses country and state selectors for overseas warehouse creation and auto-fills timezone when safe', async () => {
const wrapper = await mountView()
wrapper.findComponent({ name: 'ElDropdown' }).vm.$emit('command', 'OVERSEAS')
await flushPromises()
const countrySelect = wrapper.findAllComponents({ name: 'ElSelect' })
.find((select) => select.props('placeholder') === '请选择国家或地区')
expect(countrySelect).toBeDefined()
expect(countrySelect!.props('modelValue')).toBe('US')
const stateSelect = wrapper.findAllComponents({ name: 'ElSelect' })
.find((select) => select.props('placeholder') === '请选择州')
expect(stateSelect).toBeDefined()
const timezoneSelect = wrapper.findAllComponents({ name: 'ElSelect' })
.find((select) => select.props('placeholder') === '请选择时区')
expect(timezoneSelect).toBeDefined()
expect(wrapper.findAllComponents({ name: 'ElOption' }).some((option) => option.props('value') === 'UTC-8')).toBe(true)
setField('input[placeholder="请输入仓库名称"]', '洛杉矶海外仓')
stateSelect!.vm.$emit('update:modelValue', 'CA')
stateSelect!.vm.$emit('change', 'CA')
setField('input[placeholder="例如 Long Beach"]', 'Long Beach')
setField('textarea[placeholder="请输入街道、园区、楼栋及门牌信息"]', '2400 E Spring St')
const saveButton = [...document.querySelectorAll('button')].find((button) => button.textContent?.trim() === '确认新增')
expect(saveButton).toBeDefined()
saveButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await flushPromises()
expect(createPlatformWarehouse).toHaveBeenCalledOnce()
expect(vi.mocked(createPlatformWarehouse).mock.calls[0][0]).toMatchObject({
warehouseName: '洛杉矶海外仓',
warehouseType: 'OVERSEAS',
countryCode: 'US',
timezone: 'UTC-8',
address: expect.objectContaining({ stateProvince: 'CA' }),
})
})
it('edits FBA warehouses with selectors for country, timezone and state', async () => {
vi.mocked(listPlatformWarehouses).mockResolvedValueOnce({ records: [fbaWarehouse, domesticWarehouse], page: 1, size: 20, total: 2, pages: 1 })
vi.mocked(getPlatformWarehouse).mockResolvedValueOnce(fbaDetail)
const wrapper = await mountView()
const editButton = wrapper.find('.el-table__body-wrapper .table-actions button')
expect(editButton).toBeDefined()
await editButton.trigger('click')
await flushPromises()
const drawerText = document.querySelector('.el-drawer')?.textContent || ''
expect(drawerText).toContain('编辑FBA 地址')
expect(drawerText).not.toContain('例如 US')
expect(drawerText).not.toContain('例如 UTC-8')
const countrySelect = wrapper.findAllComponents({ name: 'ElSelect' })
.find((select) => select.props('placeholder') === '请选择国家或地区')
const stateSelect = wrapper.findAllComponents({ name: 'ElSelect' })
.find((select) => select.props('placeholder') === '请选择州')
const timezoneSelect = wrapper.findAllComponents({ name: 'ElSelect' })
.find((select) => select.props('placeholder') === '请选择时区')
expect(countrySelect?.props('modelValue')).toBe('US')
expect(stateSelect?.props('modelValue')).toBe('OH')
expect(timezoneSelect?.props('modelValue')).toBe('UTC-5')
expect(countrySelect).toBeDefined()
const saveButton = [...document.querySelectorAll('button')].find((button) => button.textContent?.trim() === '保存修改')
expect(saveButton).toBeDefined()
saveButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await flushPromises()
expect(updatePlatformWarehouse).toHaveBeenCalledOnce()
const [, , payload] = vi.mocked(updatePlatformWarehouse).mock.calls[0]
expect(payload.contactEmail).toBe('warehouse@example.com')
const [, payload] = vi.mocked(updatePlatformWarehouse).mock.calls[0]
expect(payload).toMatchObject({
tenantId: 10001, warehouseType: 'FBA', countryCode: 'US', timezone: 'America/Los_Angeles',
})
expect(payload.address).toMatchObject({
countryCode: 'US', district: '北苑街道', postalCode: '322000', addressLine1: '真实物流园 1 号库', addressLine2: 'A 栋',
latitude: '29.3', longitude: '120.0',
warehouseType: 'FBA',
countryCode: 'US',
timezone: 'UTC-5',
address: expect.objectContaining({ stateProvince: 'OH' }),
})
})
it('edits existing records with full fields and preserves backend-only capabilities', async () => {
const wrapper = await mountView()
const editButton = wrapper.find('.el-table__body-wrapper .table-actions button')
expect(editButton).toBeDefined()
await editButton.trigger('click')
await flushPromises()
const saveButton = [...document.querySelectorAll('button')].find((button) => button.textContent?.trim() === '保存修改')
expect(saveButton).toBeDefined()
saveButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await flushPromises()
expect(getPlatformWarehouse).toHaveBeenCalledWith('19001')
expect(updatePlatformWarehouse).toHaveBeenCalledOnce()
const [, payload] = vi.mocked(updatePlatformWarehouse).mock.calls[0]
expect(payload.contactEmail).toBe('warehouse@example.com')
expect(payload.capabilities).toContainEqual(expect.objectContaining({
capabilityCode: 'RECEIVING', cutoffTime: '17:00:00', description: '周一至周六 08:30-18:00',
capabilityCode: 'RECEIVING',
cutoffTime: '17:00:00',
description: '周一至周六 08:30-18:00',
}))
expect(payload.capabilities).toContainEqual(expect.objectContaining({ capabilityCode: 'STORAGE', description: '托盘仓储' }))
})
it('shows field-level required errors and does not submit an empty warehouse', async () => {
it('renders FBA import precheck totals and row-level errors without committing by default', async () => {
const wrapper = await mountView()
const createButton = wrapper.findAll('button').find((button) => button.text().trim() === '新增仓库')
expect(createButton).toBeDefined()
await createButton?.trigger('click')
await wrapper.findAll('button').find((button) => button.text().trim() === '导入仓库')?.trigger('click')
await flushPromises()
expect(document.querySelector('input[placeholder="例如 广东省"]')).not.toBeNull()
expect(document.querySelector('input[placeholder="例如 CN、US"]')).not.toBeNull()
expect(document.querySelector('input[placeholder="例如 Asia/Shanghai"]')).not.toBeNull()
expect(document.querySelector('input[placeholder="请输入仓库联系人姓名"]')).not.toBeNull()
expect(document.querySelector('textarea[placeholder="请输入街道、园区、楼栋及库位信息"]')).not.toBeNull()
await wrapper.find('input[placeholder="例如 CN-NB-01"]').trigger('blur')
await wrapper.vm.$nextTick()
expect(wrapper.find('#warehouse-code-error').text()).toBe('请输入仓库编码')
const saveButton = wrapper.findAll('button').find((button) => button.text().trim() === '保存仓库')
expect(saveButton).toBeDefined()
await saveButton?.trigger('click')
const upload = wrapper.findComponent({ name: 'ElUpload' })
const file = new File(['content'], 'fba.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
upload.props('onChange')({ name: file.name, raw: file })
await flushPromises()
expect(createPlatformWarehouse).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('请输入仓库编码')
expect(wrapper.text()).toContain('请输入仓库名称')
expect(wrapper.text()).toContain('请输入城市')
expect(wrapper.text()).toContain('请输入详细地址')
const precheckButton = [...document.querySelectorAll('button')].find((button) => button.textContent?.trim() === '预检文件')
expect(precheckButton).toBeDefined()
precheckButton!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await flushPromises()
expect(importWarehouses).toHaveBeenCalledWith(file, true, 'CREATE_ONLY')
expect(document.body.textContent).toContain('新增')
expect(document.body.textContent).toContain('更新')
expect(document.body.textContent).toContain('无变化')
expect(document.body.textContent).toContain('错误')
expect(document.body.textContent).toContain('文件内编码重复')
expect(importWarehouses).toHaveBeenCalledTimes(1)
})
})
+531 -123
View File
@@ -1,43 +1,135 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { MapPin, Pencil, Plus, Power, Warehouse as WarehouseIcon } from '@lucide/vue'
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
import { ChevronDown, Download, FileSpreadsheet, MapPin, Pencil, Plus, Power, Upload, Warehouse as WarehouseIcon } from '@lucide/vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import {
changePlatformWarehouseStatus,
createFbaWarehouse,
createPlatformWarehouse,
downloadWarehouseImportTemplate,
getPlatformWarehouse,
importWarehouses,
listPlatformWarehouses,
updatePlatformWarehouse,
type WarehouseImportMode,
type WarehouseImportResult,
type PlatformWarehouseDetailResource,
type PlatformWarehouseResource,
type PlatformWarehouseSummaryResource,
type SavePlatformWarehouseRequest,
type WarehouseType,
} from '@/modules/warehouses/api/platformWarehouses'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import type { Warehouse } from '@/modules/warehouses/types'
const DEFAULT_TENANT_ID = 10001
const warehouseTypeLabels: Record<WarehouseType, string> = {
DOMESTIC: '国内收货仓',
FBA: 'FBA 仓',
FBA: 'FBA 地址',
OVERSEAS: '海外仓',
}
const timezoneOptions = [
'UTC-12', 'UTC-11', 'UTC-10', 'UTC-9', 'UTC-8', 'UTC-7', 'UTC-6', 'UTC-5', 'UTC-4', 'UTC-3',
'UTC-2', 'UTC-1', 'UTC+0', 'UTC+1', 'UTC+2', 'UTC+3', 'UTC+4', 'UTC+5', 'UTC+6', 'UTC+7',
'UTC+8', 'UTC+9', 'UTC+10', 'UTC+11', 'UTC+12', 'UTC+13', 'UTC+14',
]
const countryOptions = [
{ label: '美国 US', value: 'US' },
{ label: '加拿大 CA', value: 'CA' },
{ label: '英国 GB', value: 'GB' },
{ label: '德国 DE', value: 'DE' },
{ label: '法国 FR', value: 'FR' },
{ label: '意大利 IT', value: 'IT' },
{ label: '西班牙 ES', value: 'ES' },
{ label: '日本 JP', value: 'JP' },
{ label: '澳大利亚 AU', value: 'AU' },
]
const countryDisplayLabels: Record<string, string> = {
CN: '中国',
US: '美国',
CA: '加拿大',
GB: '英国',
DE: '德国',
FR: '法国',
IT: '意大利',
ES: '西班牙',
JP: '日本',
AU: '澳大利亚',
}
const usStateOptions = [
{ label: 'AL Alabama', value: 'AL', timezone: 'UTC-6' },
{ label: 'AK Alaska', value: 'AK', timezone: 'UTC-9' },
{ label: 'AZ Arizona', value: 'AZ', timezone: 'UTC-7' },
{ label: 'AR Arkansas', value: 'AR', timezone: 'UTC-6' },
{ label: 'CA California', value: 'CA', timezone: 'UTC-8' },
{ label: 'CO Colorado', value: 'CO', timezone: 'UTC-7' },
{ label: 'CT Connecticut', value: 'CT', timezone: 'UTC-5' },
{ label: 'DE Delaware', value: 'DE', timezone: 'UTC-5' },
{ label: 'FL Florida', value: 'FL', timezone: null },
{ label: 'GA Georgia', value: 'GA', timezone: 'UTC-5' },
{ label: 'HI Hawaii', value: 'HI', timezone: 'UTC-10' },
{ label: 'IA Iowa', value: 'IA', timezone: 'UTC-6' },
{ label: 'ID Idaho', value: 'ID', timezone: null },
{ label: 'IL Illinois', value: 'IL', timezone: 'UTC-6' },
{ label: 'IN Indiana', value: 'IN', timezone: null },
{ label: 'KS Kansas', value: 'KS', timezone: null },
{ label: 'KY Kentucky', value: 'KY', timezone: null },
{ label: 'LA Louisiana', value: 'LA', timezone: 'UTC-6' },
{ label: 'MA Massachusetts', value: 'MA', timezone: 'UTC-5' },
{ label: 'MD Maryland', value: 'MD', timezone: 'UTC-5' },
{ label: 'ME Maine', value: 'ME', timezone: 'UTC-5' },
{ label: 'MI Michigan', value: 'MI', timezone: null },
{ label: 'MN Minnesota', value: 'MN', timezone: 'UTC-6' },
{ label: 'MO Missouri', value: 'MO', timezone: 'UTC-6' },
{ label: 'MS Mississippi', value: 'MS', timezone: 'UTC-6' },
{ label: 'MT Montana', value: 'MT', timezone: 'UTC-7' },
{ label: 'NC North Carolina', value: 'NC', timezone: 'UTC-5' },
{ label: 'ND North Dakota', value: 'ND', timezone: null },
{ label: 'NE Nebraska', value: 'NE', timezone: null },
{ label: 'NH New Hampshire', value: 'NH', timezone: 'UTC-5' },
{ label: 'NJ New Jersey', value: 'NJ', timezone: 'UTC-5' },
{ label: 'NM New Mexico', value: 'NM', timezone: 'UTC-7' },
{ label: 'NV Nevada', value: 'NV', timezone: 'UTC-8' },
{ label: 'NY New York', value: 'NY', timezone: 'UTC-5' },
{ label: 'OH Ohio', value: 'OH', timezone: 'UTC-5' },
{ label: 'OK Oklahoma', value: 'OK', timezone: 'UTC-6' },
{ label: 'OR Oregon', value: 'OR', timezone: null },
{ label: 'PA Pennsylvania', value: 'PA', timezone: 'UTC-5' },
{ label: 'RI Rhode Island', value: 'RI', timezone: 'UTC-5' },
{ label: 'SC South Carolina', value: 'SC', timezone: 'UTC-5' },
{ label: 'SD South Dakota', value: 'SD', timezone: null },
{ label: 'TN Tennessee', value: 'TN', timezone: null },
{ label: 'TX Texas', value: 'TX', timezone: null },
{ label: 'VA Virginia', value: 'VA', timezone: 'UTC-5' },
{ label: 'VT Vermont', value: 'VT', timezone: 'UTC-5' },
{ label: 'WA Washington', value: 'WA', timezone: 'UTC-8' },
{ label: 'WI Wisconsin', value: 'WI', timezone: 'UTC-6' },
{ label: 'WV West Virginia', value: 'WV', timezone: 'UTC-5' },
{ label: 'WY Wyoming', value: 'WY', timezone: 'UTC-7' },
]
const access = useAccessPreviewStore()
const editorOpen = ref(false)
const editorMode = ref<'CREATE' | 'EDIT'>('CREATE')
const createType = ref<WarehouseType>('FBA')
const saving = ref(false)
const loading = ref(false)
const editLoading = ref(false)
const page = ref(1)
const pageSize = ref(10)
const pageSize = ref(20)
const total = ref(0)
const loaded = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const detailWarningCount = ref(0)
const statusLoadingIds = ref(new Set<string>())
const importOpen = ref(false)
const importFile = ref<File | null>(null)
const importMode = ref<WarehouseImportMode>('CREATE_ONLY')
const importResult = ref<WarehouseImportResult | null>(null)
const importing = ref(false)
const downloadingTemplate = ref(false)
type WarehouseFilters = QueryModel & { keyword: string; warehouseType: WarehouseType | ''; status: Warehouse['status'] | '' }
const filters = ref<WarehouseFilters>({ keyword: '', warehouseType: '', status: '' })
const appliedFilters = ref<WarehouseFilters>({ ...filters.value })
@@ -63,20 +155,20 @@ interface WarehouseRow extends Warehouse {
warehouseType: WarehouseType
countryCode: string
timezone: string
postalCode: string
detail: PlatformWarehouseDetailResource | null
}
const rows = ref<WarehouseRow[]>([])
const warehouseColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' },
{ key: 'warehouse', label: '仓库', minWidth: 200, slotName: 'warehouse' },
{ key: 'warehouseType', label: '仓库类型', width: 130, slotName: 'warehouseType' },
{ prop: 'countryCode', label: '国家/地区码', width: 120 },
{ prop: 'timezone', label: '时区', minWidth: 170 },
{ key: 'city', label: '所在城市', minWidth: 130, slotName: 'city' },
{ key: 'address', label: '收货地址', minWidth: 260, slotName: 'address' },
{ key: 'warehouse', label: '仓库', minWidth: 190, slotName: 'warehouse' },
{ key: 'warehouseType', label: '仓库类型', width: 120, slotName: 'warehouseType' },
{ key: 'countryCode', label: '国家/地区', width: 140, slotName: 'countryCode' },
{ prop: 'timezone', label: '时区', width: 110 },
{ key: 'city', label: '所在城市', minWidth: 140, slotName: 'city' },
{ key: 'address', label: '地址', minWidth: 280, slotName: 'address' },
{ key: 'contact', label: '联系人', minWidth: 150, slotName: 'contact' },
{ prop: 'businessHours', label: '收货时间', minWidth: 190, formatter: (row: WarehouseRow) => row.businessHours || '-' },
{ key: 'status', label: '状态', width: 100, slotName: 'status' },
]
@@ -84,27 +176,67 @@ type WarehouseForm = Omit<Warehouse, 'updatedAt'> & {
warehouseType: WarehouseType
countryCode: string
timezone: string
postalCode: string
}
const emptyForm = (): WarehouseForm => ({
id: '', code: '', name: '', province: '', city: '', address: '', contactName: '', contactPhone: '',
warehouseType: 'DOMESTIC', countryCode: 'CN', timezone: 'Asia/Shanghai',
businessHours: '周一至周六 08:30-18:00', status: 'ACTIVE', remark: '',
function generatedCode(type: WarehouseType) {
const prefix = type === 'DOMESTIC' ? 'CN' : type === 'FBA' ? 'FBA' : 'US'
return `${prefix}-WH-${Date.now().toString(36).slice(-7).toUpperCase()}`
}
const emptyForm = (type: WarehouseType = 'FBA'): WarehouseForm => ({
id: '',
code: type === 'FBA' ? '' : generatedCode(type),
name: '',
province: '',
city: '',
address: '',
postalCode: '',
contactName: '',
contactPhone: '',
warehouseType: type,
countryCode: type === 'DOMESTIC' ? 'CN' : 'US',
timezone: type === 'DOMESTIC' ? 'UTC+8' : '',
businessHours: type === 'FBA' ? '' : '周一至周六 08:30-18:00',
status: 'ACTIVE',
remark: '',
})
const form = reactive(emptyForm())
type WarehouseRequiredField = 'code' | 'name' | 'countryCode' | 'timezone' | 'city' | 'address'
type WarehouseRequiredField = 'code' | 'name' | 'province' | 'countryCode' | 'timezone' | 'city' | 'postalCode' | 'address'
const requiredFieldMessages: Record<WarehouseRequiredField, string> = {
code: '请输入仓库编码',
name: '请输入仓库名称',
countryCode: '请输入国家或地区二字码',
timezone: '请输入 IANA 时区',
province: '请选择或输入州/省',
countryCode: '请选择国家或地区',
timezone: '请输入时区',
city: '请输入城市',
postalCode: '请输入邮编',
address: '请输入详细地址',
}
const formErrors = reactive<Record<WarehouseRequiredField, string>>({
code: '', name: '', countryCode: '', timezone: '', city: '', address: '',
code: '', name: '', province: '', countryCode: '', timezone: '', city: '', postalCode: '', address: '',
})
const editorLabel = computed(() => form.id ? '编辑公共仓库' : '新增公共仓库')
const editorPermission = computed(() => form.id ? 'platform:warehouse:edit' : 'platform:warehouse:create')
const editorLabel = computed(() => editorMode.value === 'EDIT'
? `编辑${warehouseTypeLabels[form.warehouseType]}`
: `新增${warehouseTypeLabels[createType.value]}`)
const editorPermission = computed(() => editorMode.value === 'EDIT' ? 'platform:warehouse:edit' : 'platform:warehouse:create')
const isFbaCreate = computed(() => editorMode.value === 'CREATE' && createType.value === 'FBA')
const isDomesticCreate = computed(() => editorMode.value === 'CREATE' && createType.value === 'DOMESTIC')
const activeWarehouseType = computed(() => editorMode.value === 'EDIT' ? form.warehouseType : createType.value)
const isDomesticForm = computed(() => activeWarehouseType.value === 'DOMESTIC')
const usesUsStateSelect = computed(() => !isDomesticForm.value && form.countryCode === 'US')
const addressRegionLabel = computed(() => usesUsStateSelect.value || isFbaCreate.value ? '州' : '省 / 州')
const regionPlaceholder = computed(() => isDomesticForm.value ? '例如 浙江省' : usesUsStateSelect.value ? '请选择州' : '例如 Ontario')
const cityPlaceholder = computed(() => isDomesticForm.value ? '例如 义乌市' : '例如 Long Beach')
const addressPlaceholder = computed(() => isFbaCreate.value ? '请输入街道和门牌信息' : '请输入街道、园区、楼栋及门牌信息')
const canCommitImport = computed(() => Boolean(
importFile.value
&& importResult.value
&& !importResult.value.committed
&& importResult.value.errorCount === 0
&& (importMode.value === 'CREATE_ONLY' || access.can('platform:warehouse:edit')),
))
const { captureBaseline } = useUnsavedWorkRegistration({
key: 'warehouse-editor',
label: editorLabel,
@@ -114,15 +246,6 @@ const { captureBaseline } = useUnsavedWorkRegistration({
})
const warehouseReady = computed(() => loaded.value && !loading.value)
const filteredRows = computed(() => rows.value.filter((warehouse) => {
const keyword = appliedFilters.value.keyword.trim().toLowerCase()
const matchesKeyword = !keyword || [warehouse.code, warehouse.name, warehouse.province, warehouse.city, warehouse.address]
.some((value) => value.toLowerCase().includes(keyword))
return matchesKeyword
&& (!appliedFilters.value.warehouseType || warehouse.warehouseType === appliedFilters.value.warehouseType)
&& (!appliedFilters.value.status || warehouse.status === appliedFilters.value.status)
}))
const pagedRows = computed(() => filteredRows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
const loadErrorDescription = computed(() => {
if (!loadError.value) return ''
const references = [
@@ -143,10 +266,28 @@ watch(
},
{ flush: 'sync' },
)
watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) => {
if (nextPage === previousPage && nextSize === previousSize) return
void loadWarehouses()
})
watch(importMode, () => { importResult.value = null })
watch(activeWarehouseType, (type) => {
if (type === 'DOMESTIC') {
form.countryCode = 'CN'
form.timezone = 'UTC+8'
} else if (form.countryCode === 'CN') {
form.countryCode = 'US'
form.province = ''
form.timezone = ''
}
clearFieldError('countryCode')
clearFieldError('timezone')
})
function toRow(warehouse: PlatformWarehouseResource, detail: PlatformWarehouseDetailResource | null): WarehouseRow {
const address = detail?.addresses[0]
const receiving = detail?.capabilities.find((item) => item.capabilityCode === 'RECEIVING')
function toRow(warehouse: PlatformWarehouseSummaryResource): WarehouseRow {
const address = warehouse.primaryAddress
const addressText = warehouse.addressSummary
|| [address?.district, address?.addressLine1, address?.addressLine2].filter(Boolean).join(' ')
return {
id: String(warehouse.id),
code: warehouse.warehouseCode,
@@ -156,14 +297,15 @@ function toRow(warehouse: PlatformWarehouseResource, detail: PlatformWarehouseDe
timezone: warehouse.timezone,
province: address?.stateProvince || '',
city: address?.city || '',
address: [address?.district, address?.addressLine1, address?.addressLine2].filter(Boolean).join(' '),
address: addressText,
postalCode: address?.postalCode || '',
contactName: warehouse.contactName || '',
contactPhone: warehouse.contactPhone || '',
businessHours: receiving?.description || (receiving?.cutoffTime ? `每日截单 ${receiving.cutoffTime}` : ''),
businessHours: '',
status: warehouse.status,
remark: warehouse.remark || '',
updatedAt: warehouse.updatedAt?.replace('T', ' ').slice(0, 16) || '',
detail,
detail: null,
}
}
@@ -173,8 +315,8 @@ function queryWarehouses(model: QueryModel) {
warehouseType: (model.warehouseType || '') as WarehouseType | '',
status: (model.status || '') as Warehouse['status'] | '',
}
page.value = 1
void loadWarehouses()
if (page.value === 1) void loadWarehouses()
else page.value = 1
}
function clearWarehouseFormErrors() {
@@ -185,15 +327,56 @@ function clearFieldError(field: WarehouseRequiredField) {
if (formErrors[field]) formErrors[field] = ''
}
function validateRequiredField(field: WarehouseRequiredField) {
formErrors[field] = form[field].trim() ? '' : requiredFieldMessages[field]
return !formErrors[field]
function formatCountryDisplay(countryCode: string) {
const code = countryCode.trim().toUpperCase()
if (!code) return '-'
const label = countryDisplayLabels[code]
return label ? `${label} ${code}` : code
}
function validateFields(fields: WarehouseRequiredField[]) {
return fields.map((field) => {
formErrors[field] = form[field].trim() ? '' : requiredFieldMessages[field]
return !formErrors[field]
}).every(Boolean)
}
function validateWarehouseForm() {
return (Object.keys(requiredFieldMessages) as WarehouseRequiredField[])
.map(validateRequiredField)
.every(Boolean)
if (isFbaCreate.value) return validateFields(['code', 'province', 'city', 'postalCode', 'timezone', 'address'])
const fields: WarehouseRequiredField[] = ['code', 'name', 'countryCode', 'timezone', 'city', 'address']
if (usesUsStateSelect.value) fields.splice(4, 0, 'province')
return validateFields(fields)
}
function onCountryChange(value: string) {
form.countryCode = value.toUpperCase()
clearFieldError('countryCode')
if (!usesUsStateSelect.value) {
form.province = ''
form.timezone = ''
clearFieldError('province')
return
}
const matchedState = usStateOptions.find((state) => state.value === form.province.toUpperCase())
if (!matchedState) {
form.province = ''
form.timezone = ''
return
}
onRegionChange(matchedState.value)
}
function onRegionChange(value: string) {
form.province = value.toUpperCase()
clearFieldError('province')
if (!usesUsStateSelect.value) return
const matchedState = usStateOptions.find((state) => state.value === form.province)
if (matchedState?.timezone) {
form.timezone = matchedState.timezone
clearFieldError('timezone')
} else {
form.timezone = ''
}
}
async function loadWarehouses() {
@@ -201,18 +384,17 @@ async function loadWarehouses() {
loading.value = true
loadError.value = null
try {
const result = await listPlatformWarehouses(DEFAULT_TENANT_ID, 1, 200)
const details = await Promise.allSettled(
result.records.map((warehouse) => getPlatformWarehouse(DEFAULT_TENANT_ID, warehouse.id)),
)
if (sequence !== loadSequence) return
rows.value = result.records.map((warehouse, index) => {
const detailResult = details[index]
return toRow(warehouse, detailResult?.status === 'fulfilled' ? detailResult.value : null)
const result = await listPlatformWarehouses({
page: page.value,
size: pageSize.value,
keyword: appliedFilters.value.keyword.trim() || undefined,
warehouseType: appliedFilters.value.warehouseType || undefined,
status: appliedFilters.value.status || undefined,
})
detailWarningCount.value = details.filter((item) => item.status === 'rejected').length
if (sequence !== loadSequence) return
rows.value = result.records.map(toRow)
total.value = result.total
loaded.value = true
page.value = 1
} catch (error) {
if (sequence !== loadSequence) return
loadError.value = getApiErrorDetails(error, '仓库数据加载失败,请稍后重试')
@@ -229,9 +411,11 @@ function requireWarehouseReady() {
return true
}
function openCreate() {
function openCreate(type: WarehouseType) {
if (!requireWarehouseReady()) return
Object.assign(form, emptyForm())
editorMode.value = 'CREATE'
createType.value = type
Object.assign(form, emptyForm(type))
clearWarehouseFormErrors()
captureBaseline()
editorOpen.value = true
@@ -241,9 +425,8 @@ async function ensureDetail(warehouse: WarehouseRow) {
if (warehouse.detail) return warehouse.detail
if (!requireWarehouseReady()) return null
try {
const detail = await getPlatformWarehouse(DEFAULT_TENANT_ID, warehouse.id)
Object.assign(warehouse, toRow(detail.warehouse, detail))
detailWarningCount.value = rows.value.filter((item) => !item.detail).length
const detail = await getPlatformWarehouse(warehouse.id)
warehouse.detail = detail
return detail
} catch (error) {
const details = getApiErrorDetails(error, '仓库详情加载失败,请重试')
@@ -258,13 +441,24 @@ async function openEdit(warehouse: WarehouseRow) {
const detail = await ensureDetail(warehouse)
if (!detail || !loaded.value) return
const address = detail.addresses[0]
Object.assign(form, warehouse, {
province: address?.stateProvince || '',
city: address?.city || '',
address: address?.addressLine1 || '',
const receiving = detail.capabilities.find((item) => item.capabilityCode === 'RECEIVING')
editorMode.value = 'EDIT'
Object.assign(form, {
id: String(detail.warehouse.id),
code: detail.warehouse.warehouseCode,
name: detail.warehouse.warehouseName,
warehouseType: detail.warehouse.warehouseType,
countryCode: detail.warehouse.countryCode,
timezone: detail.warehouse.timezone,
province: address?.stateProvince || '',
city: address?.city || '',
address: address?.addressLine1 || '',
postalCode: address?.postalCode || '',
contactName: detail.warehouse.contactName || '',
contactPhone: detail.warehouse.contactPhone || '',
businessHours: receiving?.description || '',
status: detail.warehouse.status,
remark: detail.warehouse.remark || '',
})
clearWarehouseFormErrors()
captureBaseline()
@@ -278,10 +472,12 @@ function nullable(value: string) {
return value.trim() || null
}
function buildSaveRequest(tenantId: number | string): SavePlatformWarehouseRequest {
function buildSaveRequest(): SavePlatformWarehouseRequest {
const existing = form.id ? rows.value.find((item) => item.id === form.id)?.detail : null
const warehouse = existing?.warehouse
const currentAddress = existing?.addresses[0]
const countryCode = isDomesticForm.value ? 'CN' : form.countryCode.trim().toUpperCase()
const timezone = isDomesticForm.value ? 'UTC+8' : form.timezone.trim()
const capabilities = (existing?.capabilities || []).map((capability) => ({
capabilityCode: capability.capabilityCode,
capabilityName: capability.capabilityName,
@@ -289,22 +485,18 @@ function buildSaveRequest(tenantId: number | string): SavePlatformWarehouseReque
cutoffTime: capability.cutoffTime,
description: capability.capabilityCode === 'RECEIVING' ? nullable(form.businessHours) : capability.description,
}))
if (!capabilities.some((item) => item.capabilityCode === 'RECEIVING')) {
if (form.warehouseType !== 'FBA' && !capabilities.some((item) => item.capabilityCode === 'RECEIVING')) {
capabilities.push({
capabilityCode: 'RECEIVING',
capabilityName: '收货服务',
enabled: true,
cutoffTime: null,
capabilityCode: 'RECEIVING', capabilityName: '收货服务', enabled: true, cutoffTime: null,
description: nullable(form.businessHours),
})
}
return {
tenantId,
warehouseCode: form.code.trim().toUpperCase(),
warehouseName: form.name.trim(),
warehouseType: form.warehouseType,
countryCode: form.countryCode.trim().toUpperCase(),
timezone: form.timezone.trim(),
countryCode,
timezone,
contactName: nullable(form.contactName),
contactPhone: nullable(form.contactPhone),
contactEmail: warehouse?.contactEmail || null,
@@ -312,11 +504,11 @@ function buildSaveRequest(tenantId: number | string): SavePlatformWarehouseReque
remark: nullable(form.remark),
address: {
addressType: currentAddress?.addressType || 'OPERATING',
countryCode: form.countryCode.trim().toUpperCase(),
countryCode,
stateProvince: nullable(form.province),
city: form.city.trim(),
district: currentAddress?.district || null,
postalCode: currentAddress?.postalCode || null,
postalCode: nullable(form.postalCode),
addressLine1: form.address.trim(),
addressLine2: currentAddress?.addressLine2 || null,
latitude: currentAddress?.latitude || null,
@@ -327,17 +519,27 @@ function buildSaveRequest(tenantId: number | string): SavePlatformWarehouseReque
}
async function saveWarehouse() {
if (!requireWarehouseReady()) return
if (!validateWarehouseForm()) return
if (!requireWarehouseReady() || !validateWarehouseForm()) return
saving.value = true
try {
const payload = buildSaveRequest(DEFAULT_TENANT_ID)
if (form.id) await updatePlatformWarehouse(DEFAULT_TENANT_ID, form.id, payload)
else await createPlatformWarehouse(payload)
const successMessage = form.id ? '仓库资料已更新' : '公共仓库已创建'
if (isFbaCreate.value) {
await createFbaWarehouse({
warehouseCode: form.code.trim().toUpperCase(),
stateProvince: form.province.trim().toUpperCase(),
city: form.city.trim(),
postalCode: form.postalCode.trim(),
addressLine1: form.address.trim(),
timezone: form.timezone.trim() || null,
})
} else {
const payload = buildSaveRequest()
if (editorMode.value === 'EDIT') await updatePlatformWarehouse(form.id, payload)
else await createPlatformWarehouse(payload)
}
editorOpen.value = false
ElMessage.success(successMessage)
await loadWarehouses()
ElMessage.success(editorMode.value === 'EDIT' ? '仓库资料已更新' : `${warehouseTypeLabels[createType.value]}已创建`)
if (page.value === 1) await loadWarehouses()
else page.value = 1
} catch (error) {
const details = getApiErrorDetails(error, '仓库保存失败,请检查后重试')
ElMessage.error(details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message)
@@ -359,7 +561,7 @@ async function toggleStatus(warehouse: WarehouseRow) {
{ confirmButtonText: `确认${targetText}`, cancelButtonText: '取消', type: target === 'INACTIVE' ? 'warning' : 'info' },
)
statusLoadingIds.value = new Set(statusLoadingIds.value).add(warehouse.id)
await changePlatformWarehouseStatus(DEFAULT_TENANT_ID, warehouse.id, target)
await changePlatformWarehouseStatus(warehouse.id, target)
warehouse.status = target
if (warehouse.detail) warehouse.detail.warehouse.status = target
ElMessage.success(`仓库已${targetText}`)
@@ -374,6 +576,91 @@ async function toggleStatus(warehouse: WarehouseRow) {
}
}
function openImport() {
importFile.value = null
importMode.value = 'CREATE_ONLY'
importResult.value = null
importOpen.value = true
}
function selectImportFile(uploadFile: UploadFile) {
const file = uploadFile.raw ?? null
if (!file) return
if (!file.name.toLowerCase().endsWith('.xlsx')) {
importFile.value = null
ElMessage.error('请选择标准 XLSX 文件')
return
}
if (file.size > 5 * 1024 * 1024) {
importFile.value = null
ElMessage.error('文件不得超过 5MB')
return
}
importFile.value = file
importResult.value = null
}
function removeImportFile() {
importFile.value = null
importResult.value = null
}
function saveBlob(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = fileName
anchor.click()
URL.revokeObjectURL(url)
}
async function downloadTemplate() {
downloadingTemplate.value = true
try {
const blob = await downloadWarehouseImportTemplate()
saveBlob(blob, '公共仓库导入模板.xlsx')
} catch (error) {
ElMessage.error(getApiErrorDetails(error, '模板下载失败,请稍后重试').message)
} finally {
downloadingTemplate.value = false
}
}
async function runImport(dryRun: boolean) {
if (!importFile.value) {
ElMessage.warning('请先选择 XLSX 文件')
return
}
importing.value = true
try {
importResult.value = await importWarehouses(importFile.value, dryRun, importMode.value)
if (dryRun) {
ElMessage.success(importResult.value.errorCount ? '预检完成,请处理错误后重新上传' : '预检通过,可以提交导入')
} else if (importResult.value.committed) {
ElMessage.success('公共仓库已导入')
if (page.value === 1) await loadWarehouses()
else page.value = 1
}
} catch (error) {
ElMessage.error(getApiErrorDetails(error, dryRun ? '文件预检失败,请检查后重试' : '公共仓库导入失败,请重试').message)
} finally {
importing.value = false
}
}
function downloadImportErrors() {
if (!importResult.value?.errors.length) return
const escape = (value: string | number) => `"${String(value).replaceAll('"', '""')}"`
const lines = ['行号,字段,错误码,原因', ...importResult.value.errors.map((error) => (
[error.row, error.field, error.code || '-', error.message].map(escape).join(',')
))]
saveBlob(new Blob([`\uFEFF${lines.join('\r\n')}`], { type: 'text/csv;charset=utf-8' }), '公共仓库导入错误清单.csv')
}
function formatImportErrorCode(row: WarehouseImportResult['errors'][number]) {
return row.code || '-'
}
onMounted(() => void loadWarehouses())
</script>
@@ -384,89 +671,210 @@ onMounted(() => void loadWarehouses())
<p>{{ loadErrorDescription }}</p>
<el-button size="small" @click="loadWarehouses()">重新查询</el-button>
</el-alert>
<el-alert v-else-if="detailWarningCount" type="warning" :closable="false" show-icon class="state-alert">
<template #title>部分仓库详情暂未加载</template>
<p>{{ detailWarningCount }} 个仓库缺少地址或服务能力详情;编辑时会再次尝试加载,不会用模拟数据补齐。</p>
</el-alert>
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="仓库筛选" :fields="queryFields" @search="queryWarehouses" />
<section class="surface-panel table-panel" aria-live="polite">
<div class="panel-heading">
<div class="panel-heading warehouse-heading">
<div>
<h2>公共仓库</h2>
<p>国内收货仓、FBA 仓和海外仓统一维护 · 共 {{ filteredRows.length }} 个</p>
<p>国内收货仓、FBA 地址和海外仓统一维护 · 共 {{ total }} 个</p>
</div>
<div v-if="access.can('platform:warehouse:create')" class="warehouse-primary-actions">
<el-button :disabled="!warehouseReady" @click="openImport"><Upload :size="16" />导入仓库</el-button>
<el-dropdown trigger="click" :disabled="!warehouseReady" @command="openCreate($event as WarehouseType)">
<el-button type="primary" :disabled="!warehouseReady"><Plus :size="16" />新增仓库<ChevronDown :size="15" /></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="FBA"><FileSpreadsheet :size="15" />新增 FBA 地址</el-dropdown-item>
<el-dropdown-item command="DOMESTIC"><WarehouseIcon :size="15" />新增国内仓</el-dropdown-item>
<el-dropdown-item command="OVERSEAS"><MapPin :size="15" />新增海外仓</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<el-button
v-if="access.can('platform:warehouse:create')"
type="primary"
:disabled="!warehouseReady"
@click="openCreate"
><Plus :size="16" />新增仓库</el-button>
</div>
<DataTable
v-model:page="page"
v-model:page-size="pageSize"
:data="pagedRows"
:data="rows"
:columns="warehouseColumns"
:loading="loading"
:total="filteredRows.length"
:page-sizes="[10, 20, 50]"
:total="total"
:page-sizes="[20, 50, 100]"
row-key="id"
aria-label="公共仓库列表"
:empty-text="loaded ? '暂无符合筛选条件的仓库' : '仓库数据暂不可用,请重新加载'"
>
<template #actions="{ row }">
<div v-if="access.can('platform:warehouse:edit') || access.can('platform:warehouse:status')" class="table-actions">
<el-button v-if="access.can('platform:warehouse:edit')" link type="primary" :disabled="!warehouseReady" @click="openEdit(row as WarehouseRow)"><Pencil :size="15" />编辑</el-button>
<el-button
v-if="access.can('platform:warehouse:status')"
link
:type="(row as WarehouseRow).status === 'ACTIVE' ? 'danger' : 'success'"
:disabled="!warehouseReady"
:loading="statusLoadingIds.has((row as WarehouseRow).id)"
@click="toggleStatus(row as WarehouseRow)"
><Power :size="15" />{{ (row as WarehouseRow).status === 'ACTIVE' ? '停用' : '启用' }}</el-button>
</div>
<span v-else class="warehouse-subline">仅查看</span>
<div v-if="access.can('platform:warehouse:edit') || access.can('platform:warehouse:status')" class="table-actions">
<el-button v-if="access.can('platform:warehouse:edit')" link type="primary" :disabled="!warehouseReady || editLoading" @click="openEdit(row as WarehouseRow)"><Pencil :size="15" />编辑</el-button>
<el-button
v-if="access.can('platform:warehouse:status')"
link
:type="(row as WarehouseRow).status === 'ACTIVE' ? 'danger' : 'success'"
:disabled="!warehouseReady"
:loading="statusLoadingIds.has((row as WarehouseRow).id)"
@click="toggleStatus(row as WarehouseRow)"
><Power :size="15" />{{ (row as WarehouseRow).status === 'ACTIVE' ? '停用' : '启用' }}</el-button>
</div>
<span v-else class="warehouse-subline">仅查看</span>
</template>
<template #warehouse="{ row }"><div class="warehouse-cell"><span><WarehouseIcon :size="18" /></span><div><strong>{{ (row as WarehouseRow).name }}</strong><small>{{ (row as WarehouseRow).code }}</small></div></div></template>
<template #warehouseType="{ row }"><el-tag effect="plain">{{ warehouseTypeLabels[(row as WarehouseRow).warehouseType] }}</el-tag></template>
<template #countryCode="{ row }">{{ formatCountryDisplay((row as WarehouseRow).countryCode) }}</template>
<template #city="{ row }">{{ [(row as WarehouseRow).province, (row as WarehouseRow).city].filter(Boolean).join(' · ') || '-' }}</template>
<template #address="{ row }"><div class="warehouse-address"><MapPin :size="15" />{{ (row as WarehouseRow).address || '详情暂未加载' }}</div></template>
<template #address="{ row }"><div class="warehouse-address"><MapPin :size="15" /><span>{{ (row as WarehouseRow).address || '-' }}<small v-if="(row as WarehouseRow).postalCode">{{ (row as WarehouseRow).postalCode }}</small></span></div></template>
<template #contact="{ row }"><strong>{{ (row as WarehouseRow).contactName || '-' }}</strong><span class="warehouse-subline">{{ (row as WarehouseRow).contactPhone || '-' }}</span></template>
<template #status="{ row }"><el-tag :type="(row as WarehouseRow).status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ (row as WarehouseRow).status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></template>
<template #pagination-left>共 {{ filteredRows.length }} 个公共仓库</template>
<template #pagination-left="{ total: tableTotal }">共 {{ tableTotal }} 个公共仓库</template>
</DataTable>
</section>
<el-drawer v-model="editorOpen" :title="editorLabel" size="620px" class="business-drawer editor-drawer" :close-on-click-modal="false">
<el-form :model="form" label-position="top" class="drawer-form" :disabled="saving">
<div class="form-section"><h3>仓库标识</h3><div class="form-grid two-cols"><el-form-item label="仓库编码" required :class="{ 'has-field-error': formErrors.code }"><el-input v-model="form.code" maxlength="50" placeholder="例如 CN-NB-01" :aria-invalid="Boolean(formErrors.code)" :aria-describedby="formErrors.code ? 'warehouse-code-error' : undefined" @input="clearFieldError('code')" @blur="validateRequiredField('code')" /><p v-if="formErrors.code" id="warehouse-code-error" class="field-error" role="alert">{{ formErrors.code }}</p></el-form-item><el-form-item label="仓库名称" required :class="{ 'has-field-error': formErrors.name }"><el-input v-model="form.name" maxlength="120" placeholder="请输入客户下单时显示的名称" :aria-invalid="Boolean(formErrors.name)" :aria-describedby="formErrors.name ? 'warehouse-name-error' : undefined" @input="clearFieldError('name')" @blur="validateRequiredField('name')" /><p v-if="formErrors.name" id="warehouse-name-error" class="field-error" role="alert">{{ formErrors.name }}</p></el-form-item><el-form-item label="仓库类型" required><el-select v-model="form.warehouseType" placeholder="请选择仓库类型"><el-option v-for="(label, value) in warehouseTypeLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item><el-form-item label="状态"><el-radio-group v-model="form.status"><el-radio-button value="ACTIVE">启用</el-radio-button><el-radio-button value="INACTIVE">停用</el-radio-button></el-radio-group></el-form-item><el-form-item label="国家或地区二字码" required :class="{ 'has-field-error': formErrors.countryCode }"><el-input v-model="form.countryCode" maxlength="2" placeholder="例如 CN、US" :aria-invalid="Boolean(formErrors.countryCode)" :aria-describedby="formErrors.countryCode ? 'warehouse-country-error' : undefined" @input="form.countryCode = String($event).toUpperCase(); clearFieldError('countryCode')" @blur="validateRequiredField('countryCode')" /><p v-if="formErrors.countryCode" id="warehouse-country-error" class="field-error" role="alert">{{ formErrors.countryCode }}</p></el-form-item><el-form-item label="IANA 时区" required :class="{ 'has-field-error': formErrors.timezone }"><el-input v-model="form.timezone" maxlength="64" placeholder="例如 Asia/Shanghai" :aria-invalid="Boolean(formErrors.timezone)" :aria-describedby="formErrors.timezone ? 'warehouse-timezone-error' : undefined" @input="clearFieldError('timezone')" @blur="validateRequiredField('timezone')" /><p v-if="formErrors.timezone" id="warehouse-timezone-error" class="field-error" role="alert">{{ formErrors.timezone }}</p></el-form-item></div></div>
<div class="form-section"><h3>地址与收货</h3><div class="form-grid two-cols"><el-form-item label="省份"><el-input v-model="form.province" maxlength="100" placeholder="例如 广东省" /></el-form-item><el-form-item label="城市" required :class="{ 'has-field-error': formErrors.city }"><el-input v-model="form.city" maxlength="100" placeholder="例如 深圳市" :aria-invalid="Boolean(formErrors.city)" :aria-describedby="formErrors.city ? 'warehouse-city-error' : undefined" @input="clearFieldError('city')" @blur="validateRequiredField('city')" /><p v-if="formErrors.city" id="warehouse-city-error" class="field-error" role="alert">{{ formErrors.city }}</p></el-form-item><el-form-item label="联系人"><el-input v-model="form.contactName" maxlength="64" placeholder="请输入仓库联系人姓名" /></el-form-item><el-form-item label="联系电话"><el-input v-model="form.contactPhone" maxlength="32" placeholder="请输入手机号或固定电话" /></el-form-item><el-form-item label="收货时间"><el-input v-model="form.businessHours" maxlength="500" placeholder="例如 周一至周六 08:30-18:00" /></el-form-item></div><el-form-item label="详细地址" required :class="{ 'has-field-error': formErrors.address }"><el-input v-model="form.address" maxlength="255" type="textarea" :rows="3" placeholder="请输入街道、园区、楼栋及库位信息" :aria-invalid="Boolean(formErrors.address)" :aria-describedby="formErrors.address ? 'warehouse-address-error' : undefined" @input="clearFieldError('address')" @blur="validateRequiredField('address')" /><p v-if="formErrors.address" id="warehouse-address-error" class="field-error" role="alert">{{ formErrors.address }}</p></el-form-item></div>
<div class="form-section"><h3>备注</h3><el-form-item label="客户可理解的收货说明"><el-input v-model="form.remark" maxlength="500" type="textarea" :rows="3" show-word-limit placeholder="例如 预约要求、进仓指引或其他注意事项" /></el-form-item></div>
<div class="form-section">
<h3>基础信息</h3>
<div class="form-grid two-cols">
<el-form-item v-if="isFbaCreate" label="FBA 编码" required :class="{ 'has-field-error': formErrors.code }"><el-input v-model="form.code" maxlength="50" placeholder="例如 LAX9" @input="form.code = String($event).toUpperCase(); clearFieldError('code')" /><p v-if="formErrors.code" class="field-error" role="alert">{{ formErrors.code }}</p></el-form-item>
<el-form-item v-if="isFbaCreate" label="国家或地区" required>
<el-select v-model="form.countryCode" disabled placeholder="美国 US">
<el-option label="美国 US" value="US" />
</el-select>
</el-form-item>
<template v-else>
<el-form-item label="仓库名称" required :class="{ 'has-field-error': formErrors.name }"><el-input v-model="form.name" maxlength="120" placeholder="请输入仓库名称" @input="clearFieldError('name')" /><p v-if="formErrors.name" class="field-error" role="alert">{{ formErrors.name }}</p></el-form-item>
<el-form-item v-if="editorMode === 'EDIT'" label="仓库类型" required><el-select v-model="form.warehouseType"><el-option v-for="(label, value) in warehouseTypeLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
<el-form-item v-if="!isDomesticCreate" label="国家或地区" required :class="{ 'has-field-error': formErrors.countryCode }">
<el-select v-if="isDomesticForm" v-model="form.countryCode" disabled placeholder="中国 CN"><el-option label="中国 CN" value="CN" /></el-select>
<el-select v-else v-model="form.countryCode" filterable placeholder="请选择国家或地区" @change="onCountryChange"><el-option v-for="country in countryOptions" :key="country.value" :label="country.label" :value="country.value" /></el-select>
<p v-if="formErrors.countryCode" class="field-error" role="alert">{{ formErrors.countryCode }}</p>
</el-form-item>
<el-form-item v-if="!isDomesticCreate" label="时区" required :class="{ 'has-field-error': formErrors.timezone }">
<el-select v-if="isDomesticForm" v-model="form.timezone" disabled placeholder="UTC+8"><el-option label="UTC+8" value="UTC+8" /></el-select>
<el-select v-else v-model="form.timezone" filterable placeholder="请选择时区" @change="clearFieldError('timezone')"><el-option v-for="timezone in timezoneOptions" :key="timezone" :label="timezone" :value="timezone" /></el-select>
<p v-if="formErrors.timezone" class="field-error" role="alert">{{ formErrors.timezone }}</p>
</el-form-item>
</template>
</div>
</div>
<div class="form-section">
<h3>地址信息</h3>
<div class="form-grid two-cols">
<el-form-item :label="addressRegionLabel" :required="isFbaCreate || usesUsStateSelect" :class="{ 'has-field-error': formErrors.province }">
<el-select v-if="usesUsStateSelect" v-model="form.province" filterable :placeholder="regionPlaceholder" @change="onRegionChange"><el-option v-for="state in usStateOptions" :key="state.value" :label="state.label" :value="state.value" /></el-select>
<el-input v-else v-model="form.province" maxlength="100" :placeholder="regionPlaceholder" @input="isFbaCreate && (form.province = String($event).toUpperCase()); clearFieldError('province')" />
<p v-if="formErrors.province" class="field-error" role="alert">{{ formErrors.province }}</p>
</el-form-item>
<el-form-item label="城市" required :class="{ 'has-field-error': formErrors.city }"><el-input v-model="form.city" maxlength="100" :placeholder="cityPlaceholder" @input="clearFieldError('city')" /><p v-if="formErrors.city" class="field-error" role="alert">{{ formErrors.city }}</p></el-form-item>
<el-form-item label="邮编" :required="isFbaCreate" :class="{ 'has-field-error': formErrors.postalCode }"><el-input v-model="form.postalCode" maxlength="20" :placeholder="isDomesticForm ? '例如 322000' : '例如 90802'" @input="clearFieldError('postalCode')" /><p v-if="formErrors.postalCode" class="field-error" role="alert">{{ formErrors.postalCode }}</p></el-form-item>
</div>
<el-form-item label="详细地址" required :class="{ 'has-field-error': formErrors.address }"><el-input v-model="form.address" maxlength="255" type="textarea" :rows="3" :placeholder="addressPlaceholder" @input="clearFieldError('address')" /><p v-if="formErrors.address" class="field-error" role="alert">{{ formErrors.address }}</p></el-form-item>
</div>
<section v-if="!isFbaCreate" class="form-section">
<h3>编码与状态</h3>
<div class="form-grid two-cols compact-grid">
<el-form-item label="仓库编码" :class="{ 'has-field-error': formErrors.code }"><el-input v-model="form.code" maxlength="50" placeholder="系统自动生成" @input="form.code = String($event).toUpperCase(); clearFieldError('code')" /><p v-if="formErrors.code" class="field-error" role="alert">{{ formErrors.code }}</p></el-form-item>
<el-form-item label="状态"><el-radio-group v-model="form.status" class="warehouse-status-toggle"><el-radio-button value="ACTIVE">启用</el-radio-button><el-radio-button value="INACTIVE">停用</el-radio-button></el-radio-group></el-form-item>
</div>
</section>
<section v-if="!isFbaCreate" class="form-section">
<h3>联系信息</h3>
<div class="form-grid two-cols compact-grid">
<el-form-item label="联系人"><el-input v-model="form.contactName" maxlength="64" placeholder="选填" /></el-form-item>
<el-form-item label="联系电话"><el-input v-model="form.contactPhone" maxlength="32" placeholder="选填" /></el-form-item>
<el-form-item label="收货时间"><el-input v-model="form.businessHours" maxlength="500" placeholder="例如 周一至周六 08:30-18:00" /></el-form-item>
</div>
<el-form-item label="备注"><el-input v-model="form.remark" maxlength="500" type="textarea" :rows="3" show-word-limit placeholder="选填" /></el-form-item>
</section>
</el-form>
<template #footer><div class="drawer-footer"><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveWarehouse">保存仓库</el-button></div></template>
<template #footer><div class="drawer-footer"><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveWarehouse">{{ editorMode === 'EDIT' ? '保存修改' : '确认新增' }}</el-button></div></template>
</el-drawer>
<el-dialog v-model="importOpen" title="导入公共仓库" width="min(760px, calc(100vw - 32px))" :close-on-click-modal="!importing" :close-on-press-escape="!importing">
<div class="import-content">
<div class="import-toolbar">
<el-button :loading="downloadingTemplate" @click="downloadTemplate"><Download :size="16" />下载 XLSX 模板</el-button>
<el-radio-group v-model="importMode" :disabled="importing">
<el-radio-button value="CREATE_ONLY">仅新增</el-radio-button>
<el-radio-button value="UPSERT" :disabled="!access.can('platform:warehouse:edit')">新增并更新</el-radio-button>
</el-radio-group>
</div>
<el-upload accept=".xlsx" :auto-upload="false" :limit="1" :on-change="selectImportFile" :on-remove="removeImportFile">
<el-button><Upload :size="16" />选择 XLSX 文件</el-button>
<template #tip><div class="el-upload__tip">最多 5MB、1000 行;请使用标准模板,FBA 名称可不填并默认使用编码。</div></template>
</el-upload>
<div v-if="importResult" class="import-result" role="status">
<dl class="import-summary">
<div><dt>总行数</dt><dd>{{ importResult.totalRows }}</dd></div>
<div><dt>有效</dt><dd>{{ importResult.validRows }}</dd></div>
<div><dt>新增</dt><dd>{{ importResult.createCount }}</dd></div>
<div><dt>更新</dt><dd>{{ importResult.updateCount }}</dd></div>
<div><dt>无变化</dt><dd>{{ importResult.unchangedCount }}</dd></div>
<div><dt>错误</dt><dd :class="{ 'is-error': importResult.errorCount }">{{ importResult.errorCount }}</dd></div>
</dl>
<div class="import-result-heading"><strong>{{ importResult.committed ? '导入已提交' : (importResult.errorCount ? '预检未通过,未写入数据' : '预检通过,尚未写入数据') }}</strong><el-button v-if="importResult.errors.length" size="small" @click="downloadImportErrors"><Download :size="15" />下载错误清单</el-button></div>
<el-table v-if="importResult.errors.length" :data="importResult.errors" max-height="260" size="small">
<el-table-column prop="row" label="行号" width="70" /><el-table-column prop="field" label="字段" width="110" /><el-table-column prop="code" label="错误码" width="130" :formatter="formatImportErrorCode" /><el-table-column prop="message" label="原因" min-width="220" />
</el-table>
</div>
</div>
<template #footer><el-button :disabled="importing" @click="importOpen = false">关闭</el-button><el-button :loading="importing" :disabled="!importFile" @click="runImport(true)">预检文件</el-button><el-button type="primary" :loading="importing" :disabled="!canCommitImport" @click="runImport(false)">确认导入</el-button></template>
</el-dialog>
</div>
</template>
<style scoped>
.state-alert p { margin: 6px 0 10px; line-height: 1.55; }
.warehouse-table-state { min-height: 180px; }
.warehouse-heading { gap: 16px; }
.warehouse-primary-actions, .import-toolbar, .import-result-heading { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.warehouse-cell { display: flex; align-items: center; gap: 10px; }
.warehouse-cell > span { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 7px; background: #eef3fa; color: var(--cc-primary); }
.warehouse-cell > span { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 8px; background: #eef3fa; color: var(--cc-primary); }
.warehouse-cell > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
.warehouse-cell strong { overflow-wrap: anywhere; }
.warehouse-cell small, .warehouse-subline { display: block; color: var(--cc-muted); font-size: 11px; }
.warehouse-cell small, .warehouse-subline, .warehouse-address small { display: block; color: var(--cc-muted); font-size: 11px; }
.warehouse-address { display: flex; align-items: flex-start; gap: 7px; line-height: 1.55; overflow-wrap: anywhere; }
.warehouse-address svg { flex: 0 0 15px; margin-top: 2px; color: var(--cc-muted); }
.warehouse-address span { min-width: 0; }
.field-error { width: 100%; margin: 6px 0 0; color: var(--el-color-danger); font-size: 12px; line-height: 1.35; overflow-wrap: anywhere; }
.compact-grid { row-gap: 10px; }
.warehouse-status-toggle :deep(.el-radio-button__inner) { min-width: 74px; }
.import-content { display: grid; gap: 20px; }
.import-toolbar { justify-content: space-between; }
.import-result { display: grid; gap: 14px; padding-top: 16px; border-top: 1px solid var(--cc-border); }
.import-summary { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); margin: 0; border: 1px solid var(--cc-border); border-radius: 8px; overflow: hidden; }
.import-summary div { min-width: 0; padding: 10px 12px; border-right: 1px solid var(--cc-border); background: var(--cc-surface-soft, #f8fafc); }
.import-summary div:last-child { border-right: 0; }
.import-summary dt { color: var(--cc-muted); font-size: 11px; }
.import-summary dd { margin: 3px 0 0; color: var(--cc-text); font-size: 16px; font-weight: 700; }
.import-summary dd.is-error { color: var(--el-color-danger); }
.import-result-heading { justify-content: space-between; }
:deep(.has-field-error .el-input__wrapper) { box-shadow: 0 0 0 1px var(--el-color-danger) inset; }
:deep(.has-field-error .el-textarea__inner) { box-shadow: 0 0 0 1px var(--el-color-danger) inset; }
:global(.el-dropdown-menu__item) { gap: 8px; }
@media (max-width: 720px) {
.panel-heading .el-button { width: 100%; }
.warehouse-heading { align-items: stretch; }
.warehouse-primary-actions { width: 100%; }
.warehouse-primary-actions > .el-button, .warehouse-primary-actions > .el-dropdown { flex: 1 1 150px; }
.warehouse-primary-actions :deep(.el-dropdown .el-button) { width: 100%; min-height: 44px; }
.warehouse-primary-actions > .el-button { min-height: 44px; }
.import-toolbar { align-items: stretch; flex-direction: column; }
.import-summary { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.import-summary div:nth-child(3) { border-right: 0; }
.import-summary div:nth-child(-n+3) { border-bottom: 1px solid var(--cc-border); }
}
@media (max-width: 420px) {
.warehouse-primary-actions { flex-direction: column; }
.warehouse-primary-actions > .el-button, .warehouse-primary-actions > .el-dropdown { width: 100%; flex-basis: auto; }
.import-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.import-summary div:nth-child(3) { border-right: 1px solid var(--cc-border); }
.import-summary div:nth-child(2n) { border-right: 0; }
.import-summary div:nth-child(-n+4) { border-bottom: 1px solid var(--cc-border); }
}
</style>