Files
admin-web/src/modules/loading-lists/pages/PreloadPlanDetailView.vue
T
2026-09-22 13:51:58 +08:00

343 lines
19 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, listDomesticPdaOperators, updateLoadingItemRemarks, updatePreloadTransport, type DomesticPdaOperator, 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 assigneeId = ref('')
const assigneeOptions = ref<DomesticPdaOperator[]>([])
const assigneeLoading = ref(false)
const selectedAssignee = computed(() => assigneeOptions.value.find((operator) => operator.userId === assigneeId.value))
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
let assigneeLoadSequence = 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 canPushPda = computed(() => canEdit.value && access.can('platform:firstleg:pda:assign'))
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 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
if (canPushPda.value && !result.summary.readiness.pdaTaskId) void loadAssigneeOptions()
} 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
}
}
async function loadAssigneeOptions() {
const sequence = ++assigneeLoadSequence
assigneeLoading.value = true
try {
if (!warehouseId.value) return
const options = await listDomesticPdaOperators(warehouseId.value)
if (sequence !== assigneeLoadSequence) return
assigneeOptions.value = options
if (assigneeId.value && !assigneeOptions.value.some((operator) => operator.userId === assigneeId.value)) assigneeId.value = ''
confirmError.value = null
} catch (cause) {
if (sequence !== assigneeLoadSequence) return
confirmError.value = getApiErrorDetails(cause, '国内 PDA 作业员加载失败,请重试')
assigneeOptions.value = []
} finally {
if (sequence === assigneeLoadSequence) assigneeLoading.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 (!canPushPda.value || !summary?.readiness.canConfirm || !assigneeId.value) 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 || !canPushPda.value || !assigneeId.value || confirmSaving.value) return
confirmSaving.value = true
confirmError.value = null
try {
const result = await confirmPreloadPlan(summary.planId, summary.version, assigneeId.value, 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], () => {
assigneeLoadSequence++
assigneeId.value = ''
assigneeOptions.value = []
assigneeLoading.value = false
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-push-pda="canPushPda" :pda-assignee-selected="Boolean(assigneeId)" :can-edit-transport="canCorrectTransport" :confirm-saving="confirmSaving" @back="goBack" @retry="loadDetail" @edit="openEdit" @edit-remarks="openRemarks" @confirm-preload="openConfirmPreload">
<template #pda-operator>
<div v-if="canPushPda && !detail?.summary.readiness.pdaTaskId" class="pda-operator-picker">
<label for="preload-pda-operator">PDA 操作员 <span>(必选)</span></label>
<el-select id="preload-pda-operator" v-model="assigneeId" filterable placeholder="请选择操作员" :loading="assigneeLoading" :disabled="confirmSaving" aria-label="PDA 操作员">
<el-option v-for="operator in assigneeOptions" :key="operator.userId" :value="operator.userId" :label="operator.displayName" />
</el-select>
</div>
</template>
</LoadingListDetailDrawer>
</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="确认预装并推送 PDA" width="min(520px, calc(100vw - 28px))" confirm-text="确认并推送" :confirm-loading="confirmSaving" :confirm-disabled="!detail?.summary.readiness.canConfirm || !assigneeId || assigneeLoading" @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>
<el-descriptions :column="1" border><el-descriptions-item label="PDA 操作员">{{ selectedAssignee?.displayName || '未选择' }}</el-descriptions-item></el-descriptions>
<LoadErrorState v-if="confirmError" compact title="推送失败" :message="confirmError.message" :show-retry="false" />
</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); }
.pda-operator-picker { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 8px; }
.pda-operator-picker label { flex: 0 0 auto; font-size: var(--admin-font-body, 13px); white-space: nowrap; }
.pda-operator-picker label span { color: var(--el-color-warning-dark-2); font-size: var(--admin-font-caption, 12px); }
.pda-operator-picker :deep(.el-select) { width: 180px; }
.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>