Files
admin-web/src/modules/outbound-dispatch/pages/OutboundDispatchesView.vue
T
2026-08-17 20:01:51 +08:00

227 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
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'
import DataTable from '@/shared/components/DataTable.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import AdminPage from '@/shared/components/AdminPage.vue'
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, OutboundDispatchQuery, OutboundDispatchResource } from '../types'
import { oceanBookingStatusLabel, outboundDispatchStatusLabel, pickTaskStatusLabel, timelineActionLabel, timelineModuleLabel, timelineStatusLabel, truckStatusLabel } from '../statusLabels'
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)
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.tenantName, value: String(item.id) })), props: { filterable: true, loading: optionsLoading.value } },
{ key: 'orderId', type: 'select', label: '入仓码', width: 280, placeholder: filters.value.customerId ? '请选择该客户的入仓码' : '请选择入仓码', options: orders.value.map((item) => ({ label: item.inboundCode || '入仓码暂缺', 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: 'inboundCode', label: '入仓码', minWidth: 145 },
{ prop: 'customerName', label: '客户', minWidth: 140 },
{ prop: 'cabinetNo', label: '柜次号', minWidth: 145, formatter: (row: OutboundDispatchResource) => row.cabinetNo || '—' },
{ prop: 'containerNo', label: '集装箱号', minWidth: 145, formatter: (row: OutboundDispatchResource) => row.containerNo || '—' },
{ 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([
loadActiveCustomers(),
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
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
}
async function loadActiveCustomers() {
const records: PlatformTenantResource[] = []
let currentPage = 1
let pages = 1
do {
const result = await listPlatformTenants({ page: currentPage, size: 200, status: 'ACTIVE' })
records.push(...result.records.filter((customer) => customer.status === 'ACTIVE'))
pages = Math.max(1, result.pages)
currentPage += 1
} while (currentPage <= pages)
return records
}
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-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" stripe row-key="inboundId" preference-key="outbound-dispatches" empty-text="暂无符合条件的出仓记录">
<template #actions="{ row }"><div class="table-actions"><el-button v-if="access.can('platform:firstleg:outbound-dispatch:view')" size="small" type="primary" plain @click="open(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can('platform:firstleg:outbound-dispatch:confirm') && row.dispatchStatus === 'READY_FOR_DISPATCH'" size="small" 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 === 'WAITING_FOR_LOADING'" size="small" type="danger" plain @click="action(row, 'reverse-dispatch')"><RotateCcw :size="15" />撤销</el-button></div></template>
<template #booking="{ row }"><StatusBadge :status="oceanBookingStatusLabel(row.oceanBookingStatus)" /></template><template #truck="{ row }"><StatusBadge :status="truckStatusLabel(row.truckAppointmentStatus)" /></template><template #pick="{ row }"><StatusBadge :status="pickTaskStatusLabel(row.pickTaskStatus)" /></template><template #status="{ row }"><StatusBadge :status="outboundDispatchStatusLabel(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" class="outbound-detail-dialog" title="国内出仓详情" width="min(900px, calc(100vw - 28px))" :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.inboundCode || '—' }}</el-descriptions-item><el-descriptions-item label="柜次号">{{ detail.aggregate.cabinetNo || '—' }}</el-descriptions-item><el-descriptions-item label="集装箱号">{{ detail.aggregate.containerNo || '—' }}</el-descriptions-item><el-descriptions-item label="出仓状态"><StatusBadge :status="outboundDispatchStatusLabel(detail.aggregate.dispatchStatus)" /></el-descriptions-item></el-descriptions><h3>统一门禁</h3><el-table class="gate-table" :data="detail.aggregate.gate?.items || []" border stripe><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">{{ timelineModuleLabel(entry.module) }} · {{ timelineActionLabel(entry.action) }}:{{ timelineStatusLabel(entry.module, entry.fromStatus) }} → {{ timelineStatusLabel(entry.module, 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; }
.gate-table { --el-table-row-hover-bg-color: #f1f7ff; }
.table-actions { display: flex; align-items: center; gap: 6px; }.table-actions :deep(.el-button) { margin-left: 0; }
:global(.outbound-detail-dialog.el-dialog) { max-height: 90vh; overflow: hidden; }
:global(.outbound-detail-dialog .el-dialog__body) { max-height: calc(90vh - 120px); overflow-y: auto; }
</style>