Files
admin-web/src/modules/overseas-warehouse/pages/DispatchesView.vue
T
2026-10-21 15:20:24 +08:00

162 lines
17 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 '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { appointmentReturnTarget } from '@/modules/appointment-calendar'
import { TransportRouteDialog, routeLocation, type RouteLocation } from '@/modules/overseas-master-data'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import { getDispatch, getDispatchBolPdf, listDispatches } from '../api/dispatches'
import { dispatchStages, reviewLabels, stageLabel, type BeforeLoadingSection, type DispatchAction, type DispatchListRow, type DispatchStage, type DispatchStatistics, type DispatchStatus, type ReviewStatus } from '../dispatchContracts'
import { canDispatchAction, dispatchActionLabels, dispatchActionPermissions, dispatchRejectionReason } from '../dispatchPresentation'
import { formatOverseasDateTime as time } from '../lastMilePresentation'
import TransportTaskCreateDialog from '../components/TransportTaskCreateDialog.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import TransportExecutionPanel from '../components/TransportExecutionPanel.vue'
import { transportLabels, type TransportType } from '../api/transportTasks'
import SmartDispatchDialog from '../components/SmartDispatchDialog.vue'
import DispatchWorkflowDialog from '../components/DispatchWorkflowDialog.vue'
import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
const auth = useAuthStore(), recovery = useDispatchCommandRecovery(() => auth.user?.id || '')
const rows = ref<DispatchListRow[]>([]), statistics = ref<DispatchStatistics | null>(null)
const loading = ref(false), loadError = ref<ApiErrorDetails | null>(null), mutationError = ref<ApiErrorDetails | null>(null)
const rejectionError = ref<ApiErrorDetails | null>(null)
const transportOpen = ref(false), transportType = ref<TransportType>('PICKUP')
const transportEditOpen = ref(false), transportEditNo = ref('')
function createTransport(type: TransportType) { transportType.value = type; transportOpen.value = true }
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const stage = ref<DispatchStage>('BEFORE_LOADING'), section = ref<BeforeLoadingSection>('VEHICLE_INFO')
const model = ref<QueryModel>({ dispatchNo: String(route.query.dispatchNo || ''), vehiclePlate: '', status: 'ACTIVE' })
const operation = ref<'ROUTE' | undefined>()
const action = ref<DispatchAction>('EDIT'), actionNo = ref(''), actionOpen = ref(false), createOpen = ref(false), bolLoadingNo = ref('')
const routeOpen = ref(false), routeOrigin = ref<RouteLocation>(routeLocation('')), routeDestinations = ref<RouteLocation[]>([])
const sections: Array<{ value: BeforeLoadingSection; label: string }> = [{ value: 'VEHICLE_INFO', label: '车辆信息' }, { value: 'DISPATCH_REVIEW', label: '调度审核' }, { value: 'LOADING_REVIEW', label: '理货员审核' }]
const showSections = computed(() => stage.value === 'BEFORE_LOADING' && model.value.status === 'ACTIVE')
const fields = computed<QueryField[]>(() => [
{ key: 'dispatchNo', label: '排车单号', type: 'input', width: 180, placeholder: '请输入排车单号' },
{ key: 'vehiclePlate', label: '车牌号', type: 'input', width: 150, placeholder: '请输入车牌号' },
{ key: 'status', label: '单据范围', type: 'select', width: 140, options: [{ value: 'ACTIVE', label: '有效排车' }, { value: 'CANCELLED', label: '已取消' }, { value: 'ALL', label: '全部单据' }] },
])
const columns = computed<DataTableColumn[]>(() => [
{ key: 'actions', label: '操作', width: 220, fixed: 'left', slotName: 'actions' },
{ key: 'taskType', label: '排车类型', width: 115, slotName: 'taskType' },
{ key: 'dispatchNo', prop: 'dispatchNo', label: '排车单号', minWidth: 190 },
{ key: 'vehicle', label: '车牌号', minWidth: 130, slotName: 'vehicle' }, { key: 'driver', label: '司机/联系电话', minWidth: 170, slotName: 'driver' },
{ key: 'warehouseName', prop: 'warehouseName', label: '起运仓', minWidth: 160 },
...(showSections.value && section.value === 'VEHICLE_INFO' ? [{ key: 'returnReason', label: '退回原因', minWidth: 240, slotName: 'returnReason' }] : []),
...(showSections.value && section.value !== 'VEHICLE_INFO' ? [{ key: 'review', label: section.value === 'DISPATCH_REVIEW' ? '调度审核' : '理货员审核', minWidth: 210, slotName: 'review' }] : []),
{ key: 'counts', label: '托数/箱数/已拣', minWidth: 160, slotName: 'counts' }, { key: 'planned', label: '计划发车', minWidth: 165, slotName: 'planned' },
{ key: 'departure', label: '实际发车', minWidth: 165, slotName: 'departure' }, { key: 'stage', label: '作业阶段', minWidth: 110, slotName: 'stage' },
...(stage.value === 'DELIVERING' ? [{ key: 'routeMap', label: '路线', width: 100, slotName: 'routeMap' }] : []),
])
const preferenceKey = computed(() => `overseas-dispatches-${stage.value}-${section.value}`)
let sequence = 0
async function load() {
const current = ++sequence
loading.value = true; loadError.value = null
try {
const result = await listDispatches({ page: page.value, size: pageSize.value, stage: stage.value, status: String(model.value.status || 'ACTIVE') as DispatchStatus | 'ALL', dispatchNo: String(model.value.dispatchNo || '').trim() || undefined, vehiclePlate: String(model.value.vehiclePlate || '').trim() || undefined,
...(showSections.value ? { beforeLoadingSection: section.value } : {}) })
if (current !== sequence) return
rows.value = result.records; total.value = result.total; statistics.value = result.statistics
} catch (cause) { if (current === sequence) { rows.value = []; total.value = 0; statistics.value = null; loadError.value = getApiErrorDetails(cause, '排车列表加载失败,请重试') } }
finally { if (current === sequence) loading.value = false }
}
function search(value: QueryModel) { model.value = value; page.value = 1; void load() }
function switchStage(value: DispatchStage) { stage.value = value; section.value = 'VEHICLE_INFO'; page.value = 1; void load() }
function switchSection(value: BeforeLoadingSection) { section.value = value; page.value = 1; void load() }
function openDetail(row: DispatchListRow) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } }) }
const rowActionOrder: DispatchAction[] = ['EDIT', 'SUBMIT_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW', 'RETURN_TO_REVIEW', 'LOADING_COMPLETE', 'CANCEL']
function rowActions(row: DispatchListRow): RowActionItem[] {
if (showSections.value && section.value !== 'VEHICLE_INFO') {
const review = section.value
return [...(canDispatchAction(row.actions, review, access.can) ? [{ key: review, label: '审核', ariaLabel: dispatchActionLabels[review], priority: 600 }] : []), { key: 'detail', label: '详情', priority: 500 }]
}
if (row.taskType === 'LOCAL_TRUCK') return [{ key: 'detail', label: '运输操作', priority: 500 }]
const available = rowActionOrder.filter(value => canDispatchAction(row.actions, value, access.can))
const primaryAction = available.find(value => value === 'EDIT') ?? available[0]
return [...available.map((value, index) => ({
key: value,
label: dispatchActionLabels[value],
ariaLabel: dispatchActionLabels[value],
priority: value === primaryAction ? 600 : 400 - index * 10,
tone: value === 'CANCEL' ? 'danger' as const : undefined,
})), { key: 'detail', label: '详情', priority: 500 }, ...(row.taskType === 'PICKUP' ? [] : [...(canDispatchAction(row.actions, 'EDIT', access.can) ? [{ key: 'route-order', label: '调整卸货顺序', priority: 190 }] : []), { key: 'bol', label: '下载BOL', priority: 100, loading: bolLoadingNo.value === row.dispatchNo }])]
}
function handleAction(value: string, row: DispatchListRow) {
if (value === 'detail') return openDetail(row)
if (showSections.value && section.value !== 'VEHICLE_INFO' && value !== section.value) return
if (value === 'bol') return void openBol(row)
rejectionError.value = null
if (value === 'route-order') { if (!canDispatchAction(row.actions, 'EDIT', access.can)) return; operation.value = 'ROUTE'; action.value = 'EDIT'; actionNo.value = row.dispatchNo; actionOpen.value = true; return }
operation.value = undefined
if (value === 'ADD_CARGO' || value === 'REMOVE_CARGO') return
const requested = value as DispatchAction
if (!canDispatchAction(row.actions, requested, access.can)) return
if (row.taskType === 'PICKUP' && requested === 'EDIT') { transportEditNo.value = row.dispatchNo; transportEditOpen.value = true; return }
action.value = requested; actionNo.value = row.dispatchNo; actionOpen.value = true
}
async function handleRejection(details: ApiErrorDetails) { rejectionError.value = details; await load() }
async function handleSaved() { rejectionError.value = null; await load() }
function recoverPending() {
const command = recovery.pending.value
if (!command) return
if (['CREATE', 'ADD_CARGO', 'REMOVE_CARGO'].includes(command.action)) { createOpen.value = true; return }
operation.value = command.operation; action.value = command.action; actionNo.value = command.dispatchNo; actionOpen.value = true
}
async function openBol(row: DispatchListRow) {
if (bolLoadingNo.value) return
const windowHandle = window.open('', '_blank')
if (!windowHandle) { ElMessage.warning('请允许本站打开BOL预览窗口'); return }
bolLoadingNo.value = row.dispatchNo
try { const pdf = await getDispatchBolPdf(row.dispatchNo); const url = URL.createObjectURL(pdf); windowHandle.location.href = url; window.setTimeout(() => URL.revokeObjectURL(url), 60_000) }
catch (cause) { windowHandle.close(); mutationError.value = getApiErrorDetails(cause, 'BOL读取失败') }
finally { bolLoadingNo.value = '' }
}
async function openRoute(row: DispatchListRow) {
try { const data = await getDispatch(row.dispatchNo); if (data.dispatchNo !== row.dispatchNo) throw new Error('路线排车号不一致'); routeOrigin.value = { kind: 'warehouse', label: row.warehouseName, warehouseId: row.warehouseId }; routeDestinations.value = [...new Set(data.orderDetails.map(item => item.destinationFbaWarehouse).filter((code): code is string => Boolean(code)))].map(code => ({ kind: 'warehouse', label: code, warehouseCode: code })); routeOpen.value = true }
catch (cause) { mutationError.value = getApiErrorDetails(cause, '运输路线读取失败') }
}
onMounted(load)
onBeforeUnmount(() => { sequence++ })
</script>
<template>
<AdminPage class="admin-density admin-workbench"><ListPageLayout aria-label="海外仓排车列表">
<OperationErrorToast :details="mutationError" />
<LoadErrorState v-if="rejectionError" title="排车操作未完成" :message="rejectionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="rejectionError" /></template></LoadErrorState>
<p v-if="recovery.pending.value" role="status">{{ recovery.pending.value.dispatchNo || '新排车单' }} 的{{ dispatchActionLabels[recovery.pending.value.action] }}{{ recovery.pending.value.result ? '操作已完成,页面资料待刷新。' : '结果尚待核对。' }}<el-button link :disabled="recovery.busy.value" @click="recoverPending">{{ recovery.pending.value.result ? '刷新页面资料' : '核对原请求' }}</el-button></p>
<div class="workbench-table is-page-surface"><DataTable v-model:page="page" v-model:page-size="pageSize" appearance="integrated" :data="rows" :columns="columns" :loading="loading" :total="total" :preference-key="preferenceKey" row-key="dispatchNo" empty-text="暂无排车记录" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><el-button v-if="appointmentReturnTarget(route.query)" link @click="router.push(appointmentReturnTarget(route.query)!)">返回预约工作</el-button><div class="density-status-tabs workbench-list-tabs" role="tablist" aria-label="排车执行阶段"><button v-for="item in dispatchStages" :key="item.value" role="tab" :aria-selected="stage === item.value" :class="{ 'is-active': stage === item.value }" @click="switchStage(item.value)">{{ item.label }} <span>{{ statistics?.stageCounts[item.value] ?? '—' }}</span></button></div></template>
<template #toolbar-end><el-button v-if="access.can(dispatchActionPermissions.CREATE)" @click="createTransport('PICKUP')">安排到仓取货</el-button><el-button v-if="access.can(dispatchActionPermissions.CREATE)" @click="createTransport('LOCAL_TRUCK')">安排本土卡车</el-button><el-button v-if="access.can(dispatchActionPermissions.CREATE) || access.can(dispatchActionPermissions.ADD_CARGO)" type="primary" @click="createOpen = true">智能排车</el-button></template>
<template #filters><div class="integrated-query-group"><div v-if="showSections" class="density-status-tabs" role="tablist" aria-label="装车前审核分区"><button v-for="item in sections" :key="item.value" role="tab" :aria-selected="section === item.value" :class="{ 'is-active': section === item.value }" @click="switchSection(item.value)">{{ item.label }}</button></div><QueryToolbar v-model="model" :fields="fields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" :search-on-reset="false" @search="search" @reset="search" /></div></template>
<template v-if="loadError" #state><LoadErrorState title="排车列表加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<template #actions="{ row }"><RowActions text-actions :actions="rowActions(row as DispatchListRow)" :max-visible="2" :more-label="`更多操作 · ${row.dispatchNo}`" @action="handleAction($event, row as DispatchListRow)" /><p v-if="!row.actions">操作资格未返回,请刷新核对</p></template>
<template #taskType="{ row }">{{ transportLabels[(row.taskType || 'DELIVERY') as keyof typeof transportLabels] }}</template>
<template #vehicle="{ row }">{{ row.resources.vehicle?.plate || '—' }}</template><template #driver="{ row }">{{ row.resources.driver?.name || '—' }}<br>{{ row.resources.driver?.phone || '—' }}</template>
<template #review="{ row }"><template v-for="review in [section === 'DISPATCH_REVIEW' ? row.reviews.dispatchReview : row.reviews.loadingReview]" :key="review.reviewRound"><div>{{ reviewLabels[review.status as ReviewStatus] }}</div><div v-if="review.reason">{{ review.reason }}</div><div v-if="review.invalidatedReason">{{ review.invalidatedReason }}</div><div>{{ time(review.reviewedAt) }}</div></template></template>
<template #returnReason="{ row }">{{ dispatchRejectionReason(row.reviews) || '—' }}</template>
<template #counts="{ row }">{{ row.counts.palletCount }}托/{{ row.counts.packageCount }}箱/已拣{{ row.counts.pickedPackageCount }}箱</template><template #planned="{ row }">{{ time(row.plannedDepartureAt) }}</template><template #departure="{ row }">{{ time(row.departedAt) }}</template><template #stage="{ row }">{{ row.status === 'CANCELLED' ? '已取消' : stageLabel(row.stage) }}</template><template #routeMap="{ row }"><el-button link @click="openRoute(row as DispatchListRow)">查看路线</el-button></template>
</DataTable></div>
</ListPageLayout></AdminPage>
<TransportTaskCreateDialog v-model="transportOpen" :type="transportType" @created="load" />
<AppDialog v-model="transportEditOpen" title="维护取货车辆信息" width="1000px"><TransportExecutionPanel v-if="transportEditOpen" :dispatch-no="transportEditNo" :pickup-approved="rows.find(row => row.dispatchNo === transportEditNo)?.reviewState === 'APPROVED'" @saved="load" /></AppDialog>
<SmartDispatchDialog v-model="createOpen" :orders="[]" @created="load" />
<DispatchWorkflowDialog v-model="actionOpen" :dispatch-no="actionNo" :action="action" :operation="operation" @saved="handleSaved" @rejected="handleRejection" />
<TransportRouteDialog v-model="routeOpen" :origin="routeOrigin" :destinations="routeDestinations" />
</template>