feat(client): optimize first-leg order actions

This commit is contained in:
2026-09-14 17:18:56 +08:00
parent cd057845bf
commit c2611a1bfd
5 changed files with 69 additions and 16 deletions
@@ -59,6 +59,7 @@ export interface MerchantUnifiedOrderRow {
lxCode?: string | null lxCode?: string | null
mainStatus: ApiUnifiedOrderMainStatus mainStatus: ApiUnifiedOrderMainStatus
nextAction: string | null nextAction: string | null
customerFeeConfirmationRequired: boolean
internalOrderStatus: ApiFirstLegStatus | null internalOrderStatus: ApiFirstLegStatus | null
customerId: number | string | null customerId: number | string | null
customerName: string customerName: string
@@ -198,6 +198,7 @@ function timeline(detail: MerchantFirstLegOrderDetail): FirstLegOrderTimelineIte
function businessSummary(summary?: MerchantUnifiedOrderRow): FirstLegOrder['businessSummary'] { function businessSummary(summary?: MerchantUnifiedOrderRow): FirstLegOrder['businessSummary'] {
return { return {
customerFeeConfirmationRequired: summary?.customerFeeConfirmationRequired ?? false,
totalPackageCount: summary?.totalPackageCount ?? null, totalPackageCount: summary?.totalPackageCount ?? null,
actualGrossWeightKg: summary?.actualGrossWeightKg ?? null, actualGrossWeightKg: summary?.actualGrossWeightKg ?? null,
actualVolumeCbm: summary?.actualVolumeCbm ?? null, actualVolumeCbm: summary?.actualVolumeCbm ?? null,
@@ -64,6 +64,7 @@ export interface FirstLegOrderTimelineItem {
} }
export interface FirstLegOrderBusinessSummary { export interface FirstLegOrderBusinessSummary {
customerFeeConfirmationRequired: boolean
totalPackageCount: number | null totalPackageCount: number | null
actualGrossWeightKg: number | null actualGrossWeightKg: number | null
actualVolumeCbm: number | null actualVolumeCbm: number | null
@@ -8,6 +8,7 @@ const editorShellSource = readFileSync(new URL('../components/FirstLegOrderEdito
const progressSource = readFileSync(new URL('../components/OrderProgressPanel.vue', import.meta.url), 'utf8') const progressSource = readFileSync(new URL('../components/OrderProgressPanel.vue', import.meta.url), 'utf8')
const customsDialogSource = readFileSync(new URL('../components/CustomsDocumentsDialog.vue', import.meta.url), 'utf8') const customsDialogSource = readFileSync(new URL('../components/CustomsDocumentsDialog.vue', import.meta.url), 'utf8')
const customsApiSource = readFileSync(new URL('../api/customs.ts', import.meta.url), 'utf8') const customsApiSource = readFileSync(new URL('../api/customs.ts', import.meta.url), 'utf8')
const orderStoreSource = readFileSync(new URL('../store/firstLegOrders.ts', import.meta.url), 'utf8')
test('routes the confirmed first-leg path to the implemented order view', () => { test('routes the confirmed first-leg path to the implemented order view', () => {
assert.match(routerSource, /path: 'orders\/first-leg'[\s\S]*?component: \(\) => import\('\.\/views\/FirstLegOrdersView\.vue'\)[\s\S]*?release: 'active'/) assert.match(routerSource, /path: 'orders\/first-leg'[\s\S]*?component: \(\) => import\('\.\/views\/FirstLegOrdersView\.vue'\)[\s\S]*?release: 'active'/)
@@ -88,7 +89,7 @@ test('uses the shared query toolbar and data table without metric cards', () =>
assert.match(source, /import QueryToolbar, \{ type QueryField, type QueryModel \}/) assert.match(source, /import QueryToolbar, \{ type QueryField, type QueryModel \}/)
assert.match(source, /import DataTable, \{ type DataTableColumn \}/) assert.match(source, /import DataTable, \{ type DataTableColumn \}/)
assert.match(source, /const filters = ref<QueryModel>/) assert.match(source, /const filters = ref<QueryModel>/)
assert.match(source, /<QueryToolbar v-model="filters" class="first-leg-query-toolbar" :fields="queryFields" ariaLabel="订单查询" @search="searchOrders">/) assert.match(source, /<QueryToolbar v-model="filters" class="first-leg-query-toolbar" :fields="queryFields" ariaLabel="订单查询" @search="searchOrders" \/>/)
assert.match(source, /async function searchOrders\(model: QueryModel\)/) assert.match(source, /async function searchOrders\(model: QueryModel\)/)
assert.match(source, /await store\.load\(\{/) assert.match(source, /await store\.load\(\{/)
assert.match(source, /<DataTable v-model:page="tablePage" v-model:page-size="tablePageSize" :data="pagedOrders" :columns="visibleOrderColumns"/) assert.match(source, /<DataTable v-model:page="tablePage" v-model:page-size="tablePageSize" :data="pagedOrders" :columns="visibleOrderColumns"/)
@@ -96,7 +97,7 @@ test('uses the shared query toolbar and data table without metric cards', () =>
assert.doesNotMatch(source, /order-metrics|订单概览/) assert.doesNotMatch(source, /order-metrics|订单概览/)
}) })
test('adds prototype download actions to the order query panel', () => { test('adds download actions to the order-list toolbar', () => {
assert.match(source, /下载唛头/) assert.match(source, /下载唛头/)
assert.match(source, /导出订单信息/) assert.match(source, /导出订单信息/)
assert.match(source, /function downloadShippingMarks\(\)/) assert.match(source, /function downloadShippingMarks\(\)/)
@@ -237,12 +238,12 @@ test('keeps the seven-stage progress and business summary usable on mobile', ()
}) })
test('keeps the first viewport compact and moves create into the table toolbar', () => { test('keeps the first viewport compact and moves create into the table toolbar', () => {
const queryToolbarSource = source.slice(source.indexOf('<QueryToolbar'), source.indexOf('</QueryToolbar>') + '</QueryToolbar>'.length) const tableToolbarSource = source.slice(source.indexOf('class="order-table-toolbar"'), source.indexOf('class="order-eligibility is-pending"'))
assert.match(source, /class="order-table-toolbar"/) assert.match(source, /class="order-table-toolbar"/)
assert.match(source, /<strong>订单列表<\/strong><span>共 \{\{ filteredOrders\.length \}\} 条<\/span>/) assert.match(source, /<strong>订单列表<\/strong><span>共 \{\{ filteredOrders\.length \}\} 条<\/span>/)
assert.match(source, /class="order-table-toolbar"[\s\S]*order:first-leg:create[\s\S]*创建订单[\s\S]*class="order-eligibility is-pending"/) assert.match(source, /class="order-table-toolbar"[\s\S]*order:first-leg:create[\s\S]*创建订单[\s\S]*class="order-eligibility is-pending"/)
assert.match(queryToolbarSource, /<template #actions>[\s\S]*下载唛头[\s\S]*导出订单信息/) assert.match(tableToolbarSource, /下载唛头[\s\S]*导出订单信息[\s\S]*批量提交草稿[\s\S]*创建订单/)
assert.doesNotMatch(queryToolbarSource, /order:first-leg:create|创建订单/) assert.doesNotMatch(source.slice(source.indexOf('<QueryToolbar'), source.indexOf('<div class="client-card order-table-card">')), /下载唛头|导出订单信息|创建订单/)
assert.match(source, /client-main:has\(> \.first-leg-page:not\(\.is-create-route\)\) > \.client-page-heading > div\) \{ position: absolute; width: 1px; height: 1px;/) assert.match(source, /client-main:has\(> \.first-leg-page:not\(\.is-create-route\)\) > \.client-page-heading > div\) \{ position: absolute; width: 1px; height: 1px;/)
}) })
@@ -532,11 +533,18 @@ test('opens billing-weight confirmation independently from order details', () =>
assert.match(source, /unavailable-message="未推送计费重"/) assert.match(source, /unavailable-message="未推送计费重"/)
}) })
test('shows the standalone billing-weight entry at every order node', () => { test('shows billing-weight confirmation only when the backend marks it pending', () => {
const templateSource = source.slice(source.indexOf('<template>'), source.lastIndexOf('</template>')) const templateSource = source.slice(source.indexOf('<template>'), source.lastIndexOf('</template>'))
const entries = templateSource.match(/<el-button link type="primary" @click="openFeeConfirmation\([^)]*\)">确认计费重<\/el-button>/g) ?? [] const entries = templateSource.match(/<el-button v-if="canConfirmInboundFee && [^"]*customerFeeConfirmationRequired" link type="primary" @click="openFeeConfirmation\([^)]*\)">确认计费重<\/el-button>/g) ?? []
assert.equal(entries.length, 2) assert.equal(entries.length, 2)
assert.doesNotMatch(templateSource, /v-if="[^"]*status === 'WAIT_SPLIT'[^"]*"[^>]*>确认计费重/) assert.match(orderStoreSource, /customerFeeConfirmationRequired: summary\?\.customerFeeConfirmationRequired \?\? false/)
})
test('submits only selected draft orders in one batch action', () => {
assert.match(source, /selectedDraftOrders = computed\(\(\) => selectedOrders\.value\.filter\(\(order\) => order\.status === 'DRAFT'\)\)/)
assert.match(source, /Promise\.allSettled\(drafts\.map\(\(order\) => submitMerchantFirstLegOrder/)
assert.match(source, /createIdempotencyKey\(`first-leg-batch-submit-\$\{order\.id\}`\)/)
assert.match(source, /v-if="canSubmitOrder"[\s\S]*批量提交草稿/)
}) })
test('discards stale inbound billing responses when billing views close or the user changes orders', () => { test('discards stale inbound billing responses when billing views close or the user changes orders', () => {
@@ -27,6 +27,7 @@ import {
getMerchantOrderAddresses, getMerchantOrderAddresses,
getMerchantPricingChannels, getMerchantPricingChannels,
importMerchantFirstLegOrders, importMerchantFirstLegOrders,
submitMerchantFirstLegOrder,
updateMerchantOrderAddress, updateMerchantOrderAddress,
uploadMerchantProductImage, uploadMerchantProductImage,
type CustomerOrderAddress, type CustomerOrderAddress,
@@ -72,6 +73,7 @@ const companyAuditLoaded = ref(false)
const companySubmissionBlocked = computed(() => companyAuditLoaded.value && tenantStore.company.auditStatus !== 'ACTIVE') const companySubmissionBlocked = computed(() => companyAuditLoaded.value && tenantStore.company.auditStatus !== 'ACTIVE')
const canCreateOrder = computed(() => auth.hasPermission('order:first-leg:create')) const canCreateOrder = computed(() => auth.hasPermission('order:first-leg:create'))
const canEditOrder = computed(() => auth.hasPermission('order:first-leg:edit')) const canEditOrder = computed(() => auth.hasPermission('order:first-leg:edit'))
const canSubmitOrder = computed(() => auth.hasPermission('order:first-leg:submit'))
const canConfirmInboundFee = computed(() => auth.hasPermission('order:first-leg:edit')) const canConfirmInboundFee = computed(() => auth.hasPermission('order:first-leg:edit'))
const availableWarehouses = computed(() => store.warehouses.filter((warehouse) => warehouse.warehouseType === 'DOMESTIC')) const availableWarehouses = computed(() => store.warehouses.filter((warehouse) => warehouse.warehouseType === 'DOMESTIC'))
const availableDestinationWarehouses = computed(() => store.warehouses.filter((warehouse) => warehouse.warehouseType === form.destinationType)) const availableDestinationWarehouses = computed(() => store.warehouses.filter((warehouse) => warehouse.warehouseType === form.destinationType))
@@ -182,8 +184,10 @@ const tablePage = ref(1)
const tablePageSize = ref(10) const tablePageSize = ref(10)
const downloadingMarks = ref(false) const downloadingMarks = ref(false)
const exportingOrders = ref(false) const exportingOrders = ref(false)
const batchSubmitting = ref(false)
const selectedOrders = ref<FirstLegOrder[]>([]) const selectedOrders = ref<FirstLegOrder[]>([])
const selectedOrderCount = computed(() => selectedOrders.value.length) const selectedOrderCount = computed(() => selectedOrders.value.length)
const selectedDraftOrders = computed(() => selectedOrders.value.filter((order) => order.status === 'DRAFT'))
const orderTableVersion = ref(0) const orderTableVersion = ref(0)
const editorOpen = ref(false) const editorOpen = ref(false)
const orderFormRef = ref<FormInstance>() const orderFormRef = ref<FormInstance>()
@@ -1485,6 +1489,46 @@ function handleOrderSelectionChange(rows: FirstLegOrder[]) {
selectedOrders.value = rows selectedOrders.value = rows
} }
async function submitSelectedDraftOrders() {
const drafts = selectedDraftOrders.value
if (batchSubmitting.value || !drafts.length) return
if (companySubmissionBlocked.value) {
ElMessage.warning('企业资料审核通过后才能提交订单')
return
}
try {
await ElMessageBox.confirm(`确认提交选中的 ${drafts.length} 条草稿订单吗?`, '批量提交订单', {
type: 'warning',
confirmButtonText: '确认提交',
cancelButtonText: '取消',
})
} catch (error) {
if (error === 'cancel' || error === 'close') return
throw error
}
batchSubmitting.value = true
try {
const results = await Promise.allSettled(drafts.map((order) => submitMerchantFirstLegOrder(
order.id,
createIdempotencyKey(`first-leg-batch-submit-${order.id}`),
)))
const successCount = results.filter((result) => result.status === 'fulfilled').length
const failures = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
await searchOrders(appliedFilters.value)
if (!failures.length) {
ElMessage.success(`已提交 ${successCount} 条草稿订单`)
return
}
const firstReason = failures[0]?.reason
const reason = firstReason instanceof Error ? firstReason.message : '提交失败'
if (successCount > 0) ElMessage.warning(`${successCount} 条提交成功,${failures.length} 条失败:${reason}`)
else ElMessage.error(`${failures.length} 条草稿订单提交失败:${reason}`)
} finally {
batchSubmitting.value = false
}
}
async function downloadShippingMarks() { async function downloadShippingMarks() {
if (downloadingMarks.value) return if (downloadingMarks.value) return
const orders = selectedOrders.value const orders = selectedOrders.value
@@ -1816,12 +1860,7 @@ async function searchOrders(model: QueryModel) {
<template> <template>
<section class="first-leg-page" :class="{ 'is-create-route': isCreatePage }"> <section class="first-leg-page" :class="{ 'is-create-route': isCreatePage }">
<template v-if="!isCreatePage"> <template v-if="!isCreatePage">
<QueryToolbar v-model="filters" class="first-leg-query-toolbar" :fields="queryFields" ariaLabel="订单查询" @search="searchOrders"> <QueryToolbar v-model="filters" class="first-leg-query-toolbar" :fields="queryFields" ariaLabel="订单查询" @search="searchOrders" />
<template #actions>
<el-button class="query-export-button" type="primary" :loading="downloadingMarks" :disabled="selectedOrderCount === 0" @click="downloadShippingMarks"><Download :size="16" />下载唛头</el-button>
<el-button class="query-export-button" type="primary" :loading="exportingOrders" :disabled="selectedOrderCount === 0" @click="exportOrderInformation"><Download :size="16" />导出订单信息</el-button>
</template>
</QueryToolbar>
<div class="client-card order-table-card"> <div class="client-card order-table-card">
<div class="order-table-toolbar"> <div class="order-table-toolbar">
@@ -1853,6 +1892,9 @@ async function searchOrders(model: QueryModel) {
<el-button link class="column-settings-reset" @click="resetOrderColumns"><RotateCcw :size="17" />恢复默认</el-button> <el-button link class="column-settings-reset" @click="resetOrderColumns"><RotateCcw :size="17" />恢复默认</el-button>
</div> </div>
</el-popover> </el-popover>
<el-button :loading="downloadingMarks" :disabled="selectedOrderCount === 0" @click="downloadShippingMarks"><Download :size="16" />下载唛头</el-button>
<el-button :loading="exportingOrders" :disabled="selectedOrderCount === 0" @click="exportOrderInformation"><Download :size="16" />导出订单信息</el-button>
<el-button v-if="canSubmitOrder" :loading="batchSubmitting" :disabled="selectedDraftOrders.length === 0 || companySubmissionBlocked" @click="submitSelectedDraftOrders"><Send :size="17" />批量提交草稿</el-button>
<el-button v-if="auth.hasPermission('order:first-leg:create')" @click="openBatchImport"><Upload :size="17" />批量导入订单</el-button> <el-button v-if="auth.hasPermission('order:first-leg:create')" @click="openBatchImport"><Upload :size="17" />批量导入订单</el-button>
<el-button v-if="auth.hasPermission('order:first-leg:create')" type="primary" :loading="pageDataLoading" :disabled="pageDataLoading || availableWarehouses.length === 0" @click="openCreate"><FilePlus2 :size="17" />创建订单</el-button> <el-button v-if="auth.hasPermission('order:first-leg:create')" type="primary" :loading="pageDataLoading" :disabled="pageDataLoading || availableWarehouses.length === 0" @click="openCreate"><FilePlus2 :size="17" />创建订单</el-button>
</div> </div>
@@ -1878,7 +1920,7 @@ async function searchOrders(model: QueryModel) {
<template #actions="{ row }"> <template #actions="{ row }">
<div class="order-row-actions"> <div class="order-row-actions">
<el-button link type="primary" @click="openDetail(row as FirstLegOrder)"><Eye :size="15" />详情</el-button> <el-button link type="primary" @click="openDetail(row as FirstLegOrder)"><Eye :size="15" />详情</el-button>
<el-button link type="primary" @click="openFeeConfirmation(row as FirstLegOrder)">确认计费重</el-button> <el-button v-if="canConfirmInboundFee && (row as FirstLegOrder).businessSummary.customerFeeConfirmationRequired" link type="primary" @click="openFeeConfirmation(row as FirstLegOrder)">确认计费重</el-button>
<el-button v-if="canEditOrder && canClientEditFirstLegOrder((row as FirstLegOrder).status)" link type="primary" :loading="openingEditor" @click="openEdit(row as FirstLegOrder)"><Pencil :size="15" />编辑</el-button> <el-button v-if="canEditOrder && canClientEditFirstLegOrder((row as FirstLegOrder).status)" link type="primary" :loading="openingEditor" @click="openEdit(row as FirstLegOrder)"><Pencil :size="15" />编辑</el-button>
<el-button v-if="eligibleCustomsDetail(row as FirstLegOrder)" link type="primary" @click="openCustomsDocuments(row as FirstLegOrder)"><FileText :size="15" />报关资料</el-button> <el-button v-if="eligibleCustomsDetail(row as FirstLegOrder)" link type="primary" @click="openCustomsDocuments(row as FirstLegOrder)"><FileText :size="15" />报关资料</el-button>
</div> </div>
@@ -1944,7 +1986,7 @@ async function searchOrders(model: QueryModel) {
<span>更新于 {{ formatDateTime(order.updatedAt) }}</span> <span>更新于 {{ formatDateTime(order.updatedAt) }}</span>
<div> <div>
<el-button link type="primary" @click="openDetail(order)"><Eye :size="15" />详情</el-button> <el-button link type="primary" @click="openDetail(order)"><Eye :size="15" />详情</el-button>
<el-button link type="primary" @click="openFeeConfirmation(order)">确认计费重</el-button> <el-button v-if="canConfirmInboundFee && order.businessSummary.customerFeeConfirmationRequired" link type="primary" @click="openFeeConfirmation(order)">确认计费重</el-button>
<el-button v-if="canEditOrder && canClientEditFirstLegOrder(order.status)" link type="primary" :loading="openingEditor" @click="openEdit(order)"><Pencil :size="15" />编辑</el-button> <el-button v-if="canEditOrder && canClientEditFirstLegOrder(order.status)" link type="primary" :loading="openingEditor" @click="openEdit(order)"><Pencil :size="15" />编辑</el-button>
<el-button v-if="eligibleCustomsDetail(order)" link type="primary" @click="openCustomsDocuments(order)"><FileText :size="15" />报关资料</el-button> <el-button v-if="eligibleCustomsDetail(order)" link type="primary" @click="openCustomsDocuments(order)"><FileText :size="15" />报关资料</el-button>
</div> </div>