Release/test 合并上生产 #2
@@ -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())
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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[]
|
||||
@@ -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>
|
||||
@@ -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' },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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'],
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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') },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
Vendored
+69
-9
@@ -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
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user