优化优化

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,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>