Files
admin-web/src/modules/unified-orders/workflows/components/UnifiedOrderWorkflowPanel.vue
T

599 lines
30 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, onMounted, ref, watch } from 'vue'
import { ElMessageBox } from 'element-plus'
import { RefreshCw, Save, Check, Play, Camera, Eye, Plus, Pencil, Trash2 } from '@lucide/vue'
import { useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import SmartLoadingWorkbench from '@/modules/unified-orders/workflows/components/SmartLoadingWorkbench.vue'
import { oceanBookingStatusLabel } from '@/modules/ocean-bookings'
import { truckAppointmentStatusLabel } from '@/modules/trucking'
import { sanitizePreloadItem, validatePreloadItem, validatePreloadItems } from '@/modules/unified-orders/workflows/preloadForm'
import {
completeLoading,
completePicking,
downloadEvidenceFile,
getEvidence,
getOutboundDispatch,
getPicking,
getPreload,
smartSplit,
startPicking,
upsertEvidence,
uploadEvidenceFile,
voidEvidence,
type Evidence,
type EvidenceType,
type PickingTask,
type PreloadItem,
type PreloadPlan,
type OutboundDispatchAggregate,
} from '@/modules/unified-orders/workflows/api'
import type { UnifiedOrderMainStatus } from '@/modules/unified-orders/api/unifiedOrders'
const props = withDefaults(defineProps<{
tenantId: string
inboundId: string | number
inboundCode?: string | null
orderId?: string | number | null
mainStatus: UnifiedOrderMainStatus
inboundVersion?: number | null
pickingVersion?: number | null
readOnly?: boolean
}>(), {
inboundVersion: null,
pickingVersion: null,
readOnly: false,
})
const emit = defineEmits<{ updated: [] }>()
const access = useAccessPreviewStore()
const router = useRouter()
const preload = ref<PreloadPlan | null>(null)
const preloadItems = ref<PreloadItem[]>([])
const picking = ref<PickingTask | null>(null)
const evidences = ref<Evidence[]>([])
const dispatch = ref<OutboundDispatchAggregate | null>(null)
const loadingRemark = ref('')
const sealNoDraft = ref('')
const splitRemark = ref('')
const busy = ref('')
const loading = ref(false)
const error = ref('')
const errorDetails = ref<ApiErrorDetails | null>(null)
const preloadLoadError = ref<ApiErrorDetails | null>(null)
const notice = ref('')
const preloadDialogOpen = ref(false)
const preloadDialogMode = ref<'create' | 'edit'>('create')
const editingPreloadIndex = ref<number | null>(null)
const preloadDraft = ref<PreloadItem>(itemTemplate())
const preloadDraftError = ref('')
const evidenceTypes: Array<{ type: EvidenceType; label: string }> = [
{ type: 'PHOTO_STEP_1', label: '空仓照' },
{ type: 'PHOTO_STEP_2', label: '半仓照' },
{ type: 'PHOTO_STEP_3', label: '满仓照' },
{ type: 'PHOTO_STEP_4', label: '锁仓照' },
{ type: 'SEAL', label: '带锁码照' },
]
const isSplit = computed(() => props.mainStatus === 'WAIT_SPLIT')
const isLoading = computed(() => props.mainStatus === 'LOADING')
const isLoaded = computed(() => props.mainStatus === 'LOADED')
const preloadVersion = computed(() => preload.value?.version ?? 0)
const pickingExpectedVersion = computed(() => picking.value?.version ?? props.pickingVersion ?? null)
const inboundExpectedVersion = computed(() => props.inboundVersion)
const canSplit = computed(() => !props.readOnly && access.can('firstleg:inbound:split'))
const canPreloadView = computed(() => access.can('firstleg:inbound:preload-view'))
const canViewLoadingPlans = computed(() => access.can('platform:firstleg:preload:list'))
const canPicking = computed(() => !props.readOnly && access.can('platform:firstleg:inbound:picking:edit'))
const canPickingView = computed(() => access.can('platform:firstleg:inbound:picking:view'))
// 操作员、理货员由 IAM 授予 loading:edit;拣货员仅能在 PDA 现场采集,不授予该权限。
const canEvidence = computed(() => !props.readOnly && isLoading.value && access.can('platform:firstleg:inbound:loading:edit'))
const canEvidenceView = computed(() => access.can('platform:firstleg:inbound:loading:view'))
const canLoadingComplete = computed(() => !props.readOnly && !isLoaded.value && access.can('platform:firstleg:inbound:loading:complete'))
const evidenceReady = computed(() => evidenceTypes.every(({ type }) => activeEvidences(type).some((item) => (
Boolean(item.fileId) && (type !== 'SEAL' || Boolean(item.sealNo?.trim()))
))))
const preloadColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 132, fixed: 'left', slotName: 'actions' },
{ key: 'containerCode', prop: 'containerCode', label: '柜号', minWidth: 150 },
{ key: 'cargoReference', prop: 'cargoReference', label: '货物引用', minWidth: 180 },
{ key: 'packageCount', prop: 'packageCount', label: '箱数', width: 90 },
{ key: 'volumeCbm', label: '体积(m³)', width: 110, slotName: 'volume' },
{ key: 'remark', prop: 'remark', label: '备注', minWidth: 180, showOverflowTooltip: true },
]
function itemTemplate(): PreloadItem {
return { containerCode: '', cargoReference: '', packageCount: null, volumeCbm: 0, remark: '' }
}
function addItem() {
preloadDialogMode.value = 'create'
editingPreloadIndex.value = null
preloadDraft.value = itemTemplate()
preloadDraftError.value = ''
preloadDialogOpen.value = true
}
function editItem(index: number) {
preloadDialogMode.value = 'edit'
editingPreloadIndex.value = index
preloadDraft.value = { ...preloadItems.value[index] }
preloadDraftError.value = ''
preloadDialogOpen.value = true
}
function saveItem() {
const validation = validatePreloadItem(preloadDraft.value)
if (!validation.valid) {
preloadDraftError.value = validation.message
return
}
const next = sanitizePreloadItem(preloadDraft.value)
if (preloadDialogMode.value === 'edit' && editingPreloadIndex.value !== null) {
preloadItems.value.splice(editingPreloadIndex.value, 1, next)
} else {
preloadItems.value.push(next)
}
preloadDialogOpen.value = false
}
function removeItem(index: number) {
preloadItems.value.splice(index, 1)
}
function preloadStatusLabel(status?: string | null) {
return workflowStatusLabel(status, { PENDING: '待提交', CONFIRMING: '确认中', CONFIRMED: '已确认' }, '尚未生成')
}
function workflowStatusLabel(value: string | null | undefined, labels: Record<string, string>, emptyLabel: string) {
if (!value) return emptyLabel
return labels[value] || '未知状态'
}
function pickingStatusLabel(status?: string | null) {
return workflowStatusLabel(status, {
PENDING: '待拣货', PICKING: '拣货中', IN_PROGRESS: '拣货中', AWAITING_REVIEW: '待复核',
COMPLETED: '已完成', CANCELLED: '已取消',
}, '暂无数据')
}
function formatVolume(value: number | null) {
return value === null || !Number.isFinite(Number(value)) ? '—' : Number(value).toFixed(3)
}
function activeEvidences(type: EvidenceType) {
return evidences.value.filter((item) => item.evidenceType === type && item.status === 'ACTIVE')
}
function idempotentMessage(action: string) {
return busy.value === action ? '正在提交,请勿重复点击' : ''
}
function gateItemName(code: string, fallback: string) {
return code === 'LOADING_EVIDENCE_COMPLETE' ? '五类装柜证据与锁码完整' : fallback
}
async function run<T>(action: string, operation: () => Promise<T>, message: string, restoreAfterConflict?: () => void) {
busy.value = action
error.value = ''
errorDetails.value = null
notice.value = ''
try {
const result = await operation()
notice.value = message
emit('updated')
return result
} catch (cause) {
const details = getApiErrorDetails(cause, '操作失败,请重试')
error.value = details.message
errorDetails.value = details
if (details.code === 'A0409' || /版本|并发|已被其他人修改/.test(details.message)) {
await refresh()
restoreAfterConflict?.()
notice.value = '系统已刷新最新数据,请核对后重新提交'
}
return null
} finally {
busy.value = ''
}
}
async function loadPreload() {
if (!canPreloadView.value || !props.inboundId) return
loading.value = true
error.value = ''
errorDetails.value = null
preloadLoadError.value = null
try {
preload.value = await getPreload(props.tenantId, props.inboundId)
preloadItems.value = preload.value.items?.map((item) => ({ ...item })) || []
} catch (cause) {
preload.value = null
preloadItems.value = []
const missing = cause instanceof ApiResponseError
&& (cause.status === 404 || cause.code === 'A0404' || cause.code === 'NOT_FOUND')
if (!missing) {
preloadLoadError.value = getApiErrorDetails(cause, '预装清单加载失败,请重试')
}
} finally {
loading.value = false
}
}
async function loadLoading() {
loading.value = true
error.value = ''
errorDetails.value = null
const messages: string[] = []
if (canPickingView.value) {
try {
picking.value = await getPicking(props.tenantId, props.inboundId)
} catch (cause) {
picking.value = null
const details = getApiErrorDetails(cause, '拣货任务加载失败')
messages.push(details.message)
errorDetails.value ||= details
}
}
if (canEvidenceView.value) {
try {
evidences.value = (await getEvidence(props.tenantId, props.inboundId)).evidences || []
} catch (cause) {
evidences.value = []
const details = getApiErrorDetails(cause, '装柜证据加载失败')
messages.push(details.message)
errorDetails.value ||= details
}
}
error.value = messages.join(';')
loading.value = false
}
async function loadDispatch() {
try {
dispatch.value = await getOutboundDispatch(props.tenantId, props.orderId || undefined, props.inboundId)
} catch {
dispatch.value = null
}
}
async function refresh() {
await loadDispatch()
if (isSplit.value) await loadPreload()
if (isLoading.value || isLoaded.value) await loadLoading()
}
async function runSmartSplit() {
const validation = validatePreloadItems(preloadItems.value)
if (!validation.valid) {
error.value = validation.message
errorDetails.value = null
return
}
const submittedItems = preloadItems.value.map((item) => sanitizePreloadItem(item))
const submittedRemark = splitRemark.value
const result = await run('smart-split', () => smartSplit(props.tenantId, props.inboundId, {
expectedVersion: preloadVersion.value,
items: submittedItems,
remark: submittedRemark.trim() || undefined,
}), '分柜清单已保存', () => {
preloadItems.value = submittedItems.map((item) => ({ ...item }))
splitRemark.value = submittedRemark
})
if (result) {
preload.value = result.preloadPlan
preloadItems.value = result.preloadPlan?.items?.map((item) => ({ ...item })) || []
}
}
function openPreloadPlans() {
void router.push({ name: 'loading-plans' })
}
function pickingRequest(): { expectedVersion: number; expectedCount: number; pickedCount: number; remark?: string } | null {
if (pickingExpectedVersion.value === null) {
error.value = '拣货版本缺失,请刷新订单详情后重试'
return null
}
return {
expectedVersion: pickingExpectedVersion.value,
expectedCount: picking.value?.expectedCount ?? 0,
pickedCount: picking.value?.pickedCount ?? 0,
remark: loadingRemark.value || undefined,
}
}
async function startPickingTask() {
const request = pickingRequest()
if (!request) return
const result = await run('picking-start', () => startPicking(props.tenantId, props.inboundId, request), '拣货任务已开始')
if (result) picking.value = result
}
async function completePickingTask() {
const request = pickingRequest()
if (!request) return
const result = await run('picking-complete', () => completePicking(props.tenantId, props.inboundId, request), '拣货已完成')
if (result) picking.value = result
}
async function uploadAndSaveEvidence(type: EvidenceType, event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file || !canEvidence.value) return
const sealNo = sealNoDraft.value.trim()
if (type === 'SEAL' && !sealNo) {
error.value = '上传带锁码照前必须填写锁码'
errorDetails.value = null
return
}
const uploaded = await run(`evidence-upload-${type}`, () => uploadEvidenceFile(props.tenantId, props.inboundId, type, file), '文件已上传,正在登记证据')
if (!uploaded) return
const result = await run(`evidence-${type}`, () => upsertEvidence(props.tenantId, props.inboundId, {
expectedVersion: 0,
evidenceType: type,
fileId: uploaded.fileId,
sealNo: type === 'SEAL' ? sealNo : undefined,
}), `${type === 'SEAL' ? '带锁码照' : '证据'}已登记`)
if (result?.evidences) {
evidences.value = result.evidences.evidences || []
if (type === 'SEAL') sealNoDraft.value = ''
}
}
async function markEvidenceVoided(item: Evidence) {
if (!canEvidence.value || !item.id || String(item.id).startsWith('draft-')) return
let reason = ''
try {
const result = await ElMessageBox.prompt('作废后照片仍会保留在审计记录中。', '作废证据照片', {
inputType: 'textarea',
inputPlaceholder: '请输入作废原因',
inputValidator: (value) => Boolean(value?.trim()) || '作废原因不能为空',
confirmButtonText: '确认作废',
cancelButtonText: '取消',
type: 'warning',
})
reason = result.value.trim()
} catch (cause) {
if (cause === 'cancel' || cause === 'close') return
throw cause
}
const result = await run(`evidence-void-${item.id}`, () => voidEvidence(props.tenantId, props.inboundId, item.id, {
expectedVersion: item.version,
reason,
}), '证据已作废')
if (result?.evidences) evidences.value = result.evidences.evidences || []
}
async function previewEvidence(item: Evidence) {
if (!canEvidenceView.value || !item.fileId) return
busy.value = `evidence-preview-${item.id}`
error.value = ''
errorDetails.value = null
try {
const blob = await downloadEvidenceFile(props.tenantId, props.inboundId, item.fileId)
const url = URL.createObjectURL(blob)
window.open(url, '_blank', 'noopener,noreferrer')
window.setTimeout(() => URL.revokeObjectURL(url), 60_000)
} catch (cause) {
const details = getApiErrorDetails(cause, '证据照片加载失败,请重试')
error.value = details.message
errorDetails.value = details
} finally {
busy.value = ''
}
}
async function finishLoading() {
const expectedVersion = inboundExpectedVersion.value
if (expectedVersion === null) {
error.value = '入库单版本缺失,刷新订单详情后才能确认装柜完成'
return
}
const result = await run('loading-complete', () => completeLoading(props.tenantId, props.inboundId, {
expectedVersion,
remark: loadingRemark.value || undefined,
}), '装柜完成已提交')
if (result) await refresh()
}
watch(() => props.mainStatus, () => { void refresh() })
onMounted(() => { void refresh() })
</script>
<template>
<section class="workflow-panel" aria-label="订单后半程作业">
<div class="workflow-heading" aria-label="订单作业上下文">
<div class="workflow-context"><span>入库作业</span><strong>{{ props.inboundCode || '入仓码待补充' }}</strong></div>
<el-tooltip content="刷新作业数据" placement="top"><el-button text :loading="loading" aria-label="刷新作业数据" @click="refresh"><RefreshCw :size="15" /></el-button></el-tooltip>
</div>
<el-alert v-if="error" class="workflow-alert" type="error" :closable="false" show-icon>
<template #title>{{ error }}</template>
<ApiErrorMeta v-if="errorDetails" :details="errorDetails" />
<el-button link type="danger" @click="refresh">重试</el-button>
</el-alert>
<el-alert v-if="notice" class="workflow-alert" type="success" :closable="false" show-icon>{{ notice }}</el-alert>
<el-alert v-if="busy" class="workflow-alert" type="info" :closable="false">{{ idempotentMessage(busy) }}</el-alert>
<el-alert v-if="props.readOnly" class="workflow-alert" type="info" :closable="false" show-icon>当前为只读查看,不能修改作业数据。</el-alert>
<el-card v-if="dispatch" shadow="never" class="workflow-card">
<template #header><div class="card-title"><span>子流程与门禁摘要</span><el-tag :type="dispatch.gate?.passed ? 'success' : 'warning'">{{ dispatch.gate?.passed ? '已通过' : '待补齐' }}</el-tag></div></template>
<div class="gate-grid">
<div><span>订舱</span><strong>{{ oceanBookingStatusLabel(dispatch.oceanBookingStatus, '未关联') }}</strong></div>
<div><span>拖车</span><strong>{{ truckAppointmentStatusLabel(dispatch.truckAppointmentStatus, '未关联') }}</strong></div>
<div><span>柜号</span><strong>{{ dispatch.containerNo || '暂无数据' }}</strong></div>
<div v-for="item in dispatch.gate?.items || []" :key="item.code"><span>{{ gateItemName(item.code, item.name) }}</span><el-tag size="small" :type="item.passed ? 'success' : 'danger'">{{ item.passed ? '已满足' : '待补齐' }}</el-tag></div>
</div>
<p class="workflow-hint">此处为订舱、拖车、PDA、报关、证据和船期的只读摘要;装柜完成仍以服务端 LOADED 门禁为准。</p>
</el-card>
<template v-if="isSplit">
<el-card shadow="never" class="workflow-card surface-panel table-panel">
<template #header><div class="card-title"><span>入库预装清单</span><el-tag>{{ preloadStatusLabel(preload?.status) }}</el-tag></div></template>
<el-alert v-if="!canPreloadView" type="warning" :closable="false" show-icon>当前账号无预装清单查询权限。</el-alert>
<LoadErrorState v-else-if="preloadLoadError" title="预装清单暂时无法加载" :message="preloadLoadError.message" :loading="loading" @retry="loadPreload">
<template #details><ApiErrorMeta :details="preloadLoadError" /></template>
</LoadErrorState>
<template v-else>
<DataTable :data="preloadItems" :columns="preloadColumns" :loading="loading" empty-text="暂无预装明细,请先新增">
<template #actions="{ $index }">
<el-button size="small" plain type="primary" :disabled="!canSplit || Boolean(busy)" :aria-label="`编辑第 ${Number($index) + 1} 条预装明细`" @click="editItem(Number($index))"><Pencil :size="14" />编辑</el-button>
<el-button size="small" plain type="danger" :disabled="!canSplit || Boolean(busy)" :aria-label="`删除第 ${Number($index) + 1} 条预装明细`" @click="removeItem(Number($index))"><Trash2 :size="14" />删除</el-button>
</template>
<template #volume="{ row }">{{ formatVolume((row as PreloadItem).volumeCbm) }}</template>
<template #toolbar-actions><el-button :disabled="!canSplit || Boolean(busy)" @click="addItem"><Plus :size="15" />新增明细</el-button></template>
</DataTable>
<el-input v-model="splitRemark" :disabled="!canSplit || Boolean(busy)" class="workflow-remark" type="textarea" :rows="2" maxlength="1000" show-word-limit aria-label="分柜备注" placeholder="分柜备注(可选)" />
</template>
<div class="workflow-actions">
<el-button :disabled="!canSplit || Boolean(busy) || Boolean(preloadLoadError)" :loading="busy === 'smart-split'" @click="runSmartSplit"><Save :size="15" />保存分柜清单</el-button>
</div>
<div class="preload-confirmation-guide">
<div><strong>计划级预装确认</strong><span>当前单票状态:{{ preloadStatusLabel(preload?.status) }}。请前往预装清单,从实际预装计划详情确认整份计划。</span></div>
<el-button v-if="canViewLoadingPlans" type="primary" plain @click="openPreloadPlans"><Eye :size="15" />前往预装清单</el-button>
<span v-else class="workflow-hint">当前账号无预装清单列表权限。</span>
</div>
</el-card>
<el-card v-if="dispatch?.containerBookingId" shadow="never" class="workflow-card">
<SmartLoadingWorkbench
:booking-id="String(dispatch.containerBookingId)"
:highlight-inbound-id="props.inboundId"
:read-only="props.readOnly"
@updated="refresh"
/>
</el-card>
<el-alert v-else type="info" :closable="false" show-icon>
当前订单尚未关联柜次装柜计划;请先在货柜智能排柜工作台选择柜次并分配货物。
</el-alert>
</template>
<template v-if="isLoading">
<el-card shadow="never" class="workflow-card">
<template #header><div class="card-title"><span>拣货与装柜</span><el-tag type="warning">装柜中</el-tag></div></template>
<el-descriptions v-if="picking" :column="2" border>
<el-descriptions-item label="拣货状态">{{ pickingStatusLabel(picking.status) }}</el-descriptions-item>
<el-descriptions-item label="拣货版本">{{ picking.version }}</el-descriptions-item>
<el-descriptions-item label="应拣件数">{{ picking.expectedCount ?? '—' }}</el-descriptions-item>
<el-descriptions-item label="已拣件数">{{ picking.pickedCount ?? '—' }}</el-descriptions-item>
</el-descriptions>
<el-empty v-else-if="!loading" description="暂无拣货任务" />
<div class="workflow-actions">
<el-button :disabled="!canPicking || !picking || Boolean(busy)" :loading="busy === 'picking-start'" @click="startPickingTask"><Play :size="15" />开始拣货</el-button>
<el-button :disabled="!canPicking || !picking || Boolean(busy)" :loading="busy === 'picking-complete'" @click="completePickingTask"><Check :size="15" />完成拣货</el-button>
</div>
<el-input v-model="loadingRemark" :disabled="props.readOnly" class="workflow-remark" type="textarea" :rows="2" aria-label="装柜备注" placeholder="装柜备注(可选)" />
</el-card>
</template>
<el-card v-if="isLoading || isLoaded" v-loading="loading" shadow="never" class="workflow-card">
<template #header><div class="card-title"><span>装柜五类证据与锁码</span><el-tag :type="evidenceReady ? 'success' : 'info'">{{ evidenceReady ? '证据齐全' : '五类必传' }}</el-tag></div></template>
<el-alert v-if="!canEvidenceView" type="warning" :closable="false" show-icon>当前账号无装柜证据查询权限。</el-alert>
<div class="evidence-grid">
<div v-for="entry in evidenceTypes" :key="entry.type" class="evidence-item">
<strong><Camera :size="15" />{{ entry.label }}<el-tag size="small" :type="activeEvidences(entry.type).length ? 'success' : 'danger'">{{ activeEvidences(entry.type).length }} 张</el-tag></strong>
<div class="evidence-control">
<el-input v-if="entry.type === 'SEAL' && canEvidence" v-model="sealNoDraft" maxlength="64" show-word-limit aria-label="下一张照片的锁码" placeholder="先填写锁码,再上传带锁码照" />
<label v-if="canEvidence" class="file-picker"><Camera :size="14" />追加照片<input type="file" accept="image/*" @change="uploadAndSaveEvidence(entry.type, $event)" /></label>
</div>
<div v-if="canEvidenceView" class="evidence-files">
<div v-for="item in activeEvidences(entry.type)" :key="String(item.id)" class="evidence-file">
<span>{{ item.fileName || '证据文件名待补充' }}<template v-if="item.sealNo"> · 锁码 {{ item.sealNo }}</template></span>
<div class="evidence-file__actions">
<el-button size="small" plain type="primary" :disabled="Boolean(busy)" @click="previewEvidence(item)"><Eye :size="14" />查看</el-button>
<el-button v-if="canEvidence" size="small" plain type="danger" :disabled="Boolean(busy)" @click="markEvidenceVoided(item)"><Trash2 :size="14" />作废</el-button>
</div>
</div>
<span v-if="activeEvidences(entry.type).length === 0" class="workflow-hint">尚未上传有效照片</span>
</div>
</div>
</div>
<p class="workflow-hint">五类证据各至少一张有效照片;锁码文本与带锁码照同时必填。错误照片只能作废并保留审计记录。</p>
<div v-if="isLoading" class="workflow-actions">
<el-button type="primary" :disabled="!canLoadingComplete || !evidenceReady || inboundExpectedVersion === null || Boolean(busy)" :loading="busy === 'loading-complete'" @click="finishLoading"><Check :size="15" />完成装柜</el-button>
<span v-if="!evidenceReady" class="workflow-hint">五类有效照片及锁码齐全后可提交;其他门禁仍由服务端校验</span>
<span v-if="inboundExpectedVersion === null" class="workflow-hint">缺少入库版本,刷新订单详情后可提交</span>
</div>
</el-card>
<el-alert v-if="isLoaded" class="workflow-alert" type="success" :closable="false" show-icon>
已装柜完成。国内端不提供在线冲正、回退或推进派送完成,后续状态仅接受海外/尾程可信回传。
</el-alert>
<AppDialog
v-model="preloadDialogOpen"
:title="preloadDialogMode === 'create' ? '新增预装明细' : '编辑预装明细'"
confirm-text="保存明细"
:confirm-disabled="Boolean(busy)"
@confirm="saveItem"
>
<el-form label-position="top" @submit.prevent>
<el-form-item label="柜号" required :error="preloadDraftError === '柜号不能为空' ? preloadDraftError : ''">
<el-input v-model="preloadDraft.containerCode" maxlength="64" placeholder="请输入柜号" @input="preloadDraftError = ''" />
</el-form-item>
<el-form-item label="货物引用" required :error="preloadDraftError === '货物引用不能为空' ? preloadDraftError : ''">
<el-input v-model="preloadDraft.cargoReference" maxlength="128" placeholder="入仓码或业务引用" @input="preloadDraftError = ''" />
</el-form-item>
<div class="preload-dialog-grid">
<el-form-item label="箱数" required><el-input-number v-model="preloadDraft.packageCount" :min="1" :step="1" step-strictly controls-position="right" @change="preloadDraftError = ''" placeholder="请输入计划装柜箱数" /></el-form-item>
<el-form-item label="体积(m³)"><el-input-number v-model="preloadDraft.volumeCbm" :min="0" :precision="3" controls-position="right" @change="preloadDraftError = ''" placeholder="请输入计划体积(选填)" /></el-form-item>
</div>
<el-form-item label="明细备注"><el-input v-model="preloadDraft.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入明细备注(选填)" /></el-form-item>
<el-alert v-if="preloadDraftError" type="error" :closable="false" show-icon>{{ preloadDraftError }}</el-alert>
</el-form>
</AppDialog>
</section>
</template>
<style scoped>
.workflow-panel { display: grid; gap: 14px; }
.workflow-heading, .card-title, .workflow-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.workflow-heading { justify-content: space-between; }
.workflow-context { display: grid; min-width: 0; gap: 3px; }
.workflow-context span { color: var(--cc-text-secondary, #64748b); font-size: 11px; }
.workflow-context strong { overflow: hidden; color: var(--cc-text, #1f2937); font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
.workflow-card { border-radius: 12px; }
.card-title { justify-content: space-between; }
.preload-dialog-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.workflow-actions { margin-top: 14px; }
.workflow-remark { margin-top: 10px; }
.preload-confirmation-guide { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 12px; padding: 12px 14px; border: 1px solid var(--cc-border); border-radius: 6px; background: #f8fafc; }
.preload-confirmation-guide > div { display: grid; min-width: 0; gap: 4px; }
.preload-confirmation-guide strong { color: var(--cc-text, #1f2937); font-size: 13px; }
.preload-confirmation-guide span { color: var(--cc-text-secondary, #64748b); font-size: 12px; line-height: 1.5; }
.preload-confirmation-guide > .el-button { flex: 0 0 auto; }
.evidence-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.evidence-item { display: grid; min-width: 0; gap: 8px; align-content: start; padding: 12px; border: 1px solid var(--cc-border); border-radius: 6px; }
.evidence-item strong { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
.evidence-control { display: flex; min-width: 0; align-items: center; gap: 8px; }
.evidence-files { display: grid; gap: 4px; }
.evidence-file { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 8px; color: var(--cc-text-secondary, #64748b); font-size: 12px; }
.evidence-file span { min-width: 0; overflow-wrap: anywhere; }
.evidence-file__actions { display: flex; flex: 0 0 auto; align-items: center; gap: 4px; }
.gate-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
.gate-grid > div { display: flex; min-height: 34px; align-items: center; justify-content: space-between; gap: 8px; padding: 7px 9px; border: 1px solid var(--cc-border); border-radius: 5px; }
.gate-grid span { color: var(--cc-text-secondary, #64748b); font-size: 12px; }
.gate-grid strong { color: var(--cc-text, #1f2937); font-size: 12px; }
.file-picker { display: inline-flex; align-items: center; gap: 4px; color: var(--cc-primary); font-size: 12px; cursor: pointer; white-space: nowrap; }
.file-picker input { display: none; }
.workflow-alert { margin-bottom: 0; }
.workflow-hint { color: var(--cc-text-secondary, #64748b); font-size: 12px; }
@media (max-width: 640px) {
.preload-dialog-grid, .evidence-grid { grid-template-columns: 1fr; }
.preload-dialog-grid { gap: 0; }
.preload-confirmation-guide { align-items: stretch; flex-direction: column; }
.evidence-item, .gate-grid { grid-template-columns: 1fr; }
.workflow-actions > .el-button { flex: 1 1 150px; }
}
</style>