优化优化
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user