This commit is contained in:
2026-08-10 15:36:53 +08:00
parent 5d97c7517a
commit f6556c57b5
54 changed files with 4107 additions and 170 deletions
@@ -0,0 +1,87 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import {
getConfirmedLoadingList,
getPreloadPlan,
listConfirmedLoadingLists,
listPreloadPlans,
updatePreloadTransport,
} from './platformLoadingLists'
const preloadSummary = {
planId: 1900000000000000001n as unknown as string,
preloadNo: 'PL-001',
containerNo: 'MSCU1234567',
internalContainerNo: 'YG-001',
customers: [{ accountId: 1900000000000000002n as unknown as string, customerName: '测试客户' }],
warehouseId: 1900000000000000003n as unknown as string,
warehouseName: '义乌仓',
vesselName: 'MATSON',
voyageNo: '001E',
containerType: '40HC',
status: 'LOADING',
skuCount: '2',
totalPackageCount: '10',
totalVolumeCbm: 12.3,
totalGrossWeightKg: 1200,
createdAt: '2026-08-10T10:00:00',
version: 3,
}
const detail = {
summary: preloadSummary,
items: [{
itemId: 11, accountId: 12, inboundId: 13, inboundDetailId: 14, productQuantity: 20,
customerName: '测试客户', inboundCode: 'RK001', skuCode: 'SKU001', destinationWarehouse: 'ONT8', chineseName: '产品', englishName: null,
goodsImageUrl: null, packageCount: 2, palletCount: 1, grossWeightKg: 10, netWeightKg: 9, volumeCbm: 1,
dimensionalWeightKg: 2, unitsPerPackage: 10, fbaTrackingNo: null, shipmentNo: null, shippingMark: null,
internalRemark: null, customerRemark: null, warehouseRemark: null, loadSequence: 1, frozenAt: null,
}],
histories: [{ historyId: 21, itemId: 11, actionType: 'UPDATE_TRANSPORT', fromStatus: 'LOADING', toStatus: 'LOADING', beforeSnapshot: null, afterSnapshot: null, operatorId: 22, operatorName: '操作员', operationReason: '调整', occurredAt: '2026-08-10T11:00:00' }],
}
describe('platform loading lists API', () => {
beforeEach(() => vi.restoreAllMocks())
it('queries preload plans with required warehouse and fixed LOADING status', async () => {
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: { records: [preloadSummary], page: '1', size: '20', total: '1', pages: '1' } })
const result = await listPreloadPlans({ page: 1, size: 20, warehouseId: '300', keyword: 'PL' })
expect(get).toHaveBeenCalledWith('/platform/v1/first-leg/loading-plans', { params: { page: 1, size: 20, warehouseId: '300', keyword: 'PL', status: 'LOADING' } })
expect(result.total).toBe(1)
expect(result.records[0]?.planId).toBe('1900000000000000001')
expect(result.records[0]?.customers[0]?.accountId).toBe('1900000000000000002')
})
it('normalizes long ids in preload detail items and history', async () => {
vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: detail })
const result = await getPreloadPlan('100', '300')
expect(result.items[0]).toMatchObject({ itemId: '11', accountId: '12', inboundId: '13', inboundDetailId: '14', productQuantity: '20' })
expect(result.histories[0]).toMatchObject({ historyId: '21', itemId: '11', operatorId: '22' })
})
it('updates transport with expectedVersion, warehouse and idempotency key', async () => {
const patch = vi.spyOn(http, 'patch').mockResolvedValue({ code: '200', message: 'ok', data: detail })
await updatePreloadTransport('100', '300', { containerNo: 'MSCU1234567', voyageNo: '001E', expectedVersion: 3, operationReason: '调整' })
expect(patch.mock.calls[0]?.[0]).toBe('/platform/v1/first-leg/loading-plans/100/transport')
expect(patch.mock.calls[0]?.[1]).toMatchObject({ expectedVersion: 3, voyageNo: '001E' })
expect(patch.mock.calls[0]?.[2]).toMatchObject({ params: { warehouseId: '300' }, headers: { 'Idempotency-Key': expect.any(String) } })
})
it('uses distinct completed list and detail endpoints', async () => {
const confirmed = { ...preloadSummary, loadingListNo: 'LL-001', totalNetWeightKg: 1100, totalQuantity: '20', remark: null, createdBy: 9, createdByName: '操作员', status: 'COMPLETED' }
const get = vi.spyOn(http, 'get')
.mockResolvedValueOnce({ code: '200', message: 'ok', data: { records: [confirmed], page: '1', size: '20', total: '1', pages: '1' } })
.mockResolvedValueOnce({ code: '200', message: 'ok', data: { ...detail, summary: confirmed } })
const page = await listConfirmedLoadingLists({ page: 1, size: 20, warehouseId: '300' })
const loaded = await getConfirmedLoadingList('100', '300')
expect(get.mock.calls[0]?.[0]).toBe('/platform/v1/first-leg/loading-lists')
expect(get.mock.calls[1]?.[0]).toBe('/platform/v1/first-leg/loading-lists/100')
expect(page.records[0]?.createdBy).toBe('9')
expect(loaded.summary.status).toBe('COMPLETED')
})
})
@@ -0,0 +1,257 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export type LoadingListMode = 'preload' | 'confirmed'
export interface LoadingCustomer {
accountId: string
customerName: string | null
}
export interface PreloadSummary {
planId: string
preloadNo: string
containerNo: string | null
internalContainerNo: string | null
customers: LoadingCustomer[]
warehouseId: string | null
warehouseName: string | null
vesselName: string | null
voyageNo: string | null
containerType: string | null
status: string
skuCount: string
totalPackageCount: string
totalVolumeCbm: number
totalGrossWeightKg: number
createdAt: string
version: number
}
export interface ConfirmedSummary {
planId: string
loadingListNo: string
warehouseId: string | null
warehouseName: string | null
containerNo: string | null
internalContainerNo: string | null
status: string
totalPackageCount: string
totalGrossWeightKg: number
totalNetWeightKg: number
totalVolumeCbm: number
totalQuantity: string
remark: string | null
createdBy: string | null
createdByName: string | null
createdAt: string
version: number
}
export type LoadingListSummary = PreloadSummary | ConfirmedSummary
export interface LoadingItem {
itemId: string
accountId: string
customerName: string | null
inboundId: string
inboundDetailId: string
inboundCode: string | null
skuCode: string | null
destinationWarehouse: string | null
chineseName: string | null
englishName: string | null
goodsImageUrl: string | null
packageCount: number
palletCount: number
grossWeightKg: number
netWeightKg: number
volumeCbm: number
dimensionalWeightKg: number
productQuantity: string
unitsPerPackage: number
fbaTrackingNo: string | null
shipmentNo: string | null
shippingMark: string | null
internalRemark: string | null
customerRemark: string | null
warehouseRemark: string | null
loadSequence: number
frozenAt: string | null
}
export interface LoadingHistory {
historyId: string
itemId: string | null
actionType: string
fromStatus: string | null
toStatus: string | null
beforeSnapshot: string | null
afterSnapshot: string | null
operatorId: string
operatorName: string | null
operationReason: string | null
occurredAt: string
}
export interface LoadingDetail<T extends LoadingListSummary = LoadingListSummary> {
summary: T
items: LoadingItem[]
histories: LoadingHistory[]
}
export interface LoadingListPage<T extends LoadingListSummary = LoadingListSummary> {
records: T[]
page: number
size: number
total: number
pages: number
}
export interface LoadingListQuery {
page: number
size: number
warehouseId: string
keyword?: string
createdFrom?: string
createdTo?: string
}
export interface TransportUpdateRequest {
containerNo: string | null
voyageNo: string | null
expectedVersion: number
operationReason: string | null
}
type RawId = string | number | null | undefined
type RawPage<T extends LoadingListSummary> = Omit<LoadingListPage<T>, 'page' | 'size' | 'total' | 'pages'> & {
page: string | number
size: string | number
total: string | number
pages: string | number
}
const basePath = '/platform/v1/first-leg'
function requiredId(value: RawId) {
return String(value ?? '')
}
function optionalId(value: RawId) {
return value === null || value === undefined ? null : String(value)
}
function normalizePreloadSummary(raw: PreloadSummary): PreloadSummary {
return {
...raw,
planId: requiredId(raw.planId),
warehouseId: optionalId(raw.warehouseId),
skuCount: String(raw.skuCount ?? '0'),
totalPackageCount: String(raw.totalPackageCount ?? '0'),
customers: (raw.customers || []).map((customer) => ({
...customer,
accountId: requiredId(customer.accountId),
})),
}
}
function normalizeConfirmedSummary(raw: ConfirmedSummary): ConfirmedSummary {
return {
...raw,
planId: requiredId(raw.planId),
warehouseId: optionalId(raw.warehouseId),
createdBy: optionalId(raw.createdBy),
totalPackageCount: String(raw.totalPackageCount ?? '0'),
totalQuantity: String(raw.totalQuantity ?? '0'),
}
}
function normalizeItem(raw: LoadingItem): LoadingItem {
return {
...raw,
itemId: requiredId(raw.itemId),
accountId: requiredId(raw.accountId),
inboundId: requiredId(raw.inboundId),
inboundDetailId: requiredId(raw.inboundDetailId),
productQuantity: String(raw.productQuantity ?? '0'),
}
}
function normalizeHistory(raw: LoadingHistory): LoadingHistory {
return {
...raw,
historyId: requiredId(raw.historyId),
itemId: optionalId(raw.itemId),
operatorId: requiredId(raw.operatorId),
}
}
function normalizePage<T extends LoadingListSummary>(raw: RawPage<T>, normalize: (row: T) => T): LoadingListPage<T> {
return {
records: (raw.records || []).map(normalize),
page: Number(raw.page),
size: Number(raw.size),
total: Number(raw.total),
pages: Number(raw.pages),
}
}
function idempotencyKey(planId: string) {
return `admin-preload-transport-${planId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
}
export async function listPreloadPlans(query: LoadingListQuery) {
const response = await http.get<ApiEnvelope<RawPage<PreloadSummary>>, ApiEnvelope<RawPage<PreloadSummary>>>(
`${basePath}/loading-plans`,
{ params: { ...query, status: 'LOADING' } },
)
return normalizePage(unwrapApiResponse(response), normalizePreloadSummary)
}
export async function getPreloadPlan(planId: string, warehouseId: string) {
const response = await http.get<ApiEnvelope<LoadingDetail<PreloadSummary>>, ApiEnvelope<LoadingDetail<PreloadSummary>>>(
`${basePath}/loading-plans/${planId}`,
{ params: { warehouseId } },
)
const result = unwrapApiResponse(response)
return {
summary: normalizePreloadSummary(result.summary),
items: (result.items || []).map(normalizeItem),
histories: (result.histories || []).map(normalizeHistory),
}
}
export async function updatePreloadTransport(planId: string, warehouseId: string, request: TransportUpdateRequest) {
const response = await http.patch<ApiEnvelope<LoadingDetail<PreloadSummary>>, ApiEnvelope<LoadingDetail<PreloadSummary>>>(
`${basePath}/loading-plans/${planId}/transport`,
request,
{ params: { warehouseId }, headers: { 'Idempotency-Key': idempotencyKey(planId) } },
)
const result = unwrapApiResponse(response)
return {
summary: normalizePreloadSummary(result.summary),
items: (result.items || []).map(normalizeItem),
histories: (result.histories || []).map(normalizeHistory),
}
}
export async function listConfirmedLoadingLists(query: LoadingListQuery) {
const response = await http.get<ApiEnvelope<RawPage<ConfirmedSummary>>, ApiEnvelope<RawPage<ConfirmedSummary>>>(
`${basePath}/loading-lists`,
{ params: query },
)
return normalizePage(unwrapApiResponse(response), normalizeConfirmedSummary)
}
export async function getConfirmedLoadingList(planId: string, warehouseId: string) {
const response = await http.get<ApiEnvelope<LoadingDetail<ConfirmedSummary>>, ApiEnvelope<LoadingDetail<ConfirmedSummary>>>(
`${basePath}/loading-lists/${planId}`,
{ params: { warehouseId } },
)
const result = unwrapApiResponse(response)
return {
summary: normalizeConfirmedSummary(result.summary),
items: (result.items || []).map(normalizeItem),
histories: (result.histories || []).map(normalizeHistory),
}
}
@@ -0,0 +1,163 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Edit3 } from '@lucide/vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import type { ApiErrorDetails } from '@/shared/api/http'
import type { LoadingDetail, LoadingListMode, LoadingListSummary, PreloadSummary } from '../api/platformLoadingLists'
const props = defineProps<{
mode: LoadingListMode
loading: boolean
detail: LoadingDetail | null
error: ApiErrorDetails | null
canEdit: boolean
}>()
const open = defineModel<boolean>({ default: false })
const emit = defineEmits<{ retry: []; edit: [] }>()
const itemColumns: DataTableColumn[] = [
{ key: 'sequence', prop: 'loadSequence', label: '装柜顺序', fixed: 'left', width: 92, hideable: false, movable: false },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 150 },
{ key: 'skuCode', prop: 'skuCode', label: 'SKU', minWidth: 130 },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150 },
{ key: 'product', label: '产品名称', minWidth: 190, slotName: 'product' },
{ key: 'destinationWarehouse', prop: 'destinationWarehouse', label: '目的仓', minWidth: 130 },
{ key: 'packageCount', prop: 'packageCount', label: '箱数', width: 86, align: 'right' },
{ key: 'palletCount', prop: 'palletCount', label: '托盘数', width: 86, align: 'right' },
{ key: 'productQuantity', prop: 'productQuantity', label: '产品数', width: 96, align: 'right' },
{ key: 'grossWeightKg', label: '毛重(kg)', width: 110, align: 'right', slotName: 'grossWeight' },
{ key: 'netWeightKg', label: '净重(kg)', width: 110, align: 'right', slotName: 'netWeight' },
{ key: 'volumeCbm', label: '体积(m³)', width: 110, align: 'right', slotName: 'volume' },
{ key: 'fbaTrackingNo', prop: 'fbaTrackingNo', label: 'FBA 追踪编码', minWidth: 155 },
{ key: 'shipmentNo', prop: 'shipmentNo', label: '货件号', minWidth: 140 },
{ key: 'shippingMark', prop: 'shippingMark', label: '唛头', minWidth: 130 },
{ key: 'frozenAt', label: '冻结时间', minWidth: 150, slotName: 'frozenAt' },
]
const summary = computed<LoadingListSummary | null>(() => props.detail?.summary || null)
const preloadSummary = computed(() => props.mode === 'preload' ? summary.value as PreloadSummary | null : null)
const title = computed(() => {
if (!summary.value) return props.mode === 'preload' ? '预装清单详情' : '装柜清单详情'
const number = props.mode === 'preload'
? (summary.value as PreloadSummary).preloadNo
: 'loadingListNo' in summary.value ? summary.value.loadingListNo : ''
return `${props.mode === 'preload' ? '预装清单' : '装柜清单'} · ${number}`
})
function statusLabel(status?: string | null) {
return ({ LOADING: '装柜中', COMPLETED: '装柜完成', EDITING: '编辑中' } as Record<string, string>)[status || ''] || status || '—'
}
function actionLabel(action: string) {
return ({
CREATE: '创建计划', ASSIGN: '分配货物', ADJUST: '调整货物', DELETE: '移除货物',
RECOMMEND: '智能排柜', PUSH_PRELOAD: '推送预装', UPDATE_TRANSPORT: '修改运输信息',
START_LOADING: '开始装柜', COMPLETE: '完成装柜',
} as Record<string, string>)[action] || action
}
function formatDateTime(value?: string | null) {
return value ? value.replace('T', ' ').slice(0, 16) : '—'
}
function formatNumber(value?: number | null, digits = 3) {
if (value === null || value === undefined || !Number.isFinite(Number(value))) return '—'
return Number(value).toLocaleString('zh-CN', { maximumFractionDigits: digits })
}
</script>
<template>
<AppDrawer
v-model="open"
:title="title"
size="min(1180px, 96vw)"
:loading="loading"
:show-footer="false"
destroy-on-close
>
<LoadErrorState v-if="error" title="清单详情加载失败" :message="error.message" @retry="emit('retry')">
<template #details><ApiErrorMeta :details="error" /></template>
</LoadErrorState>
<div v-else-if="detail && summary" class="detail-layout">
<section class="detail-summary" aria-label="清单基本信息">
<div><span>仓库</span><strong>{{ summary.warehouseName || '—' }}</strong></div>
<div><span>集装箱号</span><strong>{{ summary.containerNo || '—' }}</strong></div>
<div><span>柜号</span><strong>{{ summary.internalContainerNo || '—' }}</strong></div>
<div><span>状态</span><StatusBadge :status="summary.status" /> <small>{{ statusLabel(summary.status) }}</small></div>
<template v-if="preloadSummary">
<div><span>船名 / 航次</span><strong>{{ preloadSummary.vesselName || '—' }} / {{ preloadSummary.voyageNo || '—' }}</strong></div>
<div><span>箱型</span><strong>{{ preloadSummary.containerType || '—' }}</strong></div>
<div class="detail-summary__wide"><span>客户</span><strong>{{ preloadSummary.customers.map((item) => item.customerName || item.accountId).join('、') || '—' }}</strong></div>
</template>
<div><span>创建时间</span><strong>{{ formatDateTime(summary.createdAt) }}</strong></div>
</section>
<div v-if="mode === 'preload' && canEdit && summary.status === 'LOADING'" class="detail-actions">
<el-button type="primary" @click="emit('edit')"><Edit3 :size="16" />编辑运输信息</el-button>
</div>
<el-alert v-else-if="mode === 'confirmed'" type="info" :closable="false" show-icon title="装柜完成后清单、明细和运输信息永久只读" />
<section class="detail-section surface-panel">
<DataTable
:data="detail.items"
:columns="itemColumns"
preference-key="loading-list-detail-items"
row-key="itemId"
empty-text="暂无装柜货物明细"
aria-label="装柜货物明细"
>
<template #toolbar-start><h2>装柜货物明细</h2></template>
<template #product="{ row }"><strong>{{ row.chineseName || row.englishName || '—' }}</strong><small v-if="row.englishName && row.chineseName">{{ row.englishName }}</small></template>
<template #grossWeight="{ row }">{{ formatNumber(row.grossWeightKg) }}</template>
<template #netWeight="{ row }">{{ formatNumber(row.netWeightKg) }}</template>
<template #volume="{ row }">{{ formatNumber(row.volumeCbm) }}</template>
<template #frozenAt="{ row }">{{ formatDateTime(row.frozenAt) }}</template>
</DataTable>
</section>
<section class="history-section surface-panel" aria-label="操作历史">
<header><h2>操作历史</h2><span>{{ detail.histories.length }} 条</span></header>
<el-empty v-if="detail.histories.length === 0" description="暂无操作历史" :image-size="72" />
<el-timeline v-else>
<el-timeline-item v-for="history in detail.histories" :key="history.historyId" :timestamp="formatDateTime(history.occurredAt)" placement="top">
<div class="history-entry">
<strong>{{ actionLabel(history.actionType) }}</strong>
<span v-if="history.fromStatus || history.toStatus">{{ statusLabel(history.fromStatus) }} → {{ statusLabel(history.toStatus) }}</span>
<p>{{ history.operatorName || history.operatorId }}<template v-if="history.operationReason"> · {{ history.operationReason }}</template></p>
</div>
</el-timeline-item>
</el-timeline>
</section>
</div>
</AppDrawer>
</template>
<style scoped>
.detail-layout { display: grid; gap: 16px; }
.detail-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; overflow: hidden; border: 1px solid var(--cc-border); background: var(--cc-border); }
.detail-summary > div { display: flex; min-width: 0; min-height: 78px; flex-direction: column; justify-content: center; gap: 7px; padding: 14px 16px; background: #fff; }
.detail-summary span { color: var(--cc-muted); font-size: 12px; }
.detail-summary strong { overflow-wrap: anywhere; color: var(--cc-text); font-size: 14px; }
.detail-summary small { color: var(--cc-muted); font-size: 11px; }
.detail-summary__wide { grid-column: span 2; }
.detail-actions { display: flex; justify-content: flex-end; }
.detail-section, .history-section { min-width: 0; overflow: hidden; border: 1px solid var(--cc-border); background: #fff; }
.detail-section :deep(.data-table__toolbar) { min-height: 56px; }
.detail-section :deep(.el-table) { width: 100%; }
.detail-section small { display: block; margin-top: 3px; color: var(--cc-muted); }
.history-section { padding: 18px; }
.history-section header { display: flex; align-items: baseline; gap: 10px; margin-bottom: 18px; }
.history-section h2 { margin: 0; font-size: 16px; }
.history-section header span { color: var(--cc-muted); font-size: 12px; }
.history-entry { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.history-entry > span { color: var(--cc-muted); font-size: 12px; }
.history-entry p { width: 100%; margin: 5px 0 0; color: var(--cc-muted); font-size: 12px; }
@media (max-width: 900px) { .detail-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 520px) { .detail-summary { grid-template-columns: 1fr; } .detail-summary__wide { grid-column: auto; } .detail-actions :deep(.el-button) { width: 100%; min-height: 44px; } }
</style>
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
const source = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/LoadingListsView.vue'), 'utf8')
const detailSource = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/LoadingListDetailDrawer.vue'), 'utf8')
describe('LoadingListsView', () => {
it('keeps the operation column first and fixed left in both lists', () => {
const actionColumns = [...source.matchAll(/key: 'actions', label: '操作', fixed: 'left'/g)]
expect(actionColumns).toHaveLength(2)
expect(source.indexOf("key: 'actions'")).toBeLessThan(source.indexOf("key: 'preloadNo'"))
expect(source.indexOf("key: 'actions'", source.indexOf("key: 'actions'") + 1)).toBeLessThan(source.indexOf("key: 'loadingListNo'"))
})
it('requires a warehouse before sending a list request', () => {
expect(source).toContain("if (!warehouseId) return null")
expect(source).toContain("ElMessage.warning('请先选择仓库')")
expect(source).toContain("'请先选择仓库后查询'")
})
it('uses the shared query, table, drawer, dialog and error components', () => {
expect(source).toContain('QueryToolbar')
expect(source).toContain('DataTable')
expect(source).toContain('AppDialog')
expect(source).toContain('LoadErrorState')
expect(source).toContain('ApiErrorMeta')
expect(detailSource).toContain('AppDrawer')
})
it('only exposes transport editing for LOADING preload details', () => {
expect(detailSource).toContain("mode === 'preload' && canEdit && summary.status === 'LOADING'")
expect(detailSource).toContain('装柜完成后清单、明细和运输信息永久只读')
expect(source).toContain('expectedVersion: detail.value.summary.version')
})
})
@@ -0,0 +1,333 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { RefreshCw } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listAllPlatformWarehouses } from '@/modules/warehouses'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import LoadingListDetailDrawer from './LoadingListDetailDrawer.vue'
import {
getConfirmedLoadingList,
getPreloadPlan,
listConfirmedLoadingLists,
listPreloadPlans,
updatePreloadTransport,
type ConfirmedSummary,
type LoadingDetail,
type LoadingListMode,
type LoadingListQuery,
type LoadingListSummary,
type PreloadSummary,
} from '../api/platformLoadingLists'
const props = defineProps<{ mode: LoadingListMode }>()
const access = useAccessPreviewStore()
const rows = ref<LoadingListSummary[]>([])
const warehouses = ref<Array<{ id: string; name: string }>>([])
const loadingWarehouses = ref(false)
const loading = ref(false)
const error = ref<ApiErrorDetails | null>(null)
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const query = reactive<QueryModel>({ warehouseId: '', keyword: '', createdRange: [] })
const detailOpen = ref(false)
const detailLoading = ref(false)
const detailError = ref<ApiErrorDetails | null>(null)
const detail = ref<LoadingDetail | null>(null)
const selectedPlanId = ref('')
const selectedWarehouseId = ref('')
const editOpen = ref(false)
const editSaving = ref(false)
const editError = ref<ApiErrorDetails | null>(null)
const editFormRef = ref<FormInstance>()
const editForm = reactive({ containerNo: '', voyageNo: '', operationReason: '' })
const isPreload = computed(() => props.mode === 'preload')
const title = computed(() => isPreload.value ? '预装清单' : '装柜清单')
const listPermission = computed(() => isPreload.value ? 'platform:firstleg:preload:list' : 'platform:firstleg:loading-list:list')
const viewPermission = computed(() => isPreload.value ? 'platform:firstleg:preload:view' : 'platform:firstleg:loading-list:view')
const canList = computed(() => access.can(listPermission.value))
const canView = computed(() => access.can(viewPermission.value))
const canEdit = computed(() => isPreload.value && access.can('platform:firstleg:preload:edit'))
const hasWarehouse = computed(() => Boolean(String(query.warehouseId || '').trim()))
const fields = computed<QueryField[]>(() => [
{
key: 'warehouseId', type: 'select', label: '仓库', placeholder: loadingWarehouses.value ? '仓库加载中' : '请选择仓库(必选)', width: 220,
options: warehouses.value.map((warehouse) => ({ label: warehouse.name, value: warehouse.id })),
props: { loading: loadingWarehouses.value, 'aria-label': '仓库(必选)' },
},
{ key: 'keyword', type: 'input', label: '关键词', placeholder: isPreload.value ? '预装单号/集装箱号/柜号' : '装柜清单号/集装箱号/柜号', width: 250 },
{ key: 'createdRange', type: 'daterange', label: '创建日期', width: 260 },
])
const columns = computed<DataTableColumn[]>(() => isPreload.value ? [
{ key: 'actions', label: '操作', fixed: 'left', width: 110, slotName: 'actions', hideable: false, movable: false },
{ key: 'preloadNo', prop: 'preloadNo', label: '预装单号', minWidth: 165 },
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 145 },
{ key: 'internalContainerNo', prop: 'internalContainerNo', label: '柜号', minWidth: 130 },
{ key: 'customers', label: '客户', minWidth: 190, slotName: 'customers' },
{ key: 'warehouseName', prop: 'warehouseName', label: '仓库', minWidth: 140 },
{ key: 'vesselVoyage', label: '船名 / 航次', minWidth: 175, slotName: 'vesselVoyage' },
{ key: 'containerType', prop: 'containerType', label: '箱型', width: 100 },
{ key: 'status', label: '状态', width: 108, slotName: 'status' },
{ key: 'skuCount', prop: 'skuCount', label: 'SKU 数', width: 90, align: 'right' },
{ key: 'totalPackageCount', prop: 'totalPackageCount', label: '总箱数', width: 90, align: 'right' },
{ key: 'totalVolumeCbm', label: '总体积(m³)', width: 120, align: 'right', slotName: 'volume' },
{ key: 'totalGrossWeightKg', label: '总毛重(kg)', width: 125, align: 'right', slotName: 'grossWeight' },
{ key: 'createdAt', label: '创建时间', minWidth: 155, slotName: 'createdAt' },
] : [
{ key: 'actions', label: '操作', fixed: 'left', width: 110, slotName: 'actions', hideable: false, movable: false },
{ key: 'loadingListNo', prop: 'loadingListNo', label: '装柜清单号', minWidth: 175 },
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 145 },
{ key: 'internalContainerNo', prop: 'internalContainerNo', label: '柜号', minWidth: 130 },
{ key: 'warehouseName', prop: 'warehouseName', label: '仓库', minWidth: 140 },
{ key: 'status', label: '状态', width: 110, slotName: 'status' },
{ key: 'totalPackageCount', prop: 'totalPackageCount', label: '总箱数', width: 90, align: 'right' },
{ key: 'totalGrossWeightKg', label: '总毛重(kg)', width: 125, align: 'right', slotName: 'grossWeight' },
{ key: 'totalNetWeightKg', label: '总净重(kg)', width: 125, align: 'right', slotName: 'netWeight' },
{ key: 'totalVolumeCbm', label: '总体积(m³)', width: 120, align: 'right', slotName: 'volume' },
{ key: 'totalQuantity', prop: 'totalQuantity', label: '产品总数', width: 105, align: 'right' },
{ key: 'remark', prop: 'remark', label: '备注', minWidth: 180, showOverflowTooltip: true },
{ key: 'createdByName', prop: 'createdByName', label: '创建人', minWidth: 120 },
{ key: 'createdAt', label: '创建时间', minWidth: 155, slotName: 'createdAt' },
])
const editRules: FormRules = {
containerNo: [{ max: 64, message: '集装箱号不能超过 64 个字符', trigger: 'blur' }],
voyageNo: [{ max: 64, message: '航次不能超过 64 个字符', trigger: 'blur' }],
operationReason: [{ max: 500, message: '修改原因不能超过 500 个字符', trigger: 'blur' }],
}
function formatDateTime(value?: string | null) {
return value ? value.replace('T', ' ').slice(0, 16) : '—'
}
function formatNumber(value?: number | null, digits = 3) {
if (value === null || value === undefined || !Number.isFinite(Number(value))) return '—'
return Number(value).toLocaleString('zh-CN', { maximumFractionDigits: digits })
}
function toQuery(): LoadingListQuery | null {
const warehouseId = String(query.warehouseId || '').trim()
if (!warehouseId) return null
const range = Array.isArray(query.createdRange) ? query.createdRange.map(String) : []
return {
page: page.value,
size: pageSize.value,
warehouseId,
keyword: String(query.keyword || '').trim() || undefined,
createdFrom: range[0] ? `${range[0]}T00:00:00` : undefined,
createdTo: range[1] ? `${range[1]}T23:59:59` : undefined,
}
}
async function loadWarehouses() {
loadingWarehouses.value = true
try {
const result = await listAllPlatformWarehouses({ status: 'ACTIVE', warehouseType: 'DOMESTIC' })
warehouses.value = result.map((warehouse) => ({ id: String(warehouse.id), name: warehouse.warehouseName }))
} catch (cause) {
error.value = getApiErrorDetails(cause, '仓库列表加载失败,请刷新后重试')
} finally {
loadingWarehouses.value = false
}
}
async function load() {
if (!canList.value) return
const request = toQuery()
if (!request) {
rows.value = []
total.value = 0
error.value = null
return
}
loading.value = true
error.value = null
try {
const result = isPreload.value ? await listPreloadPlans(request) : await listConfirmedLoadingLists(request)
rows.value = result.records
total.value = result.total
} catch (cause) {
error.value = getApiErrorDetails(cause, `${title.value}加载失败,请稍后重试`)
} finally {
loading.value = false
}
}
function search() {
if (!hasWarehouse.value) {
rows.value = []
total.value = 0
ElMessage.warning('请先选择仓库')
return
}
if (page.value !== 1) page.value = 1
else void load()
}
function reset() {
page.value = 1
rows.value = []
total.value = 0
error.value = null
}
async function loadDetail() {
if (!selectedPlanId.value || !selectedWarehouseId.value || !canView.value) return
detailLoading.value = true
detailError.value = null
try {
detail.value = isPreload.value
? await getPreloadPlan(selectedPlanId.value, selectedWarehouseId.value)
: await getConfirmedLoadingList(selectedPlanId.value, selectedWarehouseId.value)
} catch (cause) {
detailError.value = getApiErrorDetails(cause, '清单详情加载失败,请稍后重试')
} finally {
detailLoading.value = false
}
}
function openDetail(rowValue: object) {
const row = rowValue as LoadingListSummary
selectedPlanId.value = String(row.planId)
selectedWarehouseId.value = String(row.warehouseId || query.warehouseId || '')
detail.value = null
detailError.value = null
detailOpen.value = true
void loadDetail()
}
function openEdit() {
if (!detail.value || !('preloadNo' in detail.value.summary) || detail.value.summary.status !== 'LOADING') return
editForm.containerNo = detail.value.summary.containerNo || ''
editForm.voyageNo = detail.value.summary.voyageNo || ''
editForm.operationReason = ''
editError.value = null
editOpen.value = true
}
async function saveTransport() {
if (!detail.value || !('preloadNo' in detail.value.summary)) return
const valid = await editFormRef.value?.validate().catch(() => false)
if (!valid) return
editSaving.value = true
editError.value = null
try {
detail.value = await updatePreloadTransport(selectedPlanId.value, selectedWarehouseId.value, {
containerNo: editForm.containerNo.trim() || null,
voyageNo: editForm.voyageNo.trim() || null,
operationReason: editForm.operationReason.trim() || null,
expectedVersion: detail.value.summary.version,
})
editOpen.value = false
ElMessage.success('运输信息已更新')
await load()
} catch (cause) {
editError.value = getApiErrorDetails(cause, '运输信息保存失败,请刷新详情后重试')
} finally {
editSaving.value = false
}
}
watch([page, pageSize], () => { if (hasWarehouse.value) void load() })
onMounted(() => void loadWarehouses())
</script>
<template>
<div class="business-page loading-lists-page">
<QueryToolbar
v-model="query"
:ariaLabel="`${title}筛选`"
:fields="fields"
:available-fields="fields"
:preference-key="`loading-lists-${mode}-query`"
:search-on-reset="false"
@search="search"
@reset="reset"
>
<template #actions>
<el-button :disabled="!hasWarehouse || loading" title="刷新" aria-label="刷新当前清单" @click="load"><RefreshCw :size="16" />刷新</el-button>
</template>
</QueryToolbar>
<LoadErrorState v-if="!canList" :title="`无权查看${title}`" message="当前账号缺少该清单的列表权限。" :show-retry="false" />
<LoadErrorState v-else-if="error" :title="`${title}加载失败`" :message="error.message" :loading="loading" @retry="load">
<template #details><ApiErrorMeta :details="error" /></template>
</LoadErrorState>
<section v-else 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="`loading-lists-${mode}`"
row-key="planId"
:empty-text="hasWarehouse ? `暂无符合筛选条件的${title}` : '请先选择仓库后查询'"
:aria-label="title"
>
<template #toolbar-start><div class="table-heading"><h2>{{ title }}</h2><span>{{ hasWarehouse ? `共 ${total} 条` : '仓库为必选条件' }}</span></div></template>
<template #actions="{ row }"><el-button v-if="canView" link type="primary" @click="openDetail(row)">查看</el-button></template>
<template #customers="{ row }">{{ (row as PreloadSummary).customers.map((item) => item.customerName || item.accountId).join('、') || '—' }}</template>
<template #vesselVoyage="{ row }">{{ (row as PreloadSummary).vesselName || '—' }} / {{ (row as PreloadSummary).voyageNo || '—' }}</template>
<template #status="{ row }"><StatusBadge :status="row.status" /><span class="status-label">{{ row.status === 'LOADING' ? '装柜中' : row.status === 'COMPLETED' ? '装柜完成' : row.status }}</span></template>
<template #grossWeight="{ row }">{{ formatNumber(row.totalGrossWeightKg) }}</template>
<template #netWeight="{ row }">{{ formatNumber((row as ConfirmedSummary).totalNetWeightKg) }}</template>
<template #volume="{ row }">{{ formatNumber(row.totalVolumeCbm) }}</template>
<template #createdAt="{ row }">{{ formatDateTime(row.createdAt) }}</template>
</DataTable>
</section>
<LoadingListDetailDrawer
v-model="detailOpen"
:mode="mode"
:loading="detailLoading"
:detail="detail"
:error="detailError"
:can-edit="canEdit"
@retry="loadDetail"
@edit="openEdit"
/>
<AppDialog
v-model="editOpen"
title="编辑预装运输信息"
confirm-text="保存"
:confirm-loading="editSaving"
:confirm-disabled="!canEdit"
@confirm="saveTransport"
>
<el-alert type="info" :closable="false" show-icon title="仅装柜中允许修改;保存时按详情版本执行乐观锁校验。" />
<el-form ref="editFormRef" class="transport-form" :model="editForm" :rules="editRules" label-position="top">
<el-form-item label="集装箱号" prop="containerNo"><el-input v-model="editForm.containerNo" maxlength="64" show-word-limit placeholder="请输入集装箱号" /></el-form-item>
<el-form-item label="航次" prop="voyageNo"><el-input v-model="editForm.voyageNo" maxlength="64" show-word-limit placeholder="请输入航次" /></el-form-item>
<el-form-item label="修改原因" prop="operationReason"><el-input v-model="editForm.operationReason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="选填,填写后将记录在操作历史中" /></el-form-item>
</el-form>
<LoadErrorState v-if="editError" compact title="保存失败" :message="editError.message" :show-retry="false">
<template #details><ApiErrorMeta :details="editError" /></template>
</LoadErrorState>
</AppDialog>
</div>
</template>
<style scoped>
.loading-lists-page { display: grid; min-width: 0; gap: 16px; }
.table-panel { min-width: 0; overflow: hidden; border: 1px solid var(--cc-border); background: #fff; }
.table-heading { display: flex; align-items: baseline; flex-wrap: wrap; gap: 10px; }
.table-heading h2 { margin: 0; font-size: 16px; }
.table-heading span, .status-label { color: var(--cc-muted); font-size: 12px; }
.status-label { margin-left: 6px; }
.transport-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; margin-top: 16px; }
.transport-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }
@media (max-width: 640px) { .transport-form { grid-template-columns: 1fr; } .transport-form :deep(.el-form-item:last-child) { grid-column: auto; } }
</style>
+2
View File
@@ -0,0 +1,2 @@
export * from './api/platformLoadingLists'
export { loadingListRoutes } from './routes'
@@ -0,0 +1,5 @@
<script setup lang="ts">
import LoadingListsView from '../components/LoadingListsView.vue'
</script>
<template><LoadingListsView mode="confirmed" /></template>
@@ -0,0 +1,5 @@
<script setup lang="ts">
import LoadingListsView from '../components/LoadingListsView.vue'
</script>
<template><LoadingListsView mode="preload" /></template>
+11
View File
@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest'
import { loadingListRoutes } from './routes'
describe('loading list routes', () => {
it('declares separate preload and completed list entries with backend permissions', () => {
expect(loadingListRoutes.map((route) => [route.path, route.meta.permission])).toEqual([
['loading-plans', 'platform:firstleg:preload:list'],
['loading-lists', 'platform:firstleg:loading-list:list'],
])
})
})
+24
View File
@@ -0,0 +1,24 @@
import type { RouteRecordRaw } from 'vue-router'
export const loadingListRoutes = [
{
path: 'loading-plans',
name: 'loading-plans',
component: () => import('./pages/PreloadPlansView.vue'),
meta: {
title: '预装清单',
description: '按仓库查看装柜中的预装计划、货物明细和操作历史',
permission: 'platform:firstleg:preload:list',
},
},
{
path: 'loading-lists',
name: 'loading-lists',
component: () => import('./pages/ConfirmedLoadingListsView.vue'),
meta: {
title: '装柜清单',
description: '按仓库查看已完成且永久只读的装柜清单',
permission: 'platform:firstleg:loading-list:list',
},
},
] satisfies RouteRecordRaw[]