Merge release/test into dev

This commit is contained in:
2026-09-15 15:36:26 +08:00
239 changed files with 4714 additions and 3170 deletions
@@ -58,7 +58,7 @@ describe('unified orders API contract', () => {
it('forwards every main-status tab with search and pagination unchanged', async () => {
const statuses = [
'PENDING_REVIEW', 'WAIT_MEASURE', 'WAIT_PALLETIZE', 'WAIT_SPLIT',
'LOADING', 'LOADED', 'DELIVERED',
'LOADING', 'LOADED', 'DELIVERED', 'COMPLETED',
] as const
vi.mocked(http.get).mockResolvedValue({ data: { records: [], page: 3, size: 20, total: 0, pages: 0 } })
@@ -10,6 +10,7 @@ export type UnifiedOrderMainStatus =
| 'LOADING'
| 'LOADED'
| 'DELIVERED'
| 'COMPLETED'
export const unifiedOrderStatusOptions: Array<{ value: UnifiedOrderMainStatus; label: string }> = [
{ value: 'PENDING_REVIEW', label: '待审核' },
@@ -19,6 +20,7 @@ export const unifiedOrderStatusOptions: Array<{ value: UnifiedOrderMainStatus; l
{ value: 'LOADING', label: '装柜中' },
{ value: 'LOADED', label: '装柜完成' },
{ value: 'DELIVERED', label: '派送完成' },
{ value: 'COMPLETED', label: '已完成' },
]
export interface UnifiedOrderMeasurementSummary {
@@ -125,6 +127,7 @@ export interface UnifiedOrderResource {
currentHoldReason: string | null
companyName?: string | null
businessType?: string | null
fulfillmentType?: string | null
pickupMethod?: string | null
requestedPickupAt: string | null
pickupAt?: string | null
@@ -69,10 +69,43 @@ describe('InboundFeeDetailManager', () => {
expect(second).toContain('调整'); expect(second).toContain('作废'); expect(secondButtons).not.toContain('审核')
expect(wrapper.find('th').text()).toContain('操作')
expect(source).not.toMatch(/<el-button[^>]+link[^>]+(?:编辑|审核|驳回|调整|作废)/)
expect(source).toMatch(/size="small" plain type="primary"[^>]+[^>]*>\s*<Pencil/)
expect(source).toMatch(/size="small" plain type="success"[^>]+[^>]*>\s*<Check/)
expect(source).toMatch(/size="small" plain type="warning"[^>]+[^>]*>\s*<Undo2/)
expect(source).toMatch(/size="small" plain type="danger"[^>]+[^>]*>\s*<X/)
expect(wrapper.findAll('.el-table__row')[0]!.findAll('.density-actions button').map(button => button.attributes('data-tone'))).toEqual(['neutral', 'warning', 'warning', 'neutral'])
expect(wrapper.findAll('.el-table__row')[1]!.findAll('.density-actions button').map(button => button.attributes('data-tone'))).toEqual(['neutral', 'danger'])
})
it.each([
[[], [], false],
[['delete'], ['删除'], false],
[['edit', 'delete'], ['编辑', '删除'], false],
[['review', 'delete'], ['审核', '驳回', '删除'], false],
[['edit', 'review'], ['编辑', '审核', '驳回'], false],
[['edit', 'review', 'delete'], ['编辑', '审核', '驳回', ''], true],
])('folds pending fee actions only when more than three are available: %j', async (actions, labels, overflow) => {
vi.mocked(listInboundFeeDetails).mockResolvedValue([pending])
const wrapper = mountManager(['firstleg:inbound:fee-detail:view', ...actions.map(action => 'firstleg:inbound:fee-detail:' + action)])
await flushPromises()
const row = wrapper.get('.el-table__row .density-actions')
expect(row.findAll('button').map(button => button.text().trim())).toEqual(labels)
expect(row.find('.el-dropdown').exists()).toBe(overflow)
if (overflow) expect(row.get('.el-dropdown button').attributes('aria-label')).toContain('操作费')
})
it.each(['编辑', '驳回'])('styles the native %s fee dialog at its own root', async action => {
const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:inbound:fee-detail:review', 'firstleg:fee-item:view'])
await flushPromises()
await wrapper.get('.el-table__row .density-actions').findAll('button').find(button => button.text().trim() === action)!.trigger('click')
await flushPromises()
const dialog = document.querySelector<HTMLElement>('.el-dialog.admin-density-dialog')!
expect(dialog).not.toBeNull()
expect(dialog.querySelector('.density-form')).not.toBeNull()
expect(dialog.style.getPropertyValue('--el-dialog-width')).toBe(action === '编辑' ? 'min(680px, calc(100vw - 28px))' : 'min(560px, calc(100vw - 28px))')
if (action === '驳回') {
const confirm = [...dialog.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent?.trim() === '确认驳回')!
confirm.click()
await flushPromises()
expect(reviewInboundFeeDetail).not.toHaveBeenCalled()
expect(dialog.querySelector('textarea')?.value).toBe('')
} else expect(dialog.textContent).toContain('业务发生日')
})
it('keeps FBA_PRICING rows derived and read-only regardless of write permissions', async () => {
@@ -1,7 +1,8 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { Check, FileUp, MoreHorizontal, Pencil, Plus, RefreshCw, Trash2, Undo2, X } from '@lucide/vue'
import { FileUp, MoreHorizontal, Plus, RefreshCw, X } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
@@ -87,6 +88,9 @@ function canDelete(row: InboundFeeDetail) { return isManual(row) && access.can('
function canReview(row: InboundFeeDetail) { return isManual(row) && access.can('firstleg:inbound:fee-detail:review') && row.reviewStatus === 'PENDING' }
function canAdjust(row: InboundFeeDetail) { return isManual(row) && access.can('firstleg:inbound:fee-detail:edit') && row.reviewStatus === 'APPROVED' && row.publicationStatus !== 'VOIDED' }
function canVoid(row: InboundFeeDetail) { return isManual(row) && access.can('firstleg:inbound:fee-detail:review') && row.reviewStatus === 'APPROVED' && row.publicationStatus !== 'VOIDED' }
function rowActionCount(row: InboundFeeDetail) {
return Number(canEdit(row)) + 2 * Number(canReview(row)) + Number(canAdjust(row)) + Number(canVoid(row)) + Number(canDelete(row))
}
function reviewLabel(value: string) { return ({ PENDING: '待审核', APPROVED: '已审核', REJECTED: '已驳回' } as Record<string, string>)[value] || value }
function publicationLabel(value: string) {
return ({ DRAFT: '草稿', PUBLISHED: '已发布', SUPERSEDED: '已被替代', VOIDED: '已作废' } as Record<string, string>)[value] || value || '未知状态'
@@ -231,7 +235,7 @@ async function save() {
async function remove(row: InboundFeeDetail) {
try {
await ElMessageBox.confirm('删除后无法恢复,是否继续?', '删除费用明细', { type: 'warning', confirmButtonText: '删除' })
await ElMessageBox.confirm('删除后无法恢复,是否继续?', '删除费用明细', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '删除' })
await deleteInboundFeeDetail(props.accountId, normalizedInboundId.value, String(row.id)); await finishAction('费用明细已删除')
} catch (error) { if (error !== 'cancel' && error !== 'close') actionError.value = getApiErrorDetails(error, '费用明细删除失败') }
}
@@ -252,7 +256,7 @@ async function reject() {
async function voidRow(row: InboundFeeDetail) {
try {
await ElMessageBox.confirm('作废会同步撤销尚未结算的财务费用来源,是否继续?', '作废费用明细', { type: 'warning', confirmButtonText: '作废' })
await ElMessageBox.confirm('作废会同步撤销尚未结算的财务费用来源,是否继续?', '作废费用明细', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '作废' })
await voidInboundFeeDetail(props.accountId, normalizedInboundId.value, String(row.id), row.sourceFeeVersion); await finishAction('费用明细已作废')
} catch (error) { if (error !== 'cancel' && error !== 'close') actionError.value = getApiErrorDetails(error, '费用作废失败') }
}
@@ -268,7 +272,7 @@ onMounted(() => void load())
</script>
<template>
<section class="fee-detail-manager" aria-label="运单费用维护">
<section class="fee-detail-manager admin-density" aria-label="运单费用维护">
<div class="fee-toolbar">
<span>共 {{ rows.length }} 条应收应付费用明细</span>
<div>
@@ -284,17 +288,20 @@ onMounted(() => void load())
<el-table :data="rows" border stripe empty-text="暂无费用数据" row-key="id">
<el-table-column label="操作" fixed="left" width="250">
<template #default="{ row }">
<el-button v-if="canEdit(row as InboundFeeDetail)" size="small" plain type="primary" @click="openEditor('edit', row as InboundFeeDetail)"><Pencil :size="15" />编辑</el-button>
<el-button v-if="canReview(row as InboundFeeDetail)" size="small" plain type="success" @click="approve(row as InboundFeeDetail)"><Check :size="15" />审核</el-button>
<el-button v-if="canReview(row as InboundFeeDetail)" size="small" plain type="warning" @click="openReject(row as InboundFeeDetail)"><Undo2 :size="15" />驳回</el-button>
<el-button v-if="canAdjust(row as InboundFeeDetail)" size="small" plain type="primary" @click="openEditor('adjust', row as InboundFeeDetail)"><Pencil :size="15" />调整</el-button>
<el-button v-if="canVoid(row as InboundFeeDetail)" size="small" plain type="danger" @click="voidRow(row as InboundFeeDetail)"><X :size="15" />作废</el-button>
<el-dropdown v-if="canDelete(row as InboundFeeDetail)">
<el-button link aria-label="更多费用操作"><MoreHorizontal :size="17" /></el-button>
<template #dropdown><el-dropdown-menu>
<el-dropdown-item @click="remove(row as InboundFeeDetail)"><Trash2 :size="15" />删除</el-dropdown-item>
</el-dropdown-menu></template>
</el-dropdown>
<div class="density-actions">
<el-button v-if="canEdit(row as InboundFeeDetail)" class="density-action" data-tone="neutral" @click="openEditor('edit', row as InboundFeeDetail)">编辑</el-button>
<el-button v-if="canReview(row as InboundFeeDetail)" class="density-action" data-tone="warning" @click="approve(row as InboundFeeDetail)">审核</el-button>
<el-button v-if="canReview(row as InboundFeeDetail)" class="density-action" data-tone="warning" @click="openReject(row as InboundFeeDetail)">驳回</el-button>
<el-button v-if="canAdjust(row as InboundFeeDetail)" class="density-action" data-tone="neutral" @click="openEditor('adjust', row as InboundFeeDetail)">调整</el-button>
<el-button v-if="canVoid(row as InboundFeeDetail)" class="density-action" data-tone="danger" @click="voidRow(row as InboundFeeDetail)">作废</el-button>
<el-button v-if="canDelete(row as InboundFeeDetail) && rowActionCount(row as InboundFeeDetail) <= 3" class="density-action" data-tone="danger" @click="remove(row as InboundFeeDetail)">删除</el-button>
<el-dropdown v-if="canDelete(row as InboundFeeDetail) && rowActionCount(row as InboundFeeDetail) > 3" trigger="click">
<el-button class="density-action" data-tone="neutral" :aria-label="(row.feeTypeNameSnapshot || '当前费用') + '的更多操作'" title="更多操作"><MoreHorizontal aria-hidden="true" :size="15" /></el-button>
<template #dropdown><el-dropdown-menu>
<el-dropdown-item @click="remove(row as InboundFeeDetail)">删除</el-dropdown-item>
</el-dropdown-menu></template>
</el-dropdown>
</div>
</template>
</el-table-column>
<el-table-column label="审核状态" width="120"><template #default="{ row }"><StatusBadge :status="reviewLabel(row.reviewStatus)" /></template></el-table-column>
@@ -316,7 +323,7 @@ onMounted(() => void load())
</div>
</template>
<AppDialog v-model="editorOpen" :title="editorMode === 'create' ? '新增费用' : editorMode === 'adjust' ? '调整已审核费用' : '编辑费用'" width="min(680px, calc(100vw - 28px))" :confirm-loading="saving" confirm-text="保存" @confirm="save">
<AppDialog class="admin-density-dialog" v-model="editorOpen" :title="editorMode === 'create' ? '新增费用' : editorMode === 'adjust' ? '调整已审核费用' : '编辑费用'" width="min(680px, calc(100vw - 28px))" :confirm-loading="saving" confirm-text="保存" @confirm="save">
<el-alert v-if="editorMode === 'adjust'" title="调整将生成新版本并重新进入待审核状态" type="warning" :closable="false" show-icon />
<el-alert v-if="selectedFeeItem?.feeCode === privateDeliveryFeeCode" title="私人仓尾程派送费仅适用于目的仓类型为“私人仓”的订单,最终资格由服务端校验。" type="info" :closable="false" show-icon />
<el-alert v-if="!canViewFeeItems" title="当前账号缺少费用项目查看权限,无法新增或修改费用" type="warning" :closable="false" show-icon />
@@ -324,7 +331,7 @@ onMounted(() => void load())
<el-alert v-if="feeItemsError" type="error" :closable="true" @close="feeItemsError = null"><template #title>{{ feeItemsError.message }}</template><ApiErrorMeta :details="feeItemsError" /></el-alert>
<el-alert v-if="suppliersError" type="error" :closable="true" @close="suppliersError = null"><template #title>{{ suppliersError.message }}</template><ApiErrorMeta :details="suppliersError" /></el-alert>
<el-alert v-if="actionError" type="error" :closable="true" @close="actionError = null"><template #title>{{ actionError.message }}</template><ApiErrorMeta :details="actionError" /></el-alert>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="fee-form">
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="fee-form density-form">
<el-form-item label="应收/应付" prop="direction"><el-segmented v-model="form.direction" :options="directionOptions" /></el-form-item>
<el-form-item label="费用项目" prop="feeItemId"><el-select v-model="form.feeItemId" filterable :loading="feeItemsLoading" :disabled="!canViewFeeItems"><el-option v-for="item in applicableItems" :key="item.id" :label="item.feeTypeName" :value="item.id" /></el-select></el-form-item>
<el-form-item v-if="form.direction === 'PAYABLE'" label="供应商" required><el-select v-model="form.supplierId" filterable :loading="suppliersLoading" :disabled="!canViewSuppliers || !selectedFeeItem || !!suppliersError" placeholder="请先选择费用项目"><el-option v-for="supplier in suppliers" :key="supplier.id" :label="supplier.supplierName" :value="supplier.id" /></el-select></el-form-item>
@@ -346,26 +353,26 @@ onMounted(() => void load())
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注(选填)" /></el-form-item>
</el-form>
</AppDialog>
<AppDialog v-model="rejectOpen" title="驳回费用" :confirm-loading="saving" confirm-text="确认驳回" confirm-type="warning" @confirm="reject">
<AppDialog class="admin-density-dialog" v-model="rejectOpen" title="驳回费用" :confirm-loading="saving" confirm-text="确认驳回" confirm-type="warning" @confirm="reject">
<el-alert v-if="actionError" type="error" :closable="true" @close="actionError = null"><template #title>{{ actionError.message }}</template><ApiErrorMeta :details="actionError" /></el-alert>
<el-form label-position="top"><el-form-item label="驳回原因" required><el-input v-model="rejectReason" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入驳回原因" /></el-form-item></el-form>
<el-form class="density-form" label-position="top"><el-form-item label="驳回原因" required><el-input v-model="rejectReason" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入驳回原因" /></el-form-item></el-form>
</AppDialog>
</section>
</template>
<style scoped>
.fee-detail-manager { display: grid; min-width: 0; max-width: 100%; gap: 12px; }
.fee-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; color: var(--el-text-color-secondary); font-size: 13px; }
.fee-toolbar > div { display: flex; flex-wrap: wrap; gap: 8px; }
.fee-detail-manager { display: grid; min-width: 0; max-width: 100%; gap: var(--admin-space-5); }
.fee-toolbar { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5); color: var(--el-text-color-secondary); font-size: var(--admin-font-body); }
.fee-toolbar > div { display: flex; flex-wrap: wrap; gap: var(--admin-space-3); }
.fee-toolbar :deep(.el-button + .el-button) { margin-left: 0; }
.fee-toolbar .is-spinning { animation: fee-refresh-spin 0.8s linear infinite; }
.fee-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; margin-top: 14px; }
.fee-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-6); margin-top: var(--admin-space-5); }
.fee-form :deep(.el-select), .fee-form :deep(.el-date-editor), .fee-form :deep(.el-input-number) { width: 100%; }
.fee-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }
.voucher-field { grid-column: 1 / -1; }.voucher-control { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; width: 100%; }.voucher-picker { display: inline-flex; align-items: center; gap: 6px; padding: 7px 12px; border: 1px solid var(--cc-border); border-radius: 6px; color: var(--el-color-primary); cursor: pointer; }.voucher-picker.is-disabled { cursor: not-allowed; opacity: .6; }.voucher-picker input { position: absolute; width: 1px; height: 1px; opacity: 0; }.voucher-hint { color: var(--cc-muted); font-size: 12px; }.voucher-list { display: grid; width: 100%; gap: 6px; margin-top: 8px; }.voucher-list > span { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 9px; border-radius: 5px; background: var(--el-fill-color-light); }.voucher-list > span > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.voucher-list button { display: inline-flex; padding: 2px; border: 0; background: transparent; color: var(--el-color-danger); cursor: pointer; }
.voucher-field { grid-column: 1 / -1; }.voucher-control { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-4); width: 100%; }.voucher-picker { display: inline-flex; align-items: center; gap: var(--admin-space-2); padding: var(--admin-space-2) var(--admin-space-5); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); color: var(--el-color-primary); cursor: pointer; }.voucher-picker.is-disabled { cursor: not-allowed; opacity: .6; }.voucher-picker input { position: absolute; width: 1px; height: 1px; opacity: 0; }.voucher-hint { color: var(--cc-muted); font-size: var(--admin-font-caption); }.voucher-list { display: grid; width: 100%; gap: var(--admin-space-2); margin-top: var(--admin-space-3); }.voucher-list > span { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-3); padding: var(--admin-space-2) var(--admin-space-3); border-radius: var(--admin-radius); background: var(--el-fill-color-light); }.voucher-list > span > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.voucher-list button { display: inline-flex; padding: 2px; border: 0; background: transparent; color: var(--el-color-danger); cursor: pointer; }
.fee-table-shell { min-width: 0; max-width: 100%; overflow: hidden; }
.fee-table-shell :deep(.el-table) { width: 100%; max-width: 100%; }
.private-delivery-tag { margin-left: 6px; vertical-align: middle; }
.private-delivery-tag { margin-left: var(--admin-space-2); vertical-align: middle; }
@keyframes fee-refresh-spin { to { transform: rotate(360deg); } }
@media (max-width: 720px) { .fee-toolbar { align-items: flex-start; flex-direction: column; } .fee-form { grid-template-columns: 1fr; } .fee-form :deep(.el-form-item:last-child) { grid-column: auto; } }
</style>
@@ -1,6 +1,6 @@
import ElementPlus, { ElMessage, ElMessageBox } from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { updatePlatformUnifiedOrderDeclaration } from '@/modules/unified-orders/api/unifiedOrders'
import { listWarehouseLogisticsChannels } from '@/modules/ocean-bookings'
import OrderDeclarationEditorDialog from './OrderDeclarationEditorDialog.vue'
@@ -23,13 +23,14 @@ const detail = {
timeline: [],
} as never
function createWrapper() {
function createWrapper(realDialogs = false) {
return mount(OrderDeclarationEditorDialog, {
attachTo: realDialogs ? document.body : undefined,
props: { modelValue: true, detail },
global: {
plugins: [ElementPlus],
stubs: {
AppDialog: {
AppDialog: realDialogs ? false : {
props: ['modelValue', 'confirmLoading', 'confirmDisabled'], emits: ['confirm', 'update:modelValue'],
template: '<section v-if="modelValue"><slot /><button data-testid="save" :disabled="confirmDisabled" @click="$emit(\'confirm\')">保存</button></section>',
},
@@ -40,6 +41,9 @@ function createWrapper() {
})
}
enableAutoUnmount(afterEach)
afterEach(() => { document.body.innerHTML = '' })
describe('OrderDeclarationEditorDialog', () => {
beforeEach(() => {
vi.mocked(updatePlatformUnifiedOrderDeclaration).mockReset()
@@ -52,6 +56,31 @@ describe('OrderDeclarationEditorDialog', () => {
vi.spyOn(ElMessage, 'success').mockImplementation(() => ({ close: vi.fn() }) as never)
})
it('styles both native dialog roots and retains all cargo fields before applying a local edit', async () => {
createWrapper(true)
await flushPromises()
const parent = document.querySelector<HTMLElement>('.el-dialog.admin-density-dialog')!
expect(parent.style.getPropertyValue('--el-dialog-width')).toBe('min(1180px, calc(100vw - 36px))')
expect(parent.querySelector('.density-descriptions')).not.toBeNull()
parent.querySelector<HTMLButtonElement>('.cargo-table .density-action')!.click()
await flushPromises()
const dialogs = document.querySelectorAll<HTMLElement>('.el-dialog.admin-density-dialog')
expect(dialogs).toHaveLength(2)
const cargo = dialogs[1]!
expect(cargo.style.getPropertyValue('--el-dialog-width')).toBe('min(980px, calc(100vw - 36px))')
expect(cargo.querySelectorAll('.cargo-editor-grid > label')).toHaveLength(22)
const name = cargo.querySelector<HTMLInputElement>('input[placeholder="请输入中文品名"]')!
expect(name.value).toBe('台灯')
name.value = '编辑后的台灯'
name.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
const apply = [...cargo.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent?.trim() === '应用本项')!
apply.click()
await flushPromises()
expect(parent.textContent).toContain('编辑后的台灯')
expect(updatePlatformUnifiedOrderDeclaration).not.toHaveBeenCalled()
})
it('loads formal warehouse channels and submits the code/name pair from one selected option', async () => {
vi.mocked(updatePlatformUnifiedOrderDeclaration).mockResolvedValue({ version: 8 } as never)
const wrapper = createWrapper()
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
@@ -180,7 +181,7 @@ async function save() {
await ElMessageBox.confirm(
'渠道变更后,系统会重新校验分柜、同柜及计费约束。原箱码继续有效,无需更换已打印箱唛;纸面上的旧渠道文字不会自动变化,请以系统当前渠道为准。订单将标记为“曾变更渠道”。确认继续吗?',
'确认变更渠道',
{ type: 'warning', confirmButtonText: '确认变更', cancelButtonText: '返回检查' },
{ customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认变更', cancelButtonText: '返回检查' },
)
} catch { return }
}
@@ -219,7 +220,7 @@ async function save() {
</script>
<template>
<AppDialog
<AppDialog class="admin-density-dialog"
:model-value="modelValue"
title="编辑客户申报资料"
width="min(1180px, calc(100vw - 36px))"
@@ -239,14 +240,14 @@ async function save() {
<template #details><ApiErrorMeta :details="channelsError" /></template>
</LoadErrorState>
<el-descriptions :column="4" border class="readonly-facts">
<el-descriptions :column="4" border class="readonly-facts density-descriptions">
<el-descriptions-item label="入仓码">{{ detail.summary.inboundCode || '—' }}</el-descriptions-item>
<el-descriptions-item label="国内收货仓">{{ detail.summary.receivingWarehouseName || '—' }}</el-descriptions-item>
<el-descriptions-item label="目的仓/中转仓">{{ detail.summary.destinationWarehouseName || '—' }}</el-descriptions-item>
<el-descriptions-item label="版本">{{ form.expectedVersion }}</el-descriptions-item>
</el-descriptions>
<el-form label-position="top" class="order-fields">
<el-form label-position="top" class="order-fields density-form">
<el-row :gutter="14">
<el-col :span="7"><el-form-item label="渠道名称" required><el-select v-model="form.channelCode" aria-label="渠道名称" filterable clearable :loading="channelsLoading" placeholder="请选择正式渠道" @change="selectChannel"><el-option v-for="channel in channels" :key="channel.channelCode" :label="`${channel.channelName} · ${channel.channelCode}`" :value="channel.channelCode" /></el-select></el-form-item></el-col>
<el-col :span="4"><el-form-item label="渠道编码"><el-input :model-value="form.channelCode" readonly aria-label="渠道编码" /></el-form-item></el-col>
@@ -258,7 +259,7 @@ async function save() {
<h3>货品申报明细 <small>不允许新增、删除或调整明细序号</small></h3>
<el-table :data="form.cargoes" border stripe size="small" class="cargo-table">
<el-table-column label="操作" width="82" fixed="left"><template #default="{ $index }"><el-button link type="primary" @click="editCargo($index)">编辑</el-button></template></el-table-column>
<el-table-column label="操作" width="82" fixed="left"><template #default="{ $index }"><el-button class="density-action" data-tone="neutral" @click="editCargo($index)">编辑</el-button></template></el-table-column>
<el-table-column prop="itemNo" label="项" width="58" align="center" />
<el-table-column label="货品" min-width="190"><template #default="{ row }"><strong class="cell-primary" :title="row.cargoName">{{ row.cargoName }}</strong><small :title="row.cargoNameEn || row.skuNo">{{ row.cargoNameEn || row.skuNo || '—' }}</small></template></el-table-column>
<el-table-column label="箱数 / 数量" width="118" align="right"><template #default="{ row }">{{ row.packageCount }} 箱 / {{ row.totalQuantity }}</template></el-table-column>
@@ -271,7 +272,7 @@ async function save() {
</div>
</AppDialog>
<AppDialog v-model="cargoEditorOpen" title="编辑货品申报" width="min(980px, calc(100vw - 36px))" confirm-text="应用本项" :close-on-click-modal="false" @confirm="applyCargo">
<AppDialog class="admin-density-dialog" v-model="cargoEditorOpen" title="编辑货品申报" width="min(980px, calc(100vw - 36px))" confirm-text="应用本项" :close-on-click-modal="false" @confirm="applyCargo">
<div v-if="cargoDraft" class="cargo-editor-grid">
<label><span>中文品名 *</span><el-input v-model="cargoDraft.cargoName" maxlength="128" placeholder="请输入中文品名" /></label>
<label><span>英文品名</span><el-input v-model="cargoDraft.cargoNameEn" maxlength="128" placeholder="请输入英文品名(选填)" /></label>
@@ -300,15 +301,15 @@ async function save() {
</template>
<style scoped>
.declaration-editor { display: grid; gap: 14px; max-height: calc(100vh - 190px); overflow: auto; padding-right: 4px; }
.declaration-editor { display: grid; gap: var(--admin-space-5); }
.readonly-facts { margin-top: 2px; }
.order-fields :deep(.el-date-editor), .order-fields :deep(.el-input) { width: 100%; }
h3 { margin: 0; color: #344054; font-size: 15px; } h3 small { margin-left: 8px; color: #98a2b3; font-weight: 400; }
h3 { margin: 0; color: #344054; font-size: var(--admin-font-section); } h3 small { margin-left: var(--admin-space-3); color: #98a2b3; font-weight: 400; }
.cargo-table { width: 100%; }
.cargo-table small, .cargo-table .cell-primary { display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cargo-table small { margin-top: 3px; color: #98a2b3; }
.cargo-editor-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px 14px; }
.cargo-editor-grid label { display: grid; min-width: 0; gap: 5px; color: #667085; font-size: 12px; }
.cargo-editor-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--admin-space-5) var(--admin-space-5); }
.cargo-editor-grid label { display: grid; min-width: 0; gap: var(--admin-space-1); color: #667085; font-size: var(--admin-font-caption); }
.cargo-editor-grid label.wide { grid-column: span 3; }
.cargo-editor-grid :deep(.el-input-number), .cargo-editor-grid :deep(.el-input), .order-fields :deep(.el-select) { width: 100%; }
@media (max-width: 900px) { .cargo-editor-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .cargo-editor-grid label.wide { grid-column: span 2; } }
@@ -37,6 +37,7 @@ vi.mock('@/modules/unified-orders/api/unifiedOrders', () => ({
{ value: 'LOADING', label: '装柜中' },
{ value: 'LOADED', label: '装柜完成' },
{ value: 'DELIVERED', label: '派送完成' },
{ value: 'COMPLETED', label: '已完成' },
],
}))
@@ -6,7 +6,17 @@ import { unifiedOrderStatusOptions } from '../api/unifiedOrders'
const source = readFileSync(resolve(process.cwd(), 'src/modules/unified-orders/pages/UnifiedOrdersView.vue'), 'utf8').replace(/\r\n/g, '\n')
const routeSource = readFileSync(resolve(process.cwd(), 'src/modules/unified-orders/routes.ts'), 'utf8').replace(/\r\n/g, '\n')
const densitySource = readFileSync(resolve(process.cwd(), 'src/styles/adminDensity.css'), 'utf8')
describe('UnifiedOrdersView contract', () => {
it('exposes desktop pallet printing only for one-click dropship orders', () => {
expect(source).toContain("detail.value?.summary.fulfillmentType === 'ONE_CLICK_DROPSHIP'")
expect(source).toContain("{ key: 'dropship-print', label: '托盘打印', icon: '🖨️' }")
expect(source).toContain("activeDetailTab === 'dropship-print'")
expect(source).toContain('<DropshipPalletPrintPanel')
expect(source).toContain("can('platform:firstleg:inbound:list')")
})
it('loads the reference unit price and lets the backend enforce the combined final lock', () => {
const openDialogSource = source.slice(source.indexOf('async function openFeeConfirmDialog()'), source.indexOf('async function executeConfirmAndSendFee()'))
const sendFeeSource = source.slice(source.indexOf('async function executeConfirmAndSendFee()'), source.indexOf('function handleOrderSelectionChange'))
@@ -18,6 +28,19 @@ describe('UnifiedOrdersView contract', () => {
expect(sendFeeSource).toContain("ElMessage.error(getApiErrorDetails(error, '确认费用发送失败').message)")
})
it('prevents duplicate confirm-and-send chains and reuses one attempt idempotency key set', () => {
const sendFeeSource = source.slice(source.indexOf('async function executeConfirmAndSendFee()'), source.indexOf('function handleOrderSelectionChange'))
expect(sendFeeSource).toContain('if (confirmFeeSending.value) return')
expect(sendFeeSource).toContain('const attempt = currentFeeConfirmAttempt(selectedWeightKg, selectedVolumeCbm, selectedUnitPrice)')
expect(sendFeeSource).toContain('attempt.calculateKey')
expect(sendFeeSource).toContain('attempt.adjustKey')
expect(sendFeeSource).toContain('attempt.confirmKey')
expect(sendFeeSource).toContain('attempt.sendKey')
expect(sendFeeSource).toContain("if (snapshot.feeStatus === 'SENT')")
expect(source).toContain('feeConfirmAttempt = null\n feeConfirmOpen.value = true')
})
it('exposes the product main statuses including dropship palletizing', () => {
expect(unifiedOrderStatusOptions.map((item) => item.label)).toEqual([
'待审核', '待测量数据', '待一件代发组托', '待分柜', '装柜中', '装柜完成', '派送完成',
@@ -553,7 +576,7 @@ describe('UnifiedOrdersView contract', () => {
it('renders order detail as a routed full page instead of a drawer', () => {
expect(source).toContain('<template v-if="isDetailRoute">')
expect(source).toContain('class="order-detail-header"')
expect(source).toContain('class="order-detail-header density-detail-header"')
expect(source).toContain('aria-label="订单详情模块"')
expect(source).toContain("{ key: 'basic', label: '基本信息'")
expect(source).toContain("{ key: 'measurement', label: '测量数据'")
@@ -629,8 +652,9 @@ describe('UnifiedOrdersView contract', () => {
})
it('keeps detail tabs compact on one horizontally scrollable row', () => {
expect(source).toContain('.detail-tabs { display: flex; overflow-x: auto; overflow-y: hidden;')
expect(source).toContain('min-width: max-content; min-height: 38px; flex: 0 0 auto;')
expect(source).toContain('class="detail-tabs density-tabs"')
expect(densitySource).toContain('overflow-x: auto; overflow-y: hidden;')
expect(densitySource).toContain('min-height: var(--admin-tab-height)')
expect(source).not.toContain('grid-template-columns: repeat(6, minmax(124px, 1fr))')
expect(source).not.toContain('.detail-tabs { grid-template-columns: repeat(6, 132px); }')
})
@@ -641,9 +665,9 @@ describe('UnifiedOrdersView contract', () => {
expect(source).toContain('<el-table :data="outerMeasurementRows" border stripe')
expect(source).toContain('<el-table :data="actualMeasurementRows" row-key="id" border stripe')
expect(source).toContain('<el-table :data="customerMeasurementRows" border stripe empty-text="暂无客户下单数据"')
expect(source).toContain('--el-table-border-color: #e5e7eb')
expect(source).toContain('--el-table-row-hover-bg-color: #f3f7fb')
expect(source).toContain('.el-table--striped .el-table__body tr.el-table__row--striped')
expect(densitySource).toContain('--el-table-border-color: var(--admin-table-border)')
expect(densitySource).toContain('--el-table-row-hover-bg-color: var(--admin-row-hover)')
expect(densitySource).toContain('.el-table__body tr.el-table__row--striped')
expect(source).toContain("label: '真实集装箱号'")
expect(source).toContain("label: '内部柜次号'")
expect(source).toContain('<dt>真实集装箱号</dt>')
@@ -652,9 +676,9 @@ describe('UnifiedOrdersView contract', () => {
it('uses outlined semantic buttons for row business actions while keeping detail navigation explicit', () => {
const rowActions = source.slice(source.indexOf('<template #actions'), source.indexOf('<template #inboundCode'))
expect(rowActions).toContain('order-action-button')
expect(rowActions).toContain('density-action')
expect(rowActions).not.toContain('v-for="action in directUnifiedOrderActions(row as UnifiedOrderResource)" :key="action" link')
expect(rowActions).toContain('@click="openDetail(row as UnifiedOrderResource)">详情</el-button>')
expect(source).toContain('<el-button link class="back-button"')
expect(source).toContain('<el-button link class="back-button density-back"')
})
})
@@ -78,6 +78,7 @@ import {
InboundFeePanel,
InboundMeasurementDialog,
MeasurementEvidenceThumbnail,
DropshipPalletPrintPanel,
sendInboundFee,
type InboundFeeSnapshot,
type InboundMeasurementDetail,
@@ -247,6 +248,14 @@ const feeConfirmReferenceLoading = ref(false)
const feeConfirmReference = ref<InboundFeeSnapshot | null>(null)
const feeConfirmUnitPrice = ref<number | null>(null)
const feeConfirmPriceReason = ref('')
type FeeConfirmAttempt = {
fingerprint: string
calculateKey: string
adjustKey: string
confirmKey: string
sendKey: string
}
let feeConfirmAttempt: FeeConfirmAttempt | null = null
const feeConfirmPriceChanged = computed(() => {
const reference = Number(feeConfirmReference.value?.unitPrice)
const current = Number(feeConfirmUnitPrice.value)
@@ -324,15 +333,17 @@ const canReleaseInbound = computed(() => Boolean(
&& holdDetail.value?.canRelease,
))
const detailTabs = [
const isDropshipOrder = computed(() => detail.value?.summary.fulfillmentType === 'ONE_CLICK_DROPSHIP')
const detailTabs = computed(() => [
{ key: 'basic', label: '基本信息', icon: '📋' },
{ key: 'cargo', label: '货品信息', icon: '📦' },
{ key: 'fees', label: '费用信息', icon: '💰' },
{ key: 'history', label: '状态历史', icon: '📝' },
{ key: 'measurement', label: '测量数据', icon: '📐' },
...(isDropshipOrder.value ? [{ key: 'dropship-print', label: '托盘打印', icon: '🖨️' }] : []),
{ key: 'other', label: '其他', icon: '🗂️' },
{ key: 'fulfillment', label: '拆分履约', icon: '🧩' },
]
])
const customerMeasurementColumns = [
{ key: 'itemNo', label: '#' }, { key: 'lengthCm', label: '箱规长(cm)' }, { key: 'widthCm', label: '箱规宽(cm)' },
@@ -1640,6 +1651,7 @@ async function openFeeConfirmDialog() {
feeConfirmReference.value = null
feeConfirmUnitPrice.value = null
feeConfirmPriceReason.value = ''
feeConfirmAttempt = null
feeConfirmOpen.value = true
feeConfirmReferenceLoading.value = true
try {
@@ -1668,7 +1680,31 @@ async function openFeeConfirmDialog() {
}
}
function currentFeeConfirmAttempt(
selectedWeightKg: number,
selectedVolumeCbm: number,
selectedUnitPrice: number,
) {
const fingerprint = JSON.stringify([
selectedWeightKg,
selectedVolumeCbm,
selectedUnitPrice,
feeConfirmPriceReason.value.trim(),
])
if (!feeConfirmAttempt || feeConfirmAttempt.fingerprint !== fingerprint) {
feeConfirmAttempt = {
fingerprint,
calculateKey: createFeeActionIdempotencyKey('calculate'),
adjustKey: createFeeAdjustmentIdempotencyKey(),
confirmKey: createFeeActionIdempotencyKey('confirm'),
sendKey: createFeeActionIdempotencyKey('send'),
}
}
return feeConfirmAttempt
}
async function executeConfirmAndSendFee() {
if (confirmFeeSending.value) return
const summary = detail.value?.summary
if (!summary?.inboundId) return
const selectedWeightKg = Number(feeConfirmWeight.value)
@@ -1686,6 +1722,7 @@ async function executeConfirmAndSendFee() {
ElMessage.warning('人工修改参考单价后,请填写至少 2 个字符的调整原因')
return
}
const attempt = currentFeeConfirmAttempt(selectedWeightKg, selectedVolumeCbm, selectedUnitPrice)
confirmFeeSending.value = true
try {
const tenantId = String(summary.tenantId)
@@ -1695,7 +1732,7 @@ async function executeConfirmAndSendFee() {
selectedVolumeCbm,
weightSource: 'ACTUAL',
volumeSource: 'ACTUAL',
}, createFeeActionIdempotencyKey('calculate'))
}, attempt.calculateKey)
const calculatedUnitPrice = Number(snapshot.unitPrice)
if (Math.abs(selectedUnitPrice - calculatedUnitPrice) > 0.000001) {
snapshot = await adjustPublishedInboundFee(tenantId, inboundId, {
@@ -1705,15 +1742,18 @@ async function executeConfirmAndSendFee() {
additionalAmount: Number(snapshot.additionalAmount || 0),
reason: feeConfirmPriceReason.value.trim() || '推送前按已展示参考单价人工确认',
toleranceConfirmed: true,
}, createFeeAdjustmentIdempotencyKey())
}, attempt.adjustKey)
}
snapshot = await confirmInboundFee(tenantId, inboundId, createFeeActionIdempotencyKey('confirm'))
snapshot = await confirmInboundFee(tenantId, inboundId, attempt.confirmKey)
if (snapshot.feeStatus === 'CONFIRMED') {
const sent = await sendInboundFee(tenantId, inboundId, createFeeActionIdempotencyKey('send'))
automaticFee.value = { feeStatus: sent.feeStatus, totalAmount: Number(sent.totalAmount || 0) }
snapshot = await sendInboundFee(tenantId, inboundId, attempt.sendKey)
}
if (snapshot.feeStatus === 'SENT') {
automaticFee.value = { feeStatus: snapshot.feeStatus, totalAmount: Number(snapshot.totalAmount || 0) }
feePanelRevision.value += 1
ElMessage.success('计费量和单价已确认并发送给客户')
feeConfirmOpen.value = false
feeConfirmAttempt = null
await loadDetail(true)
return
}
@@ -1838,7 +1878,7 @@ async function cancelCoLoadGroup() {
await ElMessageBox.confirm(
'确认解除整个同柜组吗?解除后这些订单可重新选择成员建组;不会拆分订单或子单。',
'解除同柜组',
{ confirmButtonText: '确认解除', cancelButtonText: '取消', type: 'warning' },
{ customClass: 'admin-density-confirm', confirmButtonText: '确认解除', cancelButtonText: '取消', type: 'warning' },
)
} catch (error) {
if (error === 'cancel' || error === 'close') return
@@ -2184,7 +2224,7 @@ onBeforeUnmount(() => {
<template>
<AdminPage>
<section class="business-page unified-orders-page">
<section class="business-page unified-orders-page admin-density">
<AppDialog class="admin-density-dialog" v-model="feeConfirmOpen" title="编辑计费数据" confirm-text="确认并发送" :confirm-loading="confirmFeeSending || feeConfirmReferenceLoading" width="min(920px, 94vw)" :teleported="false" @confirm="executeConfirmAndSendFee">
<el-alert class="fee-confirm-price-alert" title="价格表单价仅作为参考;本次对客单价可人工修改,客户将一次确认计费量、单价和费用合计。" type="info" :closable="false" show-icon />
<el-form label-position="top" class="fee-confirm-form">
@@ -2212,11 +2252,11 @@ onBeforeUnmount(() => {
</AppDialog>
<template v-if="isDetailRoute">
<DetailPageLayout ariaLabel="统一订单详情">
<header class="order-detail-header">
<div class="order-detail-heading">
<el-button link class="back-button" @click="fromMeasurementReview ? returnToMeasurementReviews() : closeDetail()"><ArrowLeft :size="16" />{{ fromMeasurementReview ? '返回测量审核列表' : '返回列表' }}</el-button>
<header class="order-detail-header density-detail-header">
<div class="order-detail-heading density-detail-heading">
<el-button link class="back-button density-back" @click="fromMeasurementReview ? returnToMeasurementReviews() : closeDetail()"><ArrowLeft :size="16" />{{ fromMeasurementReview ? '返回测量审核列表' : '返回列表' }}</el-button>
<div class="order-title">
<h1>{{ detail?.summary.inboundCode || '入仓码暂缺' }}</h1>
<h1 class="density-detail-title">{{ detail?.summary.inboundCode || '入仓码暂缺' }}</h1>
</div>
<StatusBadge v-if="detail" :status="statusLabel(detail.summary.mainStatus)" />
</div>
@@ -2232,7 +2272,7 @@ onBeforeUnmount(() => {
<div v-else v-loading="detailLoading" class="order-detail-content">
<template v-if="detail">
<dl class="detail-overview" aria-label="订单概览">
<dl class="detail-overview density-detail-overview" aria-label="订单概览">
<div><dt>客户</dt><dd>{{ customerName(detail.summary) }}</dd></div>
<div><dt>渠道</dt><dd>{{ detail.summary.channelName || '未配置渠道' }}</dd></div>
<div><dt>总箱数</dt><dd>{{ detail.summary.totalPackageCount ?? '—' }} 箱 <small>({{ detail.order.cargoes.length }} 条货品)</small></dd></div>
@@ -2242,13 +2282,13 @@ onBeforeUnmount(() => {
<div><dt>最近更新</dt><dd>{{ formatDate(detail.summary.updatedAt) }}</dd></div>
</dl>
<div class="detail-tabs" role="tablist" aria-label="订单详情模块">
<button v-for="tab in detailTabs" :id="`order-detail-tab-${tab.key}`" :key="tab.key" type="button" role="tab" :class="{ active: activeDetailTab === tab.key }" :aria-selected="activeDetailTab === tab.key" :aria-controls="`order-detail-panel-${tab.key}`" :tabindex="activeDetailTab === tab.key ? 0 : -1" @click="selectDetailTab(tab.key)"><span class="detail-tab-icon" aria-hidden="true">{{ tab.icon }}</span><span>{{ tab.label }}</span></button>
<div class="detail-tabs density-tabs" role="tablist" aria-label="订单详情模块">
<button v-for="tab in detailTabs" :id="`order-detail-tab-${tab.key}`" :key="tab.key" type="button" role="tab" :class="{ active: activeDetailTab === tab.key }" :aria-selected="activeDetailTab === tab.key" :aria-controls="`order-detail-panel-${tab.key}`" :tabindex="activeDetailTab === tab.key ? 0 : -1" @click="selectDetailTab(tab.key)"><span class="density-tab-icon" aria-hidden="true">{{ tab.icon }}</span><span>{{ tab.label }}</span></button>
</div>
<section :id="`order-detail-panel-${activeDetailTab}`" class="detail-panel" role="tabpanel" :aria-labelledby="`order-detail-tab-${activeDetailTab}`" tabindex="0">
<template v-if="activeDetailTab === 'basic'">
<dl class="order-info-grid">
<dl class="order-info-grid density-fields">
<div><dt>公司</dt><dd>{{ customerName(detail.summary) }}</dd></div>
<div><dt>运输方式</dt><dd>{{ firstLegTransportModeLabel(detail.order.order?.transportMode) }}</dd></div>
<div><dt>当前状态</dt><dd><StatusBadge :status="statusLabel(detail.summary.mainStatus)" /></dd></div>
@@ -2396,6 +2436,17 @@ onBeforeUnmount(() => {
</section>
</template>
<template v-else-if="activeDetailTab === 'dropship-print'">
<el-empty v-if="!normalizedInboundId" description="订单尚未生成入库单,暂无托盘码可打印" :image-size="64" />
<el-alert v-else-if="!can('platform:firstleg:inbound:list')" title="当前账号缺少入库单查看权限,无法打印托盘码" type="warning" :closable="false" show-icon />
<DropshipPalletPrintPanel
v-else
:customer-id="String(detail.summary.tenantId)"
:inbound-id="normalizedInboundId"
:inbound-code="detail.summary.inboundCode"
/>
</template>
<template v-else-if="activeDetailTab === 'measurement'">
<div class="panel-title">
<p>{{ detail.summary.measurement?.completedAt ? `测量完成于 ${formatDate(detail.summary.measurement.completedAt)}` : '尚未完成测量' }}</p>
@@ -2589,11 +2640,11 @@ onBeforeUnmount(() => {
<el-button v-if="can('platform:firstleg:smart-loading:list')" type="primary" @click="smartLoadingOpen = true"><PackageOpen :size="16" />智能装柜</el-button>
</template>
<template #actions="{ row }">
<div class="row-actions">
<el-button class="order-action-button" size="small" @click="openDetail(row as UnifiedOrderResource)">详情</el-button>
<el-button v-for="action in directUnifiedOrderActions(row as UnifiedOrderResource)" :key="action" :class="['order-action-button', `order-action-${action}`]" size="small" :loading="action === 'approve' && approvalLoadingId === String((row as UnifiedOrderResource).orderId)" @click="runMoreAction(action, row as UnifiedOrderResource)">{{ unifiedOrderActionLabel(action) }}</el-button>
<div class="row-actions density-actions">
<el-button class="density-action" size="small" @click="openDetail(row as UnifiedOrderResource)">详情</el-button>
<el-button v-for="action in directUnifiedOrderActions(row as UnifiedOrderResource)" :key="action" class="density-action" :data-tone="action === 'approve' ? 'warning' : action === 'measurement' ? 'purple' : 'neutral'" size="small" :loading="action === 'approve' && approvalLoadingId === String((row as UnifiedOrderResource).orderId)" @click="runMoreAction(action, row as UnifiedOrderResource)">{{ unifiedOrderActionLabel(action) }}</el-button>
<el-dropdown v-if="moreUnifiedOrderActions(row as UnifiedOrderResource).length" trigger="click" @command="runMoreAction($event, row as UnifiedOrderResource)">
<el-button class="order-action-button" size="small" :aria-label="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)" :title="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)"><MoreHorizontal :size="17" aria-hidden="true" /></el-button>
<el-button class="density-action" size="small" :aria-label="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)" :title="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)"><MoreHorizontal :size="17" aria-hidden="true" /></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-for="action in moreUnifiedOrderActions(row as UnifiedOrderResource)" :key="action" :command="action">{{ unifiedOrderActionLabel(action) }}</el-dropdown-item>
@@ -2711,7 +2762,7 @@ onBeforeUnmount(() => {
</AppDialog>
</template>
<AppDrawer v-model="relationOpen" title="父子订单关联" size="min(860px, 94vw)" :show-footer="false" destroy-on-close>
<AppDrawer class="admin-density-drawer" v-model="relationOpen" title="父子订单关联" size="min(860px, 94vw)" :show-footer="false" destroy-on-close>
<OrderFulfillmentTreePanel
v-if="relationOrder"
:tenant-id="String(relationOrder.tenantId)"
@@ -2786,20 +2837,17 @@ onBeforeUnmount(() => {
</template>
<style scoped>
.detail-overview > div { display: flex; min-width: 0; align-items: baseline; gap: 6px; }
.detail-overview dt { flex: 0 0 auto; color: var(--cc-muted); font-size: 12px; }
.detail-overview dd { min-width: 0; margin: 0; color: var(--cc-text); font-size: 13px; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
.detail-overview small { color: var(--cc-muted); font-size: 12px; }
.detail-tabs button:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: -2px; }
.panel-title > div:first-child { display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 12px; }
.compact-empty { margin: 0; padding: 14px 10px; color: var(--cc-muted); text-align: center; font-size: 13px; }
.detail-tabs { border: 1px solid var(--cc-border); border-block: 0; }
.panel-title > div:first-child { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--admin-space-1, 4px) var(--admin-space-5, 12px); }
.compact-empty { margin: 0; padding: 14px var(--admin-space-4, 10px); color: var(--cc-muted); text-align: center; font-size: var(--admin-font-body, 13px); }
.approval-facts { margin: 0; border-top: 1px solid var(--cc-border); }
.approval-facts > div { display: grid; grid-template-columns: 88px minmax(0, 1fr); gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--cc-border); }
.approval-facts dt { color: var(--cc-muted); font-size: 12px; }
.approval-facts dd { min-width: 0; margin: 0; overflow-wrap: anywhere; font-size: 13px; }
.approval-hint { margin: 12px 0 0; color: #475467; font-size: 13px; }
.detail-panel :deep(.el-table) { font-size: 13px; --el-table-header-bg-color: #f8fafc; }
.detail-panel :deep(.el-table__cell) { height: 36px; padding: 4px 0; }
.approval-facts > div { display: grid; grid-template-columns: 88px minmax(0, 1fr); gap: var(--admin-space-5, 12px); padding: var(--admin-space-3, 8px) 0; border-bottom: 1px solid var(--cc-border); }
.approval-facts dt { color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); }
.approval-facts dd { min-width: 0; margin: 0; overflow-wrap: anywhere; font-size: var(--admin-font-body, 13px); }
.approval-hint { margin: var(--admin-space-5, 12px) 0 0; color: #475467; font-size: var(--admin-font-body, 13px); }
.detail-panel :deep(.el-table) { font-size: var(--admin-font-body, 13px); --el-table-header-bg-color: var(--admin-label-bg, #f8fafc); }
.detail-panel :deep(.el-table__cell) { height: var(--admin-row-height, 36px); padding: var(--admin-space-1, 4px) 0; }
.detail-panel :deep(.el-table .cell) { line-height: 22px; overflow-wrap: anywhere; }
.detail-panel :deep(.el-table__empty-block) { min-height: 44px; }
.detail-panel :deep(.el-table__empty-text) { line-height: 44px; }
@@ -2807,36 +2855,35 @@ onBeforeUnmount(() => {
.measurement-section-title:has([aria-expanded="false"]) { border-bottom: 1px solid var(--cc-border); }
.fulfillment-facts + .fulfillment-facts { border-top: 0; }
@media (min-width: 961px) {
.table-card :deep(.data-table__toolbar.is-compact) { min-height: 44px; padding: 6px 12px; }
.table-card :deep(.data-table__pagination) { min-height: 44px; padding: 6px 12px; }
.table-card :deep(.data-table__pagination) { min-height: 44px; padding: var(--admin-space-2, 6px) var(--admin-space-5, 12px); }
}
.unified-orders-page { min-width: 0; gap: 12px; --cc-list-table-font-size: 13px; --cc-list-query-font-size: 13px; --cc-list-action-font-size: 13px; }
.page-intro { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
.unified-orders-page { min-width: 0; gap: var(--admin-space-5, 12px); --cc-list-table-font-size: 13px; --cc-list-query-font-size: 13px; --cc-list-action-font-size: 13px; }
.page-intro { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--admin-space-6, 16px); margin-bottom: var(--admin-space-6, 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 p { margin: 5px 0 0; color: var(--cc-muted); font-size: var(--admin-font-body, 13px); line-height: 1.6; }
.page-intro :deep(.el-button) { flex: 0 0 auto; }
.orders-query-panel { overflow: hidden; border: 1px solid var(--cc-border); border-radius: 4px; background: #fff; }
.status-tabs { display: flex; max-width: 100%; overflow-x: auto; margin-bottom: 0; border-bottom: 1px solid #e5e7eb; background: #f9fafb; scrollbar-width: thin; }
.status-tab { position: relative; flex: 0 0 auto; min-height: 38px; padding: 0 16px; border: 0; background: transparent; color: var(--cc-muted); font: inherit; font-size: 13px; cursor: pointer; }
.orders-query-panel { overflow: hidden; border: 1px solid var(--cc-border); border-radius: var(--admin-radius, 4px); background: var(--admin-surface, #fff); }
.status-tabs { display: flex; max-width: 100%; overflow-x: auto; margin-bottom: 0; border-bottom: 1px solid var(--admin-table-border, #e5e7eb); background: #f9fafb; scrollbar-width: thin; }
.status-tab { position: relative; flex: 0 0 auto; min-height: var(--admin-tab-height, 38px); padding: 0 var(--admin-space-6, 16px); border: 0; background: transparent; color: var(--cc-muted); font: inherit; font-size: var(--admin-font-body, 13px); cursor: pointer; }
.status-tab::after { position: absolute; right: 12px; bottom: 0; left: 12px; height: 3px; background: transparent; content: ''; }
.status-tab:hover { color: var(--cc-primary); background: #f6f9fd; }
.status-tab:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: -2px; }
.status-tab.is-active { color: var(--cc-primary); font-weight: 600; }
.status-tab.is-active::after { background: var(--cc-primary); }
.orders-query-toolbar { border: 0; padding: 10px 12px; }
.table-card { overflow: hidden; border-radius: 4px; box-shadow: none; }
.coload-error { margin: 8px 12px; padding: 8px 10px; border: 1px solid var(--el-color-danger-light-5); border-radius: 4px; background: var(--el-color-danger-light-9); color: var(--el-color-danger); font-size: 12px; }
.orders-query-toolbar { border: 0; padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); }
.table-card { overflow: hidden; border-radius: var(--admin-radius, 4px); box-shadow: none; }
.coload-error { margin: var(--admin-space-3, 8px) var(--admin-space-5, 12px); padding: var(--admin-space-3, 8px) var(--admin-space-4, 10px); border: 1px solid var(--el-color-danger-light-5); border-radius: var(--admin-radius, 4px); background: var(--el-color-danger-light-9); color: var(--el-color-danger); font-size: var(--admin-font-caption, 12px); }
.coload-error :deep(.api-error-meta) { margin-top: 5px; }
.order-code-cell { display: grid; justify-items: start; gap: 4px; }
.order-code-cell { display: grid; justify-items: start; gap: var(--admin-space-1, 4px); }
.coload-tag-trigger { margin: 0; padding: 0; border: 0; background: transparent; cursor: pointer; }
.coload-tag-trigger:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 2px; border-radius: 3px; }
.coload-selection { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
.coload-selection > span { padding: 4px 7px; border: 1px solid var(--cc-border); border-radius: 4px; background: var(--cc-surface-muted); font-size: 12px; }
.coload-detail-summary { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 10px; }
.coload-detail-scroll { overflow-x: auto; border: 1px solid var(--cc-border); border-radius: 4px; }
.coload-detail-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.coload-detail-table th,.coload-detail-table td { padding: 8px 10px; border-bottom: 1px solid var(--cc-border); text-align: left; vertical-align: top; }
.coload-selection { display: flex; flex-wrap: wrap; gap: var(--admin-space-2, 6px); margin-top: var(--admin-space-5, 12px); }
.coload-selection > span { padding: var(--admin-space-1, 4px) 7px; border: 1px solid var(--cc-border); border-radius: var(--admin-radius, 4px); background: var(--cc-surface-muted); font-size: var(--admin-font-caption, 12px); }
.coload-detail-summary { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5, 12px); margin-bottom: var(--admin-space-4, 10px); }
.coload-detail-scroll { overflow-x: auto; border: 1px solid var(--cc-border); border-radius: var(--admin-radius, 4px); }
.coload-detail-table { width: 100%; border-collapse: collapse; font-size: var(--admin-font-caption, 12px); }
.coload-detail-table th,.coload-detail-table td { padding: var(--admin-space-3, 8px) var(--admin-space-4, 10px); border-bottom: 1px solid var(--cc-border); text-align: left; vertical-align: top; }
.coload-detail-table th { background: var(--cc-surface-muted); color: var(--cc-muted); font-weight: 600; }
.coload-detail-table tbody tr:last-child td { border-bottom: 0; }
.coload-detail-table td { min-width: 210px; }
@@ -2844,95 +2891,49 @@ onBeforeUnmount(() => {
.coload-detail-table td strong,.coload-detail-table td span { display: block; overflow-wrap: anywhere; }
.coload-detail-table td span { color: var(--cc-muted); }
.coload-detail-table :deep(.el-button) { min-height: 24px; padding: 2px 0; }
.coload-toolbar-action { display: flex; min-width: 0; align-items: center; gap: 8px; }
.subsequent-fulfillment { margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--cc-border); }
.subsequent-fulfillment h3 { margin: 0 0 8px; font-size: 14px; }
.coload-toolbar-action { display: flex; min-width: 0; align-items: center; gap: var(--admin-space-3, 8px); }
.subsequent-fulfillment { margin-top: var(--admin-space-5, 12px); padding-top: var(--admin-space-4, 10px); border-top: 1px solid var(--cc-border); }
.subsequent-fulfillment h3 { margin: 0 0 var(--admin-space-3, 8px); font-size: var(--admin-font-section, 14px); }
.fulfillment-facts { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 0; border-top: 1px solid var(--cc-border); border-left: 1px solid var(--cc-border); overflow: hidden; }
.batch-approval-summary { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: 12px 0 8px; color: var(--cc-muted); font-size: 13px; white-space: nowrap; }
.batch-approval-summary { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-6, 16px); margin: var(--admin-space-5, 12px) 0 var(--admin-space-3, 8px); color: var(--cc-muted); font-size: var(--admin-font-body, 13px); white-space: nowrap; }
.batch-approval-summary strong { color: var(--cc-text); }
.batch-approval-table :deep(.cell) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fulfillment-facts > div { display: flex; align-items: baseline; gap: 10px; min-width: 0; padding: 8px 10px; border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); background: #fff; }
.fulfillment-facts > div { display: flex; align-items: baseline; gap: var(--admin-space-4, 10px); min-width: 0; padding: var(--admin-space-3, 8px) var(--admin-space-4, 10px); border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); background: var(--admin-surface, #fff); }
.fulfillment-facts > div.wide { grid-column: span 2; }
.fulfillment-facts dt { flex: 0 0 auto; color: var(--cc-muted); font-size: 12px; }
.fulfillment-facts dd { min-width: 0; margin: 0; overflow-wrap: anywhere; font-size: 13px; }
.fulfillment-empty { margin: 0; padding: 12px; border: 1px dashed var(--cc-border); border-radius: 6px; color: var(--cc-muted); text-align: center; }
.table-card :deep(.el-table) { --el-table-border-color: #e5e7eb; --el-table-header-bg-color: #f9fafb; --el-table-row-hover-bg-color: #f3f7fb; --el-table-tr-bg-color: #fff; color: #374151; font-size: 13px; }
.table-card :deep(.el-table th.el-table__cell) { height: 36px; padding: 4px 0; background: #f9fafb; color: #374151; font-size: 13px; font-weight: 600; }
.table-card :deep(.el-table td.el-table__cell) { height: 36px; padding: 4px 0; }
.table-card :deep(.el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell) { background: #fcfcfd; }
.table-card :deep(.el-table__body tr:hover > td.el-table__cell) { background: #f3f7fb; }
.row-actions { display: flex; align-items: center; gap: 6px; white-space: nowrap; }
.fulfillment-facts dt { flex: 0 0 auto; color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); }
.fulfillment-facts dd { min-width: 0; margin: 0; overflow-wrap: anywhere; font-size: var(--admin-font-body, 13px); }
.fulfillment-empty { margin: 0; padding: var(--admin-space-5, 12px); border: 1px dashed var(--cc-border); border-radius: 6px; color: var(--cc-muted); text-align: center; }
.row-actions { display: flex; align-items: center; gap: var(--admin-space-2, 6px); white-space: nowrap; }
.row-actions :deep(.el-button) { margin-left: 0; }
.table-card :deep(.data-table__system-cell .row-actions .el-button) { height: 28px; margin-left: 0; padding: 4px 10px; font-size: 13px; }
.row-actions :deep(.order-action-button) {
--el-button-text-color: #52525b;
--el-button-bg-color: #fff;
--el-button-border-color: #dfe3ee;
--el-button-hover-text-color: #3f3f46;
--el-button-hover-bg-color: #f8fafc;
--el-button-hover-border-color: #b8c0d4;
--el-button-active-text-color: var(--el-button-hover-text-color);
--el-button-active-bg-color: var(--el-button-hover-bg-color);
--el-button-active-border-color: var(--el-button-hover-border-color);
--el-button-disabled-text-color: #a1a1aa;
--el-button-disabled-bg-color: #fff;
--el-button-disabled-border-color: #e5e7eb;
border-radius: 6px;
font-weight: 600;
box-shadow: none;
}
.row-actions :deep(.order-action-approve) {
--el-button-text-color: #c76500;
--el-button-border-color: #fed7aa;
--el-button-hover-text-color: #a34b00;
--el-button-hover-bg-color: #fff7ed;
--el-button-hover-border-color: #fb923c;
}
.row-actions :deep(.order-action-measurement) {
--el-button-text-color: #7c3aed;
--el-button-border-color: #ddd6fe;
--el-button-hover-text-color: #6d28d9;
--el-button-hover-bg-color: #f5f3ff;
--el-button-hover-border-color: #a78bfa;
}
.row-actions :deep(.order-action-button:focus-visible) { outline: 2px solid var(--el-button-text-color); outline-offset: 2px; }
.table-card :deep(.data-table__system-cell .row-actions .el-dropdown) { margin-left: 0; }
.planning-cell { display: grid; min-width: 0; gap: 4px; }
.planning-cell > small { color: var(--cc-muted); font-size: 11px; }
.planning-cell { display: grid; min-width: 0; gap: var(--admin-space-1, 4px); }
.planning-cell > small { color: var(--cc-muted); font-size: var(--admin-font-caption, 11px); }
.planning-volume-input,.planning-arrival-input { width: 100%; max-width: 100%; }
.planning-cell :deep(.el-input__wrapper) { min-height: 32px; padding-right: 9px; padding-left: 9px; }
.planning-cell :deep(.el-input__wrapper) { min-height: var(--admin-control-height, 32px); padding-right: 9px; padding-left: 9px; }
.planning-cell :deep(.el-input__inner) { min-width: 0; text-align: left; }
.inbound-relation-cell { display: grid; min-width: 0; gap: 5px; }
.inbound-code-value { display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.order-relation-link { width: fit-content; max-width: 100%; height: auto; justify-content: flex-start; padding: 0; font-size: 12px; }
.order-relation-link { width: fit-content; max-width: 100%; height: auto; justify-content: flex-start; padding: 0; font-size: var(--admin-font-caption, 12px); }
.order-relation-link :deep(span) { min-width: 0; white-space: normal; text-align: left; }
.primary-cell { display: grid; min-width: 0; gap: 4px; }
.primary-cell { display: grid; min-width: 0; gap: var(--admin-space-1, 4px); }
.primary-cell strong { overflow-wrap: anywhere; }
.primary-cell span { color: var(--cc-muted); font-size: 12px; overflow-wrap: anywhere; }
.order-detail-header { display: flex; min-width: 0; min-height: 40px; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 0; padding: 8px 12px; border: 1px solid var(--cc-border); border-bottom: 0; border-radius: 4px 4px 0 0; background: #fff; }
.order-detail-heading { display: flex; min-width: 0; align-items: center; gap: 10px; }
.primary-cell span { color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); overflow-wrap: anywhere; }
.order-detail-header { border: 1px solid var(--cc-border); border-bottom: 0; border-radius: 4px 4px 0 0; }
.back-button { flex: 0 0 auto; padding: 0; }
.order-title { min-width: 0; }
.order-title h1 { margin: 0; color: #172033; font-size: 16px; line-height: 1.5; overflow-wrap: anywhere; }
.detail-header-actions { display: flex; flex: 0 0 auto; gap: 8px; }
.detail-header-actions { display: flex; flex: 0 0 auto; gap: var(--admin-space-3, 8px); }
.detail-header-actions :deep(.el-button + .el-button) { margin-left: 0; }
.detail-load-error { min-height: 280px; }
.order-detail-content { min-width: 0; }
.detail-overview { display: flex; flex-wrap: wrap; gap: 6px 24px; margin: 0; padding: 8px 12px; border: 1px solid var(--cc-border); border-top: 0; background: #fff; }
.detail-tabs { display: flex; overflow-x: auto; overflow-y: hidden; border: 1px solid var(--cc-border); border-block: 0; background: #fff; scrollbar-width: thin; }
.detail-tabs button { position: relative; display: inline-flex; min-width: max-content; min-height: 38px; flex: 0 0 auto; align-items: center; justify-content: center; gap: 6px; padding: 0 16px; border: 0; background: transparent; color: #475467; font-size: 13px; font-weight: 500; cursor: pointer; }
.detail-tab-icon { display: inline-flex; width: 18px; height: 18px; flex: 0 0 18px; align-items: center; justify-content: center; font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif; font-size: 17px; font-weight: 400; line-height: 1; }
.detail-tabs button:last-child { border-right: 0; }
.detail-tabs button::after { position: absolute; right: 12px; bottom: 0; left: 12px; height: 2px; background: transparent; content: ''; }
.detail-tabs button:hover { background: #fafbff; color: #344054; }
.detail-tabs button.active { background: #f7f8ff; color: var(--cc-primary); font-weight: 600; }
.detail-tabs button.active::after { background: var(--cc-primary); }
.detail-panel { min-width: 0; padding: 12px; border: 1px solid var(--cc-border); border-radius: 0 0 4px 4px; background: #fff; }
.panel-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 28px; margin-bottom: 8px; }
.panel-title h2 { margin: 0; color: #344054; font-size: 14px; font-weight: 600; }
.panel-title p { margin: 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
.detail-overview { border: 1px solid var(--cc-border); border-top: 0; }
.detail-panel { min-width: 0; padding: var(--admin-space-5, 12px); border: 1px solid var(--cc-border); border-radius: 0 0 4px 4px; background: var(--admin-surface, #fff); }
.panel-title { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5, 12px); min-height: var(--admin-action-height, 28px); margin-bottom: var(--admin-space-3, 8px); }
.panel-title h2 { margin: 0; color: #344054; font-size: var(--admin-font-section, 14px); font-weight: 600; }
.panel-title p { margin: 0; color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); line-height: 1.5; }
.panel-title > svg { color: var(--cc-primary); }
.measurement-title-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.measurement-title-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--admin-space-3, 8px); }
.measurement-title-actions :deep(.el-button) { margin-left: 0; }
.fee-confirm-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 22px; }
.fee-confirm-price-alert { margin-bottom: 14px; }
@@ -2940,84 +2941,81 @@ onBeforeUnmount(() => {
.fee-confirm-number { width: 100%; }
.fee-confirm-number :deep(.el-input__wrapper) { padding-right: 92px; }
.fee-confirm-unit { position: absolute; top: 50%; right: 12px; z-index: 1; transform: translateY(-50%); color: var(--cc-muted); pointer-events: none; }
.fee-confirm-reference-title { margin: 8px 0 12px; color: var(--cc-text); font-size: 14px; }
.fee-confirm-reference-title { margin: var(--admin-space-3, 8px) 0 var(--admin-space-5, 12px); color: var(--cc-text); font-size: var(--admin-font-section, 14px); }
.fee-confirm-price-reason { grid-column: 1 / -1; }
.fee-confirm-price-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 0 0 16px; border: 1px solid var(--cc-border); background: #f8fafc; }
.fee-confirm-price-summary > div { min-width: 0; padding: 10px 12px; }
.fee-confirm-price-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 0 0 var(--admin-space-6, 16px); border: 1px solid var(--cc-border); background: var(--admin-label-bg, #f8fafc); }
.fee-confirm-price-summary > div { min-width: 0; padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px); }
.fee-confirm-price-summary > div + div { border-left: 1px solid var(--cc-border); }
.fee-confirm-price-summary dt { color: var(--cc-muted); font-size: 11px; }
.fee-confirm-price-summary dd { margin: 4px 0 0; color: var(--cc-text); font-size: 13px; font-weight: 650; }
.fee-confirm-price-summary dt { color: var(--cc-muted); font-size: var(--admin-font-caption, 11px); }
.fee-confirm-price-summary dd { margin: var(--admin-space-1, 4px) 0 0; color: var(--cc-text); font-size: var(--admin-font-body, 13px); font-weight: 650; }
@media (max-width: 680px) { .fee-confirm-form { grid-template-columns: 1fr; } .fee-confirm-unit { right: 12px; } }
.fee-section-title { margin: 12px 0 8px; color: #344054; font-size: 14px; line-height: 1.4; }
.fee-section-title { margin: var(--admin-space-5, 12px) 0 var(--admin-space-3, 8px); color: #344054; font-size: var(--admin-font-section, 14px); line-height: 1.4; }
.fee-overview { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; border: 1px solid var(--cc-border); }
.fee-overview > div { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; padding: 8px 10px; }
.fee-overview > div { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--admin-space-1, 4px) var(--admin-space-5, 12px); padding: var(--admin-space-3, 8px) var(--admin-space-4, 10px); }
.fee-overview > div + div { border-left: 1px solid var(--cc-border); }
.fee-overview dt, .fee-overview small { color: var(--cc-muted); font-size: 12px; }
.fee-overview dd { margin: 0; font-size: 16px; font-weight: 600; font-variant-numeric: tabular-nums; }
.fee-overview dt, .fee-overview small { color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); }
.fee-overview dd { margin: 0; font-size: var(--admin-font-title, 16px); font-weight: 600; font-variant-numeric: tabular-nums; }
.fee-overview small { margin-left: auto; }
.fee-section-title--secondary { margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--cc-border); }
.order-info-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0; margin: 0; overflow: hidden; border-top: 1px solid var(--cc-border); border-left: 1px solid var(--cc-border); background: #fff; }
.order-info-grid > div, .measurement-grid > div { display: grid; grid-template-columns: 112px minmax(0, 1fr); min-width: 0; min-height: 36px; align-items: stretch; gap: 0; border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); background: #fff; }
.fee-section-title--secondary { margin-top: var(--admin-space-5, 12px); padding-top: var(--admin-space-4, 10px); border-top: 1px solid var(--cc-border); }
.order-info-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 0; }
.order-info-grid > div.wide { grid-column: span 2; }
.order-info-grid dt, .measurement-grid dt { margin: 0; padding: 8px; color: #667085; background: #f8fafc; font-size: 12px; line-height: 20px; }
.order-info-grid dd, .measurement-grid dd { min-width: 0; margin: 0; padding: 8px; color: #25324a; font-size: 13px; font-weight: 400; line-height: 20px; overflow-wrap: anywhere; }
.detail-table-wrap { overflow-x: auto; }
.detail-table-wrap :deep(.el-table) { min-width: 720px; --el-table-header-bg-color: #f8fafc; }
.cargo-image { width: 40px; height: 32px; border: 1px solid var(--cc-border); border-radius: 2px; background: #f8fafc; cursor: zoom-in; }
.measurement-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; margin: 0; border: 1px solid var(--cc-border); background: var(--cc-border); }
.measurement-section { margin-top: 12px; }
.measurement-section-title { display: flex; width: 100%; min-height: 36px; align-items: center; gap: 12px; padding: 4px 10px; border: 1px solid var(--cc-border); border-bottom: 0; background: #f8fafc; color: var(--cc-text); }
.detail-table-wrap :deep(.el-table) { min-width: 720px; --el-table-header-bg-color: var(--admin-label-bg, #f8fafc); }
.cargo-image { width: 40px; height: var(--admin-control-height, 32px); border: 1px solid var(--cc-border); border-radius: 2px; background: var(--admin-label-bg, #f8fafc); cursor: zoom-in; }
.measurement-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 0; }
.measurement-section { margin-top: var(--admin-space-5, 12px); }
.measurement-section-title { display: flex; width: 100%; min-height: var(--admin-row-height, 36px); align-items: center; gap: var(--admin-space-5, 12px); padding: var(--admin-space-1, 4px) var(--admin-space-4, 10px); border: 1px solid var(--cc-border); border-bottom: 0; background: var(--admin-label-bg, #f8fafc); color: var(--cc-text); }
.measurement-section-title:hover { background: #f2f6fb; }
.measurement-section-title h3 { flex: 0 0 auto; margin: 0; color: var(--cc-text); font-size: 13px; font-weight: 600; }
.measurement-section-summary { display: flex; min-width: 0; flex: 1; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 4px 16px; color: var(--cc-muted); font-size: 12px; }
.measurement-section-toggle { display: flex; min-width: 0; min-height: 28px; flex: 1; align-items: center; gap: 16px; padding: 0; border: 0; background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; }
.measurement-section-title h3 { flex: 0 0 auto; margin: 0; color: var(--cc-text); font-size: var(--admin-font-body, 13px); font-weight: 600; }
.measurement-section-summary { display: flex; min-width: 0; flex: 1; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: var(--admin-space-1, 4px) var(--admin-space-6, 16px); color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); }
.measurement-section-toggle { display: flex; min-width: 0; min-height: var(--admin-action-height, 28px); flex: 1; align-items: center; gap: var(--admin-space-6, 16px); padding: 0; border: 0; background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; }
.measurement-section-toggle:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: 2px; }
.measurement-section-toggle > svg { flex: 0 0 auto; margin-left: auto; color: var(--cc-muted); }
.measurement-section-mode { flex: 0 0 190px; }
.measurement-section-title > .measurement-section-mode { display: none; }
.measurement-rule-selector { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin: 0; padding: 8px 10px; border: 1px solid var(--cc-border); border-top: 0; background: #fff; }
.measurement-rule-selector__label { color: var(--cc-text); font-size: 12px; font-weight: 650; white-space: nowrap; }
.measurement-rule-selector { display: flex; flex-wrap: wrap; align-items: center; gap: var(--admin-space-4, 10px); margin: 0; padding: var(--admin-space-3, 8px) var(--admin-space-4, 10px); border: 1px solid var(--cc-border); border-top: 0; background: var(--admin-surface, #fff); }
.measurement-rule-selector__label { color: var(--cc-text); font-size: var(--admin-font-caption, 12px); font-weight: 650; white-space: nowrap; }
.measurement-rule-selector__control { width: 270px; }
.measurement-rule-selector__hint { color: var(--cc-muted); font-size: 12px; }
.measurement-rule-selector__hint { color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); }
.measurement-outer-dimension-input { width: 128px; max-width: 100%; }
.measurement-total { display: flex; min-height: 32px; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 4px 20px; padding: 6px 10px; border: 1px solid var(--cc-border); border-top: 0; background: #f8fafc; color: #475467; font-size: 12px; font-variant-numeric: tabular-nums; }
.measurement-total { display: flex; min-height: var(--admin-control-height, 32px); flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: var(--admin-space-1, 4px) 20px; padding: var(--admin-space-2, 6px) var(--admin-space-4, 10px); border: 1px solid var(--cc-border); border-top: 0; background: var(--admin-label-bg, #f8fafc); color: #475467; font-size: var(--admin-font-caption, 12px); font-variant-numeric: tabular-nums; }
.measurement-total strong { margin-right: auto; color: var(--cc-text); font-size: 13px; }
.weight-evidence { margin-top: 12px; padding: 10px 0 0; border-top: 1px solid var(--cc-border); background: #fff; }
.weight-evidence__heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 8px; }
.weight-evidence__heading h4 { margin: 0; color: var(--cc-text); font-size: 14px; }
.weight-evidence__heading p { margin: 5px 0 0; color: var(--cc-muted); font-size: 12px; }
.measurement-total strong { margin-right: auto; color: var(--cc-text); font-size: var(--admin-font-body, 13px); }
.weight-evidence { margin-top: var(--admin-space-5, 12px); padding: var(--admin-space-4, 10px) 0 0; border-top: 1px solid var(--cc-border); background: var(--admin-surface, #fff); }
.weight-evidence__heading { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-6, 16px); margin-bottom: var(--admin-space-3, 8px); }
.weight-evidence__heading h4 { margin: 0; color: var(--cc-text); font-size: var(--admin-font-section, 14px); }
.weight-evidence__heading p { margin: 5px 0 0; color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); }
.weight-evidence__heading dl { margin: 0; text-align: right; }
.weight-evidence__heading dt { color: var(--cc-muted); font-size: 11px; }
.weight-evidence__heading dd { margin: 0; color: var(--cc-text); font-size: 13px; font-weight: 600; }
.weight-evidence-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 8px; }
.weight-evidence-card { display: grid; grid-template-columns: 96px minmax(0, 1fr); align-items: center; min-width: 0; padding: 8px; border: 1px solid var(--cc-border); background: #fff; }
.weight-evidence-card > dl { display: grid; gap: 4px; margin: 0; padding: 0 0 0 8px; }
.weight-evidence-card > dl > div { display: flex; min-width: 0; justify-content: space-between; gap: 12px; }
.weight-evidence-card dt { color: var(--cc-muted); font-size: 11px; }
.weight-evidence-card dd { min-width: 0; margin: 0; color: var(--cc-text); font-size: 12px; font-weight: 650; text-align: right; overflow-wrap: anywhere; }
.weight-evidence__heading dt { color: var(--cc-muted); font-size: var(--admin-font-caption, 11px); }
.weight-evidence__heading dd { margin: 0; color: var(--cc-text); font-size: var(--admin-font-body, 13px); font-weight: 600; }
.weight-evidence-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: var(--admin-space-3, 8px); }
.weight-evidence-card { display: grid; grid-template-columns: 96px minmax(0, 1fr); align-items: center; min-width: 0; padding: var(--admin-space-3, 8px); border: 1px solid var(--cc-border); background: var(--admin-surface, #fff); }
.weight-evidence-card > dl { display: grid; gap: var(--admin-space-1, 4px); margin: 0; padding: 0 0 0 var(--admin-space-3, 8px); }
.weight-evidence-card > dl > div { display: flex; min-width: 0; justify-content: space-between; gap: var(--admin-space-5, 12px); }
.weight-evidence-card dt { color: var(--cc-muted); font-size: var(--admin-font-caption, 11px); }
.weight-evidence-card dd { min-width: 0; margin: 0; color: var(--cc-text); font-size: var(--admin-font-caption, 12px); font-weight: 650; text-align: right; overflow-wrap: anywhere; }
.measurement-original-preview { display: block; width: 100%; max-height: min(72vh, 760px); object-fit: contain; border-radius: 8px; background: #f2f4f7; }
.hold-section { padding-bottom: 12px; border-bottom: 1px solid var(--cc-border); }
.other-notes-section { padding-top: 10px; }
.hold-summary { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 8px 10px; border: 1px solid var(--cc-border); background: #fff; }
.hold-summary dl { display: grid; min-width: 0; flex: 1; grid-template-columns: 210px minmax(280px, 1fr); gap: 12px; margin: 0; }
.hold-summary dl > div { display: flex; align-items: baseline; gap: 10px; min-width: 0; }
.hold-summary dt { flex: 0 0 auto; margin: 0; color: var(--cc-muted); font-size: 12px; }
.hold-summary dd { margin: 0; color: var(--cc-text); font-size: 13px; overflow-wrap: anywhere; }
.hold-actions { display: flex; flex: 0 0 auto; gap: 8px; }
.hold-section { padding-bottom: var(--admin-space-5, 12px); border-bottom: 1px solid var(--cc-border); }
.other-notes-section { padding-top: var(--admin-space-4, 10px); }
.hold-summary { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-6, 16px); padding: var(--admin-space-3, 8px) var(--admin-space-4, 10px); border: 1px solid var(--cc-border); background: var(--admin-surface, #fff); }
.hold-summary dl { display: grid; min-width: 0; flex: 1; grid-template-columns: 210px minmax(280px, 1fr); gap: var(--admin-space-5, 12px); margin: 0; }
.hold-summary dl > div { display: flex; align-items: baseline; gap: var(--admin-space-4, 10px); min-width: 0; }
.hold-summary dt { flex: 0 0 auto; margin: 0; color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); }
.hold-summary dd { margin: 0; color: var(--cc-text); font-size: var(--admin-font-body, 13px); overflow-wrap: anywhere; }
.hold-actions { display: flex; flex: 0 0 auto; gap: var(--admin-space-3, 8px); }
.hold-actions :deep(.el-button) { margin-left: 0; }
.hold-block-reason { margin: 12px 0 0; padding: 9px 12px; border: 1px solid var(--el-color-warning-light-5); border-radius: 4px; background: var(--el-color-warning-light-9); color: var(--el-color-warning-dark-2); font-size: 12px; }
.hold-history-title { margin: 16px 0 8px; color: #344054; font-size: 14px; }
.hold-block-reason { margin: var(--admin-space-5, 12px) 0 0; padding: 9px var(--admin-space-5, 12px); border: 1px solid var(--el-color-warning-light-5); border-radius: var(--admin-radius, 4px); background: var(--el-color-warning-light-9); color: var(--el-color-warning-dark-2); font-size: var(--admin-font-caption, 12px); }
.hold-history-title { margin: var(--admin-space-6, 16px) 0 var(--admin-space-3, 8px); color: #344054; font-size: var(--admin-font-section, 14px); }
.hold-history-table { min-width: 0; overflow-x: auto; }
.hold-command-form { margin-top: 16px; }
.hold-evidence-hint { margin: -4px 0 0; color: var(--cc-muted); font-size: 12px; }
.hold-command-form { margin-top: var(--admin-space-6, 16px); }
.hold-evidence-hint { margin: -var(--admin-space-1, 4px) 0 0; color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); }
.notes-list { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; }
.notes-list p { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.55; overflow-wrap: anywhere; }
.notes-list p { margin: var(--admin-space-1, 4px) 0 0; color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); line-height: 1.55; overflow-wrap: anywhere; }
.notes-list li { padding: 14px 0; border-bottom: 1px solid #edf0f4; }
.notes-list li:first-child { padding-top: 0; }
.notes-list li:last-child { padding-bottom: 0; border-bottom: 0; }
.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; }
.notes-list li > p { margin: 0; color: var(--cc-text); font-size: var(--admin-font-body, 13px); }
.notes-list span { display: block; margin-top: 5px; color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); }
@media (max-width: 960px) { .order-info-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .measurement-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 720px) { .page-intro { align-items: stretch; flex-direction: column; } .page-intro :deep(.el-button) { width: 100%; min-height: 44px; } .status-tab { min-height: 46px; padding: 0 14px; } .table-actions :deep(.el-button) { width: 100%; min-height: 40px; } .row-actions { flex-wrap: wrap; } .order-detail-header { align-items: stretch; flex-direction: column; } .order-detail-heading { flex-wrap: wrap; } .detail-header-actions { display: grid; grid-template-columns: 1fr; } .detail-header-actions :deep(.el-button) { min-height: 40px; } .order-info-grid, .measurement-grid { grid-template-columns: 1fr; } .order-info-grid > div.wide { grid-column: auto; } .detail-tabs button { padding: 0 18px; } .detail-panel { padding: 16px; } .measurement-section-title { align-items: flex-start; flex-wrap: wrap; padding: 12px; } .measurement-section-summary { width: 100%; flex-basis: calc(100% - 34px); flex-wrap: wrap; justify-content: flex-start; gap: 6px 16px; white-space: normal; } .measurement-section-title > svg { margin-left: auto; } .measurement-total { flex-wrap: wrap; justify-content: flex-start; gap: 6px 16px; } .measurement-total strong { width: 100%; } }
@media (max-width: 720px) { .weight-evidence__heading { flex-direction: column; } .weight-evidence__heading dl { text-align: left; } }
@@ -4,30 +4,27 @@
border-top: 0;
}
.unified-orders-page .inbound-fee-panel .fee-total,
.unified-orders-page .inbound-fee-panel .fee-summary-item { padding: 8px 10px; gap: 4px; }
.unified-orders-page .inbound-fee-panel .fee-summary-item { padding: var(--admin-space-3, 8px) var(--admin-space-4, 10px); gap: var(--admin-space-1, 4px); }
.unified-orders-page .inbound-fee-panel .fee-summary span,
.unified-orders-page .inbound-fee-panel .fee-summary small { font-size: 12px; }
.unified-orders-page .inbound-fee-panel .fee-summary small { font-size: var(--admin-font-caption, 12px); }
.unified-orders-page .inbound-fee-panel .fee-total strong { font-size: 17px; }
.unified-orders-page .inbound-fee-panel .fee-summary-item strong { font-size: 14px; }
.unified-orders-page .inbound-fee-panel .calculation-flow > div { min-height: 36px; padding: 6px 10px; }
.unified-orders-page .inbound-fee-panel .calculation-flow span { font-size: 12px; }
.unified-orders-page .inbound-fee-panel .fee-detail-grid > section { padding: 10px 12px 10px 0; }
.unified-orders-page .inbound-fee-panel .fee-detail-grid > section + section { padding-right: 0; padding-left: 12px; }
.unified-orders-page .inbound-fee-panel .fee-detail-grid dl { margin-top: 8px; }
.unified-orders-page .inbound-fee-panel .fee-detail-grid dl > div { padding: 6px 0; }
.unified-orders-page .inbound-fee-panel .fee-timeline ol { margin-top: 8px; }
.unified-orders-page .inbound-fee-panel .fee-advanced > summary { padding: 8px 2px; }
.unified-orders-page .fee-detail-manager { gap: 8px; }
.unified-orders-page .fulfillment-tree-panel { gap: 8px; }
.unified-orders-page .inbound-fee-panel .fee-summary-item strong { font-size: var(--admin-font-section, 14px); }
.unified-orders-page .inbound-fee-panel .calculation-flow > div { min-height: var(--admin-row-height, 36px); padding: var(--admin-space-2, 6px) var(--admin-space-4, 10px); }
.unified-orders-page .inbound-fee-panel .calculation-flow span { font-size: var(--admin-font-caption, 12px); }
.unified-orders-page .inbound-fee-panel .fee-detail-grid > section { padding: var(--admin-space-4, 10px) var(--admin-space-5, 12px) var(--admin-space-4, 10px) 0; }
.unified-orders-page .inbound-fee-panel .fee-detail-grid > section + section { padding-right: 0; padding-left: var(--admin-space-5, 12px); }
.unified-orders-page .inbound-fee-panel .fee-detail-grid dl { margin-top: var(--admin-space-3, 8px); }
.unified-orders-page .inbound-fee-panel .fee-detail-grid dl > div { padding: var(--admin-space-2, 6px) 0; }
.unified-orders-page .inbound-fee-panel .fee-timeline ol { margin-top: var(--admin-space-3, 8px); }
.unified-orders-page .inbound-fee-panel .fee-advanced > summary { padding: var(--admin-space-3, 8px) 2px; }
.unified-orders-page .fee-detail-manager { gap: var(--admin-space-3, 8px); }
.unified-orders-page .fulfillment-tree-panel { gap: var(--admin-space-3, 8px); }
.unified-orders-page .fulfillment-tree-panel .tree-summary {
display: flex; flex-wrap: wrap; gap: 6px 28px; padding: 8px 10px;
border-radius: 0; background: #fff;
display: flex; flex-wrap: wrap; gap: var(--admin-space-2, 6px) 28px; padding: var(--admin-space-3, 8px) var(--admin-space-4, 10px);
border-radius: 0; background: var(--admin-surface, #fff);
}
.unified-orders-page .fulfillment-tree-panel .tree-summary > div { display: flex; align-items: baseline; gap: 8px; }
.unified-orders-page .fulfillment-tree-panel .tree-summary strong { overflow: visible; white-space: normal; overflow-wrap: anywhere; font-size: 13px; font-weight: 600; }
.unified-orders-page .detail-panel .el-empty { padding: 12px 0; }
.unified-orders-page .detail-panel .el-empty__image { display: none; }
.unified-orders-page .detail-panel .el-empty__description { margin-top: 0; }
.unified-orders-page .fulfillment-tree-panel .tree-summary > div { display: flex; align-items: baseline; gap: var(--admin-space-3, 8px); }
.unified-orders-page .fulfillment-tree-panel .tree-summary strong { overflow: visible; white-space: normal; overflow-wrap: anywhere; font-size: var(--admin-font-body, 13px); font-weight: 600; }
.admin-density-dialog.el-dialog .batch-approval-table .cell { white-space: normal; overflow-wrap: anywhere; }
.admin-density-dialog.el-dialog .batch-approval-table .el-tag { height: auto; min-height: 24px; white-space: normal; line-height: 20px; }