优化
This commit is contained in:
@@ -1,8 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { RefreshCw, Save, Check, Play, Camera } from '@lucide/vue'
|
||||
import { RefreshCw, Save, Check, Play, Camera, Plus, Pencil, Trash2 } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
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 { sanitizePreloadItem, validatePreloadItem, validatePreloadItems } from '@/modules/unified-orders/workflows/preloadForm'
|
||||
import {
|
||||
completeLoading,
|
||||
completePicking,
|
||||
@@ -52,7 +58,14 @@ 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: '空仓照' },
|
||||
@@ -78,18 +91,62 @@ const canEvidence = computed(() => !props.readOnly && access.can('platform:first
|
||||
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 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() {
|
||||
preloadItems.value.push(itemTemplate())
|
||||
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 ({ PENDING: '待提交', CONFIRMING: '确认中', CONFIRMED: '已确认' } as Record<string, string>)[status || ''] || '尚未生成'
|
||||
}
|
||||
|
||||
function formatVolume(value: number | null) {
|
||||
return value === null || !Number.isFinite(Number(value)) ? '—' : Number(value).toFixed(3)
|
||||
}
|
||||
|
||||
function evidence(type: EvidenceType) {
|
||||
return evidences.value.find((item) => item.evidenceType === type && item.status === 'ACTIVE')
|
||||
}
|
||||
@@ -102,9 +159,10 @@ function gateItemName(code: string, fallback: string) {
|
||||
return code === 'LOADING_EVIDENCE_COMPLETE' ? '五类装柜证据与锁码完整' : fallback
|
||||
}
|
||||
|
||||
async function run<T>(action: string, operation: () => Promise<T>, message: string) {
|
||||
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()
|
||||
@@ -112,7 +170,14 @@ async function run<T>(action: string, operation: () => Promise<T>, message: stri
|
||||
emit('updated')
|
||||
return result
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : '操作失败,请重试'
|
||||
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 = ''
|
||||
@@ -123,6 +188,8 @@ 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 })) || []
|
||||
@@ -131,7 +198,9 @@ async function loadPreload() {
|
||||
preloadItems.value = []
|
||||
const missing = cause instanceof ApiResponseError
|
||||
&& (cause.status === 404 || cause.code === 'A0404' || cause.code === 'NOT_FOUND')
|
||||
error.value = missing ? '' : cause instanceof Error ? cause.message : '预装清单加载失败,请重试'
|
||||
if (!missing) {
|
||||
preloadLoadError.value = getApiErrorDetails(cause, '预装清单加载失败,请重试')
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -140,6 +209,7 @@ async function loadPreload() {
|
||||
async function loadLoading() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
errorDetails.value = null
|
||||
const messages: string[] = []
|
||||
if (canPickingView.value) {
|
||||
try {
|
||||
@@ -157,26 +227,41 @@ async function loadLoading() {
|
||||
messages.push(cause instanceof Error ? cause.message : '装柜证据加载失败')
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
error.value = messages.join(';')
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
await loadDispatch()
|
||||
if (isSplit.value) await loadPreload()
|
||||
if (isLoading.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: preloadItems.value,
|
||||
remark: splitRemark.value || undefined,
|
||||
}), '智能分柜已提交')
|
||||
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 })) || []
|
||||
@@ -317,6 +402,7 @@ onMounted(() => { void refresh() })
|
||||
|
||||
<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>
|
||||
@@ -336,25 +422,41 @@ onMounted(() => { void refresh() })
|
||||
|
||||
<template v-if="isSplit">
|
||||
<el-card shadow="never" class="workflow-card">
|
||||
<template #header><div class="card-title"><span>预装清单</span><el-tag>{{ preload?.status || '待确认' }}</el-tag></div></template>
|
||||
<el-empty v-if="!preload && !loading" description="暂无预装清单,可直接生成分柜方案" />
|
||||
<div class="preload-items" role="group" aria-label="预装清单明细">
|
||||
<div v-for="(item, index) in preloadItems" :key="index" class="preload-row">
|
||||
<el-input v-model="item.containerCode" :disabled="props.readOnly" aria-label="柜号" placeholder="柜号" />
|
||||
<el-input v-model="item.cargoReference" :disabled="props.readOnly" aria-label="货物引用" placeholder="货物引用" />
|
||||
<el-input-number v-model="item.packageCount" :disabled="props.readOnly" aria-label="件数" :min="1" controls-position="right" />
|
||||
<el-input-number v-model="item.volumeCbm" :disabled="props.readOnly" aria-label="体积" :min="0" :precision="3" controls-position="right" />
|
||||
<el-button v-if="!props.readOnly" text type="danger" aria-label="删除预装项" @click="removeItem(index)">删除</el-button>
|
||||
</div>
|
||||
<el-button v-if="!props.readOnly" plain @click="addItem">新增预装项</el-button>
|
||||
<el-input v-model="splitRemark" :disabled="props.readOnly" class="workflow-remark" type="textarea" :rows="2" aria-label="分柜备注" placeholder="分柜备注(可选)" />
|
||||
</div>
|
||||
<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 link type="primary" :disabled="!canSplit || Boolean(busy)" :aria-label="`编辑第 ${Number($index) + 1} 条预装明细`" @click="editItem(Number($index))"><Pencil :size="14" />编辑</el-button>
|
||||
<el-button link 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-start><div><strong>明细</strong><p>入库级清单由人工维护;柜次候选和智能建议在下方柜次工作台加载。</p></div></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)" :loading="busy === 'smart-split'" @click="runSmartSplit"><Save :size="15" />智能分柜/保存调整</el-button>
|
||||
<el-button :disabled="!canPreload || !preload || Boolean(busy)" :loading="busy === 'preload-confirming'" @click="startPreloadConfirmation"><Play :size="15" />提交确认</el-button>
|
||||
<el-button :disabled="!canSplit || Boolean(busy) || Boolean(preloadLoadError)" :loading="busy === 'smart-split'" @click="runSmartSplit"><Save :size="15" />保存分柜清单</el-button>
|
||||
<el-button :disabled="!canPreload || !preload || preload.status !== 'PENDING' || Boolean(busy)" :loading="busy === 'preload-confirming'" @click="startPreloadConfirmation"><Play :size="15" />提交确认</el-button>
|
||||
<el-button type="primary" :disabled="!canPreload || !preload || preload?.status !== 'CONFIRMING' || Boolean(busy)" :loading="busy === 'preload-confirm'" @click="finishPreloadConfirmation"><Check :size="15" />确认预装清单</el-button>
|
||||
</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">
|
||||
@@ -401,6 +503,29 @@ onMounted(() => { void refresh() })
|
||||
<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 = ''" /></el-form-item>
|
||||
<el-form-item label="体积(m³)"><el-input-number v-model="preloadDraft.volumeCbm" :min="0" :precision="3" controls-position="right" @change="preloadDraftError = ''" /></el-form-item>
|
||||
</div>
|
||||
<el-form-item label="明细备注"><el-input v-model="preloadDraft.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item>
|
||||
<el-alert v-if="preloadDraftError" type="error" :closable="false" show-icon>{{ preloadDraftError }}</el-alert>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -412,8 +537,7 @@ onMounted(() => { void refresh() })
|
||||
.workflow-heading p { margin: 4px 0 0; color: var(--cc-text-secondary, #64748b); font-size: 13px; }
|
||||
.workflow-card { border-radius: 12px; }
|
||||
.card-title { justify-content: space-between; }
|
||||
.preload-items { display: grid; gap: 10px; }
|
||||
.preload-row { display: grid; grid-template-columns: 1fr 1fr 120px 120px auto; gap: 8px; align-items: center; }
|
||||
.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; }
|
||||
.evidence-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||
@@ -430,7 +554,8 @@ onMounted(() => { void refresh() })
|
||||
.workflow-alert { margin-bottom: 0; }
|
||||
.workflow-hint { color: var(--cc-text-secondary, #64748b); font-size: 12px; }
|
||||
@media (max-width: 640px) {
|
||||
.preload-row, .evidence-grid { grid-template-columns: 1fr; }
|
||||
.preload-dialog-grid, .evidence-grid { grid-template-columns: 1fr; }
|
||||
.preload-dialog-grid { gap: 0; }
|
||||
.evidence-item, .gate-grid { grid-template-columns: 1fr; }
|
||||
.workflow-actions > .el-button { flex: 1 1 150px; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user