162 lines
17 KiB
Vue
162 lines
17 KiB
Vue
<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>
|