This commit is contained in:
2026-09-22 14:26:53 +08:00
8 changed files with 174 additions and 45 deletions
+3
View File
@@ -267,6 +267,7 @@ export interface PlatformManagedUser {
lastLoginAt: string | null
protectedAccount: boolean
overseasWarehouseId: number | string | null
domesticWarehouseId?: number | string | null
}
export interface PlatformManagedUserPage {
@@ -309,6 +310,7 @@ export interface CreatePlatformManagedUserRequest {
tenantId: number | string
roleIds: Array<number | string>
overseasWarehouseId: number | string | null
domesticWarehouseId?: number | string | null
password: string
status: 'ACTIVE' | 'DISABLED'
}
@@ -320,6 +322,7 @@ export interface UpdatePlatformManagedUserRequest {
tenantId: number | string
roleIds: Array<number | string>
overseasWarehouseId: number | string | null
domesticWarehouseId?: number | string | null
}
async function getData<T>(path: string): Promise<T> {
@@ -29,6 +29,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
const access = useAccessPreviewStore()
const auth = useAuthStore()
@@ -37,6 +38,9 @@ const options = ref<PlatformUserOptions>({ tenants: [], roles: [] })
const overseasWarehouses = ref<SharedReceivingOverseasWarehouseOption[]>([])
const overseasWarehousesLoading = ref(false)
const overseasWarehousesError = ref<ApiErrorDetails | null>(null)
const domesticWarehouses = ref<PlatformWarehouseSummaryResource[]>([])
const domesticWarehousesLoading = ref(false)
const domesticWarehousesError = ref<ApiErrorDetails | null>(null)
const loading = ref(false)
const saving = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
@@ -81,6 +85,7 @@ const emptyForm = () => ({
tenantId: '0',
roleIds: [] as string[],
overseasWarehouseId: '',
domesticWarehouseId: '',
password: '',
confirmPassword: '',
status: 'ACTIVE' as 'ACTIVE' | 'DISABLED',
@@ -111,6 +116,10 @@ const overseasPdaRoleIds = computed(() => new Set(
availableRoles.value.filter((role) => role.roleCode === 'OVERSEAS_WAREHOUSE_PDA_OPERATOR').map((role) => String(role.id)),
))
const hasOverseasPdaRole = computed(() => form.roleIds.some((roleId) => overseasPdaRoleIds.value.has(roleId)))
const domesticPdaRoleIds = computed(() => new Set(
availableRoles.value.filter((role) => role.roleCode === 'CN_FIRSTLEG_PDA_OPERATOR').map((role) => String(role.id)),
))
const hasDomesticPdaRole = computed(() => form.roleIds.some((roleId) => domesticPdaRoleIds.value.has(roleId)))
const formRules = computed<FormRules>(() => ({
username: editingUser.value ? [] : [
@@ -127,6 +136,9 @@ const formRules = computed<FormRules>(() => ({
overseasWarehouseId: !hasOverseasPdaRole.value || (editingUser.value && !canEdit.value) ? [] : [
{ required: true, message: '请选择实际海外收货仓', trigger: 'change' },
],
domesticWarehouseId: !hasDomesticPdaRole.value || (editingUser.value && !canEdit.value) ? [] : [
{ required: true, message: '请选择国内作业仓', trigger: 'change' },
],
password: editingUser.value && !form.resetPassword ? [] : [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ pattern: /^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, message: '密码为 8-64 位,且必须包含字母和数字', trigger: 'blur' },
@@ -217,6 +229,7 @@ function openEdit(row: PlatformManagedUser) {
tenantId: '0',
roleIds: row.roleIds.map(String),
overseasWarehouseId: row.overseasWarehouseId == null ? '' : String(row.overseasWarehouseId),
domesticWarehouseId: row.domesticWarehouseId == null ? '' : String(row.domesticWarehouseId),
password: '',
confirmPassword: '',
status: row.status,
@@ -243,6 +256,19 @@ async function loadOverseasWarehouses() {
}
}
async function loadDomesticWarehouses() {
domesticWarehousesLoading.value = true
domesticWarehousesError.value = null
try {
domesticWarehouses.value = await listAllPlatformWarehouses({ warehouseType: 'DOMESTIC', status: 'ACTIVE' })
} catch (error) {
domesticWarehouses.value = []
domesticWarehousesError.value = getApiErrorDetails(error, '国内作业仓加载失败,请重试')
} finally {
domesticWarehousesLoading.value = false
}
}
function isCurrentUser(row: PlatformManagedUser | null) {
if (!row) return false
return String(row.userId) === auth.user?.id || String(row.authUserId) === auth.user?.id
@@ -266,6 +292,7 @@ async function saveUser() {
tenantId: '0',
roleIds: form.roleIds,
overseasWarehouseId: hasOverseasPdaRole.value ? form.overseasWarehouseId : null,
...(hasDomesticPdaRole.value ? { domesticWarehouseId: form.domesticWarehouseId } : {}),
password: form.password,
status: form.status,
})
@@ -280,6 +307,7 @@ async function saveUser() {
tenantId: '0',
roleIds: form.roleIds,
overseasWarehouseId: hasOverseasPdaRole.value ? form.overseasWarehouseId : null,
...(hasDomesticPdaRole.value ? { domesticWarehouseId: form.domesticWarehouseId } : {}),
})
}
if (canChangeStatus.value && form.status !== row.status) {
@@ -340,6 +368,14 @@ watch(hasOverseasPdaRole, (selected) => {
}
void loadOverseasWarehouses()
})
watch(hasDomesticPdaRole, (selected) => {
if (!selected) {
form.domesticWarehouseId = ''
domesticWarehousesError.value = null
return
}
void loadDomesticWarehouses()
})
onMounted(loadInitialData)
</script>
@@ -435,6 +471,20 @@ onMounted(loadInitialData)
</p>
<p v-else class="field-help">仓库将写入登录 Token,海外 PDA 不提供仓库切换。</p>
</el-form-item>
<el-form-item v-if="hasDomesticPdaRole" label="国内作业仓" prop="domesticWarehouseId" required>
<el-select v-model="form.domesticWarehouseId" filterable
:loading="domesticWarehousesLoading" :disabled="Boolean(editingUser) && !canEdit"
placeholder="请选择国内作业仓">
<el-option v-for="warehouse in domesticWarehouses" :key="String(warehouse.id)"
:label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`"
:value="String(warehouse.id)" />
</el-select>
<p v-if="domesticWarehousesError" class="field-error">
{{ domesticWarehousesError.message }}
<el-button link type="primary" :loading="domesticWarehousesLoading" @click="loadDomesticWarehouses">重新加载</el-button>
</p>
<p v-else class="field-help">该人员只会出现在此仓库的国内头程 PDA 任务候选中。</p>
</el-form-item>
<el-form-item v-if="editingUser" label="最后登录">
<el-input :model-value="formatLogin(editingUser.lastLoginAt)" disabled />
</el-form-item>
@@ -146,11 +146,11 @@ describe('platform loading lists API', () => {
},
})
const result = await confirmPreloadPlan('1900000000000000001', 3, 'confirm-operation-1')
const result = await confirmPreloadPlan('1900000000000000001', 3, '1900000000000000007', 'confirm-operation-1')
expect(post).toHaveBeenCalledWith(
'/platform/v1/first-leg/loading-plans/1900000000000000001/confirm',
{ expectedVersion: 3 },
{ expectedVersion: 3, assigneeId: '1900000000000000007' },
{ headers: { 'Idempotency-Key': 'confirm-operation-1' } },
)
expect(result).toMatchObject({
@@ -186,6 +186,11 @@ export interface PreloadConfirmationResponse {
readiness: PreloadReadiness
}
export interface DomesticPdaOperator {
userId: string
displayName: string
}
type RawId = string | number | null | undefined
type RawPage<T extends LoadingListSummary> = Omit<LoadingListPage<T>, 'page' | 'size' | 'total' | 'pages'> & {
page: string | number
@@ -359,10 +364,17 @@ export async function updateLoadingItemRemarks(planId: string, itemId: string, r
}
}
export async function confirmPreloadPlan(planId: string, expectedVersion: number, operationId: string) {
export async function listDomesticPdaOperators(warehouseId: string, keyword?: string): Promise<DomesticPdaOperator[]> {
const response = await http.get<ApiEnvelope<DomesticPdaOperator[]>, ApiEnvelope<DomesticPdaOperator[]>>(
'/platform/v1/iam/users/domestic-pda-operators', { params: { warehouseId, keyword } },
)
return unwrapApiResponse(response).map((operator) => ({ ...operator, userId: requiredId(operator.userId) }))
}
export async function confirmPreloadPlan(planId: string, expectedVersion: number, assigneeId: string, operationId: string) {
const response = await http.post<ApiEnvelope<PreloadConfirmationResponse>, ApiEnvelope<PreloadConfirmationResponse>>(
`${basePath}/loading-plans/${encodeURIComponent(planId)}/confirm`,
{ expectedVersion },
{ expectedVersion, assigneeId },
{ headers: { 'Idempotency-Key': operationId } },
)
const result = unwrapApiResponse(response)
@@ -1,7 +1,7 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed } from 'vue'
import { ArrowLeft, Check, Pencil } from '@lucide/vue'
import { computed, ref, watch } from 'vue'
import { ArrowLeft, Check, ChevronRight, Pencil } from '@lucide/vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
@@ -18,10 +18,11 @@ const props = defineProps<{
detail: LoadingDetail | null
error: ApiErrorDetails | null
canEdit: boolean
canPushPda?: boolean
pdaAssigneeSelected?: boolean
dense?: boolean
embedded?: boolean
canEditTransport?: boolean
transportNote?: string
confirmSaving?: boolean
}>()
@@ -101,6 +102,13 @@ const visibleHistories = computed(() => {
return true
})
})
const historyExpanded = ref(props.mode !== 'preload')
let lastHistoryPlanId: string | number | undefined
watch(() => props.detail?.summary.planId, (id) => {
if (id == null) return
if (lastHistoryPlanId !== undefined && id !== lastHistoryPlanId) historyExpanded.value = props.mode !== 'preload'
lastHistoryPlanId = id
})
const blockingReasonLabels: Record<string, string> = {
LOADING_PLAN_NOT_EDITING: '装柜计划已不在可确认的编辑状态',
PRELOAD_LINK_MISSING: '装柜明细尚未完整关联入库预装票据',
@@ -156,13 +164,13 @@ function formatNumber(value?: number | null, digits = 3) {
<el-button link type="primary" class="detail-page-header__back" :class="{ 'density-back': mode === 'preload' || dense }" @click="emit('back')"><ArrowLeft :size="16" />返回列表</el-button>
<div v-if="!embedded" class="detail-page-header__identity">
<h1>{{ title }}</h1>
<p>{{ mode === 'preload' ? '核对运输信息、确认预装并维护逐条仓库备注' : '查看已完成装柜明细和柜次费用' }}</p>
<p v-if="mode !== 'preload'">查看已完成装柜明细和柜次费用</p>
</div>
<div v-if="detail && summary" class="detail-page-header__actions">
<PalletLabelPrintDialog :dense="mode === 'preload' || dense" v-if="summary.warehouseId" :plan-id="summary.planId" :warehouse-id="summary.warehouseId" :disabled="!summary.containerNo || summary.status === 'EDITING'" />
<template v-if="canEditItemRemark">
<el-button v-if="canEditTransport" @click="emit('edit')"><Pencil :size="16" />{{ summary.containerNo ? '纠正集装箱号 / 航次' : '补录集装箱号 / 航次' }}</el-button>
<el-button type="primary" :loading="confirmSaving" :disabled="!preloadSummary?.readiness.canConfirm" @click="emit('confirmPreload')"><Check :size="16" />确认预装</el-button>
<el-button v-if="canPushPda" type="primary" :loading="confirmSaving" :disabled="!preloadSummary?.readiness.canConfirm || !pdaAssigneeSelected" @click="emit('confirmPreload')"><Check :size="16" />确认预装并推送 PDA</el-button>
</template>
</div>
</header>
@@ -192,7 +200,6 @@ function formatNumber(value?: number | null, digits = 3) {
<strong>暂不能确认预装:</strong>
<span>{{ blockingReasonText || '请刷新详情获取最新确认条件' }}</span>
</div>
<div v-if="preloadSummary" class="readiness-note transport-note" role="status"><strong>真实集装箱号:</strong><span>{{ transportNote }}</span></div>
<section class="detail-section">
<DataTable
@@ -204,7 +211,7 @@ function formatNumber(value?: number | null, digits = 3) {
empty-text="暂无装柜货物明细"
aria-label="装柜货物明细"
>
<template #toolbar-start><div class="detail-section__heading"><div><h2>装柜货物明细 <span>{{ detail.items.length }} 条</span></h2><p>仓库备注将随装柜完成资料下发海外仓</p></div></div></template>
<template #toolbar-start><div class="detail-section__heading"><h2>装柜货物明细 <span>{{ detail.items.length }} 条</span></h2><slot v-if="mode === 'preload'" name="pda-operator" /></div></template>
<template #actions="{ row }">
<el-button :class="{ 'density-action': mode === 'preload' || dense }" size="small" :type="mode === 'preload' ? undefined : 'primary'" :plain="mode !== 'preload'" :aria-label="`编辑 ${row.inboundCode || row.orderNo} 的备注`" @click="emit('editRemarks', row as LoadingItem)"><Pencil v-if="mode !== 'preload'" :size="14" />编辑备注</el-button>
</template>
@@ -222,9 +229,9 @@ function formatNumber(value?: number | null, digits = 3) {
</DataTable>
</section>
<section class="history-section" aria-label="操作历史">
<header><h2>操作历史</h2><span>{{ visibleHistories.length }} 条</span></header>
<el-table :data="visibleHistories" border stripe empty-text="暂无操作历史" aria-label="清单操作历史">
<section class="history-section" :class="{ 'is-expanded': historyExpanded }" aria-label="操作历史">
<header><button type="button" class="history-section__toggle" :aria-expanded="historyExpanded" aria-controls="loading-history-table" @click="historyExpanded = !historyExpanded"><h2>操作历史</h2><span>{{ visibleHistories.length }} 条</span><ChevronRight :size="16" class="history-section__arrow" :class="{ 'is-expanded': historyExpanded }" aria-hidden="true" /></button></header>
<el-table v-if="historyExpanded" id="loading-history-table" :data="visibleHistories" border stripe empty-text="暂无操作历史" aria-label="清单操作历史">
<el-table-column label="时间" width="170"><template #default="{ row }">{{ formatDateTime(row.occurredAt) }}</template></el-table-column>
<el-table-column label="事件" width="140"><template #default="{ row }">{{ actionLabel(row.actionType) }}</template></el-table-column>
<el-table-column label="状态变化" min-width="200"><template #default="{ row }"><span v-if="row.fromStatus || row.toStatus">{{ loadingListStatusLabel(row.fromStatus) }} → {{ loadingListStatusLabel(row.toStatus) }}</span><span v-else>—</span></template></el-table-column>
@@ -262,16 +269,21 @@ function formatNumber(value?: number | null, digits = 3) {
.readiness-note strong { flex: 0 0 auto; color: var(--el-color-warning-dark-2); }
.readiness-note span { min-width: 0; color: var(--cc-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.detail-section, .history-section { min-width: 0; overflow: hidden; padding: var(--admin-space-6, 16px) 0 0; border-top: 1px solid var(--cc-border); }
.detail-section__heading { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; }
.detail-section__heading h2 { margin: 0; font-size: 15px; }
.detail-section__heading h2 span { margin-left: var(--admin-space-2, 6px); color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); font-weight: 400; }
.detail-section__heading p { margin: 2px 0 0; color: var(--cc-muted); font-size: var(--admin-font-caption, 11px); line-height: 1.35; }
.detail-section :deep(.el-table) { width: 100%; }
.detail-section :deep(.el-table th.el-table__cell) { padding: 7px 0; }
.detail-section :deep(.el-table td.el-table__cell) { padding: var(--admin-space-2, 6px) 0; }
.detail-section :deep(.identifier-column .cell) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.detail-section small { display: block; margin-top: 3px; color: var(--cc-muted); }
.remark-cell { min-width: 0; line-height: 1.45; overflow-wrap: anywhere; }
.history-section header { display: flex; align-items: baseline; gap: var(--admin-space-4, 10px); margin-bottom: 18px; }
.history-section header { margin: 0; }
.history-section.is-expanded header { margin-bottom: 18px; }
.history-section__toggle { display: inline-flex; align-items: center; gap: var(--admin-space-4, 10px); padding: 0; border: 0; background: transparent; color: inherit; cursor: pointer; }
.history-section__toggle:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 4px; border-radius: 2px; }
.history-section__arrow { color: var(--cc-muted); transition: transform 0.2s ease; }
.history-section__arrow.is-expanded { transform: rotate(90deg); }
.history-section h2 { margin: 0; font-size: var(--admin-font-title, 16px); }
.history-section header span { color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); }
.loading-density { gap: var(--admin-space-5, 12px); padding: var(--admin-space-5, 12px); border-radius: var(--admin-radius, 4px); }
@@ -287,8 +299,8 @@ function formatNumber(value?: number | null, digits = 3) {
.loading-density .detail-layout :deep(.detail-page-layout__content) { gap: var(--admin-space-5, 12px); }
.loading-density .detail-section, .loading-density .history-section { padding-top: var(--admin-space-3, 8px); }
.loading-density .detail-section__heading h2, .loading-density .history-section h2 { font-size: var(--admin-font-section, 14px); }
.loading-density .history-section header { margin-bottom: var(--admin-space-3, 8px); }
.loading-density .detail-section__heading p, .loading-density small { font-size: var(--admin-font-caption, 12px); }
.loading-density .history-section.is-expanded header { margin-bottom: var(--admin-space-3, 8px); }
.loading-density small { font-size: var(--admin-font-caption, 12px); }
.loading-density .readiness-note { align-items: baseline; }
.loading-density .readiness-note span { white-space: normal; overflow: visible; overflow-wrap: anywhere; }
@media (max-width: 720px) { .loading-density .detail-summary { grid-template-columns: 1fr; } .loading-density .detail-summary__wide { grid-column: auto; } }
@@ -116,11 +116,11 @@ describe('LoadingListsView', () => {
expect(confirmedDetailSource).toContain('MODE_STATUS_MISMATCH')
expect(confirmedDetailSource).toContain('watch([planId, warehouseId], () => void loadDetail())')
expect(confirmedDetailSource).toContain('if (sequence !== loadSequence) return')
expect(preloadDetailSource).toContain('watch([planId, warehouseId], () => void loadDetail())')
expect(preloadDetailSource).toContain('watch([planId, warehouseId], () => {')
})
it('uses backend readiness for whole-plan confirmation and translates blocking reasons', () => {
expect(detailSource).toContain(':disabled="!preloadSummary?.readiness.canConfirm"')
expect(detailSource).toContain(':disabled="!preloadSummary?.readiness.canConfirm || !pdaAssigneeSelected"')
expect(detailSource).toContain("OCEAN_BOOKING_NOT_RELEASED: '关联订舱尚未放舱'")
expect(detailSource).toContain("PRELOAD_ALREADY_CONFIRMED: '整份预装计划已经确认'")
expect(detailSource).toContain("confirmationStatus !== 'CONFIRMED' && !preloadSummary.readiness.canConfirm")
@@ -185,7 +185,7 @@ describe('LoadingListsView', () => {
it('keeps customer remarks read-only while exposing three editable collaboration remarks', () => {
expect(detailSource).toContain('来源:客户下单')
expect(detailSource).toContain('来源:订单内部备注')
expect(detailSource).toContain('仓库备注将随装柜完成资料下发海外仓')
expect(detailSource).not.toContain('仓库备注将随装柜完成资料下发海外仓')
expect(detailSource).toContain("emit('editRemarks', row as LoadingItem)")
expect(detailSource).toContain("props.mode === 'preload' && props.canEdit && ['EDITING', 'LOADING'].includes(summary.value?.status || '')")
expect(detailSource).not.toContain("emit('editCustomerRemark'")
@@ -68,9 +68,9 @@ describe('PreloadPlanDetailView prototype contract', () => {
expect(source).toContain("['EDITING', 'LOADING'].includes(summary.status)")
expect(source).toContain('if (!summary.containerNo) return true')
expect(source).not.toContain('summary?.containerNo && summary.status')
expect(source).toContain('PDA 已领取或作业已开始,真实集装箱号只读。')
expect(source).toContain('该历史 PDA 任务缺少真实集装箱号;补录保存后即可进入任务。')
expect(source).toContain('确认预装前必须补录真实集装箱号。')
expect(source).not.toContain('transportCorrectionNote')
expect(detailSource).not.toContain('class="readiness-note transport-note"')
expect(detailSource).not.toContain('真实集装箱号:</strong>')
expect(source).toContain("isContainerCorrection.value ? '纠正集装箱号 / 航次' : '补录集装箱号 / 航次'")
expect(source).toContain("isContainerCorrection.value && !String(value || '').trim() ? new Error('纠错时请输入操作原因')")
expect(source).toContain("String(value || '').trim().toUpperCase()")
@@ -86,24 +86,38 @@ describe('PreloadPlanDetailView prototype contract', () => {
expect(detailSource).toContain('role="status"')
expect(detailSource).toContain("confirmationStatus !== 'CONFIRMED' && !preloadSummary.readiness.canConfirm")
expect(detailSource).not.toContain('type="success" :closable="false" show-icon title="整份预装计划已确认"')
expect(detailSource).toContain('<el-button type="primary" :loading="confirmSaving"')
expect(detailSource).toContain('type="primary" :loading="confirmSaving"')
expect(detailSource).not.toContain('<el-button type="success" :loading="confirmSaving"')
})
it('keeps explanatory notes outside the item list hidden', () => {
expect(detailSource).not.toContain('核对运输信息、确认预装并维护逐条仓库备注')
expect(detailSource).not.toContain('仓库备注将随装柜完成资料下发海外仓')
expect(source).not.toContain('暂无候选,请先配置国内头程 PDA 作业员')
})
it('confirms the whole plan through readiness with stable idempotency and refreshes the detail', () => {
expect(source).toContain('confirmPreloadPlan(summary.planId, summary.version, confirmOperationId.value)')
expect(source).toContain('confirmPreloadPlan(summary.planId, summary.version, assigneeId.value, confirmOperationId.value)')
expect(source).toContain('listDomesticPdaOperators(warehouseId.value)')
expect(source).toContain('v-model="assigneeId"')
expect(source).toContain('<template #pda-operator>')
expect(detailSource).toContain('<slot v-if="mode === \'preload\'" name="pda-operator" />')
expect(source).not.toContain('暂无候选,请先配置国内头程 PDA 作业员')
expect(detailSource).toContain('!pdaAssigneeSelected')
expect(source).toContain('selectedAssignee?.displayName')
expect(source).not.toContain('<el-form-item label="国内头程 PDA 作业员(必选)">')
expect(source).toContain('summary?.readiness.canConfirm')
expect(source).toContain('confirmSaving.value')
expect(source).toContain('await loadDetail()')
expect(source).toContain('本操作将确认整份预装计划')
expect(source).toContain(':confirm-disabled="!detail?.summary.readiness.canConfirm"')
expect(source).toContain(':confirm-disabled="!detail?.summary.readiness.canConfirm || !assigneeId || assigneeLoading"')
expect(source).not.toContain('/inbounds/')
})
it('keeps backend confirmation errors visible and supports narrow dialogs', () => {
expect(source).toContain("confirmError.value = getApiErrorDetails(cause, '确认预装失败,请稍后重试')")
expect(source).toContain('<OperationErrorToast :details="remarkError || editError || confirmError" />')
expect(source).not.toContain(':message="confirmError.message"')
expect(source).toContain(':message="confirmError.message"')
expect(source).toContain('width="min(520px, calc(100vw - 28px))"')
expect(source).toContain("label=\"柜次号\"")
expect(source).toContain("label=\"集装箱号\"")
@@ -12,7 +12,7 @@ import { getApiErrorDetails, http, unwrapApiResponse, type ApiEnvelope, type Api
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'
import { confirmPreloadPlan, getConfirmedLoadingList, getPreloadPlan, listDomesticPdaOperators, updateLoadingItemRemarks, updatePreloadTransport, type DomesticPdaOperator, type LoadingDetail, type LoadingItem, type PreloadSummary } from '../api/platformLoadingLists'
const route = useRoute()
const router = useRouter()
@@ -28,6 +28,10 @@ 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)
@@ -37,6 +41,7 @@ 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' }
@@ -49,6 +54,7 @@ async function resolveLoadingPlanLifecycle() {
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
@@ -57,15 +63,6 @@ const canCorrectTransport = computed(() => {
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: [
@@ -117,6 +114,7 @@ async function loadDetail() {
return
}
detail.value = result
if (canPushPda.value && !result.summary.readiness.pdaTaskId) void loadAssigneeOptions()
} catch (cause) {
if (sequence !== loadSequence) return
error.value = getApiErrorDetails(cause, '预装清单详情加载失败,请稍后重试')
@@ -165,6 +163,25 @@ async function saveTransport() {
}
}
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
@@ -224,7 +241,7 @@ async function saveRemarks() {
function openConfirmPreload() {
const summary = detail.value?.summary
if (!canEdit.value || !summary?.readiness.canConfirm) return
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
@@ -232,15 +249,15 @@ function openConfirmPreload() {
async function submitConfirmPreload() {
const summary = detail.value?.summary
if (!summary?.readiness.canConfirm || confirmSaving.value) return
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, confirmOperationId.value)
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 任务已具备下发条件' : '预装已确认')
ElMessage.success(result.readiness.readyForPda ? '预装已确认,PDA 任务已推送给指定作业员' : '预装已确认')
} catch (cause) {
confirmError.value = getApiErrorDetails(cause, '确认预装失败,请稍后重试')
} finally {
@@ -249,14 +266,29 @@ async function submitConfirmPreload() {
}
onMounted(() => void loadDetail())
watch([planId, warehouseId], () => 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-edit-transport="canCorrectTransport" :transport-note="transportCorrectionNote" :confirm-saving="confirmSaving" @back="goBack" @retry="loadDetail" @edit="openEdit" @edit-remarks="openRemarks" @confirm-preload="openConfirmPreload" />
<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">
@@ -278,7 +310,7 @@ watch([planId, warehouseId], () => void loadDetail())
<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">
<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>
@@ -286,6 +318,8 @@ watch([planId, warehouseId], () => void loadDetail())
<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>
@@ -296,6 +330,10 @@ watch([planId, warehouseId], () => void loadDetail())
.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; }