优化
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainerDetailView.vue'), 'utf8')
|
||||
|
||||
describe('ContainerDetailView', () => {
|
||||
it('keeps identity, status and key facts in a compact overview', () => {
|
||||
expect(source).toContain('class="container-overview surface-panel"')
|
||||
expect(source).toContain('class="container-overview__header"')
|
||||
expect(source).toContain('class="container-overview__summary"')
|
||||
expect(source).toContain('grid-template-columns:repeat(5,minmax(0,1fr))')
|
||||
})
|
||||
|
||||
it('keeps real document, exception and authorized photo operations', () => {
|
||||
expect(source).toContain('uploadContainerDocument(')
|
||||
expect(source).toContain('downloadContainerDocument(')
|
||||
expect(source).toContain('uploadContainerClearance(')
|
||||
expect(source).toContain('completeContainerClearance(')
|
||||
expect(source).toContain('markContainerException')
|
||||
expect(source).toContain('cancelContainerException')
|
||||
expect(source).toContain('downloadContainerPdaPhoto(photo.downloadPath)')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,168 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, FileUp } from '@lucide/vue'
|
||||
import { ElMessage, type UploadFile } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue'
|
||||
import {
|
||||
cancelContainerException,
|
||||
completeContainerClearance,
|
||||
downloadContainerDocument,
|
||||
downloadContainerPdaPhoto,
|
||||
getContainer,
|
||||
getContainerClearance,
|
||||
getContainerOverview,
|
||||
markContainerException,
|
||||
uploadContainerClearance,
|
||||
uploadContainerDocument,
|
||||
} from '../api/platformContainerLoading'
|
||||
import type { BusinessDocument, ContainerBookingResource, ContainerOverview, ContainerOverviewDocument, ContainerPdaPhoto, ContainerPlanStatus } from '../types'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
const containerId = computed(() => String(route.params.containerId || ''))
|
||||
const canEdit = computed(() => access.can('platform:firstleg:container:edit'))
|
||||
const loading = ref(false)
|
||||
const detail = ref<ContainerBookingResource | null>(null)
|
||||
const overview = ref<ContainerOverview | null>(null)
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
const overviewError = ref<ApiErrorDetails | null>(null)
|
||||
const documentOpen = ref(false)
|
||||
const documentType = ref<'DELIVERY_ORDER' | 'PICKUP_ORDER'>('DELIVERY_ORDER')
|
||||
const documentFile = ref<File | null>(null)
|
||||
const documentSaving = ref(false)
|
||||
const documentDownloading = ref(false)
|
||||
const documentError = ref<ApiErrorDetails | null>(null)
|
||||
const clearanceOpen = ref(false)
|
||||
const clearanceDocument = ref<BusinessDocument | null>(null)
|
||||
const clearanceFile = ref<File | null>(null)
|
||||
const clearanceLoading = ref(false)
|
||||
const clearanceSaving = ref(false)
|
||||
const clearanceError = ref<ApiErrorDetails | null>(null)
|
||||
const exceptionOpen = ref(false)
|
||||
const exceptionReason = ref('')
|
||||
const exceptionSaving = ref(false)
|
||||
const exceptionError = ref<ApiErrorDetails | null>(null)
|
||||
const photoBusyId = ref<string | null>(null)
|
||||
const photoError = ref<ApiErrorDetails | null>(null)
|
||||
|
||||
const selectedDocument = computed(() => overview.value?.documents.find((item) => item.documentType === documentType.value) || null)
|
||||
const documentTitle = computed(() => documentType.value === 'DELIVERY_ORDER' ? 'DO 资料' : 'PU 资料')
|
||||
const title = computed(() => detail.value?.containerNo || detail.value?.cabinetNo || '货柜详情')
|
||||
const exceptionTitle = computed(() => overview.value?.exception.active ? '取消货柜异常' : '标记货柜异常')
|
||||
|
||||
function statusText(status?: ContainerPlanStatus | null) { return status ? ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' })[status] : '—' }
|
||||
function formatTime(value?: string | null) { return value?.replace('T', ' ').slice(0, 16) || '—' }
|
||||
|
||||
async function load() {
|
||||
if (!containerId.value) return
|
||||
loading.value = true; error.value = null; overviewError.value = null
|
||||
try {
|
||||
const [resource, aggregate] = await Promise.all([getContainer(containerId.value), getContainerOverview(containerId.value)])
|
||||
detail.value = resource; overview.value = aggregate
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '货柜详情加载失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
function openDocument(type: string) {
|
||||
if (type === 'DESTINATION_CLEARANCE') { void openClearance(); return }
|
||||
if (!['DELIVERY_ORDER', 'PICKUP_ORDER'].includes(type)) return
|
||||
documentType.value = type as 'DELIVERY_ORDER' | 'PICKUP_ORDER'
|
||||
documentFile.value = null; documentError.value = null; documentOpen.value = true
|
||||
}
|
||||
function selectDocumentFile(file: UploadFile) { documentFile.value = file.raw || null; documentError.value = null }
|
||||
function removeDocumentFile() { documentFile.value = null }
|
||||
async function saveDocument() {
|
||||
if (!documentFile.value || !overview.value) { documentError.value = { code: 'FILE_REQUIRED', message: '请选择需要上传的资料文件' }; return }
|
||||
documentSaving.value = true; documentError.value = null
|
||||
try {
|
||||
await uploadContainerDocument(containerId.value, documentType.value, documentFile.value, selectedDocument.value?.version || 0, `上传${documentTitle.value}`)
|
||||
overview.value = await getContainerOverview(containerId.value)
|
||||
documentOpen.value = false; documentFile.value = null; ElMessage.success(`${documentTitle.value}已上传`)
|
||||
} catch (cause) { documentError.value = getApiErrorDetails(cause, `${documentTitle.value}上传失败`) }
|
||||
finally { documentSaving.value = false }
|
||||
}
|
||||
async function saveBlob(blob: Blob, fileName: string) { const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = fileName || '货柜资料'; anchor.click(); URL.revokeObjectURL(url) }
|
||||
async function downloadDocument(item: ContainerOverviewDocument) {
|
||||
if (!['DELIVERY_ORDER', 'PICKUP_ORDER'].includes(item.documentType)) return
|
||||
documentDownloading.value = true
|
||||
try { await saveBlob(await downloadContainerDocument(containerId.value, item.documentType as 'DELIVERY_ORDER' | 'PICKUP_ORDER', item.fileId), item.fileName); ElMessage.success('资料下载已开始') }
|
||||
catch (cause) { ElMessage.error(getApiErrorDetails(cause, '资料下载失败').message) }
|
||||
finally { documentDownloading.value = false }
|
||||
}
|
||||
async function previewPhoto(photo: ContainerPdaPhoto) {
|
||||
photoBusyId.value = photo.evidenceId; photoError.value = null
|
||||
try { const url = URL.createObjectURL(await downloadContainerPdaPhoto(photo.downloadPath)); window.open(url, '_blank', 'noopener,noreferrer'); window.setTimeout(() => URL.revokeObjectURL(url), 60_000) }
|
||||
catch (cause) { photoError.value = getApiErrorDetails(cause, '装柜照片加载失败') }
|
||||
finally { photoBusyId.value = null }
|
||||
}
|
||||
|
||||
function openException() { exceptionReason.value = ''; exceptionError.value = null; exceptionOpen.value = true }
|
||||
async function saveException() {
|
||||
if (!overview.value || !exceptionReason.value.trim()) { exceptionError.value = { code: 'REASON_REQUIRED', message: '请输入异常原因或取消备注' }; return }
|
||||
exceptionSaving.value = true; exceptionError.value = null
|
||||
try {
|
||||
const action = overview.value.exception.active ? cancelContainerException : markContainerException
|
||||
await action(containerId.value, overview.value.container.version, exceptionReason.value.trim())
|
||||
overview.value = await getContainerOverview(containerId.value)
|
||||
exceptionOpen.value = false; ElMessage.success(overview.value.exception.active ? '货柜已标记异常' : '货柜异常已取消')
|
||||
} catch (cause) { exceptionError.value = getApiErrorDetails(cause, '货柜异常处理失败') }
|
||||
finally { exceptionSaving.value = false }
|
||||
}
|
||||
|
||||
async function openClearance() {
|
||||
clearanceOpen.value = true; clearanceDocument.value = null; clearanceFile.value = null; clearanceError.value = null; clearanceLoading.value = true
|
||||
try { clearanceDocument.value = await getContainerClearance(containerId.value) }
|
||||
catch (cause) { const details = getApiErrorDetails(cause); if (details.code !== 'A0404') clearanceError.value = details }
|
||||
finally { clearanceLoading.value = false }
|
||||
}
|
||||
function selectClearanceFile(file: UploadFile) { clearanceFile.value = file.raw || null; clearanceError.value = null }
|
||||
function removeClearanceFile() { clearanceFile.value = null }
|
||||
async function uploadClearance() {
|
||||
if (!clearanceFile.value) { clearanceError.value = { code: 'FILE_REQUIRED', message: '请选择清关资料文件' }; return }
|
||||
clearanceSaving.value = true; clearanceError.value = null
|
||||
try { clearanceDocument.value = await uploadContainerClearance(containerId.value, clearanceFile.value, clearanceDocument.value?.version || 0, '上传目的港清关资料'); overview.value = await getContainerOverview(containerId.value); clearanceFile.value = null; ElMessage.success('清关资料已上传') }
|
||||
catch (cause) { clearanceError.value = getApiErrorDetails(cause, '清关资料上传失败') }
|
||||
finally { clearanceSaving.value = false }
|
||||
}
|
||||
async function completeClearance() {
|
||||
if (!clearanceDocument.value) return
|
||||
clearanceSaving.value = true; clearanceError.value = null
|
||||
try { clearanceDocument.value = await completeContainerClearance(containerId.value, clearanceDocument.value.version, '确认目的港清关完成'); overview.value = await getContainerOverview(containerId.value); ElMessage.success('已完成清关') }
|
||||
catch (cause) { clearanceError.value = getApiErrorDetails(cause, '清关完成失败') }
|
||||
finally { clearanceSaving.value = false }
|
||||
}
|
||||
|
||||
onMounted(async () => { await load(); if (route.query.action === 'documents') openDocument('DELIVERY_ORDER'); if (route.query.action === 'exception' && canEdit.value) openException() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage class="container-detail-page">
|
||||
<section class="container-overview surface-panel">
|
||||
<header class="container-overview__header"><el-button link type="primary" @click="router.push({ name: 'container-loading' })"><ArrowLeft :size="16" />返回列表</el-button><div><h1>{{ title }}</h1><p>柜次资料、履约轨迹、PDA 进度与单证</p></div><StatusBadge v-if="detail" :status="statusText(detail.planStatus)" /></header>
|
||||
<div v-if="detail" class="container-overview__summary"><div><span>柜次号</span><strong>{{ detail.cabinetNo || '—' }}</strong></div><div><span>集装箱号</span><strong>{{ detail.containerNo || '待录入' }}</strong></div><div><span>装柜清单号</span><strong>{{ detail.planNo || '—' }}</strong></div><div><span>柜型</span><strong>{{ detail.containerType || '—' }}</strong></div><div><span>装柜仓库</span><strong>{{ detail.warehouseName || '—' }}</strong></div></div>
|
||||
</section>
|
||||
<LoadErrorState v-if="error" title="货柜详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<template v-else-if="detail">
|
||||
<section class="detail-fields surface-panel"><h2>运输与单证快照</h2><dl><div><dt>真实订舱</dt><dd>{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}</dd></div><div><dt>异常状态</dt><dd>{{ detail.exceptionActive ? '异常' : '正常' }}</dd></div><div><dt>目的仓</dt><dd>{{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}</dd></div><div><dt>船公司</dt><dd>{{ detail.shippingCompanyName || '—' }}</dd></div><div><dt>船名 / 航次</dt><dd>{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</dd></div><div><dt>拖车公司</dt><dd>{{ detail.terminalTruckingCompany || '—' }}</dd></div><div><dt>预计开船</dt><dd>{{ formatTime(detail.estimatedSailingAt) }}</dd></div><div><dt>预计到港</dt><dd>{{ formatTime(detail.estimatedPortArrivalAt) }}</dd></div></dl></section>
|
||||
<LoadErrorState v-if="overviewError" compact title="履约摘要加载失败" :message="overviewError.message" :show-retry="false" />
|
||||
<ContainerOverviewPanel v-else-if="overview" :overview="overview" :can-manage-documents="canEdit" :can-manage-exception="canEdit" :document-busy="documentDownloading" :photo-busy-id="photoBusyId" :photo-error="photoError" @upload-document="openDocument" @download-document="downloadDocument" @manage-exception="openException" @preview-photo="previewPhoto" />
|
||||
</template>
|
||||
|
||||
<AppDialog v-model="documentOpen" :title="documentTitle" width="min(520px, calc(100vw - 28px))" confirm-text="上传资料" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><el-form label-position="top"><el-form-item :label="documentTitle"><el-upload :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ selectedDocument ? '上传新文件将替换当前资料并保留历史。' : `请选择需要提交的${documentTitle}。` }}</div></template></el-upload></el-form-item></el-form><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false" /></AppDialog>
|
||||
<AppDialog v-model="exceptionOpen" :title="exceptionTitle" width="min(520px, calc(100vw - 28px))" :confirm-text="overview?.exception.active ? '确认取消' : '确认标记'" :confirm-loading="exceptionSaving" :confirm-disabled="!exceptionReason.trim()" @confirm="saveException"><el-alert :title="overview?.exception.active ? '取消备注为必填项,提交后将保留历史记录。' : '异常原因将同步记录在货柜详情和操作日志中。'" :type="overview?.exception.active ? 'warning' : 'error'" :closable="false" /><el-form label-position="top"><el-form-item :label="overview?.exception.active ? '取消备注' : '异常原因'" required><el-input v-model="exceptionReason" type="textarea" :rows="4" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="exceptionError" compact title="异常处理失败" :message="exceptionError.message" :show-retry="false" /></AppDialog>
|
||||
<AppDialog v-model="clearanceOpen" title="目的港清关资料" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :show-confirm="!clearanceDocument?.completedAt" confirm-text="上传资料" :confirm-loading="clearanceSaving" :confirm-disabled="!clearanceFile" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`清关已完成:${clearanceDocument.fileName}`" /><template v-else><el-form label-position="top"><el-form-item label="清关资料"><el-upload :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button><FileUp :size="16" />选择文件</el-button></el-upload></el-form-item></el-form><el-button v-if="clearanceDocument" type="success" plain :loading="clearanceSaving" @click="completeClearance">确认清关完成</el-button></template><LoadErrorState v-if="clearanceError" compact title="清关资料操作失败" :message="clearanceError.message" :show-retry="false" /></AppDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container-detail-page { display:grid; min-width:0; gap:16px; }.container-overview { overflow:hidden; }.container-overview__header { display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; gap:16px; padding:12px 16px; }.container-overview__header h1 { margin:0; font-size:18px; }.container-overview__header p { margin:2px 0 0; color:var(--cc-muted); font-size:12px; }.container-overview__summary { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); border-top:1px solid var(--cc-border); }.container-overview__summary>div { display:flex; min-width:0; flex-direction:column; gap:5px; padding:10px 14px; border-right:1px solid var(--cc-border); }.container-overview__summary>div:last-child { border-right:0; }.container-overview__summary span,dt { color:var(--cc-muted); font-size:12px; }.container-overview__summary strong,dd { overflow:hidden; margin:0; text-overflow:ellipsis; white-space:nowrap; }.detail-fields { padding:14px 16px; }.detail-fields h2 { margin:0 0 12px; font-size:15px; }.detail-fields dl { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:12px 18px; margin:0; }.detail-fields dl>div { min-width:0; }.detail-fields dd { margin-top:4px; font-size:13px; }
|
||||
</style>
|
||||
@@ -187,10 +187,11 @@ describe('ContainersView', () => {
|
||||
expect(source).not.toContain('@reset=')
|
||||
})
|
||||
|
||||
it('uses centered dialogs for container detail, create and edit operations', () => {
|
||||
it('opens container detail as an independent route while keeping create and edit dialogs', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
|
||||
expect(source).toContain('<AppDialog v-model="detailOpen" class="container-detail-dialog"')
|
||||
expect(source).toContain('width="min(820px, calc(100vw - 28px))"')
|
||||
expect(source).toContain("router.push({ name: 'container-detail', params: { containerId: row.id } })")
|
||||
expect(source).toContain("query: { action: command }")
|
||||
expect(source).not.toContain('<AppDialog v-model="detailOpen"')
|
||||
expect(source).toContain('<AppDialog v-model="editorOpen" class="container-editor-dialog"')
|
||||
expect(source).toContain('max-height: 90vh')
|
||||
expect(source).toContain('overflow-y: auto')
|
||||
@@ -199,6 +200,20 @@ describe('ContainersView', () => {
|
||||
expect(source).not.toContain('<AppDrawer v-model="editorOpen"')
|
||||
})
|
||||
|
||||
it('registers a list-authorized independent container detail page', () => {
|
||||
const routes = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/routes.ts'), 'utf8')
|
||||
const detail = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainerDetailView.vue'), 'utf8')
|
||||
expect(routes).toContain("path: 'container-loading/:containerId'")
|
||||
expect(routes).toContain("name: 'container-detail'")
|
||||
expect(routes).toContain("permission: 'platform:firstleg:container:list'")
|
||||
expect(detail).toContain('getContainer(containerId.value)')
|
||||
expect(detail).toContain('getContainerOverview(containerId.value)')
|
||||
expect(detail).toContain('<ContainerOverviewPanel')
|
||||
expect(detail).toContain("@upload-document=\"openDocument\"")
|
||||
expect(detail).toContain("@manage-exception=\"openException\"")
|
||||
expect(detail).toContain("@preview-photo=\"previewPhoto\"")
|
||||
})
|
||||
|
||||
it('keeps the prototype field sequence ahead of contract-only supplemental fields', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
|
||||
const labels = ['集装箱号', '柜次号', '装柜仓库', '提单号', '船名', '航次', '箱型(货柜类型)', '船公司', '起运港', '目的港', '目的仓', '货柜容量(m³)']
|
||||
@@ -271,8 +286,9 @@ describe('ContainersView trucking supplier contract', () => {
|
||||
previous = current
|
||||
}
|
||||
expect(source).toContain("MISSING: '未上传', UPLOADED: '已上传', COMPLETED: '已完成'")
|
||||
expect(source).toContain("detail.planNo || '—'")
|
||||
expect(source).toContain("detail.exceptionActive ? '异常' : '正常'")
|
||||
const detail = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainerDetailView.vue'), 'utf8')
|
||||
expect(detail).toContain("detail.planNo || '—'")
|
||||
expect(detail).toContain("detail.exceptionActive ? '异常' : '正常'")
|
||||
})
|
||||
|
||||
it('uses active TRUCKING suppliers without allowing free-text carrier names', () => {
|
||||
|
||||
@@ -425,24 +425,15 @@ async function loadRows() {
|
||||
} finally { if (sequence === listSequence) loading.value = false }
|
||||
}
|
||||
function search() { applied.value = { ...filters.value, sailingRange: [...filters.value.sailingRange] }; if (page.value === 1) void loadRows(); else page.value = 1 }
|
||||
async function openDetail(source: object) {
|
||||
function openDetail(source: object) {
|
||||
const row = source as ContainerBookingResource
|
||||
detailTarget.value = row
|
||||
detailOpen.value = true; detail.value = null; overview.value = null; detailError.value = null; overviewError.value = null; detailLoading.value = true
|
||||
const sequence = ++detailSequence
|
||||
try { const [result, aggregate] = await Promise.all([getContainer(row.id), getContainerOverview(row.id)]); if (sequence === detailSequence) { detail.value = result; overview.value = aggregate; clearanceDocument.value = null } }
|
||||
catch (error) { if (sequence === detailSequence) detailError.value = getApiErrorDetails(error, '货柜详情加载失败') }
|
||||
finally { if (sequence === detailSequence) detailLoading.value = false }
|
||||
void router.push({ name: 'container-detail', params: { containerId: row.id } })
|
||||
}
|
||||
function handleMoreAction(command: string, source: object) {
|
||||
const row = source as ContainerBookingResource
|
||||
if (command === 'link') { void openLink(row); return }
|
||||
if (command === 'overview') { void openDetail(row); return }
|
||||
detailTarget.value = row
|
||||
void openDetail(row).then(() => {
|
||||
if (command === 'exception') openExceptionDialog()
|
||||
if (command === 'documents') openDocumentDialog('DELIVERY_ORDER')
|
||||
})
|
||||
if (command === 'overview') { openDetail(row); return }
|
||||
if (['exception', 'documents'].includes(command)) void router.push({ name: 'container-detail', params: { containerId: row.id }, query: { action: command } })
|
||||
}
|
||||
function openDocumentDialog(type: string) {
|
||||
if (!['DELIVERY_ORDER', 'PICKUP_ORDER'].includes(type)) {
|
||||
@@ -607,11 +598,6 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin
|
||||
</section>
|
||||
</ListPageLayout>
|
||||
|
||||
<AppDialog v-model="detailOpen" class="container-detail-dialog" title="货柜详情" width="min(820px, calc(100vw - 28px))" :loading="detailLoading" :show-confirm="false" cancel-text="关闭">
|
||||
<LoadErrorState v-if="detailError" title="货柜详情加载失败" :message="detailError.message" @retry="detailTarget && openDetail(detailTarget)"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
|
||||
<template v-else-if="detail"><el-descriptions :column="2" border class="details"><el-descriptions-item label="柜次号">{{ detail.cabinetNo }}</el-descriptions-item><el-descriptions-item label="装柜清单号">{{ detail.planNo || '—' }}</el-descriptions-item><el-descriptions-item label="真实订舱">{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}</el-descriptions-item><el-descriptions-item label="集装箱号">{{ detail.containerNo || '待录入' }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusText(detail.planStatus) }}</el-descriptions-item><el-descriptions-item label="异常状态">{{ detail.exceptionActive ? '异常' : '正常' }}</el-descriptions-item><el-descriptions-item label="目的港清关资料">{{ documentStatusText(detail.destinationClearanceDocumentStatus) }}</el-descriptions-item><el-descriptions-item label="报关资料">{{ documentStatusText(detail.customsDeclarationDocumentStatus) }}</el-descriptions-item><el-descriptions-item label="DO资料">{{ documentStatusText(detail.deliveryOrderDocumentStatus) }}</el-descriptions-item><el-descriptions-item label="PU资料">{{ documentStatusText(detail.pickupOrderDocumentStatus) }}</el-descriptions-item><el-descriptions-item label="柜型">{{ detail.containerType }}</el-descriptions-item><el-descriptions-item label="装柜仓库">{{ detail.warehouseName || '—' }}</el-descriptions-item><el-descriptions-item label="目的仓">{{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}</el-descriptions-item><el-descriptions-item label="船公司">{{ detail.shippingCompanyName || '—' }}</el-descriptions-item><el-descriptions-item label="船名/航次">{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</el-descriptions-item><el-descriptions-item label="提单号">{{ detail.billOfLadingNo || '—' }}</el-descriptions-item><el-descriptions-item label="拖车公司">{{ detail.terminalTruckingCompany || '—' }}</el-descriptions-item><el-descriptions-item label="预计开船">{{ formatTime(detail.estimatedSailingAt) }}</el-descriptions-item><el-descriptions-item label="预计到港">{{ formatTime(detail.estimatedPortArrivalAt) }}</el-descriptions-item></el-descriptions><LoadErrorState v-if="overviewError" compact title="履约摘要加载失败" :message="overviewError.message" :show-retry="false"><template #details><ApiErrorMeta :details="overviewError" /></template></LoadErrorState><ContainerOverviewPanel v-else-if="overview" :overview="overview" :can-manage-documents="canEdit" :can-manage-exception="canEdit" :document-busy="documentDownloading" :photo-busy-id="photoBusyId" :photo-error="photoError" @upload-document="openDocumentDialog" @download-document="downloadDocument" @manage-exception="openExceptionDialog" @preview-photo="previewPhoto" /></template>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model="clearanceDialogOpen" title="目的港清关资料" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :confirm-loading="clearanceActionLoading" :confirm-disabled="!clearanceFile" :show-confirm="!clearanceDocument?.completedAt" confirm-text="上传资料" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`清关已完成:${clearanceDocument.fileName}`" /><el-form v-else label-position="top"><el-form-item label="清关资料"><el-upload :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ clearanceDocument ? '上传新文件将替换当前资料。' : '请选择需要提交的目的港清关资料。' }}</div></template></el-upload></el-form-item></el-form><LoadErrorState v-if="clearanceError" compact title="操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState></AppDialog>
|
||||
|
||||
<AppDialog v-model="documentDialogOpen" :title="documentDialogTitle" width="min(520px, calc(100vw - 28px))" confirm-text="上传资料" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><el-form label-position="top"><el-form-item :label="documentDialogTitle"><el-upload :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ selectedOverviewDocument ? '上传新文件将替换当前资料并保留操作历史。' : `请选择需要提交的${documentDialogTitle}。` }}</div></template></el-upload></el-form-item></el-form><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false"><template #details><ApiErrorMeta :details="documentError" /></template></LoadErrorState></AppDialog>
|
||||
|
||||
@@ -7,6 +7,12 @@ export const containerLoadingRoutes = [
|
||||
component: () => import('./pages/ContainersView.vue'),
|
||||
meta: { title: '货柜与排柜', description: '维护货柜资料并进入智能排柜工作台', permission: 'platform:firstleg:container:list' },
|
||||
},
|
||||
{
|
||||
path: 'container-loading/:containerId',
|
||||
name: 'container-detail',
|
||||
component: () => import('./pages/ContainerDetailView.vue'),
|
||||
meta: { title: '货柜详情', description: '查看货柜资料、履约轨迹、单证与异常', permission: 'platform:firstleg:container:list', showPageHeading: false },
|
||||
},
|
||||
{
|
||||
path: 'container-loading/:containerId/smart-loading',
|
||||
name: 'smart-loading-workbench',
|
||||
|
||||
@@ -10,10 +10,10 @@ describe('container fee API', () => {
|
||||
const values = { feeItemId: '7', direction: 'PAYABLE' as const, partnerId: '41', partnerName: '承运商A', businessDate: '2026-08-12', amount: 100, currency: 'CNY' as const, settlementIncluded: true, remark: null }
|
||||
await listContainerFees('9', '11'); const created = await createContainerFee('9', '11', values)
|
||||
await approveContainerFee('9', '11', '3'); await rejectContainerFee('9', '11', '3', '资料不全')
|
||||
await adjustContainerFee('9', '11', '3', 2, values); await voidContainerFee('9', '11', '3', 3)
|
||||
await adjustContainerFee('9', '11', '3', { expectedSourceVersion: 2, businessDate: '2026-08-13', amount: 120 }); await voidContainerFee('9', '11', '3', 3)
|
||||
expect(get).toHaveBeenCalledWith('/platform/v1/loading-plans/9/fee-details', { params: { accountId: '11' } })
|
||||
expect(post).toHaveBeenCalledWith('/platform/v1/loading-plans/9/fee-details', values, { params: { accountId: '11' } })
|
||||
expect(post).toHaveBeenCalledWith('/platform/v1/loading-plans/9/fee-details/3/adjust', { expectedSourceVersion: 2, ...values }, { params: { accountId: '11' } })
|
||||
expect(post).toHaveBeenCalledWith('/platform/v1/loading-plans/9/fee-details/3/adjust', { expectedSourceVersion: 2, businessDate: '2026-08-13', amount: 120 }, { params: { accountId: '11' } })
|
||||
expect(post).toHaveBeenCalledWith('/platform/v1/loading-plans/9/fee-details/3/void', { expectedSourceVersion: 3 }, { params: { accountId: '11' } })
|
||||
expect(created).toMatchObject({ id: '3', loadingPlanId: '9', containerBookingId: '99', direction: 'PAYABLE', partnerId: '41', partnerName: '承运商A' })
|
||||
})
|
||||
|
||||
@@ -38,6 +38,12 @@ export interface ContainerFeeSaveRequest {
|
||||
remark: string | null
|
||||
}
|
||||
|
||||
export interface ContainerFeeAdjustRequest {
|
||||
expectedSourceVersion: number
|
||||
businessDate: string
|
||||
amount: number
|
||||
}
|
||||
|
||||
export interface ApplicableFeeItem {
|
||||
id: string
|
||||
name: string
|
||||
@@ -109,8 +115,8 @@ export async function rejectContainerFee(planId: string, accountId: string, feeI
|
||||
await http.post(`${base(planId)}/${encodeURIComponent(feeId)}/reject`, { reason }, { params: params(accountId) })
|
||||
}
|
||||
|
||||
export async function adjustContainerFee(planId: string, accountId: string, feeId: string, expectedSourceVersion: number, values: ContainerFeeSaveRequest) {
|
||||
const response = await http.post<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>(`${base(planId)}/${encodeURIComponent(feeId)}/adjust`, { expectedSourceVersion, ...values }, { params: params(accountId) })
|
||||
export async function adjustContainerFee(planId: string, accountId: string, feeId: string, payload: ContainerFeeAdjustRequest) {
|
||||
const response = await http.post<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>(`${base(planId)}/${encodeURIComponent(feeId)}/adjust`, payload, { params: params(accountId) })
|
||||
return normalizeFee(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { createContainerFee, listApplicableFeeItems, listContainerCostDetails, listContainerFeePartnerOptions, listContainerFees } from '../api/containerFees'
|
||||
import { adjustContainerFee, createContainerFee, listApplicableFeeItems, listContainerCostDetails, listContainerFeePartnerOptions, listContainerFees } from '../api/containerFees'
|
||||
import ContainerFeesPanel from './ContainerFeesPanel.vue'
|
||||
|
||||
vi.mock('../api/containerFees', async (importOriginal) => {
|
||||
@@ -32,6 +32,7 @@ beforeEach(() => {
|
||||
vi.mocked(listContainerFees).mockReset().mockResolvedValue([]); vi.mocked(listApplicableFeeItems).mockReset().mockResolvedValue([])
|
||||
vi.mocked(listContainerFeePartnerOptions).mockReset().mockResolvedValue([])
|
||||
vi.mocked(listContainerCostDetails).mockReset().mockResolvedValue([]); vi.mocked(createContainerFee).mockReset()
|
||||
vi.mocked(adjustContainerFee).mockReset().mockResolvedValue({} as never)
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
@@ -58,10 +59,22 @@ describe('ContainerFeesPanel contract', () => {
|
||||
expect(panel).toContain("row.reviewStatus === 'APPROVED'")
|
||||
expect(panel).toContain('adjustContainerFee')
|
||||
expect(panel).toContain('voidContainerFee')
|
||||
expect(panel).toContain('调整后版本递增并重新进入待审核状态')
|
||||
expect(panel).toContain('调整仅允许修改业务日期和金额')
|
||||
expect(panel).toContain('请先作废后新增')
|
||||
expect(panel).toContain("row.partnerName || '服务端未返回'")
|
||||
})
|
||||
|
||||
it('adjusts only business date and amount while preserving the approved snapshot', async () => {
|
||||
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit'])
|
||||
const item = { id: '2', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '7', feeItemName: '拖车费', direction: 'PAYABLE' as const, partnerId: '41', partnerName: '承运商A', businessDate: '2026-08-12', amount: 100, currency: 'CNY' as const, settlementIncluded: true, reviewStatus: 'APPROVED' as const, sourceStatus: 'ACTIVE' as const, sourceVersion: 3, remark: '原备注', reviewerName: null, reviewedAt: null, reviewReason: null }
|
||||
const state = (wrapper.vm.$ as unknown as { setupState: { openAdjust(value: typeof item): void; save(): Promise<void>; form: { businessDate: string; amount: number } } }).setupState
|
||||
state.openAdjust(item); await flushPromises()
|
||||
state.form.businessDate = '2026-08-13'; state.form.amount = 120
|
||||
await state.save()
|
||||
expect(adjustContainerFee).toHaveBeenCalledWith('9', '11', '2', { expectedSourceVersion: 3, businessDate: '2026-08-13', amount: 120 })
|
||||
expect(wrapper.text()).toContain('已审核费用仍可调整日期与金额')
|
||||
})
|
||||
|
||||
it('shows only server allocations and supplier snapshots', () => {
|
||||
expect(panel).toContain('listContainerCostDetails')
|
||||
expect(panel).toContain('仅展示 Finance 已持久化结果')
|
||||
@@ -123,8 +136,7 @@ describe('ContainerFeesPanel contract', () => {
|
||||
{ id: '2', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '2', feeItemName: '已审费用', direction: 'PAYABLE', partnerId: '41', partnerName: '承运商A', businessDate: '2026-08-12', amount: 20, currency: 'CNY', settlementIncluded: true, reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null },
|
||||
])
|
||||
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view'])
|
||||
expect(wrapper.text()).not.toContain('新增费用')
|
||||
for (const label of ['通过', '驳回', '调整', '作废']) expect(wrapper.findAll('button').some((button) => button.text() === label)).toBe(false)
|
||||
for (const label of ['新增费用', '通过', '驳回', '调整', '作废']) expect(wrapper.findAll('button').some((button) => button.text() === label)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps receivable usable when counterparty permission is missing', async () => {
|
||||
|
||||
@@ -25,6 +25,7 @@ const partnersError = ref<ApiErrorDetails | null>(null)
|
||||
const feeItemsLoading = ref(false)
|
||||
const partnersLoading = ref(false)
|
||||
const editorOpen = ref(false)
|
||||
const editorError = ref<ApiErrorDetails | null>(null)
|
||||
const rejectOpen = ref(false)
|
||||
const saving = ref(false)
|
||||
const target = ref<ContainerFeeResource | null>(null)
|
||||
@@ -113,21 +114,38 @@ async function load() {
|
||||
finally { if (sequence === loadSequence) loading.value = false }
|
||||
}
|
||||
|
||||
function openCreate() { if (!canEdit.value || !canViewFeeItems.value || !accountId.value) return; resetForm(); editorOpen.value = true; void Promise.all([loadFeeItems(), loadPartners()]) }
|
||||
function openAdjust(item: ContainerFeeResource) { if (!canEdit.value || !canViewFeeItems.value || !accountId.value) return; resetForm(item); editorOpen.value = true; void Promise.all([loadFeeItems(), loadPartners()]) }
|
||||
function openCreate() { if (!canEdit.value || !canViewFeeItems.value || !accountId.value) return; resetForm(); editorError.value = null; editorOpen.value = true; void Promise.all([loadFeeItems(), loadPartners()]) }
|
||||
function openAdjust(item: ContainerFeeResource) { if (!canEdit.value || !accountId.value) return; resetForm(item); editorError.value = null; editorOpen.value = true }
|
||||
async function save() {
|
||||
if (!canEdit.value || !canViewFeeItems.value || !accountId.value) return
|
||||
if (form.direction === 'PAYABLE' && (!canViewPartners.value || !form.partnerId)) { ElMessage.warning(canViewPartners.value ? '应付费用必须选择往来单位' : '当前账号缺少往来单位查看权限,无法保存应付费用'); return }
|
||||
if (!canEdit.value || !accountId.value || (!target.value && !canViewFeeItems.value)) return
|
||||
if (!target.value && form.direction === 'PAYABLE' && (!canViewPartners.value || !form.partnerId)) { ElMessage.warning(canViewPartners.value ? '应付费用必须选择往来单位' : '当前账号缺少往来单位查看权限,无法保存应付费用'); return }
|
||||
if (!await formRef.value?.validate().catch(() => false)) return
|
||||
editorError.value = null
|
||||
if (target.value) {
|
||||
saving.value = true
|
||||
try {
|
||||
await adjustContainerFee(props.planId, accountId.value, target.value.id, {
|
||||
expectedSourceVersion: target.value.sourceVersion,
|
||||
businessDate: form.businessDate,
|
||||
amount: form.amount,
|
||||
})
|
||||
editorOpen.value = false; ElMessage.success('费用调整已提交审核'); await load()
|
||||
} catch (cause) {
|
||||
const details = getApiErrorDetails(cause, '费用调整失败')
|
||||
editorError.value = details.code === 'A0409'
|
||||
? { ...details, message: `${details.message};当前输入已保留,请刷新费用明细后重试。` }
|
||||
: details
|
||||
} finally { saving.value = false }
|
||||
return
|
||||
}
|
||||
const partner = partners.value.find((item) => item.id === String(form.partnerId))
|
||||
const payload: ContainerFeeSaveRequest = { ...form, partnerId: form.direction === 'PAYABLE' ? form.partnerId : null, partnerName: form.direction === 'PAYABLE' ? partner?.partnerName || null : null }
|
||||
if (form.direction === 'PAYABLE' && !payload.partnerName) { ElMessage.warning('请选择有效的往来单位'); return }
|
||||
saving.value = true
|
||||
try {
|
||||
if (target.value) await adjustContainerFee(props.planId, accountId.value, target.value.id, target.value.sourceVersion, payload)
|
||||
else await createContainerFee(props.planId, accountId.value, payload)
|
||||
editorOpen.value = false; ElMessage.success(target.value ? '费用调整已提交审核' : '费用已新增'); await load()
|
||||
} catch (cause) { ElMessage.error(getApiErrorDetails(cause, '费用保存失败').message) }
|
||||
await createContainerFee(props.planId, accountId.value, payload)
|
||||
editorOpen.value = false; ElMessage.success('费用已新增'); await load()
|
||||
} catch (cause) { editorError.value = getApiErrorDetails(cause, '费用保存失败') }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
async function approve(item: ContainerFeeResource) { if (!canReview.value || !accountId.value) return; await approveContainerFee(props.planId, accountId.value, item.id).then(() => ElMessage.success('费用已审核通过')).then(load).catch((cause) => ElMessage.error(getApiErrorDetails(cause, '审核失败').message)) }
|
||||
@@ -162,7 +180,7 @@ onMounted(() => void load())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="fees-panel surface-panel" aria-label="柜次费用维护">
|
||||
<section class="fees-panel" aria-label="柜次费用维护">
|
||||
<header class="fees-header">
|
||||
<div><h2>柜次费用</h2><p>费用与财务分摊均来自服务端真实记录</p></div>
|
||||
<div class="fees-header__actions">
|
||||
@@ -174,7 +192,7 @@ onMounted(() => void load())
|
||||
<template v-else>
|
||||
<el-alert v-if="customerOptions.length === 0" type="warning" :closable="false" show-icon title="装柜明细缺少客户归属,无法安全维护费用" />
|
||||
<el-alert v-else-if="!accountId" type="info" :closable="false" show-icon title="该柜次包含多个客户,请先选择费用归属客户" />
|
||||
<el-alert v-else-if="!canViewFeeItems" type="warning" :closable="false" show-icon title="当前账号缺少费用项目查看权限,无法新增或调整费用" />
|
||||
<el-alert v-else-if="!canViewFeeItems" type="warning" :closable="false" show-icon title="当前账号缺少费用项目查看权限,无法新增费用;已审核费用仍可调整日期与金额" />
|
||||
<LoadErrorState v-else-if="error" compact title="柜次费用加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<DataTable v-else :data="fees" :columns="columns" :loading="loading" preference-key="container-fees" row-key="id" empty-text="当前客户暂无柜次费用">
|
||||
<template #toolbar-start><div class="fees-table-title"><strong>费用明细</strong><span>{{ fees.length }} 条</span></div></template>
|
||||
@@ -187,20 +205,52 @@ onMounted(() => void load())
|
||||
<div v-if="accountId" class="allocation-section"><header><div><h3>财务体积分摊</h3><p>{{ selectedCustomer?.label }} · 仅展示 Finance 已持久化结果</p></div></header><el-empty v-if="!loading && costs.length === 0" description="尚无与该柜次关联的财务分摊结果" :image-size="64" /><article v-for="cost in costs" :key="cost.id" class="cost-block"><div class="cost-summary"><strong>{{ cost.feeItemName || '柜次成本' }}</strong><span>往来单位:{{ cost.supplierName || '服务端未返回' }}</span><span>金额:¥{{ money(cost.amount) }}</span></div><DataTable :data="cost.allocations" :columns="allocationColumns" :preference-key="`container-cost-${cost.id}`" row-key="id" empty-text="服务端未返回分摊明细"><template #volume="{ row }">{{ row.volumeCbm.toFixed(3) }}</template><template #rate="{ row }">{{ (row.allocationRate * 100).toFixed(2) }}%</template><template #allocated="{ row }">{{ money(row.allocatedAmount) }}</template></DataTable></article></div>
|
||||
</template>
|
||||
|
||||
<AppDialog v-model="editorOpen" :title="target ? '调整已审核费用' : '新增柜次费用'" confirm-text="保存" :confirm-loading="saving" @confirm="save"><el-alert v-if="target" type="info" :closable="false" show-icon title="调整后版本递增并重新进入待审核状态" /><el-alert v-if="!canViewPartners" type="warning" :closable="false" show-icon title="当前账号缺少往来单位查看权限,仅可维护应收费用" /><el-alert v-else-if="partnersError" type="warning" :closable="false" show-icon :title="partnersError.message" /><el-alert v-if="feeItemsError" type="warning" :closable="false" show-icon :title="feeItemsError.message" /><el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="fee-form"><el-form-item label="方向" prop="direction"><el-segmented v-model="form.direction" :options="[{ label: '应收', value: 'RECEIVABLE' }, { label: '应付', value: 'PAYABLE', disabled: !canViewPartners || !!partnersError }]" /></el-form-item><el-form-item label="费用项目" prop="feeItemId"><el-select v-model="form.feeItemId" filterable :loading="feeItemsLoading" :disabled="!canViewFeeItems" placeholder="请选择启用且适用的费用项目"><el-option v-for="item in applicableItems" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item><el-form-item v-if="form.direction === 'PAYABLE'" label="往来单位" required><el-select v-model="form.partnerId" filterable :loading="partnersLoading" :disabled="!canViewPartners || !!partnersError" placeholder="请选择启用的往来单位"><el-option v-for="partner in partners" :key="partner.id" :label="partner.partnerName" :value="partner.id" /></el-select></el-form-item><el-form-item label="业务日期" prop="businessDate"><el-date-picker v-model="form.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="金额(CNY)" prop="amount"><el-input-number v-model="form.amount" :min="0" :precision="2" controls-position="right" /></el-form-item><el-form-item label="参与结算"><el-switch v-model="form.settlementIncluded" /></el-form-item><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></el-form></AppDialog>
|
||||
<AppDialog v-model="editorOpen" :title="target ? '调整已审核费用' : '新增柜次费用'" confirm-text="保存" :confirm-loading="saving" @confirm="save">
|
||||
<div v-if="target" class="adjust-snapshot">
|
||||
<p>调整仅允许修改业务日期和金额;如需变更方向、费用项目、往来单位或其他信息,请先作废后新增。</p>
|
||||
<el-descriptions :column="3" border size="small">
|
||||
<el-descriptions-item label="方向">{{ target.direction === 'RECEIVABLE' ? '应收' : '应付' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="费用项目">{{ target.feeItemName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="往来单位">{{ target.partnerName || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="币种">{{ target.currency }}</el-descriptions-item>
|
||||
<el-descriptions-item label="参与结算">{{ target.settlementIncluded ? '是' : '否' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注">{{ target.remark || '—' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
<template v-else>
|
||||
<el-alert v-if="!canViewPartners" type="warning" :closable="false" show-icon title="当前账号缺少往来单位查看权限,仅可维护应收费用" />
|
||||
<el-alert v-else-if="partnersError" type="warning" :closable="false" show-icon :title="partnersError.message" />
|
||||
<el-alert v-if="feeItemsError" type="warning" :closable="false" show-icon :title="feeItemsError.message" />
|
||||
</template>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="fee-form">
|
||||
<template v-if="!target">
|
||||
<el-form-item label="方向" prop="direction"><el-segmented v-model="form.direction" :options="[{ label: '应收', value: 'RECEIVABLE' }, { label: '应付', value: 'PAYABLE', disabled: !canViewPartners || !!partnersError }]" /></el-form-item>
|
||||
<el-form-item label="费用项目" prop="feeItemId"><el-select v-model="form.feeItemId" filterable :loading="feeItemsLoading" :disabled="!canViewFeeItems" placeholder="请选择启用且适用的费用项目"><el-option v-for="item in applicableItems" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item>
|
||||
<el-form-item v-if="form.direction === 'PAYABLE'" label="往来单位" required><el-select v-model="form.partnerId" filterable :loading="partnersLoading" :disabled="!canViewPartners || !!partnersError" placeholder="请选择启用的往来单位"><el-option v-for="partner in partners" :key="partner.id" :label="partner.partnerName" :value="partner.id" /></el-select></el-form-item>
|
||||
</template>
|
||||
<el-form-item label="业务日期" prop="businessDate"><el-date-picker v-model="form.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item>
|
||||
<el-form-item label="金额(CNY)" prop="amount"><el-input-number v-model="form.amount" :min="0" :precision="2" controls-position="right" /></el-form-item>
|
||||
<template v-if="!target">
|
||||
<el-form-item label="参与结算"><el-switch v-model="form.settlementIncluded" /></el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
<LoadErrorState v-if="editorError" compact title="费用保存失败" :message="editorError.message" :show-retry="false"><template #details><ApiErrorMeta :details="editorError" /></template></LoadErrorState>
|
||||
</AppDialog>
|
||||
<AppDialog v-model="rejectOpen" title="驳回柜次费用" confirm-text="确认驳回" :confirm-loading="saving" @confirm="reject"><el-input v-model="rejection" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入驳回原因" /></AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fees-panel { min-width: 0; overflow: hidden; border: 1px solid var(--cc-border); background: #fff; }
|
||||
.fees-header, .allocation-section > header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 14px; }
|
||||
.fees-panel { min-width: 0; overflow: hidden; padding-top: 16px; border-top: 1px solid var(--cc-border); }
|
||||
.fees-header, .allocation-section > header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 0 0 12px; }
|
||||
.fees-header h2, .allocation-section h3 { margin: 0; font-size: 16px; }.fees-header p, .allocation-section p { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; }
|
||||
.fees-header__actions, .customer-context { display: flex; align-items: center; gap: 10px; }
|
||||
.customer-context > span { color: var(--cc-muted); font-size: 13px; white-space: nowrap; }.customer-context :deep(.el-select) { width: 260px; }
|
||||
.fees-table-title { display: flex; align-items: baseline; gap: 8px; }.fees-table-title strong { font-size: 14px; }.fees-table-title span { color: var(--cc-muted); font-size: 12px; }
|
||||
.fees-panel :deep(.data-table__toolbar) { min-height: 48px; padding: 6px 12px; }
|
||||
.fees-panel :deep(.el-table th.el-table__cell) { padding: 7px 0; }.fees-panel :deep(.el-table td.el-table__cell) { padding: 6px 0; }
|
||||
.row-actions { display: flex; flex-wrap: wrap; }.allocation-section { border-top: 1px solid var(--cc-border); }.cost-block { border-top: 1px solid var(--cc-border); }.cost-summary { display: flex; flex-wrap: wrap; gap: 10px 18px; padding: 10px 14px; background: #f8fafc; font-size: 13px; }.cost-summary span { color: var(--cc-muted); }.fee-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; margin-top: 14px; }.fee-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }
|
||||
.row-actions { display: flex; flex-wrap: wrap; }.allocation-section { padding-top: 16px; border-top: 1px solid var(--cc-border); }.cost-block { border-top: 1px solid var(--cc-border); }.cost-summary { display: flex; flex-wrap: wrap; gap: 10px 18px; padding: 10px 14px; background: #f8fafc; font-size: 13px; }.cost-summary span { color: var(--cc-muted); }.fee-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; margin-top: 14px; }.fee-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }
|
||||
.adjust-snapshot p { margin: 0 0 12px; color: var(--cc-muted); font-size: 13px; line-height: 1.5; }
|
||||
@media (max-width: 640px) { .fees-header { align-items: stretch; flex-direction: column; }.fees-header .el-button { width: 100%; }.fee-form { grid-template-columns: 1fr; }.fee-form :deep(.el-form-item:last-child) { grid-column: auto; }.customer-context { align-items: stretch; flex-direction: column; } }
|
||||
</style>
|
||||
|
||||
@@ -24,7 +24,7 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{ back: []; retry: []; edit: []; editWarehouseRemark: [item: LoadingItem]; confirmPreload: []; attachCustoms: []; completeCustoms: [] }>()
|
||||
|
||||
const actionColumn: DataTableColumn = { key: 'actions', label: '操作', fixed: 'left', width: 106, slotName: 'actions', hideable: false, movable: false }
|
||||
const actionColumn: DataTableColumn = { key: 'actions', label: '操作', fixed: 'left', width: 132, slotName: 'actions', hideable: false, movable: false }
|
||||
const baseItemColumns: DataTableColumn[] = [
|
||||
{ key: 'sequence', prop: 'loadSequence', label: '装柜顺序', width: 92 },
|
||||
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 170, className: 'identifier-column', showOverflowTooltip: true },
|
||||
@@ -113,7 +113,7 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
|
||||
<template>
|
||||
<section v-loading="loading" class="loading-detail-content" :aria-label="title" :aria-busy="loading">
|
||||
<section class="detail-overview surface-panel" aria-label="清单概览">
|
||||
<section class="detail-overview" aria-label="清单概览">
|
||||
<header class="detail-page-header">
|
||||
<el-button link type="primary" class="detail-page-header__back" @click="emit('back')"><ArrowLeft :size="16" />返回列表</el-button>
|
||||
<div class="detail-page-header__identity">
|
||||
@@ -122,8 +122,8 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
</div>
|
||||
<div v-if="detail && summary" class="detail-page-header__actions">
|
||||
<template v-if="canEditItemRemark">
|
||||
<el-button type="primary" @click="emit('edit')"><Pencil :size="16" />{{ preloadSummary?.containerNo ? '修改集装箱号 / 航次' : '录入集装箱号' }}</el-button>
|
||||
<el-button type="success" :loading="confirmSaving" :disabled="!preloadSummary?.readiness.canConfirm" @click="emit('confirmPreload')"><Check :size="16" />确认预装</el-button>
|
||||
<el-button @click="emit('edit')"><Pencil :size="16" />{{ preloadSummary?.containerNo ? '修改集装箱号 / 航次' : '录入集装箱号' }}</el-button>
|
||||
<el-button type="primary" :loading="confirmSaving" :disabled="!preloadSummary?.readiness.canConfirm" @click="emit('confirmPreload')"><Check :size="16" />确认预装</el-button>
|
||||
</template>
|
||||
<template v-else-if="mode === 'confirmed'">
|
||||
<el-button v-if="canManageCustoms && !detail.customsDocument?.completedAt" type="primary" @click="emit('attachCustoms')"><FileUp :size="16" />{{ detail.customsDocument ? '更换报关资料' : '上传报关资料' }}</el-button>
|
||||
@@ -154,10 +154,12 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
|
||||
<div v-else-if="detail && summary" class="detail-layout">
|
||||
<DetailPageLayout :ariaLabel="title">
|
||||
<el-alert v-if="preloadSummary?.readiness.confirmationStatus === 'CONFIRMED'" type="success" :closable="false" show-icon title="整份预装计划已确认" :description="preloadSummary.readiness.pdaTaskStatus ? `PDA 任务状态:${pdaTaskStatusLabels[preloadSummary.readiness.pdaTaskStatus] || '未知状态'}` : 'PDA 前置条件已满足,等待服务端生成任务。'" />
|
||||
<el-alert v-else-if="preloadSummary && !preloadSummary.readiness.canConfirm" type="warning" :closable="false" show-icon title="当前暂不能确认预装" :description="blockingReasonText || '请刷新详情获取最新确认条件。'" />
|
||||
<div v-if="preloadSummary && preloadSummary.readiness.confirmationStatus !== 'CONFIRMED' && !preloadSummary.readiness.canConfirm" class="readiness-note" role="status">
|
||||
<strong>暂不能确认预装:</strong>
|
||||
<span>{{ blockingReasonText || '请刷新详情获取最新确认条件' }}</span>
|
||||
</div>
|
||||
|
||||
<section class="detail-section surface-panel">
|
||||
<section class="detail-section">
|
||||
<DataTable
|
||||
:data="detail.items"
|
||||
:columns="itemColumns"
|
||||
@@ -185,7 +187,7 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
|
||||
<ContainerFeesPanel :plan-id="summary.planId" :container-booking-id="summary.containerBookingId" :customers="feeCustomers" />
|
||||
|
||||
<section class="history-section surface-panel" aria-label="操作历史">
|
||||
<section class="history-section" aria-label="操作历史">
|
||||
<header><h2>操作历史</h2><span>{{ detail.histories.length }} 条</span></header>
|
||||
<el-empty v-if="detail.histories.length === 0" description="暂无操作历史" :image-size="72" />
|
||||
<el-timeline v-else>
|
||||
@@ -204,25 +206,29 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.loading-detail-content, .detail-layout { display: grid; min-width: 0; gap: 16px; }
|
||||
.detail-overview { overflow: hidden; }
|
||||
.detail-page-header { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 16px; padding: 14px 16px; }
|
||||
.loading-detail-content { display: grid; min-width: 0; gap: 16px; padding: 16px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
|
||||
.detail-layout { display: grid; min-width: 0; }
|
||||
.detail-overview { min-width: 0; }
|
||||
.detail-page-header { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 16px; padding: 8px 0 12px; }
|
||||
.detail-page-header__back { margin-right: 2px; }
|
||||
.detail-page-header__identity { min-width: 0; }
|
||||
.detail-page-header__identity h1 { margin: 0; color: var(--cc-text); font-size: 18px; line-height: 1.35; }
|
||||
.detail-page-header__identity p { margin: 3px 0 0; color: var(--cc-muted); font-size: 12px; }
|
||||
.detail-page-header__actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; }
|
||||
.detail-page-header__actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.detail-summary { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); overflow: hidden; border-top: 1px solid var(--cc-border); }
|
||||
.detail-summary > div { display: flex; min-width: 0; min-height: 68px; flex-direction: column; justify-content: center; gap: 5px; padding: 11px 14px; border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); background: #fff; }
|
||||
.detail-summary > div:nth-child(5n) { border-right: 0; }
|
||||
.detail-summary span { color: var(--cc-muted); font-size: 12px; }
|
||||
.detail-summary strong { overflow: hidden; color: var(--cc-text); font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.detail-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px 28px; padding: 10px 0 2px; border-top: 1px solid var(--cc-border); }
|
||||
.detail-summary > div { display: flex; min-width: 0; min-height: 26px; align-items: center; gap: 6px; }
|
||||
.detail-summary > div > span:first-child { flex: 0 0 auto; color: var(--cc-muted); font-size: 12px; white-space: nowrap; }
|
||||
.detail-summary > div > span:first-child::after { content: ":"; }
|
||||
.detail-summary strong { min-width: 0; color: var(--cc-text); font-size: 13px; overflow-wrap: anywhere; }
|
||||
.detail-summary :deep(.status-badge) { width: fit-content; }
|
||||
.detail-summary small { color: var(--cc-muted); font-size: 11px; }
|
||||
.detail-summary__wide { grid-column: span 2; }
|
||||
.detail-layout :deep(.detail-page-layout__content) { display: grid; min-width: 0; gap: 16px; }
|
||||
.detail-section, .history-section { min-width: 0; overflow: hidden; border: 1px solid var(--cc-border); background: #fff; }
|
||||
.detail-summary__wide { grid-column: auto; }
|
||||
.detail-layout :deep(.detail-page-layout__content) { display: grid; min-width: 0; gap: 20px; }
|
||||
.readiness-note { display: flex; min-width: 0; align-items: center; padding: 2px 4px; color: var(--cc-text); font-size: 12px; }
|
||||
.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: 16px 0 0; border-top: 1px solid var(--cc-border); }
|
||||
.detail-section__heading h2 { margin: 0; font-size: 15px; }
|
||||
.detail-section__heading h2 span { margin-left: 6px; color: var(--cc-muted); font-size: 12px; font-weight: 400; }
|
||||
.detail-section__heading p { margin: 2px 0 0; color: var(--cc-muted); font-size: 11px; line-height: 1.35; }
|
||||
@@ -233,7 +239,6 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
.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 { padding: 14px 16px; }
|
||||
.history-section header { display: flex; align-items: baseline; gap: 10px; margin-bottom: 18px; }
|
||||
.history-section h2 { margin: 0; font-size: 16px; }
|
||||
.history-section header span { color: var(--cc-muted); font-size: 12px; }
|
||||
|
||||
@@ -120,7 +120,8 @@ describe('LoadingListsView', () => {
|
||||
expect(detailSource).toContain("PRELOAD_NOT_PUSHED: '尚未推送预装清单'")
|
||||
expect(detailSource).toContain("OCEAN_BOOKING_NOT_RELEASED: '关联订舱尚未放舱'")
|
||||
expect(detailSource).toContain("PRELOAD_ALREADY_CONFIRMED: '整份预装计划已经确认'")
|
||||
expect(detailSource).toContain("confirmationStatus === 'CONFIRMED'")
|
||||
expect(detailSource).toContain("confirmationStatus !== 'CONFIRMED' && !preloadSummary.readiness.canConfirm")
|
||||
expect(detailSource).toContain('confirmationStatusLabels[preloadSummary.readiness.confirmationStatus]')
|
||||
expect(detailSource).toContain("pdaTaskStatusLabels")
|
||||
expect(preloadDetailSource).toContain('@confirm-preload="openConfirmPreload"')
|
||||
})
|
||||
@@ -186,12 +187,18 @@ describe('LoadingListsView', () => {
|
||||
expect(detailSource).not.toContain("emit('editInternalRemark'")
|
||||
})
|
||||
|
||||
it('uses a compact desktop detail header, five-column summary and dense non-wrapping item table', () => {
|
||||
expect(detailSource).toContain('class="detail-overview surface-panel"')
|
||||
it('uses one white detail surface, a readable four-column summary and dense item table', () => {
|
||||
expect(detailSource).toContain('class="detail-overview"')
|
||||
expect(detailSource).not.toContain('class="detail-overview surface-panel"')
|
||||
expect(detailSource).toContain('class="detail-page-header"')
|
||||
expect(detailSource).toContain("@click=\"emit('back')\"")
|
||||
expect(detailSource).toContain('class="detail-page-header__actions"')
|
||||
expect(detailSource).toContain('grid-template-columns: repeat(5, minmax(0, 1fr))')
|
||||
expect(detailSource).toContain('grid-template-columns: repeat(4, minmax(0, 1fr))')
|
||||
expect(detailSource).toContain('min-height: 26px')
|
||||
expect(detailSource).toContain('> div > span:first-child::after { content: ":"; }')
|
||||
expect(detailSource).not.toContain('> div > span::after { content: ":"; }')
|
||||
expect(detailSource).toContain('background: #fff')
|
||||
expect(detailSource).toContain('overflow-wrap: anywhere')
|
||||
expect(detailSource).toContain("className: 'identifier-column'")
|
||||
expect(detailSource).toContain('showOverflowTooltip: true')
|
||||
expect(detailSource).toContain('white-space: nowrap')
|
||||
@@ -202,7 +209,12 @@ describe('LoadingListsView', () => {
|
||||
it('merges the page header and summary while spacing independent business sections', () => {
|
||||
expect(detailSource).toContain('<section v-if="detail && summary" class="detail-summary"')
|
||||
expect(detailSource).not.toContain('class="detail-summary surface-panel"')
|
||||
expect(detailSource).toContain('.detail-layout :deep(.detail-page-layout__content) { display: grid; min-width: 0; gap: 16px; }')
|
||||
expect(detailSource).toContain('.detail-layout :deep(.detail-page-layout__content) { display: grid; min-width: 0; gap: 20px; }')
|
||||
expect(detailSource).toContain('<section class="detail-section">')
|
||||
expect(detailSource).not.toContain('class="detail-section surface-panel"')
|
||||
expect(detailSource).not.toContain('class="history-section surface-panel"')
|
||||
expect(detailSource).toContain('padding: 16px 0 0')
|
||||
expect(detailSource).toContain("fixed: 'left', width: 132")
|
||||
})
|
||||
|
||||
it('does not expose unknown history action codes', () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/pages/PreloadPlanDetailView.vue'), 'utf8')
|
||||
const detailSource = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/LoadingListDetailDrawer.vue'), 'utf8')
|
||||
|
||||
describe('PreloadPlanDetailView prototype contract', () => {
|
||||
it('is a formal desktop detail page wired to the shared header return action', () => {
|
||||
@@ -51,8 +52,18 @@ describe('PreloadPlanDetailView prototype contract', () => {
|
||||
it('is the formal container-number entry and does not imply that preload directly opens PDA', () => {
|
||||
expect(source).toContain("{ required: true, message: '请输入集装箱号'")
|
||||
expect(source).toContain("detail.value?.summary.containerNo ? '修改集装箱号 / 航次' : '录入集装箱号'")
|
||||
expect(source).toContain('预装清单不会直接开放 PDA')
|
||||
expect(source).toContain('订舱关联与放舱、预装确认后')
|
||||
expect(source).toContain('成功后由服务端按真实条件创建唯一 PDA 任务')
|
||||
expect(source).toContain('当前页面不会伪造 PDA 成功')
|
||||
})
|
||||
|
||||
it('uses a flat blocking note instead of repeated colored page alerts', () => {
|
||||
expect(source).not.toContain('<el-alert\n v-if="detail"')
|
||||
expect(detailSource).toContain('class="readiness-note"')
|
||||
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).not.toContain('<el-button type="success" :loading="confirmSaving"')
|
||||
})
|
||||
|
||||
it('confirms the whole plan through readiness with stable idempotency and refreshes the detail', () => {
|
||||
|
||||
@@ -189,14 +189,6 @@ onMounted(() => void loadDetail())
|
||||
<AdminPage>
|
||||
<div class="preload-detail-page">
|
||||
<OperationErrorToast :details="remarkError || editError || confirmError" />
|
||||
<el-alert
|
||||
v-if="detail"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
:title="detail.summary.containerNo ? '集装箱号已录入,继续完成订舱放舱与预装确认' : '下一步:录入集装箱号'"
|
||||
description="预装清单不会直接开放 PDA;需完成集装箱号、订舱关联与放舱、预装确认后,才由服务端生成 PDA 任务。"
|
||||
/>
|
||||
<LoadingListDetailDrawer :mode="'preload'" :loading="loading" :detail="detail" :error="error" :can-edit="canEdit" :confirm-saving="confirmSaving" @back="goBack" @retry="loadDetail" @edit="openEdit" @edit-warehouse-remark="openWarehouseRemark" @confirm-preload="openConfirmPreload" />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -44,13 +44,13 @@ describe('TruckAppointmentForm interaction contract', () => {
|
||||
expect(source).toContain("plannedLoadingAt: [{ required: true, message: '请选择预计装箱时间'")
|
||||
expect(source).toContain("draft.customerName || '历史客户(需重新选择)'")
|
||||
expect(source).toContain(':value="String(draft.customerId)" disabled')
|
||||
expect(source).toContain('props.resource?.oceanBookingNo')
|
||||
expect(source).toContain('props.resource?.cabinetNo')
|
||||
expect(source).not.toContain('props.resource?.containerBookingId].filter')
|
||||
expect(source).not.toContain('const relationText = computed')
|
||||
})
|
||||
|
||||
it('keeps booking, cabinet and loading plan optional and only uses them for completion hints', () => {
|
||||
expect(source).toContain('订舱、柜次和装柜计划均为可选关联,不影响独立开立拖车预约')
|
||||
expect(source).toContain('class="relation-note"')
|
||||
expect(source).not.toContain('class="relation-alert">历史关联快照')
|
||||
expect(source).toContain('<el-divider content-position="left">可选关联</el-divider>')
|
||||
expect(source).toContain('label="关联柜次(可选)"')
|
||||
expect(source).toContain('label="关联订舱(可选)"')
|
||||
|
||||
@@ -38,7 +38,6 @@ const rules: FormRules = {
|
||||
grossWeightKg: [nonNegative('毛重')], volumeCbm: [nonNegative('体积')], payableAmount: [nonNegative('应付拖车费')], receivableAmount: [nonNegative('应收拖车费')],
|
||||
currency: [{ pattern: /^[A-Z]{3,8}$/, message: '币种请输入 3-8 位大写字母(如 RMB)', trigger: 'blur' }],
|
||||
}
|
||||
const relationText = computed(() => [props.resource?.customerName, props.resource?.oceanBookingNo, props.resource?.cabinetNo, props.resource?.containerNo, props.resource?.warehouseName].filter(Boolean).join(' / ') || '无历史关联')
|
||||
const customers = ref<PlatformTenantResource[]>([])
|
||||
const carriers = ref<Supplier[]>([])
|
||||
const containers = ref<ContainerBookingResource[]>([])
|
||||
@@ -122,8 +121,7 @@ watch(() => props.readonly, (readonly) => { if (!readonly && !customers.value.le
|
||||
|
||||
<template>
|
||||
<el-form ref="formRef" :model="draft" :rules="rules" label-position="top" :disabled="readonly || submitting" class="truck-form">
|
||||
<el-alert type="info" :closable="false" class="relation-alert">订舱、柜次和装柜计划均为可选关联,不影响独立开立拖车预约;选择后仅用于补全快照和一致性检查。</el-alert>
|
||||
<el-alert v-if="resource" type="info" :closable="false" class="relation-alert">历史关联快照:{{ relationText }}</el-alert>
|
||||
<p class="relation-note">订舱、柜次和装柜计划均为可选关联,不影响独立开立拖车预约;选择后仅用于补全快照和一致性检查。</p>
|
||||
<el-alert v-if="relationWarning" type="warning" :closable="false" show-icon :title="relationWarning" class="relation-alert" />
|
||||
<el-alert v-if="optionsError" type="warning" :closable="false" show-icon :title="optionsError" class="relation-alert"><el-button link type="warning" :loading="optionsLoading" @click="loadOptions">重新加载选项</el-button></el-alert>
|
||||
<el-row :gutter="16">
|
||||
@@ -169,10 +167,11 @@ watch(() => props.readonly, (readonly) => { if (!readonly && !customers.value.le
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.truck-form { padding-bottom: 12px; }
|
||||
.truck-form { padding-bottom: 4px; }
|
||||
.relation-note { margin:0 0 8px; padding:8px 10px; border-left:3px solid var(--cc-primary); background:var(--el-fill-color-extra-light); color:var(--cc-muted); font-size:12px; line-height:1.5; }
|
||||
.relation-alert { margin-bottom: 16px; }
|
||||
.form-actions { display: flex; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
|
||||
:deep(.el-divider) { margin: 18px 0 12px; }
|
||||
:deep(.el-divider) { margin: 14px 0 10px; }
|
||||
:deep(.el-divider__text) { color: var(--el-text-color-primary); font-size: 14px; font-weight: 600; }
|
||||
:deep(.el-input), :deep(.el-textarea), :deep(.el-date-editor) { max-width: 100%; }
|
||||
.field-hint { margin-top:4px; color:var(--cc-muted); font-size:12px; }
|
||||
|
||||
@@ -5,6 +5,25 @@ import { resolve } from 'node:path'
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/trucking/pages/TruckAppointmentDetailView.vue'), 'utf8')
|
||||
|
||||
describe('TruckAppointmentDetailView status contract', () => {
|
||||
it('merges identity, status and relation snapshot into a compact overview', () => {
|
||||
expect(source).toContain('class="truck-overview surface-panel"')
|
||||
expect(source).toContain('class="truck-overview__heading"')
|
||||
expect(source).toContain('class="truck-overview__identity"')
|
||||
expect(source).toContain('class="truck-overview__status"')
|
||||
expect(source).toContain('class="relation-snapshot"')
|
||||
expect(source).toContain("const relationSnapshot = computed(() => resource.value ?")
|
||||
expect(source).not.toContain('class="surface-panel status-panel"')
|
||||
expect(source).not.toContain('class="status-bar"')
|
||||
expect(source).not.toContain('class="detail-identity"')
|
||||
expect(source).not.toContain('PageToolbar')
|
||||
})
|
||||
|
||||
it('spaces independent form and history sections without nested overview cards', () => {
|
||||
expect(source).toContain('.truck-detail-page :deep(.detail-page-layout__content) { display:grid; min-width:0; gap:16px; }')
|
||||
expect(source).toContain('.form-panel { padding:16px; }')
|
||||
expect(source).toContain('.history-section { padding:16px; }')
|
||||
})
|
||||
|
||||
it('matches the backend edit lifecycle and avoids a self-transition action', () => {
|
||||
expect(source).toContain('const targets: TruckAppointmentStatus[] =')
|
||||
expect(source).not.toContain('const targets: string[] =')
|
||||
|
||||
@@ -7,7 +7,6 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
import PageToolbar from '@/shared/components/PageToolbar.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getTruckAppointment, listTruckAppointmentAttachments, listTruckAppointmentHistory, performTruckAppointmentAction, rescheduleTruckAppointment, updateTruckAppointment, type TruckAppointmentAction } from '../api/platformTrucking'
|
||||
@@ -17,6 +16,7 @@ import { truckAppointmentActionLabel, truckAppointmentStatusLabel, truckAppointm
|
||||
const route = useRoute(); const router = useRouter(); const loading = ref(false); const saving = ref(false); const statusSaving = ref(false); const attachmentOpen = ref(false); const resource = ref<TruckAppointmentResource | null>(null); const error = ref<ApiErrorDetails | null>(null); const form = reactive<SaveTruckAppointmentRequest>({ expectedVersion: 0, customerId: null, carrierId: null, plannedLoadingAt: null })
|
||||
const history = ref<TruckAppointmentStatusHistory[]>([])
|
||||
const access = useAccessPreviewStore(); const editing = computed(() => route.query.edit === '1'); const id = computed(() => String(route.params.id)); const canEdit = computed(() => access.can('platform:firstleg:truck-appointment:edit') && ['DRAFT', 'PENDING_DISPATCH', 'RESCHEDULED'].includes(resource.value?.status || '') && editing.value); const canChangeStatus = computed(() => access.can('platform:firstleg:truck-appointment:operate')); const canManageAttachments = computed(() => access.can('platform:firstleg:truck-appointment:edit'))
|
||||
const relationSnapshot = computed(() => resource.value ? [resource.value.customerName, resource.value.oceanBookingNo, resource.value.cabinetNo, resource.value.containerNo, resource.value.warehouseName].filter(Boolean).join(' / ') || '无历史关联' : '')
|
||||
async function loadRelated() { if (!resource.value) return; const [attachments, histories] = await Promise.all([listTruckAppointmentAttachments(id.value), listTruckAppointmentHistory(id.value)]); resource.value.attachments = attachments; history.value = histories }
|
||||
async function load() { loading.value = true; error.value = null; try { const result = await getTruckAppointment(id.value); resource.value = result; Object.assign(form, { ...result, expectedVersion: result.version ?? 0 }); await loadRelated() } catch (err) { error.value = getApiErrorDetails(err, '详情加载失败,请稍后重试') } finally { loading.value = false } }
|
||||
async function submit() { saving.value = true; error.value = null; try { await updateTruckAppointment(id.value, form); ElMessage.success('拖车预约已保存'); await load() } catch (err) { error.value = getApiErrorDetails(err, '保存失败,请稍后重试') } finally { saving.value = false } }
|
||||
@@ -46,14 +46,22 @@ onMounted(load)
|
||||
<AdminPage as="div" class="truck-detail-page">
|
||||
<DetailPageLayout aria-label="拖车预约详情">
|
||||
<template #header>
|
||||
<div class="detail-heading">
|
||||
<el-button @click="router.push({ name: 'trucking' })"><ArrowLeft :size="16" />返回列表</el-button>
|
||||
<PageToolbar v-if="resource" class="truck-page-toolbar" as="section" :title="resource.appointmentNo || '拖车预约'" :description="[resource.customerName, resource.orderNo].filter(Boolean).join(' · ')" />
|
||||
</div>
|
||||
<section class="truck-overview surface-panel" aria-label="拖车预约概览">
|
||||
<div class="truck-overview__heading">
|
||||
<el-button link type="primary" @click="router.push({ name: 'trucking' })"><ArrowLeft :size="16" />返回列表</el-button>
|
||||
<div class="truck-overview__identity"><h1>{{ resource?.appointmentNo || '拖车预约详情' }}</h1><p>{{ resource ? [resource.customerName, resource.orderNo].filter(Boolean).join(' · ') || '客户信息缺失' : '查看拖车预约详情' }}</p></div>
|
||||
<span v-if="resource" class="truck-overview__status">当前状态:<strong>{{ truckAppointmentStatusLabel(resource.status) }}</strong></span>
|
||||
<div v-if="resource && canChangeStatus" class="status-actions"><el-button v-for="status in availableTargets" :key="status" size="small" :type="status === 'CANCELLED' ? 'danger' : status === 'EXCEPTION' ? 'warning' : 'primary'" plain :loading="statusSaving" @click="changeStatus(status)">{{ truckAppointmentStatusLabels[status] }}</el-button></div>
|
||||
</div>
|
||||
<template v-if="resource">
|
||||
<p class="relation-snapshot"><strong>历史关联快照:</strong>{{ relationSnapshot }}</p>
|
||||
<el-alert v-if="dispatchBlockReason" :title="dispatchBlockReason" type="warning" :closable="false" show-icon />
|
||||
<ApiErrorMeta v-if="error" :details="error" />
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
<LoadErrorState v-if="error && !resource" title="详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<template v-else-if="resource">
|
||||
<section class="surface-panel status-panel"><div class="status-bar"><span>当前状态:<strong>{{ truckAppointmentStatusLabel(resource.status) }}</strong></span><div v-if="canChangeStatus" class="status-actions"><el-button v-for="status in availableTargets" :key="status" :type="status === 'CANCELLED' ? 'danger' : status === 'EXCEPTION' ? 'warning' : 'primary'" plain :loading="statusSaving" @click="changeStatus(status)">{{ truckAppointmentStatusLabels[status] }}</el-button></div></div><el-alert v-if="dispatchBlockReason" :title="dispatchBlockReason" type="warning" :closable="false" show-icon /><ApiErrorMeta v-if="error" :details="error" /></section>
|
||||
<section class="surface-panel form-panel"><TruckAppointmentForm v-model="form" :resource="resource" :readonly="!canEdit" :submitting="saving" @submit="submit" @attachments="attachmentOpen = true" /></section>
|
||||
<section class="surface-panel history-section"><h3>状态历史</h3><el-empty v-if="!history.length" description="暂无状态历史" :image-size="48" /><ol v-else><li v-for="(item, index) in history" :key="item.id || `${item.createdAt}-${index}`"><strong>{{ truckAppointmentActionLabel(item.action) }}</strong><span>{{ truckAppointmentStatusLabel(item.fromStatus) }} → {{ truckAppointmentStatusLabel(item.toStatus) }}</span><small>{{ item.operatorName || item.operatorId || '系统' }} · {{ item.createdAt || item.occurredAt || '—' }}<template v-if="item.reason"> · {{ item.reason }}</template></small></li></ol></section>
|
||||
<TruckAttachmentDialog v-model="attachmentOpen" :appointment-id="id" :attachments="resource.attachments || []" :can-manage="canManageAttachments" @changed="loadRelated" />
|
||||
@@ -62,9 +70,12 @@ onMounted(load)
|
||||
</AdminPage>
|
||||
</template>
|
||||
<style scoped>
|
||||
.status-bar { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; margin-bottom:16px; padding:12px; background:var(--el-fill-color-light); border-radius:6px; }
|
||||
.truck-overview { overflow:hidden; }
|
||||
.truck-overview__heading { display:grid; grid-template-columns:auto minmax(260px, 1fr) auto auto; align-items:center; gap:12px 18px; padding:12px 16px; }
|
||||
.truck-overview__identity { min-width:0; }.truck-overview__identity h1 { margin:0; overflow:hidden; color:var(--cc-text); font-size:18px; line-height:1.35; text-overflow:ellipsis; white-space:nowrap; }.truck-overview__identity p { margin:2px 0 0; overflow:hidden; color:var(--cc-muted); font-size:12px; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.truck-overview__status { font-size:13px; white-space:nowrap; }.truck-overview__status strong { margin-left:4px; font-size:15px; }
|
||||
.status-actions { display:flex; flex-wrap:wrap; gap:8px; }.status-actions :deep(.el-button) { margin-left:0; }
|
||||
.truck-detail-page { min-width:0; }.detail-heading { display:flex; min-width:0; align-items:flex-start; gap:16px; }.truck-page-toolbar { min-width:0; flex:1; }.status-panel { padding:12px 16px; }.form-panel { padding:24px; }.history-section { padding:20px; }.history-section h3 { margin:0 0 12px; font-size:15px; }.history-section ol { display:grid; gap:12px; margin:0; padding:0; list-style:none; }.history-section li { display:grid; grid-template-columns:120px minmax(120px, 1fr) minmax(200px, 2fr); gap:12px; padding:10px 12px; border-radius:6px; background:var(--el-fill-color-lighter); }.history-section small { color:var(--cc-muted); overflow-wrap:anywhere; }
|
||||
@media (max-width: 720px) { .detail-heading { flex-direction:column; }.truck-page-toolbar { width:100%; }.form-panel { padding:16px; } }
|
||||
.relation-snapshot { margin:0; padding:9px 16px; border-top:1px solid var(--cc-border); color:var(--cc-muted); font-size:12px; line-height:1.5; }.relation-snapshot strong { margin-right:4px; color:var(--cc-text); }
|
||||
.truck-detail-page { min-width:0; }.truck-detail-page :deep(.detail-page-layout__content) { display:grid; min-width:0; gap:16px; }.form-panel { padding:16px; }.history-section { padding:16px; }.history-section h3 { margin:0 0 12px; font-size:15px; }.history-section ol { display:grid; gap:10px; margin:0; padding:0; list-style:none; }.history-section li { display:grid; grid-template-columns:120px minmax(120px, 1fr) minmax(200px, 2fr); gap:12px; padding:9px 12px; border-radius:6px; background:var(--el-fill-color-lighter); }.history-section small { color:var(--cc-muted); overflow-wrap:anywhere; }
|
||||
@media (max-width: 640px) { .history-section { padding:16px; }.history-section li { grid-template-columns:1fr; gap:4px; } }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user