Files
admin-web/src/modules/loading-lists/pages/PreloadPlanDetailView.vue
T
2026-09-16 17:49:23 +08:00

305 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/adminDensity.css'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, http, unwrapApiResponse, type ApiEnvelope, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
import LoadingListDetailDrawer from '../components/LoadingListDetailDrawer.vue'
import { confirmPreloadPlan, getConfirmedLoadingList, getPreloadPlan, updateLoadingItemRemarks, updatePreloadTransport, type LoadingDetail, type LoadingItem, type PreloadSummary } from '../api/platformLoadingLists'
const route = useRoute()
const router = useRouter()
const access = useAccessPreviewStore()
const workspace = useWorkspaceTabsStore()
const loading = ref(false)
const detail = ref<LoadingDetail<PreloadSummary> | null>(null)
const error = ref<ApiErrorDetails | null>(null)
const editOpen = ref(false)
const editSaving = ref(false)
const editError = ref<ApiErrorDetails | null>(null)
const confirmOpen = ref(false)
const confirmSaving = ref(false)
const confirmError = ref<ApiErrorDetails | null>(null)
const confirmOperationId = ref('')
const editFormRef = ref<FormInstance>()
const editForm = reactive({ containerNo: '', voyageNo: '', operationReason: '' })
const remarkOpen = ref(false)
const remarkSaving = ref(false)
const remarkError = ref<ApiErrorDetails | null>(null)
const remarkTarget = ref<LoadingItem | null>(null)
const remarkForm = reactive({ internalRemark: '', warehouseRemark: '', customerServiceRemark: '' })
const remarkOperationId = ref('')
let loadSequence = 0
const planId = computed(() => String(route.params.planId || ''))
const warehouseId = computed(() => typeof route.query.warehouseId === 'string' ? route.query.warehouseId : '')
type LoadingPlanLifecycle = { planId: string | number; planStatus: string; detailResource: 'PRELOAD' | 'LOADING_LIST' }
async function resolveLoadingPlanLifecycle() {
const response = await http.get<ApiEnvelope<LoadingPlanLifecycle>, ApiEnvelope<LoadingPlanLifecycle>>(
`/platform/v1/first-leg/loading-plans/${encodeURIComponent(planId.value)}/lifecycle`,
{ params: { warehouseId: warehouseId.value } },
)
return unwrapApiResponse(response)
}
const canEdit = computed(() => access.can('platform:firstleg:preload:edit'))
const canCorrectTransport = computed(() => {
const summary = detail.value?.summary
const pdaStatus = summary?.readiness.pdaTaskStatus
if (!canEdit.value || !summary || !['EDITING', 'LOADING'].includes(summary.status)) return false
if (!summary.containerNo) return true
return !pdaStatus || pdaStatus === 'PENDING'
})
const isContainerCorrection = computed(() => Boolean(detail.value?.summary.containerNo))
const transportCorrectionNote = computed(() => {
const summary = detail.value?.summary
if (!summary) return ''
if (!summary.containerNo && summary.readiness.pdaTaskId) return '该历史 PDA 任务缺少真实集装箱号;补录保存后即可进入任务。'
if (summary.readiness.pdaTaskStatus && summary.readiness.pdaTaskStatus !== 'PENDING') return 'PDA 已领取或作业已开始,真实集装箱号只读。'
if (!canCorrectTransport.value) return '当前服务端阶段不允许纠正真实集装箱号。'
if (!summary.containerNo) return '确认预装前必须补录真实集装箱号。'
return '真实集装箱号仅可在 PDA 领取前纠错,纠错保存时必须填写操作原因。'
})
const editDialogTitle = computed(() => isContainerCorrection.value ? '纠正集装箱号 / 航次' : '补录集装箱号 / 航次')
const editRules: FormRules = {
containerNo: [
{ required: true, message: '请输入集装箱号', trigger: 'blur' },
{ max: 64, message: '集装箱号不能超过 64 个字符', trigger: 'blur' },
],
voyageNo: [{ max: 64, message: '航次不能超过 64 个字符', trigger: 'blur' }],
operationReason: [
{ validator: (_rule, value, callback) => callback(isContainerCorrection.value && !String(value || '').trim() ? new Error('纠错时请输入操作原因') : undefined), trigger: 'blur' },
{ max: 500, message: '操作原因不能超过 500 个字符', trigger: 'blur' },
],
}
function normalizedContainerNo(value: string | null | undefined) { return String(value || '').trim().toUpperCase() }
async function loadDetail() {
const sequence = ++loadSequence
detail.value = null
if (!planId.value || !warehouseId.value) {
loading.value = false
error.value = { code: 'MISSING_WAREHOUSE_CONTEXT', message: '该清单缺少所属仓库信息,无法安全查询详情,请返回列表重新进入。' }
return
}
loading.value = true
error.value = null
try {
const lifecycle = await resolveLoadingPlanLifecycle()
if (sequence !== loadSequence) return
if (lifecycle.detailResource === 'LOADING_LIST') {
if (lifecycle.planStatus !== 'COMPLETED') {
error.value = { code: 'MODE_STATUS_MISMATCH', message: '装柜计划阶段异常,暂时无法切换详情,请稍后重试。' }
return
}
const confirmed = await getConfirmedLoadingList(planId.value, warehouseId.value)
await router.replace({ name: 'domestic-container-detail', params: { containerId: confirmed.summary.containerBookingId }, query: { tab: 'loading-list' } })
workspace.removeByIds(['preload-plan-detail'])
if (sequence === loadSequence) ElMessage.success('装柜已完成,已切换至装柜清单详情')
return
}
if (lifecycle.detailResource !== 'PRELOAD') {
error.value = { code: 'MODE_STATUS_MISMATCH', message: '无法识别当前装柜计划阶段,请稍后重试。' }
return
}
const result = await getPreloadPlan(planId.value, warehouseId.value)
if (sequence !== loadSequence) return
if (!['EDITING', 'LOADING'].includes(result.summary.status)) {
detail.value = null
error.value = { code: 'MODE_STATUS_MISMATCH', message: '该计划不属于预装清单状态,请从对应清单入口查看。' }
return
}
detail.value = result
} catch (cause) {
if (sequence !== loadSequence) return
error.value = getApiErrorDetails(cause, '预装清单详情加载失败,请稍后重试')
} finally {
if (sequence === loadSequence) loading.value = false
}
}
function goBack() {
if (window.history.length > 1) router.back()
else void router.push({ name: 'loading-plans' })
}
function openEdit() {
if (!detail.value || !canCorrectTransport.value) return
editForm.containerNo = detail.value.summary.containerNo || ''
editForm.voyageNo = detail.value.summary.voyageNo || ''
editForm.operationReason = ''
editError.value = null
editOpen.value = true
}
async function saveTransport() {
if (!detail.value || !canCorrectTransport.value || !(await editFormRef.value?.validate().catch(() => false))) return
const correcting = isContainerCorrection.value
editForm.containerNo = normalizedContainerNo(editForm.containerNo)
editSaving.value = true
editError.value = null
try {
detail.value = await updatePreloadTransport(planId.value, warehouseId.value, {
containerNo: editForm.containerNo,
voyageNo: editForm.voyageNo.trim() || null,
operationReason: editForm.operationReason.trim() || null,
expectedVersion: detail.value.summary.version,
})
editOpen.value = false
ElMessage.success(correcting ? '运输信息已纠正' : '真实集装箱号已补录')
} catch (cause) {
const details = getApiErrorDetails(cause, '运输信息保存失败,请刷新详情后重试')
if (details.code === 'A0409' || /版本|并发|已被其他人修改/.test(details.message)) {
await loadDetail()
editError.value = { ...details, message: '运输信息已被其他人修改,系统已刷新最新版本;当前输入已保留,请核对后重新保存。' }
} else editError.value = details
} finally {
editSaving.value = false
}
}
function openRemarks(item: LoadingItem) {
if (!canEdit.value || !detail.value || !['EDITING', 'LOADING'].includes(detail.value.summary.status)) return
remarkTarget.value = item
remarkForm.internalRemark = item.internalRemark || ''
remarkForm.warehouseRemark = item.warehouseRemark || ''
remarkForm.customerServiceRemark = item.customerServiceRemark || ''
remarkError.value = null
remarkOperationId.value = `admin-preload-remarks-${detail.value.summary.planId}-${item.itemId}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
remarkOpen.value = true
}
async function saveRemarks() {
const target = remarkTarget.value
const current = detail.value
if (!target || !current || !canEdit.value || remarkSaving.value) return
if (Object.values(remarkForm).some((value) => value.length > 1000)) {
remarkError.value = { code: 'REMARK_TOO_LONG', message: '每项备注不能超过 1000 个字符' }
return
}
remarkSaving.value = true
remarkError.value = null
try {
const result = await updateLoadingItemRemarks(current.summary.planId, target.itemId, {
internalRemark: remarkForm.internalRemark.trim() || null,
warehouseRemark: remarkForm.warehouseRemark.trim() || null,
customerServiceRemark: remarkForm.customerServiceRemark.trim() || null,
expectedPlanVersion: current.summary.version,
expectedItemVersion: target.itemVersion,
operationId: remarkOperationId.value,
})
const updatedItem = result.items.find((item) => item.id === target.itemId)
detail.value = {
...current,
summary: { ...current.summary, version: result.version },
items: current.items.map((item) => item.itemId === target.itemId
? { ...item, itemVersion: updatedItem?.version ?? item.itemVersion,
internalRemark: updatedItem?.internalRemark ?? null,
warehouseRemark: updatedItem?.warehouseRemark ?? null,
customerServiceRemark: updatedItem?.customerServiceRemark ?? null }
: item),
}
remarkOpen.value = false
remarkTarget.value = null
remarkOperationId.value = ''
ElMessage.success('备注已更新')
} catch (cause) {
const details = getApiErrorDetails(cause, '备注保存失败,请稍后重试')
if (details.code === 'A0409' || /版本|并发|状态.*变化/.test(details.message)) {
await loadDetail()
remarkTarget.value = detail.value?.items.find((item) => item.itemId === target.itemId) || target
remarkError.value = { ...details, message: '计划状态或明细版本已变化,系统已刷新最新数据;当前输入已保留,请核对后重新保存。' }
} else remarkError.value = details
} finally {
remarkSaving.value = false
}
}
function openConfirmPreload() {
const summary = detail.value?.summary
if (!canEdit.value || !summary?.readiness.canConfirm) return
confirmError.value = null
confirmOperationId.value = `admin-preload-confirm-${summary.planId}-${summary.version}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
confirmOpen.value = true
}
async function submitConfirmPreload() {
const summary = detail.value?.summary
if (!summary?.readiness.canConfirm || confirmSaving.value) return
confirmSaving.value = true
confirmError.value = null
try {
const result = await confirmPreloadPlan(summary.planId, summary.version, confirmOperationId.value)
await loadDetail()
confirmOpen.value = false
confirmOperationId.value = ''
ElMessage.success(result.readiness.readyForPda ? '预装已确认,PDA 任务已具备下发条件' : '预装已确认')
} catch (cause) {
confirmError.value = getApiErrorDetails(cause, '确认预装失败,请稍后重试')
} finally {
confirmSaving.value = false
}
}
onMounted(() => void loadDetail())
watch([planId, warehouseId], () => void loadDetail())
</script>
<template>
<AdminPage>
<div class="preload-detail-page admin-density">
<OperationErrorToast :details="remarkError || editError || confirmError" />
<LoadingListDetailDrawer :mode="'preload'" :loading="loading" :detail="detail" :error="error" :can-edit="canEdit" :can-edit-transport="canCorrectTransport" :transport-note="transportCorrectionNote" :confirm-saving="confirmSaving" @back="goBack" @retry="loadDetail" @edit="openEdit" @edit-remarks="openRemarks" @confirm-preload="openConfirmPreload" />
</div>
<AppDialog v-model="editOpen" class="loading-transport-dialog admin-density-dialog" :title="editDialogTitle" width="min(620px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="editSaving" :confirm-disabled="!canEdit" @confirm="saveTransport">
<el-form ref="editFormRef" class="transport-form" :model="editForm" :rules="editRules" label-position="top">
<el-form-item label="真实集装箱号" prop="containerNo"><el-input v-model="editForm.containerNo" maxlength="64" placeholder="请输入提柜后取得的真实集装箱号" @blur="editForm.containerNo = normalizedContainerNo(editForm.containerNo)" /></el-form-item>
<el-form-item label="航次" prop="voyageNo"><el-input v-model="editForm.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item>
<el-form-item :label="isContainerCorrection ? '纠错原因' : '操作原因(可选)'" prop="operationReason"><el-input v-model="editForm.operationReason" type="textarea" :rows="3" maxlength="500" :placeholder="isContainerCorrection ? '必填,将记录在操作历史中' : '可填写补录说明'" /></el-form-item>
</el-form>
</AppDialog>
<AppDialog v-model="remarkOpen" class="remarks-dialog admin-density-dialog" title="编辑备注" width="min(680px, calc(100vw - 28px))" confirm-text="保存备注" cancel-text="取消" :confirm-loading="remarkSaving" :confirm-disabled="!canEdit" @confirm="saveRemarks">
<el-alert type="info" :closable="false" show-icon title="客户备注来自客户下单且不可修改;其余备注可维护" />
<el-form label-position="top" class="remarks-form">
<el-form-item label="客户备注(只读)"><el-input :model-value="remarkTarget?.customerRemark || '—'" disabled /><small>来源:客户下单</small></el-form-item>
<el-form-item label="内部备注"><el-input v-model="remarkForm.internalRemark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="可填写、修改或清空内部备注" /></el-form-item>
<el-form-item label="仓库备注"><el-input v-model="remarkForm.warehouseRemark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="可填写、修改或清空仓库备注" /></el-form-item>
<el-form-item label="客服备注"><el-input v-model="remarkForm.customerServiceRemark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="可填写、修改或清空客服备注" /></el-form-item>
</el-form>
<LoadErrorState v-if="remarkError" compact title="备注保存失败" :message="remarkError.message" :show-retry="false"><template #details><ApiErrorMeta :details="remarkError" /></template></LoadErrorState>
</AppDialog>
<AppDialog v-model="confirmOpen" class="preload-confirm-dialog admin-density-dialog" title="确认整份预装计划" width="min(520px, calc(100vw - 28px))" confirm-text="确认预装" :confirm-loading="confirmSaving" :confirm-disabled="!detail?.summary.readiness.canConfirm" @confirm="submitConfirmPreload">
<div class="confirm-content">
<el-alert type="warning" :closable="false" show-icon title="本操作将确认整份预装计划" />
<el-descriptions v-if="detail" :column="1" border>
<el-descriptions-item label="预装单号">{{ detail.summary.preloadNo }}</el-descriptions-item>
<el-descriptions-item label="柜次号">{{ detail.summary.cabinetNo || '—' }}</el-descriptions-item>
<el-descriptions-item label="集装箱号">{{ detail.summary.containerNo || '待补录' }}</el-descriptions-item>
</el-descriptions>
</div>
</AppDialog>
</AdminPage>
</template>
<style scoped>
.preload-detail-page { display: grid; min-width: 0; width: 100%; gap: var(--admin-space-5, 12px); }
.transport-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-6, 16px); }
.transport-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }
.confirm-content { display: grid; gap: var(--admin-space-5, 12px); }
.remarks-form { margin-top: 14px; }
.remarks-form small { display: block; margin-top: var(--admin-space-1, 4px); color: var(--cc-muted); }
:global(.loading-transport-dialog.el-dialog) { max-height: 90vh; overflow: hidden; }
@media (min-width: 1920px) { .preload-detail-page { width: 100%; max-width: none; gap: var(--admin-space-5, 12px); } }
@media (max-width: 720px) { .transport-form { grid-template-columns: 1fr; } .transport-form :deep(.el-form-item:last-child) { grid-column: auto; } }
</style>