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

599 lines
30 KiB
Vue
Raw Normal View History

2026-08-06 13:56:04 +08:00
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
2026-08-11 18:06:35 +08:00
import { ElMessageBox } from 'element-plus'
import { RefreshCw, Save, Check, Play, Camera, Eye, Plus, Pencil, Trash2 } from '@lucide/vue'
import { useRouter } from 'vue-router'
2026-08-06 13:56:04 +08:00
import { useAccessPreviewStore } from '@/core/access/accessPreview'
2026-08-10 15:36:53 +08:00
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'
2026-08-11 18:43:56 +08:00
import { oceanBookingStatusLabel } from '@/modules/ocean-bookings'
import { truckAppointmentStatusLabel } from '@/modules/trucking'
2026-08-10 15:36:53 +08:00
import { sanitizePreloadItem, validatePreloadItem, validatePreloadItems } from '@/modules/unified-orders/workflows/preloadForm'
2026-08-06 13:56:04 +08:00
import {
completeLoading,
completePicking,
downloadEvidenceFile,
2026-08-06 13:56:04 +08:00
getEvidence,
getOutboundDispatch,
2026-08-06 13:56:04 +08:00
getPicking,
getPreload,
smartSplit,
startPicking,
upsertEvidence,
uploadEvidenceFile,
voidEvidence,
2026-08-06 13:56:04 +08:00
type Evidence,
type EvidenceType,
type PickingTask,
type PreloadItem,
type PreloadPlan,
type OutboundDispatchAggregate,
2026-08-06 13:56:04 +08:00
} from '@/modules/unified-orders/workflows/api'
import type { UnifiedOrderMainStatus } from '@/modules/unified-orders/api/unifiedOrders'
const props = withDefaults(defineProps<{
tenantId: string
inboundId: string | number
2026-08-30 17:43:03 +08:00
inboundCode?: string | null
orderId?: string | number | null
2026-08-06 13:56:04 +08:00
mainStatus: UnifiedOrderMainStatus
inboundVersion?: number | null
pickingVersion?: number | null
2026-08-10 09:12:06 +08:00
readOnly?: boolean
2026-08-06 13:56:04 +08:00
}>(), {
inboundVersion: null,
pickingVersion: null,
2026-08-10 09:12:06 +08:00
readOnly: false,
2026-08-06 13:56:04 +08:00
})
const emit = defineEmits<{ updated: [] }>()
const access = useAccessPreviewStore()
const router = useRouter()
2026-08-06 13:56:04 +08:00
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)
2026-08-06 13:56:04 +08:00
const loadingRemark = ref('')
const sealNoDraft = ref('')
2026-08-06 13:56:04 +08:00
const splitRemark = ref('')
const busy = ref('')
const loading = ref(false)
const error = ref('')
2026-08-10 15:36:53 +08:00
const errorDetails = ref<ApiErrorDetails | null>(null)
const preloadLoadError = ref<ApiErrorDetails | null>(null)
2026-08-06 13:56:04 +08:00
const notice = ref('')
2026-08-10 15:36:53 +08:00
const preloadDialogOpen = ref(false)
const preloadDialogMode = ref<'create' | 'edit'>('create')
const editingPreloadIndex = ref<number | null>(null)
const preloadDraft = ref<PreloadItem>(itemTemplate())
const preloadDraftError = ref('')
2026-08-06 13:56:04 +08:00
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: '带锁码照' },
2026-08-06 13:56:04 +08:00
]
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)
2026-08-10 09:12:06 +08:00
const canSplit = computed(() => !props.readOnly && access.can('firstleg:inbound:split'))
2026-08-06 13:56:04 +08:00
const canPreloadView = computed(() => access.can('firstleg:inbound:preload-view'))
const canViewLoadingPlans = computed(() => access.can('platform:firstleg:preload:list'))
2026-08-10 09:12:06 +08:00
const canPicking = computed(() => !props.readOnly && access.can('platform:firstleg:inbound:picking:edit'))
2026-08-06 13:56:04 +08:00
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'))
2026-08-06 13:56:04 +08:00
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()))
))))
2026-08-06 13:56:04 +08:00
2026-08-10 15:36:53 +08:00
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 },
]
2026-08-06 13:56:04 +08:00
function itemTemplate(): PreloadItem {
return { containerCode: '', cargoReference: '', packageCount: null, volumeCbm: 0, remark: '' }
}
function addItem() {
2026-08-10 15:36:53 +08:00
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
2026-08-06 13:56:04 +08:00
}
function removeItem(index: number) {
preloadItems.value.splice(index, 1)
}
2026-08-10 15:36:53 +08:00
function preloadStatusLabel(status?: string | null) {
2026-08-11 18:43:56 +08:00
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: '已取消',
}, '暂无数据')
2026-08-10 15:36:53 +08:00
}
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')
2026-08-06 13:56:04 +08:00
}
function idempotentMessage(action: string) {
return busy.value === action ? '正在提交,请勿重复点击' : ''
}
function gateItemName(code: string, fallback: string) {
return code === 'LOADING_EVIDENCE_COMPLETE' ? '五类装柜证据与锁码完整' : fallback
}
2026-08-10 15:36:53 +08:00
async function run<T>(action: string, operation: () => Promise<T>, message: string, restoreAfterConflict?: () => void) {
2026-08-06 13:56:04 +08:00
busy.value = action
error.value = ''
2026-08-10 15:36:53 +08:00
errorDetails.value = null
2026-08-06 13:56:04 +08:00
notice.value = ''
try {
const result = await operation()
notice.value = message
emit('updated')
return result
} catch (cause) {
2026-08-10 15:36:53 +08:00
const details = getApiErrorDetails(cause, '操作失败,请重试')
error.value = details.message
errorDetails.value = details
if (details.code === 'A0409' || /版本|并发|已被其他人修改/.test(details.message)) {
await refresh()
restoreAfterConflict?.()
notice.value = '系统已刷新最新数据,请核对后重新提交'
}
2026-08-06 13:56:04 +08:00
return null
} finally {
busy.value = ''
}
}
async function loadPreload() {
if (!canPreloadView.value || !props.inboundId) return
loading.value = true
error.value = ''
2026-08-10 15:36:53 +08:00
errorDetails.value = null
preloadLoadError.value = null
2026-08-06 13:56:04 +08:00
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')
2026-08-10 15:36:53 +08:00
if (!missing) {
preloadLoadError.value = getApiErrorDetails(cause, '预装清单加载失败,请重试')
}
2026-08-06 13:56:04 +08:00
} finally {
loading.value = false
}
}
async function loadLoading() {
loading.value = true
error.value = ''
2026-08-10 15:36:53 +08:00
errorDetails.value = null
2026-08-06 13:56:04 +08:00
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
2026-08-06 13:56:04 +08:00
}
}
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
2026-08-06 13:56:04 +08:00
}
}
2026-08-10 15:36:53 +08:00
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
}
2026-08-06 13:56:04 +08:00
}
async function refresh() {
2026-08-10 15:36:53 +08:00
await loadDispatch()
2026-08-06 13:56:04 +08:00
if (isSplit.value) await loadPreload()
if (isLoading.value || isLoaded.value) await loadLoading()
2026-08-06 13:56:04 +08:00
}
async function runSmartSplit() {
2026-08-10 15:36:53 +08:00
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
2026-08-06 13:56:04 +08:00
const result = await run('smart-split', () => smartSplit(props.tenantId, props.inboundId, {
expectedVersion: preloadVersion.value,
2026-08-10 15:36:53 +08:00
items: submittedItems,
remark: submittedRemark.trim() || undefined,
}), '分柜清单已保存', () => {
preloadItems.value = submittedItems.map((item) => ({ ...item }))
splitRemark.value = submittedRemark
})
2026-08-06 13:56:04 +08:00
if (result) {
preload.value = result.preloadPlan
preloadItems.value = result.preloadPlan?.items?.map((item) => ({ ...item })) || []
}
}
function openPreloadPlans() {
void router.push({ name: 'loading-plans' })
2026-08-06 13:56:04 +08:00
}
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
2026-08-11 18:06:35 +08:00
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 = ''
}
}
2026-08-06 13:56:04 +08:00
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="订单后半程作业">
2026-08-10 16:24:54 +08:00
<div class="workflow-heading" aria-label="订单作业上下文">
2026-08-30 17:43:03 +08:00
<div class="workflow-context"><span>入库作业</span><strong>{{ props.inboundCode || '入仓码待补充' }}</strong></div>
2026-08-10 16:24:54 +08:00
<el-tooltip content="刷新作业数据" placement="top"><el-button text :loading="loading" aria-label="刷新作业数据" @click="refresh"><RefreshCw :size="15" /></el-button></el-tooltip>
2026-08-06 13:56:04 +08:00
</div>
<el-alert v-if="error" class="workflow-alert" type="error" :closable="false" show-icon>
<template #title>{{ error }}</template>
2026-08-10 15:36:53 +08:00
<ApiErrorMeta v-if="errorDetails" :details="errorDetails" />
2026-08-06 13:56:04 +08:00
<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>
2026-08-06 13:56:04 +08:00
<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">
2026-08-11 18:43:56 +08:00
<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>
2026-08-06 13:56:04 +08:00
<template v-if="isSplit">
2026-08-10 16:24:54 +08:00
<el-card shadow="never" class="workflow-card surface-panel table-panel">
2026-08-10 15:36:53 +08:00
<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 }">
2026-08-12 17:45:49 +08:00
<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>
2026-08-10 15:36:53 +08:00
</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>
2026-08-06 13:56:04 +08:00
<div class="workflow-actions">
2026-08-10 15:36:53 +08:00
<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>
2026-08-06 13:56:04 +08:00
</div>
</el-card>
2026-08-10 15:36:53 +08:00
<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>
2026-08-06 13:56:04 +08:00
</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>
2026-08-11 18:43:56 +08:00
<el-descriptions-item label="拣货状态">{{ pickingStatusLabel(picking.status) }}</el-descriptions-item>
2026-08-06 13:56:04 +08:00
<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>
2026-08-10 09:12:06 +08:00
<el-input v-model="loadingRemark" :disabled="props.readOnly" class="workflow-remark" type="textarea" :rows="2" aria-label="装柜备注" placeholder="装柜备注(可选)" />
2026-08-06 13:56:04 +08:00
</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>
2026-08-06 13:56:04 +08:00
<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">
2026-08-30 17:43:03 +08:00
<span>{{ item.fileName || '证据文件名待补充' }}<template v-if="item.sealNo"> · 锁码 {{ item.sealNo }}</template></span>
<div class="evidence-file__actions">
2026-08-12 17:45:49 +08:00
<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>
2026-08-06 13:56:04 +08:00
</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>
2026-08-06 13:56:04 +08:00
<span v-if="inboundExpectedVersion === null" class="workflow-hint">缺少入库版本,刷新订单详情后可提交</span>
</div>
</el-card>
2026-08-06 13:56:04 +08:00
<el-alert v-if="isLoaded" class="workflow-alert" type="success" :closable="false" show-icon>
已装柜完成。国内端不提供在线冲正、回退或推进派送完成,后续状态仅接受海外/尾程可信回传。
</el-alert>
2026-08-10 15:36:53 +08:00
<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 : ''">
2026-08-14 19:59:32 +08:00
<el-input v-model="preloadDraft.cargoReference" maxlength="128" placeholder="入仓码或业务引用" @input="preloadDraftError = ''" />
2026-08-10 15:36:53 +08:00
</el-form-item>
<div class="preload-dialog-grid">
2026-09-03 19:29:38 +08:00
<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>
2026-08-10 15:36:53 +08:00
</div>
2026-09-03 19:29:38 +08:00
<el-form-item label="明细备注"><el-input v-model="preloadDraft.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入明细备注(选填)" /></el-form-item>
2026-08-10 15:36:53 +08:00
<el-alert v-if="preloadDraftError" type="error" :closable="false" show-icon>{{ preloadDraftError }}</el-alert>
</el-form>
</AppDialog>
2026-08-06 13:56:04 +08:00
</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; }
2026-08-10 16:24:54 +08:00
.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; }
2026-08-06 13:56:04 +08:00
.workflow-card { border-radius: 12px; }
.card-title { justify-content: space-between; }
2026-08-10 15:36:53 +08:00
.preload-dialog-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
2026-08-06 13:56:04 +08:00
.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; }
2026-08-06 13:56:04 +08:00
.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; }
2026-08-06 13:56:04 +08:00
.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; }
2026-08-06 13:56:04 +08:00
.workflow-alert { margin-bottom: 0; }
.workflow-hint { color: var(--cc-text-secondary, #64748b); font-size: 12px; }
@media (max-width: 640px) {
2026-08-10 15:36:53 +08:00
.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; }
2026-08-06 13:56:04 +08:00
.workflow-actions > .el-button { flex: 1 1 150px; }
}
</style>