优化优化

This commit is contained in:
2026-08-11 18:06:35 +08:00
parent 8a8c5a3faa
commit 03e1cd0c80
40 changed files with 2092 additions and 354 deletions
@@ -1,7 +1,7 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { OutboundDispatchDetail, OutboundDispatchQuery, OutboundDispatchResource, OutboundGateResult } from '../types'
const basePath = '/platform/v1/first-leg/outbound-dispatches'; const keys = ['orderId', 'inboundId', 'containerBookingId', 'customerId'] as const
function item(raw: OutboundDispatchResource) { const value = { ...raw } as OutboundDispatchResource & Record<string, unknown>; for (const key of keys) if (value[key] != null) value[key] = String(value[key]); value.version = Number(value.version ?? 0); return value }
function item(raw: OutboundDispatchResource): OutboundDispatchResource { const value = { ...raw } as OutboundDispatchResource & Record<string, unknown>; for (const key of keys) if (value[key] != null) value[key] = String(value[key]); value.version = Number(value.version ?? 0); return value }
function idem() { return `outbound-dispatch-${Date.now()}-${Math.random().toString(36).slice(2)}` }
export async function listOutboundDispatches(query: OutboundDispatchQuery) { const response = await http.get<ApiEnvelope<OutboundDispatchResource[]>, ApiEnvelope<OutboundDispatchResource[]>>(basePath, { params: query }); return (unwrapApiResponse(response) || []).map(item) }
export async function getOutboundDispatchDetail(query: OutboundDispatchQuery) { const response = await http.get<ApiEnvelope<OutboundDispatchDetail>, ApiEnvelope<OutboundDispatchDetail>>(`${basePath}/detail`, { params: query }); const data = unwrapApiResponse(response); return { ...data, aggregate: item(data.aggregate) } }
@@ -1,5 +1,138 @@
import { describe, expect, it } from 'vitest'
import ElementPlus from 'element-plus'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
const source = readFileSync(resolve(process.cwd(), 'src/modules/outbound-dispatch/pages/OutboundDispatchesView.vue'), 'utf8')
describe('OutboundDispatchesView', () => { it('uses standard list components and left actions', () => { expect(source).toContain('QueryToolbar'); expect(source).toContain('DataTable'); expect(source).toContain("fixed: 'left'") }); it('waits for a business id before querying', () => { expect(source).not.toContain('onMounted(load)'); expect(source).toContain("String(filters.orderId || '').trim() || undefined"); expect(source).toContain('if (!params.orderId && !params.inboundId && !params.containerBookingId)'); expect(source).toContain('请输入订单 ID、入库单 ID 或柜次 ID 后查询'); expect(source).toContain('@reset="resetSearch"') }); it('shows gate, timeline and guarded dispatch actions', () => { expect(source).toContain('aggregate.gate?.items'); expect(source).toContain('detail.timeline'); expect(source).toContain("'confirm-dispatch'"); expect(source).toContain("'reverse-dispatch'"); expect(source).toContain("error.value?.code === 'A0409'") }) })
import { createPinia, setActivePinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import DataTable from '@/shared/components/DataTable.vue'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listPlatformUnifiedOrders, type UnifiedOrderResource } from '@/modules/unified-orders'
import { listContainers, type ContainerBookingResource } from '@/modules/container-loading'
import { getOutboundDispatchDetail, listOutboundDispatches } from '../api/platformOutboundDispatch'
import type { OutboundDispatchResource } from '../types'
import OutboundDispatchesView from './OutboundDispatchesView.vue'
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
vi.mock('@/modules/unified-orders', () => ({ listPlatformUnifiedOrders: vi.fn() }))
vi.mock('@/modules/container-loading', () => ({ listContainers: vi.fn() }))
vi.mock('../api/platformOutboundDispatch', () => ({ listOutboundDispatches: vi.fn(), getOutboundDispatchDetail: vi.fn(), changeOutboundDispatch: vi.fn() }))
const customer = { id: '10001', tenantCode: 'CUS-1', tenantName: '示例客户', legalName: '示例客户有限公司', status: 'ACTIVE' } as PlatformTenantResource
const order = { orderId: '20001', orderNo: 'ORD-001', inboundId: '30001', inboundCode: 'IN-001', customerId: '10001', customerName: '示例客户' } as UnifiedOrderResource
const secondOrder = { ...order, orderId: '20002', orderNo: 'ORD-002', inboundId: '30002', inboundCode: 'IN-002' } as UnifiedOrderResource
const container = { id: '40001', bookingNo: 'BK-001', cabinetNo: 'CAB-001', containerNo: 'MSCU-001' } as ContainerBookingResource
const row = { orderId: '20001', orderNo: 'ORD-001', inboundId: '30001', inboundCode: 'IN-001', customerId: '10001', customerName: '示例客户', containerBookingId: '40001', containerNo: 'MSCU-001', dispatchStatus: 'WAITING', version: 1, gate: { passed: true, items: [], checkedAt: '2026-08-11T10:00:00' } } as OutboundDispatchResource
enableAutoUnmount(afterEach)
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => { resolve = resolvePromise; reject = rejectPromise })
return { promise, resolve, reject }
}
function mountPage() {
return mount(OutboundDispatchesView, { attachTo: document.body, global: { plugins: [ElementPlus, createPinia()] } })
}
async function setFilters(wrapper: ReturnType<typeof mountPage>, values: Record<string, string>) {
const toolbar = wrapper.findComponent(QueryToolbar)
toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), ...values })
await flushPromises()
return toolbar
}
describe('OutboundDispatchesView', () => {
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [customer], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(listPlatformUnifiedOrders).mockResolvedValue({ records: [order, secondOrder], page: 1, size: 200, total: 2, pages: 1 })
vi.mocked(listContainers).mockResolvedValue({ records: [container], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(listOutboundDispatches).mockResolvedValue([row])
})
it('starts idle and queries real ids selected through business labels', async () => {
const wrapper = mountPage()
await flushPromises()
expect(listOutboundDispatches).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('请选择订单 / 入库单或柜次后查询')
const toolbar = await setFilters(wrapper, { orderId: '20001' })
toolbar.vm.$emit('search')
await flushPromises()
expect(listOutboundDispatches).toHaveBeenCalledOnce()
expect(listOutboundDispatches).toHaveBeenCalledWith({ orderId: '20001', inboundId: '30001', containerBookingId: undefined })
expect(wrapper.text()).toContain('ORD-001')
})
it('does not issue a search when reset returns to the initial state', async () => {
const wrapper = mountPage()
await flushPromises()
const toolbar = await setFilters(wrapper, { containerBookingId: '40001' })
toolbar.vm.$emit('search')
await flushPromises()
expect(listOutboundDispatches).toHaveBeenCalledOnce()
const resetButton = wrapper.findAll('button').find((button) => button.text().includes('重置'))
await resetButton?.trigger('click')
await flushPromises()
expect(listOutboundDispatches).toHaveBeenCalledOnce()
expect(wrapper.text()).toContain('请选择订单 / 入库单或柜次后查询')
})
it('shows loading, empty and recoverable error states', async () => {
const pending = deferred<OutboundDispatchResource[]>()
vi.mocked(listOutboundDispatches).mockReturnValueOnce(pending.promise)
const wrapper = mountPage()
await flushPromises()
const toolbar = await setFilters(wrapper, { containerBookingId: '40001' })
toolbar.vm.$emit('search')
await flushPromises()
expect(wrapper.findComponent(DataTable).props('loading')).toBe(true)
pending.resolve([])
await flushPromises()
expect(wrapper.text()).toContain('暂无符合条件的出仓记录')
vi.mocked(listOutboundDispatches).mockRejectedValueOnce(new Error('network down')).mockResolvedValueOnce([row])
toolbar.vm.$emit('search')
await flushPromises()
expect(wrapper.text()).toContain('国内出仓加载失败')
const retry = wrapper.findAll('button').find((button) => button.text().includes('重新加载'))
await retry?.trigger('click')
await flushPromises()
expect(listOutboundDispatches).toHaveBeenCalledTimes(3)
expect(wrapper.text()).toContain('ORD-001')
})
it('ignores stale list responses when a newer query wins', async () => {
const first = deferred<OutboundDispatchResource[]>()
const second = deferred<OutboundDispatchResource[]>()
vi.mocked(listOutboundDispatches).mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise)
const wrapper = mountPage()
await flushPromises()
let toolbar = await setFilters(wrapper, { orderId: '20001' })
toolbar.vm.$emit('search')
toolbar = await setFilters(wrapper, { orderId: '20002' })
toolbar.vm.$emit('search')
second.resolve([{ ...row, orderId: '20002', orderNo: 'ORD-002', inboundId: '30002', inboundCode: 'IN-002' }])
await flushPromises()
first.resolve([row])
await flushPromises()
expect(wrapper.text()).toContain('ORD-002')
expect(wrapper.text()).not.toContain('ORD-001')
})
it('keeps the operation column first and guarded workflow actions', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/outbound-dispatch/pages/OutboundDispatchesView.vue'), 'utf8')
const columnSource = source.slice(source.indexOf('const columns'))
expect(columnSource.indexOf("key: 'actions'")).toBeLessThan(columnSource.indexOf("prop: 'orderNo'"))
expect(columnSource).toContain("fixed: 'left'")
expect(source).toContain('aggregate.gate?.items')
expect(source).toContain('detail.timeline')
expect(source).toContain("'confirm-dispatch'")
expect(source).toContain("'reverse-dispatch'")
expect(source).toContain("error.value?.code === 'A0409'")
expect(getOutboundDispatchDetail).not.toHaveBeenCalled()
})
})
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Eye, RotateCcw, Send } from '@lucide/vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
@@ -12,18 +12,197 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listPlatformUnifiedOrders, type UnifiedOrderResource } from '@/modules/unified-orders'
import { listContainers, type ContainerBookingResource } from '@/modules/container-loading'
import { changeOutboundDispatch, getOutboundDispatchDetail, listOutboundDispatches } from '../api/platformOutboundDispatch'
import type { OutboundDispatchDetail, OutboundDispatchResource } from '../types'
const access = useAccessPreviewStore(); const rows = ref<OutboundDispatchResource[]>([]); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null); const filters = reactive<QueryModel>({ orderId: '', inboundId: '', containerBookingId: '' })
const fields: QueryField[] = [{ key: 'orderId', type: 'input', label: '订单 ID' }, { key: 'inboundId', type: 'input', label: '入库单 ID' }, { key: 'containerBookingId', type: 'input', label: '柜次 ID' }]
const columns = [{ key: 'actions', label: '操作', fixed: 'left', width: 240, slotName: 'actions', hideable: false, movable: false }, { prop: 'orderNo', label: '订单号', minWidth: 145 }, { prop: 'inboundCode', label: '入库单号', minWidth: 145 }, { prop: 'customerName', label: '客户', minWidth: 140 }, { prop: 'containerNo', label: '柜号', minWidth: 130 }, { prop: 'oceanBookingStatus', label: '订舱', width: 105, slotName: 'booking' }, { prop: 'truckAppointmentStatus', label: '拖车', width: 105, slotName: 'truck' }, { prop: 'pickTaskStatus', label: '拣货', width: 105, slotName: 'pick' }, { prop: 'dispatchStatus', label: '出仓状态', width: 115, slotName: 'status' }, { key: 'gate', label: '门禁', width: 90, slotName: 'gate' }]
function query() { return { orderId: String(filters.orderId || '').trim() || undefined, inboundId: String(filters.inboundId || '').trim() || undefined, containerBookingId: String(filters.containerBookingId || '').trim() || undefined } }
import type { OutboundDispatchDetail, OutboundDispatchQuery, OutboundDispatchResource } from '../types'
type DispatchFilters = QueryModel & { customerId: string; orderId: string; containerBookingId: string }
const access = useAccessPreviewStore()
const rows = ref<OutboundDispatchResource[]>([])
const loading = ref(false)
const error = ref<ApiErrorDetails | null>(null)
const queried = ref(false)
async function load() { const params = query(); if (!params.orderId && !params.inboundId && !params.containerBookingId) { rows.value = []; error.value = null; queried.value = false; return } queried.value = true; loading.value = true; error.value = null; try { rows.value = await listOutboundDispatches(params) } catch (cause) { error.value = getApiErrorDetails(cause) } finally { loading.value = false } }
function resetSearch() { rows.value = []; error.value = null; queried.value = false }
const detailOpen = ref(false); const detail = ref<OutboundDispatchDetail | null>(null); const actionError = ref<ApiErrorDetails | null>(null)
async function open(value: unknown) { const row = value as OutboundDispatchResource; detailOpen.value = true; actionError.value = null; try { detail.value = await getOutboundDispatchDetail({ inboundId: row.inboundId, containerBookingId: row.containerBookingId || undefined }) } catch (cause) { actionError.value = getApiErrorDetails(cause) } }
async function action(value: unknown, name: 'confirm-dispatch' | 'reverse-dispatch') { const row = value as OutboundDispatchResource; try { await ElMessageBox.confirm(name === 'confirm-dispatch' ? '确认国内出仓?系统将校验全部装柜门禁。' : '确认撤销国内出仓?', '出仓状态确认', { type: name === 'reverse-dispatch' ? 'warning' : 'info' }); await changeOutboundDispatch(name, { inboundId: row.inboundId, containerBookingId: row.containerBookingId, expectedVersion: row.version, remark: name === 'confirm-dispatch' ? '后台确认出仓' : '后台撤销出仓' }); ElMessage.success('出仓状态已更新'); await load() } catch (cause) { if (cause !== 'cancel' && !(cause instanceof Error && cause.message === 'cancel')) error.value = getApiErrorDetails(cause); if (error.value?.code === 'A0409') await load() } }
const filters = ref<DispatchFilters>({ customerId: '', orderId: '', containerBookingId: '' })
const customers = ref<PlatformTenantResource[]>([])
const orders = ref<UnifiedOrderResource[]>([])
const containers = ref<ContainerBookingResource[]>([])
const optionsLoading = ref(false)
const optionsError = ref('')
const activeQuery = ref<OutboundDispatchQuery | null>(null)
let listRequestId = 0
let optionRequestId = 0
let detailRequestId = 0
let disposed = false
const fields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户', width: 240, placeholder: '全部客户', options: customers.value.map((item) => ({ label: item.legalName?.trim() || item.tenantName, value: String(item.id), disabled: item.status !== 'ACTIVE' })), props: { filterable: true, loading: optionsLoading.value } },
{ key: 'orderId', type: 'select', label: '订单 / 入库单', width: 280, placeholder: filters.value.customerId ? '请选择该客户的订单' : '请选择订单或入库单', options: orders.value.map((item) => ({ label: `${item.orderNo || item.orderId} / ${item.inboundCode || item.inboundId || '未入库'}`, value: String(item.orderId), disabled: item.inboundId == null })), props: { filterable: true, loading: optionsLoading.value } },
{ key: 'containerBookingId', type: 'select', label: '柜次', width: 240, placeholder: '请选择柜号或集装箱号', options: containers.value.map((item) => ({ label: `${item.cabinetNo || item.bookingNo}${item.containerNo ? ` / ${item.containerNo}` : ''}`, value: item.id })), props: { filterable: true, loading: optionsLoading.value } },
])
const columns = [
{ key: 'actions', label: '操作', fixed: 'left', width: 240, slotName: 'actions', hideable: false, movable: false },
{ prop: 'orderNo', label: '订单号', minWidth: 145 },
{ prop: 'inboundCode', label: '入库单号', minWidth: 145 },
{ prop: 'customerName', label: '客户', minWidth: 140 },
{ prop: 'containerNo', label: '柜号', minWidth: 130 },
{ prop: 'oceanBookingStatus', label: '订舱', width: 105, slotName: 'booking' },
{ prop: 'truckAppointmentStatus', label: '拖车', width: 105, slotName: 'truck' },
{ prop: 'pickTaskStatus', label: '拣货', width: 105, slotName: 'pick' },
{ prop: 'dispatchStatus', label: '出仓状态', width: 115, slotName: 'status' },
{ key: 'gate', label: '门禁', width: 90, slotName: 'gate' },
]
function currentQuery(): OutboundDispatchQuery | null {
const order = orders.value.find((item) => String(item.orderId) === filters.value.orderId)
const params = {
orderId: order ? String(order.orderId) : undefined,
inboundId: order?.inboundId == null ? undefined : String(order.inboundId),
containerBookingId: String(filters.value.containerBookingId || '').trim() || undefined,
}
return params.orderId || params.inboundId || params.containerBookingId ? params : null
}
async function load(params = activeQuery.value) {
if (!params) return
const requestId = ++listRequestId
loading.value = true
error.value = null
queried.value = true
try {
const result = await listOutboundDispatches(params)
if (disposed || requestId !== listRequestId) return
rows.value = result
} catch (cause) {
if (disposed || requestId !== listRequestId) return
rows.value = []
error.value = getApiErrorDetails(cause)
} finally {
if (!disposed && requestId === listRequestId) loading.value = false
}
}
function search() {
const params = currentQuery()
if (!params) {
listRequestId += 1
activeQuery.value = null
rows.value = []
error.value = null
loading.value = false
queried.value = false
return
}
activeQuery.value = params
void load(params)
}
function resetSearch() {
listRequestId += 1
activeQuery.value = null
rows.value = []
error.value = null
loading.value = false
queried.value = false
}
async function loadOptions(customerId = filters.value.customerId) {
const requestId = ++optionRequestId
optionsLoading.value = true
optionsError.value = ''
const results = await Promise.allSettled([
listPlatformTenants({ page: 1, size: 200 }),
listPlatformUnifiedOrders({ customerId: customerId || undefined, page: 1, size: 200 }),
listContainers({ page: 1, size: 200 }),
])
if (disposed || requestId !== optionRequestId) return
if (results[0].status === 'fulfilled') customers.value = results[0].value.records
if (results[1].status === 'fulfilled') orders.value = results[1].value.records
if (results[2].status === 'fulfilled') containers.value = results[2].value.records
const failed = ['客户', '订单', '柜次'].filter((_, index) => results[index]?.status === 'rejected')
if (failed.length) optionsError.value = `${failed.join('、')}选项加载失败,可重新加载后再查询`
optionsLoading.value = false
}
watch(() => filters.value.customerId, (customerId, previous) => {
if (customerId === previous) return
filters.value = { ...filters.value, orderId: '' }
orders.value = []
void loadOptions(customerId)
})
const detailOpen = ref(false)
const detail = ref<OutboundDispatchDetail | null>(null)
const detailTarget = ref<OutboundDispatchResource | null>(null)
const detailLoading = ref(false)
const actionError = ref<ApiErrorDetails | null>(null)
async function open(value: unknown) {
const row = value as OutboundDispatchResource
const requestId = ++detailRequestId
detailOpen.value = true
detailTarget.value = row
detail.value = null
detailLoading.value = true
actionError.value = null
try {
const result = await getOutboundDispatchDetail({ inboundId: row.inboundId, containerBookingId: row.containerBookingId || undefined })
if (disposed || requestId !== detailRequestId) return
detail.value = result
} catch (cause) {
if (disposed || requestId !== detailRequestId) return
actionError.value = getApiErrorDetails(cause)
} finally {
if (!disposed && requestId === detailRequestId) detailLoading.value = false
}
}
function retryDetail() { if (detailTarget.value) void open(detailTarget.value) }
async function action(value: unknown, name: 'confirm-dispatch' | 'reverse-dispatch') {
const row = value as OutboundDispatchResource
try {
await ElMessageBox.confirm(name === 'confirm-dispatch' ? '确认国内出仓?系统将校验全部装柜门禁。' : '确认撤销国内出仓?', '出仓状态确认', { type: name === 'reverse-dispatch' ? 'warning' : 'info' })
await changeOutboundDispatch(name, { inboundId: row.inboundId, containerBookingId: row.containerBookingId, expectedVersion: row.version, remark: name === 'confirm-dispatch' ? '后台确认出仓' : '后台撤销出仓' })
ElMessage.success('出仓状态已更新')
await load()
} catch (cause) {
if (cause !== 'cancel' && !(cause instanceof Error && cause.message === 'cancel')) error.value = getApiErrorDetails(cause)
if (error.value?.code === 'A0409') await load()
}
}
onMounted(() => void loadOptions())
onBeforeUnmount(() => { disposed = true; listRequestId += 1; optionRequestId += 1; detailRequestId += 1 })
</script>
<template><AdminPage as="div"><ListPageLayout aria-label="国内出仓列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="国内出仓筛选" :fields="fields" @search="load" @reset="resetSearch" /></template><template v-if="error" #state><LoadErrorState title="国内出仓加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template><section v-if="!error" class="surface-panel table-panel"><el-empty v-if="!queried" description="请输入订单 ID、入库单 ID 或柜次 ID 后查询" /><DataTable v-else :data="rows" :columns="columns" :loading="loading" row-key="inboundId" preference-key="outbound-dispatches" empty-text="暂无符合条件的出仓记录"><template #actions="{ row }"><el-button v-if="access.can('platform:firstleg:outbound-dispatch:view')" link type="primary" @click="open(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can('platform:firstleg:outbound-dispatch:confirm') && row.dispatchStatus !== 'DISPATCHED'" link type="primary" @click="action(row, 'confirm-dispatch')"><Send :size="15" />确认</el-button><el-button v-if="access.can('platform:firstleg:outbound-dispatch:reverse') && row.dispatchStatus === 'DISPATCHED'" link type="danger" @click="action(row, 'reverse-dispatch')"><RotateCcw :size="15" />撤销</el-button></template><template #booking="{ row }"><StatusBadge :status="row.oceanBookingStatus || '未关联'" /></template><template #truck="{ row }"><StatusBadge :status="row.truckAppointmentStatus || '未关联'" /></template><template #pick="{ row }"><StatusBadge :status="row.pickTaskStatus || '未关联'" /></template><template #status="{ row }"><StatusBadge :status="row.dispatchStatus || '待处理'" /></template><template #gate="{ row }"><el-tag :type="row.gate?.passed ? 'success' : 'danger'">{{ row.gate?.passed ? '通过' : '未通过' }}</el-tag></template></DataTable></section></ListPageLayout><AppDialog v-model="detailOpen" title="国内出仓详情" width="900px" :show-confirm="false"><LoadErrorState v-if="actionError" title="出仓详情加载失败" :message="actionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="actionError" /></template></LoadErrorState><template v-else-if="detail"><el-descriptions :column="2" border><el-descriptions-item label="订单号">{{ detail.aggregate.orderNo }}</el-descriptions-item><el-descriptions-item label="入库单号">{{ detail.aggregate.inboundCode }}</el-descriptions-item><el-descriptions-item label="柜号">{{ detail.aggregate.containerNo || '—' }}</el-descriptions-item><el-descriptions-item label="出仓状态"><StatusBadge :status="detail.aggregate.dispatchStatus || '待处理'" /></el-descriptions-item></el-descriptions><h3>统一门禁</h3><el-table :data="detail.aggregate.gate?.items || []"><el-table-column label="结果" width="80"><template #default="scope"><el-tag :type="scope.row.passed ? 'success' : 'danger'">{{ scope.row.passed ? '通过' : '阻断' }}</el-tag></template></el-table-column><el-table-column prop="name" label="门禁" min-width="140" /><el-table-column prop="actual" label="当前情况" min-width="180" /><el-table-column prop="requirement" label="要求" min-width="180" /></el-table><h3>整体时间线</h3><el-timeline><el-timeline-item v-for="entry in detail.timeline" :key="`${entry.module}-${entry.occurredAt}-${entry.action}`" :timestamp="entry.occurredAt">{{ entry.module }} · {{ entry.action }}:{{ entry.fromStatus || '—' }} → {{ entry.toStatus || '—' }}<small>{{ entry.operatorName || '系统' }} {{ entry.remark || '' }}</small></el-timeline-item></el-timeline></template></AppDialog></AdminPage></template>
<style scoped>h3{margin:20px 0 12px}small{display:block;color:var(--cc-muted)}</style>
<template>
<AdminPage as="div">
<ListPageLayout aria-label="国内出仓列表">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="国内出仓筛选" :fields="fields" :search-on-reset="false" @search="search" @reset="resetSearch" /></template>
<template v-if="optionsError || error" #state>
<el-alert v-if="optionsError" type="warning" :closable="false" show-icon :title="optionsError"><el-button link type="warning" :loading="optionsLoading" @click="loadOptions()">重新加载选项</el-button></el-alert>
<LoadErrorState v-if="error" title="国内出仓加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
</template>
<section v-if="!error" class="surface-panel table-panel">
<el-empty v-if="!queried" description="请选择订单 / 入库单或柜次后查询" />
<DataTable v-else :data="rows" :columns="columns" :loading="loading" row-key="inboundId" preference-key="outbound-dispatches" empty-text="暂无符合条件的出仓记录">
<template #actions="{ row }"><el-button v-if="access.can('platform:firstleg:outbound-dispatch:view')" link type="primary" @click="open(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can('platform:firstleg:outbound-dispatch:confirm') && row.dispatchStatus !== 'DISPATCHED'" link type="primary" @click="action(row, 'confirm-dispatch')"><Send :size="15" />确认</el-button><el-button v-if="access.can('platform:firstleg:outbound-dispatch:reverse') && row.dispatchStatus === 'DISPATCHED'" link type="danger" @click="action(row, 'reverse-dispatch')"><RotateCcw :size="15" />撤销</el-button></template>
<template #booking="{ row }"><StatusBadge :status="row.oceanBookingStatus || '未关联'" /></template><template #truck="{ row }"><StatusBadge :status="row.truckAppointmentStatus || '未关联'" /></template><template #pick="{ row }"><StatusBadge :status="row.pickTaskStatus || '未关联'" /></template><template #status="{ row }"><StatusBadge :status="row.dispatchStatus || '待处理'" /></template><template #gate="{ row }"><el-tag :type="row.gate?.passed ? 'success' : 'danger'">{{ row.gate?.passed ? '通过' : '未通过' }}</el-tag></template>
</DataTable>
</section>
</ListPageLayout>
<AppDialog v-model="detailOpen" title="国内出仓详情" width="900px" :show-confirm="false">
<LoadErrorState v-if="actionError" title="出仓详情加载失败" :message="actionError.message" :loading="detailLoading" @retry="retryDetail"><template #details><ApiErrorMeta :details="actionError" /></template></LoadErrorState>
<div v-else-if="detailLoading" class="detail-loading" aria-busy="true"><el-skeleton :rows="5" animated /></div>
<template v-else-if="detail"><el-descriptions :column="2" border><el-descriptions-item label="订单号">{{ detail.aggregate.orderNo }}</el-descriptions-item><el-descriptions-item label="入库单号">{{ detail.aggregate.inboundCode }}</el-descriptions-item><el-descriptions-item label="柜号">{{ detail.aggregate.containerNo || '—' }}</el-descriptions-item><el-descriptions-item label="出仓状态"><StatusBadge :status="detail.aggregate.dispatchStatus || '待处理'" /></el-descriptions-item></el-descriptions><h3>统一门禁</h3><el-table :data="detail.aggregate.gate?.items || []"><el-table-column label="结果" width="80"><template #default="scope"><el-tag :type="scope.row.passed ? 'success' : 'danger'">{{ scope.row.passed ? '通过' : '阻断' }}</el-tag></template></el-table-column><el-table-column prop="name" label="门禁" min-width="140" /><el-table-column prop="actual" label="当前情况" min-width="180" /><el-table-column prop="requirement" label="要求" min-width="180" /></el-table><h3>整体时间线</h3><el-timeline><el-timeline-item v-for="entry in detail.timeline" :key="`${entry.module}-${entry.occurredAt}-${entry.action}`" :timestamp="entry.occurredAt">{{ entry.module }} · {{ entry.action }}:{{ entry.fromStatus || '—' }} → {{ entry.toStatus || '—' }}<small>{{ entry.operatorName || '系统' }} {{ entry.remark || '' }}</small></el-timeline-item></el-timeline></template>
</AppDialog>
</AdminPage>
</template>
<style scoped>
h3 { margin: 20px 0 12px; }
small { display: block; color: var(--cc-muted); }
.detail-loading { padding: 12px 0; }
</style>