fix(admin): complete container loading workflows

This commit is contained in:
2026-08-11 11:15:44 +08:00
parent 76f5a1debf
commit 9b994040b8
13 changed files with 456 additions and 135 deletions
@@ -8,8 +8,18 @@ import { listAllPlatformWarehouses } from '@/modules/warehouses'
import { listContainers } from '../api/platformContainerLoading'
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('../api/platformContainerLoading', () => ({ listContainers: vi.fn(), getContainer: vi.fn(), createContainer: vi.fn(), updateContainer: vi.fn(), updateContainerTransport: vi.fn() }))
function createTestRouter() {
return createRouter({ history: createMemoryHistory(), routes: [
{ path: '/', component: ContainersView },
{ path: '/container-loading/:containerId/smart-loading', name: 'smart-loading-workbench', component: { template: '<div />' } },
{ path: '/loading-plans', name: 'loading-plans', component: { template: '<div />' } },
{ path: '/loading-lists', name: 'loading-lists', component: { template: '<div />' } },
] })
}
describe('ContainersView', () => {
beforeEach(() => {
setActivePinia(createPinia())
@@ -18,7 +28,7 @@ describe('ContainersView', () => {
})
it('loads the real paged contract and renders core container fields', async () => {
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: ContainersView }] })
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
@@ -29,6 +39,18 @@ describe('ContainersView', () => {
expect(wrapper.find('button[aria-label="查看 CB-1"]').exists()).toBe(true)
})
it('keeps the cross-warehouse list usable when warehouse form options fail', async () => {
vi.mocked(listAllPlatformWarehouses).mockRejectedValueOnce(new Error('warehouse unavailable'))
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
expect(wrapper.text()).toContain('CB-1')
expect(wrapper.text()).not.toContain('装柜仓库加载失败')
expect(wrapper.find('button[aria-label="查看 CB-1"]').exists()).toBe(true)
})
it('uses stable view labels when cabinetNo is missing', async () => {
const base = {
id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', planStatus: 'EDITING' as const, version: 0,
@@ -46,7 +68,7 @@ describe('ContainersView', () => {
],
page: 1, size: 20, total: 3, pages: 1,
} as never)
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: ContainersView }] })
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
@@ -57,4 +79,39 @@ describe('ContainersView', () => {
expect(labels).toEqual(expect.arrayContaining(['查看 MSCU-2', '查看 BK-3', '查看货柜详情']))
expect(labels.every((label) => !/undefined|null/.test(label))).toBe(true)
})
it('routes each plan status to its owning workflow without reopening pushed drafts', async () => {
const base = {
id: '9001', planId: '9101', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', 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,
}
vi.mocked(listContainers).mockResolvedValueOnce({
records: [
{ ...base, planStatus: 'EDITING' as const },
{ ...base, id: '9002', planId: '9102', cabinetNo: 'CB-2', planStatus: 'LOADING' as const },
{ ...base, id: '9003', planId: '9103', cabinetNo: 'CB-3', planStatus: 'COMPLETED' as const },
],
page: 1, size: 20, total: 3, pages: 1,
})
const router = createTestRouter()
await router.push('/'); await router.isReady()
const push = vi.spyOn(router, 'push').mockResolvedValue(undefined as never)
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
expect(wrapper.text()).toContain('排柜')
expect(wrapper.text()).toContain('查看预装清单')
expect(wrapper.text()).toContain('查看装柜清单')
await wrapper.findAll('button').find((button) => button.text().trim() === '排柜')!.trigger('click')
await wrapper.findAll('button').find((button) => button.text().includes('查看预装清单'))!.trigger('click')
await wrapper.findAll('button').find((button) => button.text().includes('查看装柜清单'))!.trigger('click')
expect(push).toHaveBeenNthCalledWith(1, { name: 'smart-loading-workbench', params: { containerId: '9001' } })
expect(push).toHaveBeenNthCalledWith(2, { name: 'loading-plans', query: { planId: '9102', warehouseId: '8' } })
expect(push).toHaveBeenNthCalledWith(3, { name: 'loading-lists', query: { planId: '9103', warehouseId: '8' } })
})
})
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { Boxes, Eye, Pencil, Plus, Sparkles } from '@lucide/vue'
import { Boxes, Eye, ListChecks, Pencil, Plus, 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'
@@ -16,7 +16,7 @@ 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 Filters = QueryModel & { keyword: string; containerType: string; status: ContainerPlanStatus | ''; sailingRange: string[] }
type ContainerForm = ContainerSaveRequest & { id: string; version: number }
const router = useRouter()
@@ -30,7 +30,7 @@ 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 filters = ref<Filters>({ keyword: '', containerType: '', status: '', sailingRange: [] })
const applied = ref<Filters>({ ...filters.value })
const detailOpen = ref(false)
const detailLoading = ref(false)
@@ -65,18 +65,19 @@ const rules: FormRules<ContainerForm> = {
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 canViewPreload = computed(() => access.can('platform:firstleg:preload:list'))
const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-list: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: 'actions', label: '操作', width: 310, 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' },
@@ -131,7 +132,7 @@ async function loadRows() {
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,
containerType: applied.value.containerType || undefined,
status: applied.value.status, estimatedSailingFrom: from ? `${from}T00:00:00` : undefined,
estimatedSailingTo: to ? `${to}T23:59:59` : undefined,
})
@@ -170,11 +171,36 @@ async function save() {
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)
if (details.code === 'A0409' && form.id) {
const submitted = { ...form }
try {
const latest = await getContainer(form.id)
detail.value = latest
replaceForm(latest)
Object.assign(form, submitted, { id: latest.id, version: latest.version })
editorError.value = { ...details, message: '数据已被其他人修改,已刷新最新版本并保留本次输入,请重新确认后提交' }
ElMessage.warning(editorError.value.message)
} catch (refreshError) {
editorError.value = getApiErrorDetails(refreshError, '数据已被其他人修改,且最新数据刷新失败,请稍后重试')
ElMessage.error(editorError.value.message)
}
} else {
editorError.value = details
ElMessage.error(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 } }) }
function goContainerProcess(source: object) {
const row = source as ContainerBookingResource
if (row.planStatus === 'EDITING') {
void router.push({ name: 'smart-loading-workbench', params: { containerId: row.id } })
return
}
void router.push({
name: row.planStatus === 'LOADING' ? 'loading-plans' : 'loading-lists',
query: { planId: row.planId || undefined, warehouseId: row.warehouseId },
})
}
watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) => {
if (nextPage !== previousPage || nextSize !== previousSize) void loadRows()
@@ -189,14 +215,13 @@ onMounted(() => { void Promise.all([loadWarehouses(), loadRows()]) })
<template #toolbar>
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="货柜筛选" :fields="queryFields" preference-key="container-loading-query" @search="search" />
</template>
<template v-if="warehouseError || loadError" #state>
<LoadErrorState v-if="warehouseError" title="装柜仓库加载失败" :message="warehouseError.message" compact @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
<LoadErrorState v-else-if="loadError" title="货柜列表加载失败" :message="loadError.message" :loading="loading" @retry="loadRows"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<template v-if="loadError" #state>
<LoadErrorState title="货柜列表加载失败" :message="loadError.message" :loading="loading" @retry="loadRows"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
</template>
<section v-if="!warehouseError && !loadError" class="surface-panel table-panel">
<section v-if="!loadError" class="surface-panel table-panel">
<DataTable 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-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增货柜</el-button></template>
<template #actions="{ row }"><div class="row-actions"><el-button link type="primary" :aria-label="containerViewLabel(row as ContainerBookingResource)" @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 !== 'COMPLETED'" link type="primary" @click="goWorkbench(row)"><Sparkles :size="15" />排柜</el-button></div></template>
<template #actions="{ row }"><div class="row-actions"><el-button link type="primary" :aria-label="containerViewLabel(row as ContainerBookingResource)" @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="row.planStatus === 'EDITING' && canSmartLoading" link type="primary" @click="goContainerProcess(row)"><Sparkles :size="15" />排柜</el-button><el-button v-else-if="row.planStatus === 'LOADING' && canViewPreload" link type="primary" @click="goContainerProcess(row)"><ListChecks :size="15" />查看预装清单</el-button><el-button v-else-if="row.planStatus === 'COMPLETED' && canViewLoadingList" link type="primary" @click="goContainerProcess(row)"><ListChecks :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>
@@ -213,10 +238,11 @@ onMounted(() => { void Promise.all([loadWarehouses(), loadRows()]) })
<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">
<AppDrawer v-model="editorOpen" :title="editorTitle" size="min(860px, 96vw)" :confirm-loading="saving" :confirm-disabled="Boolean(warehouseError && !transportOnly)" 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>
<LoadErrorState v-if="warehouseError && !transportOnly" title="装柜仓库加载失败" :message="warehouseError.message" compact @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehouseError" /></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 v-if="!warehouseError || transportOnly" 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>
@@ -11,6 +11,12 @@ import {
recommendSmartLoading,
} from '../api/platformContainerLoading'
const { routerPush } = vi.hoisted(() => ({ routerPush: vi.fn() }))
vi.mock('vue-router', () => ({
useRouter: () => ({ push: routerPush }),
}))
vi.mock('@/core/access/accessPreview', () => ({
useAccessPreviewStore: () => ({ can: () => true }),
}))
@@ -82,22 +88,38 @@ describe('GlobalSmartLoadingView', () => {
expect(wrapper.text()).toContain('CB-1 装柜方案')
})
it('keeps loading containers editable and hides completed containers', async () => {
const loadingContainer = { ...container, id: '9007199254740995', cabinetNo: 'CB-LOADING', planStatus: 'LOADING' as const }
const completedContainer = { ...container, id: '9007199254740996', cabinetNo: 'CB-COMPLETED', planStatus: 'COMPLETED' as const }
vi.mocked(listContainers).mockResolvedValue({ records: [completedContainer, loadingContainer], page: 1, size: 100, total: 2, pages: 1 })
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({ ...plan, bookingId: loadingContainer.id, status: 'LOADING' })
it('keeps pushed containers visible and routes them to the preload list without loading an editing draft', async () => {
const pushed = { ...container, id: '9007199254740995', planId: '9007199254740996', cabinetNo: 'CB-2', planStatus: 'LOADING' as const }
vi.mocked(listContainers).mockResolvedValue({ records: [pushed], page: 1, size: 100, total: 1, pages: 1 })
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(loadingContainer.id)
expect(wrapper.text()).toContain('CB-LOADING')
expect(wrapper.text()).toContain('装柜中')
expect(wrapper.text()).not.toContain('CB-COMPLETED')
expect(wrapper.text()).toContain('智能推荐')
expect(wrapper.text()).toContain('加入')
expect(wrapper.text()).not.toContain('推送预装')
expect(getSmartLoadingPlanByBooking).not.toHaveBeenCalled()
expect(listSmartLoadingCandidates).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('该柜次正在预装流程中')
expect(wrapper.text()).not.toContain('智能推荐')
expect(wrapper.findAll('button').some((item) => item.text().trim() === '推送预装')).toBe(false)
await wrapper.findAll('button').find((item) => item.text().includes('查看预装清单'))!.trigger('click')
expect(routerPush).toHaveBeenCalledWith({
name: 'loading-plans',
query: { planId: pushed.planId, warehouseId: pushed.warehouseId },
})
})
it('routes completed containers to the read-only loading list', async () => {
const completed = { ...container, id: '9007199254740997', planId: '9007199254740998', cabinetNo: 'CB-3', planStatus: 'COMPLETED' as const }
vi.mocked(listContainers).mockResolvedValue({ records: [completed], page: 1, size: 100, total: 1, pages: 1 })
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(getSmartLoadingPlanByBooking).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('该柜次已完成装柜')
await wrapper.findAll('button').find((item) => item.text().includes('查看装柜清单'))!.trigger('click')
expect(routerPush).toHaveBeenCalledWith({
name: 'loading-lists',
query: { planId: completed.planId, warehouseId: completed.warehouseId },
})
})
it('refreshes the latest plan after A0409 without losing the selected container', async () => {
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Plus, RefreshCw, Save, Sparkles, Trash2, Upload } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -30,6 +31,7 @@ import type {
} from '../types'
const access = useAccessPreviewStore()
const router = useRouter()
const containers = ref<ContainerBookingResource[]>([])
const selectedContainerId = ref('')
const plan = ref<SmartLoadingPlan | null>(null)
@@ -45,7 +47,7 @@ const assignOpen = ref(false)
const selectedCandidate = ref<SmartLoadingCandidate | null>(null)
const assignForm = reactive({ packageCount: 1 })
const quantityDrafts = reactive<Record<string, number>>({})
const query = ref<QueryModel>({ orderKeyword: '', containerKeyword: '', warehouseId: '', status: '' })
const query = ref<QueryModel>({ orderKeyword: '', containerKeyword: '', status: '' })
const appliedQuery = ref<QueryModel>({ ...query.value })
let selectionRequest = 0
@@ -54,7 +56,8 @@ const canAssign = computed(() => access.can('platform:firstleg:smart-loading:ass
const canDelete = computed(() => access.can('platform:firstleg:smart-loading:delete'))
const canPush = computed(() => access.can('platform:firstleg:smart-loading:push'))
const selectedContainer = computed(() => containers.value.find((item) => item.id === selectedContainerId.value) || null)
const editable = computed(() => plan.value?.status === 'EDITING' || plan.value?.status === 'LOADING')
const editable = computed(() => plan.value?.status === 'EDITING')
const selectedPlanStatus = computed(() => selectedContainer.value?.planStatus || null)
const assignedDetailIds = computed(() => new Set((plan.value?.items || []).map((item) => item.inboundDetailId)))
const totalPackages = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.packageCount || 0), 0))
const totalVolume = computed(() => (plan.value?.items || []).reduce((sum, item) => sum + Number(item.volumeCbm || 0), 0))
@@ -64,28 +67,20 @@ const weightLimit = computed(() => Number(selectedContainer.value?.maxWeightKg |
const volumePercent = computed(() => volumeLimit.value ? totalVolume.value / volumeLimit.value * 100 : 0)
const weightPercent = computed(() => weightLimit.value ? totalWeight.value / weightLimit.value * 100 : 0)
const warehouseOptions = computed(() => {
const values = new Map<string, string>()
for (const item of containers.value) values.set(item.warehouseId, item.warehouseName || `仓库 ${item.warehouseId}`)
return [...values].map(([value, label]) => ({ value, label }))
})
const queryFields = computed<QueryField[]>(() => [
{ key: 'orderKeyword', type: 'input', label: '订单或货物', placeholder: '订单号、入库单、客户、货物', width: 250 },
{ key: 'containerKeyword', type: 'input', label: '柜次', placeholder: '柜次号、集装箱号、目的仓', width: 230 },
{ key: 'warehouseId', type: 'select', label: '装柜仓库', options: warehouseOptions.value, width: 190 },
{ key: 'status', type: 'select', label: '柜次状态', width: 160, options: [
{ label: '可编辑', value: 'EDITING' },
{ label: '装柜中', value: 'LOADING' },
{ label: '已推送预装', value: 'LOADING' },
{ label: '已完成', value: 'COMPLETED' },
] },
])
const filteredContainers = computed(() => {
const keyword = String(appliedQuery.value.containerKeyword || '').trim().toLowerCase()
const warehouseId = String(appliedQuery.value.warehouseId || '')
const status = String(appliedQuery.value.status || '')
return containers.value.filter((item) => {
if (item.planStatus === 'COMPLETED') return false
if (warehouseId && item.warehouseId !== warehouseId) return false
if (status && item.planStatus !== status) return false
if (!keyword) return true
return [item.cabinetNo, item.containerNo, item.bookingNo, item.destinationWarehouseCode, item.destinationWarehouseName]
@@ -131,7 +126,7 @@ function formatNumber(value: number, digits = 3) {
function candidateRow(source: object) { return source as SmartLoadingCandidate }
function itemRow(source: object) { return source as SmartLoadingItem }
function statusLabel(status: ContainerPlanStatus) {
return ({ EDITING: '可编辑', LOADING: '装柜中', COMPLETED: '已完成' } as const)[status]
return ({ EDITING: '可编辑', LOADING: '已推送预装', COMPLETED: '已完成' } as const)[status]
}
function statusType(status: ContainerPlanStatus) {
return status === 'EDITING' ? 'primary' : status === 'LOADING' ? 'warning' : 'success'
@@ -168,7 +163,9 @@ async function loadSelectedData(preserved: Record<string, number> = {}) {
const request = ++selectionRequest
clearSelectionData()
if (!container) return
if (container.planStatus === 'COMPLETED') return
// Draft recovery is an EDITING-only contract. Later states remain visible and
// continue through their dedicated read-only operational lists.
if (container.planStatus !== 'EDITING') return
selectionLoading.value = true
try {
const [candidateResult, planResult] = await Promise.all([
@@ -190,7 +187,15 @@ async function loadSelectedData(preserved: Record<string, number> = {}) {
}
}
async function load() {
function openList(status: 'LOADING' | 'COMPLETED') {
const routeName = status === 'LOADING' ? 'loading-plans' : 'loading-lists'
const query = selectedContainer.value
? { planId: selectedContainer.value.planId || undefined, warehouseId: selectedContainer.value.warehouseId }
: undefined
void router.push({ name: routeName, query })
}
async function loadAll(preserved: Record<string, number> = {}) {
loading.value = true
loadError.value = null
try {
@@ -198,17 +203,16 @@ async function load() {
const remainingPages = firstPage.pages > 1
? await Promise.all(Array.from({ length: firstPage.pages - 1 }, (_, index) => listContainers({ page: index + 2, size: 100 })))
: []
containers.value = [firstPage, ...remainingPages]
.flatMap((page) => page.records)
.filter((item) => item.planStatus !== 'COMPLETED')
containers.value = [firstPage, ...remainingPages].flatMap((page) => page.records)
if (!filteredContainers.value.some((item) => item.id === selectedContainerId.value)) selectedContainerId.value = preferredContainerId()
await loadSelectedData()
await loadSelectedData(preserved)
} catch (error) {
loadError.value = getApiErrorDetails(error, '智能装柜工作台加载失败')
} finally {
loading.value = false
}
}
async function load() { await loadAll() }
async function selectContainer(containerId: string) {
if (selectedContainerId.value === containerId) return
selectedContainerId.value = containerId
@@ -300,7 +304,7 @@ async function mutate(
} catch (error) {
const details = getApiErrorDetails(error, fallback)
if (details.code === 'A0409') {
await loadSelectedData(recovery.preserveDrafts)
await loadAll(recovery.preserveDrafts)
mutationError.value = { ...details, message: '方案已被其他人修改,已刷新最新版本并保留本次输入,请重新确认后提交' }
if (recovery.preserveAssign) assignOpen.value = true
ElMessage.warning(mutationError.value.message)
@@ -374,7 +378,7 @@ onMounted(load)
</section>
<aside class="dispatch-pane container-pane" aria-label="货柜列表">
<header class="pane-header"><div><strong>货柜</strong><small>可用与装柜中</small></div></header>
<header class="pane-header"><div><strong>货柜</strong><small>可用、已推送与已完成</small></div></header>
<div v-loading="loading" class="container-list">
<el-empty v-if="!filteredContainers.length" description="暂无符合条件的货柜" :image-size="72" />
<button
@@ -409,6 +413,18 @@ onMounted(load)
<template #details><ApiErrorMeta :details="selectionError" /></template>
</LoadErrorState>
<el-empty v-else-if="!selectedContainer" description="请选择一个货柜" />
<section v-else-if="selectedPlanStatus === 'LOADING'" class="readonly-status-card" aria-label="预装中柜次">
<el-tag type="warning">已推送预装</el-tag>
<strong>该柜次正在预装流程中</strong>
<p>智能装柜方案已锁定,不能继续调整。预装运输信息和未拣货明细请在预装清单维护。</p>
<el-button type="primary" @click="openList('LOADING')">查看预装清单</el-button>
</section>
<section v-else-if="selectedPlanStatus === 'COMPLETED'" class="readonly-status-card" aria-label="已完成柜次">
<el-tag type="success">已完成</el-tag>
<strong>该柜次已完成装柜</strong>
<p>装柜计划、明细和运输信息已永久只读,请在装柜清单查看完整记录。</p>
<el-button type="primary" @click="openList('COMPLETED')">查看装柜清单</el-button>
</section>
<el-empty v-else-if="noDraft" description="当前货柜尚未创建装柜方案">
<el-button v-if="canCreate" type="primary" :loading="mutating" @click="createPlan"><Plus :size="16" />创建装柜方案</el-button>
</el-empty>
@@ -466,6 +482,9 @@ onMounted(load)
.plan-actions,.row-actions { flex-wrap: wrap; }
.plan-actions :deep(.el-button + .el-button),.row-actions :deep(.el-button + .el-button) { margin-left: 0; }
.capacity-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; padding: 12px; border-bottom: 1px solid var(--cc-border); }
.readonly-status-card { display: grid; align-content: start; justify-items: start; gap: 10px; padding: 28px 24px; }
.readonly-status-card strong { font-size: 16px; }
.readonly-status-card p { max-width: 460px; margin: 0; color: var(--cc-muted); line-height: 1.6; }
.meter-title { justify-content: space-between; margin-bottom: 8px; font-size: 12px; }
.plan-pane :deep(.el-input-number) { width: 120px; }
.assign-dialog p { margin: 0 0 10px; }
@@ -1,16 +1,18 @@
import ElementPlus from 'element-plus'
import { AxiosError } from 'axios'
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'
import { getContainer, getSmartLoadingPlanByBooking, listSmartLoadingCandidates, recommendSmartLoading } 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(),
}))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
const container = {
id: '9007199254740993', planId: '9007199254740994', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1',
@@ -25,6 +27,7 @@ const container = {
describe('SmartLoadingWorkbenchView', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
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 }])
@@ -42,4 +45,39 @@ describe('SmartLoadingWorkbenchView', () => {
expect(wrapper.text()).toContain('ORD-1')
expect(wrapper.text()).toContain('按 110% 上限')
})
it('does not call the editing contract for an already pushed container', async () => {
vi.mocked(getContainer).mockResolvedValue({ ...container, planStatus: 'LOADING' })
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(getSmartLoadingPlanByBooking).not.toHaveBeenCalled()
expect(listSmartLoadingCandidates).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('该柜次正在预装流程中')
expect(wrapper.text()).toContain('查看预装清单')
expect(wrapper.text()).not.toContain('智能推荐')
})
it('refreshes the latest plan after A0409', async () => {
vi.mocked(getSmartLoadingPlanByBooking)
.mockResolvedValueOnce({ id: '99', bookingId: container.id, planNo: 'LP-99', status: 'EDITING', containerNo: 'MSCU-1', voyageNo: null, version: 2, items: [] })
.mockResolvedValueOnce({ id: '99', bookingId: container.id, planNo: 'LP-99', status: 'EDITING', containerNo: 'MSCU-1', voyageNo: null, version: 3, items: [] })
vi.mocked(recommendSmartLoading).mockRejectedValue(new AxiosError(
'conflict', 'ERR_BAD_REQUEST', undefined, undefined,
{ data: { code: 'A0409', message: '数据已被修改', requestId: 'request-1', traceId: 'trace-1' }, status: 200, statusText: 'OK', headers: {}, config: {} as never },
))
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()
await wrapper.findAll('button').find((button) => button.text().includes('智能推荐'))!.trigger('click')
await flushPromises()
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('版本 3')
expect(wrapper.text()).toContain('已刷新最新版本')
expect(wrapper.text()).toContain('request-1')
})
})
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Plus, RefreshCw, Save, Sparkles, Trash2, Upload } from '@lucide/vue'
import { ArrowLeft, ListChecks, Plus, RefreshCw, Save, 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'
@@ -38,6 +38,8 @@ const canCreate = computed(() => access.can('platform:firstleg:smart-loading:cre
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 canViewPreload = computed(() => access.can('platform:firstleg:preload:list'))
const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-list:list'))
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))
@@ -93,6 +95,11 @@ async function load() {
loading.value = true; loadError.value = null; noDraft.value = false
try {
container.value = await getContainer(containerId.value)
if (container.value.planStatus !== 'EDITING') {
plan.value = null
candidates.value = []
return
}
try { acceptPlan(await getSmartLoadingPlanByBooking(containerId.value)) }
catch (error) {
const detail = getApiErrorDetails(error)
@@ -103,11 +110,18 @@ async function load() {
} catch (error) { loadError.value = getApiErrorDetails(error, '智能排柜工作台加载失败') }
finally { loading.value = false }
}
function openOperationalList() {
if (!container.value || container.value.planStatus === 'EDITING') return
void router.push({
name: container.value.planStatus === 'LOADING' ? 'loading-plans' : 'loading-lists',
query: { planId: container.value.planId || undefined, warehouseId: container.value.warehouseId },
})
}
async function createPlan() {
mutating.value = true
mutationError.value = null
try { acceptPlan(await createSmartLoadingPlan(containerId.value, null)); ElMessage.success('排柜计划已创建') }
catch (error) { handleMutationError(error, '创建排柜计划失败') }
catch (error) { await 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 }
@@ -118,7 +132,7 @@ async function assign() {
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, '分配失败') }
} catch (error) { await handleMutationError(error, '分配失败') }
finally { mutating.value = false }
}
async function recommend() {
@@ -126,7 +140,7 @@ async function recommend() {
mutating.value = true
mutationError.value = null
try { acceptPlan(await recommendSmartLoading(plan.value.id, plan.value.version)); ElMessage.success('智能建议已生成,请人工核对后再推送') }
catch (error) { handleMutationError(error, '智能推荐失败') }
catch (error) { await handleMutationError(error, '智能推荐失败') }
finally { mutating.value = false }
}
async function adjust(source: object) {
@@ -137,7 +151,7 @@ async function adjust(source: object) {
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, '调整失败') }
catch (error) { await handleMutationError(error, '调整失败') }
finally { mutating.value = false }
}
async function remove(source: object) {
@@ -148,7 +162,7 @@ async function remove(source: object) {
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, '移除失败') }
catch (error) { await handleMutationError(error, '移除失败') }
finally { mutating.value = false }
})
}
@@ -159,12 +173,19 @@ async function pushPreload() {
mutating.value = true
mutationError.value = null
try { acceptPlan(await pushSmartLoadingPreload(plan.value.id, plan.value.version)); ElMessage.success('已推送预装清单') }
catch (error) { handleMutationError(error, '推送失败') }
catch (error) { await handleMutationError(error, '推送失败') }
finally { mutating.value = false }
}
function handleMutationError(error: unknown, fallback: string) {
async function handleMutationError(error: unknown, fallback: string) {
const details = getApiErrorDetails(error, fallback)
mutationError.value = { ...details, message: details.code === 'A0409' ? '计划已被其他人修改,请刷新最新计划后重试' : details.message }
if (details.code === 'A0409') {
const preserved = { ...quantityDrafts }
await load()
for (const item of plan.value?.items || []) {
if (preserved[item.id] != null) quantityDrafts[item.id] = preserved[item.id]
}
}
mutationError.value = { ...details, message: details.code === 'A0409' ? '计划已被其他人修改,已刷新最新版本并保留本次输入,请重新确认后提交' : details.message }
ElMessage.error(mutationError.value.message)
}
@@ -190,6 +211,13 @@ onMounted(load)
<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?.planStatus !== 'EDITING'" class="readonly-status-card" aria-label="只读装柜状态">
<el-tag :type="container?.planStatus === 'COMPLETED' ? 'success' : 'warning'">{{ container?.planStatus === 'COMPLETED' ? '已完成' : '已推送预装' }}</el-tag>
<strong>{{ container?.planStatus === 'COMPLETED' ? '该柜次已完成装柜' : '该柜次正在预装流程中' }}</strong>
<p>智能装柜方案已锁定,不能继续调整。请进入对应清单查看后续信息。</p>
<el-button v-if="container?.planStatus === 'LOADING' ? canViewPreload : canViewLoadingList" type="primary" @click="openOperationalList"><ListChecks :size="16" />{{ container?.planStatus === 'COMPLETED' ? '查看装柜清单' : '查看预装清单' }}</el-button>
</section>
<template v-else>
<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>
@@ -211,6 +239,7 @@ onMounted(load)
<template #route="{ row }"><el-tag :type="routeMatches(row) ? 'success' : 'danger'">{{ routeMatches(row) ? '匹配' : '冲突' }}</el-tag><small>{{ explainCandidate(row) }}</small></template>
</DataTable>
</template>
</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>
@@ -232,6 +261,9 @@ onMounted(load)
.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; }
.readonly-status-card { display: grid; justify-items: start; gap: 10px; padding: 24px; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; }
.readonly-status-card strong { font-size: 16px; }
.readonly-status-card p { margin: 0; color: var(--cc-muted); }
.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; }
@@ -92,7 +92,7 @@ function formatNumber(value?: number | null, digits = 3) {
<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>
<div><span>状态</span><StatusBadge :status="statusLabel(summary.status)" /></div>
<template v-if="preloadSummary">
<div><span>船名 / 航次</span><strong>{{ preloadSummary.vesselName || '—' }} / {{ preloadSummary.voyageNo || '—' }}</strong></div>
<div><span>箱型</span><strong>{{ preloadSummary.containerType || '—' }}</strong></div>
@@ -108,6 +108,7 @@ function formatNumber(value?: number | null, digits = 3) {
<el-alert v-else-if="mode === 'confirmed'" type="info" :closable="false" show-icon title="装柜完成后清单、明细和运输信息永久只读" />
<section class="detail-section surface-panel">
<header class="detail-section__header"><h2>装柜货物明细</h2><span>{{ detail.items.length }} 条</span></header>
<DataTable
:data="detail.items"
:columns="itemColumns"
@@ -116,7 +117,6 @@ function formatNumber(value?: number | null, digits = 3) {
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>
@@ -153,7 +153,9 @@ function formatNumber(value?: number | null, digits = 3) {
.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__header { display: flex; align-items: baseline; gap: 10px; padding: 18px 18px 0; }
.detail-section__header h2 { margin: 0; font-size: 16px; }
.detail-section__header span { color: var(--cc-muted); font-size: 12px; }
.detail-section :deep(.el-table) { width: 100%; }
.detail-section small { display: block; margin-top: 3px; color: var(--cc-muted); }
.history-section { padding: 18px; }
@@ -21,6 +21,7 @@ describe('LoadingListsView', () => {
expect(source).toContain("selectedWarehouseId.value = String(row.warehouseId || '')")
expect(source).not.toContain("ElMessage.warning('请先选择仓库')")
expect(source).not.toContain("'请先选择仓库后查询'")
expect(source).toContain('MISSING_WAREHOUSE_CONTEXT')
})
it('uses the shared query, table, drawer, dialog and error components', () => {
@@ -43,4 +44,24 @@ describe('LoadingListsView', () => {
expect(source).toContain('openEditFromRow(row)')
expect(source).toContain('await loadDetail()')
})
it('refreshes the detail version on A0409 while preserving transport input', () => {
expect(source).toContain("details.code === 'A0409'")
expect(source).toContain('await loadDetail()')
expect(source).toContain('当前输入已保留,请核对后重新保存')
})
it('opens a validated plan and warehouse deep link without mixing list modes', () => {
expect(source).toContain('useRoute()')
expect(source).toContain('route.query.planId')
expect(source).toContain('route.query.warehouseId')
expect(source).toContain("const expectedStatus = isPreload.value ? 'LOADING' : 'COMPLETED'")
expect(source).toContain('MODE_STATUS_MISMATCH')
})
it('passes translated status text directly to the badge', () => {
expect(source).toContain('<StatusBadge :status="statusLabel(row.status)" />')
expect(source).not.toContain('<StatusBadge :status="row.status" />')
expect(detailSource).toContain('<StatusBadge :status="statusLabel(summary.status)" />')
})
})
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { Eye, Pencil } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -28,6 +29,7 @@ import {
} from '../api/platformLoadingLists'
const props = defineProps<{ mode: LoadingListMode }>()
const route = useRoute()
const access = useAccessPreviewStore()
const rows = ref<LoadingListSummary[]>([])
const loading = ref(false)
@@ -47,6 +49,7 @@ const editSaving = ref(false)
const editError = ref<ApiErrorDetails | null>(null)
const editFormRef = ref<FormInstance>()
const editForm = reactive({ containerNo: '', voyageNo: '', operationReason: '' })
const handledDeepLink = ref('')
const isPreload = computed(() => props.mode === 'preload')
const title = computed(() => isPreload.value ? '预装清单' : '装柜清单')
@@ -107,6 +110,14 @@ function formatNumber(value?: number | null, digits = 3) {
return Number(value).toLocaleString('zh-CN', { maximumFractionDigits: digits })
}
function statusLabel(status?: string | null) {
return status === 'LOADING' ? '装柜中' : status === 'COMPLETED' ? '装柜完成' : status || '未知状态'
}
function queryId(value: unknown) {
return typeof value === 'string' && /^[1-9]\d*$/.test(value) ? value : ''
}
function toQuery(): LoadingListQuery {
const range = Array.isArray(query.createdRange) ? query.createdRange.map(String) : []
return {
@@ -127,6 +138,7 @@ async function load() {
const result = isPreload.value ? await listPreloadPlans(request) : await listConfirmedLoadingLists(request)
rows.value = result.records
total.value = result.total
await openDeepLinkedDetail()
} catch (cause) {
error.value = getApiErrorDetails(cause, `${title.value}加载失败,请稍后重试`)
} finally {
@@ -134,6 +146,30 @@ async function load() {
}
}
async function openDeepLinkedDetail() {
const planId = queryId(route.query.planId)
const warehouseId = queryId(route.query.warehouseId)
if (!planId || !warehouseId || !canView.value) return
const key = `${props.mode}:${planId}:${warehouseId}`
if (handledDeepLink.value === key) return
handledDeepLink.value = key
selectedPlanId.value = planId
selectedWarehouseId.value = warehouseId
detail.value = null
detailError.value = null
detailOpen.value = true
await loadDetail()
const expectedStatus = isPreload.value ? 'LOADING' : 'COMPLETED'
const loadedDetail = detail.value as LoadingDetail | null
if (loadedDetail && loadedDetail.summary.status !== expectedStatus) {
detail.value = null
detailError.value = {
code: 'MODE_STATUS_MISMATCH',
message: `该计划不属于当前${title.value}状态,请从对应清单入口查看。`,
}
}
}
function search() {
if (page.value !== 1) page.value = 1
else void load()
@@ -141,7 +177,15 @@ function search() {
async function loadDetail() {
if (!selectedPlanId.value || !selectedWarehouseId.value || !canView.value) return
if (!canView.value) return
if (!selectedPlanId.value || !selectedWarehouseId.value) {
detail.value = null
detailError.value = {
message: '该清单缺少所属仓库信息,无法安全查询详情,请联系管理员核对数据。',
code: 'MISSING_WAREHOUSE_CONTEXT',
}
return
}
detailLoading.value = true
detailError.value = null
try {
@@ -204,13 +248,23 @@ async function saveTransport() {
ElMessage.success('运输信息已更新')
await load()
} catch (cause) {
editError.value = getApiErrorDetails(cause, '运输信息保存失败,请刷新详情后重试')
const details = getApiErrorDetails(cause, '运输信息保存失败,请刷新详情后重试')
if (details.code === 'A0409' || /版本|并发|已被其他人修改/.test(details.message)) {
await loadDetail()
editError.value = {
...details,
message: '运输信息已被其他人修改,系统已刷新最新版本;当前输入已保留,请核对后重新保存。',
}
} else {
editError.value = details
}
} finally {
editSaving.value = false
}
}
watch([page, pageSize], () => void load())
watch(() => [route.query.planId, route.query.warehouseId], () => void openDeepLinkedDetail())
onMounted(() => {
void load()
})
@@ -256,7 +310,7 @@ onMounted(() => {
</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 #status="{ row }"><StatusBadge :status="statusLabel(row.status)" /></template>
<template #grossWeight="{ row }">{{ formatNumber(row.totalGrossWeightKg) }}</template>
<template #netWeight="{ row }">{{ formatNumber((row as ConfirmedSummary).totalNetWeightKg) }}</template>
<template #volume="{ row }">{{ formatNumber(row.totalVolumeCbm) }}</template>
@@ -301,8 +355,6 @@ onMounted(() => {
<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; }
.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; } }
@@ -6,6 +6,7 @@ import {
completeLoading,
confirmingPreload,
deleteSmartLoadingItem,
downloadEvidenceFile,
getContainerBooking,
getOutboundDispatch,
getPicking,
@@ -86,6 +87,13 @@ describe('unified order workflow API contract', () => {
)
})
it('downloads loading evidence through the tenant and inbound scoped file endpoint', async () => {
const blob = new Blob(['photo'], { type: 'image/jpeg' })
vi.mocked(http.get).mockResolvedValue(blob)
await expect(downloadEvidenceFile('2', '8', '88')).resolves.toBe(blob)
expect(http.get).toHaveBeenCalledWith('/platform/v1/files/loading-evidence/2/8/88', { responseType: 'blob' })
})
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 } })
@@ -538,6 +538,17 @@ export async function uploadEvidenceFile(
return unwrapApiResponse(response)
}
export function downloadEvidenceFile(
tenantId: string | number,
inboundId: string | number,
fileId: string | number,
) {
return http.get<Blob, Blob>(
`/platform/v1/files/loading-evidence/${encodeURIComponent(String(tenantId))}/${encodeURIComponent(String(inboundId))}/${encodeURIComponent(String(fileId))}`,
{ responseType: 'blob' },
)
}
export async function voidEvidence(
tenantId: string | number,
inboundId: string | number,
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useAuthStore } from '@/core/auth/auth'
import UnifiedOrderWorkflowPanel from './UnifiedOrderWorkflowPanel.vue'
import {
getEvidence,
getOutboundDispatch,
getPreload,
smartSplit,
@@ -14,6 +15,7 @@ vi.mock('../api', async (importOriginal) => {
const original = await importOriginal<typeof import('../api')>()
return {
...original,
getEvidence: vi.fn(),
getOutboundDispatch: vi.fn(),
getPreload: vi.fn(),
smartSplit: vi.fn(),
@@ -29,14 +31,14 @@ function preload(cargoReference: string, version = 3) {
}
}
function mountPanel(permissions: string[]) {
function mountPanel(permissions: string[], mainStatus: 'WAIT_SPLIT' | 'LOADING' | 'LOADED' = 'WAIT_SPLIT') {
const pinia = createPinia()
setActivePinia(pinia)
useAuthStore().permissions = permissions
return mount(UnifiedOrderWorkflowPanel, {
props: {
tenantId: '2086085512165634052', inboundId: '2086085512165634051', orderId: '2086085512165634053',
mainStatus: 'WAIT_SPLIT', inboundVersion: 4,
mainStatus, inboundVersion: 4,
},
global: { plugins: [pinia, ElementPlus] },
})
@@ -45,6 +47,7 @@ function mountPanel(permissions: string[]) {
describe('UnifiedOrderWorkflowPanel preload editor', () => {
beforeEach(() => {
vi.mocked(getOutboundDispatch).mockReset().mockResolvedValue(null as never)
vi.mocked(getEvidence).mockReset().mockResolvedValue({ inboundId: '2086085512165634051', evidences: [] })
vi.mocked(getPreload).mockReset().mockResolvedValue(preload('REF-OLD'))
vi.mocked(smartSplit).mockReset()
})
@@ -94,4 +97,23 @@ describe('UnifiedOrderWorkflowPanel preload editor', () => {
expect(wrapper.text()).toContain('REF-OLD')
expect(wrapper.text()).not.toContain('REF-SERVER')
})
it('keeps completed loading evidence visible and renders every active photo', async () => {
vi.mocked(getEvidence).mockResolvedValue({
inboundId: '2086085512165634051',
evidences: [
{ id: '1', inboundId: '2086085512165634051', tenantId: '2086085512165634052', evidenceType: 'PHOTO_STEP_1', fileId: '11', fileName: 'empty-1.jpg', sealNo: null, remark: null, version: 0, status: 'ACTIVE', createdAt: null, updatedAt: null },
{ id: '2', inboundId: '2086085512165634051', tenantId: '2086085512165634052', evidenceType: 'PHOTO_STEP_1', fileId: '12', fileName: 'empty-2.jpg', sealNo: null, remark: null, version: 0, status: 'ACTIVE', createdAt: null, updatedAt: null },
],
})
const wrapper = mountPanel(['platform:firstleg:inbound:loading:view'], 'LOADED')
await flushPromises()
expect(getEvidence).toHaveBeenCalledWith('2086085512165634052', '2086085512165634051')
expect(wrapper.text()).toContain('empty-1.jpg')
expect(wrapper.text()).toContain('empty-2.jpg')
expect(wrapper.text()).toContain('2 张')
expect(wrapper.text()).not.toContain('完成装柜')
expect(wrapper.findAll('button').some((button) => button.text() === '作废')).toBe(false)
})
})
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { RefreshCw, Save, Check, Play, Camera, Plus, Pencil, Trash2 } from '@lucide/vue'
import { RefreshCw, Save, Check, Play, Camera, Eye, Plus, Pencil, Trash2 } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -14,6 +14,7 @@ import {
completePicking,
confirmingPreload,
confirmPreload,
downloadEvidenceFile,
getEvidence,
getOutboundDispatch,
getPicking,
@@ -54,6 +55,7 @@ const picking = ref<PickingTask | null>(null)
const evidences = ref<Evidence[]>([])
const dispatch = ref<OutboundDispatchAggregate | null>(null)
const loadingRemark = ref('')
const sealNoDraft = ref('')
const splitRemark = ref('')
const busy = ref('')
const loading = ref(false)
@@ -87,9 +89,12 @@ const canPreloadView = computed(() => access.can('firstleg:inbound:preload-view'
const canPicking = computed(() => !props.readOnly && access.can('platform:firstleg:inbound:picking:edit'))
const canPickingView = computed(() => access.can('platform:firstleg:inbound:picking:view'))
// 操作员、理货员由 IAM 授予 loading:edit;拣货员仅能在 PDA 现场采集,不授予该权限。
const canEvidence = computed(() => !props.readOnly && access.can('platform:firstleg:inbound:loading:edit'))
const canEvidence = computed(() => !props.readOnly && isLoading.value && access.can('platform:firstleg:inbound:loading:edit'))
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 evidenceReady = computed(() => evidenceTypes.every(({ type }) => activeEvidences(type).some((item) => (
Boolean(item.fileId) && (type !== 'SEAL' || Boolean(item.sealNo?.trim()))
))))
const preloadColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 132, fixed: 'left', slotName: 'actions' },
@@ -147,8 +152,8 @@ 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')
function activeEvidences(type: EvidenceType) {
return evidences.value.filter((item) => item.evidenceType === type && item.status === 'ACTIVE')
}
function idempotentMessage(action: string) {
@@ -216,7 +221,9 @@ async function loadLoading() {
picking.value = await getPicking(props.tenantId, props.inboundId)
} catch (cause) {
picking.value = null
messages.push(cause instanceof Error ? cause.message : '拣货任务加载失败')
const details = getApiErrorDetails(cause, '拣货任务加载失败')
messages.push(details.message)
errorDetails.value ||= details
}
}
if (canEvidenceView.value) {
@@ -224,7 +231,9 @@ async function loadLoading() {
evidences.value = (await getEvidence(props.tenantId, props.inboundId)).evidences || []
} catch (cause) {
evidences.value = []
messages.push(cause instanceof Error ? cause.message : '装柜证据加载失败')
const details = getApiErrorDetails(cause, '装柜证据加载失败')
messages.push(details.message)
errorDetails.value ||= details
}
}
error.value = messages.join(';')
@@ -242,7 +251,7 @@ async function loadDispatch() {
async function refresh() {
await loadDispatch()
if (isSplit.value) await loadPreload()
if (isLoading.value) await loadLoading()
if (isLoading.value || isLoaded.value) await loadLoading()
}
async function runSmartSplit() {
@@ -309,35 +318,29 @@ async function completePickingTask() {
if (result) picking.value = result
}
async function saveEvidence(type: EvidenceType) {
const current = evidence(type)
const result = await run(`evidence-${type}`, () => upsertEvidence(props.tenantId, props.inboundId, {
expectedVersion: current?.version ?? 0,
evidenceType: type,
fileId: current?.fileId || undefined,
fileName: current?.fileName || undefined,
sealNo: current?.sealNo || undefined,
remark: current?.remark || undefined,
}), `${type === 'SEAL' ? '封签' : '证据'}已保存`)
if (result?.evidences) evidences.value = result.evidences.evidences || []
}
async function uploadAndSaveEvidence(type: EvidenceType, event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file || !canEvidence.value) return
const sealNo = sealNoDraft.value.trim()
if (type === 'SEAL' && !sealNo) {
error.value = '上传带锁码照前必须填写锁码'
errorDetails.value = null
return
}
const uploaded = await run(`evidence-upload-${type}`, () => uploadEvidenceFile(props.tenantId, props.inboundId, type, file), '文件已上传,正在登记证据')
if (!uploaded) return
const current = evidence(type)
const result = await run(`evidence-${type}`, () => upsertEvidence(props.tenantId, props.inboundId, {
expectedVersion: current?.version ?? 0,
expectedVersion: 0,
evidenceType: type,
fileId: uploaded.fileId,
sealNo: current?.sealNo || undefined,
remark: current?.remark || undefined,
sealNo: type === 'SEAL' ? sealNo : undefined,
}), `${type === 'SEAL' ? '带锁码照' : '证据'}已登记`)
if (result?.evidences) evidences.value = result.evidences.evidences || []
if (result?.evidences) {
evidences.value = result.evidences.evidences || []
if (type === 'SEAL') sealNoDraft.value = ''
}
}
async function markEvidenceVoided(item: Evidence) {
@@ -351,6 +354,25 @@ async function markEvidenceVoided(item: Evidence) {
if (result?.evidences) evidences.value = result.evidences.evidences || []
}
async function previewEvidence(item: Evidence) {
if (!canEvidenceView.value || !item.fileId) return
busy.value = `evidence-preview-${item.id}`
error.value = ''
errorDetails.value = null
try {
const blob = await downloadEvidenceFile(props.tenantId, props.inboundId, item.fileId)
const url = URL.createObjectURL(blob)
window.open(url, '_blank', 'noopener,noreferrer')
window.setTimeout(() => URL.revokeObjectURL(url), 60_000)
} catch (cause) {
const details = getApiErrorDetails(cause, '证据照片加载失败,请重试')
error.value = details.message
errorDetails.value = details
} finally {
busy.value = ''
}
}
async function finishLoading() {
const expectedVersion = inboundExpectedVersion.value
if (expectedVersion === null) {
@@ -364,28 +386,6 @@ async function finishLoading() {
if (result) await refresh()
}
function updateEvidenceField(type: EvidenceType, field: 'fileName' | 'sealNo' | 'remark', value: string) {
const current = evidence(type)
if (current) {
current[field] = value || null
return
}
evidences.value.push({
id: `draft-${type}`,
inboundId: props.inboundId,
tenantId: props.tenantId,
evidenceType: type,
fileId: null,
fileName: field === 'fileName' ? value : null,
sealNo: field === 'sealNo' ? value : null,
remark: field === 'remark' ? value : null,
version: 0,
status: 'ACTIVE',
createdAt: null,
updatedAt: null,
})
}
watch(() => props.mainStatus, () => { void refresh() })
onMounted(() => { void refresh() })
</script>
@@ -404,7 +404,7 @@ onMounted(() => { void refresh() })
</el-alert>
<el-alert v-if="notice" class="workflow-alert" type="success" :closable="false" show-icon>{{ notice }}</el-alert>
<el-alert v-if="busy" class="workflow-alert" type="info" :closable="false">{{ idempotentMessage(busy) }}</el-alert>
<el-alert v-if="props.readOnly" class="workflow-alert" type="info" :closable="false" show-icon>当前仅开放进度查询;五图取证和装柜完成门禁接通后再开放操作。</el-alert>
<el-alert v-if="props.readOnly" class="workflow-alert" type="info" :closable="false" show-icon>当前为只读查看,不能修改作业数据。</el-alert>
<el-card v-if="dispatch" shadow="never" class="workflow-card">
<template #header><div class="card-title"><span>子流程与门禁摘要</span><el-tag :type="dispatch.gate?.passed ? 'success' : 'warning'">{{ dispatch.gate?.passed ? '已通过' : '待补齐' }}</el-tag></div></template>
@@ -472,29 +472,37 @@ onMounted(() => { void refresh() })
<el-input v-model="loadingRemark" :disabled="props.readOnly" class="workflow-remark" type="textarea" :rows="2" aria-label="装柜备注" placeholder="装柜备注(可选)" />
</el-card>
<el-card shadow="never" class="workflow-card">
<template #header><div class="card-title"><span>装柜五类证据与锁码</span><el-tag type="info">五类必传</el-tag></div></template>
</template>
<el-card v-if="isLoading || isLoaded" v-loading="loading" shadow="never" class="workflow-card">
<template #header><div class="card-title"><span>装柜五类证据与锁码</span><el-tag :type="evidenceReady ? 'success' : 'info'">{{ evidenceReady ? '证据齐全' : '五类必传' }}</el-tag></div></template>
<el-alert v-if="!canEvidenceView" type="warning" :closable="false" show-icon>当前账号无装柜证据查询权限。</el-alert>
<div class="evidence-grid">
<div v-for="entry in evidenceTypes" :key="entry.type" class="evidence-item">
<strong><Camera :size="15" />{{ entry.label }}</strong>
<strong><Camera :size="15" />{{ entry.label }}<el-tag size="small" :type="activeEvidences(entry.type).length ? 'success' : 'danger'">{{ activeEvidences(entry.type).length }} 张</el-tag></strong>
<div class="evidence-control">
<el-input v-if="entry.type === 'SEAL'" :disabled="props.readOnly" :model-value="evidence(entry.type)?.sealNo || ''" aria-label="锁码" placeholder="锁码(带锁码照必填)" @update:model-value="updateEvidenceField(entry.type, 'sealNo', $event)" />
<span v-if="evidence(entry.type)?.fileId" class="workflow-hint">{{ evidence(entry.type)?.fileName || '已上传文件' }}</span>
<label v-if="canEvidence" class="file-picker"><Camera :size="14" />上传照片<input type="file" accept="image/*" @change="uploadAndSaveEvidence(entry.type, $event)" /></label>
<el-input v-if="entry.type === 'SEAL' && canEvidence" v-model="sealNoDraft" maxlength="64" show-word-limit aria-label="下一张照片的锁码" placeholder="先填写锁码,再上传带锁码照" />
<label v-if="canEvidence" class="file-picker"><Camera :size="14" />追加照片<input type="file" accept="image/*" @change="uploadAndSaveEvidence(entry.type, $event)" /></label>
</div>
<div class="evidence-actions">
<el-button v-if="entry.type === 'SEAL'" plain :disabled="!canEvidence || Boolean(busy)" :loading="busy === `evidence-${entry.type}`" @click="saveEvidence(entry.type)">保存锁码</el-button>
<el-button v-for="item in evidences.filter((candidate) => candidate.evidenceType === entry.type && candidate.status === 'ACTIVE')" :key="String(item.id)" link type="danger" :disabled="!canEvidence || Boolean(busy)" @click="markEvidenceVoided(item)">作废</el-button>
<div v-if="canEvidenceView" class="evidence-files">
<div v-for="item in activeEvidences(entry.type)" :key="String(item.id)" class="evidence-file">
<span>{{ item.fileName || `证据 #${item.id}` }}<template v-if="item.sealNo"> · 锁码 {{ item.sealNo }}</template></span>
<div class="evidence-file__actions">
<el-button link type="primary" :disabled="Boolean(busy)" @click="previewEvidence(item)"><Eye :size="14" />查看</el-button>
<el-button v-if="canEvidence" link type="danger" :disabled="Boolean(busy)" @click="markEvidenceVoided(item)">作废</el-button>
</div>
</div>
<span v-if="activeEvidences(entry.type).length === 0" class="workflow-hint">尚未上传有效照片</span>
</div>
</div>
</div>
<p class="workflow-hint">五类证据各至少一张有效照片;锁码文本与带锁码照同时必填。错误照片只能作废并保留审计记录。</p>
<div class="workflow-actions">
<el-button type="primary" :disabled="!canLoadingComplete || inboundExpectedVersion === null || Boolean(busy)" :loading="busy === 'loading-complete'" @click="finishLoading"><Check :size="15" />完成装柜</el-button>
<div v-if="isLoading" class="workflow-actions">
<el-button type="primary" :disabled="!canLoadingComplete || !evidenceReady || inboundExpectedVersion === null || Boolean(busy)" :loading="busy === 'loading-complete'" @click="finishLoading"><Check :size="15" />完成装柜</el-button>
<span v-if="!evidenceReady" class="workflow-hint">五类有效照片及锁码齐全后可提交;其他门禁仍由服务端校验</span>
<span v-if="inboundExpectedVersion === null" class="workflow-hint">缺少入库版本,刷新订单详情后可提交</span>
</div>
</el-card>
</template>
</el-card>
<el-alert v-if="isLoaded" class="workflow-alert" type="success" :closable="false" show-icon>
已装柜完成。国内端不提供在线冲正、回退或推进派送完成,后续状态仅接受海外/尾程可信回传。
@@ -538,10 +546,13 @@ onMounted(() => { void refresh() })
.workflow-actions { margin-top: 14px; }
.workflow-remark { margin-top: 10px; }
.evidence-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.evidence-item { display: grid; grid-template-columns: minmax(100px, auto) minmax(0, 1fr) auto; gap: 8px; align-items: center; }
.evidence-item { display: grid; min-width: 0; gap: 8px; align-content: start; padding: 12px; border: 1px solid var(--cc-border); border-radius: 6px; }
.evidence-item strong { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
.evidence-control { display: flex; min-width: 0; align-items: center; gap: 8px; }
.evidence-actions { display: flex; align-items: center; gap: 4px; }
.evidence-files { display: grid; gap: 4px; }
.evidence-file { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 8px; color: var(--cc-text-secondary, #64748b); font-size: 12px; }
.evidence-file span { min-width: 0; overflow-wrap: anywhere; }
.evidence-file__actions { display: flex; flex: 0 0 auto; align-items: center; gap: 4px; }
.gate-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
.gate-grid > div { display: flex; min-height: 34px; align-items: center; justify-content: space-between; gap: 8px; padding: 7px 9px; border: 1px solid var(--cc-border); border-radius: 5px; }
.gate-grid span { color: var(--cc-text-secondary, #64748b); font-size: 12px; }