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
+6
View File
@@ -21,6 +21,9 @@ import { warehouseRoutes } from '@/modules/warehouses/routes'
import { prototypeModuleRoutes } from '@/prototype/routes'
import { oceanBookingRoutes } from '@/modules/ocean-bookings/routes'
import { truckingRoutes } from '@/modules/trucking/routes'
import { feeItemRoutes } from '@/modules/fee-items/routes'
import { containerLoadingRoutes } from '@/modules/container-loading/routes'
import { loadingListRoutes } from '@/modules/loading-lists/routes'
const router = createRouter({
history: createWebHistory(),
@@ -46,6 +49,8 @@ const router = createRouter({
...prototypeModuleRoutes,
...oceanBookingRoutes,
...truckingRoutes,
...containerLoadingRoutes,
...loadingListRoutes,
...customerRoutes,
...warehouseRoutes,
...unifiedOrderRoutes,
@@ -53,6 +58,7 @@ const router = createRouter({
...inboundRoutes,
...fbaPricingRoutes,
...warehouseResourceRoutes,
...feeItemRoutes,
...masterDataRoutes,
...downloadRoutes,
...iamRoutes,
@@ -40,6 +40,11 @@ describe('adminNavigation', () => {
'快递公司',
'往来单位',
])
expect(adminNavigation.find((item) => item.key === 'container-management')?.children?.map((item) => item.title)).toEqual([
'货柜列表',
'预装清单',
'装柜清单',
])
expect(adminNavigation.find((item) => item.key === 'overseas-inventory')?.children?.map((item) => item.title)).toEqual([
'入库单',
'换标单',
@@ -78,6 +83,14 @@ describe('adminNavigation', () => {
expect(paths).not.toContain('/inbounds')
expect(paths).not.toContain('/first-leg-pricing/customer-rules')
expect(paths).toContain('/downloads')
expect(paths).toContain('/master-data/fee-items')
expect(paths).toContain('/container-loading')
expect(paths).toContain('/loading-plans')
expect(paths).toContain('/loading-lists')
expect(paths).not.toContain('/prototype/container-management/container-list')
expect(paths).not.toContain('/prototype/container-management/preload-list')
expect(paths).not.toContain('/prototype/container-management/manifest')
expect(paths).not.toContain('/prototype/general/fee-items')
expect(paths).toContain('/prototype/finance/profit-report')
expect(paths).toContain('/prototype/system-management/brand-settings')
expect(paths).toContain('/system/permissions')
@@ -128,6 +141,27 @@ describe('adminNavigation', () => {
expect(getNavigationLeaves(filtered).map((item) => item.path)).toContain('/customers')
})
it('shows the formal fee item page only with its view permission', () => {
const filtered = filterAdminNavigation(adminNavigation, new Set(['firstleg:fee-item:view']))
expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual(['/dashboard', '/master-data/fee-items'])
})
it('shows each formal container workflow entry only with its matching permission', () => {
const filtered = filterAdminNavigation(adminNavigation, new Set([
'platform:firstleg:container:list',
'platform:firstleg:preload:list',
'platform:firstleg:loading-list:list',
]))
expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual([
'/dashboard',
'/container-loading',
'/loading-plans',
'/loading-lists',
])
})
it('supports manually hiding a module without removing its route definition', () => {
const filtered = filterAdminNavigation([{ key: 'hidden', title: '隐藏模块', icon: adminNavigation[0].icon, path: '/hidden', hidden: true }], new Set())
+4 -5
View File
@@ -95,11 +95,10 @@ export const adminNavigation: AdminNavigationNode[] = [
key: 'container-management',
title: '货柜管理',
icon: WarehouseIcon,
hidden: true,
children: [
{ key: 'container-list', title: '货柜列表', icon: WarehouseIcon, path: '/prototype/container-management/container-list' },
{ key: 'container-manifest', title: '装柜清单', icon: ClipboardList, path: '/prototype/container-management/manifest' },
{ key: 'container-preload', title: '预装清单', icon: ClipboardList, path: '/prototype/container-management/preload-list' },
{ key: 'container-list', title: '货柜列表', icon: WarehouseIcon, path: '/container-loading', permission: 'platform:firstleg:container:list' },
{ key: 'container-preload', title: '预装清单', icon: ClipboardList, path: '/loading-plans', permission: 'platform:firstleg:preload:list' },
{ key: 'container-manifest', title: '装柜清单', icon: ClipboardList, path: '/loading-lists', permission: 'platform:firstleg:loading-list:list' },
],
},
{
@@ -161,7 +160,7 @@ export const adminNavigation: AdminNavigationNode[] = [
children: [
// { key: 'first-leg-orders', title: '头程订单', icon: ClipboardList, path: '/orders/first-leg', permission: 'platform:order:list', hidden: true },
{ key: 'general-product', title: '产品信息', icon: Database, path: '/prototype/general/product-information', hidden: true },
{ key: 'general-fee', title: '费用项目', icon: Database, path: '/prototype/general/fee-items', hidden: true },
{ key: 'general-fee', title: '费用项目', icon: Database, path: '/master-data/fee-items', permission: 'firstleg:fee-item:view' },
{ key: 'warehouses', title: '仓库管理', icon: WarehouseIcon, path: '/warehouses', permission: 'platform:warehouse:list' },
{ key: 'fba-carrier-rates', title: '船期价格表', icon: CircleDollarSign, path: '/first-leg-pricing/carrier-rates', permission: 'firstleg:fba-pricing:rate:view' },
{ key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/prototype/vehicle-management/vehicle-list', hidden: true },
@@ -0,0 +1,60 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import {
adjustSmartLoadingItem, assignSmartLoadingItem, createSmartLoadingPlan, getContainer,
listContainers, listSmartLoadingCandidates, pushSmartLoadingPreload, removeSmartLoadingItem,
updateContainer, updateContainerTransport,
} from './platformContainerLoading'
vi.mock('@/shared/api/http', () => ({
http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn(), delete: vi.fn() },
unwrapApiResponse: <T>(response: { data: T }) => response.data,
}))
const container = { id: 9007199254740993, planId: 9007199254740994, warehouseId: 9007199254740995, shippingCompanyId: 9007199254740996, version: '2', capacityCbm: '68', maxWeightKg: '28000' }
const plan = { id: 9007199254740993, bookingId: 9007199254740994, version: '3', status: 'EDITING', items: [{ id: 9007199254740995, loadingPlanId: 9007199254740993, accountId: 1, inboundId: 2, inboundDetailId: 3, orderId: 4, destinationWarehouseId: 5, packageCount: '6', version: '1' }] }
describe('platform container loading API', () => {
beforeEach(() => vi.clearAllMocks())
it('normalizes paged container ids and numeric metadata', async () => {
vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { records: [container], page: '1', size: '20', total: '1', pages: '1' } })
const result = await listContainers({ page: 1, size: 20 })
expect(result.records[0]).toMatchObject({ id: String(container.id), planId: String(container.planId), warehouseId: String(container.warehouseId), version: 2, capacityCbm: 68 })
expect(http.get).toHaveBeenCalledWith('/platform/v1/containers', { params: { page: 1, size: 20 } })
})
it('uses full-edit and transport whitelist request shapes', async () => {
vi.mocked(http.put).mockResolvedValue({ code: '200', data: container })
vi.mocked(http.patch).mockResolvedValue({ code: '200', data: container })
const data = { cabinetNo: 'CB-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', containerNo: null, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null, destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: null, maxWeightKg: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }
await updateContainer('7', 2, data)
await updateContainerTransport('7', 3, { containerNo: 'MSCU1', voyageNo: 'V8' })
expect(http.put).toHaveBeenCalledWith('/platform/v1/containers/7', { version: 2, data })
expect(http.patch).toHaveBeenCalledWith('/platform/v1/containers/7/transport', { version: 3, containerNo: 'MSCU1', voyageNo: 'V8' })
})
it('normalizes every long id in candidates and plans', async () => {
vi.mocked(http.get)
.mockResolvedValueOnce({ code: '200', data: container })
.mockResolvedValueOnce({ code: '200', data: [{ accountId: 1, inboundId: 2, inboundDetailId: 3, orderId: 4, warehouseId: 5, destinationWarehouseId: 6, availablePackageCount: '7' }] })
expect((await getContainer('9')).id).toBe(String(container.id))
expect(await listSmartLoadingCandidates('5')).toMatchObject([{ accountId: '1', inboundDetailId: '3', destinationWarehouseId: '6', availablePackageCount: 7 }])
})
it('sends plan and item versions plus operation and idempotency keys', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', data: plan })
vi.mocked(http.patch).mockResolvedValue({ code: '200', data: plan })
vi.mocked(http.delete).mockResolvedValue({ code: '200', data: plan })
await createSmartLoadingPlan('7', null)
await assignSmartLoadingItem('10', { inboundId: '2', inboundDetailId: '3', packageCount: 4, expectedVersion: 5 })
await adjustSmartLoadingItem('10', '11', 6, 7, 8)
await removeSmartLoadingItem('10', '11', 9, 10)
await pushSmartLoadingPreload('10', 11)
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/first-leg/smart-loading/plans', { bookingId: '7', remark: null })
expect(vi.mocked(http.post).mock.calls[1]?.[1]).toMatchObject({ expectedVersion: 5, operationId: expect.stringContaining('assign-') })
expect(vi.mocked(http.patch).mock.calls[0]?.[1]).toMatchObject({ expectedPlanVersion: 7, expectedItemVersion: 8, operationId: expect.stringContaining('adjust-') })
expect(vi.mocked(http.delete).mock.calls[0]?.[1]).toMatchObject({ data: { expectedPlanVersion: 9, expectedItemVersion: 10, operationId: expect.stringContaining('remove-') } })
expect(vi.mocked(http.post).mock.calls[2]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.stringContaining('push-preload-') } })
})
})
@@ -0,0 +1,128 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type {
ContainerBookingResource,
ContainerPage,
ContainerQuery,
ContainerSaveRequest,
SmartLoadingCandidate,
SmartLoadingItem,
SmartLoadingPlan,
} from '../types'
const containerPath = '/platform/v1/containers'
const smartLoadingPath = '/platform/v1/first-leg/smart-loading'
function id(value: unknown): string { return value == null ? '' : String(value) }
function optionalId(value: unknown): string | null { return value == null ? null : String(value) }
function numberValue(value: unknown, fallback = 0): number {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : fallback
}
function operationId(prefix: string) { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}` }
function container(raw: ContainerBookingResource): ContainerBookingResource {
return {
...raw,
id: id(raw.id),
planId: optionalId(raw.planId),
warehouseId: id(raw.warehouseId),
shippingCompanyId: optionalId(raw.shippingCompanyId),
version: numberValue(raw.version),
capacityCbm: raw.capacityCbm == null ? null : numberValue(raw.capacityCbm),
maxWeightKg: raw.maxWeightKg == null ? null : numberValue(raw.maxWeightKg),
}
}
function item(raw: SmartLoadingItem): SmartLoadingItem {
return {
...raw,
id: id(raw.id), loadingPlanId: id(raw.loadingPlanId), accountId: id(raw.accountId),
inboundId: id(raw.inboundId), inboundDetailId: id(raw.inboundDetailId), orderId: id(raw.orderId),
destinationWarehouseId: optionalId(raw.destinationWarehouseId), version: numberValue(raw.version),
packageCount: numberValue(raw.packageCount),
productQuantity: raw.productQuantity == null ? null : numberValue(raw.productQuantity),
grossWeightKg: raw.grossWeightKg == null ? null : numberValue(raw.grossWeightKg),
volumeCbm: raw.volumeCbm == null ? null : numberValue(raw.volumeCbm),
}
}
function plan(raw: SmartLoadingPlan): SmartLoadingPlan {
return { ...raw, id: id(raw.id), bookingId: id(raw.bookingId), version: numberValue(raw.version), items: (raw.items || []).map(item) }
}
export async function listContainers(query: ContainerQuery): Promise<ContainerPage> {
const response = await http.get<ApiEnvelope<ContainerPage>, ApiEnvelope<ContainerPage>>(containerPath, { params: query })
const result = unwrapApiResponse(response)
return { ...result, records: (result.records || []).map(container), page: numberValue(result.page, query.page), size: numberValue(result.size, query.size), total: numberValue(result.total), pages: numberValue(result.pages) }
}
export async function getContainer(containerId: string) {
const response = await http.get<ApiEnvelope<ContainerBookingResource>, ApiEnvelope<ContainerBookingResource>>(`${containerPath}/${encodeURIComponent(containerId)}`)
return container(unwrapApiResponse(response))
}
export async function createContainer(payload: ContainerSaveRequest) {
const response = await http.post<ApiEnvelope<ContainerBookingResource>, ApiEnvelope<ContainerBookingResource>>(containerPath, payload)
return container(unwrapApiResponse(response))
}
export async function updateContainer(containerId: string, version: number, data: ContainerSaveRequest) {
const response = await http.put<ApiEnvelope<ContainerBookingResource>, ApiEnvelope<ContainerBookingResource>>(`${containerPath}/${encodeURIComponent(containerId)}`, { version, data })
return container(unwrapApiResponse(response))
}
export async function updateContainerTransport(containerId: string, version: number, payload: { containerNo: string | null; voyageNo: string | null }) {
const response = await http.patch<ApiEnvelope<ContainerBookingResource>, ApiEnvelope<ContainerBookingResource>>(`${containerPath}/${encodeURIComponent(containerId)}/transport`, { ...payload, version })
return container(unwrapApiResponse(response))
}
export async function listSmartLoadingCandidates(warehouseId: string) {
const response = await http.get<ApiEnvelope<SmartLoadingCandidate[]>, ApiEnvelope<SmartLoadingCandidate[]>>(`${smartLoadingPath}/candidates`, { params: { warehouseId } })
return unwrapApiResponse(response).map((raw) => ({
...raw, accountId: id(raw.accountId), inboundId: id(raw.inboundId), inboundDetailId: id(raw.inboundDetailId),
orderId: id(raw.orderId), warehouseId: id(raw.warehouseId), destinationWarehouseId: optionalId(raw.destinationWarehouseId),
availablePackageCount: numberValue(raw.availablePackageCount),
unitWeightKg: raw.unitWeightKg == null ? null : numberValue(raw.unitWeightKg),
unitVolumeCbm: raw.unitVolumeCbm == null ? null : numberValue(raw.unitVolumeCbm),
}))
}
export async function getSmartLoadingPlanByBooking(bookingId: string) {
const response = await http.get<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(`${smartLoadingPath}/plans/by-booking/${encodeURIComponent(bookingId)}`)
return plan(unwrapApiResponse(response))
}
export async function getSmartLoadingPlan(planId: string) {
const response = await http.get<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(`${smartLoadingPath}/plans/${encodeURIComponent(planId)}`)
return plan(unwrapApiResponse(response))
}
export async function createSmartLoadingPlan(bookingId: string, remark: string | null) {
const response = await http.post<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(`${smartLoadingPath}/plans`, { bookingId, remark })
return plan(unwrapApiResponse(response))
}
export async function assignSmartLoadingItem(planId: string, payload: { inboundId: string; inboundDetailId: string; packageCount: number; expectedVersion: number }) {
const response = await http.post<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/assignments`, { ...payload, operationId: operationId('assign') })
return plan(unwrapApiResponse(response))
}
export async function recommendSmartLoading(planId: string, expectedVersion: number) {
const response = await http.post<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/recommend`, { expectedVersion, operationId: operationId('recommend') })
return plan(unwrapApiResponse(response))
}
export async function adjustSmartLoadingItem(planId: string, itemId: string, packageCount: number, expectedPlanVersion: number, expectedItemVersion: number) {
const response = await http.patch<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/items/${encodeURIComponent(itemId)}`, { packageCount, expectedPlanVersion, expectedItemVersion, operationId: operationId('adjust') })
return plan(unwrapApiResponse(response))
}
export async function removeSmartLoadingItem(planId: string, itemId: string, expectedPlanVersion: number, expectedItemVersion: number) {
const response = await http.delete<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/items/${encodeURIComponent(itemId)}`, { data: { expectedPlanVersion, expectedItemVersion, operationId: operationId('remove') } })
return plan(unwrapApiResponse(response))
}
export async function pushSmartLoadingPreload(planId: string, expectedVersion: number) {
const response = await http.post<ApiEnvelope<SmartLoadingPlan>, ApiEnvelope<SmartLoadingPlan>>(`${smartLoadingPath}/plans/${encodeURIComponent(planId)}/push-preload`, { expectedVersion }, { headers: { 'Idempotency-Key': operationId('push-preload') } })
return plan(unwrapApiResponse(response))
}
+2
View File
@@ -0,0 +1,2 @@
export * from './api/platformContainerLoading'
export * from './types'
@@ -0,0 +1,31 @@
import ElementPlus from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { createMemoryHistory, createRouter } from 'vue-router'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ContainersView from './ContainersView.vue'
import { listAllPlatformWarehouses } from '@/modules/warehouses'
import { listContainers } from '../api/platformContainerLoading'
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
vi.mock('../api/platformContainerLoading', () => ({ listContainers: vi.fn(), getContainer: vi.fn(), createContainer: vi.fn(), updateContainer: vi.fn(), updateContainerTransport: vi.fn() }))
describe('ContainersView', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.mocked(listAllPlatformWarehouses).mockResolvedValue([])
vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', planStatus: 'EDITING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }], page: 1, size: 20, total: 1, pages: 1 })
})
it('loads the real paged contract and renders core container fields', async () => {
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: ContainersView }] })
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
expect(listContainers).toHaveBeenCalledWith(expect.objectContaining({ page: 1, size: 20 }))
expect(wrapper.text()).toContain('CB-1')
expect(wrapper.text()).toContain('MSCU-1')
expect(wrapper.text()).toContain('宁波 → 长滩')
expect(wrapper.find('button[aria-label="查看 CB-1"]').exists()).toBe(true)
})
})
@@ -0,0 +1,231 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { Boxes, Eye, Pencil, Plus, RefreshCcw, Sparkles } from '@lucide/vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { createContainer, getContainer, listContainers, updateContainer, updateContainerTransport } from '../api/platformContainerLoading'
import type { ContainerBookingResource, ContainerPlanStatus, ContainerSaveRequest } from '../types'
type Filters = QueryModel & { keyword: string; warehouseId: string; containerType: string; status: ContainerPlanStatus | ''; sailingRange: string[] }
type ContainerForm = ContainerSaveRequest & { id: string; version: number }
const router = useRouter()
const access = useAccessPreviewStore()
const rows = ref<ContainerBookingResource[]>([])
const warehouses = ref<PlatformWarehouseSummaryResource[]>([])
const loading = ref(false)
const warehouseLoading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const warehouseError = ref<ApiErrorDetails | null>(null)
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const filters = ref<Filters>({ keyword: '', warehouseId: '', containerType: '', status: '', sailingRange: [] })
const applied = ref<Filters>({ ...filters.value })
const detailOpen = ref(false)
const detailLoading = ref(false)
const detailError = ref<ApiErrorDetails | null>(null)
const detail = ref<ContainerBookingResource | null>(null)
const detailTarget = ref<ContainerBookingResource | null>(null)
const editorOpen = ref(false)
const editorError = ref<ApiErrorDetails | null>(null)
const saving = ref(false)
const formRef = ref<FormInstance>()
let listSequence = 0
let detailSequence = 0
function emptyForm(): ContainerForm {
return {
id: '', version: 0, containerNo: null, cabinetNo: '', warehouseId: '', warehouseName: null,
billOfLadingNo: null, vesselName: null, voyageNo: null, containerType: '40HQ', shippingCompanyId: null,
shippingCompanyName: null, departurePortCode: null, departurePortName: null, destinationPortCode: null,
destinationPortName: null, destinationWarehouseCode: null, destinationWarehouseName: null,
capacityCbm: null, maxWeightKg: null, terminalTruckingCompany: null, loadingAt: null,
estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null,
estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null,
}
}
const form = reactive<ContainerForm>(emptyForm())
const rules: FormRules<ContainerForm> = {
cabinetNo: [{ required: true, message: '请输入柜次号', trigger: 'blur' }],
warehouseId: [{ required: true, message: '请选择装柜仓库', trigger: 'change' }],
containerType: [{ required: true, message: '请选择柜型', trigger: 'change' }],
}
const canCreate = computed(() => access.can('platform:firstleg:container:create'))
const canEdit = computed(() => access.can('platform:firstleg:container:edit'))
const canSmartLoading = computed(() => access.can('platform:firstleg:smart-loading:list'))
const editorTitle = computed(() => form.id ? (detail.value?.planStatus === 'LOADING' ? '修改运输信息' : '编辑货柜') : '新增货柜')
const transportOnly = computed(() => Boolean(form.id) && detail.value?.planStatus === 'LOADING')
const queryFields = computed<QueryField[]>(() => [
{ key: 'keyword', type: 'input', label: '关键词', width: 220, placeholder: '柜次号、柜号或提单号', props: { 'aria-label': '关键词', maxlength: 128 } },
{ key: 'warehouseId', type: 'select', label: '装柜仓库', width: 220, options: warehouses.value.map((warehouse) => ({ label: `${warehouse.warehouseName}(${warehouse.warehouseCode})`, value: String(warehouse.id) })), props: { 'aria-label': '装柜仓库', filterable: true, loading: warehouseLoading.value } },
{ key: 'containerType', type: 'select', label: '柜型', width: 140, options: ['20GP', '40GP', '40HQ', '45HQ'].map((value) => ({ label: value, value })), props: { 'aria-label': '柜型' } },
{ key: 'status', type: 'select', label: '排柜状态', width: 150, options: [{ label: '编辑中', value: 'EDITING' }, { label: '装柜中', value: 'LOADING' }, { label: '已完成', value: 'COMPLETED' }], props: { 'aria-label': '排柜状态' } },
{ key: 'sailingRange', type: 'daterange', label: '预计开船', width: 260, props: { 'aria-label': '预计开船日期范围' } },
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 230, fixed: 'left', slotName: 'actions' },
{ key: 'cabinetNo', label: '柜次', minWidth: 180, slotName: 'cabinet' },
{ key: 'containerNo', label: '集装箱号', minWidth: 150, slotName: 'containerNo' },
{ key: 'planStatus', label: '状态', width: 110, slotName: 'status' },
{ key: 'warehouseName', prop: 'warehouseName', label: '装柜仓库', minWidth: 160 },
{ key: 'route', label: '航线', minWidth: 220, slotName: 'route' },
{ key: 'containerType', prop: 'containerType', label: '柜型', width: 100 },
{ key: 'capacity', label: '容量上限', minWidth: 150, slotName: 'capacity' },
{ key: 'vessel', label: '船名/航次', minWidth: 180, slotName: 'vessel' },
{ key: 'estimatedSailingAt', label: '预计开船', minWidth: 160, slotName: 'estimatedSailingAt' },
]
function optional(value: string | null) { return value?.trim() || null }
function statusText(status: ContainerPlanStatus) { return ({ EDITING: '编辑中', LOADING: '装柜中', COMPLETED: '已完成' })[status] }
function formatTime(value: string | null | undefined) { return value?.replace('T', ' ').slice(0, 16) || '—' }
function replaceForm(source: ContainerBookingResource | null) {
Object.assign(form, emptyForm(), source || {})
}
function payload(): ContainerSaveRequest {
const selectedWarehouse = warehouses.value.find((warehouse) => String(warehouse.id) === form.warehouseId)
return {
containerNo: optional(form.containerNo), cabinetNo: form.cabinetNo.trim(),
warehouseId: form.warehouseId,
warehouseName: selectedWarehouse?.warehouseName || optional(form.warehouseName),
billOfLadingNo: optional(form.billOfLadingNo), vesselName: optional(form.vesselName), voyageNo: optional(form.voyageNo),
containerType: form.containerType,
shippingCompanyId: optional(form.shippingCompanyId), shippingCompanyName: optional(form.shippingCompanyName),
departurePortCode: optional(form.departurePortCode), departurePortName: optional(form.departurePortName),
destinationPortCode: optional(form.destinationPortCode), destinationPortName: optional(form.destinationPortName),
destinationWarehouseCode: optional(form.destinationWarehouseCode), destinationWarehouseName: optional(form.destinationWarehouseName),
capacityCbm: form.capacityCbm, maxWeightKg: form.maxWeightKg, terminalTruckingCompany: optional(form.terminalTruckingCompany),
loadingAt: form.loadingAt, estimatedSailingAt: form.estimatedSailingAt, actualSailingAt: form.actualSailingAt,
estimatedPortArrivalAt: form.estimatedPortArrivalAt, actualPortArrivalAt: form.actualPortArrivalAt,
estimatedWarehouseArrivalAt: form.estimatedWarehouseArrivalAt, actualWarehouseArrivalAt: form.actualWarehouseArrivalAt,
}
}
async function loadWarehouses() {
warehouseLoading.value = true; warehouseError.value = null
try { warehouses.value = await listAllPlatformWarehouses({ warehouseType: 'DOMESTIC', status: 'ACTIVE' }) }
catch (error) { warehouseError.value = getApiErrorDetails(error, '装柜仓库加载失败') }
finally { warehouseLoading.value = false }
}
async function loadRows() {
const sequence = ++listSequence
loading.value = true; loadError.value = null
const [from, to] = applied.value.sailingRange || []
try {
const result = await listContainers({
page: page.value, size: pageSize.value, keyword: applied.value.keyword.trim() || undefined,
warehouseId: applied.value.warehouseId || undefined, containerType: applied.value.containerType || undefined,
status: applied.value.status, estimatedSailingFrom: from ? `${from}T00:00:00` : undefined,
estimatedSailingTo: to ? `${to}T23:59:59` : undefined,
})
if (sequence !== listSequence) return
rows.value = result.records; total.value = result.total
} catch (error) {
if (sequence !== listSequence) return
rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '货柜列表加载失败,请稍后重试')
} finally { if (sequence === listSequence) loading.value = false }
}
function search() { applied.value = { ...filters.value, sailingRange: [...filters.value.sailingRange] }; page.value = 1; void loadRows() }
async function openDetail(source: object) {
const row = source as ContainerBookingResource
detailTarget.value = row
detailOpen.value = true; detail.value = null; detailError.value = null; detailLoading.value = true
const sequence = ++detailSequence
try { const result = await getContainer(row.id); if (sequence === detailSequence) detail.value = result }
catch (error) { if (sequence === detailSequence) detailError.value = getApiErrorDetails(error, '货柜详情加载失败') }
finally { if (sequence === detailSequence) detailLoading.value = false }
}
function openCreate() { detail.value = null; editorError.value = null; replaceForm(null); editorOpen.value = true }
async function openEdit(source: object) {
const row = source as ContainerBookingResource
editorError.value = null
try { detail.value = await getContainer(row.id); replaceForm(detail.value); editorOpen.value = true }
catch (error) { ElMessage.error(getApiErrorDetails(error, '货柜详情加载失败').message) }
}
async function save() {
if (!(await formRef.value?.validate().catch(() => false))) return
saving.value = true
editorError.value = null
try {
if (!form.id) await createContainer(payload())
else if (transportOnly.value) await updateContainerTransport(form.id, form.version, { containerNo: optional(form.containerNo), voyageNo: optional(form.voyageNo) })
else await updateContainer(form.id, form.version, payload())
ElMessage.success(form.id ? '货柜已更新' : '货柜已新增'); editorOpen.value = false; await loadRows()
} catch (error) {
const details = getApiErrorDetails(error, '保存失败')
editorError.value = { ...details, message: details.code === 'A0409' ? '数据已被其他人修改,请刷新最新数据后重试' : details.message }
ElMessage.error(details.code === 'A0409' ? '数据已被其他人修改,请刷新列表后重试' : details.message)
} finally { saving.value = false }
}
function goWorkbench(source: object) { const row = source as ContainerBookingResource; void router.push({ name: 'smart-loading-workbench', params: { containerId: row.id } }) }
watch([page, pageSize], ([nextPage], [previousPage]) => { if (nextPage !== previousPage || page.value > 0) void loadRows() })
onMounted(() => { void Promise.all([loadWarehouses(), loadRows()]) })
</script>
<template>
<section class="container-page">
<header class="page-header"><div><h1>货柜与智能排柜</h1><p>维护平台货柜资料,从柜次进入智能排柜并推送预装清单。</p></div><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增货柜</el-button></header>
<LoadErrorState v-if="warehouseError" title="装柜仓库加载失败" :message="warehouseError.message" compact @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="货柜筛选" :fields="queryFields" preference-key="container-loading-query" @search="search" @reset="search" />
<LoadErrorState v-if="loadError" title="货柜列表加载失败" :message="loadError.message" :loading="loading" @retry="loadRows"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<DataTable v-else v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" preference-key="container-loading-table" empty-text="暂无货柜">
<template #toolbar-start><div><h2>货柜列表</h2><p>共 {{ total }} 个柜次</p></div></template>
<template #toolbar-end><el-button :loading="loading" aria-label="刷新货柜列表" title="刷新" @click="loadRows"><RefreshCcw :size="16" /></el-button></template>
<template #actions="{ row }"><div class="row-actions"><el-button link type="primary" :aria-label="`查看 ${row.cabinetNo}`" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="canEdit && row.planStatus !== 'COMPLETED'" link type="primary" @click="openEdit(row)"><Pencil :size="15" />编辑</el-button><el-button v-if="canSmartLoading && row.planStatus === 'EDITING'" link type="primary" @click="goWorkbench(row)"><Sparkles :size="15" />排柜</el-button></div></template>
<template #cabinet="{ row }"><strong>{{ row.cabinetNo }}</strong><small>{{ row.bookingNo }}</small></template>
<template #containerNo="{ row }">{{ row.containerNo || '待录入' }}</template>
<template #status="{ row }"><el-tag :type="row.planStatus === 'COMPLETED' ? 'success' : row.planStatus === 'LOADING' ? 'warning' : 'info'">{{ statusText(row.planStatus) }}</el-tag></template>
<template #route="{ row }">{{ row.departurePortName || row.departurePortCode || '—' }} → {{ row.destinationPortName || row.destinationPortCode || '—' }}</template>
<template #capacity="{ row }">{{ row.capacityCbm ? `${row.capacityCbm} m³` : '—' }} / {{ row.maxWeightKg ? `${row.maxWeightKg} kg` : '—' }}</template>
<template #vessel="{ row }">{{ [row.vesselName, row.voyageNo].filter(Boolean).join(' / ') || '—' }}</template>
<template #estimatedSailingAt="{ row }">{{ formatTime(row.estimatedSailingAt) }}</template>
</DataTable>
<AppDrawer v-model="detailOpen" title="货柜详情" size="min(760px, 94vw)" :loading="detailLoading" :show-confirm="false" cancel-text="关闭">
<LoadErrorState v-if="detailError" title="货柜详情加载失败" :message="detailError.message" @retry="detailTarget && openDetail(detailTarget)"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<el-descriptions v-else-if="detail" :column="2" border class="details"><el-descriptions-item label="柜次号">{{ detail.cabinetNo }}</el-descriptions-item><el-descriptions-item label="集装箱号">{{ detail.containerNo || '—' }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusText(detail.planStatus) }}</el-descriptions-item><el-descriptions-item label="柜型">{{ detail.containerType }}</el-descriptions-item><el-descriptions-item label="装柜仓库">{{ detail.warehouseName || '—' }}</el-descriptions-item><el-descriptions-item label="目的仓">{{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}</el-descriptions-item><el-descriptions-item label="船公司">{{ detail.shippingCompanyName || '—' }}</el-descriptions-item><el-descriptions-item label="船名/航次">{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</el-descriptions-item><el-descriptions-item label="提单号">{{ detail.billOfLadingNo || '—' }}</el-descriptions-item><el-descriptions-item label="拖车公司">{{ detail.terminalTruckingCompany || '—' }}</el-descriptions-item><el-descriptions-item label="预计开船">{{ formatTime(detail.estimatedSailingAt) }}</el-descriptions-item><el-descriptions-item label="预计到港">{{ formatTime(detail.estimatedPortArrivalAt) }}</el-descriptions-item></el-descriptions>
</AppDrawer>
<AppDrawer v-model="editorOpen" :title="editorTitle" size="min(860px, 96vw)" :confirm-loading="saving" confirm-text="保存" @confirm="save">
<LoadErrorState v-if="editorError" title="货柜保存失败" :message="editorError.message" compact :retry-text="form.id ? '刷新最新数据' : '重试保存'" @retry="form.id ? openEdit({ id: form.id }) : save()"><template #details><ApiErrorMeta :details="editorError" /></template></LoadErrorState>
<el-alert v-if="transportOnly" title="装柜中仅允许修改集装箱号和航次,其他字段已锁定。" type="warning" :closable="false" />
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="form-grid">
<el-form-item label="柜次号" prop="cabinetNo"><el-input v-model="form.cabinetNo" maxlength="64" :disabled="transportOnly" /></el-form-item>
<el-form-item label="集装箱号"><el-input v-model="form.containerNo" maxlength="64" placeholder="开始装柜前必须填写" /></el-form-item>
<el-form-item label="装柜仓库" prop="warehouseId"><el-select v-model="form.warehouseId" filterable :disabled="transportOnly"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="String(warehouse.id)" /></el-select></el-form-item>
<el-form-item label="柜型" prop="containerType"><el-select v-model="form.containerType" :disabled="transportOnly"><el-option v-for="value in ['20GP','40GP','40HQ','45HQ']" :key="value" :label="value" :value="value" /></el-select></el-form-item>
<el-form-item label="容量上限(m³)"><el-input-number v-model="form.capacityCbm" :min="0.001" :precision="3" controls-position="right" :disabled="transportOnly" /></el-form-item>
<el-form-item label="重量上限(kg)"><el-input-number v-model="form.maxWeightKg" :min="0.001" :precision="3" controls-position="right" :disabled="transportOnly" /></el-form-item>
<el-form-item label="提单号"><el-input v-model="form.billOfLadingNo" maxlength="128" :disabled="transportOnly" /></el-form-item>
<el-form-item label="船公司"><el-input v-model="form.shippingCompanyName" maxlength="128" :disabled="transportOnly" /></el-form-item>
<el-form-item label="船名"><el-input v-model="form.vesselName" maxlength="128" :disabled="transportOnly" /></el-form-item>
<el-form-item label="航次"><el-input v-model="form.voyageNo" maxlength="64" /></el-form-item>
<el-form-item label="起运港代码"><el-input v-model="form.departurePortCode" maxlength="32" :disabled="transportOnly" /></el-form-item>
<el-form-item label="起运港名称"><el-input v-model="form.departurePortName" maxlength="128" :disabled="transportOnly" /></el-form-item>
<el-form-item label="目的港代码"><el-input v-model="form.destinationPortCode" maxlength="32" :disabled="transportOnly" /></el-form-item>
<el-form-item label="目的港名称"><el-input v-model="form.destinationPortName" maxlength="128" :disabled="transportOnly" /></el-form-item>
<el-form-item label="目的仓代码"><el-input v-model="form.destinationWarehouseCode" maxlength="64" :disabled="transportOnly" /></el-form-item>
<el-form-item label="目的仓名称"><el-input v-model="form.destinationWarehouseName" maxlength="128" :disabled="transportOnly" /></el-form-item>
<el-form-item label="拖车公司"><el-input v-model="form.terminalTruckingCompany" maxlength="128" :disabled="transportOnly" /></el-form-item>
<el-form-item label="计划装柜时间"><el-date-picker v-model="form.loadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" :disabled="transportOnly" /></el-form-item>
<el-form-item label="预计开船时间"><el-date-picker v-model="form.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" :disabled="transportOnly" /></el-form-item>
<el-form-item label="预计到港时间"><el-date-picker v-model="form.estimatedPortArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" :disabled="transportOnly" /></el-form-item>
</el-form>
</AppDrawer>
</section>
</template>
<style scoped>
.container-page { display: grid; gap: 16px; min-width: 0; }.page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }.page-header h1,.page-header p,.container-page h2 { margin: 0; }.page-header p,.container-page small,.container-page h2 + p { display: block; margin-top: 4px; color: var(--cc-muted); }.row-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.form-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 0 16px; margin-top: 16px; }.form-grid :deep(.el-select),.form-grid :deep(.el-date-editor),.form-grid :deep(.el-input-number) { width: 100%; }.details :deep(.el-descriptions__label) { width: 120px; }@media(max-width:720px){.page-header { flex-direction: column; }.page-header .el-button { width: 100%; min-height: 44px; }.form-grid { grid-template-columns: 1fr; }.details :deep(.el-descriptions__body table),.details :deep(tbody),.details :deep(tr),.details :deep(td){display:block;width:100%!important}.row-actions { min-width: 180px; }}
</style>
@@ -0,0 +1,45 @@
import ElementPlus from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { createMemoryHistory, createRouter } from 'vue-router'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import SmartLoadingWorkbenchView from './SmartLoadingWorkbenchView.vue'
import { getContainer, getSmartLoadingPlanByBooking, listSmartLoadingCandidates } from '../api/platformContainerLoading'
vi.mock('../api/platformContainerLoading', () => ({
getContainer: vi.fn(), getSmartLoadingPlanByBooking: vi.fn(), listSmartLoadingCandidates: vi.fn(),
createSmartLoadingPlan: vi.fn(), assignSmartLoadingItem: vi.fn(), recommendSmartLoading: vi.fn(),
adjustSmartLoadingItem: vi.fn(), removeSmartLoadingItem: vi.fn(), pushSmartLoadingPreload: vi.fn(),
}))
const container = {
id: '9007199254740993', planId: '9007199254740994', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1',
warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', planStatus: 'EDITING' as const, version: 1,
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null,
destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', capacityCbm: 68, maxWeightKg: 28000,
terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null,
estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null,
}
describe('SmartLoadingWorkbenchView', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.mocked(getContainer).mockResolvedValue(container)
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({ id: '99', bookingId: container.id, planNo: 'LP-99', status: 'EDITING', containerNo: 'MSCU-1', voyageNo: null, version: 2, items: [] })
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ accountId: '1', inboundId: '2', inboundDetailId: '3', orderId: '4', orderNo: 'ORD-1', inboundCode: 'IN-1', warehouseId: '8', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '示例企业', goodsName: '服装', availablePackageCount: 12, unitWeightKg: 10, unitVolumeCbm: 0.2 }])
})
it('restores the real draft and explains candidate capacity', async () => {
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/container-loading/:containerId/smart-loading', component: SmartLoadingWorkbenchView }] })
await router.push(`/container-loading/${container.id}/smart-loading`); await router.isReady()
const wrapper = mount(SmartLoadingWorkbenchView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
expect(getContainer).toHaveBeenCalledWith(container.id)
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id)
expect(listSmartLoadingCandidates).toHaveBeenCalledWith('8')
expect(wrapper.text()).toContain('LP-99')
expect(wrapper.text()).toContain('ORD-1')
expect(wrapper.text()).toContain('按 110% 上限')
})
})
@@ -0,0 +1,236 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Plus, RefreshCcw, Sparkles, Trash2, Upload } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AdminPage from '@/shared/components/AdminPage.vue'
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 PageToolbar from '@/shared/components/PageToolbar.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import {
adjustSmartLoadingItem, assignSmartLoadingItem, createSmartLoadingPlan, getContainer,
getSmartLoadingPlanByBooking, listSmartLoadingCandidates, pushSmartLoadingPreload,
recommendSmartLoading, removeSmartLoadingItem,
} from '../api/platformContainerLoading'
import type { ContainerBookingResource, SmartLoadingCandidate, SmartLoadingItem, SmartLoadingPlan } from '../types'
const route = useRoute()
const router = useRouter()
const access = useAccessPreviewStore()
const containerId = computed(() => String(route.params.containerId || ''))
const container = ref<ContainerBookingResource | null>(null)
const plan = ref<SmartLoadingPlan | null>(null)
const candidates = ref<SmartLoadingCandidate[]>([])
const loading = ref(false)
const mutating = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const mutationError = ref<ApiErrorDetails | null>(null)
const noDraft = ref(false)
const assignOpen = ref(false)
const selectedCandidate = ref<SmartLoadingCandidate | null>(null)
const assignForm = reactive({ packageCount: 1 })
const quantityDrafts = reactive<Record<string, number>>({})
const canCreate = computed(() => access.can('platform:firstleg:smart-loading:create'))
const canAssign = computed(() => access.can('platform:firstleg:smart-loading:assign'))
const canDelete = computed(() => access.can('platform:firstleg:smart-loading:delete'))
const canPush = computed(() => access.can('platform:firstleg:smart-loading:push'))
const editable = computed(() => plan.value?.status === 'EDITING')
const assignedDetailIds = computed(() => new Set((plan.value?.items || []).map((item) => item.inboundDetailId)))
const totalVolume = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.volumeCbm || 0), 0))
const totalWeight = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.grossWeightKg || 0), 0))
const volumeLimit = computed(() => Number(container.value?.capacityCbm || 0) * 1.1)
const weightLimit = computed(() => Number(container.value?.maxWeightKg || 0) * 1.1)
const volumePercent = computed(() => volumeLimit.value ? totalVolume.value / volumeLimit.value * 100 : 0)
const weightPercent = computed(() => weightLimit.value ? totalWeight.value / weightLimit.value * 100 : 0)
const itemColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' },
{ key: 'sequence', prop: 'loadSequence', label: '顺序', width: 75 },
{ key: 'order', label: '订单/入库单', minWidth: 190, slotName: 'order' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150 },
{ key: 'goods', label: '货物', minWidth: 190, slotName: 'goods' },
{ key: 'packageCount', label: '分配箱数', width: 150, slotName: 'packageCount' },
{ key: 'volumeCbm', label: '体积', width: 120, slotName: 'volume' },
{ key: 'grossWeightKg', label: '毛重', width: 130, slotName: 'weight' },
{ key: 'destination', label: '目的仓', minWidth: 160, slotName: 'destination' },
]
const candidateColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
{ key: 'order', label: '订单/入库单', minWidth: 190, slotName: 'order' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150 },
{ key: 'goodsName', prop: 'goodsName', label: '货物', minWidth: 180 },
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '可用箱数', width: 110 },
{ key: 'unitVolumeCbm', label: '单箱体积', width: 120, slotName: 'unitVolume' },
{ key: 'unitWeightKg', label: '单箱重量', width: 120, slotName: 'unitWeight' },
{ key: 'route', label: '目的仓匹配', minWidth: 170, slotName: 'route' },
]
function formatNumber(value: number, digits = 3) { return Number.isFinite(value) ? value.toFixed(digits) : '0.000' }
function candidateRow(source: object) { return source as SmartLoadingCandidate }
function itemRow(source: object) { return source as SmartLoadingItem }
function routeMatches(source: object) {
const candidate = candidateRow(source)
const target = container.value?.destinationWarehouseCode
return !target || target === candidate.destinationWarehouseCode
}
function explainCandidate(source: object) {
const candidate = candidateRow(source)
if (!routeMatches(candidate)) return `目的仓不一致(候选:${candidate.destinationWarehouseCode || '未设置'})`
const volume = Number(candidate.unitVolumeCbm || 0)
const weight = Number(candidate.unitWeightKg || 0)
const volumeRoom = volumeLimit.value ? Math.floor(Math.max(volumeLimit.value - totalVolume.value, 0) / Math.max(volume, 0.000001)) : candidate.availablePackageCount
const weightRoom = weightLimit.value ? Math.floor(Math.max(weightLimit.value - totalWeight.value, 0) / Math.max(weight, 0.000001)) : candidate.availablePackageCount
return `按 110% 上限,当前最多可装 ${Math.max(Math.min(candidate.availablePackageCount, volumeRoom, weightRoom), 0)} 箱`
}
function syncDrafts() { for (const item of plan.value?.items || []) quantityDrafts[item.id] = item.packageCount }
function acceptPlan(next: SmartLoadingPlan) { plan.value = next; noDraft.value = false; syncDrafts() }
async function load() {
loading.value = true; loadError.value = null; noDraft.value = false
try {
container.value = await getContainer(containerId.value)
try { acceptPlan(await getSmartLoadingPlanByBooking(containerId.value)) }
catch (error) {
const detail = getApiErrorDetails(error)
if (detail.code === 'A0404') { plan.value = null; noDraft.value = true }
else throw error
}
candidates.value = await listSmartLoadingCandidates(container.value.warehouseId)
} catch (error) { loadError.value = getApiErrorDetails(error, '智能排柜工作台加载失败') }
finally { loading.value = false }
}
async function createPlan() {
mutating.value = true
mutationError.value = null
try { acceptPlan(await createSmartLoadingPlan(containerId.value, null)); ElMessage.success('排柜计划已创建') }
catch (error) { handleMutationError(error, '创建排柜计划失败') }
finally { mutating.value = false }
}
function openAssign(source: object) { const candidate = candidateRow(source); selectedCandidate.value = candidate; assignForm.packageCount = Math.min(candidate.availablePackageCount, 1); assignOpen.value = true }
async function assign() {
if (!plan.value || !selectedCandidate.value || assignForm.packageCount < 1) return
mutating.value = true
mutationError.value = null
try {
acceptPlan(await assignSmartLoadingItem(plan.value.id, { inboundId: selectedCandidate.value.inboundId, inboundDetailId: selectedCandidate.value.inboundDetailId, packageCount: assignForm.packageCount, expectedVersion: plan.value.version }))
assignOpen.value = false; ElMessage.success('货物已分配')
} catch (error) { handleMutationError(error, '分配失败') }
finally { mutating.value = false }
}
async function recommend() {
if (!plan.value) return
mutating.value = true
mutationError.value = null
try { acceptPlan(await recommendSmartLoading(plan.value.id, plan.value.version)); ElMessage.success('智能建议已生成,请人工核对后再推送') }
catch (error) { handleMutationError(error, '智能推荐失败') }
finally { mutating.value = false }
}
async function adjust(source: object) {
const item = itemRow(source)
if (!plan.value) return
const count = Number(quantityDrafts[item.id])
if (!Number.isInteger(count) || count < 1) { ElMessage.warning('分配箱数必须为正整数'); return }
mutating.value = true
mutationError.value = null
try { acceptPlan(await adjustSmartLoadingItem(plan.value.id, item.id, count, plan.value.version, item.version)); ElMessage.success('箱数已调整') }
catch (error) { handleMutationError(error, '调整失败') }
finally { mutating.value = false }
}
async function remove(source: object) {
const item = itemRow(source)
if (!plan.value) return
await ElMessageBox.confirm(`确认从计划移除“${item.productNameZh || item.orderNo || item.id}”吗?`, '移除装柜明细', { type: 'warning', confirmButtonText: '移除', cancelButtonText: '取消' }).catch(() => false).then(async (confirmed) => {
if (!confirmed || !plan.value) return
mutating.value = true
mutationError.value = null
try { acceptPlan(await removeSmartLoadingItem(plan.value.id, item.id, plan.value.version, item.version)); ElMessage.success('装柜明细已移除') }
catch (error) { handleMutationError(error, '移除失败') }
finally { mutating.value = false }
})
}
async function pushPreload() {
if (!plan.value) return
const confirmed = await ElMessageBox.confirm('推送后计划进入装柜流程,不能继续调整排柜明细。确认推送预装清单?', '推送预装清单', { type: 'warning', confirmButtonText: '确认推送', cancelButtonText: '取消' }).catch(() => false)
if (!confirmed || !plan.value) return
mutating.value = true
mutationError.value = null
try { acceptPlan(await pushSmartLoadingPreload(plan.value.id, plan.value.version)); ElMessage.success('已推送预装清单') }
catch (error) { handleMutationError(error, '推送失败') }
finally { mutating.value = false }
}
function handleMutationError(error: unknown, fallback: string) {
const details = getApiErrorDetails(error, fallback)
mutationError.value = { ...details, message: details.code === 'A0409' ? '计划已被其他人修改,请刷新最新计划后重试' : details.message }
ElMessage.error(mutationError.value.message)
}
onMounted(load)
</script>
<template>
<AdminPage>
<section class="business-page workbench">
<PageToolbar title="智能排柜" :description="`${container?.cabinetNo || '正在加载'} · ${container?.containerNo || '待录入集装箱号'}`">
<el-button text aria-label="返回货柜列表" @click="router.back()"><ArrowLeft :size="18" /></el-button>
<template #actions>
<el-button :loading="loading" title="刷新" aria-label="刷新智能排柜" @click="load"><RefreshCcw :size="16" /></el-button>
<el-button v-if="canAssign && editable" type="primary" plain :loading="mutating" @click="recommend"><Sparkles :size="16" />智能推荐</el-button>
<el-button v-if="canPush && editable" type="primary" :disabled="!plan?.items.length" :loading="mutating" @click="pushPreload"><Upload :size="16" />推送预装</el-button>
</template>
</PageToolbar>
<LoadErrorState v-if="loadError" title="智能排柜加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<template v-else>
<LoadErrorState v-if="mutationError" title="排柜操作失败" :message="mutationError.message" compact retry-text="刷新最新计划" @retry="load"><template #details><ApiErrorMeta :details="mutationError" /></template></LoadErrorState>
<section v-if="container" class="summary-band"><div><span>柜型</span><strong>{{ container.containerType }}</strong></div><div><span>装柜仓库</span><strong>{{ container.warehouseName || '—' }}</strong></div><div><span>目的仓</span><strong>{{ container.destinationWarehouseName || container.destinationWarehouseCode || '不限' }}</strong></div><div><span>计划状态</span><strong>{{ plan?.status === 'LOADING' ? '已推送预装' : plan ? '编辑中' : '未创建' }}</strong></div></section>
<section v-if="container" class="capacity-grid"><div><div class="meter-title"><span>体积占用(含 110% 上限)</span><strong>{{ formatNumber(totalVolume) }} / {{ formatNumber(volumeLimit) }} m³</strong></div><el-progress :percentage="Math.min(volumePercent,100)" :status="volumePercent > 100 ? 'exception' : undefined" /></div><div><div class="meter-title"><span>重量占用(含 110% 上限)</span><strong>{{ formatNumber(totalWeight,1) }} / {{ formatNumber(weightLimit,1) }} kg</strong></div><el-progress :percentage="Math.min(weightPercent,100)" :status="weightPercent > 100 ? 'exception' : undefined" /></div></section>
<el-empty v-if="noDraft" description="该货柜尚未创建排柜计划"><el-button v-if="canCreate" type="primary" :loading="mutating" @click="createPlan"><Plus :size="16" />创建排柜计划</el-button></el-empty>
<template v-else-if="plan">
<DataTable :data="plan.items" :columns="itemColumns" :loading="loading || mutating" preference-key="smart-loading-items" empty-text="暂无已分配货物">
<template #toolbar-start><div><h2>装柜方案</h2><p>计划 {{ plan.planNo }} · 版本 {{ plan.version }};智能建议必须经人工核对。</p></div></template>
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canAssign && editable" link type="primary" :disabled="quantityDrafts[row.id] === row.packageCount" @click="adjust(row)">保存箱数</el-button><el-button v-if="canDelete && editable" link type="danger" @click="remove(row)"><Trash2 :size="15" />移除</el-button></div></template>
<template #order="{ row }"><strong>{{ row.orderNo || '—' }}</strong><small>{{ row.inboundCode || '—' }}</small></template>
<template #goods="{ row }"><strong>{{ row.productNameZh || '未命名货物' }}</strong><small>{{ row.skuCode || '无 SKU' }}</small></template>
<template #packageCount="{ row }"><el-input-number v-model="quantityDrafts[row.id]" :min="1" :step="1" :disabled="!canAssign || !editable" controls-position="right" /></template>
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template><template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0),1) }} kg</template>
<template #destination="{ row }">{{ row.destinationWarehouseName || row.destinationWarehouseCode || '—' }}</template>
</DataTable>
<DataTable :data="candidates" :columns="candidateColumns" :loading="loading || mutating" preference-key="smart-loading-candidates" empty-text="当前仓库暂无可排柜货物">
<template #toolbar-start><div><h2>候选货物</h2><p>系统基于仓库、目的仓、可用箱数及 110% 容量上限校验。</p></div></template>
<template #actions="{ row }"><el-button v-if="canAssign && editable" link type="primary" :disabled="!routeMatches(row) || assignedDetailIds.has(row.inboundDetailId) || row.availablePackageCount < 1" @click="openAssign(row)"><Plus :size="15" />分配</el-button></template>
<template #order="{ row }"><strong>{{ row.orderNo || '—' }}</strong><small>{{ row.inboundCode || '—' }}</small></template>
<template #unitVolume="{ row }">{{ formatNumber(Number(row.unitVolumeCbm || 0)) }} m³</template><template #unitWeight="{ row }">{{ formatNumber(Number(row.unitWeightKg || 0),1) }} kg</template>
<template #route="{ row }"><el-tag :type="routeMatches(row) ? 'success' : 'danger'">{{ routeMatches(row) ? '匹配' : '冲突' }}</el-tag><small>{{ explainCandidate(row) }}</small></template>
</DataTable>
</template>
</template>
<AppDialog v-model="assignOpen" title="分配候选货物" :confirm-loading="mutating" confirm-text="确认分配" @confirm="assign"><div v-if="selectedCandidate" class="assign-dialog"><p><strong>{{ selectedCandidate.goodsName || '未命名货物' }}</strong></p><p>{{ selectedCandidate.orderNo || '—' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱</p><el-form label-position="top"><el-form-item label="本次分配箱数" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" /></el-form-item></el-form><el-alert :title="explainCandidate(selectedCandidate)" type="info" :closable="false" /></div></AppDialog>
</section>
</AdminPage>
</template>
<style scoped>
.workbench { display: grid; gap: 16px; min-width: 0; }
.page-header,.title-row,.header-actions,.row-actions,.meter-title { display: flex; align-items: center; }
.page-header { align-items: flex-start; justify-content: space-between; gap: 16px; }
.title-row { gap: 8px; }
.title-row h1,.title-row p,.workbench h2,.workbench h2 + p { margin: 0; }
.title-row h1 { font-size: 20px; line-height: 1.35; }
.title-row p,.workbench h2 + p,.workbench small { display: block; margin-top: 4px; color: var(--cc-muted); }
.header-actions,.row-actions { flex-wrap: wrap; gap: 8px; }
.header-actions :deep(.el-button + .el-button),.row-actions :deep(.el-button + .el-button) { margin-left: 0; }
.summary-band { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 1px; border: 1px solid var(--cc-border); background: var(--cc-border); }
.summary-band>div { display: grid; gap: 5px; padding: 14px; background: #fff; }
.summary-band span { color: var(--cc-muted); font-size: 12px; }
.capacity-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 16px; padding: 16px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
.meter-title { justify-content: space-between; gap: 12px; margin-bottom: 10px; font-size: 13px; }
.row-actions { min-width: 150px; }
.workbench :deep(.el-input-number) { width: 130px; }
.assign-dialog p { margin: 0 0 10px; }
.assign-dialog :deep(.el-input-number) { width: 100%; }
@media (max-width: 760px) { .page-header,.header-actions { width: 100%; flex-direction: column; align-items: stretch; } .summary-band,.capacity-grid { grid-template-columns: 1fr; } .meter-title { align-items: flex-start; flex-direction: column; } .header-actions .el-button { min-height: 44px; margin-left: 0; } }
</style>
+16
View File
@@ -0,0 +1,16 @@
import type { RouteRecordRaw } from 'vue-router'
export const containerLoadingRoutes = [
{
path: 'container-loading',
name: 'container-loading',
component: () => import('./pages/ContainersView.vue'),
meta: { title: '货柜与排柜', description: '维护货柜资料并进入智能排柜工作台', permission: 'platform:firstleg:container:list' },
},
{
path: 'container-loading/:containerId/smart-loading',
name: 'smart-loading-workbench',
component: () => import('./pages/SmartLoadingWorkbenchView.vue'),
meta: { title: '智能排柜', description: '生成、调整并确认可解释的装柜计划', permission: 'platform:firstleg:smart-loading:list' },
},
] satisfies RouteRecordRaw[]
+139
View File
@@ -0,0 +1,139 @@
export type ContainerPlanStatus = 'EDITING' | 'LOADING' | 'COMPLETED'
export interface ContainerBookingResource {
id: string
planId: string | null
bookingNo: string
containerNo: string | null
cabinetNo: string
warehouseId: string
warehouseName: string | null
billOfLadingNo: string | null
vesselName: string | null
voyageNo: string | null
containerType: string
shippingCompanyId: string | null
shippingCompanyName: string | null
departurePortCode: string | null
departurePortName: string | null
destinationPortCode: string | null
destinationPortName: string | null
destinationWarehouseCode: string | null
destinationWarehouseName: string | null
capacityCbm: number | null
maxWeightKg: number | null
terminalTruckingCompany: string | null
loadingAt: string | null
estimatedSailingAt: string | null
actualSailingAt: string | null
estimatedPortArrivalAt: string | null
actualPortArrivalAt: string | null
estimatedWarehouseArrivalAt: string | null
actualWarehouseArrivalAt: string | null
planStatus: ContainerPlanStatus
version: number
createdAt?: string | null
updatedAt?: string | null
}
export interface ContainerPage {
records: ContainerBookingResource[]
page: number
size: number
total: number
pages: number
}
export interface ContainerQuery {
page: number
size: number
keyword?: string
containerNo?: string
cabinetNo?: string
warehouseId?: string
containerType?: string
status?: ContainerPlanStatus | ''
estimatedSailingFrom?: string
estimatedSailingTo?: string
}
export interface ContainerSaveRequest {
containerNo: string | null
cabinetNo: string
warehouseId: string
warehouseName: string | null
billOfLadingNo: string | null
vesselName: string | null
voyageNo: string | null
containerType: string
shippingCompanyId: string | null
shippingCompanyName: string | null
departurePortCode: string | null
departurePortName: string | null
destinationPortCode: string | null
destinationPortName: string | null
destinationWarehouseCode: string | null
destinationWarehouseName: string | null
capacityCbm: number | null
maxWeightKg: number | null
terminalTruckingCompany: string | null
loadingAt: string | null
estimatedSailingAt: string | null
actualSailingAt: string | null
estimatedPortArrivalAt: string | null
actualPortArrivalAt: string | null
estimatedWarehouseArrivalAt: string | null
actualWarehouseArrivalAt: string | null
}
export interface SmartLoadingCandidate {
accountId: string
inboundId: string
inboundDetailId: string
orderId: string
orderNo: string | null
inboundCode: string | null
warehouseId: string
destinationWarehouseId: string | null
destinationWarehouseCode: string | null
destinationWarehouseName: string | null
customerName: string | null
goodsName: string | null
availablePackageCount: number
unitWeightKg: number | null
unitVolumeCbm: number | null
}
export interface SmartLoadingItem {
id: string
loadingPlanId: string
accountId: string
inboundId: string
inboundDetailId: string
orderId: string
orderNo: string | null
inboundCode: string | null
customerName: string | null
destinationWarehouseId: string | null
destinationWarehouseCode: string | null
destinationWarehouseName: string | null
skuCode: string | null
productNameZh: string | null
packageCount: number
productQuantity: number | null
grossWeightKg: number | null
volumeCbm: number | null
loadSequence: number | null
version: number
}
export interface SmartLoadingPlan {
id: string
planNo: string
status: ContainerPlanStatus
bookingId: string
containerNo: string | null
voyageNo: string | null
version: number
items: SmartLoadingItem[]
}
@@ -0,0 +1,68 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import {
changePlatformFeeItemStatus,
createPlatformFeeItem,
deletePlatformFeeItem,
getPlatformFeeItem,
listPlatformFeeItems,
updatePlatformFeeItem,
type SavePlatformFeeItemRequest,
} from './platformFeeItems'
vi.mock('@/shared/api/http', () => ({
http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn(), delete: vi.fn() },
unwrapApiResponse: <T>(response: { data: T }) => response.data,
}))
const payload: SavePlatformFeeItemRequest = {
category: '仓储成本',
tradeScope: 'INTERNATIONAL',
feeTypeName: '仓库租赁费',
pickupPayable: false,
pickupReceivable: false,
warehousePayable: true,
warehouseReceivable: true,
allocationFee: true,
description: null,
}
describe('platform fee item API', () => {
beforeEach(() => vi.clearAllMocks())
it('sends normalized public list filters without customer context', async () => {
vi.mocked(http.get).mockResolvedValue({
code: '200', message: 'success', data: { records: [], page: '2', size: '20', total: '21', pages: '2' },
})
await expect(listPlatformFeeItems({
category: ' 仓储成本 ', tradeScope: 'INTERNATIONAL',
keyword: ' 租赁 ', status: 'ACTIVE', page: 2, size: 20,
})).resolves.toMatchObject({ page: 2, size: 20, total: 21, pages: 2 })
expect(http.get).toHaveBeenCalledWith('/platform/v1/fee-items', { params: {
category: '仓储成本', tradeScope: 'INTERNATIONAL',
keyword: '租赁', status: 'ACTIVE', page: 2, size: 20,
} })
})
it('uses public detail and mutation contracts', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: { id: '11' } })
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: { id: '11' } })
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: { id: '11' } })
vi.mocked(http.patch).mockResolvedValue({ code: '200', message: 'success', data: null })
vi.mocked(http.delete).mockResolvedValue({ code: '200', message: 'success', data: null })
await getPlatformFeeItem('11')
await createPlatformFeeItem(payload)
await updatePlatformFeeItem('11', payload)
await changePlatformFeeItemStatus('11', 'DISABLED')
await deletePlatformFeeItem('11')
expect(http.get).toHaveBeenCalledWith('/platform/v1/fee-items/11')
expect(http.post).toHaveBeenCalledWith('/platform/v1/fee-items', payload)
expect(http.put).toHaveBeenCalledWith('/platform/v1/fee-items/11', payload)
expect(http.patch).toHaveBeenCalledWith('/platform/v1/fee-items/11/status', { status: 'DISABLED' })
expect(http.delete).toHaveBeenCalledWith('/platform/v1/fee-items/11')
})
})
@@ -0,0 +1,119 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export type FeeItemTradeScope = 'DOMESTIC' | 'INTERNATIONAL'
export type FeeItemStatus = 'ACTIVE' | 'DISABLED'
export interface PlatformFeeItemResource {
id: number | string
category: string
tradeScope: FeeItemTradeScope
feeCode: string | null
feeTypeName: string
pickupPayable: boolean
pickupReceivable: boolean
warehousePayable: boolean
warehouseReceivable: boolean
allocationFee: boolean
description: string | null
status: FeeItemStatus
createdByName?: string | null
updatedByName?: string | null
createdAt?: string | null
updatedAt?: string | null
}
export interface SavePlatformFeeItemRequest {
category: string
tradeScope: FeeItemTradeScope
feeTypeName: string
pickupPayable: boolean
pickupReceivable: boolean
warehousePayable: boolean
warehouseReceivable: boolean
allocationFee: boolean
description: string | null
}
export interface PlatformFeeItemQuery {
category?: string
tradeScope?: FeeItemTradeScope | ''
keyword?: string
status?: FeeItemStatus | ''
page: number
size: number
}
export interface PlatformFeeItemPage {
records: PlatformFeeItemResource[]
page: number
size: number
total: number
pages: number
}
const basePath = '/platform/v1/fee-items'
export async function listPlatformFeeItems(query: PlatformFeeItemQuery) {
const response = await http.get<ApiEnvelope<PlatformFeeItemPage>, ApiEnvelope<PlatformFeeItemPage>>(basePath, {
params: {
category: query.category?.trim() || undefined,
tradeScope: query.tradeScope || undefined,
keyword: query.keyword?.trim() || undefined,
status: query.status || undefined,
page: query.page,
size: query.size,
},
})
const result = unwrapApiResponse(response)
return {
...result,
records: result.records || [],
page: Number(result.page),
size: Number(result.size),
total: Number(result.total),
pages: Number(result.pages),
}
}
export async function getPlatformFeeItem(id: number | string) {
const response = await http.get<ApiEnvelope<PlatformFeeItemResource>, ApiEnvelope<PlatformFeeItemResource>>(
`${basePath}/${id}`,
)
return unwrapApiResponse(response)
}
export async function createPlatformFeeItem(payload: SavePlatformFeeItemRequest) {
const response = await http.post<ApiEnvelope<PlatformFeeItemResource>, ApiEnvelope<PlatformFeeItemResource>>(
basePath,
payload,
)
return unwrapApiResponse(response)
}
export async function updatePlatformFeeItem(id: number | string, payload: SavePlatformFeeItemRequest) {
const response = await http.put<ApiEnvelope<PlatformFeeItemResource>, ApiEnvelope<PlatformFeeItemResource>>(
`${basePath}/${id}`,
payload,
)
return unwrapApiResponse(response)
}
export async function changePlatformFeeItemStatus(id: number | string, status: FeeItemStatus) {
const response = await http.patch<ApiEnvelope<null>, ApiEnvelope<null>>(
`${basePath}/${id}/status`,
{ status },
)
return unwrapApiResponse(response)
}
export async function deletePlatformFeeItem(id: number | string) {
const response = await http.delete<ApiEnvelope<null>, ApiEnvelope<null>>(
`${basePath}/${id}`,
)
return unwrapApiResponse(response)
}
@@ -0,0 +1,82 @@
import ElementPlus from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import { listPlatformFeeItems } from '@/modules/fee-items/api/platformFeeItems'
import FeeItemsView from './FeeItemsView.vue'
vi.mock('@/modules/fee-items/api/platformFeeItems', () => ({
listPlatformFeeItems: vi.fn(),
getPlatformFeeItem: vi.fn(),
createPlatformFeeItem: vi.fn(),
updatePlatformFeeItem: vi.fn(),
changePlatformFeeItemStatus: vi.fn(),
deletePlatformFeeItem: vi.fn(),
}))
class ResizeObserverStub implements ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
function createMemoryStorage(): Storage {
const values = new Map<string, string>()
return {
get length() { return values.size },
clear: () => values.clear(),
getItem: (key) => values.get(key) ?? null,
key: (index) => [...values.keys()][index] ?? null,
removeItem: (key) => values.delete(key),
setItem: (key, value) => values.set(key, value),
}
}
async function mountView(permissions: string[]) {
const pinia = createPinia()
setActivePinia(pinia)
const auth = useAuthStore()
auth.token = 'token'
auth.permissions = permissions
useAccessPreviewStore().initializeAfterLogin()
const wrapper = mount(FeeItemsView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
return wrapper
}
enableAutoUnmount(afterEach)
afterAll(() => vi.unstubAllGlobals())
describe('FeeItemsView public master data and access', () => {
beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
vi.stubGlobal('localStorage', createMemoryStorage())
vi.stubGlobal('sessionStorage', createMemoryStorage())
document.body.innerHTML = ''
vi.mocked(listPlatformFeeItems).mockReset().mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 })
})
it('loads public fee items immediately without customer selection', async () => {
const wrapper = await mountView(['firstleg:fee-item:view', 'firstleg:fee-item:create'])
expect(wrapper.find('.query-toolbar').exists()).toBe(true)
expect(wrapper.find('.data-table').exists()).toBe(true)
expect(wrapper.text()).toContain('平台公共主数据')
expect(listPlatformFeeItems).toHaveBeenCalledWith(expect.objectContaining({ page: 1, size: 20 }))
const vm = wrapper.vm as unknown as { applyFilters(model: Record<string, string>): void }
vm.applyFilters({ keyword: '', category: '', tradeScope: '', status: '' })
await flushPromises()
expect(listPlatformFeeItems).toHaveBeenCalledWith(expect.objectContaining({ page: 1, size: 20 }))
})
it('renders an explicit no-access state without loading customers or fee items', async () => {
const wrapper = await mountView([])
expect(wrapper.text()).toContain('无权查看费用项目')
expect(listPlatformFeeItems).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,472 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { Pencil, Plus, Power, Trash2 } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import {
changePlatformFeeItemStatus,
createPlatformFeeItem,
deletePlatformFeeItem,
getPlatformFeeItem,
listPlatformFeeItems,
updatePlatformFeeItem,
type FeeItemStatus,
type FeeItemTradeScope,
type PlatformFeeItemResource,
type SavePlatformFeeItemRequest,
} from '@/modules/fee-items/api/platformFeeItems'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
type FeeItemFilters = QueryModel & {
keyword: string
category: string
tradeScope: FeeItemTradeScope | ''
status: FeeItemStatus | ''
}
type FeeItemForm = SavePlatformFeeItemRequest & { id: string }
const access = useAccessPreviewStore()
const rows = ref<PlatformFeeItemResource[]>([])
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const filters = ref<FeeItemFilters>(emptyFilters())
const appliedFilters = ref<FeeItemFilters>(emptyFilters())
const editorOpen = ref(false)
const editorLoading = ref(false)
const saving = ref(false)
const formRef = ref<FormInstance>()
const statusLoadingIds = ref(new Set<string>())
const statusOpen = ref(false)
const statusTarget = ref<PlatformFeeItemResource | null>(null)
const statusSaving = ref(false)
const deleteOpen = ref(false)
const deleteTarget = ref<PlatformFeeItemResource | null>(null)
const deleting = ref(false)
let loadSequence = 0
let editorSequence = 0
function emptyFilters(): FeeItemFilters {
return { keyword: '', category: '', tradeScope: '', status: '' }
}
function emptyForm(): FeeItemForm {
return {
id: '',
category: '',
tradeScope: 'DOMESTIC',
feeTypeName: '',
pickupPayable: false,
pickupReceivable: false,
warehousePayable: false,
warehouseReceivable: false,
allocationFee: false,
description: null,
}
}
const form = reactive<FeeItemForm>(emptyForm())
const rules: FormRules<FeeItemForm> = {
category: [{ required: true, message: '请输入费用分类', trigger: 'blur' }],
tradeScope: [{ required: true, message: '请选择贸易范围', trigger: 'change' }],
feeTypeName: [{ required: true, message: '请输入费用名称', trigger: 'blur' }],
}
const canView = computed(() => access.can('firstleg:fee-item:view'))
const editorTitle = computed(() => form.id ? '编辑费用项目' : '新增费用项目')
const editorPermission = computed(() => form.id ? 'firstleg:fee-item:edit' : 'firstleg:fee-item:create')
const { captureBaseline } = useUnsavedWorkRegistration({
key: 'fee-item-editor',
label: editorTitle,
isOpen: editorOpen,
model: form,
requiredPermission: editorPermission,
})
const queryFields = computed<QueryField[]>(() => [
{ key: 'keyword', type: 'input', width: 210, label: '费用名称', placeholder: '请输入费用名称', props: { 'aria-label': '费用名称', maxlength: 128 } },
{ key: 'category', type: 'input', width: 190, label: '费用分类', placeholder: '请输入费用分类', props: { 'aria-label': '费用分类', maxlength: 64 } },
{
key: 'tradeScope', type: 'select', width: 160, label: '贸易范围', placeholder: '全部范围', props: { 'aria-label': '贸易范围' },
options: [{ label: '国内', value: 'DOMESTIC' }, { label: '国际', value: 'INTERNATIONAL' }],
},
{
key: 'status', type: 'select', width: 150, label: '状态', placeholder: '全部状态', props: { 'aria-label': '状态' },
options: [{ label: '启用', value: 'ACTIVE' }, { label: '停用', value: 'DISABLED' }],
},
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 250, fixed: 'left', slotName: 'actions' },
{ key: 'feeTypeName', label: '费用项目', minWidth: 210, slotName: 'feeItem' },
{ key: 'category', prop: 'category', label: '费用分类', minWidth: 150 },
{ key: 'tradeScope', label: '贸易范围', width: 110, slotName: 'tradeScope' },
{ key: 'pickup', label: '提货', minWidth: 160, slotName: 'pickup' },
{ key: 'warehouse', label: '仓库', minWidth: 160, slotName: 'warehouse' },
{ key: 'allocationFee', label: '分摊费', width: 100, slotName: 'allocationFee' },
{ key: 'description', label: '说明', minWidth: 220, slotName: 'description' },
{ key: 'updatedAt', label: '更新时间', minWidth: 155, slotName: 'updatedAt' },
{ key: 'status', label: '状态', width: 100, slotName: 'status' },
]
function optionalText(value: string | null) {
return value?.trim() || null
}
function formatTime(value?: string | null) {
return value?.replace('T', ' ').slice(0, 16) || '—'
}
function directionText(payable: boolean, receivable: boolean) {
if (payable && receivable) return '应付、应收'
if (payable) return '应付'
if (receivable) return '应收'
return '不适用'
}
async function loadFeeItems() {
if (!canView.value) {
rows.value = []
total.value = 0
loadError.value = null
return
}
const sequence = ++loadSequence
loading.value = true
loadError.value = null
try {
const result = await listPlatformFeeItems({
keyword: appliedFilters.value.keyword,
category: appliedFilters.value.category,
tradeScope: appliedFilters.value.tradeScope,
status: appliedFilters.value.status,
page: page.value,
size: pageSize.value,
})
if (sequence !== loadSequence) return
rows.value = result.records
total.value = result.total
} catch (error) {
if (sequence !== loadSequence) return
rows.value = []
total.value = 0
loadError.value = getApiErrorDetails(error, '费用项目加载失败,请稍后重试')
} finally {
if (sequence === loadSequence) loading.value = false
}
}
function applyFilters(model: QueryModel) {
appliedFilters.value = {
keyword: String(model.keyword || ''),
category: String(model.category || ''),
tradeScope: (model.tradeScope || '') as FeeItemTradeScope | '',
status: (model.status || '') as FeeItemStatus | '',
}
if (page.value === 1) void loadFeeItems()
else page.value = 1
}
function openCreate() {
editorSequence += 1
Object.assign(form, emptyForm())
captureBaseline()
editorOpen.value = true
void formRef.value?.clearValidate()
}
function assignForm(item: PlatformFeeItemResource) {
Object.assign(form, {
id: String(item.id),
category: item.category,
tradeScope: item.tradeScope,
feeTypeName: item.feeTypeName,
pickupPayable: item.pickupPayable,
pickupReceivable: item.pickupReceivable,
warehousePayable: item.warehousePayable,
warehouseReceivable: item.warehouseReceivable,
allocationFee: item.allocationFee,
description: item.description,
})
captureBaseline()
editorOpen.value = true
void formRef.value?.clearValidate()
}
async function openEdit(item: PlatformFeeItemResource) {
const sequence = ++editorSequence
editorLoading.value = true
try {
const detail = await getPlatformFeeItem(item.id)
if (sequence === editorSequence) assignForm(detail)
} catch (error) {
if (sequence !== editorSequence) return
const details = getApiErrorDetails(error, '费用项目详情加载失败,请稍后重试')
ElMessage.error(details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message)
} finally {
if (sequence === editorSequence) editorLoading.value = false
}
}
function buildPayload(): SavePlatformFeeItemRequest {
return {
category: form.category.trim(),
tradeScope: form.tradeScope,
feeTypeName: form.feeTypeName.trim(),
pickupPayable: form.pickupPayable,
pickupReceivable: form.pickupReceivable,
warehousePayable: form.warehousePayable,
warehouseReceivable: form.warehouseReceivable,
allocationFee: form.allocationFee,
description: optionalText(form.description),
}
}
async function saveFeeItem() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
saving.value = true
try {
if (form.id) await updatePlatformFeeItem(form.id, buildPayload())
else await createPlatformFeeItem(buildPayload())
editorOpen.value = false
ElMessage.success(form.id ? '费用项目已更新' : '费用项目已创建')
await loadFeeItems()
} catch (error) {
const details = getApiErrorDetails(error, '费用项目保存失败,请检查后重试')
ElMessage.error(details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message)
} finally {
saving.value = false
}
}
function requestStatusChange(item: PlatformFeeItemResource) {
statusTarget.value = item
statusOpen.value = true
}
async function confirmStatusChange() {
const item = statusTarget.value
if (!item) return
const nextStatus: FeeItemStatus = item.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE'
const action = nextStatus === 'ACTIVE' ? '启用' : '停用'
const key = String(item.id)
statusLoadingIds.value = new Set(statusLoadingIds.value).add(key)
statusSaving.value = true
try {
await changePlatformFeeItemStatus(item.id, nextStatus)
ElMessage.success(`已${action}“${item.feeTypeName}”`)
statusOpen.value = false
statusTarget.value = null
await loadFeeItems()
} catch (error) {
const details = getApiErrorDetails(error, `${action}费用项目失败`)
ElMessage.error(details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message)
} finally {
const pending = new Set(statusLoadingIds.value)
pending.delete(key)
statusLoadingIds.value = pending
statusSaving.value = false
}
}
function requestDelete(item: PlatformFeeItemResource) {
deleteTarget.value = item
deleteOpen.value = true
}
async function confirmDelete() {
if (!deleteTarget.value) return
deleting.value = true
try {
await deletePlatformFeeItem(deleteTarget.value.id)
ElMessage.success(`已删除“${deleteTarget.value.feeTypeName}”`)
deleteOpen.value = false
deleteTarget.value = null
if (rows.value.length === 1 && page.value > 1) page.value -= 1
else await loadFeeItems()
} catch (error) {
const details = getApiErrorDetails(error, '费用项目删除失败;已被业务使用的项目不能删除')
ElMessage.error(details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message)
} finally {
deleting.value = false
}
}
watch([page, pageSize], () => void loadFeeItems())
watch(
() => access.can(editorPermission.value),
(allowed) => {
if (!allowed && editorOpen.value) editorOpen.value = false
},
{ flush: 'sync' },
)
onMounted(() => {
if (canView.value) void loadFeeItems()
})
</script>
<template>
<div class="business-page fee-items-page">
<el-result v-if="!canView" icon="warning" title="无权查看费用项目" sub-title="请联系管理员开通费用项目查看权限" />
<template v-else>
<QueryToolbar
v-model="filters"
class="surface-panel"
ariaLabel="费用项目筛选"
:fields="queryFields"
preference-key="fee-items-query"
@search="applyFilters"
/>
<LoadErrorState
v-if="loadError"
title="费用项目加载失败"
:message="loadError.message"
:loading="loading"
@retry="loadFeeItems"
>
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
<section class="surface-panel table-panel">
<DataTable
v-model:page="page"
v-model:page-size="pageSize"
:data="rows"
:columns="columns"
:loading="loading"
:total="total"
:page-sizes="[20, 50, 100]"
preference-key="fee-items-table"
aria-label="费用项目列表"
row-key="id"
empty-text="暂无符合条件的费用项目"
>
<template #toolbar-start>
<div><h2>费用项目列表</h2><p>平台公共主数据 · 共 {{ total }} 项</p></div>
</template>
<template #toolbar-actions>
<el-button
v-if="access.can('firstleg:fee-item:create')"
type="primary"
:disabled="loading"
@click="openCreate"
><Plus :size="16" />新增费用项目</el-button>
</template>
<template #actions="{ row }">
<div v-if="access.can('firstleg:fee-item:edit') || access.can('firstleg:fee-item:delete')" class="table-actions">
<el-button v-if="access.can('firstleg:fee-item:edit')" link type="primary" :disabled="loading || editorLoading" @click="openEdit(row as PlatformFeeItemResource)"><Pencil :size="15" />编辑</el-button>
<el-button
v-if="access.can('firstleg:fee-item:edit')"
link
:type="(row as PlatformFeeItemResource).status === 'ACTIVE' ? 'warning' : 'success'"
:loading="statusLoadingIds.has(String((row as PlatformFeeItemResource).id))"
:disabled="loading"
@click="requestStatusChange(row as PlatformFeeItemResource)"
><Power :size="15" />{{ (row as PlatformFeeItemResource).status === 'ACTIVE' ? '停用' : '启用' }}</el-button>
<el-button v-if="access.can('firstleg:fee-item:delete')" link type="danger" :disabled="loading" @click="requestDelete(row as PlatformFeeItemResource)"><Trash2 :size="15" />删除</el-button>
</div>
<span v-else class="muted-value">仅查看</span>
</template>
<template #feeItem="{ row }"><div class="fee-item-name"><strong>{{ (row as PlatformFeeItemResource).feeTypeName }}</strong><small>{{ (row as PlatformFeeItemResource).feeCode || '未分配编码' }}</small></div></template>
<template #tradeScope="{ row }"><el-tag effect="plain">{{ (row as PlatformFeeItemResource).tradeScope === 'DOMESTIC' ? '国内' : '国际' }}</el-tag></template>
<template #pickup="{ row }">{{ directionText((row as PlatformFeeItemResource).pickupPayable, (row as PlatformFeeItemResource).pickupReceivable) }}</template>
<template #warehouse="{ row }">{{ directionText((row as PlatformFeeItemResource).warehousePayable, (row as PlatformFeeItemResource).warehouseReceivable) }}</template>
<template #allocationFee="{ row }">{{ (row as PlatformFeeItemResource).allocationFee ? '是' : '否' }}</template>
<template #description="{ row }"><span class="description-cell">{{ (row as PlatformFeeItemResource).description || '—' }}</span></template>
<template #updatedAt="{ row }">{{ formatTime((row as PlatformFeeItemResource).updatedAt) }}</template>
<template #status="{ row }"><el-tag :type="(row as PlatformFeeItemResource).status === 'ACTIVE' ? 'success' : 'info'">{{ (row as PlatformFeeItemResource).status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></template>
<template #empty>
<el-empty v-if="!loading" description="暂无符合条件的费用项目">
<el-button v-if="access.can('firstleg:fee-item:create')" type="primary" @click="openCreate"><Plus :size="16" />新增第一项费用</el-button>
</el-empty>
</template>
</DataTable>
</section>
</template>
<AppDrawer
v-model="editorOpen"
:title="editorTitle"
size="min(620px, 94vw)"
:loading="editorLoading"
:confirm-loading="saving"
confirm-text="保存费用项目"
@confirm="saveFeeItem"
>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" :disabled="saving || editorLoading">
<div class="form-grid two-cols">
<el-form-item label="费用名称" prop="feeTypeName" required><el-input v-model="form.feeTypeName" maxlength="128" placeholder="请输入费用名称" /></el-form-item>
<el-form-item label="费用分类" prop="category" required><el-input v-model="form.category" maxlength="64" placeholder="例如 仓储成本" /></el-form-item>
</div>
<el-form-item label="贸易范围" prop="tradeScope" required>
<el-segmented v-model="form.tradeScope" :options="[{ label: '国内', value: 'DOMESTIC' }, { label: '国际', value: 'INTERNATIONAL' }]" />
</el-form-item>
<div class="form-section">
<h3>费用方向</h3>
<div class="direction-grid">
<fieldset><legend>提货环节</legend><el-checkbox v-model="form.pickupPayable">应付</el-checkbox><el-checkbox v-model="form.pickupReceivable">应收</el-checkbox></fieldset>
<fieldset><legend>仓库环节</legend><el-checkbox v-model="form.warehousePayable">应付</el-checkbox><el-checkbox v-model="form.warehouseReceivable">应收</el-checkbox></fieldset>
</div>
<el-checkbox v-model="form.allocationFee">作为分摊费用</el-checkbox>
</div>
<el-form-item label="说明"><el-input v-model="form.description" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入适用场景或核算说明" /></el-form-item>
</el-form>
</AppDrawer>
<AppDialog
v-model="statusOpen"
:title="statusTarget?.status === 'ACTIVE' ? '停用费用项目' : '启用费用项目'"
:confirm-text="statusTarget?.status === 'ACTIVE' ? '确认停用' : '确认启用'"
:confirm-type="statusTarget?.status === 'ACTIVE' ? 'warning' : 'success'"
:confirm-loading="statusSaving"
@confirm="confirmStatusChange"
>
<p class="delete-message">
确定{{ statusTarget?.status === 'ACTIVE' ? '停用' : '启用' }}“<strong>{{ statusTarget?.feeTypeName }}</strong>”吗?
<template v-if="statusTarget?.status === 'ACTIVE'">停用后该项目不能再用于新的费用记录。</template>
</p>
</AppDialog>
<AppDialog
v-model="deleteOpen"
title="删除费用项目"
confirm-text="确认删除"
confirm-type="danger"
:confirm-loading="deleting"
@confirm="confirmDelete"
>
<p class="delete-message">确定删除“<strong>{{ deleteTarget?.feeTypeName }}</strong>”吗?删除后无法恢复;已被业务单据使用的项目将由服务端拒绝删除。</p>
</AppDialog>
</div>
</template>
<style scoped>
.fee-items-page { display: grid; gap: 16px; }
.fee-item-name { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
.fee-item-name strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fee-item-name small { color: var(--cc-muted); font-size: 11px; }
.description-cell { display: -webkit-box; overflow: hidden; line-height: 1.5; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
.direction-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-bottom: 12px; }
.direction-grid fieldset { min-width: 0; margin: 0; padding: 12px; border: 1px solid var(--el-border-color); border-radius: 6px; }
.direction-grid legend { padding: 0 5px; color: var(--el-text-color-secondary); font-size: 13px; }
.delete-message { margin: 0; color: var(--el-text-color-regular); line-height: 1.7; overflow-wrap: anywhere; }
@media (max-width: 720px) {
.direction-grid, .form-grid.two-cols { grid-template-columns: 1fr; }
.table-actions { flex-wrap: wrap; }
}
</style>
+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest'
import { feeItemRoutes } from './routes'
describe('fee item routes', () => {
it('registers the formal platform master-data management page', () => {
expect(feeItemRoutes).toHaveLength(1)
expect(feeItemRoutes[0]).toMatchObject({
path: 'master-data/fee-items',
name: 'fee-items',
meta: { permission: 'firstleg:fee-item:view' },
})
})
})
+14
View File
@@ -0,0 +1,14 @@
import type { RouteRecordRaw } from 'vue-router'
export const feeItemRoutes = [
{
path: 'master-data/fee-items',
name: 'fee-items',
component: () => import('@/modules/fee-items/pages/FeeItemsView.vue'),
meta: {
title: '费用项目',
description: '按客户维护头程费用项目及其适用范围',
permission: 'firstleg:fee-item:view',
},
},
] satisfies RouteRecordRaw[]
@@ -566,13 +566,13 @@ onMounted(() => void loadTenants())
</section>
</template>
<el-dialog v-model="expressDialogOpen" title="维护履约快递" width="520px">
<el-dialog v-model="expressDialogOpen" title="维护履约快递" width="min(520px, calc(100vw - 28px))">
<el-alert title="当前后端契约只接收快递公司名称和快递单号;快递主数据关联待后端补充。" type="info" :closable="false" show-icon />
<el-form ref="expressFormRef" :model="expressForm" :rules="expressRules" label-position="top" class="delivery-form"><el-form-item label="快递公司" prop="expressCompany" required><el-input v-model.trim="expressForm.expressCompany" maxlength="64" placeholder="例如 顺丰速运" /></el-form-item><el-form-item label="快递单号" prop="expressTrackingNo" required><el-input v-model.trim="expressForm.expressTrackingNo" maxlength="128" placeholder="请输入完整快递单号" /></el-form-item></el-form>
<template #footer><el-button :disabled="expressSaving" @click="expressDialogOpen = false">取消</el-button><el-button type="primary" :loading="expressSaving" @click="saveExpress">保存快递信息</el-button></template>
</el-dialog>
<el-dialog v-model="noteDialogOpen" title="添加内部备注" width="520px">
<el-dialog v-model="noteDialogOpen" title="添加内部备注" width="min(520px, calc(100vw - 28px))">
<el-form ref="noteFormRef" :model="noteForm" :rules="noteRules" label-position="top"><el-form-item label="内部备注" prop="content" required><el-input v-model.trim="noteForm.content" maxlength="2000" type="textarea" :rows="4" show-word-limit placeholder="仅平台内部可见" /></el-form-item></el-form>
<template #footer><el-button :disabled="noteSaving" @click="noteDialogOpen = false">取消</el-button><el-button type="primary" :loading="noteSaving" @click="saveNote">保存备注</el-button></template>
</el-dialog>
+36 -28
View File
@@ -17,6 +17,12 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import FormSection from '@/shared/components/FormSection.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import PageToolbar from '@/shared/components/PageToolbar.vue'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
@@ -295,24 +301,27 @@ onMounted(loadInitialData)
</script>
<template>
<div class="business-page">
<el-alert v-if="loadError" type="error" :closable="false" show-icon role="alert">
<template #title>
<div class="api-error-alert-title"><span>{{ loadError.message }}</span><ApiErrorMeta :details="loadError" /></div>
<el-button link type="danger" @click="loadInitialData">重新加载</el-button>
<AdminPage as="div">
<PageToolbar title="内部用户" description="维护平台内部员工账号、平台角色和使用状态。" />
<ListPageLayout aria-label="内部用户管理">
<template #toolbar>
<QueryToolbar
v-model="filters"
class="surface-panel"
ariaLabel="用户筛选"
:fields="queryFields"
preference-key="internal-users-query"
@search="applyFilters"
/>
</template>
<template v-if="loadError" #state>
<LoadErrorState title="内部用户列表暂时无法加载" :message="loadError.message" :loading="loading" @retry="loadInitialData">
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
</template>
</el-alert>
<QueryToolbar
v-model="filters"
class="surface-panel"
ariaLabel="用户筛选"
:fields="queryFields"
preference-key="internal-users-query"
@search="applyFilters"
/>
<section class="surface-panel table-panel" :aria-busy="loading">
<section class="surface-panel table-panel" :aria-busy="loading">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="users" :columns="userColumns" preference-key="internal-users-table"
:loading="loading" :total="total" aria-label="内部用户列表" row-key="membershipId" empty-text="没有符合筛选条件的内部用户">
<template #toolbar-start><div><h2>内部用户列表 <span class="secondary-text">共 {{ total }} 条</span></h2><p>维护平台内部员工账号、平台角色和使用状态。</p></div></template>
@@ -340,11 +349,13 @@ onMounted(loadInitialData)
<template #status="{ row }"><StatusBadge :status="(row as PlatformManagedUser).status === 'ACTIVE' ? '正常' : '禁用'" /></template>
<template #lastLoginAt="{ row }">{{ formatLogin((row as PlatformManagedUser).lastLoginAt) }}</template>
</DataTable>
</section>
</section>
</ListPageLayout>
<el-dialog v-model="editorOpen" :title="editorLabel" width="min(760px, calc(100vw - 32px))" :close-on-click-modal="false" destroy-on-close>
<AppDialog v-model="editorOpen" :title="editorLabel" width="min(760px, calc(100vw - 32px))" destroy-on-close :confirm-loading="saving" :confirm-text="editingUser ? '保存修改' : '确认添加'" @confirm="saveUser">
<el-form ref="formRef" :model="form" :rules="formRules" label-position="top">
<div class="form-grid two-cols">
<FormSection title="用户资料" description="维护登录身份、联系方式与平台角色。">
<div class="form-grid two-cols">
<el-form-item label="用户名" prop="username" required>
<el-input v-model="form.username" :disabled="Boolean(editingUser)" placeholder="登录用户名" maxlength="64" />
</el-form-item>
@@ -372,9 +383,10 @@ onMounted(loadInitialData)
<el-form-item v-if="editingUser" label="最后登录">
<el-input :model-value="formatLogin(editingUser.lastLoginAt)" disabled />
</el-form-item>
</div>
</div>
</FormSection>
<template v-if="!editingUser || canResetPassword">
<FormSection v-if="!editingUser || canResetPassword" title="登录密码" description="密码为 8-64 位,并同时包含字母和数字。">
<el-checkbox v-if="editingUser" v-model="form.resetPassword">同时重置密码</el-checkbox>
<div v-if="!editingUser || form.resetPassword" class="form-grid two-cols password-fields">
<el-form-item :label="editingUser ? '新密码' : '初始密码'" prop="password" required>
@@ -384,14 +396,10 @@ onMounted(loadInitialData)
<el-input v-model="form.confirmPassword" type="password" show-password autocomplete="new-password" placeholder="再次输入密码" />
</el-form-item>
</div>
</template>
</FormSection>
</el-form>
<template #footer>
<el-button :disabled="saving" @click="editorOpen = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="saveUser">{{ editingUser ? '保存修改' : '确认添加' }}</el-button>
</template>
</el-dialog>
</div>
</AppDialog>
</AdminPage>
</template>
<style scoped>
@@ -38,6 +38,12 @@ function mountServerView() {
plugins: [pinia],
directives: { loading: () => undefined },
stubs: {
AdminPage: { template: '<main><slot /></main>' },
PageToolbar: { template: '<header><slot /><slot name="actions" /></header>' },
ListPageLayout: { template: '<section><slot name="toolbar" /><slot name="state" /><slot /></section>' },
FormSection: { template: '<section><slot /></section>' },
AppDrawer: { template: '<section><slot /><slot name="footer" /></section>' },
LoadErrorState: { props: ['message'], template: '<div role="alert">{{ message }}<slot name="details" /></div>' },
DataTable: { template: '<section><slot name="toolbar-start" /><slot name="toolbar-actions" /></section>' },
'el-alert': { props: ['title'], template: '<div role="alert">{{ title }}<slot name="title" /></div>' },
'el-input': true,
+23 -12
View File
@@ -5,6 +5,12 @@ import { Braces, FolderTree, Pencil, Plus, ShieldCheck, Trash2 } from '@lucide/v
import StatusBadge from '@/shared/components/StatusBadge.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import FormSection from '@/shared/components/FormSection.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import PageToolbar from '@/shared/components/PageToolbar.vue'
import {
changePlatformPermissionStatus,
createPlatformPermission,
@@ -230,11 +236,17 @@ onMounted(loadPermissions)
</script>
<template>
<div class="business-page">
<el-alert v-if="loadError" type="error" :closable="false" show-icon role="alert"><template #title><div class="api-error-alert-title"><span>{{ loadError.message }}</span><ApiErrorMeta :details="loadError" /></div><el-button link type="danger" @click="loadPermissions">重新加载</el-button></template></el-alert>
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="权限筛选" :fields="queryFields" preference-key="permissions-query" @search="applyFilters" />
<AdminPage as="div">
<PageToolbar title="权限管理" description="维护平台菜单与按钮权限资源。" />
<ListPageLayout aria-label="平台权限资源管理">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="权限筛选" :fields="queryFields" preference-key="permissions-query" @search="applyFilters" /></template>
<template v-if="loadError" #state>
<LoadErrorState title="权限资源暂时无法加载" :message="loadError.message" :loading="loading" @retry="loadPermissions">
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
</template>
<section class="surface-panel table-panel">
<section class="surface-panel table-panel">
<DataTable :key="tableRenderKey" v-model:page="page" v-model:page-size="pageSize" :data="pagedTreeRows" :columns="permissionColumns" preference-key="permissions-table" :loading="loading" :total="treeRows.length" row-key="id" :expand-row-keys="expandedRowKeys" :tree-props="{ children: 'children' }" empty-text="没有符合条件的权限资源">
<template #toolbar-start><div><h2>权限资源树 <span class="secondary-text">共 {{ permissions.length }} 项</span></h2><p>默认展示目录与菜单,展开菜单后查看按钮权限;接口权限将在服务端契约确定后补充。</p></div></template>
<template #toolbar-actions><div class="permission-tree-heading-actions"><div class="permission-tree-tools"><div class="legend-list"><span><i class="is-menu" />目录 / 菜单</span><span><i class="is-action" />操作</span><span><i class="is-api" />接口预留</span></div><div class="tree-view-actions"><el-button link type="primary" @click="expandAllRows">展开全部</el-button><el-button link @click="collapseActionRows">收起操作权限</el-button></div></div><el-button v-if="canCreate" type="primary" @click="openCreate(null)"><Plus :size="16" />新增权限</el-button></div></template>
@@ -244,12 +256,12 @@ onMounted(loadPermissions)
<template #endpoint="{ row }"><div class="endpoint-cell"><span v-if="(row as Permission).method !== '-'">{{ (row as Permission).method }}</span><code>{{ (row as Permission).path || '-' }}</code></div></template>
<template #status="{ row }"><StatusBadge :status="(row as Permission).status" /></template>
</DataTable>
</section>
</section>
</ListPageLayout>
<el-drawer v-model="editorOpen" :title="form.id ? '编辑权限资源' : '新增权限资源'" size="600px" class="business-drawer editor-drawer" :modal="false" :close-on-click-modal="false">
<AppDrawer v-model="editorOpen" :title="form.id ? '编辑权限资源' : '新增权限资源'" size="600px" class="business-drawer editor-drawer" :modal="false" :confirm-loading="saving" :confirm-text="form.id ? '保存修改' : '保存权限'" :show-confirm="form.id ? canEdit : canCreate" @confirm="savePermission">
<el-form ref="formRef" :model="form" :rules="formRules" label-position="top" class="drawer-form">
<div class="form-section">
<h3>资源定义</h3>
<FormSection title="资源定义" description="定义权限名称、编码、类型和层级关系。">
<div class="form-grid two-cols">
<el-form-item label="权限名称" prop="name" required><el-input v-model="form.name" placeholder="用户可理解的中文名称" maxlength="64" /></el-form-item>
<el-form-item label="权限编码" prop="code" required><el-input v-model="form.code" :disabled="Boolean(form.id)" placeholder="如:platform:customer:list" maxlength="128" /></el-form-item>
@@ -258,12 +270,11 @@ onMounted(loadPermissions)
<el-form-item v-if="form.type === '菜单'" label="路由路径" prop="path" required><el-input v-model="form.path" placeholder="如:/customers" /></el-form-item>
<el-form-item label="显示排序" prop="sort" required><el-input-number v-model="form.sort" :min="0" :max="999" controls-position="right" /></el-form-item>
</div>
</div>
</FormSection>
<div class="form-callout"><Braces :size="18" /><div><strong>权限编码约定</strong><p>使用稳定的“业务域:资源:动作”编码;一期真实资源只创建菜单和按钮,接口鉴权由服务端实现。</p></div></div>
</el-form>
<template #footer><div class="drawer-footer"><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button v-if="form.id ? canEdit : canCreate" type="primary" :loading="saving" @click="savePermission">{{ form.id ? '保存修改' : '保存权限' }}</el-button></div></template>
</el-drawer>
</div>
</AppDrawer>
</AdminPage>
</template>
<style scoped>
+6
View File
@@ -36,6 +36,12 @@ function mountView(pinia = createPinia()) {
plugins: [pinia],
directives: { loading: () => undefined },
stubs: {
AdminPage: { template: '<main><slot /></main>' },
PageToolbar: { template: '<header><slot /><slot name="actions" /></header>' },
ListPageLayout: { template: '<section><slot name="toolbar" /><slot name="state" /><slot /></section>' },
FormSection: { template: '<section><slot /></section>' },
AppDrawer: { template: '<section><slot /><slot name="footer" /></section>' },
LoadErrorState: { props: ['message'], template: '<div role="alert">{{ message }}<slot name="details" /></div>' },
DataTable: { template: '<section><slot name="toolbar-start" /><slot name="toolbar-actions" /></section>' },
'el-alert': { props: ['title'], template: '<div role="alert">{{ title }}<slot name="title" /></div>' },
'el-input': true,
+23 -11
View File
@@ -18,6 +18,12 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import FormSection from '@/shared/components/FormSection.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import PageToolbar from '@/shared/components/PageToolbar.vue'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import type { Permission, Role } from '@/modules/iam/types'
@@ -231,11 +237,17 @@ onMounted(loadRoles)
</script>
<template>
<div class="business-page">
<el-alert v-if="loadError" type="error" :closable="false" show-icon role="alert"><template #title><div class="api-error-alert-title"><span>{{ loadError.message }}</span><ApiErrorMeta :details="loadError" /></div><el-button link type="danger" @click="loadRoles">重新加载</el-button></template></el-alert>
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="角色筛选" :fields="queryFields" preference-key="roles-query" @search="applyFilters" />
<AdminPage as="div">
<PageToolbar title="角色管理" description="维护平台角色及其菜单、按钮权限。" />
<ListPageLayout aria-label="平台角色管理">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="角色筛选" :fields="queryFields" preference-key="roles-query" @search="applyFilters" /></template>
<template v-if="loadError" #state>
<LoadErrorState title="角色列表暂时无法加载" :message="loadError.message" :loading="loading" @retry="loadRoles">
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
</template>
<section class="surface-panel table-panel">
<section class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="pagedRows" :columns="roleColumns" preference-key="roles-table" :loading="loading" :total="filteredRows.length" row-key="id" empty-text="没有符合条件的角色">
<template #toolbar-start><div><h2>角色与授权 <span class="secondary-text">共 {{ filteredRows.length }} 条</span></h2><p>角色负责聚合菜单、操作和数据范围,内部用户可分配多个角色。</p></div></template>
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增角色</el-button></template>
@@ -245,14 +257,14 @@ onMounted(loadRoles)
<template #roleType="{ row }"><el-tag :type="(row as Role).builtIn ? 'warning' : 'info'" effect="plain" size="small">{{ (row as Role).builtIn ? '系统内置' : '自定义' }}</el-tag></template>
<template #status="{ row }"><StatusBadge :status="(row as Role).status" /></template>
</DataTable>
</section>
</section>
</ListPageLayout>
<el-drawer v-model="editorOpen" :title="form.id ? '配置角色' : '新增角色'" size="760px" class="business-drawer editor-drawer" :modal="false" :close-on-click-modal="false">
<AppDrawer v-model="editorOpen" :title="form.id ? '配置角色' : '新增角色'" size="760px" class="business-drawer editor-drawer" :modal="false" :confirm-loading="saving" confirm-text="保存角色" :show-confirm="form.id ? canEdit || canGrant : canCreate" @confirm="saveRole">
<el-form ref="formRef" :model="form" :rules="formRules" label-position="top" class="drawer-form">
<div class="form-section"><h3>基本信息</h3><div class="form-grid two-cols"><el-form-item label="角色名称" prop="name" required><el-input v-model="form.name" :disabled="Boolean(form.id) && !canEdit" placeholder="如:订单运营专员" maxlength="64" /></el-form-item><el-form-item label="角色编码" prop="code" required><el-input v-model="form.code" :disabled="Boolean(form.id)" placeholder="如:ORDER_OPERATOR" maxlength="64" /></el-form-item></div><el-form-item label="角色说明"><el-input v-model="form.description" :disabled="Boolean(form.id) && !canEdit" type="textarea" :rows="2" placeholder="说明该角色的工作范围" maxlength="200" show-word-limit /></el-form-item></div>
<div v-if="canGrant" class="form-section permission-section"><div class="section-heading-row"><div><h3>菜单与操作权限</h3><p>保存时按后端契约全量替换当前角色权限;未勾选表示清空授权。</p></div><span>{{ form.permissionIds.length }} 项原有授权</span></div><el-tree ref="treeRef" class="permission-tree" :data="permissionTree" node-key="id" show-checkbox check-strictly default-expand-all :props="{ label: 'name', children: 'children' }" @check="syncPermissionIds"><template #default="{ data }"><div class="permission-node"><span>{{ data.name }}</span><code>{{ data.code }}</code></div></template></el-tree></div>
<FormSection title="基本信息" description="维护角色名称、编码和职责说明。"><div class="form-grid two-cols"><el-form-item label="角色名称" prop="name" required><el-input v-model="form.name" :disabled="Boolean(form.id) && !canEdit" placeholder="如:订单运营专员" maxlength="64" /></el-form-item><el-form-item label="角色编码" prop="code" required><el-input v-model="form.code" :disabled="Boolean(form.id)" placeholder="如:ORDER_OPERATOR" maxlength="64" /></el-form-item></div><el-form-item label="角色说明"><el-input v-model="form.description" :disabled="Boolean(form.id) && !canEdit" type="textarea" :rows="2" placeholder="说明该角色的工作范围" maxlength="200" show-word-limit /></el-form-item></FormSection>
<FormSection v-if="canGrant" title="菜单与操作权限" :description="`保存时按后端契约全量替换当前角色权限;当前 ${form.permissionIds.length} 项授权。`"><el-tree ref="treeRef" class="permission-tree" :data="permissionTree" node-key="id" show-checkbox check-strictly default-expand-all :props="{ label: 'name', children: 'children' }" @check="syncPermissionIds"><template #default="{ data }"><div class="permission-node"><span>{{ data.name }}</span><code>{{ data.code }}</code></div></template></el-tree></FormSection>
</el-form>
<template #footer><div class="drawer-footer"><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button v-if="form.id ? canEdit || canGrant : canCreate" type="primary" :loading="saving" @click="saveRole">保存角色</el-button></div></template>
</el-drawer>
</div>
</AppDrawer>
</AdminPage>
</template>
@@ -493,11 +493,6 @@ onBeforeUnmount(() => { disposed = true; loadRequestId += 1; detailRequestId +=
<style scoped>
.inbound-orders-page { min-width: 0; }
.inbound-query-toolbar { align-items: stretch; border-radius: 8px; background: #f8fafc; }
.inbound-query-toolbar :deep(.query-toolbar__filters) { display: grid; flex: 1 1 auto; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px 16px; }
.inbound-query-toolbar :deep(.query-toolbar__filters .el-input),
.inbound-query-toolbar :deep(.query-toolbar__filters .el-select),
.inbound-query-toolbar :deep(.query-toolbar__filters .el-date-editor) { width: 100%; }
.inbound-query-toolbar :deep(.query-toolbar__actions) { align-content: flex-end; align-items: flex-end; }
.inbound-tabs { display: flex; width: max-content; max-width: 100%; min-width: 0; overflow-x: auto; border-bottom: 2px solid var(--cc-border); scrollbar-width: none; }
.inbound-tabs::-webkit-scrollbar { display: none; }
.inbound-tab { min-height: 42px; flex: 0 0 auto; padding: 10px 22px; border: 0; border-bottom: 2px solid transparent; margin-bottom: -2px; background: transparent; color: #475467; cursor: pointer; font-size: 13px; line-height: 1.4; white-space: nowrap; transition: border-color .16s ease, color .16s ease, background-color .16s ease; }
@@ -524,7 +519,6 @@ onBeforeUnmount(() => { disposed = true; loadRequestId += 1; detailRequestId +=
.detail-error strong { color: #b42318; font-size: 16px; }
.detail-error p { margin: 7px 0; color: #172033; font-size: 13px; }
.detail-error :deep(.el-button) { margin-top: 14px; }
@media (max-width: 1180px) { .inbound-query-toolbar :deep(.query-toolbar__filters) { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@media (max-width: 900px) { .inbound-tabs { width: 100%; } .inbound-query-toolbar :deep(.query-toolbar__filters) { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 720px) { .inbound-query-toolbar :deep(.query-toolbar__filters) { grid-template-columns: 1fr; } .inbound-tab { padding-inline: 14px; } .table-tabs-row { padding: 0 10px; } .inbound-stats-row { min-height: 0; align-items: stretch; flex-direction: column; gap: 10px; padding: 10px; } .inbound-stats-row .total-info { justify-content: flex-start; } }
@media (max-width: 900px) { .inbound-tabs { width: 100%; } }
@media (max-width: 720px) { .inbound-tab { padding-inline: 14px; } .table-tabs-row { padding: 0 10px; } .inbound-stats-row { min-height: 0; align-items: stretch; flex-direction: column; gap: 10px; padding: 10px; } .inbound-stats-row .total-info { justify-content: flex-start; } }
</style>
@@ -314,7 +314,7 @@ onMounted(() => {
</div>
</template>
<el-dialog v-model="reasonDialogOpen" :title="reasonDialog === 'reject' ? '填写驳回原因' : '填写异常放行原因'" width="520px" destroy-on-close>
<el-dialog v-model="reasonDialogOpen" :title="reasonDialog === 'reject' ? '填写驳回原因' : '填写异常放行原因'" width="min(520px, calc(100vw - 28px))" destroy-on-close>
<el-alert v-if="reasonDialog === 'release'" title="异常放行不会抹去驳回记录;请说明为什么允许该测量继续进入自动计费入库。" type="warning" :closable="false" show-icon />
<el-input v-model="reason" type="textarea" :rows="5" maxlength="1000" show-word-limit :placeholder="reasonDialog === 'reject' ? '请说明需要修正的测量问题' : '请填写异常放行的业务原因'" />
<template #footer><el-button :disabled="Boolean(actionLoading)" @click="reasonDialog = null">取消</el-button><el-button :type="reasonDialog === 'reject' ? 'danger' : 'warning'" :loading="Boolean(actionLoading)" @click="submitReason">确认{{ reasonDialog === 'reject' ? '驳回' : '放行' }}</el-button></template>
@@ -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[]
@@ -3,8 +3,12 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { ExternalLink, Pencil, Plus, Power, Truck, Upload } from '@lucide/vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import BatchImportDialog from '@/shared/components/BatchImportDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import FormSection from '@/shared/components/FormSection.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import PageToolbar from '@/shared/components/PageToolbar.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -278,15 +282,26 @@ onMounted(() => {
</script>
<template>
<div class="business-page express-company-page">
<AdminPage>
<div class="business-page express-company-page">
<PageToolbar title="快递公司" :description="`平台公共数据 · 共 ${total} 家快递公司`">
<template #actions>
<el-button v-if="access.can('platform:express:import')" :disabled="loading" @click="openImport"><Upload :size="16" />批量导入</el-button>
<el-button v-if="access.can('platform:express:create')" type="primary" :disabled="loading" @click="openCreate"><Plus :size="16" />新增快递公司</el-button>
</template>
</PageToolbar>
<ListPageLayout ariaLabel="快递公司列表">
<template #toolbar>
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="快递公司筛选" :fields="queryFields" preference-key="express-companies-query" @search="applyFilters" />
</template>
<template #state>
<el-alert v-if="loadError" type="error" :closable="false" show-icon class="state-alert">
<template #title>快递公司加载失败</template>
<p>{{ loadError.message }}</p>
<ApiErrorMeta :details="loadError" />
<el-button size="small" @click="loadExpressCompanies()">重新查询</el-button>
</el-alert>
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="快递公司筛选" :fields="queryFields" preference-key="express-companies-query" @search="applyFilters" />
</template>
<section class="surface-panel table-panel">
<DataTable
@@ -302,8 +317,6 @@ onMounted(() => {
row-key="id"
:empty-text="appliedFilters.keyword.trim() || appliedFilters.status ? '暂无符合筛选条件的快递公司' : '暂无快递公司档案'"
>
<template #toolbar-start><div><h2>快递公司列表</h2><p>平台公共数据 · 共 {{ total }} 家快递公司</p></div></template>
<template #toolbar-actions><div class="panel-actions"><el-button v-if="access.can('platform:express:import')" :disabled="loading" @click="openImport"><Upload :size="16" />批量导入</el-button><el-button v-if="access.can('platform:express:create')" type="primary" :disabled="loading" @click="openCreate"><Plus :size="16" />新增快递公司</el-button></div></template>
<template #actions="{ row }">
<div v-if="access.can('platform:express:edit') || access.can('platform:express:status')" class="table-actions">
<el-button
@@ -341,6 +354,7 @@ onMounted(() => {
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 家快递公司</span></template>
</DataTable>
</section>
</ListPageLayout>
<BatchImportDialog
v-model="importOpen"
@@ -356,15 +370,15 @@ onMounted(() => {
<el-drawer v-model="editorOpen" :title="editorTitle" size="580px" class="business-drawer editor-drawer" :close-on-click-modal="false">
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="drawer-form" :disabled="saving">
<div class="form-section">
<FormSection title="基础信息">
<h3>公司标识</h3>
<div class="form-grid two-cols">
<el-form-item label="快递公司编码" prop="companyCode" required><el-input v-model.trim="form.companyCode" maxlength="50" placeholder="例如 SF" /></el-form-item>
<el-form-item label="快递公司名称" prop="companyName" required><el-input v-model.trim="form.companyName" maxlength="128" placeholder="例如 顺丰速运" /></el-form-item>
</div>
<el-form-item label="状态" prop="status" required><el-radio-group v-model="form.status"><el-radio-button value="ACTIVE">启用</el-radio-button><el-radio-button value="INACTIVE">停用</el-radio-button></el-radio-group></el-form-item>
</div>
<div class="form-section">
</FormSection>
<FormSection title="服务配置">
<h3>服务与查询</h3>
<div class="form-grid two-cols">
<el-form-item label="客服电话"><el-input v-model.trim="form.servicePhone" maxlength="32" placeholder="例如 95338" /></el-form-item>
@@ -372,19 +386,21 @@ onMounted(() => {
</div>
<el-form-item label="轨迹查询地址模板"><el-input v-model.trim="form.trackingUrlTemplate" maxlength="500" placeholder="https://example.com/track/{trackingNo}" /></el-form-item>
<p class="form-help">可使用 <code>{trackingNo}</code> 作为快递单号占位符;一期不包含轨迹订阅和自动回传。</p>
</div>
<div class="form-section"><h3>备注</h3><el-form-item label="内部说明"><el-input v-model.trim="form.remark" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入适用范围、服务限制或其他说明" /></el-form-item></div>
</FormSection>
<FormSection title="备注"><el-form-item label="内部说明"><el-input v-model.trim="form.remark" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入适用范围、服务限制或其他说明" /></el-form-item></FormSection>
</el-form>
<template #footer><div class="drawer-footer"><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveExpressCompany">保存快递公司</el-button></div></template>
</el-drawer>
</div>
</div>
</AdminPage>
</template>
<style scoped>
.express-company-page { gap: 16px; }
.state-alert p { margin: 6px 0 0; line-height: 1.55; }
.state-alert .el-button { margin-top: 10px; }
.express-company-cell { display: flex; min-width: 0; align-items: center; gap: 10px; }
.express-company-cell > span { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 7px; background: #eef3fa; color: var(--cc-primary); }
.express-company-cell > span { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border: 1px solid var(--cc-border); border-radius: 7px; background: #f8fafc; color: var(--cc-primary); }
.express-company-cell > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
.express-company-cell strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.express-company-cell small { color: var(--cc-muted); font-size: 11px; }
@@ -397,5 +413,6 @@ onMounted(() => {
.panel-actions .el-button { margin-left: 0; }
@media (max-width: 720px) {
.panel-actions, .panel-actions .el-button { width: 100%; }
.express-company-page :deep(.query-toolbar__actions) { width: 100%; }
}
</style>
@@ -19,9 +19,12 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import LineClampText from '@/shared/components/LineClampText.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
@@ -760,8 +763,10 @@ onMounted(() => {
</script>
<template>
<section class="unified-orders-page">
<AdminPage>
<section class="business-page unified-orders-page">
<template v-if="orderId">
<DetailPageLayout ariaLabel="统一订单详情">
<header class="order-detail-header">
<div class="order-detail-heading">
<el-button link class="back-button" @click="closeDetail"><ArrowLeft :size="16" />返回列表</el-button>
@@ -972,9 +977,11 @@ onMounted(() => {
</section>
</template>
</div>
</DetailPageLayout>
</template>
<template v-else>
<ListPageLayout ariaLabel="统一订单列表">
<div class="orders-query-panel">
<div class="status-tabs" role="tablist" aria-label="订单主状态">
<button
@@ -991,7 +998,7 @@ onMounted(() => {
>{{ tab.label }}</button>
</div>
<QueryToolbar id="unified-orders-advanced-filters" v-model="filters" ariaLabel="订单列表筛选" :fields="queryFields" :available-fields="allQueryFields" preference-key="unified-orders-query-v2" :search-on-reset="false" @search="search" @reset="reset">
<QueryToolbar id="unified-orders-advanced-filters" v-model="filters" class="orders-query-toolbar" ariaLabel="订单列表筛选" :fields="queryFields" :available-fields="allQueryFields" preference-key="unified-orders-query-v2" :search-on-reset="false" @search="search" @reset="reset">
<template #actions>
<el-button :aria-expanded="showAdvancedFilters" aria-controls="unified-orders-advanced-filters" @click="showAdvancedFilters = !showAdvancedFilters">
<SlidersHorizontal :size="16" />{{ showAdvancedFilters ? '收起筛选' : '高级筛选' }}
@@ -1040,7 +1047,7 @@ onMounted(() => {
<template #updatedAt="{ row }">{{ formatDate((row as UnifiedOrderResource).updatedAt) }}</template>
</DataTable>
</div>
</ListPageLayout>
</template>
<AppDialog v-model="noteOpen" title="添加内部备注" confirm-text="保存备注" :confirm-loading="noteSaving" @confirm="saveNote">
@@ -1052,10 +1059,11 @@ onMounted(() => {
</AppDialog>
</section>
</AdminPage>
</template>
<style scoped>
.unified-orders-page { min-width: 0; }
.unified-orders-page { min-width: 0; gap: 16px; }
.page-intro { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
.page-intro h1 { margin: 0; color: var(--cc-text); font-size: 22px; line-height: 1.35; }
.page-intro p { margin: 5px 0 0; color: var(--cc-muted); font-size: 13px; line-height: 1.6; }
@@ -1068,9 +1076,9 @@ onMounted(() => {
.status-tab:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: -2px; }
.status-tab.is-active { color: var(--cc-primary); font-weight: 600; }
.status-tab.is-active::after { background: var(--cc-primary); }
.orders-query-panel :deep(.query-toolbar) { border: 0; }
.orders-query-toolbar { border: 0; }
.orders-load-error { margin-top: 12px; }
.table-card { margin-top: 16px; overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
.table-card { overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
.row-actions { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
.row-actions :deep(.el-button) { margin-left: 0; }
.primary-cell { display: grid; min-width: 0; gap: 4px; }
@@ -1,17 +1,25 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import {
adjustSmartLoadingItem,
assignSmartLoadingItem,
completeLoading,
confirmingPreload,
deleteSmartLoadingItem,
getContainerBooking,
getOutboundDispatch,
getPicking,
getPreload,
listSmartLoadingCandidates,
pushSmartLoadingPreload,
recommendSmartLoading,
smartSplit,
uploadEvidenceFile,
voidEvidence,
} from './api'
vi.mock('@/shared/api/http', () => ({
http: { get: vi.fn(), post: vi.fn() },
http: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), delete: vi.fn() },
unwrapApiResponse: <T>(response: { data: T }) => response.data,
}))
@@ -19,6 +27,8 @@ describe('unified order workflow API contract', () => {
beforeEach(() => {
vi.mocked(http.get).mockReset()
vi.mocked(http.post).mockReset()
vi.mocked(http.patch).mockReset()
vi.mocked(http.delete).mockReset()
})
it('uses the C03 preload endpoints and expected version', async () => {
@@ -75,4 +85,69 @@ describe('unified order workflow API contract', () => {
expect.objectContaining({ params: { tenantId: '2' }, headers: { 'Idempotency-Key': expect.any(String) } }),
)
})
it('normalizes smart-loading long identifiers as strings', async () => {
vi.mocked(http.get)
.mockResolvedValueOnce({ data: { id: '2086085512165634050', warehouseId: '2086085512165634051', capacityCbm: '68', maxWeightKg: '25000', version: 2 } })
.mockResolvedValueOnce({ data: [{
accountId: '2086085512165634052', inboundId: '2086085512165634053', inboundDetailId: '2086085512165634054',
orderId: '2086085512165634055', warehouseId: '2086085512165634051', availablePackageCount: '12',
unitWeightKg: '10.5', unitVolumeCbm: '0.25',
}] })
const container = await getContainerBooking('2086085512165634050')
const candidates = await listSmartLoadingCandidates(container.warehouseId!)
expect(container).toMatchObject({ id: '2086085512165634050', warehouseId: '2086085512165634051', capacityCbm: 68 })
expect(candidates[0]).toMatchObject({
accountId: '2086085512165634052', inboundId: '2086085512165634053',
inboundDetailId: '2086085512165634054', orderId: '2086085512165634055', availablePackageCount: 12,
})
})
it('normalizes preload and outbound aggregate identifiers as strings', async () => {
vi.mocked(http.get)
.mockResolvedValueOnce({ data: {
id: '2086085512165634050', inboundId: '2086085512165634051', tenantId: '2086085512165634052',
status: 'PENDING', version: 1, items: [],
} })
.mockResolvedValueOnce({ data: [{
orderId: '2086085512165634053', inboundId: '2086085512165634051',
containerBookingId: '2086085512165634054', loadingPlanId: '2086085512165634055',
oceanBookingId: '2086085512165634056', truckAppointmentId: '2086085512165634057', gate: null,
}] })
const preload = await getPreload('2086085512165634052', '2086085512165634051')
const dispatch = await getOutboundDispatch('2086085512165634052', '2086085512165634053', '2086085512165634051')
expect(preload).toMatchObject({ id: '2086085512165634050', inboundId: '2086085512165634051', tenantId: '2086085512165634052' })
expect(dispatch).toMatchObject({
orderId: '2086085512165634053', containerBookingId: '2086085512165634054',
loadingPlanId: '2086085512165634055', oceanBookingId: '2086085512165634056', truckAppointmentId: '2086085512165634057',
})
})
it('uses optimistic locks and operation identifiers for smart-loading mutations', async () => {
const plan = { id: '91', bookingId: '81', status: 'EDITING', version: 4, items: [] }
vi.mocked(http.post).mockResolvedValue({ data: plan })
vi.mocked(http.patch).mockResolvedValue({ data: plan })
vi.mocked(http.delete).mockResolvedValue({ data: plan })
await assignSmartLoadingItem('91', { inboundId: '71', inboundDetailId: '72', packageCount: 3, expectedVersion: 4 })
await recommendSmartLoading('91', 4)
await adjustSmartLoadingItem('91', '92', { packageCount: 2, expectedPlanVersion: 4, expectedItemVersion: 1 })
await deleteSmartLoadingItem('91', '92', { expectedPlanVersion: 4, expectedItemVersion: 1 })
await pushSmartLoadingPreload('91', 4)
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/first-leg/smart-loading/plans/91/assignments', {
inboundId: '71', inboundDetailId: '72', packageCount: 3, expectedVersion: 4,
operationId: expect.stringContaining('smart-loading-assign'),
})
expect(http.post).toHaveBeenNthCalledWith(2, '/platform/v1/first-leg/smart-loading/plans/91/recommend', {
expectedVersion: 4, operationId: expect.stringContaining('smart-loading-recommend'),
})
expect(http.patch).toHaveBeenCalledWith('/platform/v1/first-leg/smart-loading/plans/91/items/92', expect.objectContaining({
expectedPlanVersion: 4, expectedItemVersion: 1, operationId: expect.stringContaining('smart-loading-adjust'),
}))
expect(http.delete).toHaveBeenCalledWith('/platform/v1/first-leg/smart-loading/plans/91/items/92', {
data: expect.objectContaining({ expectedPlanVersion: 4, expectedItemVersion: 1, operationId: expect.stringContaining('smart-loading-delete') }),
})
expect(http.post).toHaveBeenNthCalledWith(3, '/platform/v1/first-leg/smart-loading/plans/91/push-preload', { expectedVersion: 4 }, {
headers: { 'Idempotency-Key': expect.stringContaining('smart-loading-push') },
})
})
})
+294 -18
View File
@@ -4,21 +4,107 @@ const inboundBasePath = '/platform/v1/inbounds'
export interface OutboundGateItem { code: string; name: string; passed: boolean; actual?: string | null; requirement?: string | null }
export interface OutboundDispatchAggregate {
orderId: number | string | null
orderId: string | null
orderNo: string | null
inboundId: number | string | null
inboundId: string | null
inboundCode: string | null
containerBookingId: number | string | null
containerBookingId: string | null
containerNo: string | null
oceanBookingId: number | string | null
loadingPlanId: string | null
loadingPlanStatus: string | null
oceanBookingId: string | null
oceanBookingStatus: string | null
truckAppointmentId: number | string | null
truckAppointmentId: string | null
truckAppointmentStatus: string | null
customsStatus?: string | null
sailingStatus?: string | null
gate: { passed: boolean; items: OutboundGateItem[] } | null
}
export interface ContainerBooking {
id: string
containerNo: string | null
cabinetNo: string | null
warehouseId: string | null
warehouseName: string | null
destinationWarehouseCode: string | null
destinationWarehouseName: string | null
capacityCbm: number | null
maxWeightKg: number | null
version: number
}
export interface SmartLoadingCandidate {
accountId: string
inboundId: string
inboundDetailId: string
orderId: string
orderNo: string | null
inboundCode: string | null
warehouseId: string
destinationWarehouseId: string | null
destinationWarehouseCode: string | null
destinationWarehouseName: string | null
customerName: string | null
goodsName: string | null
availablePackageCount: number
unitWeightKg: number
unitVolumeCbm: number
}
export interface SmartLoadingPlanItem {
id: string
loadingPlanId: string
accountId: string
customerName: string | null
inboundId: string
inboundDetailId: string
orderId: string
orderNo: string | null
inboundCode: string | null
destinationWarehouseCode: string | null
destinationWarehouseName: string | null
skuCode: string | null
productNameZh: string | null
packageCount: number
grossWeightKg: number
volumeCbm: number
loadSequence: number | null
version: number
}
export interface SmartLoadingPlan {
id: string
planNo: string | null
status: string
bookingId: string
containerNo: string | null
voyageNo: string | null
version: number
items: SmartLoadingPlanItem[]
}
export interface CreateSmartLoadingPlanRequest { bookingId: string; remark?: string }
export interface AssignSmartLoadingItemRequest {
inboundId: string
inboundDetailId: string
packageCount: number
expectedVersion: number
operationId: string
}
export interface RecommendSmartLoadingRequest { expectedVersion: number; operationId: string }
export interface AdjustSmartLoadingItemRequest {
packageCount: number
expectedPlanVersion: number
expectedItemVersion: number
operationId: string
}
export interface DeleteSmartLoadingItemRequest {
expectedPlanVersion: number
expectedItemVersion: number
operationId: string
}
export type PreloadStatus = 'PENDING' | 'CONFIRMING' | 'CONFIRMED' | string
/** 五类装柜取证。旧接口仍使用 PHOTO_STEP_* / SEAL 编码,页面统一映射为业务名称。 */
export type EvidenceType = 'PHOTO_STEP_1' | 'PHOTO_STEP_2' | 'PHOTO_STEP_3' | 'PHOTO_STEP_4' | 'SEAL'
@@ -32,9 +118,9 @@ export interface PreloadItem {
}
export interface PreloadPlan {
id: number | string
inboundId: number | string
tenantId: number | string
id: string
inboundId: string
tenantId: string
status: PreloadStatus
version: number
items: PreloadItem[]
@@ -139,24 +225,214 @@ function idempotencyKey(operation: string, inboundId: string | number) {
return `admin-${operation}-${inboundId}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
}
function operationId(operation: string, resourceId: string | number) {
return `admin-${operation}-${resourceId}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
function nullableId(value: number | string | null | undefined) {
return value === null || value === undefined || value === '' ? null : String(value)
}
function numberValue(value: number | string | null | undefined, fallback = 0) {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : fallback
}
function normalizeContainer(raw: Record<string, unknown>): ContainerBooking {
return {
id: String(raw.id),
containerNo: raw.containerNo == null ? null : String(raw.containerNo),
cabinetNo: raw.cabinetNo == null ? null : String(raw.cabinetNo),
warehouseId: nullableId(raw.warehouseId as number | string | null),
warehouseName: raw.warehouseName == null ? null : String(raw.warehouseName),
destinationWarehouseCode: raw.destinationWarehouseCode == null ? null : String(raw.destinationWarehouseCode),
destinationWarehouseName: raw.destinationWarehouseName == null ? null : String(raw.destinationWarehouseName),
capacityCbm: raw.capacityCbm == null ? null : numberValue(raw.capacityCbm as number | string),
maxWeightKg: raw.maxWeightKg == null ? null : numberValue(raw.maxWeightKg as number | string),
version: numberValue(raw.version as number | string),
}
}
function normalizeCandidate(raw: Record<string, unknown>): SmartLoadingCandidate {
return {
accountId: String(raw.accountId), inboundId: String(raw.inboundId), inboundDetailId: String(raw.inboundDetailId),
orderId: String(raw.orderId), orderNo: raw.orderNo == null ? null : String(raw.orderNo),
inboundCode: raw.inboundCode == null ? null : String(raw.inboundCode), warehouseId: String(raw.warehouseId),
destinationWarehouseId: nullableId(raw.destinationWarehouseId as number | string | null),
destinationWarehouseCode: raw.destinationWarehouseCode == null ? null : String(raw.destinationWarehouseCode),
destinationWarehouseName: raw.destinationWarehouseName == null ? null : String(raw.destinationWarehouseName),
customerName: raw.customerName == null ? null : String(raw.customerName),
goodsName: raw.goodsName == null ? null : String(raw.goodsName),
availablePackageCount: numberValue(raw.availablePackageCount as number | string),
unitWeightKg: numberValue(raw.unitWeightKg as number | string), unitVolumeCbm: numberValue(raw.unitVolumeCbm as number | string),
}
}
function normalizeSmartLoadingItem(raw: Record<string, unknown>): SmartLoadingPlanItem {
return {
id: String(raw.id), loadingPlanId: String(raw.loadingPlanId), accountId: String(raw.accountId),
customerName: raw.customerName == null ? null : String(raw.customerName), inboundId: String(raw.inboundId),
inboundDetailId: String(raw.inboundDetailId), orderId: String(raw.orderId),
orderNo: raw.orderNo == null ? null : String(raw.orderNo), inboundCode: raw.inboundCode == null ? null : String(raw.inboundCode),
destinationWarehouseCode: raw.destinationWarehouseCode == null ? null : String(raw.destinationWarehouseCode),
destinationWarehouseName: raw.destinationWarehouseName == null ? null : String(raw.destinationWarehouseName),
skuCode: raw.skuCode == null ? null : String(raw.skuCode), productNameZh: raw.productNameZh == null ? null : String(raw.productNameZh),
packageCount: numberValue(raw.packageCount as number | string), grossWeightKg: numberValue(raw.grossWeightKg as number | string),
volumeCbm: numberValue(raw.volumeCbm as number | string), loadSequence: raw.loadSequence == null ? null : numberValue(raw.loadSequence as number | string),
version: numberValue(raw.version as number | string),
}
}
function normalizeSmartLoadingPlan(raw: Record<string, unknown>): SmartLoadingPlan {
return {
id: String(raw.id), planNo: raw.planNo == null ? null : String(raw.planNo), status: String(raw.status || ''),
bookingId: String(raw.bookingId), containerNo: raw.containerNo == null ? null : String(raw.containerNo),
voyageNo: raw.voyageNo == null ? null : String(raw.voyageNo), version: numberValue(raw.version as number | string),
items: Array.isArray(raw.items) ? raw.items.map((item) => normalizeSmartLoadingItem(item as Record<string, unknown>)) : [],
}
}
function normalizeOutboundDispatch(raw: Record<string, unknown>): OutboundDispatchAggregate {
return {
orderId: nullableId(raw.orderId as number | string | null),
orderNo: raw.orderNo == null ? null : String(raw.orderNo),
inboundId: nullableId(raw.inboundId as number | string | null),
inboundCode: raw.inboundCode == null ? null : String(raw.inboundCode),
containerBookingId: nullableId(raw.containerBookingId as number | string | null),
containerNo: raw.containerNo == null ? null : String(raw.containerNo),
loadingPlanId: nullableId(raw.loadingPlanId as number | string | null),
loadingPlanStatus: raw.loadingPlanStatus == null ? null : String(raw.loadingPlanStatus),
oceanBookingId: nullableId(raw.oceanBookingId as number | string | null),
oceanBookingStatus: raw.oceanBookingStatus == null ? null : String(raw.oceanBookingStatus),
truckAppointmentId: nullableId(raw.truckAppointmentId as number | string | null),
truckAppointmentStatus: raw.truckAppointmentStatus == null ? null : String(raw.truckAppointmentStatus),
customsStatus: raw.customsStatus == null ? null : String(raw.customsStatus),
sailingStatus: raw.sailingStatus == null ? null : String(raw.sailingStatus),
gate: raw.gate == null ? null : raw.gate as OutboundDispatchAggregate['gate'],
}
}
function normalizePreloadPlan(raw: Record<string, unknown>): PreloadPlan {
return {
id: String(raw.id), inboundId: String(raw.inboundId), tenantId: String(raw.tenantId),
status: String(raw.status || ''), version: numberValue(raw.version as number | string),
items: Array.isArray(raw.items) ? raw.items.map((item) => ({ ...(item as PreloadItem) })) : [],
remark: raw.remark == null ? null : String(raw.remark),
createdAt: raw.createdAt == null ? null : String(raw.createdAt),
updatedAt: raw.updatedAt == null ? null : String(raw.updatedAt),
}
}
function normalizeSplitAction(raw: Record<string, unknown>): SplitActionResponse {
return {
mainStatus: String(raw.mainStatus || ''),
preloadPlan: raw.preloadPlan == null ? null : normalizePreloadPlan(raw.preloadPlan as Record<string, unknown>),
}
}
const smartLoadingBasePath = '/platform/v1/first-leg/smart-loading'
export async function getContainerBooking(bookingId: string | number) {
const response = await http.get<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>(
`/platform/v1/containers/${bookingId}`,
)
return normalizeContainer(unwrapApiResponse(response))
}
export async function listSmartLoadingCandidates(warehouseId: string | number) {
const response = await http.get<ApiEnvelope<Record<string, unknown>[]>, ApiEnvelope<Record<string, unknown>[]>>(
`${smartLoadingBasePath}/candidates`, { params: { warehouseId: String(warehouseId) } },
)
return unwrapApiResponse(response).map(normalizeCandidate)
}
export async function getSmartLoadingPlan(planId: string | number) {
const response = await http.get<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>(
`${smartLoadingBasePath}/plans/${planId}`,
)
return normalizeSmartLoadingPlan(unwrapApiResponse(response))
}
export async function getSmartLoadingPlanByBooking(bookingId: string | number) {
const response = await http.get<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>(
`${smartLoadingBasePath}/plans/by-booking/${bookingId}`,
)
return normalizeSmartLoadingPlan(unwrapApiResponse(response))
}
export async function createSmartLoadingPlan(bookingId: string | number, remark?: string) {
const response = await http.post<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>(
`${smartLoadingBasePath}/plans`, { bookingId: String(bookingId), remark: remark || undefined },
)
return normalizeSmartLoadingPlan(unwrapApiResponse(response))
}
export async function assignSmartLoadingItem(planId: string | number, request: Omit<AssignSmartLoadingItemRequest, 'operationId'>) {
const response = await http.post<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>(
`${smartLoadingBasePath}/plans/${planId}/assignments`,
{ ...request, operationId: operationId('smart-loading-assign', planId) },
)
return normalizeSmartLoadingPlan(unwrapApiResponse(response))
}
export async function recommendSmartLoading(planId: string | number, expectedVersion: number) {
const response = await http.post<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>(
`${smartLoadingBasePath}/plans/${planId}/recommend`,
{ expectedVersion, operationId: operationId('smart-loading-recommend', planId) },
)
return normalizeSmartLoadingPlan(unwrapApiResponse(response))
}
export async function adjustSmartLoadingItem(
planId: string | number,
itemId: string | number,
request: Omit<AdjustSmartLoadingItemRequest, 'operationId'>,
) {
const response = await http.patch<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>(
`${smartLoadingBasePath}/plans/${planId}/items/${itemId}`,
{ ...request, operationId: operationId('smart-loading-adjust', itemId) },
)
return normalizeSmartLoadingPlan(unwrapApiResponse(response))
}
export async function deleteSmartLoadingItem(
planId: string | number,
itemId: string | number,
request: Omit<DeleteSmartLoadingItemRequest, 'operationId'>,
) {
const response = await http.delete<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>(
`${smartLoadingBasePath}/plans/${planId}/items/${itemId}`,
{ data: { ...request, operationId: operationId('smart-loading-delete', itemId) } },
)
return normalizeSmartLoadingPlan(unwrapApiResponse(response))
}
export async function pushSmartLoadingPreload(planId: string | number, expectedVersion: number) {
const response = await http.post<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>(
`${smartLoadingBasePath}/plans/${planId}/push-preload`, { expectedVersion },
{ headers: { 'Idempotency-Key': idempotencyKey('smart-loading-push', planId) } },
)
return normalizeSmartLoadingPlan(unwrapApiResponse(response))
}
function writeOptions(operation: string, inboundId: string | number, tenantId: string | number) {
return { params: query(tenantId), headers: { 'Idempotency-Key': idempotencyKey(operation, inboundId) } }
}
export async function getPreload(tenantId: string | number, inboundId: string | number) {
const response = await http.get<ApiEnvelope<PreloadPlan>, ApiEnvelope<PreloadPlan>>(
const response = await http.get<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>(
`${inboundBasePath}/${inboundId}/preload`, { params: query(tenantId) },
)
return unwrapApiResponse(response)
return normalizePreloadPlan(unwrapApiResponse(response))
}
export async function getOutboundDispatch(tenantId: string | number, orderId?: string | number, inboundId?: string | number) {
const response = await http.get<ApiEnvelope<OutboundDispatchAggregate[]>, ApiEnvelope<OutboundDispatchAggregate[]>>(
const response = await http.get<ApiEnvelope<Record<string, unknown>[]>, ApiEnvelope<Record<string, unknown>[]>>(
'/platform/v1/first-leg/outbound-dispatches',
{ params: { tenantId: String(tenantId), ...(orderId ? { orderId: String(orderId) } : {}), ...(inboundId ? { inboundId: String(inboundId) } : {}) } },
)
const result = unwrapApiResponse(response)
return result?.[0] || null
return result?.[0] ? normalizeOutboundDispatch(result[0]) : null
}
export async function smartSplit(
@@ -164,10 +440,10 @@ export async function smartSplit(
inboundId: string | number,
request: SmartSplitRequest,
) {
const response = await http.post<ApiEnvelope<SplitActionResponse>, ApiEnvelope<SplitActionResponse>>(
const response = await http.post<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>(
`${inboundBasePath}/${inboundId}/split/smart`, request, writeOptions('smart-split', inboundId, tenantId),
)
return unwrapApiResponse(response)
return normalizeSplitAction(unwrapApiResponse(response))
}
export async function confirmingPreload(
@@ -175,11 +451,11 @@ export async function confirmingPreload(
inboundId: string | number,
request: ConfirmPreloadRequest,
) {
const response = await http.post<ApiEnvelope<SplitActionResponse>, ApiEnvelope<SplitActionResponse>>(
const response = await http.post<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>(
`${inboundBasePath}/${inboundId}/preload/confirming`, request,
writeOptions('preload-confirming', inboundId, tenantId),
)
return unwrapApiResponse(response)
return normalizeSplitAction(unwrapApiResponse(response))
}
export async function confirmPreload(
@@ -187,11 +463,11 @@ export async function confirmPreload(
inboundId: string | number,
request: ConfirmPreloadRequest,
) {
const response = await http.post<ApiEnvelope<SplitActionResponse>, ApiEnvelope<SplitActionResponse>>(
const response = await http.post<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>(
`${inboundBasePath}/${inboundId}/preload/confirm`, request,
writeOptions('preload-confirm', inboundId, tenantId),
)
return unwrapApiResponse(response)
return normalizeSplitAction(unwrapApiResponse(response))
}
export async function getPicking(tenantId: string | number, inboundId: string | number) {
@@ -0,0 +1,377 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { Check, Plus, RefreshCw, Sparkles, Trash2 } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
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 { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import {
adjustSmartLoadingItem,
assignSmartLoadingItem,
createSmartLoadingPlan,
deleteSmartLoadingItem,
getContainerBooking,
getSmartLoadingPlanByBooking,
listSmartLoadingCandidates,
pushSmartLoadingPreload,
recommendSmartLoading,
type ContainerBooking,
type SmartLoadingCandidate,
type SmartLoadingPlan,
type SmartLoadingPlanItem,
} from '@/modules/unified-orders/workflows/api'
const props = defineProps<{
bookingId: string
highlightInboundId?: string | number | null
readOnly?: boolean
}>()
const emit = defineEmits<{ updated: [] }>()
const access = useAccessPreviewStore()
const container = ref<ContainerBooking | null>(null)
const plan = ref<SmartLoadingPlan | null>(null)
const candidates = ref<SmartLoadingCandidate[]>([])
const loading = ref(false)
const action = ref('')
const loadError = ref<ApiErrorDetails | null>(null)
const actionError = ref<ApiErrorDetails | null>(null)
const conflictRefreshed = ref(false)
const assignOpen = ref(false)
const adjustOpen = ref(false)
const removeOpen = ref(false)
const pushOpen = ref(false)
const selectedCandidate = ref<SmartLoadingCandidate | null>(null)
const selectedItem = ref<SmartLoadingPlanItem | null>(null)
const packageCount = ref(1)
const filters = ref<QueryModel>({ keyword: '', eligibility: '' })
const appliedFilters = ref<QueryModel>({ ...filters.value })
const canView = computed(() => access.can('platform:firstleg:smart-loading:list'))
const canCreate = computed(() => !props.readOnly && access.can('platform:firstleg:smart-loading:create'))
const canAssign = computed(() => !props.readOnly && access.can('platform:firstleg:smart-loading:assign'))
const canDelete = computed(() => !props.readOnly && access.can('platform:firstleg:smart-loading:delete'))
const canPush = computed(() => !props.readOnly && access.can('platform:firstleg:smart-loading:push'))
const editable = computed(() => plan.value?.status === 'EDITING')
const usedVolume = computed(() => plan.value?.items.reduce((sum, item) => sum + item.volumeCbm, 0) ?? 0)
const usedWeight = computed(() => plan.value?.items.reduce((sum, item) => sum + item.grossWeightKg, 0) ?? 0)
const totalPackages = computed(() => plan.value?.items.reduce((sum, item) => sum + item.packageCount, 0) ?? 0)
const volumeRate = computed(() => utilization(usedVolume.value, container.value?.capacityCbm))
const weightRate = computed(() => utilization(usedWeight.value, container.value?.maxWeightKg))
const queryFields: QueryField[] = [
{ key: 'keyword', type: 'input', label: '候选货物', placeholder: '订单号、入仓码、客户或品名', props: { 'aria-label': '候选货物关键词' } },
{
key: 'eligibility', type: 'select', label: '可装状态', placeholder: '全部状态',
options: [{ label: '可加入', value: 'ELIGIBLE' }, { label: '存在冲突', value: 'CONFLICT' }],
},
]
const candidateColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 92, fixed: 'left', slotName: 'actions' },
{ key: 'priority', label: '推荐顺序', width: 92, slotName: 'priority' },
{ key: 'order', label: '订单/入仓码', minWidth: 180, slotName: 'order' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 140 },
{ key: 'goodsName', prop: 'goodsName', label: '品名', minWidth: 160 },
{ key: 'destination', label: '目的仓', minWidth: 150, slotName: 'destination' },
{ key: 'availablePackageCount', prop: 'availablePackageCount', label: '可用箱数', width: 100 },
{ key: 'unitVolumeCbm', label: '单箱体积(m³)', width: 120, slotName: 'unitVolume' },
{ key: 'unitWeightKg', label: '单箱重量(kg)', width: 120, slotName: 'unitWeight' },
{ key: 'explanation', label: '建议与冲突说明', minWidth: 270, slotName: 'explanation' },
]
const itemColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 150, fixed: 'left', slotName: 'actions' },
{ key: 'loadSequence', prop: 'loadSequence', label: '装柜顺序', width: 100 },
{ key: 'order', label: '订单/入仓码', minWidth: 180, slotName: 'order' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 140 },
{ key: 'productNameZh', prop: 'productNameZh', label: '品名', minWidth: 160 },
{ key: 'skuCode', prop: 'skuCode', label: 'SKU', minWidth: 130 },
{ key: 'packageCount', prop: 'packageCount', label: '箱数', width: 90 },
{ key: 'grossWeightKg', label: '毛重(kg)', width: 110, slotName: 'weight' },
{ key: 'volumeCbm', label: '体积(m³)', width: 110, slotName: 'volume' },
{ key: 'destination', label: '目的仓', minWidth: 150, slotName: 'destination' },
]
function utilization(used: number, capacity: number | null | undefined) {
return capacity && capacity > 0 ? Math.round(used / capacity * 1000) / 10 : null
}
function formatNumber(value: number, precision = 3) {
return Number.isFinite(value) ? value.toLocaleString('zh-CN', { maximumFractionDigits: precision }) : '—'
}
function routeMatches(candidate: SmartLoadingCandidate) {
const target = container.value?.destinationWarehouseCode
return !target || candidate.destinationWarehouseCode === target
}
function fittingCount(candidate: SmartLoadingCandidate) {
if (!routeMatches(candidate) || candidate.availablePackageCount <= 0) return 0
const volumeLeft = (container.value?.capacityCbm ?? 0) - usedVolume.value
const weightLeft = (container.value?.maxWeightKg ?? 0) - usedWeight.value
if (!container.value?.capacityCbm || !container.value.maxWeightKg) return 0
const byVolume = candidate.unitVolumeCbm > 0 ? Math.floor(Math.max(volumeLeft, 0) / candidate.unitVolumeCbm) : candidate.availablePackageCount
const byWeight = candidate.unitWeightKg > 0 ? Math.floor(Math.max(weightLeft, 0) / candidate.unitWeightKg) : candidate.availablePackageCount
return Math.max(0, Math.min(candidate.availablePackageCount, byVolume, byWeight))
}
function candidateExplanation(candidate: SmartLoadingCandidate) {
if (!routeMatches(candidate)) return `不可装:目的仓与柜次 ${container.value?.destinationWarehouseName || container.value?.destinationWarehouseCode || '目的仓'} 不一致`
if (!container.value?.capacityCbm || !container.value.maxWeightKg) return '不可装:柜次容量或最大承重尚未配置'
const count = fittingCount(candidate)
if (count <= 0) return '不可装:剩余容积或承重不足'
return `可装 ${count} 箱;同仓同目的仓,按入库完成时间先进先出推荐`
}
const filteredCandidates = computed(() => {
const keyword = String(appliedFilters.value.keyword || '').trim().toLowerCase()
const eligibility = String(appliedFilters.value.eligibility || '')
return candidates.value.filter((candidate) => {
const searchable = [candidate.orderNo, candidate.inboundCode, candidate.customerName, candidate.goodsName].join(' ').toLowerCase()
if (keyword && !searchable.includes(keyword)) return false
const eligible = fittingCount(candidate) > 0
return !eligibility || (eligibility === 'ELIGIBLE' ? eligible : !eligible)
})
})
function isConflict(error: unknown) {
const details = getApiErrorDetails(error)
return error instanceof ApiResponseError && error.code === 'A0409'
|| details.code === 'A0409'
|| /版本|并发|已被其他人修改/.test(details.message)
}
async function load() {
if (!canView.value || !props.bookingId) return
loading.value = true
loadError.value = null
try {
const nextContainer = await getContainerBooking(props.bookingId)
container.value = nextContainer
try {
plan.value = await getSmartLoadingPlanByBooking(props.bookingId)
} catch (error) {
const details = getApiErrorDetails(error)
if (error instanceof ApiResponseError && ['A0404', 'NOT_FOUND'].includes(error.code)) plan.value = null
else throw Object.assign(error instanceof Error ? error : new Error(details.message), { details })
}
candidates.value = nextContainer.warehouseId ? await listSmartLoadingCandidates(nextContainer.warehouseId) : []
} catch (error) {
container.value = null
plan.value = null
candidates.value = []
loadError.value = getApiErrorDetails(error, '智能排柜数据加载失败,请稍后重试')
} finally {
loading.value = false
}
}
async function runAction(name: string, operation: () => Promise<SmartLoadingPlan>, success: string) {
action.value = name
actionError.value = null
conflictRefreshed.value = false
try {
plan.value = await operation()
ElMessage.success(success)
emit('updated')
if (container.value?.warehouseId) candidates.value = await listSmartLoadingCandidates(container.value.warehouseId)
return true
} catch (error) {
actionError.value = getApiErrorDetails(error, `${success}失败,请重试`)
if (isConflict(error)) {
await load()
conflictRefreshed.value = true
}
return false
} finally {
action.value = ''
}
}
async function createPlan() {
await runAction('create', () => createSmartLoadingPlan(props.bookingId), '装柜草稿已创建')
}
async function recommend() {
if (!plan.value) return
await runAction('recommend', () => recommendSmartLoading(plan.value!.id, plan.value!.version), '智能建议已生成')
}
function openAssign(candidate: SmartLoadingCandidate) {
selectedCandidate.value = candidate
packageCount.value = Math.max(1, fittingCount(candidate))
assignOpen.value = true
}
async function confirmAssign() {
if (!plan.value || !selectedCandidate.value) return
const candidate = selectedCandidate.value
const success = await runAction('assign', () => assignSmartLoadingItem(plan.value!.id, {
inboundId: candidate.inboundId,
inboundDetailId: candidate.inboundDetailId,
packageCount: packageCount.value,
expectedVersion: plan.value!.version,
}), '候选货物已加入装柜计划')
if (success) assignOpen.value = false
}
function openAdjust(item: SmartLoadingPlanItem) {
selectedItem.value = item
packageCount.value = item.packageCount
adjustOpen.value = true
}
async function confirmAdjust() {
if (!plan.value || !selectedItem.value) return
const item = selectedItem.value
const success = await runAction('adjust', () => adjustSmartLoadingItem(plan.value!.id, item.id, {
packageCount: packageCount.value,
expectedPlanVersion: plan.value!.version,
expectedItemVersion: item.version,
}), '装柜箱数已调整')
if (success) adjustOpen.value = false
}
function openRemove(item: SmartLoadingPlanItem) {
selectedItem.value = item
removeOpen.value = true
}
async function confirmRemove() {
if (!plan.value || !selectedItem.value) return
const item = selectedItem.value
const success = await runAction('remove', () => deleteSmartLoadingItem(plan.value!.id, item.id, {
expectedPlanVersion: plan.value!.version,
expectedItemVersion: item.version,
}), '装柜明细已移除')
if (success) removeOpen.value = false
}
async function confirmPush() {
if (!plan.value) return
const success = await runAction('push', () => pushSmartLoadingPreload(plan.value!.id, plan.value!.version), '装柜计划已推送预装')
if (success) pushOpen.value = false
}
function applyFilters(model: QueryModel) {
appliedFilters.value = { keyword: model.keyword || '', eligibility: model.eligibility || '' }
}
watch(() => [props.bookingId, access.permissionSignature], () => { void load() })
onMounted(() => { void load() })
</script>
<template>
<section class="smart-loading" aria-labelledby="smart-loading-title">
<header class="smart-loading__heading">
<div>
<h3 id="smart-loading-title">智能排柜</h3>
<p>{{ container?.cabinetNo || container?.containerNo || `柜次 ${bookingId}` }} · {{ container?.warehouseName || '仓库待加载' }}</p>
</div>
<div class="smart-loading__actions">
<el-button circle :loading="loading" aria-label="刷新智能排柜" @click="load"><RefreshCw :size="16" /></el-button>
<el-button v-if="!plan" type="primary" :disabled="!canCreate || loading" :loading="action === 'create'" @click="createPlan"><Plus :size="15" />创建装柜草稿</el-button>
<el-button v-if="plan" :disabled="!canAssign || !editable || Boolean(action)" :loading="action === 'recommend'" @click="recommend"><Sparkles :size="15" />生成智能建议</el-button>
<el-button v-if="plan" type="primary" :disabled="!canPush || !editable || !plan.items.length || !container?.containerNo || Boolean(action)" @click="pushOpen = true"><Check :size="15" />推送预装</el-button>
</div>
</header>
<el-alert v-if="!canView" type="warning" :closable="false" show-icon>当前账号无智能排柜查询权限。</el-alert>
<LoadErrorState v-else-if="loadError" title="智能排柜暂时无法加载" :message="loadError.message" :loading="loading" @retry="load">
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
<template v-else>
<el-alert v-if="actionError" class="smart-loading__error" type="error" :closable="false" show-icon>
<template #title>{{ actionError.message }}</template>
<p v-if="conflictRefreshed">系统已刷新最新计划,当前输入仍保留,请核对后重新提交。</p>
<ApiErrorMeta :details="actionError" />
</el-alert>
<div v-if="container" class="smart-loading__summary">
<div><span>计划状态</span><strong>{{ plan?.status || '尚未创建' }}</strong></div>
<div><span>已装箱数</span><strong>{{ totalPackages }}</strong></div>
<div><span>容积</span><strong>{{ formatNumber(usedVolume) }} / {{ formatNumber(container.capacityCbm || 0) }} m³</strong><small>{{ volumeRate == null ? '容量待配置' : `${volumeRate}%` }}</small></div>
<div><span>重量</span><strong>{{ formatNumber(usedWeight) }} / {{ formatNumber(container.maxWeightKg || 0) }} kg</strong><small>{{ weightRate == null ? '承重待配置' : `${weightRate}%` }}</small></div>
</div>
<section v-if="plan" class="smart-loading__section">
<div class="smart-loading__section-title"><h4>当前装柜方案</h4><span>版本 {{ plan.version }} · {{ plan.planNo || '—' }}</span></div>
<DataTable :data="plan.items" :columns="itemColumns" :loading="loading" empty-text="装柜草稿暂无明细">
<template #actions="{ row }">
<el-button link type="primary" :disabled="!canAssign || !editable || Boolean(action)" @click="openAdjust(row as SmartLoadingPlanItem)">调整</el-button>
<el-button link type="danger" :disabled="!canDelete || !editable || Boolean(action)" @click="openRemove(row as SmartLoadingPlanItem)"><Trash2 :size="13" />移除</el-button>
</template>
<template #order="{ row }"><strong>{{ (row as SmartLoadingPlanItem).orderNo || '—' }}</strong><small>{{ (row as SmartLoadingPlanItem).inboundCode || '—' }}</small></template>
<template #weight="{ row }">{{ formatNumber((row as SmartLoadingPlanItem).grossWeightKg) }}</template>
<template #volume="{ row }">{{ formatNumber((row as SmartLoadingPlanItem).volumeCbm) }}</template>
<template #destination="{ row }">{{ (row as SmartLoadingPlanItem).destinationWarehouseName || (row as SmartLoadingPlanItem).destinationWarehouseCode || '—' }}</template>
</DataTable>
</section>
<section class="smart-loading__section">
<div class="smart-loading__section-title"><h4>候选货物</h4><span>后端按入库完成时间排序</span></div>
<QueryToolbar v-model="filters" ariaLabel="候选货物筛选" :fields="queryFields" @search="applyFilters" @reset="applyFilters" />
<DataTable :data="filteredCandidates" :columns="candidateColumns" :loading="loading" empty-text="当前柜次仓库暂无可用候选货物">
<template #actions="{ row }"><el-button link type="primary" :disabled="!plan || !canAssign || !editable || fittingCount(row as SmartLoadingCandidate) <= 0 || Boolean(action)" @click="openAssign(row as SmartLoadingCandidate)">加入</el-button></template>
<template #priority="{ $index }"><span class="priority">{{ Number($index) + 1 }}</span></template>
<template #order="{ row }"><strong>{{ (row as SmartLoadingCandidate).orderNo || '—' }}</strong><small>{{ (row as SmartLoadingCandidate).inboundCode || '—' }}</small></template>
<template #destination="{ row }">{{ (row as SmartLoadingCandidate).destinationWarehouseName || (row as SmartLoadingCandidate).destinationWarehouseCode || '—' }}</template>
<template #unitVolume="{ row }">{{ formatNumber((row as SmartLoadingCandidate).unitVolumeCbm) }}</template>
<template #unitWeight="{ row }">{{ formatNumber((row as SmartLoadingCandidate).unitWeightKg) }}</template>
<template #explanation="{ row }"><el-tag size="small" :type="fittingCount(row as SmartLoadingCandidate) > 0 ? 'success' : 'danger'">{{ fittingCount(row as SmartLoadingCandidate) > 0 ? '可加入' : '冲突' }}</el-tag><span>{{ candidateExplanation(row as SmartLoadingCandidate) }}</span></template>
</DataTable>
</section>
</template>
<AppDialog v-model="assignOpen" title="加入装柜方案" :confirm-loading="action === 'assign'" :confirm-disabled="packageCount < 1 || packageCount > (selectedCandidate ? fittingCount(selectedCandidate) : 0)" confirm-text="确认加入" @confirm="confirmAssign">
<p>{{ selectedCandidate?.orderNo || selectedCandidate?.inboundCode }} · 最多可加入 {{ selectedCandidate ? fittingCount(selectedCandidate) : 0 }} 箱</p>
<el-input-number v-model="packageCount" :min="1" :max="selectedCandidate ? fittingCount(selectedCandidate) : 1" controls-position="right" aria-label="加入箱数" />
<ApiErrorMeta v-if="actionError" :details="actionError" />
</AppDialog>
<AppDialog v-model="adjustOpen" title="调整装柜箱数" :confirm-loading="action === 'adjust'" :confirm-disabled="packageCount < 1" confirm-text="保存调整" @confirm="confirmAdjust">
<p>{{ selectedItem?.orderNo || selectedItem?.inboundCode }} · 当前 {{ selectedItem?.packageCount || 0 }} 箱</p>
<el-input-number v-model="packageCount" :min="1" controls-position="right" aria-label="调整后箱数" />
<ApiErrorMeta v-if="actionError" :details="actionError" />
</AppDialog>
<AppDialog v-model="removeOpen" title="移除装柜明细" confirm-type="danger" confirm-text="确认移除" :confirm-loading="action === 'remove'" @confirm="confirmRemove">
<p>将从当前草稿移除 {{ selectedItem?.orderNo || selectedItem?.inboundCode }} 的 {{ selectedItem?.packageCount || 0 }} 箱货物。此操作会写入审计历史。</p>
<ApiErrorMeta v-if="actionError" :details="actionError" />
</AppDialog>
<AppDialog v-model="pushOpen" title="推送预装清单" confirm-type="primary" confirm-text="确认推送" :confirm-loading="action === 'push'" @confirm="confirmPush">
<p>推送后装柜计划将进入装柜中,当前草稿明细不可再增删调整。请确认柜号和货物分配无误。</p>
<ApiErrorMeta v-if="actionError" :details="actionError" />
</AppDialog>
</section>
</template>
<style scoped>
.smart-loading { display: grid; gap: 14px; }
.smart-loading__heading, .smart-loading__actions, .smart-loading__section-title { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
.smart-loading__heading, .smart-loading__section-title { justify-content: space-between; }
.smart-loading__heading h3, .smart-loading__section-title h4 { margin: 0; font-size: 15px; }
.smart-loading__heading p, .smart-loading__section-title span, .smart-loading small { display: block; margin: 3px 0 0; color: var(--cc-text-secondary, #64748b); font-size: 12px; }
.smart-loading__summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
.smart-loading__summary > div { min-width: 0; padding: 10px; border: 1px solid var(--cc-border); border-radius: 5px; background: var(--el-fill-color-extra-light); }
.smart-loading__summary span { display: block; margin-bottom: 4px; color: var(--cc-text-secondary, #64748b); font-size: 12px; }
.smart-loading__summary strong { display: block; overflow-wrap: anywhere; font-size: 13px; }
.smart-loading__section { display: grid; gap: 10px; padding-top: 4px; }
.smart-loading__error p { margin: 6px 0 0; }
.smart-loading :deep(.el-table__cell small), .smart-loading :deep(.el-table__cell strong) { display: block; }
.smart-loading :deep(.el-table__cell small) { margin-top: 2px; color: var(--cc-text-secondary, #64748b); }
.smart-loading :deep(.el-table__cell .el-tag + span) { margin-left: 6px; }
.priority { display: inline-grid; width: 26px; height: 26px; place-items: center; border-radius: 50%; background: var(--el-color-primary-light-9); color: var(--el-color-primary); font-weight: 700; }
@media (max-width: 760px) {
.smart-loading__summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.smart-loading__actions { width: 100%; }
.smart-loading__actions :deep(.el-button:not(.is-circle)) { flex: 1 1 150px; margin-left: 0; }
}
@media (max-width: 420px) { .smart-loading__summary { grid-template-columns: 1fr; } }
</style>
@@ -0,0 +1,97 @@
import ElementPlus from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useAuthStore } from '@/core/auth/auth'
import UnifiedOrderWorkflowPanel from './UnifiedOrderWorkflowPanel.vue'
import {
getOutboundDispatch,
getPreload,
smartSplit,
} from '../api'
vi.mock('../api', async (importOriginal) => {
const original = await importOriginal<typeof import('../api')>()
return {
...original,
getOutboundDispatch: vi.fn(),
getPreload: vi.fn(),
smartSplit: vi.fn(),
}
})
function preload(cargoReference: string, version = 3) {
return {
id: '2086085512165634050', inboundId: '2086085512165634051', tenantId: '2086085512165634052',
status: 'PENDING', version,
items: [{ containerCode: 'CAB-01', cargoReference, packageCount: 2, volumeCbm: 1.25, remark: null }],
remark: null, createdAt: null, updatedAt: null,
}
}
function mountPanel(permissions: string[]) {
const pinia = createPinia()
setActivePinia(pinia)
useAuthStore().permissions = permissions
return mount(UnifiedOrderWorkflowPanel, {
props: {
tenantId: '2086085512165634052', inboundId: '2086085512165634051', orderId: '2086085512165634053',
mainStatus: 'WAIT_SPLIT', inboundVersion: 4,
},
global: { plugins: [pinia, ElementPlus] },
})
}
describe('UnifiedOrderWorkflowPanel preload editor', () => {
beforeEach(() => {
vi.mocked(getOutboundDispatch).mockReset().mockResolvedValue(null as never)
vi.mocked(getPreload).mockReset().mockResolvedValue(preload('REF-OLD'))
vi.mocked(smartSplit).mockReset()
})
it('renders the formal preload table and keeps the operation column first', async () => {
const wrapper = mountPanel(['firstleg:inbound:preload-view', 'firstleg:inbound:split'])
await flushPromises()
expect(getPreload).toHaveBeenCalledWith('2086085512165634052', '2086085512165634051')
expect(wrapper.text()).toContain('入库预装清单')
expect(wrapper.text()).toContain('REF-OLD')
const headers = wrapper.findAll('.el-table__header-wrapper th').map((node) => node.text())
expect(headers[0]).toContain('操作')
expect(wrapper.find('button[aria-label="编辑第 1 条预装明细"]').exists()).toBe(true)
})
it('does not query or expose editing controls without permission', async () => {
const wrapper = mountPanel([])
await flushPromises()
expect(getPreload).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('当前账号无预装清单查询权限')
expect(wrapper.find('button[aria-label="编辑第 1 条预装明细"]').exists()).toBe(false)
})
it('blocks an empty list before calling the real endpoint', async () => {
vi.mocked(getPreload).mockResolvedValue({ ...preload('REF-OLD'), items: [] })
const wrapper = mountPanel(['firstleg:inbound:preload-view', 'firstleg:inbound:split'])
await flushPromises()
const save = wrapper.findAll('button').find((button) => button.text().includes('保存分柜清单'))
expect(save).toBeTruthy()
await save!.trigger('click')
expect(smartSplit).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('请至少添加一条预装明细')
})
it('refreshes the version but preserves submitted rows after a conflict', async () => {
vi.mocked(getPreload)
.mockResolvedValueOnce(preload('REF-OLD', 3))
.mockResolvedValueOnce(preload('REF-SERVER', 4))
vi.mocked(smartSplit).mockRejectedValue(new Error('数据已被其他人修改,请刷新后重试'))
const wrapper = mountPanel(['firstleg:inbound:preload-view', 'firstleg:inbound:split'])
await flushPromises()
const save = wrapper.findAll('button').find((button) => button.text().includes('保存分柜清单'))
await save!.trigger('click')
await flushPromises()
expect(getPreload).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('系统已刷新最新数据,请核对后重新提交')
expect(wrapper.text()).toContain('REF-OLD')
expect(wrapper.text()).not.toContain('REF-SERVER')
})
})
@@ -1,8 +1,14 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { RefreshCw, Save, Check, Play, Camera } from '@lucide/vue'
import { RefreshCw, Save, Check, Play, Camera, Plus, Pencil, Trash2 } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { ApiResponseError } from '@/shared/api/http'
import { ApiResponseError, 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 SmartLoadingWorkbench from '@/modules/unified-orders/workflows/components/SmartLoadingWorkbench.vue'
import { sanitizePreloadItem, validatePreloadItem, validatePreloadItems } from '@/modules/unified-orders/workflows/preloadForm'
import {
completeLoading,
completePicking,
@@ -52,7 +58,14 @@ const splitRemark = ref('')
const busy = ref('')
const loading = ref(false)
const error = ref('')
const errorDetails = ref<ApiErrorDetails | null>(null)
const preloadLoadError = ref<ApiErrorDetails | null>(null)
const notice = ref('')
const preloadDialogOpen = ref(false)
const preloadDialogMode = ref<'create' | 'edit'>('create')
const editingPreloadIndex = ref<number | null>(null)
const preloadDraft = ref<PreloadItem>(itemTemplate())
const preloadDraftError = ref('')
const evidenceTypes: Array<{ type: EvidenceType; label: string }> = [
{ type: 'PHOTO_STEP_1', label: '空仓照' },
@@ -78,18 +91,62 @@ const canEvidence = computed(() => !props.readOnly && access.can('platform:first
const canEvidenceView = computed(() => access.can('platform:firstleg:inbound:loading:view'))
const canLoadingComplete = computed(() => !props.readOnly && !isLoaded.value && access.can('platform:firstleg:inbound:loading:complete'))
const preloadColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 132, fixed: 'left', slotName: 'actions' },
{ key: 'containerCode', prop: 'containerCode', label: '柜号', minWidth: 150 },
{ key: 'cargoReference', prop: 'cargoReference', label: '货物引用', minWidth: 180 },
{ key: 'packageCount', prop: 'packageCount', label: '箱数', width: 90 },
{ key: 'volumeCbm', label: '体积(m³)', width: 110, slotName: 'volume' },
{ key: 'remark', prop: 'remark', label: '备注', minWidth: 180, showOverflowTooltip: true },
]
function itemTemplate(): PreloadItem {
return { containerCode: '', cargoReference: '', packageCount: null, volumeCbm: 0, remark: '' }
}
function addItem() {
preloadItems.value.push(itemTemplate())
preloadDialogMode.value = 'create'
editingPreloadIndex.value = null
preloadDraft.value = itemTemplate()
preloadDraftError.value = ''
preloadDialogOpen.value = true
}
function editItem(index: number) {
preloadDialogMode.value = 'edit'
editingPreloadIndex.value = index
preloadDraft.value = { ...preloadItems.value[index] }
preloadDraftError.value = ''
preloadDialogOpen.value = true
}
function saveItem() {
const validation = validatePreloadItem(preloadDraft.value)
if (!validation.valid) {
preloadDraftError.value = validation.message
return
}
const next = sanitizePreloadItem(preloadDraft.value)
if (preloadDialogMode.value === 'edit' && editingPreloadIndex.value !== null) {
preloadItems.value.splice(editingPreloadIndex.value, 1, next)
} else {
preloadItems.value.push(next)
}
preloadDialogOpen.value = false
}
function removeItem(index: number) {
preloadItems.value.splice(index, 1)
}
function preloadStatusLabel(status?: string | null) {
return ({ PENDING: '待提交', CONFIRMING: '确认中', CONFIRMED: '已确认' } as Record<string, string>)[status || ''] || '尚未生成'
}
function formatVolume(value: number | null) {
return value === null || !Number.isFinite(Number(value)) ? '—' : Number(value).toFixed(3)
}
function evidence(type: EvidenceType) {
return evidences.value.find((item) => item.evidenceType === type && item.status === 'ACTIVE')
}
@@ -102,9 +159,10 @@ function gateItemName(code: string, fallback: string) {
return code === 'LOADING_EVIDENCE_COMPLETE' ? '五类装柜证据与锁码完整' : fallback
}
async function run<T>(action: string, operation: () => Promise<T>, message: string) {
async function run<T>(action: string, operation: () => Promise<T>, message: string, restoreAfterConflict?: () => void) {
busy.value = action
error.value = ''
errorDetails.value = null
notice.value = ''
try {
const result = await operation()
@@ -112,7 +170,14 @@ async function run<T>(action: string, operation: () => Promise<T>, message: stri
emit('updated')
return result
} catch (cause) {
error.value = cause instanceof Error ? cause.message : '操作失败,请重试'
const details = getApiErrorDetails(cause, '操作失败,请重试')
error.value = details.message
errorDetails.value = details
if (details.code === 'A0409' || /版本|并发|已被其他人修改/.test(details.message)) {
await refresh()
restoreAfterConflict?.()
notice.value = '系统已刷新最新数据,请核对后重新提交'
}
return null
} finally {
busy.value = ''
@@ -123,6 +188,8 @@ async function loadPreload() {
if (!canPreloadView.value || !props.inboundId) return
loading.value = true
error.value = ''
errorDetails.value = null
preloadLoadError.value = null
try {
preload.value = await getPreload(props.tenantId, props.inboundId)
preloadItems.value = preload.value.items?.map((item) => ({ ...item })) || []
@@ -131,7 +198,9 @@ async function loadPreload() {
preloadItems.value = []
const missing = cause instanceof ApiResponseError
&& (cause.status === 404 || cause.code === 'A0404' || cause.code === 'NOT_FOUND')
error.value = missing ? '' : cause instanceof Error ? cause.message : '预装清单加载失败,请重试'
if (!missing) {
preloadLoadError.value = getApiErrorDetails(cause, '预装清单加载失败,请重试')
}
} finally {
loading.value = false
}
@@ -140,6 +209,7 @@ async function loadPreload() {
async function loadLoading() {
loading.value = true
error.value = ''
errorDetails.value = null
const messages: string[] = []
if (canPickingView.value) {
try {
@@ -157,26 +227,41 @@ async function loadLoading() {
messages.push(cause instanceof Error ? cause.message : '装柜证据加载失败')
}
}
error.value = messages.join(';')
loading.value = false
}
async function loadDispatch() {
try {
dispatch.value = await getOutboundDispatch(props.tenantId, props.orderId || undefined, props.inboundId)
} catch {
dispatch.value = null
}
error.value = messages.join(';')
loading.value = false
}
async function refresh() {
await loadDispatch()
if (isSplit.value) await loadPreload()
if (isLoading.value) await loadLoading()
}
async function runSmartSplit() {
const validation = validatePreloadItems(preloadItems.value)
if (!validation.valid) {
error.value = validation.message
errorDetails.value = null
return
}
const submittedItems = preloadItems.value.map((item) => sanitizePreloadItem(item))
const submittedRemark = splitRemark.value
const result = await run('smart-split', () => smartSplit(props.tenantId, props.inboundId, {
expectedVersion: preloadVersion.value,
items: preloadItems.value,
remark: splitRemark.value || undefined,
}), '智能分柜已提交')
items: submittedItems,
remark: submittedRemark.trim() || undefined,
}), '分柜清单已保存', () => {
preloadItems.value = submittedItems.map((item) => ({ ...item }))
splitRemark.value = submittedRemark
})
if (result) {
preload.value = result.preloadPlan
preloadItems.value = result.preloadPlan?.items?.map((item) => ({ ...item })) || []
@@ -317,6 +402,7 @@ onMounted(() => { void refresh() })
<el-alert v-if="error" class="workflow-alert" type="error" :closable="false" show-icon>
<template #title>{{ error }}</template>
<ApiErrorMeta v-if="errorDetails" :details="errorDetails" />
<el-button link type="danger" @click="refresh">重试</el-button>
</el-alert>
<el-alert v-if="notice" class="workflow-alert" type="success" :closable="false" show-icon>{{ notice }}</el-alert>
@@ -336,25 +422,41 @@ onMounted(() => { void refresh() })
<template v-if="isSplit">
<el-card shadow="never" class="workflow-card">
<template #header><div class="card-title"><span>预装清单</span><el-tag>{{ preload?.status || '待确认' }}</el-tag></div></template>
<el-empty v-if="!preload && !loading" description="暂无预装清单,可直接生成分柜方案" />
<div class="preload-items" role="group" aria-label="预装清单明细">
<div v-for="(item, index) in preloadItems" :key="index" class="preload-row">
<el-input v-model="item.containerCode" :disabled="props.readOnly" aria-label="柜号" placeholder="柜号" />
<el-input v-model="item.cargoReference" :disabled="props.readOnly" aria-label="货物引用" placeholder="货物引用" />
<el-input-number v-model="item.packageCount" :disabled="props.readOnly" aria-label="件数" :min="1" controls-position="right" />
<el-input-number v-model="item.volumeCbm" :disabled="props.readOnly" aria-label="体积" :min="0" :precision="3" controls-position="right" />
<el-button v-if="!props.readOnly" text type="danger" aria-label="删除预装项" @click="removeItem(index)">删除</el-button>
</div>
<el-button v-if="!props.readOnly" plain @click="addItem">新增预装项</el-button>
<el-input v-model="splitRemark" :disabled="props.readOnly" class="workflow-remark" type="textarea" :rows="2" aria-label="分柜备注" placeholder="分柜备注(可选)" />
</div>
<template #header><div class="card-title"><span>入库预装清单</span><el-tag>{{ preloadStatusLabel(preload?.status) }}</el-tag></div></template>
<el-alert v-if="!canPreloadView" type="warning" :closable="false" show-icon>当前账号无预装清单查询权限。</el-alert>
<LoadErrorState v-else-if="preloadLoadError" title="预装清单暂时无法加载" :message="preloadLoadError.message" :loading="loading" @retry="loadPreload">
<template #details><ApiErrorMeta :details="preloadLoadError" /></template>
</LoadErrorState>
<template v-else>
<DataTable :data="preloadItems" :columns="preloadColumns" :loading="loading" empty-text="暂无预装明细,请先新增">
<template #actions="{ $index }">
<el-button link type="primary" :disabled="!canSplit || Boolean(busy)" :aria-label="`编辑第 ${Number($index) + 1} 条预装明细`" @click="editItem(Number($index))"><Pencil :size="14" />编辑</el-button>
<el-button link type="danger" :disabled="!canSplit || Boolean(busy)" :aria-label="`删除第 ${Number($index) + 1} 条预装明细`" @click="removeItem(Number($index))"><Trash2 :size="14" />删除</el-button>
</template>
<template #volume="{ row }">{{ formatVolume((row as PreloadItem).volumeCbm) }}</template>
<template #toolbar-start><div><strong>明细</strong><p>入库级清单由人工维护;柜次候选和智能建议在下方柜次工作台加载。</p></div></template>
<template #toolbar-actions><el-button :disabled="!canSplit || Boolean(busy)" @click="addItem"><Plus :size="15" />新增明细</el-button></template>
</DataTable>
<el-input v-model="splitRemark" :disabled="!canSplit || Boolean(busy)" class="workflow-remark" type="textarea" :rows="2" maxlength="1000" show-word-limit aria-label="分柜备注" placeholder="分柜备注(可选)" />
</template>
<div class="workflow-actions">
<el-button :disabled="!canSplit || Boolean(busy)" :loading="busy === 'smart-split'" @click="runSmartSplit"><Save :size="15" />智能分柜/保存调整</el-button>
<el-button :disabled="!canPreload || !preload || Boolean(busy)" :loading="busy === 'preload-confirming'" @click="startPreloadConfirmation"><Play :size="15" />提交确认</el-button>
<el-button :disabled="!canSplit || Boolean(busy) || Boolean(preloadLoadError)" :loading="busy === 'smart-split'" @click="runSmartSplit"><Save :size="15" />保存分柜清单</el-button>
<el-button :disabled="!canPreload || !preload || preload.status !== 'PENDING' || Boolean(busy)" :loading="busy === 'preload-confirming'" @click="startPreloadConfirmation"><Play :size="15" />提交确认</el-button>
<el-button type="primary" :disabled="!canPreload || !preload || preload?.status !== 'CONFIRMING' || Boolean(busy)" :loading="busy === 'preload-confirm'" @click="finishPreloadConfirmation"><Check :size="15" />确认预装清单</el-button>
</div>
</el-card>
<el-card v-if="dispatch?.containerBookingId" shadow="never" class="workflow-card">
<SmartLoadingWorkbench
:booking-id="String(dispatch.containerBookingId)"
:highlight-inbound-id="props.inboundId"
:read-only="props.readOnly"
@updated="refresh"
/>
</el-card>
<el-alert v-else type="info" :closable="false" show-icon>
当前订单尚未关联柜次装柜计划;请先在货柜智能排柜工作台选择柜次并分配货物。
</el-alert>
</template>
<template v-if="isLoading">
@@ -401,6 +503,29 @@ onMounted(() => { void refresh() })
<el-alert v-if="isLoaded" class="workflow-alert" type="success" :closable="false" show-icon>
已装柜完成。国内端不提供在线冲正、回退或推进派送完成,后续状态仅接受海外/尾程可信回传。
</el-alert>
<AppDialog
v-model="preloadDialogOpen"
:title="preloadDialogMode === 'create' ? '新增预装明细' : '编辑预装明细'"
confirm-text="保存明细"
:confirm-disabled="Boolean(busy)"
@confirm="saveItem"
>
<el-form label-position="top" @submit.prevent>
<el-form-item label="柜号" required :error="preloadDraftError === '柜号不能为空' ? preloadDraftError : ''">
<el-input v-model="preloadDraft.containerCode" maxlength="64" placeholder="请输入柜号" @input="preloadDraftError = ''" />
</el-form-item>
<el-form-item label="货物引用" required :error="preloadDraftError === '货物引用不能为空' ? preloadDraftError : ''">
<el-input v-model="preloadDraft.cargoReference" maxlength="128" placeholder="订单号、入仓码或业务引用" @input="preloadDraftError = ''" />
</el-form-item>
<div class="preload-dialog-grid">
<el-form-item label="箱数" required><el-input-number v-model="preloadDraft.packageCount" :min="1" :step="1" step-strictly controls-position="right" @change="preloadDraftError = ''" /></el-form-item>
<el-form-item label="体积(m³)"><el-input-number v-model="preloadDraft.volumeCbm" :min="0" :precision="3" controls-position="right" @change="preloadDraftError = ''" /></el-form-item>
</div>
<el-form-item label="明细备注"><el-input v-model="preloadDraft.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item>
<el-alert v-if="preloadDraftError" type="error" :closable="false" show-icon>{{ preloadDraftError }}</el-alert>
</el-form>
</AppDialog>
</section>
</template>
@@ -412,8 +537,7 @@ onMounted(() => { void refresh() })
.workflow-heading p { margin: 4px 0 0; color: var(--cc-text-secondary, #64748b); font-size: 13px; }
.workflow-card { border-radius: 12px; }
.card-title { justify-content: space-between; }
.preload-items { display: grid; gap: 10px; }
.preload-row { display: grid; grid-template-columns: 1fr 1fr 120px 120px auto; gap: 8px; align-items: center; }
.preload-dialog-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.workflow-actions { margin-top: 14px; }
.workflow-remark { margin-top: 10px; }
.evidence-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
@@ -430,7 +554,8 @@ onMounted(() => { void refresh() })
.workflow-alert { margin-bottom: 0; }
.workflow-hint { color: var(--cc-text-secondary, #64748b); font-size: 12px; }
@media (max-width: 640px) {
.preload-row, .evidence-grid { grid-template-columns: 1fr; }
.preload-dialog-grid, .evidence-grid { grid-template-columns: 1fr; }
.preload-dialog-grid { gap: 0; }
.evidence-item, .gate-grid { grid-template-columns: 1fr; }
.workflow-actions > .el-button { flex: 1 1 150px; }
}
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { sanitizePreloadItem, validatePreloadItem, validatePreloadItems } from './preloadForm'
describe('preload form contract', () => {
it('requires a meaningful row before submitting the real smart-split endpoint', () => {
expect(validatePreloadItems([])).toEqual({ valid: false, message: '请至少添加一条预装明细' })
expect(validatePreloadItem({ containerCode: '', cargoReference: 'SKU-1', packageCount: 1, volumeCbm: 0, remark: null }).message)
.toBe('柜号不能为空')
expect(validatePreloadItem({ containerCode: 'C-1', cargoReference: '', packageCount: 1, volumeCbm: 0, remark: null }).message)
.toBe('货物引用不能为空')
expect(validatePreloadItem({ containerCode: 'C-1', cargoReference: 'SKU-1', packageCount: 1.5, volumeCbm: 0, remark: null }).message)
.toBe('箱数必须为大于 0 的整数')
})
it('normalizes whitespace and numeric values without changing identifier strings', () => {
expect(sanitizePreloadItem({
containerCode: ' 2086085512165634050 ', cargoReference: ' REF-1 ', packageCount: 2, volumeCbm: 1.25, remark: ' note ',
})).toEqual({
containerCode: '2086085512165634050', cargoReference: 'REF-1', packageCount: 2, volumeCbm: 1.25, remark: 'note',
})
})
})
@@ -0,0 +1,38 @@
import type { PreloadItem } from './api'
export interface PreloadItemValidation {
valid: boolean
message: string
}
export function validatePreloadItem(item: PreloadItem): PreloadItemValidation {
if (!item.containerCode?.trim()) return { valid: false, message: '柜号不能为空' }
if (!item.cargoReference?.trim()) return { valid: false, message: '货物引用不能为空' }
if (!Number.isInteger(item.packageCount) || Number(item.packageCount) <= 0) {
return { valid: false, message: '箱数必须为大于 0 的整数' }
}
if (item.volumeCbm !== null && (!Number.isFinite(Number(item.volumeCbm)) || Number(item.volumeCbm) < 0)) {
return { valid: false, message: '体积不能小于 0' }
}
if ((item.remark?.length || 0) > 500) return { valid: false, message: '明细备注不能超过 500 个字符' }
return { valid: true, message: '' }
}
export function validatePreloadItems(items: PreloadItem[]): PreloadItemValidation {
if (!items.length) return { valid: false, message: '请至少添加一条预装明细' }
for (let index = 0; index < items.length; index += 1) {
const result = validatePreloadItem(items[index])
if (!result.valid) return { valid: false, message: `第 ${index + 1} 条:${result.message}` }
}
return { valid: true, message: '' }
}
export function sanitizePreloadItem(item: PreloadItem): PreloadItem {
return {
containerCode: item.containerCode?.trim() || null,
cargoReference: item.cargoReference?.trim() || null,
packageCount: Number(item.packageCount),
volumeCbm: item.volumeCbm === null ? 0 : Number(item.volumeCbm),
remark: item.remark?.trim() || null,
}
}
@@ -599,10 +599,4 @@ onMounted(() => {
.pallet-editor-footer > div { display: grid; grid-template-columns: 1fr 1fr; }
.pallet-editor-footer :deep(.el-button) { min-height: 44px; margin-left: 0; }
}
@media (max-width: 768px) {
.pallet-filter-toolbar :deep(.query-toolbar__filters) { width: 100%; flex-direction: column; }
.pallet-filter-toolbar :deep(.query-toolbar__filters .el-input),
.pallet-filter-toolbar :deep(.query-toolbar__filters .el-select),
.pallet-filter-toolbar :deep(.query-toolbar__filters .el-date-editor) { width: 100% !important; }
}
</style>
+25
View File
@@ -0,0 +1,25 @@
<script setup lang="ts">
withDefaults(defineProps<{
as?: string
width?: 'full' | 'comfortable'
spacing?: 'compact' | 'normal' | 'relaxed'
}>(), {
as: 'main',
width: 'full',
spacing: 'normal',
})
</script>
<template>
<component :is="as" class="admin-page" :class="[`is-width-${width}`, `is-spacing-${spacing}`]">
<slot />
</component>
</template>
<style scoped>
.admin-page { display: flex; min-width: 0; flex-direction: column; margin: 0 auto; }
.admin-page.is-width-comfortable { width: min(100%, 1440px); }
.admin-page.is-spacing-compact { gap: 12px; }
.admin-page.is-spacing-normal { gap: 16px; }
.admin-page.is-spacing-relaxed { gap: 20px; }
</style>
@@ -0,0 +1,21 @@
<script setup lang="ts">
withDefaults(defineProps<{
ariaLabel?: string
}>(), {
ariaLabel: '详情页面',
})
</script>
<template>
<article class="detail-page-layout" :aria-label="ariaLabel">
<header v-if="$slots.header" class="detail-page-layout__header"><slot name="header" /></header>
<div v-if="$slots.summary" class="detail-page-layout__summary"><slot name="summary" /></div>
<div class="detail-page-layout__content"><slot /></div>
<footer v-if="$slots.footer" class="detail-page-layout__footer"><slot name="footer" /></footer>
</article>
</template>
<style scoped>
.detail-page-layout { display: flex; min-width: 0; flex-direction: column; gap: 16px; }
.detail-page-layout__header, .detail-page-layout__summary, .detail-page-layout__content, .detail-page-layout__footer { min-width: 0; }
</style>
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts">
withDefaults(defineProps<{
title: string
description?: string
bordered?: boolean
}>(), {
description: '',
bordered: true,
})
</script>
<template>
<section class="form-section" :class="{ 'is-bordered': bordered }">
<div class="form-section__heading">
<h2>{{ title }}</h2>
<p v-if="description">{{ description }}</p>
</div>
<div class="form-section__content"><slot /></div>
</section>
</template>
<style scoped>
.form-section { min-width: 0; padding: 20px 0; }
.form-section.is-bordered { border-bottom: 1px solid var(--cc-border, #e3e8ef); }
.form-section:first-child { padding-top: 0; }
.form-section:last-child { padding-bottom: 0; border-bottom: 0; }
.form-section__heading { margin-bottom: 16px; }
.form-section__heading h2 { margin: 0; color: var(--cc-text, #172033); font-size: 14px; line-height: 1.4; }
.form-section__heading p { margin: 4px 0 0; color: var(--cc-muted, #667085); font-size: 12px; line-height: 1.5; }
</style>
+21
View File
@@ -0,0 +1,21 @@
<script setup lang="ts">
withDefaults(defineProps<{
ariaLabel?: string
}>(), {
ariaLabel: '列表页面',
})
</script>
<template>
<section class="list-page-layout" :aria-label="ariaLabel">
<div v-if="$slots.toolbar" class="list-page-layout__toolbar"><slot name="toolbar" /></div>
<div v-if="$slots.metrics" class="list-page-layout__metrics"><slot name="metrics" /></div>
<div v-if="$slots.state" class="list-page-layout__state"><slot name="state" /></div>
<div v-if="$slots.default" class="list-page-layout__content"><slot /></div>
</section>
</template>
<style scoped>
.list-page-layout { display: flex; min-width: 0; flex-direction: column; gap: 16px; }
.list-page-layout__toolbar, .list-page-layout__metrics, .list-page-layout__state, .list-page-layout__content { min-width: 0; }
</style>
+55
View File
@@ -0,0 +1,55 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import AdminPage from './AdminPage.vue'
import DetailPageLayout from './DetailPageLayout.vue'
import FormSection from './FormSection.vue'
import ListPageLayout from './ListPageLayout.vue'
import PageToolbar from './PageToolbar.vue'
describe('admin page layout components', () => {
it('renders a semantic page root with stable layout classes', () => {
const wrapper = mount(AdminPage, {
props: { width: 'comfortable', spacing: 'compact' },
slots: { default: '<p>内容</p>' },
})
expect(wrapper.element.tagName).toBe('MAIN')
expect(wrapper.classes()).toEqual(expect.arrayContaining(['is-width-comfortable', 'is-spacing-compact']))
expect(wrapper.text()).toBe('内容')
})
it('keeps page title and actions in separate regions', () => {
const wrapper = mount(PageToolbar, {
props: { title: '客户管理', description: '维护客户资料' },
slots: { actions: '<button>新增</button>' },
})
expect(wrapper.get('h1').text()).toBe('客户管理')
expect(wrapper.get('.page-toolbar__description').text()).toBe('维护客户资料')
expect(wrapper.get('.page-toolbar__actions').text()).toBe('新增')
})
it('renders optional list regions only when supplied', () => {
const wrapper = mount(ListPageLayout, {
slots: { toolbar: '<div>筛选</div>', default: '<div>表格</div>' },
})
expect(wrapper.find('.list-page-layout__toolbar').exists()).toBe(true)
expect(wrapper.find('.list-page-layout__metrics').exists()).toBe(false)
expect(wrapper.text()).toContain('筛选')
expect(wrapper.text()).toContain('表格')
})
it('supports detail regions and form section descriptions', () => {
const detail = mount(DetailPageLayout, {
slots: { header: '<h1>详情</h1>', default: '<p>正文</p>', footer: '<button>保存</button>' },
})
expect(detail.find('.detail-page-layout__header').exists()).toBe(true)
expect(detail.find('.detail-page-layout__footer').text()).toBe('保存')
const form = mount(FormSection, {
props: { title: '基础信息', description: '填写必填字段' },
slots: { default: '<input aria-label="名称" />' },
})
expect(form.get('h2').text()).toBe('基础信息')
expect(form.get('.form-section__heading p').text()).toBe('填写必填字段')
expect(form.find('input').exists()).toBe(true)
})
})
+41
View File
@@ -0,0 +1,41 @@
<script setup lang="ts">
withDefaults(defineProps<{
title: string
description?: string
eyebrow?: string
as?: 'header' | 'section'
}>(), {
description: '',
eyebrow: '',
as: 'header',
})
</script>
<template>
<component :is="as" class="page-toolbar">
<div class="page-toolbar__copy">
<p v-if="eyebrow" class="page-toolbar__eyebrow">{{ eyebrow }}</p>
<h1>{{ title }}</h1>
<p v-if="description" class="page-toolbar__description">{{ description }}</p>
</div>
<div v-if="$slots.default || $slots.actions" class="page-toolbar__actions">
<slot />
<slot name="actions" />
</div>
</component>
</template>
<style scoped>
.page-toolbar { display: flex; min-width: 0; align-items: flex-start; justify-content: space-between; gap: 16px; }
.page-toolbar__copy { min-width: 0; flex: 1; }
.page-toolbar h1 { margin: 0; color: var(--cc-text, #172033); font-size: 20px; line-height: 1.35; }
.page-toolbar__eyebrow { margin: 0 0 5px; color: var(--cc-primary); font-size: 11px; font-weight: 700; }
.page-toolbar__description { margin: 5px 0 0; color: var(--cc-muted, #667085); font-size: 13px; line-height: 1.55; }
.page-toolbar__actions { display: flex; flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.page-toolbar__actions :deep(.el-button + .el-button) { margin-left: 0; }
@media (max-width: 720px) {
.page-toolbar { flex-direction: column; }
.page-toolbar__actions { width: 100%; justify-content: flex-start; }
.page-toolbar__actions :deep(.el-button) { min-height: 44px; }
}
</style>
+69 -9
View File
@@ -1,20 +1,80 @@
:root {
font-family: "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif;
color: #172033;
background: #f4f6f8;
color: #1d2939;
background: #f5f7fa;
font-synthesis: none;
--cc-primary: #667eea;
--cc-navy: #667eea;
--cc-topbar: rgba(102, 126, 234, .95);
--cc-menu-active: #667eea;
--cc-border: #e3e8ef;
/* Core palette: restrained blue for actions, neutral surfaces for scanning. */
--cc-primary: #155eef;
--cc-primary-hover: #004eeb;
--cc-primary-soft: #eff4ff;
--cc-navy: #1d2939;
--cc-topbar: #1d2939;
--cc-menu-active: #155eef;
--cc-page: #f5f7fa;
--cc-surface: #ffffff;
--cc-surface-muted: #f8fafc;
--cc-border: #e4e7ec;
--cc-border-strong: #d0d5dd;
--cc-text: #1d2939;
--cc-text-secondary: #475467;
--cc-muted: #667085;
--cc-placeholder: #98a2b3;
--cc-success: #067647;
--cc-success-soft: #ecfdf3;
--cc-warning: #b54708;
--cc-warning-soft: #fffaeb;
--cc-danger: #b42318;
--cc-danger-soft: #fef3f2;
--cc-info: #175cd3;
--cc-info-soft: #eff8ff;
--cc-focus: rgba(21, 94, 239, .28);
--cc-radius-sm: 4px;
--cc-radius-md: 6px;
--cc-radius-lg: 8px;
--cc-radius-pill: 999px;
--cc-shadow-sm: 0 1px 2px rgba(16, 24, 40, .05);
--cc-shadow-md: 0 4px 12px rgba(16, 24, 40, .08);
--cc-shadow-shell: 0 1px 4px rgba(16, 24, 40, .08);
--cc-space-1: 4px;
--cc-space-2: 8px;
--cc-space-3: 12px;
--cc-space-4: 16px;
--cc-space-5: 20px;
--cc-space-6: 24px;
--cc-space-7: 32px;
--el-color-primary: var(--cc-primary);
--el-color-primary-light-3: #84adff;
--el-color-primary-light-5: #b2ccff;
--el-color-primary-light-7: #d5e3ff;
--el-color-primary-light-8: #e1ebff;
--el-color-primary-light-9: var(--cc-primary-soft);
--el-color-primary-dark-2: var(--cc-primary-hover);
--el-color-success: var(--cc-success);
--el-color-warning: var(--cc-warning);
--el-color-danger: var(--cc-danger);
--el-color-info: var(--cc-info);
--el-border-color: var(--cc-border);
--el-border-color-light: #eaecf0;
--el-border-color-lighter: #f2f4f7;
--el-fill-color-light: var(--cc-surface-muted);
--el-fill-color-lighter: #fbfcfd;
--el-bg-color: var(--cc-surface);
--el-bg-color-page: var(--cc-page);
--el-text-color-primary: var(--cc-text);
--el-text-color-regular: var(--cc-text-secondary);
--el-text-color-secondary: var(--cc-muted);
--el-text-color-placeholder: var(--cc-placeholder);
--el-border-radius-base: var(--cc-radius-md);
--el-border-radius-small: var(--cc-radius-sm);
--el-border-radius-round: var(--cc-radius-pill);
--el-box-shadow-light: var(--cc-shadow-md);
}
* { box-sizing: border-box; }
html, body, #app { width: 100%; min-width: 0; min-height: 100%; margin: 0; }
body { min-height: 100vh; background: #f4f6f8; }
body { min-height: 100vh; background: var(--cc-page); }
button, input, textarea, select { font: inherit; }
button { letter-spacing: 0; }
:focus-visible { outline: 3px solid rgba(21, 94, 239, .28); outline-offset: 2px; }
:focus-visible { outline: 3px solid var(--cc-focus); outline-offset: 2px; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
+22 -22
View File
@@ -1,5 +1,5 @@
.app-shell { min-width: 0; min-height: 100vh; }
.app-sidebar { position: fixed; inset: 0 auto 0 0; z-index: 20; display: flex; width: 240px; flex-direction: column; background: rgba(255,255,255,.96); color: #52525b; box-shadow: 4px 0 32px rgba(0,0,0,.08); }
.app-sidebar { position: fixed; inset: 0 auto 0 0; z-index: 20; display: flex; width: 240px; flex-direction: column; border-right: 1px solid var(--cc-border); background: var(--cc-surface); color: var(--cc-text-secondary); box-shadow: var(--cc-shadow-shell); }
.is-collapsed .app-sidebar { width: 76px; }
.brand-block { display: flex; height: 64px; flex: 0 0 64px; align-items: center; padding: 0 18px; background: var(--cc-topbar); }
.brand-logo { display: block; object-fit: contain; object-position: left center; }
@@ -12,14 +12,14 @@
min-height: 0;
overflow-y: auto;
padding: 12px 8px;
scrollbar-color: rgba(102, 126, 234, .28) transparent;
scrollbar-color: rgba(21, 94, 239, .22) transparent;
scrollbar-width: thin;
}
.side-nav::-webkit-scrollbar { width: 10px; }
.side-nav::-webkit-scrollbar-track,
.side-nav::-webkit-scrollbar-corner { background: transparent; }
.side-nav::-webkit-scrollbar-thumb { border: 3px solid transparent; border-radius: 999px; background: rgba(102, 126, 234, .28); background-clip: padding-box; }
.side-nav::-webkit-scrollbar-thumb:hover { background-color: rgba(102, 126, 234, .46); }
.side-nav::-webkit-scrollbar-thumb { border: 3px solid transparent; border-radius: var(--cc-radius-pill); background: rgba(21, 94, 239, .22); background-clip: padding-box; }
.side-nav::-webkit-scrollbar-thumb:hover { background-color: rgba(21, 94, 239, .38); }
.side-nav::-webkit-scrollbar-button,
.side-nav::-webkit-scrollbar-button:single-button,
.side-nav::-webkit-scrollbar-button:vertical:decrement,
@@ -29,45 +29,45 @@
border-right: 0 !important;
background: transparent !important;
--el-menu-bg-color: transparent;
--el-menu-text-color: #52525b;
--el-menu-text-color: var(--cc-text-secondary);
--el-menu-active-color: var(--cc-menu-active);
--el-menu-hover-bg-color: rgba(102,126,234,.08);
--el-menu-hover-bg-color: var(--cc-primary-soft);
}
.side-nav-menu.el-menu--collapse { width: 56px; }
.side-nav-menu .el-menu-item,
.side-nav-menu .el-sub-menu__title { height: 46px; margin: 2px 0; border-radius: 0 12px 12px 0; line-height: 46px; transition: color .2s ease, background .2s ease, transform .2s ease; }
.side-nav-menu .el-sub-menu__title { height: 44px; margin: 2px 0; border-radius: 0 var(--cc-radius-lg) var(--cc-radius-lg) 0; line-height: 44px; transition: color .16s ease, background .16s ease; }
.side-nav-menu .el-menu-item:hover,
.side-nav-menu .el-sub-menu__title:hover { color: var(--cc-menu-active); background: linear-gradient(135deg, rgba(102,126,234,.1), rgba(240,147,251,.08)); transform: translateX(3px); }
.side-nav-menu .el-menu-item.is-active { background: linear-gradient(135deg, rgba(102,126,234,.18), rgba(240,147,251,.12)); color: var(--cc-menu-active); font-weight: 700; box-shadow: inset 3px 0 var(--cc-menu-active); }
.side-nav-menu .el-sub-menu .el-menu { padding: 4px 0; background: linear-gradient(180deg, rgba(102,126,234,.03), transparent) !important; }
.side-nav-menu .el-sub-menu .el-menu-item { min-width: 0; height: 42px; margin: 0 12px; padding-left: 54px !important; border-radius: 10px; color: #71717a; line-height: 42px; }
.side-nav-menu .el-sub-menu .el-menu-item:hover { background: rgba(102,126,234,.08); color: var(--cc-menu-active); transform: translateX(4px); }
.side-nav-menu .el-sub-menu .el-menu-item.is-active { background: rgba(102,126,234,.12); box-shadow: none; color: var(--cc-menu-active); font-weight: 700; }
.side-nav-menu .el-sub-menu__title:hover { color: var(--cc-menu-active); background: var(--cc-primary-soft); }
.side-nav-menu .el-menu-item.is-active { background: var(--cc-primary-soft); color: var(--cc-menu-active); font-weight: 700; box-shadow: inset 3px 0 var(--cc-menu-active); }
.side-nav-menu .el-sub-menu .el-menu { padding: 4px 0; background: var(--cc-surface-muted) !important; }
.side-nav-menu .el-sub-menu .el-menu-item { min-width: 0; height: 40px; margin: 0 12px; padding-left: 54px !important; border-radius: var(--cc-radius-md); color: var(--cc-muted); line-height: 40px; }
.side-nav-menu .el-sub-menu .el-menu-item:hover { background: var(--cc-primary-soft); color: var(--cc-menu-active); }
.side-nav-menu .el-sub-menu .el-menu-item.is-active { background: var(--cc-primary-soft); box-shadow: none; color: var(--cc-menu-active); font-weight: 700; }
.side-nav-menu .el-menu-item svg,
.side-nav-menu .el-sub-menu__title svg { flex: 0 0 auto; margin-right: 12px; }
.app-main { min-width: 0; min-height: 100vh; margin-left: 240px; }
.is-collapsed .app-main { margin-left: 76px; }
.app-header { position: sticky; top: 0; z-index: 15; display: flex; min-width: 0; min-height: 64px; align-items: center; gap: 16px; padding: 8px 20px; background: var(--cc-topbar); color: #fff; box-shadow: 0 8px 32px rgba(0,0,0,.15), 0 2px 8px rgba(0,0,0,.1); backdrop-filter: blur(20px); }
.shell-menu-button { display: grid; width: 44px; height: 44px; flex: 0 0 44px; place-items: center; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #fff; cursor: pointer; transition: background .2s ease, transform .2s ease; }
.shell-menu-button:hover { background: rgba(255,255,255,.15); transform: scale(1.05); }
.app-header { position: sticky; top: 0; z-index: 15; display: flex; min-width: 0; min-height: 64px; align-items: center; gap: 16px; padding: 8px 20px; background: var(--cc-topbar); color: #fff; box-shadow: var(--cc-shadow-shell); }
.shell-menu-button { display: grid; width: 44px; height: 44px; flex: 0 0 44px; place-items: center; padding: 0; border: 0; border-radius: var(--cc-radius-md); background: transparent; color: #fff; cursor: pointer; transition: background .16s ease; }
.shell-menu-button:hover { background: rgba(255,255,255,.12); }
.shell-menu-button:focus-visible { outline-color: rgba(255,255,255,.8); }
.page-heading { min-width: 0; flex: 1; }
.page-heading h1 { margin: 0; overflow: hidden; font-size: 20px; letter-spacing: 0; text-overflow: ellipsis; white-space: nowrap; }
.page-heading p { margin: 4px 0 0; overflow: hidden; color: rgba(255,255,255,.78); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.page-heading p { margin: 4px 0 0; overflow: hidden; color: rgba(255,255,255,.72); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.header-user { display: flex; min-width: 0; flex: 0 0 auto; align-items: center; gap: 10px; }
.user-avatar { display: grid; width: 36px; height: 36px; place-items: center; border-radius: 50%; background: #fff; color: var(--cc-primary); font-weight: 700; box-shadow: 0 4px 12px rgba(0,0,0,.15); }
.user-avatar { display: grid; width: 36px; height: 36px; place-items: center; border-radius: 50%; background: #fff; color: var(--cc-primary); font-weight: 700; box-shadow: var(--cc-shadow-sm); }
.user-copy { display: flex; flex-direction: column; }
.user-copy strong { font-size: 13px; }
.user-copy span { color: var(--cc-muted); font-size: 11px; }
.header-user .el-button { width: 44px; height: 44px; color: #fff; }
.header-user .el-button:hover { background: rgba(255,255,255,.15); color: #fff; }
.page-container { padding: 20px 20px 32px; }
.page-container { padding: var(--cc-space-5) var(--cc-space-5) var(--cc-space-7); }
/* Static permission preview */
.app-header > .el-dropdown { min-width: 0; max-width: 320px; flex: 0 1 320px; }
.role-preview-trigger { display: flex; width: 100%; min-width: 0; min-height: 44px; align-items: center; gap: 10px; padding: 6px 10px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; color: #172033; text-align: left; cursor: pointer; }
.role-preview-trigger:hover { border-color: #b8c6d9; background: #f8fafc; }
.role-preview-trigger:focus-visible { border-color: var(--cc-primary); outline: 3px solid rgba(21, 94, 239, .2); outline-offset: 1px; }
.role-preview-trigger { display: flex; width: 100%; min-width: 0; min-height: 44px; align-items: center; gap: 10px; padding: 6px 10px; border: 1px solid var(--cc-border); border-radius: var(--cc-radius-md); background: var(--cc-surface); color: var(--cc-text); text-align: left; cursor: pointer; }
.role-preview-trigger:hover { border-color: var(--cc-border-strong); background: var(--cc-surface-muted); }
.role-preview-trigger:focus-visible { border-color: var(--cc-primary); outline: 3px solid var(--cc-focus); outline-offset: 1px; }
.role-preview-trigger:disabled, .role-preview-trigger[aria-disabled="true"] { cursor: not-allowed; opacity: .58; }
.role-preview-trigger > svg { flex: 0 0 auto; }
.role-preview-copy { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 2px; }