Release/test 合并上生产 #2

Merged
admin merged 736 commits from release/test into main 2026-09-17 11:50:56 +08:00
14 changed files with 504 additions and 118 deletions
Showing only changes of commit c972ad408a - Show all commits
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { overseasHttp } from '@/shared/api/overseasHttp'
import { listDispatchCandidates, listDispatches, preArrangeDispatch } from './dispatches'
import { listDispatchCandidates, listDispatches, lockDispatchDraftPallet, preArrangeDispatch, releaseDispatchDraft, unlockDispatchDraftPallet } from './dispatches'
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn() } }))
@@ -31,9 +31,31 @@ describe('dispatch overseas API', () => {
it('loads candidates and creates a pre-arrange dispatch in an explicit customer scope', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: [] })
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: { id: '1', dispatchNo: 'PC-1' } })
await listDispatchCandidates({ fbaWarehouseCode: 'ONT8', sourceStage: 'PLAN' })
await listDispatchCandidates({ planIds: '101,102', fbaWarehouseCode: 'ONT8', sourceStage: 'PLAN' })
await preArrangeDispatch({ planPalletIds: ['190000000000000099'], route: 'LAX -> ONT8' })
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/candidates', { params: { fbaWarehouseCode: 'ONT8', sourceStage: 'PLAN' } })
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/candidates', { params: { planIds: '101,102', fbaWarehouseCode: 'ONT8', sourceStage: 'PLAN' } })
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/pre-arrange', { planPalletIds: ['190000000000000099'], route: 'LAX -> ONT8' })
})
it('loads platform candidates without selected inbound plans', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: [] })
await listDispatchCandidates({ sourceStage: 'RECEIVED' })
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/candidates', {
params: { sourceStage: 'RECEIVED' },
})
})
it('locks, unlocks, and releases intelligent dispatch drafts through dedicated endpoints', async () => {
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: null })
await lockDispatchDraftPallet('99', 'draft-1')
await unlockDispatchDraftPallet('99', 'draft-1')
await releaseDispatchDraft('draft-1')
expect(overseasHttp.post).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/dispatches/draft-locks/lock', { planPalletId: '99', draftToken: 'draft-1' })
expect(overseasHttp.post).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/dispatches/draft-locks/unlock', { planPalletId: '99', draftToken: 'draft-1' })
expect(overseasHttp.post).toHaveBeenNthCalledWith(3, '/platform/v1/last-leg/dispatches/draft-locks/release', { draftToken: 'draft-1' })
})
})
@@ -16,6 +16,14 @@ export interface DispatchPalletCandidateDto {
planPalletId: string
warehousePalletId?: string | null
orderId: string
customerId?: string | null
customerName?: string | null
planNo?: string | null
orderType?: string | null
volumeWeightKg?: number | null
containerNo?: string | null
appointmentAt?: string | null
expectedLoadingAt?: string | null
overseasWarehouseId?: string | null
overseasWarehouseName?: string | null
orderNo?: string | null
@@ -29,9 +37,11 @@ export interface DispatchPalletCandidateDto {
packageCount?: number | null
volumeCbm?: number | null
sourceStage?: 'PLAN' | 'RECEIVED' | null
cartonMarks?: string | null
}
export interface DispatchCandidateQuery {
planIds?: string
orderNo?: string
fbaWarehouseCode?: string
sourceStage?: 'PLAN' | 'RECEIVED'
@@ -43,6 +53,7 @@ export interface PreArrangeDispatchPayload {
fbaAppointmentNo?: string
fbaAppointmentStatus?: string
vehicleAppointmentStatus?: string
draftToken?: string
}
export interface DispatchSummaryDto {
@@ -95,3 +106,24 @@ export async function preArrangeDispatch(payload: PreArrangeDispatchPayload) {
)
return unwrapApiResponse(response)
}
export async function lockDispatchDraftPallet(planPalletId: string, draftToken: string) {
const response = await overseasHttp.post<ApiEnvelope<null>, ApiEnvelope<null>>(
'/platform/v1/last-leg/dispatches/draft-locks/lock', { planPalletId, draftToken },
)
return unwrapApiResponse(response)
}
export async function unlockDispatchDraftPallet(planPalletId: string, draftToken: string) {
const response = await overseasHttp.post<ApiEnvelope<null>, ApiEnvelope<null>>(
'/platform/v1/last-leg/dispatches/draft-locks/unlock', { planPalletId, draftToken },
)
return unwrapApiResponse(response)
}
export async function releaseDispatchDraft(draftToken: string) {
const response = await overseasHttp.post<ApiEnvelope<null>, ApiEnvelope<null>>(
'/platform/v1/last-leg/dispatches/draft-locks/release', { draftToken },
)
return unwrapApiResponse(response)
}
@@ -1,17 +1,17 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { overseasHttp } from '@/shared/api/overseasHttp'
import { getLastMileOrder, listLastMileOrders, transferLastMileOrders } from './lastMileOrders'
import { convertLastMileOrderToInboundPlan, getLastMileOrder, listLastMileOrders, transferLastMileOrders } from './lastMileOrders'
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), put: vi.fn() } }))
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), put: vi.fn(), post: vi.fn() } }))
describe('last-mile order overseas API', () => {
beforeEach(() => vi.mocked(overseasHttp.get).mockReset())
it('loads all platform orders without a customer scope', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [], page: 2, size: 20, total: 0, pages: 0 } })
await expect(listLastMileOrders({ page: 2, size: 20, status: 'PENDING_ASSIGNMENT' })).resolves.toMatchObject({ page: 2 })
await expect(listLastMileOrders({ page: 2, size: 20, orderType: 'FIRST_LEG', currentStatus: 'PENDING_ASSIGNMENT', warehouse: 'LAX' })).resolves.toMatchObject({ page: 2 })
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders', {
params: { page: 2, size: 20, status: 'PENDING_ASSIGNMENT' },
params: { page: 2, size: 20, orderType: 'FIRST_LEG', currentStatus: 'PENDING_ASSIGNMENT', warehouse: 'LAX' },
})
})
@@ -26,6 +26,19 @@ describe('last-mile order overseas API', () => {
expect(source).not.toMatch(/outbound|delivery|dispatch/)
})
it('passes all supported list filters and pagination without customerId', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [], page: 2, size: 50, total: 0, pages: 0 } })
await listLastMileOrders({
page: 2, size: 50, orderNo: 'LM-1', inboundCode: 'IN-1', sourceOrderNo: 'SRC-1',
containerNo: 'CONT-1', status: 'PENDING_DELIVERY', transferWarehouseCode: 'ONT8',
abnormal: true, detained: false, createdFrom: '2026-08-01T00:00:00', createdTo: '2026-08-31T23:59:59',
})
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/orders', {
params: expect.objectContaining({ page: 2, size: 50, sourceOrderNo: 'SRC-1', transferWarehouseCode: 'ONT8', abnormal: true, detained: false }),
})
expect(JSON.stringify(vi.mocked(overseasHttp.get).mock.calls[0]?.[1])).not.toContain('customerId')
})
it('uses the real destination transfer endpoint and edit contract', async () => {
vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', message: 'success', data: 1 })
await transferLastMileOrders({
@@ -33,4 +46,11 @@ describe('last-mile order overseas API', () => {
})
expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/orders/destination', expect.objectContaining({ orderIds: ['190000000000000099'] }))
})
it('uses the inbound-plan conversion endpoint without customerId', async () => {
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: {} })
await convertLastMileOrderToInboundPlan({ orderId: '190000000000000099' })
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/convert', { orderId: '190000000000000099' })
expect(JSON.stringify(vi.mocked(overseasHttp.post).mock.calls[0]?.[1])).not.toContain('customerId')
})
})
@@ -13,9 +13,12 @@ export interface LastMileOrderQuery {
page: number
size: number
orderNo?: string
orderType?: string
inboundCode?: string
containerNo?: string
currentStatus?: string
status?: string
warehouse?: string
sourceOrderNo?: string
transferWarehouseCode?: string
abnormal?: boolean
@@ -103,6 +106,10 @@ export interface TransferDestinationPayload {
remark?: string
}
export interface ConvertInboundPlanPayload {
orderId: string
}
export interface LastMileOrderDetailDto {
order: LastMileOrderDto
cargo: LastMileCargoDto[]
@@ -130,3 +137,10 @@ export async function transferLastMileOrders(payload: TransferDestinationPayload
)
return unwrapApiResponse(response)
}
export async function convertLastMileOrderToInboundPlan(payload: ConvertInboundPlanPayload) {
const response = await overseasHttp.post<ApiEnvelope<unknown>, ApiEnvelope<unknown>>(
'/platform/v1/last-leg/inbound-plans/convert', payload,
)
return unwrapApiResponse(response)
}
@@ -22,6 +22,7 @@ describe('overseas pallet API', () => {
await updatePallet('200', '9', payload)
await deletePallet('200', '9')
expect(overseasHttp.get).toHaveBeenNthCalledWith(1, '/platform/v1/warehouse-admin', { params: { page: 1, size: 200 } })
expect(overseasHttp.get).toHaveBeenNthCalledWith(2, '/platform/v1/warehouse-admin/200/pallets', { params: { page: 1, size: 20, keyword: 'PLT' } })
expect(overseasHttp.get).toHaveBeenNthCalledWith(3, '/platform/v1/warehouse-admin/200/pallets/9', {})
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/warehouse-admin/200/pallets', payload)
@@ -41,7 +41,7 @@ export interface SavePalletPayload {
export async function listCustomerWarehouses() {
const response = await overseasHttp.get<ApiEnvelope<OverseasPageResult<CustomerWarehouseDto>>, ApiEnvelope<OverseasPageResult<CustomerWarehouseDto>>>(
'/platform/v1/warehouse-admin',
{ params: { page: 1, size: 200, status: 'ACTIVE' } },
{ params: { page: 1, size: 200 } },
)
return unwrapApiResponse(response)
}
@@ -1,129 +1,270 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { Plus, Trash2 } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { ArrowRight, GripVertical, Plus, Trash2 } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import { listDispatchCandidates, preArrangeDispatch, type DispatchPalletCandidateDto } from '../api/dispatches'
import { listDispatchCandidates, lockDispatchDraftPallet, preArrangeDispatch, releaseDispatchDraft, unlockDispatchDraftPallet, type DispatchPalletCandidateDto } from '../api/dispatches'
import type { InboundOrderListItem } from '../types'
interface LoadingContainer { id: string; capacity: number; palletIds: string[] }
const props = defineProps<{ modelValue: boolean; orders: InboundOrderListItem[] }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean]; created: [] }>()
const router = useRouter()
const loading = ref(false)
const saving = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const mutationError = ref<ApiErrorDetails | null>(null)
const candidates = ref<DispatchPalletCandidateDto[]>([])
const selectedIds = ref<string[]>([])
const query = reactive({ orderNo: '', sourceStage: '' as '' | 'PLAN' | 'RECEIVED' })
const route = ref('')
const activeOrderKey = ref('')
const containers = ref<LoadingContainer[]>([])
const busyPalletIds = ref<string[]>([])
const draftToken = ref('')
const finalized = ref(false)
const query = reactive({ orderNo: '', orderType: '', sourceStage: 'RECEIVED' as '' | 'PLAN' | 'RECEIVED', fbaWarehouseCode: '' })
const warehouseTab = ref<'ALL' | 'LAX' | 'NYC'>('ALL')
const fbaWarehouseCode = computed(() => props.orders[0]?.fbaWarehouse || '')
const selected = computed(() => candidates.value.filter((item) => selectedIds.value.includes(String(item.planPalletId))))
const selectedPackages = computed(() => selected.value.reduce((sum, item) => sum + Number(item.packageCount || 0), 0))
const selectedVolume = computed(() => selected.value.reduce((sum, item) => sum + Number(item.volumeCbm || 0), 0))
const orderTypeOptions = [
{ value: 'FIRST_LEG', label: '头程订单' },
{ value: 'RELABEL_APPLICATION', label: '换标订单' },
{ value: 'RETURN_RELABEL', label: '退换标单' },
{ value: 'LOCAL_LOOSEBOARD', label: '本土散板' },
{ value: 'WAREHOUSE_TRANSFER', label: '仓储转运单' },
]
const planIds = computed(() => [...new Set(props.orders.map((item) => item.planId).filter(Boolean))])
const visibleCandidates = computed(() => candidates.value.filter((item) => {
const typeMatches = !query.orderType || String(item.orderType || '') === query.orderType
if (!typeMatches) return false
if (warehouseTab.value === 'ALL') return true
const warehouse = `${item.overseasWarehouseId || ''} ${item.overseasWarehouseName || ''}`.toLowerCase()
return warehouseTab.value === 'LAX'
? /lax|洛杉矶|los angeles/.test(warehouse)
: /nyc|jfk|纽约|new york/.test(warehouse)
}))
const orderGroups = computed(() => {
const groups = new Map<string, DispatchPalletCandidateDto[]>()
for (const item of visibleCandidates.value) {
const key = String(item.orderId || item.orderNo || item.planId)
groups.set(key, [...(groups.get(key) || []), item])
}
return [...groups.entries()].map(([key, pallets]) => ({ key, first: pallets[0]!, pallets }))
})
const activePallets = computed(() => orderGroups.value.find((item) => item.key === activeOrderKey.value)?.pallets || [])
const assignedIds = computed(() => containers.value.flatMap((item) => item.palletIds))
const assigned = computed(() => assignedIds.value.map((id) => candidates.value.find((item) => palletId(item) === id)).filter((item): item is DispatchPalletCandidateDto => Boolean(item)))
const assignedPackages = computed(() => assigned.value.reduce((sum, item) => sum + Number(item.packageCount || 0), 0))
const assignedVolume = computed(() => assigned.value.reduce((sum, item) => sum + Number(item.volumeCbm || 0), 0))
function palletId(item: DispatchPalletCandidateDto) { return String(item.planPalletId) }
function text(value: unknown) { const normalized = String(value ?? '').trim(); return normalized || '—' }
function toggle(candidate: DispatchPalletCandidateDto, checked: boolean) {
const id = String(candidate.planPalletId)
selectedIds.value = checked ? [...new Set([...selectedIds.value, id])] : selectedIds.value.filter((value) => value !== id)
function number(value: unknown, digits = 0) { const normalized = Number(value); return Number.isFinite(normalized) ? normalized.toFixed(digits) : '0' }
function dateTime(value: unknown) { const raw = String(value || '').trim(); return raw ? raw.replace('T', ' ').slice(0, 16) : '—' }
function orderType(value: unknown) { return ({ FIRST_LEG: '头程订单', RELABEL_APPLICATION: '换标订单', RETURN_RELABEL: '退换标单', LOCAL_LOOSEBOARD: '本土散板', WAREHOUSE_TRANSFER: '仓储转运单', ONE_CLICK_DROPSHIP: '一件代发', REJECTED: '拒收订单', EXPRESS: '快递订单' } as Record<string, string>)[String(value || '')] || text(value) }
function cartonMarks(item: DispatchPalletCandidateDto) { return String(item.cartonMarks || '').split(',').map((value) => value.trim()).filter(Boolean) }
function containerFor(id: string) { return containers.value.find((item) => item.palletIds.includes(id)) }
function makeDraftToken() { return globalThis.crypto?.randomUUID?.() || `dispatch-${Date.now()}-${Math.random().toString(16).slice(2)}` }
function ensureDraftToken() {
if (!draftToken.value.trim()) draftToken.value = makeDraftToken()
return draftToken.value
}
function remove(candidate: DispatchPalletCandidateDto) { toggle(candidate, false) }
async function load() {
loading.value = true; loadError.value = null
try {
candidates.value = await listDispatchCandidates({
orderNo: query.orderNo.trim() || undefined,
fbaWarehouseCode: fbaWarehouseCode.value || undefined,
sourceStage: query.sourceStage || undefined,
})
const validIds = new Set(candidates.value.map((item) => String(item.planPalletId)))
selectedIds.value = selectedIds.value.filter((id) => validIds.has(id))
candidates.value = await listDispatchCandidates({ planIds: planIds.value.length ? planIds.value.join(',') : undefined, orderNo: query.orderNo.trim() || undefined, fbaWarehouseCode: query.fbaWarehouseCode.trim() || undefined, sourceStage: query.sourceStage || undefined })
activeOrderKey.value = orderGroups.value[0]?.key || ''
} catch (error) {
candidates.value = []
loadError.value = getApiErrorDetails(error, '可排车托盘加载失败,请重试')
candidates.value = []; activeOrderKey.value = ''
loadError.value = getApiErrorDetails(error, '可排车入仓单加载失败,请重试')
} finally { loading.value = false }
}
function reset() { query.orderNo = ''; query.orderType = ''; query.sourceStage = 'RECEIVED'; query.fbaWarehouseCode = ''; warehouseTab.value = 'ALL'; void load() }
function reset() { query.orderNo = ''; query.sourceStage = ''; void load() }
async function addContainer() {
try {
const result = await ElMessageBox.prompt('请输入该装车托盘容器可装的托数', '添加装车托盘', {
confirmButtonText: '确定', cancelButtonText: '取消', inputValue: '20', inputType: 'number',
inputValidator: (value) => Number.isInteger(Number(value)) && Number(value) > 0 && Number(value) <= 500 ? true : '请输入 1 至 500 的整数',
})
containers.value.push({ id: `${Date.now()}-${containers.value.length + 1}`, capacity: Number(result.value), palletIds: [] })
} catch { /* user cancelled */ }
}
async function movePallet(item: DispatchPalletCandidateDto, containerId: string) {
const id = palletId(item)
const target = containers.value.find((container) => container.id === containerId)
if (!target || target.palletIds.includes(id)) return
if (containerFor(id)) { ElMessage.warning('该托盘已在其他装车托盘中'); return }
if (target.palletIds.length >= target.capacity) { ElMessage.warning('该装车托盘已达到设置的托数'); return }
const anchor = assigned.value[0]
if (anchor?.customerId && anchor.customerId !== item.customerId) { ElMessage.warning('一次排车只能装同一客户的托盘'); return }
if (anchor?.overseasWarehouseId && anchor.overseasWarehouseId !== item.overseasWarehouseId) { ElMessage.warning('一次排车只能装同一海外仓的托盘'); return }
busyPalletIds.value.push(id); mutationError.value = null
try { await lockDispatchDraftPallet(id, ensureDraftToken()); target.palletIds.push(id) }
catch (error) { mutationError.value = getApiErrorDetails(error, '托盘锁定失败,请刷新后重试') }
finally { busyPalletIds.value = busyPalletIds.value.filter((value) => value !== id) }
}
async function removePallet(item: DispatchPalletCandidateDto) {
const id = palletId(item)
const owner = containerFor(id)
if (!owner) return
busyPalletIds.value.push(id); mutationError.value = null
try { await unlockDispatchDraftPallet(id, ensureDraftToken()); owner.palletIds = owner.palletIds.filter((value) => value !== id) }
catch (error) { mutationError.value = getApiErrorDetails(error, '托盘解锁失败,请重试') }
finally { busyPalletIds.value = busyPalletIds.value.filter((value) => value !== id) }
}
async function deleteContainer(container: LoadingContainer) {
for (const id of [...container.palletIds]) {
const item = candidates.value.find((candidate) => palletId(candidate) === id)
if (item) await removePallet(item)
}
if (!container.palletIds.length) containers.value = containers.value.filter((item) => item.id !== container.id)
}
function dragStart(event: DragEvent, item: DispatchPalletCandidateDto) { event.dataTransfer?.setData('text/plain', palletId(item)) }
function dropInto(event: DragEvent, containerId: string) {
const id = event.dataTransfer?.getData('text/plain') || ''
const item = candidates.value.find((candidate) => palletId(candidate) === id)
if (item) void movePallet(item, containerId)
}
async function close(value = false) {
const token = ensureDraftToken()
if (!value && token && !finalized.value) {
try { await releaseDispatchDraft(token) } catch { /* server-side expiry remains the fallback */ }
}
emit('update:modelValue', value)
}
async function submit() {
if (!selectedIds.value.length) { ElMessage.warning('请至少选择一个托盘'); return }
if (!assignedIds.value.length) { ElMessage.warning('请至少移入一个托盘'); return }
saving.value = true; mutationError.value = null
try {
await preArrangeDispatch({
planPalletIds: selectedIds.value,
route: route.value.trim() || undefined,
fbaAppointmentNo: props.orders.find((item) => item.appointmentNo !== '—')?.appointmentNo,
})
emit('update:modelValue', false); emit('created'); ElMessage.success('预排车创建成功')
await preArrangeDispatch({ planPalletIds: assignedIds.value, draftToken: ensureDraftToken() })
finalized.value = true; emit('update:modelValue', false); emit('created'); ElMessage.success('预排车创建成功')
await router.push({ name: 'overseas-dispatches' })
} catch (error) { mutationError.value = getApiErrorDetails(error, '预排车创建失败,请重试') }
finally { saving.value = false }
}
watch(() => props.modelValue, (open) => {
if (!open) return
selectedIds.value = []; query.orderNo = ''; query.sourceStage = ''; route.value = ''; mutationError.value = null
draftToken.value = makeDraftToken(); finalized.value = false; containers.value = []
query.orderNo = ''; query.orderType = ''; query.sourceStage = 'RECEIVED'; query.fbaWarehouseCode = ''; warehouseTab.value = 'ALL'; mutationError.value = null
void load()
}, { immediate: true })
watch([warehouseTab, () => query.orderType], () => {
activeOrderKey.value = orderGroups.value[0]?.key || ''
})
</script>
<template>
<OperationErrorToast :details="mutationError" />
<AppDialog :model-value="modelValue" title="智能排车" width="min(1600px, calc(100vw - 48px))" confirm-text="预排车" :confirm-loading="saving" :confirm-disabled="!selectedIds.length" @update:model-value="emit('update:modelValue', $event)" @confirm="submit">
<AppDialog :model-value="modelValue" title="智能排车" width="min(1800px, calc(100vw - 40px))" :show-footer="false" @update:model-value="close($event)">
<section class="dispatch-query" aria-label="智能排车筛选">
<el-input v-model="query.orderNo" clearable placeholder="订单号" @keyup.enter="load" />
<el-select v-model="query.sourceStage" clearable placeholder="全部类型"><el-option label="计划托盘" value="PLAN" /><el-option label="已收货托盘" value="RECEIVED" /></el-select>
<el-input :model-value="fbaWarehouseCode" readonly placeholder="FBA仓库" />
<el-select v-model="query.orderType" clearable placeholder="全部订单类型"><el-option v-for="option in orderTypeOptions" :key="option.value" :label="option.label" :value="option.value" /></el-select>
<el-select v-model="query.sourceStage" clearable placeholder="全部状态"><el-option label="入库完成托盘" value="RECEIVED" /><el-option label="计划托盘" value="PLAN" /></el-select>
<el-input v-model="query.fbaWarehouseCode" clearable placeholder="FBA仓库" @keyup.enter="load" />
<el-button type="primary" :loading="loading" @click="load">查询</el-button><el-button :disabled="loading" @click="reset">重置</el-button>
</section>
<LoadErrorState v-if="loadError" title="可排车托盘加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<nav class="warehouse-tabs" aria-label="海外仓筛选">
<button v-for="tab in [{ value: 'ALL', label: '全部' }, { value: 'LAX', label: '洛杉矶' }, { value: 'NYC', label: '纽约' }]" :key="tab.value" type="button" :class="['warehouse-tab', { active: warehouseTab === tab.value }]" @click="warehouseTab = tab.value as 'ALL' | 'LAX' | 'NYC'">{{ tab.label }}</button>
</nav>
<LoadErrorState v-if="loadError" title="可排车入仓单加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<div v-else class="dispatch-workbench">
<section class="candidate-pane" aria-label="可排车托盘列表">
<header><h3>可排车托盘 <span>({{ candidates.length }}个)</span></h3></header>
<el-table v-loading="loading" :data="candidates" border height="500" empty-text="暂无可排车托盘">
<el-table-column width="48"><template #default="{ row }"><el-checkbox :model-value="selectedIds.includes(String((row as DispatchPalletCandidateDto).planPalletId))" @change="toggle(row as DispatchPalletCandidateDto, Boolean($event))" /></template></el-table-column>
<el-table-column prop="orderNo" label="订单号" min-width="150" />
<el-table-column prop="inboundCode" label="入仓码" min-width="130" />
<el-table-column prop="palletNo" label="托盘号" min-width="130" />
<el-table-column prop="fbaWarehouseCode" label="目的地FBA" width="115" />
<el-table-column prop="packageCount" label="箱数" width="80" align="right" />
<el-table-column prop="volumeCbm" label="体积(m³)" width="105" align="right" />
</el-table>
<section class="order-pane" aria-label="可排车入仓单">
<header><div><h3>出库单列表 <span>({{ orderGroups.length }}单)</span></h3><div class="order-type-tabs"><button v-for="option in [{ value: '', label: '全部' }, ...orderTypeOptions]" :key="option.value || 'all'" type="button" :class="{ active: query.orderType === option.value }" @click="query.orderType = option.value">{{ option.label }}</button></div></div></header>
<div v-loading="loading" class="order-table-wrap">
<el-table :data="orderGroups" border height="520" highlight-current-row row-key="key" @current-change="activeOrderKey = $event?.key || ''">
<el-table-column label="订单号" min-width="150"><template #default="{ row }">{{ text(row.first.orderNo) }}</template></el-table-column>
<el-table-column label="订单类型" width="100"><template #default="{ row }">{{ orderType(row.first.orderType) }}</template></el-table-column>
<el-table-column label="重量(kg)" width="100" align="right"><template #default="{ row }">{{ number(row.first.volumeWeightKg, 2) }}</template></el-table-column>
<el-table-column label="托盘数量" width="90" align="right"><template #default="{ row }">{{ row.pallets.length }}</template></el-table-column>
<el-table-column label="目的地FBA" width="110"><template #default="{ row }">{{ text(row.first.fbaWarehouseCode) }}</template></el-table-column>
<el-table-column label="体积(m³)" width="100" align="right"><template #default="{ row }">{{ number(row.pallets.reduce((sum: number, item: DispatchPalletCandidateDto) => sum + Number(item.volumeCbm || 0), 0), 2) }}</template></el-table-column>
<el-table-column label="货柜号" min-width="125"><template #default="{ row }">{{ text(row.first.containerNo) }}</template></el-table-column>
<el-table-column label="预约送仓时间" min-width="145"><template #default="{ row }">{{ dateTime(row.first.appointmentAt) }}</template></el-table-column>
<el-table-column label="预计装车时间" min-width="145"><template #default="{ row }">{{ dateTime(row.first.expectedLoadingAt) }}</template></el-table-column>
</el-table>
</div>
</section>
<aside class="plan-pane" aria-label="装车托盘">
<header><div><h3>装车托盘</h3><p>托盘 {{ selected.length }} · 箱数 {{ selectedPackages }} · 立方 {{ selectedVolume.toFixed(2) }}</p></div><el-button disabled><Plus :size="15" />添加装托盘</el-button></header>
<el-input v-model="route" maxlength="255" placeholder="排车路线(选填)" />
<div class="selected-list">
<div v-for="item in selected" :key="String(item.planPalletId)" class="selected-pallet">
<div><strong>{{ text(item.palletNo) }}</strong><span>{{ text(item.orderNo) }} · {{ text(item.fbaWarehouseCode) }}</span></div>
<div><span>{{ Number(item.packageCount || 0) }}箱 / {{ Number(item.volumeCbm || 0).toFixed(2) }}m³</span><el-button text type="danger" :aria-label="`移除托盘${text(item.palletNo)}`" @click="remove(item)"><Trash2 :size="15" /></el-button></div>
<section class="detail-pane" aria-label="入仓单托盘和箱号详情">
<header><h3>托盘明细 <span>{{ activePallets.length ? `(${activePallets.length}托)` : '' }}</span></h3><span>订单号:{{ text(activePallets[0]?.orderNo) }}</span></header>
<div class="pallet-list">
<div v-for="item in activePallets" :key="palletId(item)" class="pallet-row" :class="{ assigned: containerFor(palletId(item)) }" draggable="true" @dragstart="dragStart($event, item)">
<GripVertical :size="16" class="drag-handle" />
<div class="pallet-copy"><strong>{{ text(item.palletNo) }}</strong><span>{{ Number(item.packageCount || 0) }}箱 · {{ number(item.volumeCbm, 2) }}m³</span><div class="carton-marks"><el-tag v-for="mark in cartonMarks(item).slice(0, 4)" :key="mark" size="small" effect="plain">{{ mark }}</el-tag><span v-if="cartonMarks(item).length > 4">+{{ cartonMarks(item).length - 4 }}</span><span v-if="!cartonMarks(item).length">暂无箱号</span></div></div>
<el-tag v-if="containerFor(palletId(item))" type="success" size="small">已锁定</el-tag>
<el-dropdown v-else trigger="click" :disabled="!containers.length || busyPalletIds.includes(palletId(item))" @command="movePallet(item, String($event))">
<el-button text type="primary" :loading="busyPalletIds.includes(palletId(item))">移入 <ArrowRight :size="14" /></el-button>
<template #dropdown><el-dropdown-menu><el-dropdown-item v-for="(container, index) in containers" :key="container.id" :command="container.id" :disabled="container.palletIds.length >= container.capacity">装车托盘 {{ index + 1 }}({{ container.palletIds.length }}/{{ container.capacity }})</el-dropdown-item></el-dropdown-menu></template>
</el-dropdown>
</div>
<el-empty v-if="!selected.length" :image-size="72" description="请选择左侧托盘" />
<el-empty v-if="!activePallets.length" :image-size="64" description="点击左侧入仓单查看托盘和箱号" />
</div>
</section>
<aside class="container-pane" aria-label="装车托盘容器">
<header><div><h3>装车托盘</h3><p>托盘 {{ assigned.length }} · 箱数 {{ assignedPackages }} · 立方 {{ assignedVolume.toFixed(2) }}</p></div><el-button type="primary" plain @click="addContainer"><Plus :size="15" />添加装车托盘</el-button></header>
<div class="container-list">
<div v-for="(container, index) in containers" :key="container.id" class="loading-container" @dragover.prevent @drop.prevent="dropInto($event, container.id)">
<div class="container-head"><strong>装车托盘 {{ index + 1 }}</strong><span>托数 <b>{{ container.palletIds.length }}</b>/{{ container.capacity }}</span><el-button text type="danger" :aria-label="`删除装车托盘${index + 1}`" @click="deleteContainer(container)"><Trash2 :size="15" /></el-button></div>
<div class="container-body">
<div v-for="id in container.palletIds" :key="id" class="loaded-pallet"><span><strong>{{ text(candidates.find((item) => palletId(item) === id)?.palletNo) }}</strong>{{ text(candidates.find((item) => palletId(item) === id)?.orderNo) }}</span><el-button text type="danger" :loading="busyPalletIds.includes(id)" @click="removePallet(candidates.find((item) => palletId(item) === id)!)">移除</el-button></div>
<span v-if="!container.palletIds.length" class="drop-hint">拖入托盘,移入后立即锁定</span>
</div>
</div>
<el-empty v-if="!containers.length" :image-size="64" description="请先添加装车托盘" />
</div>
</aside>
</div>
<template #footer="{ close }"><div class="dialog-footer"><span>已选托盘会在预排车成功后锁定。</span><div><el-button :disabled="saving" @click="close">取消</el-button><el-button type="primary" :loading="saving" :disabled="!selectedIds.length" @click="submit">预排车</el-button></div></div></template>
<footer class="dialog-footer"><span>托盘移入即锁定,移除或关闭弹窗即解锁。</span><div><el-button :disabled="saving" @click="close(false)">取消</el-button><el-button type="primary" :loading="saving" :disabled="!assignedIds.length" @click="submit">预排车</el-button></div></footer>
</AppDialog>
</template>
<style scoped>
.dispatch-query { display: flex; align-items: center; gap: 12px; padding: 18px; border: 1px solid var(--cc-border); border-radius: 6px; background: var(--cc-bg); }
.dispatch-query :deep(.el-input), .dispatch-query :deep(.el-select) { width: 210px; }
.dispatch-workbench { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(420px, .95fr); gap: 20px; margin-top: 18px; }
.candidate-pane, .plan-pane { min-width: 0; }
.candidate-pane header, .plan-pane header { display: flex; min-height: 52px; align-items: center; justify-content: space-between; gap: 12px; }
h3, p { margin: 0; } h3 { font-size: 17px; } h3 span, p { color: var(--cc-muted); font-size: 13px; font-weight: 400; }
.plan-pane > .el-input { margin-bottom: 12px; }
.selected-list { min-height: 444px; max-height: 444px; overflow-y: auto; border: 1px dashed #cfd5df; border-radius: 6px; background: #fafbfc; }
.selected-pallet { display: flex; min-height: 72px; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 16px; border-bottom: 1px solid var(--cc-border); background: #fff; }
.selected-pallet > div { display: flex; min-width: 0; align-items: center; gap: 12px; } .selected-pallet > div:first-child { align-items: flex-start; flex-direction: column; gap: 4px; }
.selected-pallet span { color: var(--cc-muted); font-size: 13px; }
.dialog-footer { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: 16px; } .dialog-footer > span { color: var(--cc-muted); font-size: 13px; }
@media (max-width: 1100px) { .dispatch-workbench { grid-template-columns: 1fr; } }
.dispatch-query { display: flex; align-items: center; gap: 12px; padding: 14px 16px; border: 1px solid #e6eaf2; border-radius: 8px; background: #f7f8fb; }
.dispatch-query :deep(.el-input), .dispatch-query :deep(.el-select) { width: 190px; }
.warehouse-tabs { display: flex; gap: 10px; margin-top: 16px; }
.warehouse-tab, .order-type-tabs button { border: 1px solid #dfe4ed; background: #fff; color: #667085; cursor: pointer; transition: all .18s ease; }
.warehouse-tab { min-width: 92px; padding: 9px 20px; border-radius: 7px; font-size: 14px; }
.warehouse-tab:hover, .order-type-tabs button:hover { border-color: #7654d6; color: #6941c6; }
.warehouse-tab.active, .order-type-tabs button.active { border-color: #6f42d8; background: #6f42d8; color: #fff; }
.dispatch-workbench { display: grid; grid-template-columns: minmax(550px, 1.5fr) minmax(330px, .85fr) minmax(390px, 1fr); gap: 16px; margin-top: 16px; }
.order-pane, .detail-pane, .container-pane { min-width: 0; }
.order-pane header, .detail-pane header, .container-pane header { display: flex; min-height: 74px; align-items: center; justify-content: space-between; gap: 10px; }
h3, p { margin: 0; } h3 { font-size: 16px; } h3 span, header > span, p { color: var(--cc-muted); font-size: 12px; font-weight: 400; }
.order-type-tabs { display: flex; gap: 6px; margin-top: 10px; overflow-x: auto; }
.order-type-tabs button { flex: 0 0 auto; padding: 5px 12px; border-radius: 16px; font-size: 12px; }
.order-table-wrap { min-height: 520px; }
.detail-pane, .container-pane { border-left: 1px solid var(--cc-border); padding-left: 16px; }
.pallet-list, .container-list { height: 520px; overflow-y: auto; border: 1px solid var(--cc-border); border-radius: 6px; background: #fafbfc; }
.pallet-row { display: grid; grid-template-columns: 20px minmax(0, 1fr) auto; min-height: 94px; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--cc-border); background: #fff; cursor: grab; transition: background .18s ease; }
.pallet-row:hover { background: #f8f6ff; }
.pallet-row.assigned { background: #f2fbf6; }.drag-handle { color: var(--cc-muted); }
.pallet-copy { display: flex; min-width: 0; flex-direction: column; gap: 4px; }.pallet-copy > span, .carton-marks > span { color: var(--cc-muted); font-size: 12px; }
.carton-marks { display: flex; min-width: 0; flex-wrap: wrap; gap: 4px; }
.container-pane > .el-input { margin-bottom: 10px; }.container-list { height: 460px; padding: 8px; }
.loading-container { margin-bottom: 10px; border: 1px dashed #bcc6d5; border-radius: 6px; background: #fff; }
.container-head { display: grid; grid-template-columns: 1fr auto 32px; min-height: 44px; align-items: center; gap: 8px; padding: 0 8px 0 12px; background: #f4f6fa; }
.container-head span { color: var(--cc-muted); font-size: 12px; }.container-head b { color: var(--el-color-primary); }
.container-body { min-height: 76px; padding: 8px; }.drop-hint { display: grid; min-height: 60px; place-items: center; color: var(--cc-muted); font-size: 12px; }
.loaded-pallet { display: flex; min-height: 52px; align-items: center; justify-content: space-between; gap: 8px; border-bottom: 1px solid var(--cc-border); }
.loaded-pallet > span { display: flex; min-width: 0; flex-direction: column; color: var(--cc-muted); font-size: 12px; }.loaded-pallet strong { color: var(--cc-text); font-size: 13px; }
.dialog-footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: 16px -20px -20px; padding: 14px 20px; border-top: 1px solid var(--cc-border); background: #fafbfc; }
.dialog-footer > span { color: var(--cc-muted); font-size: 12px; }
@media (max-width: 1280px) { .dispatch-workbench { grid-template-columns: minmax(500px, 1.3fr) minmax(320px, 1fr); }.container-pane { grid-column: 1 / -1; border-left: 0; padding-left: 0; }.container-list { height: 300px; } }
</style>
@@ -113,9 +113,6 @@ function openAppointment() {
}
function openSmartDispatch() {
const error = !selectedRows.value.length ? '请至少选择 1 条入库单'
: new Set(selectedRows.value.map((row) => row.fbaWarehouse)).size !== 1 ? '所选入库单必须属于同一 FBA 仓库' : ''
if (error) { ElMessage.warning(error); return }
smartDispatchOpen.value = true
}
@@ -175,8 +172,8 @@ onMounted(() => { void load() })
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="入库单筛选" preference-key="overseas-inbound-orders-query" @search="search" @reset="reset" /></template>
<template v-if="loadError" #state><LoadErrorState title="入库单加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-inbound-orders" row-key="lineId" empty-text="暂无入库单" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="access.can(permission.inboundOrderEdit)" plain :disabled="!selectedRows.length" @click="openAppointment"><CalendarCheck2 :size="16" />预约</el-button><el-button v-if="access.can(permission.inboundOrderEdit)" type="primary" :disabled="!selectedRows.length" @click="openSmartDispatch"><Truck :size="16" />智能排车</el-button></template>
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="(row as InboundOrderListItem).appointmentNo !== '—' && access.can(permission.inboundOrderEdit)" size="small" plain @click="openAppointmentEdit(row)"><Pencil :size="15" />编辑</el-button></template>
<template #toolbar-actions><el-button v-if="access.can(permission.inboundOrderAppointment)" plain :disabled="!selectedRows.length" @click="openAppointment"><CalendarCheck2 :size="16" />预约</el-button><el-button v-if="access.can(permission.inboundOrderSmartDispatch)" type="primary" @click="openSmartDispatch"><Truck :size="16" />智能排车</el-button></template>
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="(row as InboundOrderListItem).appointmentNo !== '—' && access.can(permission.inboundOrderAppointment)" size="small" plain @click="openAppointmentEdit(row)"><Pencil :size="15" />编辑</el-button></template>
<template #pagination-left>共 {{ total }} 条入库单 · 已选 {{ selectedRows.length }} 条</template>
</DataTable></section>
</ListPageLayout>
@@ -1,45 +1,65 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { Eye } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { listLastMileOrders } from '../api/lastMileOrders'
import { convertLastMileOrderToInboundPlan, listLastMileOrders, transferLastMileOrders } from '../api/lastMileOrders'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { overseasWarehousePermissions as permission } from '../permissions'
import { toLastMileOrderListItem } from '../lastMilePresentation'
import type { LastMileOrderListItem } from '../types'
const rows = ref<LastMileOrderListItem[]>([])
const access = useAccessPreviewStore()
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const router = useRouter()
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const selectedRows = ref<LastMileOrderListItem[]>([])
const actionLoading = ref(false)
const actionError = ref<ApiErrorDetails | null>(null)
const transferOpen = ref(false)
const transferForm = reactive({ warehouseId: '', warehouseCode: '', warehouseName: '', remark: '' })
const statusTab = ref<'全部' | '待分配'>('全部')
const filters = ref<QueryModel>({ orderNo: '', inboundCode: '', warehouse: '', fbaWarehouse: '', orderType: '', owner: '', containerNo: '', status: '', consolidated: '', portEta: [] })
const filters = ref<QueryModel>({ orderNo: '', orderType: '', warehouse: '', inboundCode: '', sourceOrderNo: '', containerNo: '', status: '', transferWarehouseCode: '', abnormal: '', detained: '', createdAt: [] })
const fields = computed<QueryField[]>(() => [
{ key: 'orderNo', type: 'input', label: '尾程单号', placeholder: '请输入尾程单号' },
{ key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码' },
{ key: 'warehouse', type: 'select', label: '海外仓', placeholder: '接口暂不支持', options: [], props: { disabled: true } },
{ key: 'fbaWarehouse', type: 'select', label: 'FBA仓库', placeholder: '接口暂不支持', options: [], props: { disabled: true } },
{ key: 'orderType', type: 'select', label: '订单类型', placeholder: '接口暂不支持', options: ['一件代发', '头程', '拒收订单', '快递订单'].map((value) => ({ label: value, value, disabled: true })), props: { disabled: true } },
{ key: 'owner', type: 'input', label: '货主', placeholder: '请通过客户名称选择', props: { disabled: true } },
{ key: 'orderType', type: 'select', label: '订单类型', placeholder: '全部类型', options: [
{ label: '头程', value: 'FIRST_LEG' }, { label: '换标申请', value: 'RELABEL_APPLICATION' },
{ label: '转仓', value: 'WAREHOUSE_TRANSFER' }, { label: '本地拆板', value: 'LOCAL_LOOSEBOARD' },
{ label: '退货换标', value: 'RETURN_RELABEL' }, { label: '一件代发', value: 'ONE_CLICK_DROPSHIP' },
] },
{ key: 'warehouse', type: 'input', label: '海外仓', placeholder: '请输入仓库名称或编码' },
{ key: 'sourceOrderNo', type: 'input', label: '来源订单号', placeholder: '请输入来源订单号' },
{ key: 'containerNo', type: 'input', label: '集装箱号', placeholder: '请输入集装箱号' },
{ key: 'transferWarehouseCode', type: 'input', label: '转仓代码', placeholder: '请输入转仓代码' },
{ key: 'status', type: 'select', label: '当前状态', placeholder: '全部状态', options: [
{ label: '待分配', value: 'PENDING_ASSIGNMENT' },
{ label: '已分配目的仓', value: 'DESTINATION_ASSIGNED' },
{ label: '已生成入库计划', value: 'INBOUND_PLANNED' },
{ label: '待入库', value: 'PENDING_INBOUND' },
{ label: '入库完成', value: 'INBOUND_COMPLETED' },
{ label: '待派送', value: 'PENDING_DELIVERY' },
{ label: '派送中', value: 'DELIVERING' },
{ label: '派送完成', value: 'DELIVERED' },
] },
{ key: 'consolidated', type: 'select', label: '是否合托', placeholder: '接口暂不支持', options: [{ label: '是', value: 'yes', disabled: true }, { label: '否', value: 'no', disabled: true }], props: { disabled: true } },
{ key: 'portEta', type: 'daterange', label: '预计到港时间', props: { disabled: true, startPlaceholder: '接口暂不支持', endPlaceholder: '接口暂不支持' } },
{ key: 'abnormal', type: 'select', label: '是否异常', placeholder: '全部', options: [{ label: '是', value: 'true' }, { label: '否', value: 'false' }] },
{ key: 'detained', type: 'select', label: '是否暂扣', placeholder: '全部', options: [{ label: '是', value: 'true' }, { label: '否', value: 'false' }] },
{ key: 'createdAt', type: 'daterange', label: '创建时间' },
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
{ key: 'selection', type: 'selection', width: 48, fixed: 'left' },
{ key: 'orderNo', prop: 'orderNo', label: '尾程单号', minWidth: 170 },
{ key: 'warning', prop: 'warning', label: '预警', width: 90 },
{ key: 'orderType', prop: 'orderType', label: '订单类型', minWidth: 110 },
@@ -77,13 +97,23 @@ async function load(model: QueryModel = filters.value) {
loading.value = true
loadError.value = null
try {
const dateRange = Array.isArray(filters.value.createdAt) ? filters.value.createdAt.map(String) : []
const booleanValue = (value: unknown) => value === 'true' ? true : value === 'false' ? false : undefined
const result = await listLastMileOrders({
page: page.value,
size: pageSize.value,
orderNo: String(filters.value.orderNo || '').trim() || undefined,
orderType: String(filters.value.orderType || '').trim() || undefined,
warehouse: String(filters.value.warehouse || '').trim() || undefined,
inboundCode: String(filters.value.inboundCode || '').trim() || undefined,
sourceOrderNo: String(filters.value.sourceOrderNo || '').trim() || undefined,
containerNo: String(filters.value.containerNo || '').trim() || undefined,
status: statusTab.value === '待分配' ? 'PENDING_ASSIGNMENT' : String(filters.value.status || '').trim() || undefined,
currentStatus: statusTab.value === '待分配' ? 'PENDING_ASSIGNMENT' : String(filters.value.status || '').trim() || undefined,
transferWarehouseCode: String(filters.value.transferWarehouseCode || '').trim() || undefined,
abnormal: booleanValue(filters.value.abnormal),
detained: booleanValue(filters.value.detained),
createdFrom: dateRange[0] ? `${dateRange[0]}T00:00:00` : undefined,
createdTo: dateRange[1] ? `${dateRange[1]}T23:59:59` : undefined,
})
rows.value = result.records.map(toLastMileOrderListItem)
total.value = result.total
@@ -101,6 +131,34 @@ async function load(model: QueryModel = filters.value) {
function search(model: QueryModel) { page.value = 1; void load(model) }
function reset(model: QueryModel) { page.value = 1; void load(model) }
function changeStatusTab() { page.value = 1; void load() }
function onSelectionChange(selection: object[]) { selectedRows.value = selection as LastMileOrderListItem[] }
function openTransfer() {
if (!selectedRows.value.length) { ElMessage.warning('请至少选择 1 条尾程订单'); return }
Object.assign(transferForm, { warehouseId: '', warehouseCode: '', warehouseName: '', remark: '' })
actionError.value = null
transferOpen.value = true
}
async function submitTransfer() {
if (!transferForm.warehouseId.trim() || !transferForm.warehouseCode.trim() || !transferForm.warehouseName.trim()) {
ElMessage.warning('请填写目的仓 ID、代码和名称')
return
}
actionLoading.value = true; actionError.value = null
try {
await transferLastMileOrders({ orderIds: selectedRows.value.map((row) => row.id), warehouseId: transferForm.warehouseId.trim(), warehouseCode: transferForm.warehouseCode.trim(), warehouseName: transferForm.warehouseName.trim(), remark: transferForm.remark.trim() || undefined })
transferOpen.value = false; selectedRows.value = []; ElMessage.success('转仓成功'); await load()
} catch (error) { actionError.value = getApiErrorDetails(error, '转仓失败,请重试') }
finally { actionLoading.value = false }
}
async function convertInboundPlan() {
if (selectedRows.value.length !== 1) { ElMessage.warning('生成入库计划时请选择 1 条尾程订单'); return }
actionLoading.value = true; actionError.value = null
try {
await convertLastMileOrderToInboundPlan({ orderId: selectedRows.value[0].id })
selectedRows.value = []; ElMessage.success('入库计划生成成功'); await load()
} catch (error) { actionError.value = getApiErrorDetails(error, '生成入库计划失败,请重试') }
finally { actionLoading.value = false }
}
function openDetail(source: object) {
const row = source as LastMileOrderListItem
@@ -111,7 +169,7 @@ onMounted(() => { void load() })
</script>
<template>
<AdminPage><ListPageLayout aria-label="尾程订单列表">
<AdminPage><OperationErrorToast :details="actionError" /><ListPageLayout aria-label="尾程订单列表">
<template #toolbar>
<section class="surface-panel last-mile-toolbar" aria-label="尾程订单状态与筛选">
<el-radio-group v-model="statusTab" aria-label="尾程订单状态" @change="changeStatusTab">
@@ -126,11 +184,20 @@ onMounted(() => { void load() })
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
</template>
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-last-mile-orders" row-key="id" empty-text="暂无尾程订单" @update:page="load()" @update:page-size="page = 1; load()">
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-last-mile-orders" row-key="id" empty-text="暂无尾程订单" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" plain :disabled="selectedRows.length !== 1 || actionLoading" @click="convertInboundPlan">生成入库计划</el-button></template>
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button></template>
<template #pagination-left>共 {{ total }} 条尾程订单</template>
<template #pagination-left>共 {{ total }} 条尾程订单 · 已选 {{ selectedRows.length }} 条</template>
</DataTable></section>
</ListPageLayout></AdminPage>
<AppDialog v-model="transferOpen" title="转仓" confirm-text="确认转仓" :confirm-loading="actionLoading" @confirm="submitTransfer">
<el-form label-position="top">
<el-form-item label="目的仓 ID" required><el-input v-model="transferForm.warehouseId" placeholder="请输入目的仓 ID" /></el-form-item>
<el-form-item label="目的仓代码" required><el-input v-model="transferForm.warehouseCode" placeholder="请输入目的仓代码" /></el-form-item>
<el-form-item label="目的仓名称" required><el-input v-model="transferForm.warehouseName" placeholder="请输入目的仓名称" /></el-form-item>
<el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" maxlength="500" /></el-form-item>
</el-form>
</AppDialog>
</template>
<style scoped>
@@ -29,6 +29,7 @@ const saving = ref(false)
const deletingId = ref('')
const loadError = ref<ApiErrorDetails | null>(null)
const mutationError = ref<ApiErrorDetails | null>(null)
const loadErrorKind = ref<'context' | 'list'>('context')
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
@@ -60,11 +61,14 @@ function toRow(dto: PalletDto): PalletListItem { return { id: String(dto.id), cu
async function loadWarehouses() {
warehouses.value = []; filters.value.warehouseId = ''; rows.value = []; total.value = 0
contextLoading.value = true; loadError.value = null
contextLoading.value = true; loadError.value = null; loadErrorKind.value = 'context'
try {
const [warehousePage, platformWarehouses] = await Promise.all([listCustomerWarehouses(), listAllPlatformWarehouses()])
const [warehousePage, platformFbaWarehouses] = await Promise.all([
listCustomerWarehouses(),
listAllPlatformWarehouses({ warehouseType: 'FBA', status: 'ACTIVE' }),
])
warehouses.value = warehousePage.records
fbaWarehouses.value = platformWarehouses.filter((item) => item.status === 'ACTIVE' && item.warehouseType === 'FBA')
fbaWarehouses.value = platformFbaWarehouses
}
catch (error) { loadError.value = getApiErrorDetails(error, '海外仓列表加载失败,请重试') }
finally { contextLoading.value = false }
@@ -74,7 +78,7 @@ async function load(model: QueryModel = filters.value) {
Object.assign(filters.value, model)
const warehouseId = String(filters.value.warehouseId || '')
if (!warehouseId) { rows.value = []; total.value = 0; return }
loading.value = true; loadError.value = null
loading.value = true; loadError.value = null; loadErrorKind.value = 'list'
try {
const result = await listPallets(warehouseId, { page: page.value, size: pageSize.value, keyword: String(filters.value.code || '').trim() || undefined, palletType: String(filters.value.type || '') || undefined, status: String(filters.value.status || '') || undefined })
rows.value = result.records.map(toRow); total.value = result.total
@@ -82,9 +86,14 @@ async function load(model: QueryModel = filters.value) {
finally { loading.value = false }
}
function retryLoad() {
if (loading.value || contextLoading.value) return
if (loadErrorKind.value === 'list') void load()
else void loadWarehouses()
}
function openCreate() {
if (!filters.value.warehouseId) { ElMessage.warning('请先选择仓库'); return }
dialogMode.value = 'create'; Object.assign(form, { id: '', warehouseId: String(filters.value.warehouseId), fbaWarehouseCode: '', code: '', type: 'WOOD', volume: 2, purchasePrice: 0, status: 'IDLE', remark: '' }); mutationError.value = null; dialogOpen.value = true
dialogMode.value = 'create'; Object.assign(form, { id: '', warehouseId: '', fbaWarehouseCode: '', code: '', type: 'WOOD', volume: 2, purchasePrice: 0, status: 'IDLE', remark: '' }); mutationError.value = null; dialogOpen.value = true
}
async function openEdit(source: object) {
@@ -97,7 +106,7 @@ async function openEdit(source: object) {
}
async function save() {
if (!form.code.trim() || !form.type || !form.fbaWarehouseCode || form.volume <= 0 || form.purchasePrice < 0 || !form.status) { ElMessage.warning('请完整填写托盘编码、类型、容积、采购价格、所属 FBA 仓和状态'); return }
if (!form.warehouseId || !form.code.trim() || !form.type || !form.fbaWarehouseCode || form.volume <= 0 || form.purchasePrice < 0 || !form.status) { ElMessage.warning('请完整填写仓库、托盘编码、类型、容积、采购价格、所属 FBA 仓和状态'); return }
saving.value = true; mutationError.value = null
const payload = { palletCode: form.code.trim(), palletType: form.type, fbaWarehouseCode: form.fbaWarehouseCode, capacityCbm: form.volume, unitPrice: form.purchasePrice, status: form.status, locationId: null, remark: form.remark.trim() || null }
try {
@@ -126,9 +135,9 @@ onMounted(loadWarehouses)
<template>
<AdminPage><OperationErrorToast :details="mutationError" /><ListPageLayout aria-label="托盘管理列表">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading || contextLoading" ariaLabel="托盘筛选" preference-key="overseas-pallets-query" @search="search" @reset="page = 1; rows = []; total = 0" /></template>
<template v-if="loadError" #state><LoadErrorState title="托盘数据加载失败" :message="loadError.message" :loading="loading || contextLoading" @retry="loadWarehouses"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<template v-if="loadError" #state><LoadErrorState title="托盘数据加载失败" :message="loadError.message" :loading="loading || contextLoading" @retry="retryLoad"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-pallets" row-key="id" :empty-text="filters.warehouseId ? '暂无托盘' : '请先选择仓库后查询'" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="access.can(permission.palletCreate)" type="primary" @click="openCreate"><Plus :size="16" />新增托盘</el-button></template>
<template #toolbar-actions><el-button type="primary" @click="openCreate"><Plus :size="16" />新增托盘</el-button></template>
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can(permission.palletEdit)" size="small" plain @click="openEdit(row)"><Pencil :size="15" />编辑</el-button><el-button v-if="access.can(permission.palletDelete)" size="small" plain type="danger" :loading="deletingId === (row as PalletListItem).id" @click="remove(row)"><Trash2 :size="15" />删除</el-button></template>
<template #pagination-left>共 {{ total }} 条托盘</template>
</DataTable></section>
@@ -139,7 +148,7 @@ onMounted(loadWarehouses)
<el-col :span="12"><el-form-item label="托盘类型" required><el-select v-model="form.type"><el-option v-for="value in ['WOOD', 'PLASTIC', 'METAL', 'PAPER', 'FUMIGATION_FREE']" :key="value" :label="palletTypeLabel(value)" :value="value" /></el-select></el-form-item></el-col>
<el-col :span="12"><el-form-item label="容积(m³)" required><el-input-number v-model="form.volume" :min="0.0001" :precision="4" controls-position="right" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="采购价格(USD)" required><el-input-number v-model="form.purchasePrice" :min="0" :precision="2" controls-position="right" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="仓库" required><el-select v-model="form.warehouseId" filterable :disabled="dialogMode === 'edit'"><el-option v-for="item in warehouses" :key="item.id" :label="`${item.warehouseName}(${item.warehouseCode})`" :value="String(item.id)" /></el-select></el-form-item></el-col>
<el-col :span="12"><el-form-item label="仓库" required><el-select v-model="form.warehouseId" filterable :disabled="dialogMode === 'edit'" placeholder="请选择海外仓"><el-option v-for="item in warehouses" :key="item.id" :label="`${item.warehouseName}(${item.warehouseCode})`" :value="String(item.id)" /></el-select></el-form-item></el-col>
<el-col :span="12"><el-form-item label="所属 FBA 仓" required><el-select v-model="form.fbaWarehouseCode" filterable placeholder="请选择所属 FBA 仓"><el-option v-for="item in fbaWarehouses" :key="String(item.id)" :label="`${item.warehouseName}(${item.warehouseCode})`" :value="item.warehouseCode" /></el-select></el-form-item></el-col>
<el-col :span="12"><el-form-item label="状态" required><el-select v-model="form.status"><el-option label="闲置" value="IDLE" /><el-option label="占用" value="OCCUPIED" /><el-option label="已报废" value="SCRAPPED" /></el-select></el-form-item></el-col>
<el-col :span="24"><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" /></el-form-item></el-col>
@@ -152,10 +152,18 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(value).toContain('760px')
expect(value).toContain("dialogMode === 'create' ? '新增托盘' : '编辑托盘'")
expect(value).toContain('createPallet')
expect(value).toContain('listAllPlatformWarehouses({ warehouseType: \'FBA\', status: \'ACTIVE\' })')
expect(value).toContain('placeholder="请选择海外仓"')
expect(value).toContain('!form.warehouseId')
expect(value).toContain('updatePallet')
expect(value).toContain('deletePallet')
expect(value).toContain('<el-button type="primary" @click="openCreate">')
expect(value).not.toContain('v-if="access.can(permission.palletCreate)"')
expect(value).toContain('v-model="form.fbaWarehouseCode"')
expect(value).toContain("warehouseType === 'FBA'")
expect(value).toContain("warehouseType: 'FBA'")
expect(value).toContain('listAllPlatformWarehouses')
expect(value).toContain('function retryLoad()')
expect(value).toContain('if (loading.value || contextLoading.value) return')
expect(value).toContain('palletCode: form.code.trim()')
expect(value).not.toContain(':readonly="dialogMode === \'edit\'"')
expect(value).not.toContain('ContractPendingFormDialog')
@@ -171,16 +179,65 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(value).toContain('所选入库单必须属于同一 FBA 仓库')
expect(value).toContain('<CalendarCheck2 :size="16" />预约')
expect(value).toContain('<Truck :size="16" />智能排车')
expect(value).toContain('type="primary" @click="openSmartDispatch"')
expect(value).not.toContain('type="primary" :disabled="!selectedRows.length" @click="openSmartDispatch"')
expect(value).toContain('access.can(permission.inboundOrderAppointment)')
expect(value).toContain('access.can(permission.inboundOrderSmartDispatch)')
expect(value).toContain('<SmartDispatchDialog')
expect(value).not.toContain('ContractPendingNotice')
})
it('keeps the six overseas warehouse lists connected to server-side pagination', () => {
for (const fileName of [
'LastMileOrdersView.vue',
'InboundOrdersView.vue',
'DispatchesView.vue',
'RelabelOrdersView.vue',
'PalletsView.vue',
'InventoryView.vue',
]) {
const value = source(fileName)
expect(value).toContain('const page = ref(1)')
expect(value).toContain('const pageSize = ref(20)')
expect(value).toContain('const total = ref(0)')
expect(value).toContain('page: page.value')
expect(value).toContain('size: pageSize.value')
expect(value).toMatch(/total\.value = \w+\.total/)
expect(value).toContain('v-model:page="page"')
expect(value).toContain('v-model:page-size="pageSize"')
expect(value).toContain(':total="total"')
expect(value).toContain('@update:page="load()"')
expect(value).toContain('@update:page-size="page = 1; load()"')
}
})
it('connects intelligent dispatch without a frontend customerId constraint', () => {
const dialog = source('../components/SmartDispatchDialog.vue')
expect(dialog).toContain('listDispatchCandidates')
expect(dialog).toContain('preArrangeDispatch')
expect(dialog).toContain('planPalletIds: selectedIds.value')
expect(dialog).toContain('planPalletIds: assignedIds.value')
expect(dialog).toContain('lockDispatchDraftPallet')
expect(dialog).toContain('unlockDispatchDraftPallet')
expect(dialog).toContain('releaseDispatchDraft')
expect(dialog).toContain("router.push({ name: 'overseas-dispatches' })")
expect(dialog).not.toContain('customerId:')
expect(dialog).toContain("planIds: planIds.value.length ? planIds.value.join(',') : undefined")
expect(dialog).toContain('v-model="query.fbaWarehouseCode" clearable')
expect(dialog).not.toContain(':model-value="fbaWarehouseCode" readonly')
expect(dialog).toContain("sourceStage: 'RECEIVED'")
expect(dialog).toContain('一次排车只能装同一客户的托盘')
expect(dialog).toContain('function ensureDraftToken()')
expect(dialog).toContain('lockDispatchDraftPallet(id, ensureDraftToken())')
expect(dialog).toContain('洛杉矶')
expect(dialog).toContain('纽约')
expect(dialog).toContain("value: 'ALL', label: '全部'")
expect(dialog).toContain('换标订单')
expect(dialog).toContain('退换标单')
expect(dialog).toContain('本土散板')
expect(dialog).toContain('仓储转运单')
expect(dialog).toContain('class="warehouse-tabs"')
expect(dialog).not.toContain('排车路线(选填)')
expect(dialog).not.toContain('v-model="route"')
})
it('adds the prototype inventory transaction page as a read-only inventory route', () => {
@@ -245,7 +302,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
it('keeps every prototype list column in its frozen order', () => {
expect(columnKeys('LastMileOrdersView.vue')).toEqual([
'actions', 'orderNo', 'warning', 'orderType', 'rejectedAt', 'inboundCode', 'owner', 'warehouse',
'actions', 'selection', 'orderNo', 'warning', 'orderType', 'rejectedAt', 'inboundCode', 'owner', 'warehouse',
'transferWarehouse', 'fbaWarehouse', 'containerNo', 'abnormal', 'held', 'pallets',
'cartons', 'volume', 'quantity', 'volumeWeight', 'portEta', 'portAta', 'warehouseEta', 'inboundOrderNo',
'outboundOrderNo', 'dispatchOrderNo', 'relabelOrderNo', 'relabelStatus', 'consolidated', 'status',
@@ -5,7 +5,8 @@ export const overseasWarehousePermissions = {
inboundOrderList: 'platform:overseas-warehouse:inbound-order:list',
inboundOrderCreate: 'platform:overseas-warehouse:inbound-order:create',
inboundOrderEdit: 'platform:overseas-warehouse:inbound-order:edit',
inboundOrderAppointment: 'platform:overseas-warehouse:inbound-order:edit',
inboundOrderAppointment: 'platform:overseas-warehouse:inbound-order:fba-appointment',
inboundOrderSmartDispatch: 'platform:overseas-warehouse:inbound-order:smart-dispatch',
dispatchList: 'platform:overseas-warehouse:dispatch:list',
inventoryList: 'platform:overseas-warehouse:inventory:list',
inventoryExport: 'platform:overseas-warehouse:inventory:export',
+22 -1
View File
@@ -1,7 +1,7 @@
import { computed, defineComponent } from 'vue'
import { shallowMount } from '@vue/test-utils'
import { beforeEach, describe, expect, it } from 'vitest'
import DataTable, { createPaginationItems, normalizePage, normalizePageSizes, type DataTableColumn } from './DataTable.vue'
import DataTable, { createPaginationItems, normalizePage, normalizePageSizes, normalizeTotal, type DataTableColumn } from './DataTable.vue'
import LayoutPreferenceList from './LayoutPreferenceList.vue'
import { UI_PREFERENCE_SCOPE, preferenceStorageKey } from '@/shared/utils/uiLayoutPreference'
@@ -59,6 +59,27 @@ describe('DataTable pagination helpers', () => {
expect(createPaginationItems(1, 0)).toEqual([])
})
it('normalizes totals serialized as JSON strings before calculating pages', () => {
expect(normalizeTotal('30')).toBe(30)
expect(normalizeTotal(' 30 ')).toBe(30)
expect(normalizeTotal(30)).toBe(30)
expect(normalizeTotal('')).toBe(0)
expect(normalizeTotal('invalid')).toBe(0)
})
it('renders page navigation when the API total is a numeric string', () => {
const wrapper = shallowMount(DataTable, {
props: { data: [], columns: configurableColumns, total: '30', page: 1, pageSize: 20 },
global: {
stubs: { ElTable: TableStub, ElTableColumn: ColumnStub, ElButton: ButtonStub, ElPopover: PopoverStub },
directives: { loading: {} },
},
})
expect(wrapper.get('.data-table__page-buttons').text()).toContain('1')
expect(wrapper.get('.data-table__page-buttons').text()).toContain('2')
})
it('shows all pages for a short result set', () => {
expect(createPaginationItems(3, 5)).toEqual([1, 2, 3, 4, 5])
})
+9 -5
View File
@@ -30,6 +30,13 @@ export function normalizePage(value: number, pageCount: number) {
return Math.min(Math.max(Math.trunc(value), 1), maximum)
}
export function normalizeTotal(value: unknown) {
const numericValue = typeof value === 'string' && value.trim() !== '' ? Number(value) : value
return typeof numericValue === 'number' && Number.isFinite(numericValue) && numericValue > 0
? Math.trunc(numericValue)
: 0
}
export function createPaginationItems(page: number, pageCount: number): PaginationItem[] {
if (pageCount <= 0) return []
if (pageCount <= 7) return Array.from({ length: pageCount }, (_, index) => index + 1)
@@ -60,7 +67,7 @@ const props = withDefaults(defineProps<{
columns: DataTableColumn[]
loading?: boolean
emptyText?: string
total?: number
total?: number | string
pageSizes?: number[]
preferenceKey?: string
columnSettingsMode?: 'auto' | 'show' | 'hide'
@@ -208,10 +215,7 @@ function resetPreference() {
watch(visibleColumns, () => {
void nextTick(() => tableRef.value?.doLayout())
})
const normalizedTotal = computed(() => {
const value = props.total ?? 0
return Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0
})
const normalizedTotal = computed(() => normalizeTotal(props.total))
const normalizedPageSizes = computed(() => normalizePageSizes(props.pageSizes))
const effectivePageSize = computed(() => (
normalizedPageSizes.value.includes(pageSize.value) ? pageSize.value : normalizedPageSizes.value[0]