优化
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
|
||||
1. 用“模块快速路由”选择最小业务模块,进入后读取该目录最近的 `AGENTS.md`;按 `routes.ts → pages/ → api/types/stores/composables` 下钻,不先扫描全部 `src/`。
|
||||
2. 页面、布局或交互任务同时读取 `../../docs/Admin-Client前端原型还原验收规则.md` 和后台原型 `../../docs/悦/货代SAAS平台后台/AGENTS.md`。
|
||||
订单样板、国内仓和客户管理样式还须读取 `../../docs/Admin高密度界面规范.md`,包含“样式维护边界”;以其已确认密度与彩色页签为视觉基线,逐操作保留业务语义和承载方式,公共样式按已确认菜单范围接入。
|
||||
Admin 样式优化还须读取 `../../docs/Admin高密度界面规范.md` 的“样式维护边界”和“新模块接入规则”;以已确认密度与彩色页签为视觉基线,逐操作保留业务语义和承载方式。国内仓、客户管理是样板;新增菜单按本轮范围显式接入,货柜管理入口见 `../../docs/Admin货柜管理样式核对记录.md`。
|
||||
3. 标准列表或公共组件任务读取 `src/shared/components/AGENTS.md`;国内/海外 HTTP、Token 或刷新任务读取 `src/shared/api/AGENTS.md`;Finance 任务读取 `src/modules/finance/AGENTS.md`;海外仓全流程任务先读 `../../docs/海外仓业务/README.md`。
|
||||
4. 平台/客户层级与权限公共语义读取 `../../docs/平台客户层级与权限契约.md`;双区域 API、海外 Token 和跨区事件读取 `../../docs/跨区域认证与服务通信契约.md`。
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { ref, watch } from 'vue'
|
||||
import { CircleCheck, CircleX, ScrollText } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
@@ -125,14 +126,14 @@ watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) =>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="customer-audit-panel">
|
||||
<section class="customer-audit-panel admin-density">
|
||||
<div class="panel-title"><div><h2>操作日志</h2><p>{{ customerName }}已归属当前客户的档案与业务操作记录</p></div><ScrollText :size="19" /></div>
|
||||
<div v-if="!access.can('platform:audit:list')" class="module-empty"><ScrollText :size="30" /><strong>暂无操作日志查看权限</strong><p>请联系管理员开通平台操作日志查看权限。</p></div>
|
||||
<template v-else>
|
||||
<QueryToolbar v-model="filters" class="audit-query" ariaLabel="客户操作日志筛选" :fields="queryFields" preference-key="customer-audit-logs-query" @search="applyFilters" />
|
||||
<LoadErrorState v-if="loadError" title="客户操作日志加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<DataTable v-else v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="id" aria-label="客户操作日志列表" empty-text="当前客户暂无操作日志" preference-key="customer-audit-logs" border stripe>
|
||||
<template #actions="{ row }"><el-button link type="primary" :aria-label="`查看操作日志${(row as PlatformAuditLogListItem).id}详情`" @click="openDetail(row as PlatformAuditLogListItem)">详情</el-button></template>
|
||||
<template #actions="{ row }"><el-button class="density-action" size="small" :aria-label="`查看操作日志${(row as PlatformAuditLogListItem).id}详情`" @click="openDetail(row as PlatformAuditLogListItem)">详情</el-button></template>
|
||||
<template #description="{ row }"><el-tooltip :content="auditDescriptionLabel((row as PlatformAuditLogListItem).description)" placement="top" :show-after="400"><span class="cell-clamp">{{ auditDescriptionLabel((row as PlatformAuditLogListItem).description) }}</span></el-tooltip></template>
|
||||
<template #clientIp="{ row }">{{ display((row as PlatformAuditLogListItem).clientIp) }}</template>
|
||||
<template #eventType="{ row }"><el-tooltip :content="(row as PlatformAuditLogListItem).eventType" placement="top" :show-after="500"><span class="event-label">{{ auditEventLabel((row as PlatformAuditLogListItem).eventType) }}</span></el-tooltip></template>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { Download, FileUp } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
|
||||
import { createPlatformExportTask } from '@/modules/downloads'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
@@ -64,7 +64,7 @@ async function load() {
|
||||
async function start() {
|
||||
if (!customs.value || customs.value.status !== 'READY_TO_START') return
|
||||
try {
|
||||
await ElMessageBox.confirm('开始后将冻结客户当前退税资料版本、关联入仓码覆盖范围及平台买单准备事实,确认开始整柜报关?', '开始整柜报关', { type: 'warning', confirmButtonText: '确认开始' })
|
||||
await ElMessageBox.confirm('开始后将冻结客户当前退税资料版本、关联入仓码覆盖范围及平台买单准备事实,确认开始整柜报关?', '开始整柜报关', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认开始' })
|
||||
} catch { return }
|
||||
actionLoading.value = true; actionError.value = null
|
||||
try { customs.value = await startContainerCustoms(props.loadingPlanId, customs.value.version); ElMessage.success('整柜报关已开始') }
|
||||
@@ -79,7 +79,7 @@ async function uploadCompletion(document: ContainerCustomsDocument) {
|
||||
const finalConfirmation = !document.completion && missingCompletionCount.value === 1
|
||||
if (finalConfirmation) {
|
||||
try {
|
||||
await ElMessageBox.confirm('这是最后一份待上传的报关完成文件;确认后系统将自动完成当前混合/退税柜报关。是否继续?', '完成整柜报关', { type: 'warning', confirmButtonText: '确认上传并完成' })
|
||||
await ElMessageBox.confirm('这是最后一份待上传的报关完成文件;确认后系统将自动完成当前混合/退税柜报关。是否继续?', '完成整柜报关', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认上传并完成' })
|
||||
} catch { return }
|
||||
}
|
||||
actionLoading.value = true; actionError.value = null
|
||||
@@ -123,7 +123,7 @@ async function downloadFile(document: ContainerCustomsDocument, kind: 'source' |
|
||||
|
||||
async function completeAllBuy() {
|
||||
if (!customs.value || customs.value.status !== 'DECLARING' || !allBuy.value) return
|
||||
try { await ElMessageBox.confirm('确认全买单柜报关已经完成?报关完成不等同于国内离仓。', '完成全买单柜报关', { type: 'warning', confirmButtonText: '确认完成' }) }
|
||||
try { await ElMessageBox.confirm('确认全买单柜报关已经完成?报关完成不等同于国内离仓。', '完成全买单柜报关', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认完成' }) }
|
||||
catch { return }
|
||||
actionLoading.value = true; actionError.value = null
|
||||
try {
|
||||
@@ -151,7 +151,7 @@ onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="container-customs surface-panel" aria-label="整柜报关">
|
||||
<section class="container-customs surface-panel admin-density" aria-label="整柜报关">
|
||||
<OperationErrorToast :details="actionError" />
|
||||
<header class="customs-header">
|
||||
<div><h2>整柜报关</h2><p>客户退税资料与平台买单事实按柜冻结;报关完成和国内离仓分别展示。</p></div>
|
||||
@@ -171,15 +171,15 @@ onMounted(load)
|
||||
<div class="customs-metrics"><span>退税总票 <strong>{{ customs.taxRefundCount }}</strong></span><span>已覆盖 <strong>{{ customs.coveredTaxRefundCount }}</strong></span><span :class="{ danger: missingCoverageCount }">缺失 <strong>{{ missingCoverageCount }}</strong></span></div>
|
||||
<div v-if="missingInbounds.length" class="compact-table-wrap">
|
||||
<h3>缺退税资料清单</h3>
|
||||
<table class="compact-table"><thead><tr><th>客户</th><th>订单号</th><th>入仓码</th></tr></thead><tbody><tr v-for="item in missingInbounds" :key="item.inboundId"><td>{{ item.customerName }}</td><td>{{ item.orderNo }}</td><td>{{ item.inboundCode }}</td></tr></tbody></table>
|
||||
<table class="compact-table density-table"><thead><tr><th>客户</th><th>订单号</th><th>入仓码</th></tr></thead><tbody><tr v-for="item in missingInbounds" :key="item.inboundId"><td>{{ item.customerName }}</td><td>{{ item.orderNo }}</td><td>{{ item.inboundCode }}</td></tr></tbody></table>
|
||||
</div>
|
||||
<div v-if="customs.documents.length" class="compact-table-wrap">
|
||||
<h3>已冻结退税资料与报关完成文件</h3>
|
||||
<table class="compact-table document-table"><thead><tr><th>操作</th><th>客户退税资料</th><th>报关完成文件</th><th>关联入仓码</th><th>版本</th><th>客户</th><th>完成情况</th></tr></thead><tbody>
|
||||
<table class="compact-table document-table density-table"><thead><tr><th>操作</th><th>客户退税资料</th><th>报关完成文件</th><th>关联入仓码</th><th>版本</th><th>客户</th><th>完成情况</th></tr></thead><tbody>
|
||||
<tr v-for="document in customs.documents" :key="document.documentId">
|
||||
<td><div v-if="canManage && customs.status === 'DECLARING'" class="document-actions"><el-upload :auto-upload="false" :limit="1" :show-file-list="false" :on-change="(upload: UploadFile) => selectCompletion(document.documentId, upload)"><el-button size="small"><FileUp :size="14" />{{ document.completion ? '覆盖报关完成文件' : '上传报关完成文件' }}</el-button></el-upload><el-button size="small" type="primary" :disabled="!completionFiles[document.documentId]" :loading="actionLoading" @click="uploadCompletion(document)">提交</el-button></div><span v-else class="readonly-label">只读</span></td>
|
||||
<td><div class="file-cell"><span :title="document.source.fileName">{{ document.source.fileName }}</span><el-button link type="primary" :loading="downloadingKey === `source:${document.documentId}`" :aria-label="`下载客户退税资料 ${document.source.fileName}`" @click="downloadFile(document, 'source')"><Download :size="14" />下载</el-button></div></td>
|
||||
<td><div v-if="document.completion" class="file-cell"><span :title="document.completion.fileName">{{ document.completion.fileName }}</span><el-button link type="primary" :loading="downloadingKey === `completion:${document.documentId}`" :aria-label="`下载报关完成文件 ${document.completion.fileName}`" @click="downloadFile(document, 'completion')"><Download :size="14" />下载</el-button></div><span v-else>未上传</span></td>
|
||||
<td><div v-if="canManage && customs.status === 'DECLARING'" class="document-actions density-actions"><el-upload :auto-upload="false" :limit="1" :show-file-list="false" :on-change="(upload: UploadFile) => selectCompletion(document.documentId, upload)"><el-button class="density-action" size="small">{{ document.completion ? '覆盖报关完成文件' : '上传报关完成文件' }}</el-button></el-upload><el-button data-tone="primary" class="density-action" size="small" type="primary" :disabled="!completionFiles[document.documentId]" :loading="actionLoading" @click="uploadCompletion(document)">提交</el-button></div><span v-else class="readonly-label">只读</span></td>
|
||||
<td><div class="file-cell"><span :title="document.source.fileName">{{ document.source.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `source:${document.documentId}`" :aria-label="`下载客户退税资料 ${document.source.fileName}`" @click="downloadFile(document, 'source')">下载</el-button></div></td>
|
||||
<td><div v-if="document.completion" class="file-cell"><span :title="document.completion.fileName">{{ document.completion.fileName }}</span><el-button class="density-action" type="primary" :loading="downloadingKey === `completion:${document.documentId}`" :aria-label="`下载报关完成文件 ${document.completion.fileName}`" @click="downloadFile(document, 'completion')">下载</el-button></div><span v-else>未上传</span></td>
|
||||
<td>{{ document.inboundCodes.join('、') || '—' }}</td><td>退税资料 v{{ document.source.version }}<span v-if="document.completion"> / 报关完成 v{{ document.completion.version }}</span></td><td>{{ documentCustomers(document) }}</td><td>{{ document.completion ? '已上传报关完成文件' : '待上传报关完成文件' }}</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
@@ -192,5 +192,5 @@ onMounted(load)
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container-customs{padding:12px 16px}.customs-header{display:flex;align-items:center;justify-content:space-between;gap:12px}.customs-header h2,.compact-table-wrap h3{margin:0;font-size:15px}.customs-header p{margin:3px 0 0;color:var(--cc-muted);font-size:12px}.customs-actions{display:flex;align-items:center;gap:8px}.action-error{margin-top:8px;padding:8px 10px;border:1px solid var(--el-color-danger-light-5);border-radius:4px;background:var(--el-color-danger-light-9);color:var(--el-color-danger);font-size:12px}.action-error :deep(.api-error-meta){margin-top:5px}.customs-metrics{display:flex;gap:20px;margin-top:10px;padding:8px 10px;background:var(--cc-surface-muted);font-size:12px}.customs-metrics strong{margin-left:4px;font-size:16px}.customs-metrics .danger strong{color:var(--el-color-danger)}.compact-table-wrap{margin-top:10px}.compact-table-wrap h3{margin-bottom:6px}.compact-table{width:100%;border-collapse:collapse;font-size:12px}.compact-table th,.compact-table td{padding:7px 9px;border:1px solid var(--cc-border);text-align:left;vertical-align:middle}.compact-table th{background:var(--cc-surface-muted);color:var(--cc-muted)}.document-table th:first-child{width:200px}.document-actions,.file-cell{display:flex;align-items:center;gap:6px}.file-cell span{display:block;max-width:210px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.readonly-label{color:var(--cc-muted)}.departure-state{display:flex;align-items:center;gap:8px;margin-top:10px;padding-top:10px;border-top:1px solid var(--cc-border);font-size:13px}
|
||||
.container-customs{padding:0;border:0}.customs-header{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-5)}.customs-header h2,.compact-table-wrap h3{margin:0;font-size:var(--admin-font-section)}.customs-header p{margin:3px 0 0;color:var(--cc-muted);font-size:var(--admin-font-caption)}.customs-actions{display:flex;align-items:center;gap:var(--admin-space-3)}.action-error{margin-top:var(--admin-space-3);padding:var(--admin-space-3) var(--admin-space-4);border:1px solid var(--el-color-danger-light-5);border-radius:var(--admin-radius);background:var(--el-color-danger-light-9);color:var(--el-color-danger);font-size:var(--admin-font-caption)}.action-error :deep(.api-error-meta){margin-top:5px}.customs-metrics{display:flex;gap:var(--admin-space-6);margin-top:var(--admin-space-4);padding:var(--admin-space-3) var(--admin-space-4);background:var(--cc-surface-muted);font-size:var(--admin-font-caption)}.customs-metrics strong{margin-left:var(--admin-space-1);font-size:var(--admin-font-body)}.customs-metrics .danger strong{color:var(--el-color-danger)}.compact-table-wrap{margin-top:var(--admin-space-4)}.compact-table-wrap h3{margin-bottom:var(--admin-space-2)}.compact-table{width:100%}.document-table th:first-child{width:270px}.document-actions,.file-cell{display:flex;align-items:center;gap:var(--admin-space-2)}.file-cell span{display:block;max-width:210px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.readonly-label{color:var(--cc-muted)}.departure-state{display:flex;align-items:center;gap:var(--admin-space-3);margin-top:var(--admin-space-4);padding-top:var(--admin-space-4);border-top:1px solid var(--cc-border);font-size:var(--admin-font-body)}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getFinancePaymentVoucher, downloadFinancePaymentVoucher } from '@/modules/finance'
|
||||
@@ -143,31 +144,31 @@ async function uploadVoucher(event: Event) {
|
||||
}
|
||||
}
|
||||
async function submit(row: OverseasContainerFeeSource) {
|
||||
await ElMessageBox.confirm(`确认提交“${row.feeItemName}”审核?`, '提交费用', { type: 'warning' })
|
||||
await ElMessageBox.confirm(`确认提交“${row.feeItemName}”审核?`, '提交费用', { customClass: 'admin-density-confirm', type: 'warning' })
|
||||
await mutate(() => submitOverseasContainerFee(row.sourceFeeId, key('container-fee-submit')), '费用已提交审核')
|
||||
}
|
||||
async function review(row: OverseasContainerFeeSource, decision: 'APPROVE' | 'REJECT') {
|
||||
let reason: string | null = null
|
||||
if (decision === 'REJECT') { const result = await ElMessageBox.prompt('请输入驳回原因', '驳回费用', { inputPattern: /\S+/, inputErrorMessage: '请输入驳回原因', type: 'warning' }); reason = result.value.trim() }
|
||||
else await ElMessageBox.confirm(`确认审核通过“${row.feeItemName}”?`, '审核费用', { type: 'warning' })
|
||||
if (decision === 'REJECT') { const result = await ElMessageBox.prompt('请输入驳回原因', '驳回费用', { customClass: 'admin-density-confirm', inputPattern: /\S+/, inputErrorMessage: '请输入驳回原因', type: 'warning' }); reason = result.value.trim() }
|
||||
else await ElMessageBox.confirm(`确认审核通过“${row.feeItemName}”?`, '审核费用', { customClass: 'admin-density-confirm', type: 'warning' })
|
||||
await mutate(() => reviewOverseasContainerFee(row.sourceFeeId, decision, reason, key('container-fee-review')), decision === 'APPROVE' ? '费用已审核通过' : '费用已驳回')
|
||||
}
|
||||
async function voidFee(row: OverseasContainerFeeSource) {
|
||||
const result = await ElMessageBox.prompt('请输入作废原因', '作废费用', { inputPattern: /\S+/, inputErrorMessage: '请输入作废原因', type: 'warning' })
|
||||
const result = await ElMessageBox.prompt('请输入作废原因', '作废费用', { customClass: 'admin-density-confirm', inputPattern: /\S+/, inputErrorMessage: '请输入作废原因', type: 'warning' })
|
||||
await mutate(() => voidOverseasContainerFee(row.sourceFeeId, row.sourceVersion, result.value.trim(), key('container-fee-void')), '费用已作废')
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="last-leg-fees">
|
||||
<div class="last-leg-fees admin-density">
|
||||
<header class="last-leg-fees__header"><div><h2>{{ title }}</h2><p>按真实货柜展示港口、堆存、修箱、吊机和码头拖车费用,金额按币种分别列示。</p></div><el-button v-if="canEdit" type="primary" :disabled="!containerNo || customers.length === 0" @click="openCreate">新增费用</el-button></header>
|
||||
<el-alert v-if="canView && !canEdit && !canReview" type="info" :closable="false" title="当前账号仅有海外段费用查看权限。" />
|
||||
<el-alert v-if="!containerNo" type="warning" :closable="false" title="当前货柜尚未补录真实集装箱号,暂不能新增尾程费用。" />
|
||||
<el-alert v-if="!canView" type="warning" :closable="false" title="当前账号无权查看尾程计费。" />
|
||||
<LoadErrorState v-else-if="error" title="尾程计费加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<el-table v-else v-loading="loading" :data="rows" empty-text="当前货柜暂无尾程费用">
|
||||
<el-table-column label="操作" fixed="left" width="240"><template #default="{ row }"><div class="row-actions"><el-button v-if="canEdit && row.sourceStatus === 'ACTIVE' && ['DRAFT', 'REJECTED'].includes(row.reviewStatus)" link type="primary" @click="submit(row as OverseasContainerFeeSource)">提交</el-button><el-button v-if="canReview && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'PENDING'" link type="success" @click="review(row as OverseasContainerFeeSource, 'APPROVE')">通过</el-button><el-button v-if="canReview && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'PENDING'" link type="danger" @click="review(row as OverseasContainerFeeSource, 'REJECT')">驳回</el-button><el-button v-if="canEdit && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'APPROVED'" link type="primary" @click="openAdjust(row as OverseasContainerFeeSource)">调整</el-button><el-button v-if="canReview && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'APPROVED'" link type="danger" @click="voidFee(row as OverseasContainerFeeSource)">作废</el-button><span v-if="row.sourceStatus !== 'ACTIVE' || (!canEdit && !canReview)">—</span></div></template></el-table-column>
|
||||
<el-table-column label="操作" fixed="left" width="240"><template #default="{ row }"><div class="row-actions density-actions"><el-button data-tone="primary" class="density-action" v-if="canEdit && row.sourceStatus === 'ACTIVE' && ['DRAFT', 'REJECTED'].includes(row.reviewStatus)" type="primary" @click="submit(row as OverseasContainerFeeSource)">提交</el-button><el-button data-tone="warning" class="density-action" v-if="canReview && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'PENDING'" type="success" @click="review(row as OverseasContainerFeeSource, 'APPROVE')">通过</el-button><el-button data-tone="danger" class="density-action" v-if="canReview && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'PENDING'" type="danger" @click="review(row as OverseasContainerFeeSource, 'REJECT')">驳回</el-button><el-button class="density-action" v-if="canEdit && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'APPROVED'" type="primary" @click="openAdjust(row as OverseasContainerFeeSource)">调整</el-button><el-button data-tone="danger" class="density-action" v-if="canReview && row.sourceStatus === 'ACTIVE' && row.reviewStatus === 'APPROVED'" type="danger" @click="voidFee(row as OverseasContainerFeeSource)">作废</el-button><span v-if="row.sourceStatus !== 'ACTIVE' || (!canEdit && !canReview)">—</span></div></template></el-table-column>
|
||||
<el-table-column prop="customerName" label="客户" min-width="160" show-overflow-tooltip><template #default="{ row }">{{ row.customerName || '—' }}</template></el-table-column>
|
||||
<el-table-column prop="feeItemCode" label="费用项目" min-width="140" show-overflow-tooltip><template #default="{ row }">{{ feeItemLabel(row.feeItemCode) }}</template></el-table-column>
|
||||
<el-table-column prop="direction" label="方向" width="100"><template #default="{ row }">{{ row.direction === 'RECEIVABLE' ? '应收' : '应付' }}</template></el-table-column>
|
||||
@@ -178,10 +179,10 @@ onMounted(load)
|
||||
<el-table-column prop="reviewStatus" label="审核状态" width="110"><template #default="{ row }"><el-tag>{{ reviewLabels[row.reviewStatus] || row.reviewStatus }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="reviewReason" label="审核说明" min-width="180" show-overflow-tooltip><template #default="{ row }">{{ row.reviewReason || '—' }}</template></el-table-column>
|
||||
</el-table>
|
||||
<AppDialog v-model="dialogOpen" :title="editing ? '调整尾程费用' : '新增尾程费用'" width="min(620px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="actionLoading" :confirm-disabled="voucherUploading" @confirm="save">
|
||||
<el-form label-position="top"><div class="fee-form-grid"><el-form-item label="客户" required><el-select v-model="form.customerId" :disabled="Boolean(editing) || voucherUploading" placeholder="请选择柜内客户"><el-option v-for="customer in customers" :key="customer.customerId" :label="customer.customerName || '未命名客户'" :value="customer.customerId" /></el-select></el-form-item><el-form-item label="费用项目" required><el-select v-model="form.feeItemCode"><el-option v-for="item in feeItems" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item><el-form-item label="费用方向" required><el-select v-model="form.direction"><el-option label="应收" value="RECEIVABLE" /><el-option label="应付" value="PAYABLE" /></el-select></el-form-item><el-form-item label="业务日期" required><el-date-picker v-model="form.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="金额(CNY)" required><el-input-number v-model="form.amount" :min="0.01" :precision="2" :step="10" placeholder="请输入费用金额" /></el-form-item><template v-if="form.direction === 'PAYABLE'"><el-form-item label="供应商" required><el-select v-model="form.supplierId" filterable placeholder="请选择有效供应商"><el-option v-for="supplier in suppliers" :key="supplier.id" :label="supplier.supplierName" :value="supplier.id" /></el-select></el-form-item><el-form-item label="结算方式" required><el-select v-model="form.settlementMode"><el-option label="账期结算" value="ON_ACCOUNT" /><el-option label="直接已付" value="DIRECT_PAID" /></el-select></el-form-item><template v-if="form.settlementMode === 'DIRECT_PAID'"><el-form-item label="付款时间" required><el-date-picker v-model="form.paidAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="付款方式" required><el-select v-model="form.paymentMethod"><el-option label="线上支付" value="ONLINE" /><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item><el-form-item label="付款流水号"><el-input v-model="form.paymentReferenceNo" maxlength="128" placeholder="请输入付款流水号(可选)" /></el-form-item><el-form-item label="付款凭证"><div class="voucher-list"><input ref="voucherInput" type="file" accept="image/*" hidden @change="uploadVoucher" /><el-button :loading="voucherUploading" :disabled="!form.customerId || actionLoading" @click="voucherInput?.click()">{{ form.paymentVoucherFileIds.length ? '重新上传凭证' : '上传付款凭证' }}</el-button><div v-for="(fileId, index) in form.paymentVoucherFileIds" :key="fileId" class="voucher-item"><el-button link type="primary" @click="previewVoucher(form.customerId, fileId)">{{ voucherNames[fileId] || `付款凭证 ${index + 1}` }}</el-button><el-button link type="danger" :disabled="voucherUploading || actionLoading" @click="form.paymentVoucherFileIds = form.paymentVoucherFileIds.filter((id) => id !== fileId)">移除</el-button></div></div></el-form-item></template></template></div></el-form>
|
||||
<AppDialog class="admin-density-dialog" v-model="dialogOpen" :title="editing ? '调整尾程费用' : '新增尾程费用'" width="min(620px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="actionLoading" :confirm-disabled="voucherUploading" @confirm="save">
|
||||
<el-form class="density-form" label-position="top"><div class="fee-form-grid"><el-form-item label="客户" required><el-select v-model="form.customerId" :disabled="Boolean(editing) || voucherUploading" placeholder="请选择柜内客户"><el-option v-for="customer in customers" :key="customer.customerId" :label="customer.customerName || '未命名客户'" :value="customer.customerId" /></el-select></el-form-item><el-form-item label="费用项目" required><el-select v-model="form.feeItemCode"><el-option v-for="item in feeItems" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item><el-form-item label="费用方向" required><el-select v-model="form.direction"><el-option label="应收" value="RECEIVABLE" /><el-option label="应付" value="PAYABLE" /></el-select></el-form-item><el-form-item label="业务日期" required><el-date-picker v-model="form.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="金额(CNY)" required><el-input-number v-model="form.amount" :min="0.01" :precision="2" :step="10" placeholder="请输入费用金额" /></el-form-item><template v-if="form.direction === 'PAYABLE'"><el-form-item label="供应商" required><el-select v-model="form.supplierId" filterable placeholder="请选择有效供应商"><el-option v-for="supplier in suppliers" :key="supplier.id" :label="supplier.supplierName" :value="supplier.id" /></el-select></el-form-item><el-form-item label="结算方式" required><el-select v-model="form.settlementMode"><el-option label="账期结算" value="ON_ACCOUNT" /><el-option label="直接已付" value="DIRECT_PAID" /></el-select></el-form-item><template v-if="form.settlementMode === 'DIRECT_PAID'"><el-form-item label="付款时间" required><el-date-picker v-model="form.paidAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="付款方式" required><el-select v-model="form.paymentMethod"><el-option label="线上支付" value="ONLINE" /><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item><el-form-item label="付款流水号"><el-input v-model="form.paymentReferenceNo" maxlength="128" placeholder="请输入付款流水号(可选)" /></el-form-item><el-form-item label="付款凭证"><div class="voucher-list"><input ref="voucherInput" type="file" accept="image/*" hidden @change="uploadVoucher" /><el-button :loading="voucherUploading" :disabled="!form.customerId || actionLoading" @click="voucherInput?.click()">{{ form.paymentVoucherFileIds.length ? '重新上传凭证' : '上传付款凭证' }}</el-button><div v-for="(fileId, index) in form.paymentVoucherFileIds" :key="fileId" class="voucher-item"><el-button link type="primary" @click="previewVoucher(form.customerId, fileId)">{{ voucherNames[fileId] || `付款凭证 ${index + 1}` }}</el-button><el-button link type="danger" :disabled="voucherUploading || actionLoading" @click="form.paymentVoucherFileIds = form.paymentVoucherFileIds.filter((id) => id !== fileId)">移除</el-button></div></div></el-form-item></template></template></div></el-form>
|
||||
</AppDialog>
|
||||
<AppDialog v-model="previewOpen" :title="previewName" width="min(900px, calc(100vw - 28px))" :show-confirm="false" cancel-text="关闭" append-to-body>
|
||||
<AppDialog class="admin-density-dialog" v-model="previewOpen" :title="previewName" width="min(900px, calc(100vw - 28px))" :show-confirm="false" cancel-text="关闭" append-to-body>
|
||||
<p v-if="previewLoading">正在读取付款凭证…</p>
|
||||
<div v-else-if="previewError"><el-alert type="error" :closable="false" :title="previewError.message" /><ApiErrorMeta :details="previewError" /></div>
|
||||
<div v-else-if="previewUrl" class="voucher-preview"><img v-if="previewIsImage" :src="previewUrl" :alt="previewName" /><p v-else>此文件暂不支持预览,请下载后查看。</p><a :href="previewUrl" :download="previewName">下载付款凭证</a></div>
|
||||
@@ -190,17 +191,17 @@ onMounted(load)
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.last-leg-fees { display: grid; gap: 16px; min-width: 0; }
|
||||
.last-leg-fees__header { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
|
||||
.last-leg-fees__header h2 { margin: 0; color: #253553; font-size: 20px; }
|
||||
.last-leg-fees__header p { margin: 6px 0 0; color: #77839b; }
|
||||
.voucher-list { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; min-width: 0; }
|
||||
.voucher-item { display: flex; gap: 8px; max-width: 100%; }
|
||||
.last-leg-fees { display: grid; gap: var(--admin-space-5); min-width: 0; }
|
||||
.last-leg-fees__header { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-6); }
|
||||
.last-leg-fees__header h2 { margin: 0; color: #253553; font-size: var(--admin-font-title); }
|
||||
.last-leg-fees__header p { margin: var(--admin-space-2) 0 0; color: #77839b; }
|
||||
.voucher-list { display: flex; flex-direction: column; align-items: flex-start; gap: var(--admin-space-3); min-width: 0; }
|
||||
.voucher-item { display: flex; gap: var(--admin-space-3); max-width: 100%; }
|
||||
.voucher-list :deep(.el-button) { white-space: normal; overflow-wrap: anywhere; margin-left: 0; }
|
||||
.voucher-preview { display: grid; gap: 16px; }
|
||||
.voucher-preview { display: grid; gap: var(--admin-space-5); }
|
||||
.voucher-preview img { max-width: 100%; max-height: 65vh; object-fit: contain; margin: auto; }
|
||||
.row-actions { display: flex; align-items: center; white-space: nowrap; }
|
||||
.fee-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }
|
||||
.row-actions { white-space: nowrap; }
|
||||
.fee-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }
|
||||
.fee-form-grid :deep(.el-select), .fee-form-grid :deep(.el-date-editor), .fee-form-grid :deep(.el-input-number) { width: 100%; }
|
||||
@media (max-width: 760px) { .last-leg-fees__header { align-items: flex-start; flex-direction: column; } .fee-form-grid { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { Download, FileUp, TriangleAlert } from '@lucide/vue'
|
||||
import { TriangleAlert } from '@lucide/vue'
|
||||
import { getApiErrorDetails } from '@/shared/api/http'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import type { ContainerOverview, ContainerOverviewDocument, ContainerPdaPhoto } from '../types'
|
||||
@@ -119,7 +120,7 @@ onBeforeUnmount(() => { photoGeneration += 1; revokePhotoUrls() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="overview-panel">
|
||||
<div class="overview-panel admin-density">
|
||||
<section v-if="visibleSections.has('exception')" class="overview-section exception-management" aria-label="异常管理">
|
||||
<header class="exception-management__header"><div><h3>异常管理</h3><p v-if="overview.exception.active" class="exception-current"><strong>{{ overview.exception.reason || '未填写异常原因' }}</strong><span>{{ overview.exception.markedByName || '操作人信息缺失' }} · {{ formatTime(overview.exception.markedAt) }}</span></p></div><div class="exception-management__actions"><StatusBadge :status="overview.exception.active ? '异常' : '正常'" /><el-button v-if="canManageException" :type="overview.exception.active ? 'primary' : 'danger'" plain @click="emit('manageException')"><TriangleAlert :size="16" />{{ overview.exception.active ? '取消异常' : '标记异常' }}</el-button></div></header>
|
||||
<div class="exception-history"><div class="exception-history__head"><span>时间</span><span>动作</span><span>操作人</span><span>原因</span></div><p v-if="overview.exceptionHistory.length === 0" class="exception-history__empty">暂无异常处理记录</p><ol v-else><li v-for="history in overview.exceptionHistory" :key="history.id"><time>{{ formatTime(history.occurredAt) }}</time><strong>{{ eventLabel(history.action) }}</strong><span>{{ history.operatorName || '操作人信息缺失' }}</span><span :title="history.reason">{{ history.reason }}</span></li></ol></div>
|
||||
@@ -127,17 +128,17 @@ onBeforeUnmount(() => { photoGeneration += 1; revokePhotoUrls() })
|
||||
|
||||
<section v-if="visibleSections.has('documents')" class="overview-section overview-section--documents" aria-label="报关清关资料">
|
||||
<div class="document-table-wrap">
|
||||
<table class="document-table">
|
||||
<table class="document-table density-table">
|
||||
<thead><tr><th class="document-table__actions">操作</th><th>资料类型</th><th>资料内容</th><th>更新人</th><th>更新时间</th><th>状态</th></tr></thead>
|
||||
<tbody><tr v-for="item in documentTypes" :key="item.type">
|
||||
<td class="document-table__actions"><div class="document-actions"><el-button v-if="documentsByType.get(item.type) && canDownloadDocument(item.type)" size="small" :loading="documentBusy" @click="emit('downloadDocument', documentsByType.get(item.type)!)"><Download :size="15" />下载</el-button><el-button v-if="canUploadDocument(item.type) && !documentsByType.get(item.type)?.completed" size="small" @click="emit('uploadDocument', item.type)"><FileUp :size="15" />{{ documentsByType.get(item.type) ? '更新附件' : '上传附件' }}</el-button><span v-if="!hasDocumentAction(item.type)" class="no-action">—</span></div></td>
|
||||
<td class="document-table__actions"><div class="document-actions density-actions"><el-button class="density-action" v-if="documentsByType.get(item.type) && canDownloadDocument(item.type)" size="small" :loading="documentBusy" @click="emit('downloadDocument', documentsByType.get(item.type)!)">下载</el-button><el-button class="density-action" v-if="canUploadDocument(item.type) && !documentsByType.get(item.type)?.completed" size="small" @click="emit('uploadDocument', item.type)">{{ documentsByType.get(item.type) ? '更新附件' : '上传附件' }}</el-button><span v-if="!hasDocumentAction(item.type)" class="no-action">—</span></div></td>
|
||||
<td><strong>{{ item.label }}</strong></td>
|
||||
<td><span class="document-file-name" :title="documentsByType.get(item.type)?.fileName || undefined">{{ documentsByType.get(item.type)?.fileName || '—' }}</span><small v-if="item.type === 'DELIVERY_ORDER' && deliveryOrderBlockedReason" class="document-blocked-reason">{{ deliveryOrderBlockedReason }}</small></td>
|
||||
<td>{{ documentsByType.get(item.type)?.uploadedByName || '—' }}</td>
|
||||
<td>{{ documentsByType.get(item.type) ? formatTime(documentsByType.get(item.type)?.uploadedAt) : '—' }}</td>
|
||||
<td><el-tag size="small" :type="documentStatusType(item.type)">{{ documentStatusLabel(item.type) }}</el-tag></td>
|
||||
</tr><tr class="pickup-code-row">
|
||||
<td class="document-table__actions"><el-button v-if="canManageDocuments" size="small" type="primary" :loading="pickupCodeBusy" :disabled="!pickupCodeValid || pickupCodeInput === (overview.container.pickupCode || '')" @click="emit('savePickupCode', pickupCodeInput)">保存</el-button><span v-else class="no-action">—</span></td>
|
||||
<td class="document-table__actions"><el-button data-tone="primary" class="density-action" v-if="canManageDocuments" size="small" type="primary" :loading="pickupCodeBusy" :disabled="!pickupCodeValid || pickupCodeInput === (overview.container.pickupCode || '')" @click="emit('savePickupCode', pickupCodeInput)">保存</el-button><span v-else class="no-action">—</span></td>
|
||||
<td><strong>PU 资料</strong></td>
|
||||
<td><el-input v-model="pickupCodeInput" :disabled="!canManageDocuments" maxlength="64" inputmode="numeric" placeholder="请输入数字 PU 码" aria-label="PU码" @input="pickupCodeInput = pickupCodeInput.replace(/\D/g, '')" /></td>
|
||||
<td>{{ overview.container.pickupCodeUpdatedByName || '—' }}</td>
|
||||
@@ -159,20 +160,20 @@ onBeforeUnmount(() => { photoGeneration += 1; revokePhotoUrls() })
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.overview-panel { display: grid; gap: 12px; }
|
||||
.overview-section { min-width: 0; padding: 12px 16px; border: 1px solid var(--cc-border); background: #fff; }
|
||||
.overview-panel { display: grid; gap: var(--admin-space-5); }
|
||||
.overview-section { min-width: 0; background: var(--admin-surface); border: 0; padding: 0; }
|
||||
.overview-section--documents{padding:0;border:0}
|
||||
.overview-section > header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
|
||||
.overview-section h3 { margin: 0; font-size: 15px; }
|
||||
.overview-section header > span, .overview-section small { color: var(--cc-muted); font-size: 12px; }
|
||||
.overview-section p { margin: 5px 0; color: var(--cc-muted); font-size: 13px; }
|
||||
.exception-management{padding-top:10px;padding-bottom:10px}.exception-management__header{margin-bottom:7px!important}.exception-management__header>div:first-child{display:flex;min-width:0;align-items:center;gap:14px}.exception-current{display:flex;min-width:0;align-items:center;gap:9px;margin:0!important}.exception-current strong,.exception-current span{overflow:hidden;max-width:360px;text-overflow:ellipsis;white-space:nowrap}.exception-current strong{color:var(--el-color-danger)}.exception-current span{font-size:12px}.exception-management__actions{display:flex;align-items:center;gap:8px}.exception-history{overflow-x:auto;border-top:1px solid var(--cc-border);font-size:12px}.exception-history__head,.exception-history li{display:grid;grid-template-columns:145px 120px 130px minmax(220px,1fr);gap:8px;align-items:center;min-width:680px;padding:6px 4px}.exception-history__head{color:var(--cc-muted);font-weight:650}.exception-history ol{margin:0;padding:0;list-style:none}.exception-history li{border-top:1px solid var(--cc-border)}.exception-history li time,.exception-history li span{overflow:hidden;color:var(--cc-muted);text-overflow:ellipsis;white-space:nowrap}.exception-history__empty{margin:0!important;padding:7px 4px;border-top:1px solid var(--cc-border);font-size:12px!important}
|
||||
.document-table-wrap{width:100%;overflow-x:auto;border:1px solid var(--cc-border)}.document-table{width:100%;min-width:940px;border-collapse:collapse;table-layout:fixed;font-size:12px}.document-table th,.document-table td{height:40px;padding:5px 10px;border-bottom:1px solid var(--cc-border);background:#fff;text-align:left;vertical-align:middle}.document-table tbody tr:last-child td{border-bottom:0}.document-table th{background:var(--cc-bg-subtle,#f7f8fa);color:var(--cc-muted);font-weight:650}.document-table th:nth-child(1){width:260px}.document-table th:nth-child(2){width:180px}.document-table th:nth-child(3){width:210px}.document-table th:nth-child(4){width:120px}.document-table th:nth-child(5){width:150px}.document-table th:nth-child(6){width:90px}.document-table__actions{position:sticky;left:0;z-index:1;box-shadow:1px 0 0 var(--cc-border)}.document-table th.document-table__actions{z-index:2}.document-table td strong{font-size:13px}.document-table td small{display:block;overflow:hidden;max-width:170px;margin-top:2px;text-overflow:ellipsis;white-space:nowrap}.document-file-name{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.document-blocked-reason{color:var(--cc-muted)}.document-actions{display:flex;align-items:center;gap:5px;white-space:nowrap}.document-actions :deep(.el-button){margin-left:0}.no-action{color:var(--cc-muted)}.pickup-code-row :deep(.el-input){max-width:260px}.pickup-code-row :deep(.el-input__inner){font-variant-numeric:tabular-nums}
|
||||
.pda-summary{display:grid;grid-template-columns:minmax(220px,1fr) minmax(0,2fr);align-items:center;gap:16px;margin-bottom:10px}.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; margin:0; }
|
||||
.metric-grid > div { display: grid; gap: 2px; padding: 6px 9px; background: var(--cc-bg-subtle, #f7f8fa); }
|
||||
.metric-grid span { color: var(--cc-muted); font-size: 12px; }
|
||||
.metric-grid strong { font-size: 18px; }
|
||||
.photo-gallery{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:8px;margin:10px 0}.photo-thumb{display:grid;min-width:0;overflow:hidden;padding:0;border:1px solid var(--cc-border);background:#fff;color:inherit;text-align:left;cursor:pointer}.photo-thumb:disabled{cursor:default}.photo-thumb img,.photo-placeholder{width:100%;aspect-ratio:4/3;object-fit:cover;background:var(--cc-bg-subtle,#f7f8fa)}.photo-placeholder{display:grid;place-items:center;padding:8px;color:var(--cc-muted);font-size:12px}.photo-placeholder--error{color:var(--el-color-danger)}.photo-caption{display:flex;align-items:center;justify-content:space-between;gap:6px;padding:6px 8px}.photo-caption small{white-space:nowrap}.compact-empty{margin:6px 0;color:var(--cc-muted);font-size:12px}.compact-timeline{display:grid;margin:0;padding:0;list-style:none}.compact-timeline li{display:grid;grid-template-columns:130px 130px 110px minmax(0,1fr);gap:8px;padding:6px 0;border-top:1px solid var(--cc-border);font-size:12px}.compact-timeline li:first-child{border-top:0}.compact-timeline time,.compact-timeline span,.compact-timeline small{overflow:hidden;color:var(--cc-muted);text-overflow:ellipsis;white-space:nowrap}
|
||||
.overview-section > header { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5); margin-bottom: var(--admin-space-5); }
|
||||
.overview-section h3 { margin: 0; font-size: var(--admin-font-section); }
|
||||
.overview-section header > span, .overview-section small { color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
.overview-section p { margin: 5px 0; color: var(--cc-muted); font-size: var(--admin-font-body); }
|
||||
.exception-management{padding-top:var(--admin-space-4);padding-bottom:var(--admin-space-4)}.exception-management__header{margin-bottom:7px!important}.exception-management__header>div:first-child{display:flex;min-width:0;align-items:center;gap:var(--admin-space-5)}.exception-current{display:flex;min-width:0;align-items:center;gap:9px;margin:0!important}.exception-current strong,.exception-current span{max-width:360px;white-space: normal;overflow-wrap: anywhere}.exception-current strong{color:var(--el-color-danger)}.exception-current span{font-size:var(--admin-font-caption)}.exception-management__actions{display:flex;align-items:center;gap:var(--admin-space-3)}.exception-history{overflow-x:auto;border-top:1px solid var(--cc-border);font-size:var(--admin-font-caption)}.exception-history__head,.exception-history li{display:grid;grid-template-columns:145px 120px 130px minmax(220px,1fr);gap:var(--admin-space-3);align-items:center;min-width:680px;padding:var(--admin-space-2) var(--admin-space-1)}.exception-history__head{color:var(--cc-muted);font-weight:650}.exception-history ol{margin:0;padding:0;list-style:none}.exception-history li{border-top:1px solid var(--cc-border)}.exception-history li time,.exception-history li span{color:var(--cc-muted);white-space: normal;overflow-wrap: anywhere}.exception-history__empty{margin:0!important;padding:7px var(--admin-space-1);border-top:1px solid var(--cc-border);font-size:var(--admin-font-caption)!important}
|
||||
.document-table-wrap{width:100%;overflow-x:auto;border:1px solid var(--cc-border)}.document-table{width:100%;min-width:940px;table-layout:fixed}.document-table tbody tr:last-child td{border-bottom:0}.document-table th:nth-child(1){width:260px}.document-table th:nth-child(2){width:180px}.document-table th:nth-child(3){width:210px}.document-table th:nth-child(4){width:120px}.document-table th:nth-child(5){width:150px}.document-table th:nth-child(6){width:90px}.document-table__actions{position:sticky;left:0;z-index:1;box-shadow:1px 0 0 var(--cc-border)}.document-table th.document-table__actions{z-index:2}.document-table td strong{font-size:var(--admin-font-body)}.document-table td small{display:block;overflow:hidden;max-width:170px;margin-top:2px;text-overflow:ellipsis;white-space:nowrap}.document-file-name{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.document-blocked-reason{color:var(--cc-muted)}.document-actions{white-space:nowrap}.document-actions :deep(.el-button){margin-left:0}.no-action{color:var(--cc-muted)}.pickup-code-row :deep(.el-input){max-width:260px}.pickup-code-row :deep(.el-input__inner){font-variant-numeric:tabular-nums}
|
||||
.pda-summary{display:grid;grid-template-columns:minmax(220px,1fr) minmax(0,2fr);align-items:center;gap:var(--admin-space-5);margin-bottom:var(--admin-space-4)}.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--admin-space-2); margin:0; }
|
||||
.metric-grid > div { display: flex; align-items: baseline; gap: var(--admin-space-2); padding: 0; background: transparent; }
|
||||
.metric-grid span { color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
.metric-grid strong { font-size: var(--admin-font-body); }
|
||||
.photo-gallery{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:var(--admin-space-3);margin:var(--admin-space-4) 0}.photo-thumb{display:grid;min-width:0;overflow:hidden;padding:0;border:1px solid var(--cc-border);background:var(--admin-surface);color:inherit;text-align:left;cursor:pointer}.photo-thumb:disabled{cursor:default}.photo-thumb img,.photo-placeholder{width:100%;aspect-ratio:4/3;object-fit:cover;background:var(--cc-bg-subtle,#f7f8fa)}.photo-placeholder{display:grid;place-items:center;padding:var(--admin-space-3);color:var(--cc-muted);font-size:var(--admin-font-caption)}.photo-placeholder--error{color:var(--el-color-danger)}.photo-caption{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-2);padding:var(--admin-space-2) var(--admin-space-3)}.photo-caption small{white-space:nowrap}.compact-empty{margin:var(--admin-space-2) 0;color:var(--cc-muted);font-size:var(--admin-font-caption)}.compact-timeline{display:grid;margin:0;padding:0;list-style:none}.compact-timeline li{display:grid;grid-template-columns:130px 130px 110px minmax(0,1fr);gap:var(--admin-space-3);padding:var(--admin-space-2) 0;border-top:1px solid var(--cc-border);font-size:var(--admin-font-caption)}.compact-timeline li:first-child{border-top:0}.compact-timeline time,.compact-timeline span,.compact-timeline small{overflow:hidden;color:var(--cc-muted);text-overflow:ellipsis;white-space:nowrap}
|
||||
@media (max-width: 1200px){.photo-gallery{grid-template-columns:repeat(4,minmax(0,1fr))}.pda-summary{grid-template-columns:1fr}.compact-timeline li{grid-template-columns:120px 120px minmax(0,1fr)}.compact-timeline small{grid-column:2/-1}}
|
||||
@media (max-width: 720px) { .exception-management__header{align-items:flex-start;flex-direction:column}.exception-management__header>div:first-child{align-items:flex-start;flex-direction:column;gap:3px}.exception-current{align-items:flex-start;flex-direction:column;gap:2px}.metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }.photo-gallery{grid-template-columns:repeat(2,minmax(0,1fr))}.compact-timeline li{grid-template-columns:1fr 1fr}.compact-timeline span,.compact-timeline small{grid-column:auto;white-space:normal} }
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { listOceanBookingLinkCandidates } from '@/modules/ocean-bookings'
|
||||
@@ -97,9 +98,9 @@ defineExpose({ candidates, candidateKey, containerNo, save })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog :model-value="modelValue" title="关联已放舱订舱" width="min(680px, calc(100vw - 28px))" confirm-text="确认关联" cancel-text="取消" :confirm-loading="saving" :confirm-disabled="!candidateKey" @update:model-value="emit('update:modelValue', $event)" @confirm="save">
|
||||
<AppDialog class="admin-density-dialog" :model-value="modelValue" title="关联已放舱订舱" width="min(680px, calc(100vw - 28px))" confirm-text="确认关联" cancel-text="取消" :confirm-loading="saving" :confirm-disabled="!candidateKey" @update:model-value="emit('update:modelValue', $event)" @confirm="save">
|
||||
<el-alert title="关联后保留当前柜次号、装柜计划和明细,并补充订舱快照;真实集装箱号可待司机提柜时录入。" type="info" :closable="false" />
|
||||
<el-form label-position="top">
|
||||
<el-form class="density-form" label-position="top">
|
||||
<el-form-item label="柜次号"><el-input :model-value="container?.cabinetNo || ''" disabled /></el-form-item>
|
||||
<el-form-item label="已放舱订舱及柜型需求" required><el-select v-model="candidateKey" filterable :loading="loading" placeholder="请选择仍有剩余数量的柜型需求" no-data-text="暂无符合当前装柜仓库、物流渠道和柜型的已放舱需求"><el-option v-for="candidate in candidates" :key="candidate.key" :label="candidate.label" :value="candidate.key" /></el-select></el-form-item>
|
||||
<el-form-item label="真实集装箱号(可选)"><el-input v-model="containerNo" maxlength="64" placeholder="如未取得可留空,后续显示待补录" @blur="containerNo = normalizeContainerNo(containerNo)" /></el-form-item>
|
||||
@@ -110,5 +111,5 @@ defineExpose({ candidates, candidateKey, containerNo, save })
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dialog-hint { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.6; }
|
||||
.dialog-hint { margin: var(--admin-space-1) 0 0; color: var(--cc-muted); font-size: var(--admin-font-caption); line-height: 1.6; }
|
||||
</style>
|
||||
|
||||
+16
-9
@@ -34,6 +34,13 @@ async function mountDetail(workbench: 'DOMESTIC' | 'OVERSEAS' = 'OVERSEAS') {
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function accessibleText(wrapper: { element: Element } | undefined) {
|
||||
if (!wrapper) return undefined
|
||||
const element = wrapper.element.cloneNode(true) as Element
|
||||
element.querySelectorAll('[aria-hidden="true"]').forEach(node => node.remove())
|
||||
return element.textContent?.trim()
|
||||
}
|
||||
|
||||
describe('ContainerDetailView empty return', () => {
|
||||
beforeEach(() => {
|
||||
routeState.query = {}
|
||||
@@ -72,11 +79,11 @@ describe('ContainerDetailView empty return', () => {
|
||||
for (const [firstLeg, lastLeg] of [[true, false], [false, true], [true, true], [false, false]]) {
|
||||
canAccess.mockImplementation((code) => code === 'firstleg:inbound:fee-detail:view' ? firstLeg! : code === 'platform:finance:customer-arap:list' ? lastLeg! : false)
|
||||
const wrapper = await mountDetail(workbench)
|
||||
const labels = wrapper.findAll('[role="tab"]').map((tab) => tab.text())
|
||||
const labels = wrapper.findAll('[role="tab"]').map((tab) => accessibleText(tab))
|
||||
expect(labels.includes('国内段费用(头程)')).toBe(firstLeg)
|
||||
expect(labels.includes('目的港及海外段费用(尾程)')).toBe(lastLeg)
|
||||
if (lastLeg) {
|
||||
await wrapper.findAll('[role="tab"]').find((tab) => tab.text() === '目的港及海外段费用(尾程)')!.trigger('click')
|
||||
await wrapper.findAll('[role="tab"]').find((tab) => accessibleText(tab) === '目的港及海外段费用(尾程)')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'ContainerLastLegFeesPanel' }).props('readonly')).toBe(false)
|
||||
}
|
||||
@@ -164,7 +171,7 @@ describe('domestic container loading list and booking link', () => {
|
||||
const wrapper = await mountDomestic()
|
||||
expect(getPreloadPlan).not.toHaveBeenCalled()
|
||||
expect(getConfirmedLoadingList).not.toHaveBeenCalled()
|
||||
const tab = wrapper.findAll('[role="tab"]').find((item) => item.text() === '装柜清单')!
|
||||
const tab = wrapper.findAll('[role="tab"]').find((item) => accessibleText(item) === '装柜清单')!
|
||||
expect(tab).toBeDefined()
|
||||
await tab.trigger('click')
|
||||
await flushPromises()
|
||||
@@ -185,8 +192,8 @@ describe('domestic container loading list and booking link', () => {
|
||||
const wrapper = await mountDomestic()
|
||||
const permitted = planStatus === 'COMPLETED' ? confirmed : preload
|
||||
const tabs = wrapper.findAll('[role="tab"]')
|
||||
expect(tabs.some((tab) => tab.text() === '装柜清单')).toBe(permitted)
|
||||
expect(tabs.find((tab) => tab.attributes('aria-selected') === 'true')?.text()).toBe(permitted ? '装柜清单' : '送柜报关')
|
||||
expect(tabs.some((tab) => accessibleText(tab) === '装柜清单')).toBe(permitted)
|
||||
expect(accessibleText(tabs.find((tab) => tab.attributes('aria-selected') === 'true'))).toBe(permitted ? '装柜清单' : '送柜报关')
|
||||
expect(getPreloadPlan).toHaveBeenCalledTimes(permitted && planStatus !== 'COMPLETED' ? 1 : 0)
|
||||
expect(getConfirmedLoadingList).toHaveBeenCalledTimes(permitted && planStatus === 'COMPLETED' ? 1 : 0)
|
||||
if (permitted) {
|
||||
@@ -203,8 +210,8 @@ describe('domestic container loading list and booking link', () => {
|
||||
routeState.query = { tab: 'loading-list' }
|
||||
vi.mocked(getContainer).mockResolvedValue(base as never)
|
||||
const wrapper = await mountDetail('OVERSEAS')
|
||||
expect(wrapper.findAll('[role="tab"]').map((tab) => tab.text())).not.toContain('装柜清单')
|
||||
expect(wrapper.findAll('[role="tab"]').find((tab) => tab.attributes('aria-selected') === 'true')?.text()).toBe('清关提柜')
|
||||
expect(wrapper.findAll('[role="tab"]').map((tab) => accessibleText(tab))).not.toContain('装柜清单')
|
||||
expect(accessibleText(wrapper.findAll('[role="tab"]').find((tab) => tab.attributes('aria-selected') === 'true'))).toBe('清关提柜')
|
||||
expect(getPreloadPlan).not.toHaveBeenCalled()
|
||||
expect(getConfirmedLoadingList).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -217,7 +224,7 @@ describe('domestic container loading list and booking link', () => {
|
||||
expect(list.props('detail')).toBeNull()
|
||||
expect(list.props('error')).toMatchObject({ message: '装柜清单加载失败' })
|
||||
expect(list.props('loading')).toBe(false)
|
||||
expect(wrapper.findAll('[role="tab"]').find((tab) => tab.attributes('aria-selected') === 'true')?.text()).toBe('装柜清单')
|
||||
expect(accessibleText(wrapper.findAll('[role="tab"]').find((tab) => tab.attributes('aria-selected') === 'true'))).toBe('装柜清单')
|
||||
})
|
||||
|
||||
it.each(['WAIT_LOADING', 'EDITING'])('retains the unlinked booking entry with an existing plan in %s', async (planStatus) => {
|
||||
@@ -254,7 +261,7 @@ describe('domestic container loading list and booking link', () => {
|
||||
vi.mocked(getPreloadPlan).mockResolvedValue({ ...loadingDetail, items } as never)
|
||||
const wrapper = await mountDomestic()
|
||||
expect(wrapper.findComponent({ name: 'LoadingListDetailDrawer' }).props('detail').items).toEqual(items)
|
||||
await wrapper.findAll('[role="tab"]').find((tab) => tab.text() === '送柜报关')!.trigger('click')
|
||||
await wrapper.findAll('[role="tab"]').find((tab) => accessibleText(tab) === '送柜报关')!.trigger('click')
|
||||
await flushPromises()
|
||||
const link = wrapper.findAll('button').find((button) => button.text() === '关联已放舱订舱')!
|
||||
expect(link.isVisible()).toBe(true)
|
||||
|
||||
@@ -35,7 +35,7 @@ describe('ContainerDetailView customs integration', () => {
|
||||
expect(source).toContain('label="装柜清单"')
|
||||
expect(source).toContain('打印装柜清单')
|
||||
expect(source).toContain("const loadingListMode = computed<LoadingListMode>(() => detail.value?.planStatus === 'COMPLETED' ? 'confirmed' : 'preload')")
|
||||
expect(source).toContain('<LoadingListDetailDrawer :mode="loadingListMode" embedded')
|
||||
expect(source).toContain('<LoadingListDetailDrawer dense :mode="loadingListMode" embedded')
|
||||
expect(source).toContain(':sections="[\'pda\']"')
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, ClipboardList, FileUp, Link2, Package, Receipt, Wallet } from '@lucide/vue'
|
||||
import { ArrowLeft, FileUp } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
@@ -350,7 +351,7 @@ async function confirmDeparture() {
|
||||
if (!aggregate || !canConfirmDeparture.value || outboundConfirming.value) return
|
||||
outboundConfirming.value = true
|
||||
try {
|
||||
await ElMessageBox.confirm('确认整柜已离开国内仓?系统将再次校验柜内全部入库绑定,成功后自动创建船节点查询任务。', '确认国内出仓', { type: 'warning', confirmButtonText: '确认离仓' })
|
||||
await ElMessageBox.confirm('确认整柜已离开国内仓?系统将再次校验柜内全部入库绑定,成功后自动创建船节点查询任务。', '确认国内出仓', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认离仓' })
|
||||
} catch { outboundConfirming.value = false; return }
|
||||
outboundError.value = null
|
||||
try {
|
||||
@@ -515,31 +516,31 @@ onMounted(async () => { await load(); if (isOverseas.value && route.query.action
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage class="container-detail-page">
|
||||
<AdminPage class="container-detail-page admin-density">
|
||||
<section class="container-overview surface-panel">
|
||||
<header class="container-overview__header"><el-button link type="primary" @click="goBack"><ArrowLeft :size="16" />返回列表</el-button><div><h1>{{ title }}</h1><p>{{ isDomestic ? '送柜、装柜、出口报关与国内出仓' : '海运节点、目的港清关、提柜与还空' }}</p></div><StatusBadge v-if="detail" :status="primaryStatusText" /></header>
|
||||
<header class="container-overview__header"><el-button link class="density-back" type="primary" @click="goBack"><ArrowLeft :size="16" />返回列表</el-button><div><h1>{{ title }}</h1><p>{{ isDomestic ? '送柜、装柜、出口报关与国内出仓' : '海运节点、目的港清关、提柜与还空' }}</p></div><StatusBadge v-if="detail" :status="primaryStatusText" /></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>{{ isDomestic ? '装柜清单号' : '当前海外阶段' }}</span><strong>{{ isDomestic ? (detail.planNo || '—') : primaryStatusText }}</strong></div><div><span>{{ isDomestic ? '柜型' : '起运仓' }}</span><strong>{{ isDomestic ? (detail.containerType || '—') : (detail.warehouseName || '—') }}</strong></div><div><span>{{ isDomestic ? '装柜仓库' : '目的仓 / 接柜仓' }}</span><strong :title="isDomestic ? detail.warehouseName || '—' : overseasDestinationText">{{ isDomestic ? (detail.warehouseName || '—') : overseasDestinationText }}</strong></div></div>
|
||||
<div v-if="isOverseas && detail" class="overseas-stage-strip" :class="{ 'is-overdue': workbenchDetail?.terminalDrayage?.emptyReturnWarning.status === 'OVERDUE' }"><div><span>当前海外履约状态</span><strong>{{ primaryStatusText }}</strong></div><p>{{ emptyReturnWarningText }}</p></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">
|
||||
<el-tabs v-model="activeTab" class="workbench-tabs" @tab-change="rememberWorkbenchTab">
|
||||
<el-tab-pane :label="isDomestic ? '送柜报关' : '清关提柜'" name="overview"><template #label><span class="workbench-tab-label"><ClipboardList :size="18" aria-hidden="true" /><span>{{ isDomestic ? '送柜报关' : '清关提柜' }}</span></span></template></el-tab-pane>
|
||||
<el-tab-pane v-if="canViewCurrentLoadingList" label="装柜清单" name="loading-list"><template #label><span class="workbench-tab-label"><Package :size="18" aria-hidden="true" /><span>装柜清单</span></span></template></el-tab-pane>
|
||||
<el-tab-pane v-if="canViewFirstLegFees" label="国内段费用(头程)" name="first-leg-fees"><template #label><span class="workbench-tab-label"><Receipt :size="18" aria-hidden="true" /><span>国内段费用(头程)</span></span></template></el-tab-pane>
|
||||
<el-tab-pane v-if="canViewLastLegFees" label="目的港及海外段费用(尾程)" name="last-leg-fees"><template #label><span class="workbench-tab-label"><Wallet :size="18" aria-hidden="true" /><span>目的港及海外段费用(尾程)</span></span></template></el-tab-pane>
|
||||
<el-tabs v-model="activeTab" class="workbench-tabs density-element-tabs" @tab-change="rememberWorkbenchTab">
|
||||
<el-tab-pane :label="isDomestic ? '送柜报关' : '清关提柜'" name="overview"><template #label><span class="workbench-tab-label"><span class="density-tab-icon" aria-hidden="true">📋</span><span>{{ isDomestic ? '送柜报关' : '清关提柜' }}</span></span></template></el-tab-pane>
|
||||
<el-tab-pane v-if="canViewCurrentLoadingList" label="装柜清单" name="loading-list"><template #label><span class="workbench-tab-label"><span class="density-tab-icon" aria-hidden="true">📦</span><span>装柜清单</span></span></template></el-tab-pane>
|
||||
<el-tab-pane v-if="canViewFirstLegFees" label="国内段费用(头程)" name="first-leg-fees"><template #label><span class="workbench-tab-label"><span class="density-tab-icon" aria-hidden="true">💰</span><span>国内段费用(头程)</span></span></template></el-tab-pane>
|
||||
<el-tab-pane v-if="canViewLastLegFees" label="目的港及海外段费用(尾程)" name="last-leg-fees"><template #label><span class="workbench-tab-label"><span class="density-tab-icon" aria-hidden="true">💰</span><span>目的港及海外段费用(尾程)</span></span></template></el-tab-pane>
|
||||
</el-tabs>
|
||||
<LoadErrorState v-if="overviewError" compact title="履约摘要加载失败" :message="overviewError.message" :show-retry="false" />
|
||||
<section v-show="activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-information-heading">
|
||||
<header class="detail-section__heading"><h2 id="container-information-heading">货柜信息</h2><PalletLabelPrintDialog v-if="overview?.loadingPlanId && detail.warehouseId" :plan-id="overview.loadingPlanId" :warehouse-id="detail.warehouseId" :disabled="!detail.containerNo || detail.planStatus === 'EDITING'" /></header>
|
||||
<div class="detail-fields"><dl><div><dt>真实订舱</dt><dd><span>{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}</span><el-button v-if="canLinkOceanBooking" link type="primary" @click="openOceanBookingLink"><Link2 :size="14" />关联已放舱订舱</el-button></dd></div><div><dt>计划渠道</dt><dd>{{ plannedChannelName }}</dd></div><div><dt>实际渠道</dt><dd>{{ detail.channelName || '待关联订舱' }}</dd></div><div><dt>异常状态</dt><dd>{{ detail.exceptionActive ? '异常' : '正常' }}</dd></div><div><dt>目的仓</dt><dd>{{ overseasDestinationText }}</dd></div><div><dt>拖车公司</dt><dd>{{ detail.terminalTruckingCompany || workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}</dd></div></dl></div>
|
||||
<header class="detail-section__heading"><h2 id="container-information-heading">货柜信息</h2><PalletLabelPrintDialog dense v-if="overview?.loadingPlanId && detail.warehouseId" :plan-id="overview.loadingPlanId" :warehouse-id="detail.warehouseId" :disabled="!detail.containerNo || detail.planStatus === 'EDITING'" /></header>
|
||||
<div class="detail-fields"><dl class="density-fields"><div><dt>真实订舱</dt><dd><span>{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}</span><el-button v-if="canLinkOceanBooking" class="density-action" data-tone="primary" @click="openOceanBookingLink">关联已放舱订舱</el-button></dd></div><div><dt>计划渠道</dt><dd>{{ plannedChannelName }}</dd></div><div><dt>实际渠道</dt><dd>{{ detail.channelName || '待关联订舱' }}</dd></div><div><dt>异常状态</dt><dd>{{ detail.exceptionActive ? '异常' : '正常' }}</dd></div><div><dt>目的仓</dt><dd>{{ overseasDestinationText }}</dd></div><div><dt>拖车公司</dt><dd>{{ detail.terminalTruckingCompany || workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}</dd></div></dl></div>
|
||||
<ContainerOverviewPanel v-if="isDomestic && overview" :overview="overview" :sections="['exception']" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-manage-exception="canEdit" @manage-exception="openException" />
|
||||
</section>
|
||||
|
||||
<section v-show="activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-voyage-heading">
|
||||
<h2 id="container-voyage-heading">船名航次信息</h2>
|
||||
<div class="detail-fields"><dl><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>{{ formatTime(detail.estimatedSailingAt) }}</dd></div><div><dt>预计到港</dt><dd>{{ formatTime(detail.estimatedPortArrivalAt) }}</dd></div></dl></div>
|
||||
<div class="carrier-panel"><CarrierScheduleQueryPanel :key="carrierRefreshKey" :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" :warehouse-timeline="overview?.timeline || []" /></div>
|
||||
<div class="detail-fields"><dl class="density-fields"><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>{{ formatTime(detail.estimatedSailingAt) }}</dd></div><div><dt>预计到港</dt><dd>{{ formatTime(detail.estimatedPortArrivalAt) }}</dd></div></dl></div>
|
||||
<div class="carrier-panel"><CarrierScheduleQueryPanel dense :key="carrierRefreshKey" :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" :warehouse-timeline="overview?.timeline || []" /></div>
|
||||
</section>
|
||||
|
||||
<section v-show="activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-customs-heading">
|
||||
@@ -572,14 +573,14 @@ onMounted(async () => { await load(); if (isOverseas.value && route.query.action
|
||||
|
||||
<section v-if="isOverseas" v-show="activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-overseas-receiving-heading">
|
||||
<header class="detail-section__heading"><h2 id="container-overseas-receiving-heading">提柜、卸柜与还空</h2><el-button v-if="canRecordEmptyReturn" type="primary" :loading="emptyReturnSaving" @click="openEmptyReturn">确认还柜</el-button></header>
|
||||
<div class="detail-fields"><dl><div><dt>码头拖车状态</dt><dd>{{ workbenchDetail?.statusSummary?.terminalDrayageStatus || '—' }}</dd></div><div><dt>码头车队</dt><dd>{{ workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}</dd></div><div><dt>预约提柜</dt><dd>{{ formatTime(workbenchDetail?.terminalDrayage?.pickupAppointmentAt) }}</dd></div><div><dt>实际提柜</dt><dd>{{ formatTime(workbenchDetail?.terminalDrayage?.actualPickupAt) }}</dd></div><div><dt>卸柜完成</dt><dd>{{ formatTime((workbenchDetail?.actualFacts?.unloadingCompletedAt || workbenchDetail?.terminalDrayage?.unloadingCompletedAt) as string | null) }}</dd></div><div><dt>实际还柜</dt><dd>{{ formatTime(workbenchDetail?.terminalDrayage?.actualEmptyReturnAt) }}</dd></div></dl></div>
|
||||
<div class="detail-fields"><dl class="density-fields"><div><dt>码头拖车状态</dt><dd>{{ workbenchDetail?.statusSummary?.terminalDrayageStatus || '—' }}</dd></div><div><dt>码头车队</dt><dd>{{ workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}</dd></div><div><dt>预约提柜</dt><dd>{{ formatTime(workbenchDetail?.terminalDrayage?.pickupAppointmentAt) }}</dd></div><div><dt>实际提柜</dt><dd>{{ formatTime(workbenchDetail?.terminalDrayage?.actualPickupAt) }}</dd></div><div><dt>卸柜完成</dt><dd>{{ formatTime((workbenchDetail?.actualFacts?.unloadingCompletedAt || workbenchDetail?.terminalDrayage?.unloadingCompletedAt) as string | null) }}</dd></div><div><dt>实际还柜</dt><dd>{{ formatTime(workbenchDetail?.terminalDrayage?.actualEmptyReturnAt) }}</dd></div></dl></div>
|
||||
</section>
|
||||
|
||||
<section v-if="isDomestic && activeTab === 'loading-list'" class="detail-section surface-panel" aria-labelledby="container-loading-list-heading">
|
||||
<header class="loading-list-heading"><div><h2 id="container-loading-list-heading">{{ loadingListHeading }}</h2><p>{{ loadingListDescription }}</p></div><el-button v-if="loadingDetail && loadingListMode === 'confirmed'" @click="printLoadingList">打印装柜清单</el-button></header>
|
||||
<ContainerOverviewPanel v-if="overview" :overview="overview" :sections="['pda']" :can-manage-documents="false" :can-manage-customs="false" :can-manage-exception="false" />
|
||||
<ContainerCustomsPanel v-if="overview?.loadingPlanId && canListCustoms" :loading-plan-id="overview.loadingPlanId" :can-list="canListCustoms" :can-manage="canManageCustoms" :departure-state="customsDepartureState" @refreshed="refreshAfterCustoms" />
|
||||
<LoadingListDetailDrawer :mode="loadingListMode" embedded :loading="loadingDetailLoading" :detail="loadingDetail" :error="loadingDetailError" :can-edit="false" @back="activeTab = 'overview'" @retry="overview && loadLoadingDetail(detail, overview)" />
|
||||
<LoadingListDetailDrawer dense :mode="loadingListMode" embedded :loading="loadingDetailLoading" :detail="loadingDetail" :error="loadingDetailError" :can-edit="false" @back="activeTab = 'overview'" @retry="overview && loadLoadingDetail(detail, overview)" />
|
||||
</section>
|
||||
|
||||
<section v-if="canViewFirstLegFees && firstLegFeesOpened" v-show="activeTab === 'first-leg-fees'" v-loading="loadingDetailLoading" class="detail-section surface-panel" aria-label="头程计费">
|
||||
@@ -594,24 +595,17 @@ onMounted(async () => { await load(); if (isOverseas.value && route.query.action
|
||||
</template>
|
||||
|
||||
<OceanBookingLinkDialog v-model="oceanBookingLinkOpen" :container="detail" @linked="load" />
|
||||
<AppDialog v-model="emptyReturnOpen" title="确认还柜" width="min(520px, calc(100vw - 28px))" confirm-text="确认还柜" :confirm-loading="emptyReturnSaving" :confirm-disabled="!emptyReturnAt || !canRecordEmptyReturn" @confirm="confirmEmptyReturn"><el-alert title="提交后货柜将进入已还柜状态,请核对实际还柜时间。" type="warning" :closable="false" show-icon /><el-form label-position="top"><el-form-item label="实际还柜时间" required><el-date-picker v-model="emptyReturnAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择实际还柜时间" /></el-form-item><el-form-item label="备注"><el-input v-model="emptyReturnReason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></el-form><LoadErrorState v-if="emptyReturnError" compact title="确认还柜失败" :message="emptyReturnError.message" :show-retry="false"><template #details><ApiErrorMeta :details="emptyReturnError" /></template></LoadErrorState></AppDialog>
|
||||
<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 :key="documentUploadKey" :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"><template #details><ApiErrorMeta :details="documentError" /></template></LoadErrorState></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 placeholder="请输入异常原因或取消备注" /></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"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState></AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="emptyReturnOpen" title="确认还柜" width="min(520px, calc(100vw - 28px))" confirm-text="确认还柜" :confirm-loading="emptyReturnSaving" :confirm-disabled="!emptyReturnAt || !canRecordEmptyReturn" @confirm="confirmEmptyReturn"><el-alert title="提交后货柜将进入已还柜状态,请核对实际还柜时间。" type="warning" :closable="false" show-icon /><el-form class="density-form" label-position="top"><el-form-item label="实际还柜时间" required><el-date-picker v-model="emptyReturnAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择实际还柜时间" /></el-form-item><el-form-item label="备注"><el-input v-model="emptyReturnReason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></el-form><LoadErrorState v-if="emptyReturnError" compact title="确认还柜失败" :message="emptyReturnError.message" :show-retry="false"><template #details><ApiErrorMeta :details="emptyReturnError" /></template></LoadErrorState></AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="documentOpen" :title="documentTitle" width="min(520px, calc(100vw - 28px))" confirm-text="上传资料" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><el-form class="density-form" label-position="top"><el-form-item :label="documentTitle"><el-upload :key="documentUploadKey" :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"><template #details><ApiErrorMeta :details="documentError" /></template></LoadErrorState></AppDialog>
|
||||
<AppDialog class="admin-density-dialog" 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 class="density-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 placeholder="请输入异常原因或取消备注" /></el-form-item></el-form><LoadErrorState v-if="exceptionError" compact title="异常处理失败" :message="exceptionError.message" :show-retry="false" /></AppDialog>
|
||||
<AppDialog class="admin-density-dialog" 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 class="density-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"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState></AppDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.workbench-tabs { min-width:0; overflow:hidden; border-bottom:1px solid rgba(228,228,231,.5); border-radius:20px 20px 0 0; background:rgba(255,255,255,.95); box-shadow:0 -4px 16px rgba(0,0,0,.04); }
|
||||
.workbench-tabs :deep(.el-tabs__header) { margin:0; }
|
||||
.workbench-tabs :deep(.el-tabs__nav-wrap::after), .workbench-tabs :deep(.el-tabs__active-bar), .workbench-tabs :deep(.el-tabs__content) { display:none; }
|
||||
.workbench-tabs :deep(.el-tabs__item) { height:auto; padding:16px 28px !important; border-bottom:3px solid transparent; color:#71717a; font-size:15px; font-weight:600; transition:background-color .2s, color .2s, border-color .2s; }
|
||||
.workbench-tabs :deep(.el-tabs__item:hover) { background:rgba(102,126,234,.05); color:#667eea; }
|
||||
.workbench-tabs :deep(.el-tabs__item.is-active) { border-bottom-color:#667eea; background:rgba(102,126,234,.08); color:#667eea; font-weight:800; }
|
||||
.workbench-tabs :deep(.el-tabs__item:focus-visible) { outline:2px solid #667eea; outline-offset:-4px; }
|
||||
.workbench-tab-label { display:inline-flex; align-items:center; justify-content:center; gap:8px; white-space:nowrap; }
|
||||
.container-detail-page { display:grid; min-width:0; gap:12px; }.container-overview { overflow:hidden; }.container-overview__header { display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; gap:14px; padding:10px 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:3px; padding:8px 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-section{display:grid;min-width:0;gap:12px;padding:12px 16px}.detail-section>h2{margin:0;font-size:15px}.detail-fields { padding:0; }.detail-fields dl { display:grid; grid-template-columns:repeat(6,minmax(0,1fr)); gap:9px 16px; margin:0; }.detail-fields dl>div { min-width:0; }.detail-fields dd { margin-top:3px; font-size:13px; }.outbound-panel{padding:12px 0 0;border-top:1px solid var(--cc-border)}.outbound-panel__header{display:flex;align-items:center;justify-content:space-between;gap:12px}.outbound-panel__header h2{margin:0;font-size:15px}.outbound-panel__header p,.outbound-panel__hint{margin:3px 0 0;color:var(--cc-muted);font-size:12px}.outbound-panel__hint--success{color:var(--el-color-success)}.outbound-panel__actions{display:flex;align-items:center;gap:8px}.outbound-panel__loading{padding:8px 0}.gate-summary{display:flex;align-items:center;gap:14px;margin-top:8px;color:var(--cc-muted);font-size:12px}.gate-summary__passed strong{color:var(--el-color-success)}.gate-summary__blocked strong{color:var(--el-color-danger)}.gate-summary__reference{margin-left:auto;padding-left:10px;border-left:1px solid var(--cc-border)}.gate-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px 12px;margin-top:7px}.gate-row{display:grid;grid-template-columns:auto minmax(130px,.9fr) minmax(100px,1fr);align-items:center;gap:6px 8px;min-width:0;padding:6px 8px;border:1px solid var(--cc-border);border-radius:6px;background:var(--cc-surface-muted)}.gate-row__actual,.gate-row small{overflow:hidden;color:var(--cc-muted);font-size:12px;text-overflow:ellipsis;white-space:nowrap}.gate-row small{grid-column:2/-1}.gate-row__coverage{display:flex;grid-column:2/-1;min-width:0;align-items:center;gap:5px;overflow:hidden}.gate-row__coverage>span{flex:0 0 auto;color:var(--cc-muted);font-size:11px}.gate-row__coverage :deep(.el-tag){max-width:120px;overflow:hidden;text-overflow:ellipsis}.carrier-panel{padding-top:4px}@media(max-width:1200px){.detail-fields dl{grid-template-columns:repeat(4,minmax(0,1fr))}.gate-list{grid-template-columns:1fr}}@media(max-width:720px){.container-overview__header{grid-template-columns:auto minmax(0,1fr)}.container-overview__header>:last-child{grid-column:2}.container-overview__summary,.detail-fields dl{grid-template-columns:repeat(2,minmax(0,1fr))}.outbound-panel__header{align-items:flex-start;flex-direction:column}.outbound-panel__actions,.gate-summary{flex-wrap:wrap}.gate-summary__reference{width:100%;margin-left:0;padding-left:0;border-left:0}.gate-row{grid-template-columns:auto minmax(0,1fr)}.gate-row__actual,.gate-row small,.gate-row__coverage{grid-column:2}}
|
||||
.detail-section__heading { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
.detail-section__heading h2 { margin:0; font-size:15px; }
|
||||
.overseas-stage-strip{display:flex;align-items:center;justify-content:space-between;gap:18px;padding:9px 16px;border-top:1px solid var(--cc-border);background:#f5f8ff}.overseas-stage-strip>div{display:flex;align-items:baseline;gap:10px}.overseas-stage-strip span,.overseas-stage-strip p{color:var(--cc-muted);font-size:12px}.overseas-stage-strip strong{color:var(--cc-primary);font-size:14px}.overseas-stage-strip p{margin:0}.overseas-stage-strip.is-overdue{background:#fff7ed}.overseas-stage-strip.is-overdue strong,.overseas-stage-strip.is-overdue p{color:#c2410c}
|
||||
.workbench-tab-label { display:inline-flex; align-items:center; justify-content:center; gap:var(--admin-space-3); white-space:nowrap; }
|
||||
.container-detail-page { display:grid; min-width:0; gap:var(--admin-space-5); }.container-overview { overflow:hidden; }.container-overview__header { display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; gap:var(--admin-space-5); padding:var(--admin-space-4) var(--admin-space-5); }.container-overview__header h1 { margin:0; font-size:var(--admin-font-title); }.container-overview__header p { margin:2px 0 0; color:var(--cc-muted); font-size:var(--admin-font-caption); }.container-overview__summary { border-top:1px solid var(--cc-border); display:flex; flex-wrap:wrap; }.container-overview__summary>div { display:flex; min-width:0; padding:var(--admin-space-3) var(--admin-space-5); border-right:1px solid var(--cc-border); align-items:baseline; gap:var(--admin-space-2); }.container-overview__summary>div:last-child { border-right:0; }.container-overview__summary span,dt { color:var(--cc-muted); font-size:var(--admin-font-caption); }.container-overview__summary strong,dd { margin:0; white-space:normal; overflow-wrap:anywhere; }.detail-section{display:grid;min-width:0;gap:var(--admin-space-5);padding:var(--admin-space-5) var(--admin-space-5)}.detail-section>h2{margin:0;font-size:var(--admin-font-section)}.detail-fields { padding:0; }.outbound-panel{padding:var(--admin-space-5) 0 0;border-top:1px solid var(--cc-border)}.outbound-panel__header{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-5)}.outbound-panel__header h2{margin:0;font-size:var(--admin-font-section)}.outbound-panel__header p,.outbound-panel__hint{margin:3px 0 0;color:var(--cc-muted);font-size:var(--admin-font-caption)}.outbound-panel__hint--success{color:var(--el-color-success)}.outbound-panel__actions{display:flex;align-items:center;gap:var(--admin-space-3)}.outbound-panel__loading{padding:var(--admin-space-3) 0}.gate-summary{display:flex;align-items:center;gap:var(--admin-space-5);margin-top:var(--admin-space-3);color:var(--cc-muted);font-size:var(--admin-font-caption)}.gate-summary__passed strong{color:var(--el-color-success)}.gate-summary__blocked strong{color:var(--el-color-danger)}.gate-summary__reference{margin-left:auto;padding-left:var(--admin-space-4);border-left:1px solid var(--cc-border)}.gate-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--admin-space-2) var(--admin-space-5);margin-top:7px}.gate-row{display:grid;grid-template-columns:auto minmax(130px,.9fr) minmax(100px,1fr);align-items:center;gap:var(--admin-space-2) var(--admin-space-3);min-width:0;padding:var(--admin-space-2) var(--admin-space-3);border:1px solid var(--cc-border);border-radius:var(--admin-radius);background:var(--cc-surface-muted)}.gate-row__actual,.gate-row small{color:var(--cc-muted);font-size:var(--admin-font-caption);white-space:normal;overflow-wrap:anywhere}.gate-row small{grid-column:2/-1}.gate-row__coverage{display:flex;grid-column:2/-1;min-width:0;align-items:center;gap:5px;overflow:hidden}.gate-row__coverage>span{flex:0 0 auto;color:var(--cc-muted);font-size:var(--admin-font-caption)}.gate-row__coverage :deep(.el-tag){max-width:120px;overflow:hidden;text-overflow:ellipsis}.carrier-panel{padding-top:var(--admin-space-1)}@media(max-width:1200px){.gate-list{grid-template-columns:1fr}}@media(max-width:720px){.container-overview__header{grid-template-columns:auto minmax(0,1fr)}.container-overview__header>:last-child{grid-column:2}.container-overview__summary,.detail-fields dl{grid-template-columns:repeat(2,minmax(0,1fr))}.outbound-panel__header{align-items:flex-start;flex-direction:column}.outbound-panel__actions,.gate-summary{flex-wrap:wrap}.gate-summary__reference{width:100%;margin-left:0;padding-left:0;border-left:0}.gate-row{grid-template-columns:auto minmax(0,1fr)}.gate-row__actual,.gate-row small,.gate-row__coverage{grid-column:2}}
|
||||
.detail-section__heading { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-5); }
|
||||
.detail-section__heading h2 { margin:0; font-size:var(--admin-font-section); }
|
||||
.overseas-stage-strip{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-6);padding:9px var(--admin-space-5);border-top:1px solid var(--cc-border);background:#f5f8ff}.overseas-stage-strip>div{display:flex;align-items:baseline;gap:var(--admin-space-4)}.overseas-stage-strip span,.overseas-stage-strip p{color:var(--cc-muted);font-size:var(--admin-font-caption)}.overseas-stage-strip strong{color:var(--cc-primary);font-size:var(--admin-font-section)}.overseas-stage-strip p{margin:0}.overseas-stage-strip.is-overdue{background:#fff7ed}.overseas-stage-strip.is-overdue strong,.overseas-stage-strip.is-overdue p{color:#c2410c}.container-overview__header h1 { overflow-wrap:anywhere; }.workbench-tab-label { gap:var(--admin-space-2); }
|
||||
</style>
|
||||
|
||||
@@ -412,10 +412,10 @@ describe('ContainersView', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
|
||||
expect(source).toContain("'domestic-container-detail' : 'overseas-container-detail'")
|
||||
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')
|
||||
expect(source).toContain('grid-template-columns: minmax(0,1fr)')
|
||||
expect(source).toContain('<AppDialog v-model="editorOpen" class="container-editor-dialog admin-density-dialog"')
|
||||
expect(source).toContain('width="min(820px, calc(100vw - 28px))"')
|
||||
expect(source).toContain('admin-density-dialog')
|
||||
expect(source).toContain('grid-template-columns: repeat(2, minmax(0, 1fr))')
|
||||
expect(source).not.toContain('AppDrawer')
|
||||
expect(source).not.toContain('<AppDrawer v-model="editorOpen"')
|
||||
})
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import ContainerSimulationCreate from '../components/ContainerSimulationCreate.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Boxes, Check, Eye, FileText, FileUp, Link2, ListChecks, Mail, MoreHorizontal, Pencil, Sparkles } from '@lucide/vue'
|
||||
import { MoreHorizontal } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
@@ -142,7 +143,7 @@ async function retryEmail(mail: TerminalDrayageEmail) {
|
||||
if (!target || !canFleetEdit.value || retryingEmail.value || mail.status !== 'FAILED') return
|
||||
retryingEmail.value = mail.id
|
||||
try {
|
||||
await ElMessageBox.confirm('发送失败时邮件仍可能已送达。请先核对车队未收到,再确认重试;同柜后续邮件会等待本邮件发送成功。', '重试邮件', { confirmButtonText: '确认重试', cancelButtonText: '取消', type: 'warning' })
|
||||
await ElMessageBox.confirm('发送失败时邮件仍可能已送达。请先核对车队未收到,再确认重试;同柜后续邮件会等待本邮件发送成功。', '重试邮件', { customClass: 'admin-density-confirm', confirmButtonText: '确认重试', cancelButtonText: '取消', type: 'warning' })
|
||||
await retryTerminalDrayageEmail(target.id, mail.id)
|
||||
ElMessage.success('邮件已重新加入发送队列')
|
||||
await openEmailHistory(target)
|
||||
@@ -679,14 +680,14 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage as="div">
|
||||
<AdminPage class="admin-density" as="div">
|
||||
<section class="container-page">
|
||||
<ListPageLayout :ariaLabel="isDomestic ? '送柜报关工作台' : '清关提柜工作台'">
|
||||
<template #toolbar>
|
||||
<div class="container-query-panel">
|
||||
<div class="container-scope-tabs" role="tablist" aria-label="货柜完结状态">
|
||||
<button type="button" role="tab" aria-controls="container-workbench-results" :aria-selected="scope === 'ACTIVE'" :tabindex="scope === 'ACTIVE' ? 0 : -1" :class="['container-scope-tab', { 'is-active': scope === 'ACTIVE' }]" @click="scope = 'ACTIVE'">未完结</button>
|
||||
<button type="button" role="tab" aria-controls="container-workbench-results" :aria-selected="scope === 'HISTORY'" :tabindex="scope === 'HISTORY' ? 0 : -1" :class="['container-scope-tab', { 'is-active': scope === 'HISTORY' }]" @click="scope = 'HISTORY'">已完结</button>
|
||||
<div class="container-scope-tabs density-tabs" role="tablist" aria-label="货柜完结状态">
|
||||
<button type="button" role="tab" aria-controls="container-workbench-results" :aria-selected="scope === 'ACTIVE'" :tabindex="scope === 'ACTIVE' ? 0 : -1" :class="['container-scope-tab', { active: scope === 'ACTIVE' }]" @click="scope = 'ACTIVE'">未完结</button>
|
||||
<button type="button" role="tab" aria-controls="container-workbench-results" :aria-selected="scope === 'HISTORY'" :tabindex="scope === 'HISTORY' ? 0 : -1" :class="['container-scope-tab', { active: scope === 'HISTORY' }]" @click="scope = 'HISTORY'">已完结</button>
|
||||
</div>
|
||||
<QueryToolbar v-model="filters" class="container-query-toolbar" ariaLabel="货柜筛选" :fields="queryFields" preference-key="container-loading-query" @search="search" />
|
||||
</div>
|
||||
@@ -696,8 +697,8 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
|
||||
</template>
|
||||
<section v-if="!loadError" id="container-workbench-results" class="surface-panel table-panel">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" stripe preference-key="container-loading-table" empty-text="暂无货柜">
|
||||
<template #toolbar-actions><ContainerSimulationCreate v-if="isDomestic && canCreate" :load-warehouse-options="loadSimulationWarehouses" @created="loadRows" /></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canView" size="small" type="primary" plain :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)"><Eye :size="15" />查看</el-button><template v-if="isDomestic"><el-button v-if="canEdit && hasAction(row as ContainerWorkbenchRow, 'EDIT_CONTAINER')" size="small" @click="openEdit(row)"><Pencil :size="15" />编辑</el-button><el-button v-if="row.planStatus === 'EDITING' && canSmartLoading" size="small" type="primary" @click="goContainerProcess(row)"><Sparkles :size="15" />装柜</el-button><el-button v-else-if="row.planStatus === 'LOADING' && canViewPreload" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />预装清单</el-button><el-button v-else-if="row.planStatus === 'COMPLETED' && canViewLoadingList" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />装柜清单</el-button></template><el-button v-if="canDrayage(row as ContainerWorkbenchRow, 'RECORD_EMPTY_RETURN')" size="small" type="primary" @click="openDrayageAction(row as ContainerBookingResource, 'RECORD_EMPTY_RETURN')">确认还柜</el-button><el-button v-if="canDrayage(row as ContainerWorkbenchRow, 'SEND_PICKUP_EMAIL')" size="small" type="primary" :loading="drayageSavingIds.includes(row.id)" @click="sendPickupEmail(row as ContainerWorkbenchRow)"><Mail :size="15" />发送邮件</el-button><el-dropdown v-if="canView" trigger="click" @command="handleMoreAction($event, row)"><el-button size="small" plain aria-label="更多货柜操作" title="更多货柜操作"><MoreHorizontal :size="16" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="overview"><FileText :size="15" />轨迹与日志</el-dropdown-item><template v-if="isOverseas"><el-dropdown-item command="email-history"><Mail :size="15" />邮件记录</el-dropdown-item><el-dropdown-item command="drayage-history"><FileText :size="15" />码头拖车记录</el-dropdown-item><el-dropdown-item v-if="canDrayage(row as ContainerWorkbenchRow, 'SCHEDULE_PICKUP')" command="drayage:SCHEDULE_PICKUP">预约提柜</el-dropdown-item><el-dropdown-item v-if="canDrayage(row as ContainerWorkbenchRow, 'RECORD_PICKUP')" command="drayage:RECORD_PICKUP">确认实际提柜</el-dropdown-item><el-dropdown-item v-if="canOverseasDocumentEdit && hasAction(row as ContainerWorkbenchRow, 'MANAGE_DESTINATION_DOCUMENTS')" command="documents"><FileUp :size="15" />清关 / DO / PU</el-dropdown-item></template><el-dropdown-item v-if="isDomestic && canEdit && hasAction(row as ContainerWorkbenchRow, 'EDIT_CONTAINER') && row.sourceType === 'SIMULATION' && !row.oceanBookingId" command="link"><Link2 :size="15" />关联已放舱订舱</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
|
||||
<template #toolbar-actions><ContainerSimulationCreate dense v-if="isDomestic && canCreate" :load-warehouse-options="loadSimulationWarehouses" @created="loadRows" /></template>
|
||||
<template #actions="{ row }"><div class="row-actions density-actions"><el-button class="density-action" v-if="canView" size="small" type="primary" plain :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)">查看</el-button><template v-if="isDomestic"><el-button class="density-action" v-if="canEdit && hasAction(row as ContainerWorkbenchRow, 'EDIT_CONTAINER')" size="small" @click="openEdit(row)">编辑</el-button><el-button data-tone="primary" class="density-action" v-if="row.planStatus === 'EDITING' && canSmartLoading" size="small" type="primary" @click="goContainerProcess(row)">装柜</el-button><el-button class="density-action" v-else-if="row.planStatus === 'LOADING' && canViewPreload" size="small" type="primary" plain @click="goContainerProcess(row)">预装清单</el-button><el-button class="density-action" v-else-if="row.planStatus === 'COMPLETED' && canViewLoadingList" size="small" type="primary" plain @click="goContainerProcess(row)">装柜清单</el-button></template><el-button data-tone="primary" class="density-action" v-if="canDrayage(row as ContainerWorkbenchRow, 'RECORD_EMPTY_RETURN')" size="small" type="primary" @click="openDrayageAction(row as ContainerBookingResource, 'RECORD_EMPTY_RETURN')">确认还柜</el-button><el-button data-tone="primary" class="density-action" v-if="canDrayage(row as ContainerWorkbenchRow, 'SEND_PICKUP_EMAIL')" size="small" type="primary" :loading="drayageSavingIds.includes(row.id)" @click="sendPickupEmail(row as ContainerWorkbenchRow)">发送邮件</el-button><el-dropdown v-if="canView" trigger="click" @command="handleMoreAction($event, row)"><el-button class="density-action" size="small" plain aria-label="更多货柜操作" title="更多货柜操作"><MoreHorizontal :size="16" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="overview">轨迹与日志</el-dropdown-item><template v-if="isOverseas"><el-dropdown-item command="email-history">邮件记录</el-dropdown-item><el-dropdown-item command="drayage-history">码头拖车记录</el-dropdown-item><el-dropdown-item v-if="canDrayage(row as ContainerWorkbenchRow, 'SCHEDULE_PICKUP')" command="drayage:SCHEDULE_PICKUP">预约提柜</el-dropdown-item><el-dropdown-item v-if="canDrayage(row as ContainerWorkbenchRow, 'RECORD_PICKUP')" command="drayage:RECORD_PICKUP">确认实际提柜</el-dropdown-item><el-dropdown-item v-if="canOverseasDocumentEdit && hasAction(row as ContainerWorkbenchRow, 'MANAGE_DESTINATION_DOCUMENTS')" command="documents">清关 / DO / PU</el-dropdown-item></template><el-dropdown-item v-if="isDomestic && canEdit && hasAction(row as ContainerWorkbenchRow, 'EDIT_CONTAINER') && row.sourceType === 'SIMULATION' && !row.oceanBookingId" command="link">关联已放舱订舱</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
|
||||
<template #emptyReturnWarning="{ row }"><el-tag :type="drayageWarningType(row as ContainerBookingResource)" effect="light" :title="drayageWarningTitle(row as ContainerBookingResource)">{{ drayageWarningText(row as ContainerBookingResource) }}</el-tag></template>
|
||||
<template #cabinet="{ row }"><strong>{{ row.cabinetNo }}</strong><small>系统生成</small></template>
|
||||
<template #oceanBooking="{ row }"><span v-if="row.oceanBookingId"><strong>{{ row.billOfLadingNo || '已关联订舱' }}</strong><small>{{ row.containerNo ? `集装箱号:${row.containerNo}` : '集装箱号:待补录' }}</small></span><span v-else>未关联</span></template>
|
||||
@@ -722,11 +723,11 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
|
||||
|
||||
<OceanBookingLinkDialog v-model="linkOpen" :container="linkTarget" @linked="loadRows" />
|
||||
|
||||
<AppDialog v-model="editorOpen" class="container-editor-dialog" :title="editorTitle" width="min(820px, calc(100vw - 28px))" :confirm-loading="saving" :confirm-disabled="Boolean(warehouseError)" confirm-text="确定" cancel-text="关闭" @confirm="save">
|
||||
<AppDialog v-model="editorOpen" class="container-editor-dialog admin-density-dialog" :title="editorTitle" width="min(820px, calc(100vw - 28px))" :confirm-loading="saving" :confirm-disabled="Boolean(warehouseError)" confirm-text="确定" cancel-text="关闭" @confirm="save">
|
||||
<LoadErrorState v-if="editorError" title="货柜保存失败" :message="editorError.message" compact :retry-text="form.id ? '刷新最新数据' : '重试保存'" @retry="form.id ? openEdit({ id: form.id }) : save()"><template #details><ApiErrorMeta :details="editorError" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="warehouseError" title="装柜仓库加载失败" :message="warehouseError.message" compact @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
|
||||
<el-alert v-if="businessOptionsError" type="warning" :closable="false" show-icon :title="businessOptionsError"><el-button link type="warning" :loading="businessOptionsLoading" @click="loadBusinessOptions">重新加载业务选项</el-button></el-alert>
|
||||
<el-form v-if="!warehouseError" ref="formRef" :model="form" :rules="rules" label-position="top" class="form-grid">
|
||||
<el-form v-if="!warehouseError" ref="formRef" :model="form" :rules="rules" label-position="top" class="form-grid density-form">
|
||||
<el-form-item label="真实集装箱号"><el-input :model-value="form.containerNo || '待补录'" disabled /><small>货柜基础资料不提供柜号写入;请在拖车提柜或预装详情中补录,后续纠错也在预装详情完成。</small></el-form-item>
|
||||
<el-form-item label="柜次号"><el-input :model-value="form.cabinetNo" disabled placeholder="系统生成" /></el-form-item>
|
||||
<el-form-item label="装柜仓库" prop="warehouseId"><el-select v-model="form.warehouseId" filterable placeholder="请选择装柜仓库" :loading="warehouseLoading" loading-text="仓库加载中" no-data-text="暂无可用国内仓库"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="String(warehouse.id)" /></el-select></el-form-item>
|
||||
@@ -747,22 +748,22 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model="drayageActionOpen" :title="drayageActionText(drayageAction)" width="min(520px, calc(100vw - 28px))" :confirm-text="drayageActionText(drayageAction)" cancel-text="取消" :confirm-loading="drayageActionSaving" :confirm-disabled="!drayageActionAt" @confirm="saveDrayageAction">
|
||||
<el-form label-position="top"><el-form-item :label="drayageAction === 'SCHEDULE_PICKUP' ? '预约提柜时间' : drayageAction === 'RECORD_PICKUP' ? '实际提柜时间' : '实际还柜时间'" required><el-date-picker v-model="drayageActionAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择时间" /></el-form-item><el-form-item label="原因/备注"><el-input v-model="drayageReason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="选填" /></el-form-item></el-form>
|
||||
<AppDialog class="admin-density-dialog" v-model="drayageActionOpen" :title="drayageActionText(drayageAction)" width="min(520px, calc(100vw - 28px))" :confirm-text="drayageActionText(drayageAction)" cancel-text="取消" :confirm-loading="drayageActionSaving" :confirm-disabled="!drayageActionAt" @confirm="saveDrayageAction">
|
||||
<el-form class="density-form" label-position="top"><el-form-item :label="drayageAction === 'SCHEDULE_PICKUP' ? '预约提柜时间' : drayageAction === 'RECORD_PICKUP' ? '实际提柜时间' : '实际还柜时间'" required><el-date-picker v-model="drayageActionAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择时间" /></el-form-item><el-form-item label="原因/备注"><el-input v-model="drayageReason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="选填" /></el-form-item></el-form>
|
||||
<LoadErrorState v-if="drayageActionError" compact :title="`${drayageActionText(drayageAction)}失败`" :message="drayageActionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="drayageActionError" /></template></LoadErrorState>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model="emailHistoryOpen" title="车队邮件记录" width="min(1100px, calc(100vw - 28px))" :show-footer="false">
|
||||
<AppDialog class="admin-density-dialog" v-model="emailHistoryOpen" title="车队邮件记录" width="min(1100px, calc(100vw - 28px))" :show-footer="false">
|
||||
<p>集装箱号:{{ emailTarget?.containerNo || '—' }} · 显示最近 100 封邮件</p>
|
||||
<el-alert v-if="!emailsLoading && !emailsError && !emailDeliveryEnabled" title="发信尚未启用,邮件保留在待发送队列中" type="warning" :closable="false" />
|
||||
<LoadErrorState v-if="emailsError" compact title="邮件记录加载失败" :message="emailsError.message" @retry="emailTarget && openEmailHistory(emailTarget)"><template #details><ApiErrorMeta :details="emailsError" /></template></LoadErrorState>
|
||||
<DataTable v-else :data="emails" :columns="emailColumns" :loading="emailsLoading" column-settings-mode="hide" empty-text="暂无邮件记录">
|
||||
<template #toolbar-actions><el-button :loading="emailsLoading" @click="emailTarget && openEmailHistory(emailTarget)">刷新记录</el-button></template>
|
||||
<template #emailActions="{ row }"><el-button v-if="canFleetEdit && row.status === 'FAILED'" size="small" :loading="retryingEmail === row.id" :disabled="Boolean(retryingEmail)" @click="retryEmail(row as TerminalDrayageEmail)">重试</el-button><span v-else>—</span></template>
|
||||
<template #emailActions="{ row }"><el-button class="density-action" v-if="canFleetEdit && row.status === 'FAILED'" size="small" :loading="retryingEmail === row.id" :disabled="Boolean(retryingEmail)" @click="retryEmail(row as TerminalDrayageEmail)">重试</el-button><span v-else>—</span></template>
|
||||
<template #emailContent="{ row }"><details><summary>{{ row.subject }}</summary><p style="white-space: pre-wrap">{{ row.body }}</p></details></template>
|
||||
</DataTable>
|
||||
</AppDialog>
|
||||
<AppDialog v-model="drayageHistoryOpen" title="码头拖车记录" width="min(980px, calc(100vw - 28px))" :show-footer="false">
|
||||
<AppDialog class="admin-density-dialog" v-model="drayageHistoryOpen" title="码头拖车记录" width="min(980px, calc(100vw - 28px))" :show-footer="false">
|
||||
<LoadErrorState v-if="drayageHistoryError" compact title="码头拖车记录加载失败" :message="drayageHistoryError.message" :loading="drayageHistoryLoading" @retry="drayageTarget && openDrayageHistory(drayageTarget)"><template #details><ApiErrorMeta :details="drayageHistoryError" /></template></LoadErrorState>
|
||||
<template v-else><el-descriptions v-if="drayageTarget && drayageFor(drayageTarget.id)" :column="4" border class="drayage-summary"><el-descriptions-item label="当前状态">{{ drayageStatusText(drayageFor(drayageTarget.id)?.status) }}</el-descriptions-item><el-descriptions-item label="码头车队">{{ drayageFor(drayageTarget.id)?.fleetSupplierName || '—' }}</el-descriptions-item><el-descriptions-item label="预计还柜">{{ formatTime(drayageFor(drayageTarget.id)?.estimatedEmptyReturnAt) }}</el-descriptions-item><el-descriptions-item label="实际还柜">{{ formatTime(drayageFor(drayageTarget.id)?.actualEmptyReturnAt) }}</el-descriptions-item></el-descriptions><DataTable :data="drayageHistory" :columns="drayageHistoryColumns" :loading="drayageHistoryLoading" stripe column-settings-mode="hide" empty-text="暂无码头拖车操作记录" /></template>
|
||||
</AppDialog>
|
||||
@@ -771,11 +772,7 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container-page { display: grid; gap: 16px; min-width: 0; }.container-page small { display: block; margin-top: 4px; color: var(--cc-muted); }.container-query-panel { overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }.container-scope-tabs { display: flex; max-width: 100%; overflow-x: auto; margin-bottom: 0; border-bottom: 1px solid #e5e7eb; background: #f9fafb; scrollbar-width: thin; }.container-scope-tab { position: relative; flex: 0 0 auto; min-height: 44px; padding: 0 18px; border: 0; background: transparent; color: var(--cc-muted); font: inherit; font-size: 14px; cursor: pointer; }.container-scope-tab::after { position: absolute; right: 12px; bottom: 0; left: 12px; height: 3px; background: transparent; content: ''; }.container-scope-tab:hover { color: var(--cc-primary); background: #f6f9fd; }.container-scope-tab:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: -2px; }.container-scope-tab.is-active { color: var(--cc-primary); font-weight: 600; }.container-scope-tab.is-active::after { background: var(--cc-primary); }.container-query-toolbar { border: 0; }.row-actions { display: flex; align-items: center; flex-wrap: nowrap; gap: 4px; white-space: nowrap; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.form-grid { display: grid; grid-template-columns: minmax(0,1fr); gap: 0; }.form-grid :deep(.el-select),.form-grid :deep(.el-date-editor),.form-grid :deep(.el-input-number),.simulation-form :deep(.el-select),.simulation-form :deep(.el-input-number),.fleet-select { width: 100%; }.details :deep(.el-descriptions__label) { width: 120px; }.drayage-load-alert { margin-bottom: 12px; }.drayage-summary { margin-bottom: 14px; }
|
||||
:global(.container-editor-dialog.el-dialog) { display: flex; width: min(820px, calc(100vw - 28px)) !important; max-height: 90vh; margin-block: 5vh !important; overflow: hidden; flex-direction: column; border-radius: 12px; }
|
||||
:global(.container-editor-dialog .el-dialog__header) { flex: 0 0 auto; margin: 0; padding: 20px 28px; border-bottom: 1px solid #e5eaf1; }
|
||||
:global(.container-editor-dialog .el-dialog__title) { color: #1e293b; font-size: 18px; font-weight: 700; }
|
||||
:global(.container-editor-dialog .el-dialog__body) { min-height: 0; padding: 28px; overflow-y: auto; }
|
||||
:global(.container-editor-dialog .el-dialog__footer) { flex: 0 0 auto; padding: 16px 28px; border-top: 1px solid #e5eaf1; background: #f8fafc; }
|
||||
@media(max-width:720px){.container-scope-tab{min-height:46px;padding:0 14px}:global(.container-editor-dialog.el-dialog){max-height:calc(100vh - 20px);margin-block:10px!important}:global(.container-editor-dialog .el-dialog__header),:global(.container-editor-dialog .el-dialog__body),:global(.container-editor-dialog .el-dialog__footer){padding-inline:16px}.details :deep(.el-descriptions__body table),.details :deep(tbody),.details :deep(tr),.details :deep(td){display:block;width:100%!important}.row-actions { min-width: 180px; }}
|
||||
.container-page { display: grid; gap: var(--admin-space-5); min-width: 0; }.container-page small { display: block; margin-top: var(--admin-space-1); color: var(--cc-muted); }.container-query-panel { overflow: hidden; border: 1px solid var(--cc-border); border-radius: var(--admin-radius); background: var(--admin-surface); }.container-scope-tabs { display: flex; max-width: 100%; overflow-x: auto; margin-bottom: 0; border-bottom: 1px solid #e5e7eb; scrollbar-width: thin; }.container-query-toolbar { border: 0; }.row-actions { flex-wrap: nowrap; white-space: nowrap; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }.form-grid :deep(.el-select),.form-grid :deep(.el-date-editor),.form-grid :deep(.el-input-number),.simulation-form :deep(.el-select),.simulation-form :deep(.el-input-number),.fleet-select { width: 100%; }.details :deep(.el-descriptions__label) { width: 120px; }.drayage-load-alert { margin-bottom: var(--admin-space-5); }.drayage-summary { margin-bottom: var(--admin-space-5); }
|
||||
@media(max-width:720px){.details :deep(.el-descriptions__body table),.details :deep(tbody),.details :deep(tr),.details :deep(td){display:block;width:100%!important}.row-actions { min-width: 180px; }}
|
||||
@media(max-width:720px){.form-grid{grid-template-columns:1fr}}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, ListChecks, Pencil, Plus, RefreshCw, Sparkles, Trash2 } from '@lucide/vue'
|
||||
import { ArrowLeft, ListChecks, RefreshCw, Sparkles } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
@@ -302,7 +303,7 @@ async function remove(source: object) {
|
||||
const message = row.coLoadGroup
|
||||
? `确认整组移除 ${row.coLoadGroup.groupNo}(${groupMemberLabel(row.coLoadGroup)}、${row.packageCount} 箱)吗?组内所有入仓片段将联动移除。`
|
||||
: `确认从计划移除入仓码“${item.inboundCode || '—'}”的 ${item.packageCount} 箱吗?`
|
||||
await ElMessageBox.confirm(message, row.coLoadGroup ? '移除同柜不可拆组' : '移除装柜明细', { type: 'warning', confirmButtonText: row.coLoadGroup ? '移除整组' : '移除', cancelButtonText: '取消' }).catch(() => false).then(async (confirmed) => {
|
||||
await ElMessageBox.confirm(message, row.coLoadGroup ? '移除同柜不可拆组' : '移除装柜明细', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: row.coLoadGroup ? '移除整组' : '移除', cancelButtonText: '取消' }).catch(() => false).then(async (confirmed) => {
|
||||
if (!confirmed || !plan.value) return
|
||||
mutating.value = true
|
||||
mutationError.value = null
|
||||
@@ -345,7 +346,7 @@ onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage>
|
||||
<AdminPage class="admin-density">
|
||||
<section class="business-page workbench">
|
||||
<section class="surface-panel workbench-command-bar" aria-label="装柜作业摘要">
|
||||
<div class="workbench-identities">
|
||||
@@ -359,7 +360,7 @@ onMounted(load)
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button text aria-label="返回货柜列表" @click="router.back()"><ArrowLeft :size="18" />返回</el-button>
|
||||
<el-button link class="density-back" aria-label="返回货柜列表" @click="router.back()"><ArrowLeft :size="18" />返回</el-button>
|
||||
<el-tooltip content="刷新" placement="top"><el-button :loading="loading" aria-label="刷新智能装柜" @click="load"><RefreshCw :size="16" /></el-button></el-tooltip>
|
||||
<el-button v-if="canAssign && assignable && plan?.status === 'EDITING'" type="primary" plain :loading="mutating" @click="recommend"><Sparkles :size="16" />智能推荐</el-button>
|
||||
</div>
|
||||
@@ -382,17 +383,17 @@ onMounted(load)
|
||||
<el-alert v-else-if="!plan.canEdit" :type="plan.finalCompletionStatus === 'COMPLETED' ? 'success' : 'info'" :closable="false" show-icon :title="plan.finalCompletionStatus === 'IN_PROGRESS' ? '正在最终确认,装柜方案暂时只读' : plan.finalCompletionStatus === 'COMPLETED' ? '装柜已完成,方案永久只读' : '当前装柜方案只读'" :description="plan.blockedReason || '请刷新计划获取最新作业状态。'" />
|
||||
<DataTable :data="displayedPlanItems" :columns="itemColumns" :loading="loading || mutating" stripe preference-key="smart-loading-items" column-settings-mode="hide" empty-text="暂无已分配货物">
|
||||
<template #toolbar-start><div><h2>{{ displayContainerNo(container) }} 装柜方案</h2><p>智能建议必须经人工核对。</p></div></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain @click="remove(row)"><Trash2 :size="15" />删除</el-button></div></template>
|
||||
<template #actions="{ row }"><div class="row-actions density-actions"><el-button data-tone="danger" class="density-action" v-if="canDelete && editable && row.canRemove" size="small" type="danger" plain @click="remove(row)">删除</el-button></div></template>
|
||||
<template #order="{ row }"><strong>{{ row.inboundCode || '—' }}</strong></template>
|
||||
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
|
||||
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroup" class="candidate-group"><el-tag size="small" type="warning"><LineClampText :content="`${row.coLoadGroup.groupNo} · ${groupMemberLabel(row.coLoadGroup)}`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`全部入仓片段联动,固定 ${row.coLoadGroup.fullPackageCount} 箱`" /></div><LineClampText v-else :content="row.unitType === 'SEALED_PALLET' ? '封托不可拆' : '普通单票'" /></template>
|
||||
<template #goods="{ row }"><div class="goods-cell"><LineClampText :content="row.productNameZh || '未命名货物'" /><LineClampText class="goods-cell__sku" :content="row.skuCode || '无 SKU'" /></div></template>
|
||||
<template #packageCount="{ row }"><LineClampText v-if="row.unitType === 'SEALED_PALLET'" :content="`${row.packageCount} 箱(整托固定)`" /><LineClampText v-else-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" /><div v-else class="package-count-editor"><el-input-number v-if="editingQuantityId === row.id" v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :max="adjustmentMax(row)" :step="1" :controls="false" :disabled="mutating" placeholder="请输入使用箱数" @blur="adjust(row)" @keyup.enter="adjust(row)" /><div v-else class="package-count-summary"><div class="package-count-used"><strong>使用:{{ row.packageCount }}</strong><el-button v-if="canAssign && editable && row.canAdjust" link circle :disabled="mutating" aria-label="调整箱数" @click="startQuantityEdit(row)"><Pencil :size="14" /></el-button></div><small v-if="(row.remainingPackageCount ?? 0) > 0">未用:{{ row.remainingPackageCount }}</small></div><LineClampText v-if="row.detailCount > 1 && editable" :content="`含 ${row.detailCount} 个规格,需删除后重加`" /><LineClampText v-if="row.pickedPackageCount > 0" :content="`已拣 ${row.pickedPackageCount} 箱`" /></div></template>
|
||||
<template #packageCount="{ row }"><LineClampText v-if="row.unitType === 'SEALED_PALLET'" :content="`${row.packageCount} 箱(整托固定)`" /><LineClampText v-else-if="row.coLoadGroup" :content="`${row.packageCount} 箱(整组固定)`" /><div v-else class="package-count-editor"><el-input-number v-if="editingQuantityId === row.id" v-model="quantityDrafts[row.id]" :min="row.minPackageCount" :max="adjustmentMax(row)" :step="1" :controls="false" :disabled="mutating" placeholder="请输入使用箱数" @blur="adjust(row)" @keyup.enter="adjust(row)" /><div v-else class="package-count-summary"><div class="package-count-used"><strong>使用:{{ row.packageCount }}</strong><el-button v-if="canAssign && editable && row.canAdjust" class="density-action" :disabled="mutating" aria-label="调整箱数" @click="startQuantityEdit(row)">调整</el-button></div><small v-if="(row.remainingPackageCount ?? 0) > 0">未用:{{ row.remainingPackageCount }}</small></div><LineClampText v-if="row.detailCount > 1 && editable" :content="`含 ${row.detailCount} 个规格,需删除后重加`" /><LineClampText v-if="row.pickedPackageCount > 0" :content="`已拣 ${row.pickedPackageCount} 箱`" /></div></template>
|
||||
<template #volume="{ row }">{{ formatNumber(Number(row.volumeCbm || 0)) }} m³</template><template #weight="{ row }">{{ formatNumber(Number(row.grossWeightKg || 0),1) }} kg</template>
|
||||
</DataTable>
|
||||
<DataTable :data="displayedCandidates" :columns="candidateColumns" :loading="loading || mutating" stripe preference-key="smart-loading-candidates" column-settings-mode="hide" empty-text="当前仓库暂无可装柜货物">
|
||||
<template #toolbar-start><div><h2>候选货物</h2><p>系统基于装柜仓库、物流渠道、可用箱数及 150% 容量上限校验。</p></div></template>
|
||||
<template #actions="{ row }"><el-button v-if="canAssign && assignable" size="small" type="primary" plain :disabled="candidateBlocked(row as SmartLoadingCandidateRow)" @click="openAssign(row)"><Plus :size="15" />{{ row.unitType === 'SEALED_PALLET' ? '整托分配' : row.coLoadGroupId ? '整组分配' : '分配' }}</el-button></template>
|
||||
<template #actions="{ row }"><el-button data-tone="primary" class="density-action" v-if="canAssign && assignable" size="small" type="primary" plain :disabled="candidateBlocked(row as SmartLoadingCandidateRow)" @click="openAssign(row)">{{ row.unitType === 'SEALED_PALLET' ? '整托分配' : row.coLoadGroupId ? '整组分配' : '分配' }}</el-button></template>
|
||||
<template #customerName="{ row }"><LineClampText :content="row.customerName" /></template>
|
||||
<template #goodsSummary="{ row }"><LineClampText :content="row.goodsSummary || '—'" /></template>
|
||||
<template #coLoadGroup="{ row }"><div v-if="row.coLoadGroupId" class="candidate-group"><el-tag size="small" type="warning"><LineClampText :content="`${row.groupNo} · ${row.memberOrderCount} 票 · ${row.inboundFragmentCount} 个入仓片段`" /></el-tag><LineClampText :content="row.memberOrderSummaries.join(';')" /><LineClampText :content="`整组 ${row.groupTotalBoxes} 箱,必须全量进入同一柜`" /><LineClampText v-if="row.blockReason" class="candidate-block" :content="row.blockReason" /><LineClampText v-else-if="row.locked" content="该组已锁定,不能重复分配" /></div><LineClampText v-else :content="row.unitType === 'SEALED_PALLET' ? '封托不可拆' : '普通单票'" /></template>
|
||||
@@ -403,46 +404,46 @@ onMounted(load)
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
<AppDialog v-model="assignOpen" :title="selectedCandidate?.unitType === 'SEALED_PALLET' ? '整托加入当前柜次' : selectedCandidate?.coLoadGroupId ? '整组加入当前柜次' : '按入仓码分配箱数'" :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.unitType === 'SEALED_PALLET' ? '确认整托分配' : selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认分配'" @confirm="assign"><div v-if="selectedCandidate" class="assign-dialog"><p><strong>{{ selectedCandidate.palletNo || selectedCandidate.groupNo || selectedCandidate.inboundCode || '入仓码暂缺' }}</strong></p><template v-if="selectedCandidate.coLoadGroupId"><p>组内 {{ selectedCandidate.memberOrderCount }} 票、{{ selectedCandidate.inboundFragmentCount }} 个入仓片段,共 {{ selectedCandidate.groupTotalBoxes }} 箱</p><p v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary">{{ summary }}</p><el-alert :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" /></template><template v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'"><p>托盘号 {{ selectedCandidate.palletNo || '—' }} · 入仓码 {{ selectedCandidate.inboundCode || '—' }} · {{ selectedCandidate.availablePackageCount }} 箱</p><el-alert title="该封托托盘将作为一个不可拆分单元整托加入;装柜阶段复用此托盘,不会重新生成工作托盘。" type="warning" :closable="false" /></template><template v-else><p>{{ selectedCandidate.goodsSummary || '货物摘要暂缺' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱</p><el-form label-position="top"><el-form-item label="本次计划装柜箱数" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" placeholder="请输入本次分配箱数" /></el-form-item></el-form><el-alert :title="explainCandidate(selectedCandidate)" type="info" :closable="false" /><small>只记录数量,具体上柜箱号未核验。</small></template></div></AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="assignOpen" :title="selectedCandidate?.unitType === 'SEALED_PALLET' ? '整托加入当前柜次' : selectedCandidate?.coLoadGroupId ? '整组加入当前柜次' : '按入仓码分配箱数'" :confirm-loading="mutating" :confirm-disabled="Boolean(selectedCandidate?.blockReason || selectedCandidate?.locked)" :confirm-text="selectedCandidate?.unitType === 'SEALED_PALLET' ? '确认整托分配' : selectedCandidate?.coLoadGroupId ? '确认整组分配' : '确认分配'" @confirm="assign"><div v-if="selectedCandidate" class="assign-dialog"><p><strong>{{ selectedCandidate.palletNo || selectedCandidate.groupNo || selectedCandidate.inboundCode || '入仓码暂缺' }}</strong></p><template v-if="selectedCandidate.coLoadGroupId"><p>组内 {{ selectedCandidate.memberOrderCount }} 票、{{ selectedCandidate.inboundFragmentCount }} 个入仓片段,共 {{ selectedCandidate.groupTotalBoxes }} 箱</p><p v-for="summary in selectedCandidate.memberOrderSummaries" :key="summary">{{ summary }}</p><el-alert :title="selectedCandidate.blockReason || '同柜且不可拆:全部订单及其入仓片段按完整箱数一次加入,不能部分输入或单独移除。'" :type="selectedCandidate.blockReason ? 'error' : 'warning'" :closable="false" /></template><template v-else-if="selectedCandidate.unitType === 'SEALED_PALLET'"><p>托盘号 {{ selectedCandidate.palletNo || '—' }} · 入仓码 {{ selectedCandidate.inboundCode || '—' }} · {{ selectedCandidate.availablePackageCount }} 箱</p><el-alert title="该封托托盘将作为一个不可拆分单元整托加入;装柜阶段复用此托盘,不会重新生成工作托盘。" type="warning" :closable="false" /></template><template v-else><p>{{ selectedCandidate.goodsSummary || '货物摘要暂缺' }} · 可用 {{ selectedCandidate.availablePackageCount }} 箱</p><el-form class="density-form" label-position="top"><el-form-item label="本次计划装柜箱数" required><el-input-number v-model="assignForm.packageCount" :min="1" :max="selectedCandidate.availablePackageCount" :step="1" controls-position="right" placeholder="请输入本次分配箱数" /></el-form-item></el-form><el-alert :title="explainCandidate(selectedCandidate)" type="info" :closable="false" /><small>只记录数量,具体上柜箱号未核验。</small></template></div></AppDialog>
|
||||
</section>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.workbench { display: grid; gap: 16px; min-width: 0; }
|
||||
.workbench { display: grid; gap: var(--admin-space-5); min-width: 0; }
|
||||
.workbench-command-bar,.header-actions,.row-actions,.meter-title { display: flex; align-items: center; }
|
||||
.workbench-command-bar { justify-content: space-between; gap: 16px; padding: 14px 16px; }
|
||||
.workbench-identities { display: flex; min-width: 0; align-items: center; gap: 32px; }
|
||||
.workbench-command-bar { justify-content: space-between; gap: var(--admin-space-5); padding: var(--admin-space-5) var(--admin-space-5); }
|
||||
.workbench-identities { display: flex; min-width: 0; align-items: center; gap: var(--admin-space-7); }
|
||||
.workbench-identity { display: grid; min-width: 0; gap: 3px; }
|
||||
.workbench-identity span { color: var(--cc-muted); font-size: 11px; }
|
||||
.workbench-identity strong { overflow: hidden; font-size: 15px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.workbench-identity span { color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
.workbench-identity strong { overflow: hidden; font-size: var(--admin-font-section); text-overflow: ellipsis; white-space: nowrap; }
|
||||
.workbench h2,.workbench h2 + p { margin: 0; }
|
||||
.workbench h2 + p,.workbench small { display: block; margin-top: 4px; color: var(--cc-muted); }
|
||||
.header-actions,.row-actions { flex-wrap: wrap; gap: 8px; }
|
||||
.workbench h2 + p,.workbench small { display: block; margin-top: var(--admin-space-1); color: var(--cc-muted); }
|
||||
.header-actions,.row-actions { flex-wrap: wrap; gap: var(--admin-space-3); }
|
||||
.header-actions :deep(.el-button + .el-button),.row-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.summary-band { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 1px; border: 1px solid var(--cc-border); background: var(--cc-border); }
|
||||
.summary-band>div { display: grid; gap: 5px; padding: 14px; background: #fff; }
|
||||
.summary-band span { color: var(--cc-muted); font-size: 12px; }
|
||||
.capacity-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 16px; padding: 16px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
|
||||
.readonly-status-card { display: grid; justify-items: start; gap: 10px; padding: 24px; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; }
|
||||
.readonly-status-card strong { font-size: 16px; }
|
||||
.summary-band>div { display: grid; gap: 5px; padding: var(--admin-space-5); background: var(--admin-surface); }
|
||||
.summary-band span { color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
.capacity-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: var(--admin-space-5); padding: var(--admin-space-5); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); background: var(--admin-surface); }
|
||||
.readonly-status-card { display: grid; justify-items: start; gap: var(--admin-space-4); padding: var(--admin-space-6); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); background: var(--admin-surface); }
|
||||
.readonly-status-card strong { font-size: var(--admin-font-section); }
|
||||
.readonly-status-card p { margin: 0; color: var(--cc-muted); }
|
||||
.meter-title { justify-content: space-between; gap: 12px; margin-bottom: 10px; font-size: 13px; }
|
||||
.meter-title { justify-content: space-between; gap: var(--admin-space-5); margin-bottom: var(--admin-space-4); font-size: var(--admin-font-body); }
|
||||
.row-actions { min-width: 150px; }
|
||||
.goods-cell { display: grid; min-width: 0; gap: 2px; }
|
||||
.goods-cell :deep(.line-clamp-text) { width: 100%; font-weight: 600; }
|
||||
.goods-cell :deep(.goods-cell__sku.line-clamp-text) { color: var(--cc-muted); font-size: 12px; font-weight: 400; }
|
||||
.package-count-editor { display: grid; min-width: 0; gap: 4px; }
|
||||
.goods-cell :deep(.goods-cell__sku.line-clamp-text) { color: var(--cc-muted); font-size: var(--admin-font-caption); font-weight: 400; }
|
||||
.package-count-editor { display: grid; min-width: 0; gap: var(--admin-space-1); }
|
||||
.package-count-summary { display: grid; gap: 3px; line-height: 1.35; }
|
||||
.package-count-used { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
|
||||
.package-count-summary strong { font-size: 13px; font-weight: 600; }
|
||||
.package-count-summary small { color: var(--cc-muted); font-size: 12px; }
|
||||
.package-count-used { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-2); }
|
||||
.package-count-summary strong { font-size: var(--admin-font-body); font-weight: 600; }
|
||||
.package-count-summary small { color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
.workbench :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; }
|
||||
.assign-dialog p { margin: 0 0 10px; }
|
||||
.assign-dialog p { margin: 0 0 var(--admin-space-4); }
|
||||
.assign-dialog :deep(.el-input-number) { width: 100%; }
|
||||
.candidate-group { display: grid; width: 100%; min-width: 0; gap: 3px; }
|
||||
.candidate-group :deep(.el-tag),.candidate-group :deep(.el-tag__content),.candidate-group :deep(.line-clamp-text) { max-width: 100%; min-width: 0; }
|
||||
.candidate-group :deep(.line-clamp-text) { width: 100%; color: var(--cc-muted); font-size: 11px; }
|
||||
.candidate-group :deep(.line-clamp-text) { width: 100%; color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
.candidate-group .candidate-block { color: var(--el-color-danger); }
|
||||
@media (max-width: 760px) { .workbench-command-bar,.header-actions { width: 100%; flex-direction: column; align-items: stretch; } .workbench-identities { flex-direction: column; align-items: stretch; gap: 10px; } .summary-band,.capacity-grid { grid-template-columns: 1fr; } .meter-title { align-items: flex-start; flex-direction: column; } .header-actions .el-button { min-height: 44px; margin-left: 0; } }
|
||||
</style>
|
||||
|
||||
@@ -223,7 +223,7 @@ onBeforeUnmount(() => {
|
||||
<DetailPageLayout v-else v-loading="loading" aria-label="客户详情">
|
||||
<template #header>
|
||||
<header class="detail-header">
|
||||
<el-button class="back-button" @click="router.push({ name: 'customers' })">返回列表</el-button>
|
||||
<el-button link class="back-button density-back" @click="router.push({ name: 'customers' })">返回列表</el-button>
|
||||
<PageToolbar class="detail-page-toolbar" as="section" :title="customer?.tenantName || '客户详情'" :description="customer?.tenantCode || '客户资料'">
|
||||
<template #actions>
|
||||
<div v-if="customer" class="header-actions">
|
||||
|
||||
@@ -55,8 +55,9 @@ onMounted(load)
|
||||
|
||||
<template>
|
||||
<AdminPage as="div" class="inventory-detail-page admin-density">
|
||||
<section class="inventory-overview">
|
||||
<header class="detail-header">
|
||||
<el-button aria-label="返回整柜业务" @click="back"><ArrowLeft :size="16" />返回</el-button>
|
||||
<el-button link class="density-back" aria-label="返回整柜业务" @click="back"><ArrowLeft :size="16" />返回列表</el-button>
|
||||
<h1>{{ detail?.inventory.containerNo || '整柜库存详情' }}</h1>
|
||||
<StatusBadge v-if="detail" :status="inventoryStatus(detail.inventory.inventoryStatus)" />
|
||||
</header>
|
||||
@@ -65,8 +66,7 @@ onMounted(load)
|
||||
<template #details><ApiErrorMeta :details="error" /></template>
|
||||
</LoadErrorState>
|
||||
|
||||
<template v-else-if="detail">
|
||||
<section class="detail-section" aria-labelledby="inventory-basic-heading">
|
||||
<section v-else-if="detail" class="detail-section" aria-labelledby="inventory-basic-heading">
|
||||
<h2 id="inventory-basic-heading">整柜信息</h2>
|
||||
<dl class="detail-fields density-fields">
|
||||
<div><dt>货物名称</dt><dd>{{ detail.inventory.cargoName }}</dd></div>
|
||||
@@ -83,25 +83,25 @@ onMounted(load)
|
||||
<div><dt>库存版本</dt><dd>{{ detail.inventory.version }}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
<div v-else class="detail-loading" v-loading="loading">正在加载整柜库存详情</div>
|
||||
</section>
|
||||
|
||||
<section class="transactions" aria-labelledby="transactions-heading">
|
||||
<section v-if="detail && !error" class="transactions" aria-labelledby="transactions-heading">
|
||||
<h2 id="transactions-heading">进销存交易记录</h2>
|
||||
<DataTable :data="detail.transactions" :columns="transactionColumns" row-key="id" stripe column-settings-mode="hide" empty-text="暂无进销存流水">
|
||||
<template #businessType="{ row }"><StatusBadge :status="row.businessType === 'INBOUND' ? '入库' : '出库'" /></template>
|
||||
</DataTable>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<div v-else class="detail-loading" v-loading="loading">正在加载整柜库存详情</div>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inventory-detail-page { display: grid; min-width: 0; gap: var(--admin-space-5, 12px); }
|
||||
.detail-header { display: flex; align-items: center; gap: var(--admin-space-5, 12px); min-height: 40px; }
|
||||
.inventory-overview { min-width: 0; border: 1px solid var(--cc-border); background: var(--admin-surface, #fff); }
|
||||
.detail-header { display: flex; flex-wrap: wrap; align-items: center; gap: var(--admin-space-5, 12px); min-height: 40px; padding: var(--admin-space-3, 8px) var(--admin-space-5, 12px); }
|
||||
.detail-header h1 { margin: 0; font-size: var(--admin-font-title, 16px); letter-spacing: 0; }
|
||||
.detail-section, .transactions { min-width: 0; border: 1px solid var(--cc-border); background: var(--admin-surface, #fff); }
|
||||
.detail-section { padding: var(--admin-space-5, 12px); }
|
||||
.transactions { min-width: 0; border: 1px solid var(--cc-border); background: var(--admin-surface, #fff); }
|
||||
.detail-section { min-width: 0; padding: 0 var(--admin-space-5, 12px) var(--admin-space-5, 12px); }
|
||||
.detail-section h2, .transactions h2 { margin: 0; font-size: var(--admin-font-section, 14px); letter-spacing: 0; }
|
||||
.detail-fields { grid-template-columns: repeat(4, minmax(0, 1fr)); margin: var(--admin-space-3, 8px) 0 0; }
|
||||
.transactions { padding-top: var(--admin-space-5, 12px); overflow: hidden; }
|
||||
|
||||
@@ -43,7 +43,7 @@ describe('ContainerFeesPanel contract', () => {
|
||||
expect(panel).toContain('class="fees-header__actions"')
|
||||
expect(panel).toContain('class="customer-context"')
|
||||
expect(panel).toContain('<template #toolbar-start><div class="fees-table-title">')
|
||||
expect(panel).toContain('.fees-panel :deep(.data-table__toolbar) { min-height: 48px;')
|
||||
expect(panel).toContain('class="fees-panel admin-density"')
|
||||
})
|
||||
|
||||
it('is exposed from the domestic container fee tab and derives customer context from real items', () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { Check, CircleX, FileUp, Pencil, Plus, Trash2, X } from '@lucide/vue'
|
||||
import { FileUp, Plus, X } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
@@ -321,7 +322,7 @@ async function reject() {
|
||||
}
|
||||
async function voidFee(item: ContainerFeeResource) {
|
||||
if (!canReview.value || !accountId.value) return
|
||||
await ElMessageBox.confirm(`确认作废“${item.feeItemName}”费用?作废后将同步财务来源状态。`, '作废费用', { type: 'warning', confirmButtonText: '确认作废' })
|
||||
await ElMessageBox.confirm(`确认作废“${item.feeItemName}”费用?作废后将同步财务来源状态。`, '作废费用', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认作废' })
|
||||
await (item.allocationGroupId
|
||||
? voidContainerFeeAllocationGroup(props.planId, item.allocationGroupId, item.sourceVersion)
|
||||
: voidContainerFee(props.planId, accountId.value, item.id, item.sourceVersion))
|
||||
@@ -350,7 +351,7 @@ onMounted(() => void load())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="fees-panel" aria-label="柜次费用维护">
|
||||
<section class="fees-panel admin-density" aria-label="柜次费用维护">
|
||||
<header class="fees-header">
|
||||
<div><h2>{{ title }}</h2><p>费用与财务分摊均来自服务端真实记录</p></div>
|
||||
<div class="fees-header__actions">
|
||||
@@ -366,7 +367,7 @@ onMounted(() => void load())
|
||||
<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>
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canReview && row.reviewStatus === 'PENDING'" link type="success" @click="approve(row as ContainerFeeResource)"><Check :size="15" />通过</el-button><el-button v-if="canReview && row.reviewStatus === 'PENDING'" link type="danger" @click="openReject(row as ContainerFeeResource)"><CircleX :size="15" />驳回</el-button><el-button v-if="canEdit && financeLoaded && row.reviewStatus === 'APPROVED' && row.sourceStatus === 'ACTIVE' && !isSettled(row as ContainerFeeResource)" link type="primary" @click="openAdjust(row as ContainerFeeResource)"><Pencil :size="15" />调整</el-button><el-button v-if="canReview && financeLoaded && row.reviewStatus === 'APPROVED' && row.sourceStatus === 'ACTIVE' && !isSettled(row as ContainerFeeResource)" link type="danger" @click="voidFee(row as ContainerFeeResource)"><Trash2 :size="15" />作废</el-button></div></template>
|
||||
<template #actions="{ row }"><div class="row-actions density-actions"><el-button data-tone="warning" class="density-action" v-if="canReview && row.reviewStatus === 'PENDING'" type="success" @click="approve(row as ContainerFeeResource)">通过</el-button><el-button data-tone="danger" class="density-action" v-if="canReview && row.reviewStatus === 'PENDING'" type="danger" @click="openReject(row as ContainerFeeResource)">驳回</el-button><el-button class="density-action" v-if="canEdit && financeLoaded && row.reviewStatus === 'APPROVED' && row.sourceStatus === 'ACTIVE' && !isSettled(row as ContainerFeeResource)" type="primary" @click="openAdjust(row as ContainerFeeResource)">调整</el-button><el-button data-tone="danger" class="density-action" v-if="canReview && financeLoaded && row.reviewStatus === 'APPROVED' && row.sourceStatus === 'ACTIVE' && !isSettled(row as ContainerFeeResource)" type="danger" @click="voidFee(row as ContainerFeeResource)">作废</el-button></div></template>
|
||||
<template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : 'warning'" effect="plain">{{ row.direction === 'RECEIVABLE' ? '应收' : '应付' }}</el-tag></template>
|
||||
<template #settlementMode="{ row }">{{ settlementLabel(row as ContainerFeeResource) }}</template>
|
||||
<template #paidSummary="{ row }">{{ paidSummary(row as ContainerFeeResource) }}</template>
|
||||
@@ -378,7 +379,7 @@ onMounted(() => void load())
|
||||
<div v-if="accountId && fees.length" class="allocation-section"><header><div><h3>财务体积分摊</h3><p>{{ selectedCustomer?.label }} · 仅展示 Finance 已持久化结果</p></div><el-button :loading="financeLoading" @click="loadFinanceDetails">{{ financeLoaded ? '刷新财务状态' : '加载财务状态' }}</el-button></header><LoadErrorState v-if="financeError" compact title="财务状态加载失败" :message="financeError.message" @retry="loadFinanceDetails"><template #details><ApiErrorMeta :details="financeError" /></template></LoadErrorState><el-empty v-else-if="!financeLoaded" description="点击加载财务状态后查看结算与成本分摊" :image-size="64" /><el-empty v-else-if="!financeLoading && 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 #orderNo="{ row }">{{ orderNumbers.get(row.orderId) || '订单编码待补充' }}</template><template #inboundCode="{ row }">{{ row.inboundId ? (inboundCodes.get(row.inboundId) || '入仓码待补充') : '—' }}</template><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">
|
||||
<AppDialog class="admin-density-dialog" 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">
|
||||
@@ -397,7 +398,7 @@ onMounted(() => void load())
|
||||
<el-alert v-else-if="suppliersError" type="warning" :closable="false" show-icon :title="suppliersError.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">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="fee-form density-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: !canViewSuppliers }]" /></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>
|
||||
@@ -428,24 +429,22 @@ onMounted(() => void load())
|
||||
</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>
|
||||
<AppDialog class="admin-density-dialog" 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; 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 { 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; }
|
||||
.voucher-field { grid-column: 1 / -1; }.voucher-control { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; width: 100%; }.voucher-picker { display: inline-flex; align-items: center; gap: 6px; padding: 7px 12px; border: 1px solid var(--cc-border); border-radius: 6px; color: var(--el-color-primary); cursor: pointer; }.voucher-picker.is-disabled { cursor: not-allowed; opacity: .6; }.voucher-picker input { position: absolute; width: 1px; height: 1px; opacity: 0; }.voucher-hint { color: var(--cc-muted); font-size: 12px; }.voucher-list { display: grid; width: 100%; gap: 6px; margin-top: 8px; }.voucher-list > span { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 9px; border-radius: 5px; background: var(--el-fill-color-light); }.voucher-list > span > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.voucher-list button { display: inline-flex; padding: 2px; border: 0; background: transparent; color: var(--el-color-danger); cursor: pointer; }
|
||||
.adjust-snapshot p { margin: 0 0 12px; color: var(--cc-muted); font-size: 13px; line-height: 1.5; }
|
||||
.fees-panel { min-width: 0; overflow: hidden; padding-top: 0; border-top: 0; }
|
||||
.fees-header, .allocation-section > header { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5); padding: 0 0 var(--admin-space-5); }
|
||||
.fees-header h2, .allocation-section h3 { margin: 0; font-size: var(--admin-font-section); }.fees-header p, .allocation-section p { margin: var(--admin-space-1) 0 0; color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
.fees-header__actions, .customer-context { display: flex; align-items: center; gap: var(--admin-space-4); }
|
||||
.customer-context > span { color: var(--cc-muted); font-size: var(--admin-font-body); white-space: nowrap; }.customer-context :deep(.el-select) { width: 260px; }
|
||||
.fees-table-title { display: flex; align-items: baseline; gap: var(--admin-space-3); }.fees-table-title strong { font-size: var(--admin-font-section); }.fees-table-title span { color: var(--cc-muted); font-size: var(--admin-font-caption); }
|
||||
.row-actions { flex-wrap: wrap; }.allocation-section { padding-top: var(--admin-space-5); border-top: 1px solid var(--cc-border); }.cost-block { border-top: 1px solid var(--cc-border); }.cost-summary { display: flex; flex-wrap: wrap; gap: var(--admin-space-4) var(--admin-space-6); padding: var(--admin-space-4) var(--admin-space-5); background: #f8fafc; font-size: var(--admin-font-body); }.cost-summary span { color: var(--cc-muted); }.fee-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-5); margin-top: var(--admin-space-5); }.fee-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }
|
||||
.voucher-field { grid-column: 1 / -1; }.voucher-control { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-4); width: 100%; }.voucher-picker { display: inline-flex; align-items: center; gap: var(--admin-space-2); padding: 7px var(--admin-space-5); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); color: var(--el-color-primary); cursor: pointer; }.voucher-picker.is-disabled { cursor: not-allowed; opacity: .6; }.voucher-picker input { position: absolute; width: 1px; height: 1px; opacity: 0; }.voucher-hint { color: var(--cc-muted); font-size: var(--admin-font-caption); }.voucher-list { display: grid; width: 100%; gap: var(--admin-space-2); margin-top: var(--admin-space-3); }.voucher-list > span { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-3); padding: var(--admin-space-2) 9px; border-radius: 5px; background: var(--el-fill-color-light); }.voucher-list > span > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.voucher-list button { display: inline-flex; padding: 2px; border: 0; background: transparent; color: var(--el-color-danger); cursor: pointer; }
|
||||
.adjust-snapshot p { margin: 0 0 var(--admin-space-5); color: var(--cc-muted); font-size: var(--admin-font-body); line-height: 1.5; }
|
||||
.business-reference { display: grid; gap: 2px; }.business-reference small { color: var(--cc-muted); }
|
||||
.allocation-editor { grid-column: 1 / -1; }.allocation-editor :deep(.el-form-item__content) { display: block; }
|
||||
.allocation-editor__rows { display: grid; gap: 8px; margin-top: 10px; }.allocation-editor__row { display: grid; grid-template-columns: minmax(0, 1fr) 160px; align-items: center; gap: 12px; padding: 8px 10px; border: 1px solid var(--cc-border); border-radius: 6px; }.allocation-editor__row > span { display: grid; min-width: 0; }.allocation-editor__row small { overflow: hidden; color: var(--cc-muted); text-overflow: ellipsis; white-space: nowrap; }.allocation-editor__sum { margin: 8px 0 0; text-align: right; color: var(--el-color-success); }.allocation-editor__sum.is-error { color: var(--el-color-danger); }
|
||||
.allocation-editor__rows { display: grid; gap: var(--admin-space-3); margin-top: var(--admin-space-4); }.allocation-editor__row { display: grid; grid-template-columns: minmax(0, 1fr) 160px; align-items: center; gap: var(--admin-space-5); padding: var(--admin-space-3) var(--admin-space-4); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); }.allocation-editor__row > span { display: grid; min-width: 0; }.allocation-editor__row small { overflow: hidden; color: var(--cc-muted); text-overflow: ellipsis; white-space: nowrap; }.allocation-editor__sum { margin: var(--admin-space-3) 0 0; text-align: right; color: var(--el-color-success); }.allocation-editor__sum.is-error { color: var(--el-color-danger); }
|
||||
@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>
|
||||
|
||||
@@ -18,6 +18,7 @@ const props = defineProps<{
|
||||
detail: LoadingDetail | null
|
||||
error: ApiErrorDetails | null
|
||||
canEdit: boolean
|
||||
dense?: boolean
|
||||
embedded?: boolean
|
||||
canEditTransport?: boolean
|
||||
transportNote?: string
|
||||
@@ -148,16 +149,16 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-loading="loading" class="loading-detail-content" :class="{ 'admin-density preload-density': mode === 'preload' }" :aria-label="title" :aria-busy="loading">
|
||||
<section v-loading="loading" class="loading-detail-content" :class="{ 'admin-density loading-density': mode === 'preload' || dense }" :aria-label="title" :aria-busy="loading">
|
||||
<section class="detail-overview" aria-label="清单概览">
|
||||
<header class="detail-page-header" :class="{ 'detail-page-header--embedded': embedded }">
|
||||
<el-button link type="primary" class="detail-page-header__back" @click="emit('back')"><ArrowLeft :size="16" />返回列表</el-button>
|
||||
<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>
|
||||
</div>
|
||||
<div v-if="detail && summary" class="detail-page-header__actions">
|
||||
<PalletLabelPrintDialog :dense="mode === 'preload'" v-if="summary.warehouseId" :plan-id="summary.planId" :warehouse-id="summary.warehouseId" :disabled="!summary.containerNo || summary.status === 'EDITING'" />
|
||||
<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>
|
||||
@@ -204,7 +205,7 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
>
|
||||
<template #toolbar-start><div class="detail-section__heading"><div><h2>装柜货物明细 <span>{{ detail.items.length }} 条</span></h2><p>仓库备注将随装柜完成资料下发海外仓</p></div></div></template>
|
||||
<template #actions="{ row }">
|
||||
<el-button :class="{ 'density-action': mode === 'preload' }" 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>
|
||||
<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>
|
||||
<template #unitType="{ row }"><el-tag size="small" :type="row.unitType === 'SEALED_PALLET' ? 'success' : 'info'">{{ row.unitType === 'SEALED_PALLET' ? '封托托盘' : '箱件' }}</el-tag><small v-if="row.indivisible">整托不可拆</small></template>
|
||||
<template #grossWeight="{ row }">{{ formatNumber(row.grossWeightKg) }}</template>
|
||||
@@ -273,22 +274,22 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
.history-section header { display: flex; align-items: baseline; gap: var(--admin-space-4, 10px); margin-bottom: 18px; }
|
||||
.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); }
|
||||
.preload-density { gap: var(--admin-space-5, 12px); padding: var(--admin-space-5, 12px); border-radius: var(--admin-radius, 4px); }
|
||||
.preload-density .detail-page-header { gap: var(--admin-space-5, 12px); padding: 0 0 var(--admin-space-3, 8px); }
|
||||
.preload-density .detail-page-header__identity h1 { font-size: var(--admin-font-title, 16px); overflow-wrap: anywhere; }
|
||||
.preload-density .detail-page-header__actions { flex-wrap: wrap; }
|
||||
.preload-density .detail-summary { gap: 0; padding: 0; border-left: 1px solid var(--cc-border); }
|
||||
.preload-density .detail-summary > div { gap: 0; align-items: stretch; min-height: var(--admin-row-height, 36px); border-bottom: 1px solid var(--cc-border); border-right: 1px solid var(--cc-border); }
|
||||
.preload-density .detail-summary > div > span:first-child { display: flex; align-items: center; flex: 0 0 96px; padding: var(--admin-space-2, 6px) var(--admin-space-3, 8px); background: var(--admin-label-bg, #f8fafc); }
|
||||
.preload-density .detail-summary > div > span:first-child::after { content: none; }
|
||||
.preload-density .detail-summary strong, .preload-density .detail-summary :deep(.status-badge) { align-self: center; margin: var(--admin-space-2, 6px) var(--admin-space-3, 8px); font-weight: 400; }
|
||||
.preload-density .detail-summary__wide { grid-column: span 2; }
|
||||
.preload-density .detail-layout :deep(.detail-page-layout__content) { gap: var(--admin-space-5, 12px); }
|
||||
.preload-density .detail-section, .preload-density .history-section { padding-top: var(--admin-space-3, 8px); }
|
||||
.preload-density .detail-section__heading h2, .preload-density .history-section h2 { font-size: var(--admin-font-section, 14px); }
|
||||
.preload-density .history-section header { margin-bottom: var(--admin-space-3, 8px); }
|
||||
.preload-density .detail-section__heading p, .preload-density small { font-size: var(--admin-font-caption, 12px); }
|
||||
.preload-density .readiness-note { align-items: baseline; }
|
||||
.preload-density .readiness-note span { white-space: normal; overflow: visible; overflow-wrap: anywhere; }
|
||||
@media (max-width: 720px) { .preload-density .detail-summary { grid-template-columns: 1fr; } .preload-density .detail-summary__wide { grid-column: auto; } }
|
||||
.loading-density { gap: var(--admin-space-5, 12px); padding: var(--admin-space-5, 12px); border-radius: var(--admin-radius, 4px); }
|
||||
.loading-density .detail-page-header { gap: var(--admin-space-5, 12px); padding: 0 0 var(--admin-space-3, 8px); }
|
||||
.loading-density .detail-page-header__identity h1 { font-size: var(--admin-font-title, 16px); overflow-wrap: anywhere; }
|
||||
.loading-density .detail-page-header__actions { flex-wrap: wrap; }
|
||||
.loading-density .detail-summary { gap: 0; padding: 0; border-left: 1px solid var(--cc-border); }
|
||||
.loading-density .detail-summary > div { gap: 0; align-items: stretch; min-height: var(--admin-row-height, 36px); border-bottom: 1px solid var(--cc-border); border-right: 1px solid var(--cc-border); }
|
||||
.loading-density .detail-summary > div > span:first-child { display: flex; align-items: center; flex: 0 0 96px; padding: var(--admin-space-2, 6px) var(--admin-space-3, 8px); background: var(--admin-label-bg, #f8fafc); }
|
||||
.loading-density .detail-summary > div > span:first-child::after { content: none; }
|
||||
.loading-density .detail-summary strong, .loading-density .detail-summary :deep(.status-badge) { align-self: center; margin: var(--admin-space-2, 6px) var(--admin-space-3, 8px); font-weight: 400; }
|
||||
.loading-density .detail-summary__wide { grid-column: span 2; }
|
||||
.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 .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; } }
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, ref } from 'vue'
|
||||
import { Download, FileUp, Trash2 } from '@lucide/vue'
|
||||
import { FileUp } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
@@ -59,7 +60,7 @@ async function remove(item: OceanBookingDocument) {
|
||||
busyDocumentId.value = item.id
|
||||
error.value = null
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认作废“${item.fileName}”?文件历史仍会保留。`, '作废订舱资料', { confirmButtonText: '确认作废', cancelButtonText: '取消', type: 'warning' })
|
||||
await ElMessageBox.confirm(`确认作废“${item.fileName}”?文件历史仍会保留。`, '作废订舱资料', { customClass: 'admin-density-confirm', confirmButtonText: '确认作废', cancelButtonText: '取消', type: 'warning' })
|
||||
await invalidate(item)
|
||||
ElMessage.success('订舱资料已作废')
|
||||
} catch (cause) {
|
||||
@@ -83,7 +84,7 @@ async function selectFile(event: Event, type: OceanBookingDocumentType) {
|
||||
error.value = null
|
||||
try {
|
||||
if (previous.length) {
|
||||
await ElMessageBox.confirm(`将上传“${file.name}”并作废当前 ${previous.length} 份同类型文件,是否继续?`, '替换订舱资料', { confirmButtonText: '确认替换', cancelButtonText: '取消', type: 'warning' })
|
||||
await ElMessageBox.confirm(`将上传“${file.name}”并作废当前 ${previous.length} 份同类型文件,是否继续?`, '替换订舱资料', { customClass: 'admin-density-confirm', confirmButtonText: '确认替换', cancelButtonText: '取消', type: 'warning' })
|
||||
}
|
||||
const uploaded = await uploadOceanBookingDocument(props.bookingId, props.bookingVersion, type, file)
|
||||
let documents = [...props.documents, uploaded.document]
|
||||
@@ -106,7 +107,7 @@ async function selectFile(event: Event, type: OceanBookingDocumentType) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="surface-panel booking-documents" aria-label="订舱资料">
|
||||
<section class="surface-panel booking-documents admin-density" aria-label="订舱资料">
|
||||
<div class="booking-documents__heading"><div><h2>订舱资料</h2><p>文件统一归属当前订舱;放舱要求至少一份有效的订舱确认文件或提单文件。</p></div><el-tag v-if="bookingStatus === 'RELEASED'" type="info">已放舱 · 永久只读</el-tag></div>
|
||||
<el-alert v-if="bookingStatus === 'DRAFT' && !editable && manageReason" type="warning" :closable="false" show-icon :title="manageReason" />
|
||||
<el-alert v-if="error" type="error" :closable="true" show-icon @close="error = null"><template #title>{{ error.message }}</template><ApiErrorMeta :details="error" /></el-alert>
|
||||
@@ -114,7 +115,7 @@ async function selectFile(event: Event, type: OceanBookingDocumentType) {
|
||||
<article v-for="type in types" :key="type.value" class="booking-document-card">
|
||||
<div class="booking-document-card__heading"><div><h3>{{ type.label }}</h3><p>{{ type.description }}</p><small class="booking-document-limit">任意格式,单文件不超过 50MB</small></div><label v-if="editable" class="booking-document-upload" :class="{ 'is-disabled': Boolean(busyType || busyDocumentId) }"><FileUp :size="15" /><span>{{ busyType === type.value ? '上传处理中' : (documentsOfType(type.value).length ? '替换文件' : '上传文件') }}</span><input type="file" :disabled="Boolean(busyType || busyDocumentId)" @change="selectFile($event, type.value)" /></label></div>
|
||||
<el-empty v-if="!documentsOfType(type.value).length" description="暂无有效文件" :image-size="48" />
|
||||
<ul v-else class="booking-document-list"><li v-for="item in documentsOfType(type.value)" :key="item.id"><div><strong :title="item.fileName">{{ item.fileName }}</strong><small>{{ fileSize(item.sizeBytes) }} · {{ item.uploadedByName || item.uploadedBy }} · {{ item.uploadedAt }}</small></div><div class="booking-document-actions"><el-button size="small" plain :loading="busyDocumentId === item.id" @click="download(item)"><Download :size="14" />下载</el-button><el-button v-if="editable" size="small" type="danger" plain :loading="busyDocumentId === item.id" @click="remove(item)"><Trash2 :size="14" />作废</el-button></div></li></ul>
|
||||
<ul v-else class="booking-document-list"><li v-for="item in documentsOfType(type.value)" :key="item.id"><div><strong :title="item.fileName">{{ item.fileName }}</strong><small>{{ fileSize(item.sizeBytes) }} · {{ item.uploadedByName || item.uploadedBy }} · {{ item.uploadedAt }}</small></div><div class="booking-document-actions density-actions"><el-button class="density-action" size="small" plain :loading="busyDocumentId === item.id" @click="download(item)">下载</el-button><el-button data-tone="danger" class="density-action" v-if="editable" size="small" type="danger" plain :loading="busyDocumentId === item.id" @click="remove(item)">作废</el-button></div></li></ul>
|
||||
</article>
|
||||
</div>
|
||||
<details v-if="invalidDocuments.length" class="booking-document-history"><summary>查看已作废资料({{ invalidDocuments.length }})</summary><ul><li v-for="item in invalidDocuments" :key="item.id"><span>{{ item.fileName }}</span><small>{{ item.invalidatedByName || item.invalidatedBy || '系统' }} · {{ item.invalidatedAt || '—' }}</small></li></ul></details>
|
||||
@@ -122,6 +123,6 @@ async function selectFile(event: Event, type: OceanBookingDocumentType) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.booking-documents{padding:16px}.booking-documents__heading,.booking-document-card__heading,.booking-document-list li,.booking-document-actions{display:flex;align-items:center}.booking-documents__heading,.booking-document-card__heading,.booking-document-list li{justify-content:space-between;gap:16px}.booking-documents__heading{margin-bottom:14px}.booking-documents h2,.booking-document-card h3{margin:0}.booking-documents__heading h2{font-size:16px}.booking-documents__heading p,.booking-document-card__heading p{margin:4px 0 0;color:var(--cc-muted);font-size:12px}.booking-document-limit{display:block;margin-top:5px;color:var(--cc-muted);font-size:11px}.booking-document-types{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.booking-document-card{min-width:0;padding:14px;border:1px solid var(--cc-border);border-radius:8px}.booking-document-card h3{font-size:14px}.booking-document-upload{display:flex;flex:0 0 auto;align-items:center;gap:6px;min-height:32px;padding:0 12px;border:1px solid var(--cc-primary);border-radius:6px;color:var(--cc-primary);font-size:12px;cursor:pointer}.booking-document-upload.is-disabled{cursor:not-allowed;opacity:.55}.booking-document-upload input{position:absolute;width:1px;height:1px;overflow:hidden;opacity:0}.booking-document-list,.booking-document-history ul{display:grid;gap:8px;margin:12px 0 0;padding:0;list-style:none}.booking-document-list li{min-width:0;padding:9px 10px;border-radius:6px;background:var(--el-fill-color-lighter)}.booking-document-list li>div:first-child{display:grid;min-width:0;gap:3px}.booking-document-list strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.booking-document-list small,.booking-document-history small{color:var(--cc-muted);font-size:11px}.booking-document-actions{flex:0 0 auto;gap:6px}.booking-document-actions :deep(.el-button){margin-left:0}.booking-document-history{margin-top:14px;color:var(--cc-muted);font-size:12px}.booking-document-history summary{cursor:pointer}.booking-document-history li{display:flex;justify-content:space-between;gap:12px;padding:7px 10px;border-bottom:1px solid var(--cc-border)}@media(max-width:900px){.booking-document-types{grid-template-columns:1fr}}@media(max-width:560px){.booking-documents__heading,.booking-document-card__heading,.booking-document-list li{align-items:flex-start;flex-direction:column}.booking-document-actions{width:100%}}
|
||||
.booking-documents{padding:var(--admin-space-5)}.booking-documents__heading,.booking-document-card__heading,.booking-document-list li,.booking-document-actions{display:flex;align-items:center}.booking-documents__heading,.booking-document-card__heading,.booking-document-list li{justify-content:space-between;gap:var(--admin-space-5)}.booking-documents__heading{margin-bottom:var(--admin-space-5)}.booking-documents h2,.booking-document-card h3{margin:0}.booking-documents__heading h2{font-size:var(--admin-font-section)}.booking-documents__heading p,.booking-document-card__heading p{margin:var(--admin-space-1) 0 0;color:var(--cc-muted);font-size:var(--admin-font-caption)}.booking-document-limit{display:block;margin-top:5px;color:var(--cc-muted);font-size:var(--admin-font-caption)}.booking-document-types{display:grid;grid-template-columns:1fr;gap:0}.booking-document-card{min-width:0;border:0;border-top:1px solid var(--cc-border);border-radius:0;padding:var(--admin-space-3) 0}.booking-document-card h3{font-size:var(--admin-font-section)}.booking-document-upload{display:flex;flex:0 0 auto;align-items:center;gap:var(--admin-space-2);min-height:var(--admin-control-height);padding:0 var(--admin-space-5);border:1px solid var(--cc-primary);border-radius:var(--admin-radius);color:var(--cc-primary);font-size:var(--admin-font-caption);cursor:pointer}.booking-document-upload.is-disabled{cursor:not-allowed;opacity:.55}.booking-document-upload input{position:absolute;width:1px;height:1px;overflow:hidden;opacity:0}.booking-document-list,.booking-document-history ul{display:grid;gap:var(--admin-space-3);margin:var(--admin-space-5) 0 0;padding:0;list-style:none}.booking-document-list li{min-width:0;border-radius:0;background:transparent;border-bottom:1px solid var(--cc-border);padding:var(--admin-space-2) 0}.booking-document-list li>div:first-child{display:grid;min-width:0;gap:3px}.booking-document-list strong{white-space:normal;overflow-wrap:anywhere}.booking-document-list small,.booking-document-history small{color:var(--cc-muted);font-size:var(--admin-font-caption)}.booking-document-actions{flex:0 0 auto}.booking-document-actions :deep(.el-button){margin-left:0}.booking-document-history{margin-top:var(--admin-space-5);color:var(--cc-muted);font-size:var(--admin-font-caption)}.booking-document-history summary{cursor:pointer}.booking-document-history li{display:flex;justify-content:space-between;gap:var(--admin-space-5);padding:7px var(--admin-space-4);border-bottom:1px solid var(--cc-border)}@media(max-width:900px){.booking-document-types{grid-template-columns:1fr;gap:0}}@media(max-width:560px){.booking-documents__heading,.booking-document-card__heading,.booking-document-list li{align-items:flex-start;flex-direction:column}.booking-document-actions{width:100%}}
|
||||
.booking-document-history li span{min-width:0;overflow-wrap:anywhere}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { Download, FileText } from '@lucide/vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
@@ -68,7 +69,7 @@ onBeforeUnmount(releasePreview)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog
|
||||
<AppDialog class="admin-density-dialog"
|
||||
v-model="open"
|
||||
:title="previewUrl ? previewTitle : '选择打印单据'"
|
||||
width="min(1120px, calc(100vw - 48px))"
|
||||
@@ -104,5 +105,5 @@ onBeforeUnmount(releasePreview)
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.booking-print-current{margin:0 0 16px;color:var(--cc-muted);font-size:13px}.booking-print-current strong{color:var(--cc-text)}.booking-print-options{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.booking-print-option{display:grid;grid-template-columns:auto auto minmax(0,1fr);align-items:center;gap:12px;min-height:92px;padding:16px;border:1px solid var(--cc-border);border-radius:10px;background:#fff;cursor:pointer}.booking-print-option.is-selected{border-color:var(--cc-primary);background:var(--cc-primary-soft)}.booking-print-option span{display:grid;min-width:0;gap:4px}.booking-print-option strong{color:var(--cc-text);font-size:15px}.booking-print-option small{color:var(--cc-muted);font-size:12px}.booking-print-preview{min-height:0;overflow:hidden;border:1px solid var(--cc-border);border-radius:8px;background:#f3f5f8}.booking-print-preview iframe{display:block;width:100%;height:min(72vh,760px);border:0;background:#fff}.booking-print-footer{width:100%}.sr-only{position:absolute;width:1px;height:1px;padding:0;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media(max-width:720px){.booking-print-options{grid-template-columns:1fr}.booking-print-preview iframe{height:68vh}}
|
||||
.booking-print-current{margin:0 0 var(--admin-space-5);color:var(--cc-muted);font-size:var(--admin-font-body)}.booking-print-current strong{color:var(--cc-text)}.booking-print-options{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--admin-space-5)}.booking-print-option{display:grid;grid-template-columns:auto auto minmax(0,1fr);align-items:center;gap:var(--admin-space-5);border:1px solid var(--cc-border);border-radius:var(--admin-radius);background:var(--admin-surface);cursor:pointer;min-height:var(--admin-empty-height, 64px);padding:var(--admin-space-3) var(--admin-space-5)}.booking-print-option.is-selected{border-color:var(--cc-primary);background:var(--cc-primary-soft)}.booking-print-option span{display:grid;min-width:0;gap:var(--admin-space-1)}.booking-print-option strong{color:var(--cc-text);font-size:var(--admin-font-section)}.booking-print-option small{color:var(--cc-muted);font-size:var(--admin-font-caption)}.booking-print-preview{min-height:0;overflow:hidden;border:1px solid var(--cc-border);border-radius:var(--admin-radius);background:#f3f5f8}.booking-print-preview iframe{display:block;width:100%;height:min(72vh,760px);border:0;background:var(--admin-surface)}.booking-print-footer{width:100%}.sr-only{position:absolute;width:1px;height:1px;padding:0;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media(max-width:720px){.booking-print-options{grid-template-columns:1fr}.booking-print-preview iframe{height:68vh}}
|
||||
</style>
|
||||
|
||||
@@ -117,8 +117,8 @@ describe('OceanBookingDetailView draft and release contract', () => {
|
||||
|
||||
it('keeps a DRAFT detail read-only when the user lacks write permission', () => {
|
||||
expect(source).toContain('const formWritable = computed(() => canSave.value)')
|
||||
expect(source).toContain(':disabled="!formWritable" class="booking-groups"')
|
||||
expect(source).toContain('.booking-groups{display:grid;grid-template-columns:minmax(0,1.35fr) minmax(0,1fr);gap:14px}')
|
||||
expect(source).toContain(':disabled="!formWritable" class="booking-groups density-form"')
|
||||
expect(source).toContain('.booking-groups{display:grid;grid-template-columns:minmax(0,1.35fr) minmax(0,1fr);gap:var(--admin-space-5)}')
|
||||
expect(source).toContain('.booking-group:last-child{grid-column:1/-1}')
|
||||
expect(source).toContain('.container-table-wrap{max-width:100%;overflow-x:auto')
|
||||
expect(source).toContain('v-if="formWritable" class="container-actions"')
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, Plus, Printer, Save, Trash2 } from '@lucide/vue'
|
||||
import { ArrowLeft, Printer, Save, Trash2 } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
@@ -348,7 +349,7 @@ async function removeDraft() {
|
||||
if (!detail.value || !canDelete.value) return
|
||||
actionError.value = null
|
||||
try {
|
||||
await ElMessageBox.confirm('确认删除该订舱草稿?删除后无法恢复。', '删除草稿', { confirmButtonText: '确认删除', cancelButtonText: '取消', type: 'warning' })
|
||||
await ElMessageBox.confirm('确认删除该订舱草稿?删除后无法恢复。', '删除草稿', { customClass: 'admin-density-confirm', confirmButtonText: '确认删除', cancelButtonText: '取消', type: 'warning' })
|
||||
actionSaving.value = true
|
||||
await deleteOceanBookingDraft(id.value, detail.value.version)
|
||||
ElMessage.success('订舱草稿已删除')
|
||||
@@ -367,12 +368,12 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage as="div" class="booking-detail">
|
||||
<AdminPage as="div" class="booking-detail admin-density">
|
||||
<OperationErrorToast :details="actionError" />
|
||||
<DetailPageLayout aria-label="订舱详情">
|
||||
<template #header>
|
||||
<div class="detail-heading">
|
||||
<el-button @click="router.push('/ocean-bookings')"><ArrowLeft :size="16" />返回列表</el-button>
|
||||
<el-button link class="density-back" @click="router.push('/ocean-bookings')"><ArrowLeft :size="16" />返回列表</el-button>
|
||||
<PageToolbar class="booking-page-toolbar" as="section" :title="detail?.bookingNo || '订舱草稿'" :description="detail ? `${detail.customerName || '未关联客户'} · ${detail.pol || '—'} → ${detail.pod || '—'}` : '先保存订舱草稿,资料完整后再确认放舱'">
|
||||
<template v-if="detail" #actions><div class="heading-status"><StatusBadge :status="statusLabel(detail.status)" /></div></template>
|
||||
</PageToolbar>
|
||||
@@ -392,7 +393,7 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
</div>
|
||||
</div>
|
||||
<el-alert v-if="optionsError" type="warning" :closable="false" show-icon :title="optionsError"><el-button link type="warning" :loading="optionsLoading" @click="loadOptions">重新加载选项</el-button></el-alert>
|
||||
<el-form ref="formRef" :model="form.data" :rules="rules" label-position="top" :disabled="!formWritable" class="booking-groups">
|
||||
<el-form ref="formRef" :model="form.data" :rules="rules" label-position="top" :disabled="!formWritable" class="booking-groups density-form">
|
||||
<section class="booking-group" aria-label="基本信息">
|
||||
<h3>基本信息</h3><div class="booking-group__body booking-group__body--fields">
|
||||
<el-form-item label="订舱号"><el-input :model-value="detail?.bookingNo || '保存后生成'" disabled /></el-form-item>
|
||||
@@ -425,13 +426,13 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
<el-form-item label="运费条款 Freight Term" prop="freightTerm"><el-input v-model="form.data.freightTerm" clearable maxlength="32" placeholder="请输入 Freight Term" /></el-form-item>
|
||||
</div>
|
||||
<div class="container-table-wrap" aria-label="柜型需求">
|
||||
<table class="container-table">
|
||||
<table class="container-table density-table">
|
||||
<thead><tr><th scope="col">操作</th><th scope="col">柜型</th><th scope="col">箱种</th><th scope="col">数量</th><th scope="col">容量</th><th scope="col">限重</th><th v-if="detail?.status === 'RELEASED'" scope="col">剩余可关联</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="(requirement, index) in form.data.containerRequirements" :key="requirement.requirementId || index">
|
||||
<td class="container-table__action">
|
||||
<el-tooltip v-if="formWritable" :content="form.data.containerRequirements.length === 1 ? '至少保留一项柜型需求' : '删除该柜型需求'" placement="top">
|
||||
<span><el-button circle plain size="small" type="danger" :aria-label="`删除柜型需求 ${index + 1}`" :disabled="form.data.containerRequirements.length === 1" @click="removeRequirement(index)"><Trash2 :size="14" /></el-button></span>
|
||||
<span><el-button data-tone="danger" class="density-action" plain size="small" type="danger" :aria-label="`删除柜型需求 ${index + 1}`" :disabled="form.data.containerRequirements.length === 1" @click="removeRequirement(index)">删除</el-button></span>
|
||||
</el-tooltip>
|
||||
<span v-else aria-hidden="true">—</span>
|
||||
</td>
|
||||
@@ -445,7 +446,7 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="formWritable" class="container-actions"><el-button class="add-container" size="small" :disabled="form.data.containerRequirements.length >= 20" @click="addRequirement"><Plus :size="15" />新增柜型需求</el-button><span>{{ form.data.containerRequirements.length }} / 20 项</span></div>
|
||||
<div v-if="formWritable" class="container-actions"><el-button class="add-container density-action" size="small" :disabled="form.data.containerRequirements.length >= 20" @click="addRequirement">新增柜型需求</el-button><span>{{ form.data.containerRequirements.length }} / 20 项</span></div>
|
||||
<el-form-item label="备注"><el-input v-model="form.data.remark" type="textarea" :rows="3" placeholder="请输入订舱备注(可选)" /></el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
@@ -467,18 +468,18 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
|
||||
<OceanBookingDocumentsPanel v-if="detail" :booking-id="detail.id" :booking-version="detail.version" :booking-status="detail.status" :documents="detail.documents" :can-manage="canManageDocuments" :manage-reason="documentManageReason" @updated="updateDocuments" />
|
||||
|
||||
<div v-if="detail" class="surface-panel"><CarrierScheduleQueryPanel :ocean-booking-id="detail.id" /></div>
|
||||
<div v-if="detail" class="surface-panel"><CarrierScheduleQueryPanel dense :ocean-booking-id="detail.id" /></div>
|
||||
|
||||
<section v-if="detail" class="surface-panel history-panel">
|
||||
<div class="section-title"><div><h2>状态历史</h2><p>当前进度:{{ statusLabel(detail.status) }}</p></div></div>
|
||||
<el-timeline><el-timeline-item v-for="event in detail.histories" :key="event.id" :timestamp="event.occurredAt"><strong>{{ statusLabel(event.fromStatus) }} → {{ statusLabel(event.toStatus) }}</strong><p>{{ event.operatorName || '系统' }}<span v-if="event.reason"> · {{ event.reason }}</span></p></el-timeline-item></el-timeline>
|
||||
<table class="density-table booking-history-table"><thead><tr><th>时间</th><th>状态变化</th><th>操作人</th><th>备注</th></tr></thead><tbody><tr v-for="event in detail.histories" :key="event.id"><td>{{ event.occurredAt }}</td><td>{{ statusLabel(event.fromStatus) }} → {{ statusLabel(event.toStatus) }}</td><td>{{ event.operatorName || '系统' }}</td><td>{{ event.reason || '—' }}</td></tr><tr v-if="!detail.histories.length"><td colspan="4">暂无状态历史</td></tr></tbody></table>
|
||||
</section>
|
||||
</template>
|
||||
</DetailPageLayout>
|
||||
<OceanBookingPrintDialog v-if="detail" v-model="printOpen" :booking-id="detail.id" :booking-no="detail.bookingNo" :allowed="canPrint" />
|
||||
|
||||
<AppDialog v-model="releaseDialogOpen" title="确认放舱" width="min(560px, 94vw)" :confirm-loading="actionSaving" :confirm-disabled="workflowBusy" confirm-text="确认放舱" cancel-text="取消" @confirm="submitRelease">
|
||||
<el-form label-position="top">
|
||||
<AppDialog class="admin-density-dialog" v-model="releaseDialogOpen" title="确认放舱" width="min(560px, 94vw)" :confirm-loading="actionSaving" :confirm-disabled="workflowBusy" confirm-text="确认放舱" cancel-text="取消" @confirm="submitRelease">
|
||||
<el-form class="density-form" label-position="top">
|
||||
<el-alert title="放舱后资料将同步到关联货柜,不能修改、不能删除;填写错误只能由管理员处理数据库,请确认资料无误。" type="warning" :closable="false" show-icon />
|
||||
<el-descriptions class="release-summary" :column="2" border>
|
||||
<el-descriptions-item label="提单号">{{ form.data.billOfLadingNo || '—' }}</el-descriptions-item>
|
||||
@@ -493,6 +494,6 @@ onMounted(() => { void load(); void loadOptions() })
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.channel-code{display:block;margin-left:8px;color:var(--cc-muted);font-size:11px}.field-error,.field-hint{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:5px;color:var(--cc-muted);font-size:12px;line-height:20px}.field-error{color:var(--el-color-danger)}
|
||||
.booking-detail{min-width:0;max-width:1880px}.detail-heading{display:flex;min-width:0;align-items:flex-start;gap:16px}.booking-page-toolbar{min-width:0;flex:1}.section-title p,.history-panel p{margin:5px 0 0;color:var(--cc-muted);font-size:13px}.heading-status{display:flex;align-items:center;gap:8px}.surface-panel{padding:16px}.surface-panel h2{margin:0;font-size:16px}.section-title{display:flex;justify-content:space-between;gap:12px;align-items:flex-start;margin-bottom:16px}.actions{display:flex;flex-wrap:wrap;gap:8px}.booking-groups{display:grid;grid-template-columns:minmax(0,1.35fr) minmax(0,1fr);gap:14px}.booking-group{min-width:0;overflow:hidden;border:1px solid #e8edf3;border-radius:10px;background:#fff}.booking-group:last-child{grid-column:1/-1}.booking-group>h3{margin:0;padding:12px 14px;border-bottom:1px solid #eef2f6;color:#1f2937;font-size:14px}.booking-group__body{padding:12px 14px}.booking-group__body--fields,.booking-terms{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:10px;row-gap:8px}.booking-field--wide{grid-column:1/-1}.booking-terms :deep(.el-select){width:100%}.party-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin-top:14px}.subsection{display:grid;gap:10px;padding:12px;border:1px solid var(--cc-border);border-radius:6px}.subsection h3{margin:0;font-size:14px}.required-mark{margin-right:4px;color:var(--el-color-danger)}.container-table-wrap{max-width:100%;overflow-x:auto;margin-bottom:10px}.container-table{width:100%;min-width:780px;table-layout:fixed;border-collapse:collapse;font-size:12px}.container-table th,.container-table td{padding:7px 6px;border-bottom:1px solid #eef2f6;text-align:left;vertical-align:middle}.container-table th{background:#fafafa;color:var(--cc-muted);font-weight:650;white-space:nowrap}.container-table th:first-child,.container-table td:first-child{width:56px}.container-table th:nth-child(2),.container-table td:nth-child(2){width:180px}.container-table th:nth-child(3),.container-table td:nth-child(3){width:90px}.container-table th:nth-child(4),.container-table td:nth-child(4){width:110px}.container-table th:nth-child(5),.container-table td:nth-child(5),.container-table th:nth-child(6),.container-table td:nth-child(6){width:100px}.container-table td :deep(.el-select),.container-table td :deep(.el-input){width:100%;min-width:0}.container-table__action{text-align:center!important}.container-table__action :deep(.el-button){width:28px;height:28px}.container-table__specification{color:var(--cc-muted);white-space:nowrap}.container-actions{display:flex;align-items:center;gap:10px;margin-bottom:14px;color:var(--cc-muted);font-size:12px}.add-container{margin:0}.history-panel strong{font-size:14px}@media(max-width:720px){.booking-groups,.party-grid{grid-template-columns:1fr}.booking-group:last-child{grid-column:auto}.detail-heading{flex-direction:column}.booking-page-toolbar{width:100%}.section-title{flex-direction:column}.actions{width:100%}}@media(max-width:520px){.booking-group__body--fields,.booking-terms{grid-template-columns:1fr}.booking-field--wide{grid-column:auto}}@media(max-width:420px){.surface-panel{padding:12px}.booking-group__body{padding:12px}.actions :deep(.el-button){min-height:44px}}
|
||||
.channel-code{display:block;margin-left:var(--admin-space-3);color:var(--cc-muted);font-size:var(--admin-font-caption)}.field-error,.field-hint{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-3);margin-top:5px;color:var(--cc-muted);font-size:var(--admin-font-caption);line-height:20px}.field-error{color:var(--el-color-danger)}
|
||||
.booking-detail{min-width:0}.detail-heading{display:flex;min-width:0;gap:var(--admin-space-5);padding:var(--admin-space-3) var(--admin-space-5);background:var(--admin-surface);border:1px solid var(--cc-border);align-items:center}.booking-page-toolbar{min-width:0;flex:1}.section-title p,.history-panel p{margin:5px 0 0;color:var(--cc-muted);font-size:var(--admin-font-body)}.heading-status{display:flex;align-items:center;gap:var(--admin-space-3)}.surface-panel{padding:var(--admin-space-5)}.surface-panel h2{margin:0;font-size:var(--admin-font-section)}.section-title{display:flex;justify-content:space-between;gap:var(--admin-space-5);align-items:flex-start;margin-bottom:var(--admin-space-5)}.actions{display:flex;flex-wrap:wrap;gap:var(--admin-space-3)}.booking-groups{display:grid;grid-template-columns:minmax(0,1.35fr) minmax(0,1fr);gap:var(--admin-space-5)}.booking-group{min-width:0;overflow:hidden;background:var(--admin-surface);border:0}.booking-group:last-child{grid-column:1/-1}.booking-group>h3{margin:0;color:#1f2937;font-size:var(--admin-font-section);padding:var(--admin-space-3) 0;border-bottom:1px solid var(--cc-border)}.booking-group__body{padding:var(--admin-space-3) 0 0}.booking-group__body--fields,.booking-terms{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:var(--admin-space-4);row-gap:var(--admin-space-3)}.booking-field--wide{grid-column:1/-1}.booking-terms :deep(.el-select){width:100%}.party-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-4);margin-top:var(--admin-space-5)}.subsection{display:grid;gap:var(--admin-space-4);border:0;padding:0}.subsection h3{margin:0;font-size:var(--admin-font-section)}.required-mark{margin-right:var(--admin-space-1);color:var(--el-color-danger)}.container-table-wrap{max-width:100%;overflow-x:auto;margin-bottom:var(--admin-space-4)}.container-table{width:100%;min-width:780px;table-layout:fixed}.container-table th:first-child,.container-table td:first-child{width:80px}.container-table th:nth-child(2),.container-table td:nth-child(2){width:180px}.container-table th:nth-child(3),.container-table td:nth-child(3){width:90px}.container-table th:nth-child(4),.container-table td:nth-child(4){width:110px}.container-table th:nth-child(5),.container-table td:nth-child(5),.container-table th:nth-child(6),.container-table td:nth-child(6){width:100px}.container-table td :deep(.el-select),.container-table td :deep(.el-input){width:100%;min-width:0}.container-table__action{text-align:center!important}.container-table__specification{color:var(--cc-muted);white-space:nowrap}.container-actions{display:flex;align-items:center;gap:var(--admin-space-4);margin-bottom:var(--admin-space-5);color:var(--cc-muted);font-size:var(--admin-font-caption)}.add-container{margin:0}@media(max-width:720px){.booking-groups,.party-grid{grid-template-columns:1fr}.booking-group:last-child{grid-column:auto}.detail-heading{flex-direction:column;padding:var(--admin-space-3) var(--admin-space-5);background:var(--admin-surface);border:1px solid var(--cc-border);align-items:center}.booking-page-toolbar{width:100%}.section-title{flex-direction:column}.actions{width:100%}}@media(max-width:520px){.booking-group__body--fields,.booking-terms{grid-template-columns:1fr}.booking-field--wide{grid-column:auto}}@media(max-width:420px){.surface-panel{padding:12px}.booking-group__body{padding:var(--admin-space-3) 0 0}.actions :deep(.el-button){min-height:44px}}.booking-history-table th:nth-child(1){width:180px}.booking-history-table th:nth-child(2){width:220px}.booking-history-table th:nth-child(3){width:140px}.booking-page-toolbar :deep(h1){font-size:var(--admin-font-title)}
|
||||
</style>
|
||||
|
||||
@@ -32,7 +32,7 @@ describe('OceanBookingsView', () => {
|
||||
})
|
||||
|
||||
it('edits DRAFT rows and views RELEASED rows', () => {
|
||||
expect(source).toContain('<Pencil v-if="row.status === \'DRAFT\'"')
|
||||
expect(source).toContain('class="density-action"')
|
||||
expect(source).toContain("row.status === 'DRAFT' ? '编辑' : '查看'")
|
||||
expect(source).not.toContain('MoreHorizontal')
|
||||
expect(source).toContain('打印单据')
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onActivated, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Eye, Pencil, Plus, Printer } from '@lucide/vue'
|
||||
import { Plus } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import DataTable from '@/shared/components/DataTable.vue'
|
||||
@@ -68,14 +69,14 @@ watch([page, pageSize], () => void load()); onMounted(() => void loadCustomers()
|
||||
onActivated(() => void load())
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage as="div" class="ocean-bookings-page">
|
||||
<AdminPage as="div" class="ocean-bookings-page admin-density">
|
||||
<ListPageLayout aria-label="订舱列表">
|
||||
<template #toolbar><QueryToolbar v-model="query" class="surface-panel" ariaLabel="订舱筛选" :fields="fields" @search="search" /></template>
|
||||
<template v-if="error" #state><LoadErrorState title="订舱列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
|
||||
<section v-if="!error" class="surface-panel table-panel">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" stripe preference-key="ocean-bookings" aria-label="订舱列表" row-key="id" empty-text="暂无符合筛选条件的订舱">
|
||||
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="createBooking"><Plus :size="16" />新增订舱</el-button></template>
|
||||
<template #actions="{ row }"><div class="table-actions"><el-button size="small" type="primary" plain @click="open(row.id)"><Pencil v-if="row.status === 'DRAFT'" :size="15" /><Eye v-else :size="15" />{{ row.status === 'DRAFT' ? '编辑' : '查看' }}</el-button><el-tooltip :content="printHint()" placement="top"><span><el-button size="small" plain :disabled="!canPrint" @click="openPrint(row)"><Printer :size="15" />打印单据</el-button></span></el-tooltip></div></template>
|
||||
<template #actions="{ row }"><div class="table-actions density-actions"><el-button data-tone="primary" class="density-action" size="small" type="primary" plain @click="open(row.id)">{{ row.status === 'DRAFT' ? '编辑' : '查看' }}</el-button><el-tooltip :content="printHint()" placement="top"><span><el-button class="density-action" size="small" plain :disabled="!canPrint" @click="openPrint(row)">打印单据</el-button></span></el-tooltip></div></template>
|
||||
<template #vessel="{ row }">{{ row.vesselName || '—' }}<span v-if="row.voyageNo"> / {{ row.voyageNo }}</span></template>
|
||||
<template #etd="{ row }">{{ formatDateTime(row.etd) }}</template>
|
||||
<template #eta="{ row }">{{ formatDateTime(row.eta) }}</template>
|
||||
@@ -87,4 +88,4 @@ onActivated(() => void load())
|
||||
<OceanBookingPrintDialog v-if="printBooking" v-model="printOpen" :booking-id="printBooking.id" :booking-no="printBooking.bookingNo" :allowed="canPrint" />
|
||||
</AdminPage>
|
||||
</template>
|
||||
<style scoped>.ocean-bookings-page{min-width:0}.table-actions{display:flex;align-items:center;flex-wrap:wrap;gap:6px}.table-actions :deep(.el-button){margin-left:0}@media(max-width:420px){.table-actions{flex-direction:column;align-items:stretch}.table-actions :deep(.el-button){width:100%;min-height:40px}}</style>
|
||||
<style scoped>.ocean-bookings-page{min-width:0}.table-actions{flex-wrap:wrap}.table-actions :deep(.el-button){margin-left:0}@media(max-width:420px){.table-actions{flex-direction:column}.table-actions :deep(.el-button){width:100%;min-height:40px}}</style>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { Plus } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
@@ -19,7 +20,7 @@ type WarehouseTimelineEntry = {
|
||||
operatorName?: string | null; occurredAt?: string | null
|
||||
}
|
||||
type FulfillmentEvent = { key: string; time: string; type: '仓内操作' | '确认离仓' | '船运节点' | '人工补录' | '异常'; title: string; summary: string; source: string; actual: boolean }
|
||||
const props = defineProps<{ oceanBookingId?: string | null; containerBookingId?: string | null; portScheduleId?: string | null; warehouseTimeline?: WarehouseTimelineEntry[] }>()
|
||||
const props = defineProps<{ dense?: boolean; oceanBookingId?: string | null; containerBookingId?: string | null; portScheduleId?: string | null; warehouseTimeline?: WarehouseTimelineEntry[] }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const loading = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
@@ -253,8 +254,8 @@ watch(manualDialogOpen, (open) => { if (!open && !manualSaving.value) { manualId
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="carrier-query" aria-label="货柜履约轨迹">
|
||||
<header><div><h2>货柜履约轨迹</h2><p>统一展示仓内操作、确认离仓与船运节点;预计节点不会推进实际进度。</p></div><div class="header-actions"><span class="history-count">仓内 {{ filteredWarehouseTimeline.length }} 条 · 共 {{ fulfillmentEvents.length }} 条</span><el-button v-if="props.containerBookingId && access.can('platform:firstleg:port-schedule-query:manual')" size="small" type="primary" plain @click="openManualDialog"><Plus :size="15" />人工补录节点</el-button><el-tag v-if="query" :type="statusType" effect="light">{{ statusText }}</el-tag></div></header>
|
||||
<section class="carrier-query" :class="{ 'admin-density': dense }" aria-label="货柜履约轨迹">
|
||||
<header><div><h2>货柜履约轨迹</h2><p>统一展示仓内操作、确认离仓与船运节点;预计节点不会推进实际进度。</p></div><div class="header-actions"><span class="history-count">仓内 {{ filteredWarehouseTimeline.length }} 条 · 共 {{ fulfillmentEvents.length }} 条</span><el-button v-if="props.containerBookingId && access.can('platform:firstleg:port-schedule-query:manual')" :class="{ 'density-action': dense }" data-tone="primary" size="small" type="primary" plain @click="openManualDialog"><Plus v-if="!dense" :size="15" />人工补录节点</el-button><el-tag v-if="query" :type="statusType" effect="light">{{ statusText }}</el-tag></div></header>
|
||||
<LoadErrorState v-if="error" compact title="船节点查询状态加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<div v-else-if="loading" class="loading-state"><el-skeleton :rows="1" animated /></div>
|
||||
<template v-else>
|
||||
@@ -267,9 +268,9 @@ watch(manualDialogOpen, (open) => { if (!open && !manualSaving.value) { manualId
|
||||
<p v-else class="empty-line">暂无货柜履约记录</p>
|
||||
<p v-if="!query" class="tracking-prompt">当前未开启跟踪任务;可独立使用人工补录修改货柜当前节点。</p>
|
||||
<details v-if="supersededActualNodes.length" class="technical-details duplicate-node-history"><summary>历史重复实际节点({{ supersededActualNodes.length }})</summary><ol class="technical-history"><li v-for="item in supersededActualNodes" :key="item.id"><span>{{ nodeTimeText(item) }}</span><strong>{{ nodeText(item.standardNode) }}</strong><small>{{ nodeLocation(item) }} · {{ item.providerCode }}<template v-if="item.sourceReference"> · {{ item.sourceReference }}</template></small></li></ol></details>
|
||||
<details v-if="query" class="technical-details"><summary>查询任务摘要、异常与变更历史</summary><dl class="query-facts"><div><dt>查询任务</dt><dd>{{ query.queryNo || query.id }}</dd></div><div><dt>跟踪标识</dt><dd>{{ trackingTypeText(query.trackingType) }} · {{ query.trackingNo || '—' }}</dd></div><div><dt>任务状态</dt><dd>{{ statusText }}</dd></div><div><dt>建立来源</dt><dd>{{ triggerSourceText(query.triggerSource) }}</dd></div><div><dt>最新实际节点</dt><dd>{{ nodeText(query.latestActualNode) }}</dd></div><div><dt>实际发生时间</dt><dd>{{ formatTime(query.latestActualAt) }}</dd></div><div><dt>最新来源</dt><dd>{{ latestSource }}</dd></div><div><dt>来源参考</dt><dd class="break-value">{{ sourceReference }}</dd></div><div><dt>观测时间</dt><dd>{{ formatTime(latestObservation) }}</dd></div><div><dt>最近成功</dt><dd>{{ formatTime(query.lastSuccessAt) }}</dd></div><div><dt>结果有效性</dt><dd>{{ queryView?.expired ? '结果已过期' : '结果有效' }}</dd></div><div v-if="query.lastErrorCode"><dt>最近错误码</dt><dd>{{ query.lastErrorCode }}</dd></div></dl><h3>任务变更历史</h3><p v-if="!histories.length" class="empty-line">暂无变更历史</p><ol v-else class="technical-history"><li v-for="item in histories" :key="item.id"><span>{{ formatTime(item.createdAt) }}</span><strong>{{ historyText(item.action) }}</strong><small>{{ item.operatorName || '系统' }}<template v-if="item.reason"> · {{ item.reason }}</template></small></li></ol></details>
|
||||
<details v-if="query" class="technical-details"><summary>查询任务摘要、异常与变更历史</summary><dl class="query-facts" :class="{ 'density-fields': dense }"><div><dt>查询任务</dt><dd>{{ query.queryNo || query.id }}</dd></div><div><dt>跟踪标识</dt><dd>{{ trackingTypeText(query.trackingType) }} · {{ query.trackingNo || '—' }}</dd></div><div><dt>任务状态</dt><dd>{{ statusText }}</dd></div><div><dt>建立来源</dt><dd>{{ triggerSourceText(query.triggerSource) }}</dd></div><div><dt>最新实际节点</dt><dd>{{ nodeText(query.latestActualNode) }}</dd></div><div><dt>实际发生时间</dt><dd>{{ formatTime(query.latestActualAt) }}</dd></div><div><dt>最新来源</dt><dd>{{ latestSource }}</dd></div><div><dt>来源参考</dt><dd class="break-value">{{ sourceReference }}</dd></div><div><dt>观测时间</dt><dd>{{ formatTime(latestObservation) }}</dd></div><div><dt>最近成功</dt><dd>{{ formatTime(query.lastSuccessAt) }}</dd></div><div><dt>结果有效性</dt><dd>{{ queryView?.expired ? '结果已过期' : '结果有效' }}</dd></div><div v-if="query.lastErrorCode"><dt>最近错误码</dt><dd>{{ query.lastErrorCode }}</dd></div></dl><h3>任务变更历史</h3><p v-if="!histories.length" class="empty-line">暂无变更历史</p><ol v-else class="technical-history"><li v-for="item in histories" :key="item.id"><span>{{ formatTime(item.createdAt) }}</span><strong>{{ historyText(item.action) }}</strong><small>{{ item.operatorName || '系统' }}<template v-if="item.reason"> · {{ item.reason }}</template></small></li></ol></details>
|
||||
</template>
|
||||
<AppDialog v-model="manualDialogOpen" title="人工补录船节点" width="min(620px, calc(100vw - 28px))" confirm-text="确认补录" :confirm-loading="manualSaving" @confirm="submitManualNode">
|
||||
<AppDialog :class="{ 'admin-density-dialog': dense }" v-model="manualDialogOpen" title="人工补录船节点" width="min(620px, calc(100vw - 28px))" confirm-text="确认补录" :confirm-loading="manualSaving" @confirm="submitManualNode">
|
||||
<el-alert v-if="manualError" type="error" :closable="false" show-icon title="补录未成功,已保留当前填写内容"><template #default><ApiErrorMeta :details="manualError" /></template></el-alert>
|
||||
<el-alert v-if="manualValidationError" type="warning" :closable="false" show-icon :title="manualValidationError" />
|
||||
<el-alert type="info" :closable="false" title="观测时间按提交时刻记录;预计事实不推进实际进度,既有事实不可编辑或删除。" />
|
||||
@@ -284,6 +285,6 @@ watch(manualDialogOpen, (open) => { if (!open && !manualSaving.value) { manualId
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.history-count{color:var(--cc-muted);font-size:12px;white-space:nowrap}
|
||||
.carrier-query{display:grid;gap:9px;padding-top:2px}.carrier-query>header{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}.header-actions{display:flex;align-items:center;gap:8px}.carrier-query h2{margin:0;font-size:15px}.carrier-query header p{margin:3px 0 0;color:var(--cc-muted);font-size:12px}.loading-state{padding:6px 0}.latest-result{display:flex;flex-wrap:wrap;align-items:center;gap:6px 16px;padding:7px 10px;border-left:3px solid var(--el-color-primary);background:var(--el-color-primary-light-9);font-size:12px}.fulfillment-timeline{display:grid;margin:0;padding:0;list-style:none}.fulfillment-timeline li{display:grid;grid-template-columns:130px 86px 150px minmax(180px,1fr) minmax(120px,.8fr);align-items:center;gap:7px;padding:6px 2px;border-top:1px solid var(--cc-border);font-size:12px}.fulfillment-timeline time,.fulfillment-timeline span,.fulfillment-timeline small{overflow:hidden;color:var(--cc-muted);text-overflow:ellipsis;white-space:nowrap}.tracking-prompt{margin:0;padding:7px 10px;border-left:3px solid var(--el-color-info);background:var(--cc-bg-subtle,#f7f8fa);color:var(--cc-muted);font-size:12px}.empty-line{margin:0;color:var(--cc-muted);font-size:12px}.technical-details{border-top:1px solid var(--cc-border);font-size:12px}.technical-details summary{padding:7px 2px;color:var(--cc-primary);cursor:pointer}.query-facts{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px 16px;margin:4px 0 10px}.query-facts>div{min-width:0}.query-facts dt{color:var(--cc-muted);font-size:11px}.query-facts dd{margin:2px 0 0;font-size:12px}.break-value{overflow-wrap:anywhere}.technical-details h3{margin:8px 0 5px;font-size:12px}.technical-history{margin:0;padding:0;list-style:none}.technical-history li{display:grid;grid-template-columns:130px 150px minmax(0,1fr);gap:8px;padding:5px 0;border-top:1px solid var(--cc-border)}.technical-history small{color:var(--cc-muted)}.manual-form{display:grid;gap:2px;margin-top:12px}.manual-form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}@media(max-width:1200px){.query-facts{grid-template-columns:repeat(2,minmax(0,1fr))}.fulfillment-timeline li{grid-template-columns:120px 82px 140px minmax(0,1fr)}.fulfillment-timeline small{grid-column:4}}@media(max-width:720px){.carrier-query>header{align-items:flex-start;flex-direction:column}.query-facts,.manual-form-grid{grid-template-columns:1fr}.fulfillment-timeline li{grid-template-columns:90px minmax(0,1fr)}.fulfillment-timeline strong,.fulfillment-timeline span,.fulfillment-timeline small{grid-column:2}.technical-history li{grid-template-columns:1fr}}
|
||||
.history-count{color:var(--cc-muted);font-size:var(--admin-font-caption, 12px);white-space:nowrap}
|
||||
.carrier-query{display:grid;gap:9px;padding-top:2px}.carrier-query>header{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--admin-space-5, 12px)}.header-actions{display:flex;align-items:center;gap:var(--admin-space-3, 8px)}.carrier-query h2{margin:0;font-size:var(--admin-font-section, 15px)}.carrier-query header p{margin:3px 0 0;color:var(--cc-muted);font-size:var(--admin-font-caption, 12px)}.loading-state{padding:var(--admin-space-2, 6px) 0}.latest-result{display:flex;flex-wrap:wrap;align-items:center;gap:var(--admin-space-2, 6px) var(--admin-space-6, 16px);padding:7px var(--admin-space-4, 10px);border-left:3px solid var(--el-color-primary);background:var(--el-color-primary-light-9);font-size:var(--admin-font-caption, 12px)}.fulfillment-timeline{display:grid;margin:0;padding:0;list-style:none}.fulfillment-timeline li{display:grid;grid-template-columns:130px 86px 150px minmax(180px,1fr) minmax(120px,.8fr);align-items:center;gap:7px;padding:var(--admin-space-2, 6px) 2px;border-top:1px solid var(--cc-border);font-size:var(--admin-font-caption, 12px)}.fulfillment-timeline time,.fulfillment-timeline span,.fulfillment-timeline small{overflow:hidden;color:var(--cc-muted);text-overflow:ellipsis;white-space:nowrap}.tracking-prompt{margin:0;padding:7px var(--admin-space-4, 10px);border-left:3px solid var(--el-color-info);background:var(--cc-bg-subtle,#f7f8fa);color:var(--cc-muted);font-size:var(--admin-font-caption, 12px)}.empty-line{margin:0;color:var(--cc-muted);font-size:var(--admin-font-caption, 12px)}.technical-details{border-top:1px solid var(--cc-border);font-size:var(--admin-font-caption, 12px)}.technical-details summary{padding:7px 2px;color:var(--cc-primary);cursor:pointer}.query-facts:not(.density-fields){display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--admin-space-3, 8px) var(--admin-space-6, 16px);margin:4px 0 var(--admin-space-4, 10px)}.query-facts:not(.density-fields)>div{min-width:0}.query-facts:not(.density-fields) dt{color:var(--cc-muted);font-size:var(--admin-font-caption, 11px)}.query-facts:not(.density-fields) dd{margin:2px 0 0;font-size:var(--admin-font-caption, 12px)}.break-value{overflow-wrap:anywhere}.technical-details h3{margin:var(--admin-space-3, 8px) 0 5px;font-size:var(--admin-font-caption, 12px)}.technical-history{margin:0;padding:0;list-style:none}.technical-history li{display:grid;grid-template-columns:130px 150px minmax(0,1fr);gap:var(--admin-space-3, 8px);padding:5px 0;border-top:1px solid var(--cc-border)}.technical-history small{color:var(--cc-muted)}.manual-form{display:grid;gap:2px;margin-top:var(--admin-space-5, 12px)}.manual-form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--admin-space-5, 12px)}@media(max-width:1200px){.query-facts{grid-template-columns:repeat(2,minmax(0,1fr))}.fulfillment-timeline li{grid-template-columns:120px 82px 140px minmax(0,1fr)}.fulfillment-timeline small{grid-column:4}}@media(max-width:720px){.carrier-query>header{align-items:flex-start;flex-direction:column}.query-facts,.manual-form-grid{grid-template-columns:1fr}.fulfillment-timeline li{grid-template-columns:90px minmax(0,1fr)}.fulfillment-timeline strong,.fulfillment-timeline span,.fulfillment-timeline small{grid-column:2}.technical-history li{grid-template-columns:1fr}}.carrier-query.admin-density .fulfillment-timeline :is(time,span,small){overflow:visible;white-space:normal;overflow-wrap:anywhere}.carrier-query.admin-density .technical-history small{overflow-wrap:anywhere}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { Eye, Save } from '@lucide/vue'
|
||||
@@ -147,7 +148,7 @@ watch(() => props.resource?.id, (id, previous) => { if (id && id !== previous) v
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-form ref="formRef" :model="draft" :rules="rules" label-position="top" :disabled="readonly || submitting" class="truck-form">
|
||||
<el-form ref="formRef" :model="draft" :rules="rules" label-position="top" :disabled="readonly || submitting" class="truck-form admin-density density-form">
|
||||
<div class="relation-note"><span>一张已放舱订舱对应一张有效拖车单;仓库和提单号由订舱冻结资料带入,货柜资料在货柜与预装流程维护,最终资格仍由服务端校验。</span><div v-if="$slots['relation-note-actions']" class="relation-note__actions"><slot name="relation-note-actions" /></div></div>
|
||||
<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>
|
||||
@@ -193,18 +194,18 @@ watch(() => props.resource?.id, (id, previous) => { if (id && id !== previous) v
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.truck-form { padding-bottom: 4px; }
|
||||
.relation-note { display:flex; min-height:40px; align-items:center; justify-content:space-between; gap:16px; margin:0 0 8px; padding:6px 8px 6px 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; }
|
||||
.truck-form { padding-bottom: var(--admin-space-1); }
|
||||
.relation-note { display:flex; min-height:40px; align-items:center; justify-content:space-between; gap:var(--admin-space-5); margin:0 0 var(--admin-space-3); padding:var(--admin-space-2) var(--admin-space-3) var(--admin-space-2) var(--admin-space-4); border-left:3px solid var(--cc-primary); background:var(--el-fill-color-extra-light); color:var(--cc-muted); font-size:var(--admin-font-caption); line-height:1.5; }
|
||||
.relation-note > span { min-width:0; }
|
||||
.relation-note__actions { display:flex; flex:0 0 auto; align-items:center; }
|
||||
.relation-note__actions :deep(.el-button) { margin-left:0; }
|
||||
.relation-alert { margin-bottom: 16px; }
|
||||
.form-actions { display: flex; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
|
||||
:deep(.el-divider) { margin: 14px 0 10px; }
|
||||
:deep(.el-divider__text) { color: var(--el-text-color-primary); font-size: 14px; font-weight: 600; }
|
||||
.relation-alert { margin-bottom: var(--admin-space-5); }
|
||||
.form-actions { display: flex; justify-content: flex-end; gap: var(--admin-space-3); flex-wrap: wrap; }
|
||||
:deep(.el-divider) { margin: var(--admin-space-5) 0 var(--admin-space-4); }
|
||||
:deep(.el-divider__text) { color: var(--el-text-color-primary); font-size: var(--admin-font-section); font-weight: 600; }
|
||||
:deep(.el-input), :deep(.el-textarea), :deep(.el-date-editor) { max-width: 100%; }
|
||||
:deep(.el-select__selected-item), :deep(.el-select-dropdown__item) { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.field-error,.field-hint { display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:4px;color:var(--cc-muted);font-size:12px;line-height:20px }.field-error{color:var(--el-color-danger)}
|
||||
.field-error,.field-hint { display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-3);margin-top:var(--admin-space-1);color:var(--cc-muted);font-size:var(--admin-font-caption);line-height:20px }.field-error{color:var(--el-color-danger)}
|
||||
@media (min-width: 1200px) {
|
||||
.truck-form :deep(.el-col-sm-12), .truck-form :deep(.el-col-sm-8) { max-width:25%; flex:0 0 25%; }
|
||||
.truck-form :deep(.relation-column), .truck-form :deep(.wide-column) { max-width:50%; flex-basis:50%; }
|
||||
|
||||
@@ -21,7 +21,7 @@ describe('TruckAttachmentDialog interaction governance', () => {
|
||||
expect(source).toContain('上传并关联')
|
||||
expect(source).toContain('deleteTruckAppointmentAttachment')
|
||||
expect(source).toContain("confirmButtonText: '确认', cancelButtonText: '取消'")
|
||||
expect(source).toContain('<Trash2 :size="15" />删除')
|
||||
expect(source).toContain('@click="remove(item)">删除</el-button>')
|
||||
expect(source).toContain('size="small" type="danger" plain')
|
||||
expect(source).not.toContain(' text type="danger"')
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { FileUp, Trash2 } from '@lucide/vue'
|
||||
import { FileUp } from '@lucide/vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { deleteTruckAppointmentAttachment, uploadTruckAppointmentAttachment } from '../api/platformTrucking'
|
||||
@@ -16,23 +17,23 @@ const attachmentTypeLabels: Record<TruckAttachmentType, string> = { DISPATCH_ORD
|
||||
function attachmentTypeLabel(value: string | null | undefined) { return value && value in attachmentTypeLabels ? attachmentTypeLabels[value as TruckAttachmentType] : '业务附件' }
|
||||
function selectFile(upload: { raw?: File }) { selectedFile.value = upload.raw || null }
|
||||
async function upload() { if (busy.value || !props.appointmentId || !selectedFile.value || !props.canManage) return; busy.value = true; error.value = null; try { await uploadTruckAppointmentAttachment(props.appointmentId, selectedFile.value, selectedType.value); selectedFile.value = null; emit('changed'); ElMessage.success('附件上传成功') } catch (cause) { error.value = getApiErrorDetails(cause, '附件上传失败,请稍后重试') } finally { busy.value = false } }
|
||||
async function remove(item: TruckAttachmentReference) { if (busy.value || !props.appointmentId || !item.id || !props.canManage) return; busy.value = true; error.value = null; try { await ElMessageBox.confirm('确认删除该附件引用?', '删除确认', { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' }); await deleteTruckAppointmentAttachment(props.appointmentId, item.id); emit('changed'); ElMessage.success('附件引用已删除') } catch (cause) { if (cause === 'cancel' || cause === 'close') return; error.value = getApiErrorDetails(cause, '附件删除失败,请稍后重试') } finally { busy.value = false } }
|
||||
async function remove(item: TruckAttachmentReference) { if (busy.value || !props.appointmentId || !item.id || !props.canManage) return; busy.value = true; error.value = null; try { await ElMessageBox.confirm('确认删除该附件引用?', '删除确认', { customClass: 'admin-density-confirm', confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' }); await deleteTruckAppointmentAttachment(props.appointmentId, item.id); emit('changed'); ElMessage.success('附件引用已删除') } catch (cause) { if (cause === 'cancel' || cause === 'close') return; error.value = getApiErrorDetails(cause, '附件删除失败,请稍后重试') } finally { busy.value = false } }
|
||||
</script>
|
||||
<template>
|
||||
<el-dialog :model-value="props.modelValue" title="预约附件" width="min(640px, 94vw)" @update:model-value="emit('update:modelValue', $event)">
|
||||
<el-dialog class="admin-density-dialog" :model-value="props.modelValue" title="预约附件" width="min(640px, 94vw)" @update:model-value="emit('update:modelValue', $event)">
|
||||
<el-alert v-if="error" type="error" :closable="true" show-icon @close="error = null"><template #title>{{ error.message }}</template><ApiErrorMeta :details="error" /></el-alert>
|
||||
<div v-if="props.canManage" class="upload-panel"><el-select v-model="selectedType" aria-label="附件类型" style="width:160px"><el-option v-for="(label, type) in attachmentTypeLabels" :key="type" :label="label" :value="type" /></el-select><el-upload :auto-upload="false" :limit="1" :show-file-list="true" :on-change="selectFile" :on-remove="() => { selectedFile = null }"><el-button><FileUp :size="15" />选择附件</el-button></el-upload><el-button type="primary" :loading="busy" :disabled="!selectedFile" @click="upload">上传并关联</el-button></div>
|
||||
<el-empty v-if="attachments.length === 0" description="暂无预约附件" :image-size="72" />
|
||||
<div v-for="item in attachments" :key="item.fileId" class="attachment-row">
|
||||
<div><strong>{{ item.fileName || '未命名文件' }}</strong><small>{{ attachmentTypeLabel(item.attachmentType) }}{{ item.uploadedAt ? ` · ${item.uploadedAt}` : '' }}</small></div>
|
||||
<el-button v-if="props.appointmentId && props.canManage && item.id" size="small" type="danger" plain :loading="busy" @click="remove(item)"><Trash2 :size="15" />删除</el-button>
|
||||
<el-button data-tone="danger" class="density-action" v-if="props.appointmentId && props.canManage && item.id" size="small" type="danger" plain :loading="busy" @click="remove(item)">删除</el-button>
|
||||
</div>
|
||||
<template #footer><el-button type="primary" @click="emit('update:modelValue', false)">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
<style scoped>
|
||||
.upload-panel { display:flex; align-items:center; flex-wrap:wrap; gap:8px; margin-bottom:16px; }
|
||||
.attachment-row { display:flex; justify-content:space-between; gap:12px; margin:14px 0; padding:12px; border:1px solid var(--el-border-color-lighter); border-radius:6px; overflow-wrap:anywhere; }.attachment-row small { display:block; margin-top:4px; color:var(--el-text-color-secondary); }
|
||||
.upload-panel { display:flex; align-items:center; flex-wrap:wrap; gap:var(--admin-space-3); margin-bottom:var(--admin-space-5); }
|
||||
.attachment-row { display:flex; justify-content:space-between; gap:var(--admin-space-5); overflow-wrap:anywhere; margin:0; padding:var(--admin-space-3) 0; border:0; border-bottom:1px solid var(--cc-border); border-radius:0; }.attachment-row small { display:block; margin-top:var(--admin-space-1); color:var(--el-text-color-secondary); }
|
||||
.attachment-row>div{min-width:0}.attachment-row strong{overflow-wrap:anywhere}
|
||||
@media (max-width: 560px) { .attachment-row { flex-direction:column; } }
|
||||
@media (max-width: 560px) { .attachment-row { flex-direction:column; margin:0; padding:var(--admin-space-3) 0; border:0; border-bottom:1px solid var(--cc-border); border-radius:0; } }
|
||||
</style>
|
||||
|
||||
@@ -37,10 +37,10 @@ describe('TruckAppointmentCreateView booking-bound creation contract', () => {
|
||||
expect(source).toContain('serial !== lookupSerial')
|
||||
})
|
||||
|
||||
it('places the return action beside the relation note instead of in a separate header row', () => {
|
||||
expect(source).toContain('<template #relation-note-actions>')
|
||||
it('places lightweight return navigation beside the new appointment heading', () => {
|
||||
expect(source).toContain('class="density-back"')
|
||||
expect(source).toContain('<ArrowLeft :size="16" />返回列表')
|
||||
expect(source).not.toContain('<template #header>')
|
||||
expect(source).toContain('<template #header><header class="create-heading surface-panel">')
|
||||
expect(source).not.toContain('class="editor-actions"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
@@ -68,14 +69,14 @@ watch(() => form.oceanBookingId, (value) => { void lookupExistingAppointment(Str
|
||||
defineExpose({ form, submit, lookupExistingAppointment, existingAppointment, existingLookupError, checkedOceanBookingId })
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage as="div" class="truck-editor-page">
|
||||
<DetailPageLayout aria-label="新增拖车预约">
|
||||
<AdminPage as="div" class="truck-editor-page admin-density">
|
||||
<DetailPageLayout aria-label="新增拖车预约"><template #header><header class="create-heading surface-panel"><el-button link class="density-back" @click="router.push({ name: 'trucking' })"><ArrowLeft :size="16" />返回列表</el-button><h1>新增拖车预约</h1></header></template>
|
||||
<LoadErrorState v-if="error" title="拖车预约创建失败" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<el-alert v-if="existingAppointment" type="warning" :closable="false" show-icon><template #title>该订舱已有拖车单 {{ existingAppointment.appointmentNo || existingAppointment.id }}({{ truckAppointmentStatusLabel(existingAppointment.status) }})</template><el-button type="primary" link :loading="navigatingToExisting" @click="openExisting">{{ existingCanEdit ? '继续编辑' : '查看拖车单' }}</el-button></el-alert>
|
||||
<LoadErrorState v-if="existingLookupError" title="已有拖车单查询失败" :message="existingLookupError.message" compact @retry="lookupExistingAppointment(String(form.oceanBookingId || ''))"><template #details><ApiErrorMeta :details="existingLookupError" /></template></LoadErrorState>
|
||||
<section class="surface-panel form-panel"><TruckAppointmentForm :model-value="form" :submitting="saving" :submit-blocked="submitBlocked" @update:model-value="updateForm" @submit="submit" @attachments="attachmentOpen = true"><template #relation-note-actions><el-button @click="router.push({ name: 'trucking' })"><ArrowLeft :size="16" />返回列表</el-button></template></TruckAppointmentForm></section>
|
||||
<section class="surface-panel form-panel"><TruckAppointmentForm :model-value="form" :submitting="saving" :submit-blocked="submitBlocked" @update:model-value="updateForm" @submit="submit" @attachments="attachmentOpen = true"></TruckAppointmentForm></section>
|
||||
<TruckAttachmentDialog v-model="attachmentOpen" :attachments="[]" />
|
||||
</DetailPageLayout>
|
||||
</AdminPage>
|
||||
</template>
|
||||
<style scoped>.truck-editor-page { min-width: 0; }.form-panel { padding:24px; } @media (max-width:720px) { .form-panel { padding:16px; } }</style>
|
||||
<style scoped>.truck-editor-page { min-width: 0; }.form-panel { padding:var(--admin-space-6); } @media (max-width:720px) { .form-panel { padding:16px; } } .create-heading{display:flex;align-items:center;gap:var(--admin-space-5);padding:var(--admin-space-3) var(--admin-space-5)} .create-heading h1{margin:0;font-size:var(--admin-font-title)}</style>
|
||||
|
||||
@@ -19,9 +19,9 @@ describe('TruckAppointmentDetailView status contract', () => {
|
||||
})
|
||||
|
||||
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; }')
|
||||
expect(source).toContain('.truck-detail-page :deep(.detail-page-layout__content) { display:grid; min-width:0; gap:var(--admin-space-5); }')
|
||||
expect(source).toContain('.form-panel { padding:var(--admin-space-5); }')
|
||||
expect(source).toContain('.history-section { padding:var(--admin-space-5); }')
|
||||
})
|
||||
|
||||
it('matches the backend edit lifecycle and avoids a self-transition action', () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onActivated, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
@@ -54,7 +55,7 @@ const dispatchBlockReason = computed(() => {
|
||||
return ''
|
||||
})
|
||||
async function confirmStatusChange(status: TruckAppointmentStatus) {
|
||||
await ElMessageBox.confirm(`确认将状态改为“${truckAppointmentStatusLabels[status]}”?`, '状态变更确认', { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' })
|
||||
await ElMessageBox.confirm(`确认将状态改为“${truckAppointmentStatusLabels[status]}”?`, '状态变更确认', { customClass: 'admin-density-confirm', confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' })
|
||||
}
|
||||
async function changeStatus(status: TruckAppointmentStatus) {
|
||||
if (!resource.value || status === resource.value.status || statusSaving.value) return
|
||||
@@ -71,12 +72,12 @@ onActivated(() => {
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage as="div" class="truck-detail-page">
|
||||
<AdminPage as="div" class="truck-detail-page admin-density">
|
||||
<DetailPageLayout aria-label="拖车预约详情">
|
||||
<template #header>
|
||||
<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>
|
||||
<el-button link class="density-back" type="primary" @click="router.push({ name: 'trucking' })"><ArrowLeft :size="16" />返回列表</el-button>
|
||||
<div class="truck-overview__identity"><h1>{{ resource?.appointmentNo || '拖车预约详情' }}</h1><p>{{ resource ? [resource.oceanBookingNo, resource.billOfLadingNo, 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>
|
||||
@@ -92,7 +93,7 @@ onActivated(() => {
|
||||
<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 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.createdAt || item.occurredAt || '—' }}<template v-if="item.reason"> · {{ item.reason }}</template></small></li></ol></section>
|
||||
<section class="surface-panel history-section"><h3>状态历史</h3><table class="density-table truck-history-table"><thead><tr><th>时间</th><th>事件</th><th>状态变化</th><th>操作人</th><th>备注</th></tr></thead><tbody><tr v-for="(item, index) in history" :key="item.id || `${item.createdAt}-${index}`"><td>{{ item.createdAt || item.occurredAt || '—' }}</td><td>{{ truckAppointmentActionLabel(item.action) }}</td><td>{{ truckAppointmentStatusLabel(item.fromStatus) }} → {{ truckAppointmentStatusLabel(item.toStatus) }}</td><td>{{ item.operatorName || '系统' }}</td><td>{{ item.reason || '—' }}</td></tr><tr v-if="!history.length"><td colspan="5">暂无状态历史</td></tr></tbody></table></section>
|
||||
<TruckAttachmentDialog v-model="attachmentOpen" :appointment-id="id" :attachments="resource.attachments || []" :can-manage="canManageAttachments" @changed="loadRelated" />
|
||||
</template>
|
||||
</DetailPageLayout>
|
||||
@@ -100,11 +101,15 @@ onActivated(() => {
|
||||
</template>
|
||||
<style scoped>
|
||||
.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; }
|
||||
.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; } }
|
||||
.truck-overview__heading { display:grid; grid-template-columns:auto minmax(260px, 1fr) auto auto; align-items:center; gap:var(--admin-space-5) var(--admin-space-6); padding:var(--admin-space-5) var(--admin-space-5); }
|
||||
.truck-overview__identity { min-width:0; }.truck-overview__identity h1 { margin:0; color:var(--cc-text); font-size:var(--admin-font-title); line-height:1.35; white-space:normal; overflow-wrap:anywhere; }.truck-overview__identity p { margin:2px 0 0; color:var(--cc-muted); font-size:var(--admin-font-caption); white-space:normal; overflow-wrap:anywhere; }
|
||||
.truck-overview__status { font-size:var(--admin-font-body); white-space:nowrap; }.truck-overview__status strong { margin-left:var(--admin-space-1); font-size:var(--admin-font-section); }
|
||||
.status-actions { display:flex; flex-wrap:wrap; gap:var(--admin-space-3); }.status-actions :deep(.el-button) { margin-left:0; }
|
||||
.relation-snapshot { margin:0; padding:9px var(--admin-space-5); border-top:1px solid var(--cc-border); color:var(--cc-muted); font-size:var(--admin-font-caption); line-height:1.5; }.relation-snapshot strong { margin-right:var(--admin-space-1); color:var(--cc-text); }
|
||||
.truck-detail-page { min-width:0; }.truck-detail-page :deep(.detail-page-layout__content) { display:grid; min-width:0; gap:var(--admin-space-5); }.form-panel { padding:var(--admin-space-5); }.history-section { padding:var(--admin-space-5); }.history-section h3 { margin:0 0 var(--admin-space-5); font-size:var(--admin-font-section); }
|
||||
@media (max-width: 640px) { .history-section { padding:16px; } }
|
||||
.truck-history-table th:nth-child(1){width:180px}
|
||||
.truck-history-table th:nth-child(2){width:130px}
|
||||
.truck-history-table th:nth-child(3){width:240px}
|
||||
.truck-history-table th:nth-child(4){width:130px}
|
||||
</style>
|
||||
|
||||
@@ -141,8 +141,8 @@ describe('TruckAppointmentsView', () => {
|
||||
expect(source).toContain('flex:0 0 auto')
|
||||
expect(source).not.toContain('platform:firstleg:truck-appointment:view')
|
||||
expect(source).toContain("access.can('platform:firstleg:truck-appointment:edit') && ['DRAFT', 'PENDING_DISPATCH', 'RESCHEDULED'].includes(row.status)")
|
||||
expect(source).toContain('<Eye :size="15" />详情')
|
||||
expect(source).toContain('<Pencil :size="15" />编辑')
|
||||
expect(source).toContain('@click="openDetail(row as TruckAppointmentResource)">详情</el-button>')
|
||||
expect(source).toContain('编辑</el-button>')
|
||||
expect(source).not.toContain('MoreHorizontal')
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Eye, Pencil, Plus } from '@lucide/vue'
|
||||
import { Plus } from '@lucide/vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
@@ -62,7 +63,7 @@ onMounted(() => { void load() })
|
||||
function openDetail(row: TruckAppointmentResource, edit = false) { router.push({ name: 'trucking-detail', params: { id: String(row.id) }, query: edit ? { edit: '1' } : undefined }) }
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage as="div" class="trucking-page">
|
||||
<AdminPage as="div" class="trucking-page admin-density">
|
||||
<ListPageLayout aria-label="拖车预约列表">
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="拖车预约查询" :fields="fields" preference-key="trucking-query" @search="search" /></template>
|
||||
<template v-if="loadError" #state><LoadErrorState title="拖车预约列表暂时无法加载" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
|
||||
@@ -73,7 +74,7 @@ function openDetail(row: TruckAppointmentResource, edit = false) { router.push({
|
||||
<template #driver="{ row }">{{ row.driverName || '—' }}<small>{{ row.driverPhone || '—' }}</small></template>
|
||||
<template #store="{ row }">{{ row.storeName || '—' }}</template>
|
||||
<template #status="{ row }"><StatusBadge :status="statusLabel(row.status)" /></template>
|
||||
<template #actions="{ row }"><div class="table-actions"><el-button size="small" type="primary" plain :aria-label="appointmentActionLabel('查看', row as TruckAppointmentResource)" @click="openDetail(row as TruckAppointmentResource)"><Eye :size="15" />详情</el-button><el-button v-if="access.can('platform:firstleg:truck-appointment:edit') && ['DRAFT', 'PENDING_DISPATCH', 'RESCHEDULED'].includes(row.status)" size="small" :aria-label="appointmentActionLabel('编辑', row as TruckAppointmentResource)" @click="openDetail(row as TruckAppointmentResource, true)"><Pencil :size="15" />编辑</el-button></div></template>
|
||||
<template #actions="{ row }"><div class="table-actions density-actions"><el-button class="density-action" size="small" type="primary" plain :aria-label="appointmentActionLabel('查看', row as TruckAppointmentResource)" @click="openDetail(row as TruckAppointmentResource)">详情</el-button><el-button class="density-action" v-if="access.can('platform:firstleg:truck-appointment:edit') && ['DRAFT', 'PENDING_DISPATCH', 'RESCHEDULED'].includes(row.status)" size="small" :aria-label="appointmentActionLabel('编辑', row as TruckAppointmentResource)" @click="openDetail(row as TruckAppointmentResource, true)">编辑</el-button></div></template>
|
||||
</DataTable>
|
||||
</section>
|
||||
</ListPageLayout>
|
||||
@@ -83,5 +84,5 @@ function openDetail(row: TruckAppointmentResource, edit = false) { router.push({
|
||||
.trucking-page { min-width: 0; }
|
||||
.table-panel { overflow:hidden; }
|
||||
small { display:block; color:var(--el-text-color-secondary); margin-top:3px; }
|
||||
.table-actions { display:flex; align-items:center; flex-wrap:nowrap; gap:6px; white-space:nowrap; }.table-actions :deep(.el-button) { flex:0 0 auto; margin-left:0; }
|
||||
.table-actions { flex-wrap:nowrap; white-space:nowrap; }.table-actions :deep(.el-button) { flex:0 0 auto; margin-left:0; }
|
||||
</style>
|
||||
|
||||
@@ -666,6 +666,6 @@ describe('UnifiedOrdersView contract', () => {
|
||||
expect(rowActions).toContain('density-action')
|
||||
expect(rowActions).not.toContain('v-for="action in directUnifiedOrderActions(row as UnifiedOrderResource)" :key="action" link')
|
||||
expect(rowActions).toContain('@click="openDetail(row as UnifiedOrderResource)">详情</el-button>')
|
||||
expect(source).toContain('<el-button link class="back-button"')
|
||||
expect(source).toContain('<el-button link class="back-button density-back"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2217,7 +2217,7 @@ onBeforeUnmount(() => {
|
||||
<DetailPageLayout ariaLabel="统一订单详情">
|
||||
<header class="order-detail-header">
|
||||
<div class="order-detail-heading">
|
||||
<el-button link class="back-button" @click="fromMeasurementReview ? returnToMeasurementReviews() : closeDetail()"><ArrowLeft :size="16" />{{ fromMeasurementReview ? '返回测量审核列表' : '返回列表' }}</el-button>
|
||||
<el-button link class="back-button density-back" @click="fromMeasurementReview ? returnToMeasurementReviews() : closeDetail()"><ArrowLeft :size="16" />{{ fromMeasurementReview ? '返回测量审核列表' : '返回列表' }}</el-button>
|
||||
<div class="order-title">
|
||||
<h1>{{ detail?.summary.inboundCode || '入仓码暂缺' }}</h1>
|
||||
</div>
|
||||
@@ -2725,7 +2725,7 @@ onBeforeUnmount(() => {
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<AppDrawer v-model="relationOpen" title="父子订单关联" size="min(860px, 94vw)" :show-footer="false" destroy-on-close>
|
||||
<AppDrawer class="admin-density-drawer" v-model="relationOpen" title="父子订单关联" size="min(860px, 94vw)" :show-footer="false" destroy-on-close>
|
||||
<OrderFulfillmentTreePanel
|
||||
v-if="relationOrder"
|
||||
:tenant-id="String(relationOrder.tenantId)"
|
||||
|
||||
@@ -198,7 +198,7 @@ describe('PalletsView', () => {
|
||||
expect(wrapper.find('.platform-tenant-selector').exists()).toBe(false)
|
||||
expect(wrapper.text()).not.toContain('租户')
|
||||
expect(wrapper.find('.data-table').exists()).toBe(true)
|
||||
expect(wrapper.find('.table-actions.pallet-row-actions').exists()).toBe(true)
|
||||
expect(wrapper.find('.density-actions.pallet-row-actions').exists()).toBe(true)
|
||||
expect(wrapper.findAll('thead th').map((item) => item.text().trim())).toEqual([
|
||||
'操作', '托盘编码', '所属仓库', '托盘类型', '托盘容积(m³)', '托盘重量(kg)',
|
||||
])
|
||||
|
||||
@@ -403,7 +403,7 @@ function setPage(value: number) {
|
||||
.data-table :deep(.data-table__single-line-cell .cell) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.data-table :deep(.data-table__system-cell .cell) { white-space: nowrap; }
|
||||
.data-table :deep(.data-table__system-cell .el-button + .el-button),
|
||||
.data-table :deep(.data-table__system-cell .el-button + .el-dropdown) { margin-left: 8px; }
|
||||
.data-table :deep(.data-table__system-cell .el-button + .el-dropdown) { margin-left: var(--cc-list-action-sibling-margin, 8px); }
|
||||
|
||||
@media (min-width: 1920px) {
|
||||
.data-table__toolbar { min-height: 64px; gap: 12px; padding: 10px 14px; }
|
||||
|
||||
+45
-12
@@ -18,10 +18,10 @@
|
||||
--el-table-row-hover-bg-color: var(--admin-row-hover);
|
||||
font-size: var(--admin-font-body, 13px);
|
||||
}
|
||||
.admin-density .data-table .el-table .el-table__cell, .admin-density-dialog .el-table .el-table__cell { height: var(--admin-row-height, 36px); padding: var(--admin-space-1, 4px) 0; }
|
||||
.admin-density .el-table .el-table__cell, .admin-density .data-table .el-table .el-table__cell, .admin-density-dialog .el-table .el-table__cell { height: var(--admin-row-height, 36px); padding: var(--admin-space-1, 4px) 0; }
|
||||
.admin-density .el-table .cell, .admin-density-dialog .el-table .cell { line-height: 22px; }
|
||||
.admin-density .el-table .el-button--small, .admin-density-dialog .el-table .el-button--small { height: var(--admin-action-height, 28px); padding: var(--admin-space-1, 4px) var(--admin-space-3, 8px); }
|
||||
.admin-density .el-table__body tr.el-table__row--striped td.el-table__cell { background: #fcfcfd; }
|
||||
.admin-density .el-table__body tr.el-table__row--striped td.el-table__cell { background: var(--admin-row-stripe); }
|
||||
.admin-density .data-table .data-table__toolbar { min-height: 44px; padding: var(--admin-space-2, 6px) var(--admin-space-5, 12px); }
|
||||
.admin-density .data-table .data-table__pagination { min-height: 48px; padding: var(--admin-space-3, 8px) var(--admin-space-5, 12px); }
|
||||
.admin-density .el-empty { padding: var(--admin-space-5, 12px) 0; }
|
||||
@@ -29,7 +29,7 @@
|
||||
.admin-density .el-empty__description { margin-top: 0; }
|
||||
.admin-density .el-empty__description p { font-size: var(--admin-font-body, 13px); }
|
||||
.admin-density-dialog .el-form-item__label { line-height: 22px; }
|
||||
.admin-density-dialog .el-descriptions__label.el-descriptions__cell { font-size: var(--admin-font-caption, 12px); background: #f8fafc; }
|
||||
.admin-density-dialog .el-descriptions__label.el-descriptions__cell { font-size: var(--admin-font-caption, 12px); background: var(--admin-label-bg); }
|
||||
.admin-density-dialog .el-descriptions__content.el-descriptions__cell { font-size: var(--admin-font-body, 13px); overflow-wrap: anywhere; }
|
||||
.admin-density-dialog.el-dialog.is-fullscreen { width: 100%; max-width: 100%; height: 100vh; max-height: 100vh; margin: 0; border-radius: 0; }
|
||||
.admin-density-dialog.el-dialog.admin-density-workspace:not(.is-fullscreen) { height: 92vh; max-height: 92vh; margin-top: 4vh; }
|
||||
@@ -71,15 +71,16 @@
|
||||
:is(.admin-density, .admin-density-dialog) .el-button.density-action {
|
||||
--action-color: var(--admin-action-neutral);
|
||||
--action-border: var(--admin-action-neutral-border);
|
||||
--action-hover-bg: var(--admin-label-bg);
|
||||
--el-button-text-color: var(--action-color);
|
||||
--el-button-border-color: var(--action-border);
|
||||
--el-button-bg-color: var(--admin-surface);
|
||||
--el-button-hover-text-color: var(--action-color);
|
||||
--el-button-hover-border-color: var(--action-color);
|
||||
--el-button-hover-bg-color: var(--admin-label-bg);
|
||||
--el-button-hover-bg-color: var(--action-hover-bg);
|
||||
--el-button-active-text-color: var(--action-color);
|
||||
--el-button-active-border-color: var(--action-color);
|
||||
--el-button-active-bg-color: var(--admin-label-bg);
|
||||
--el-button-active-bg-color: var(--action-hover-bg);
|
||||
--el-button-disabled-text-color: #a1a1aa;
|
||||
--el-button-disabled-border-color: var(--admin-table-border);
|
||||
--el-button-disabled-bg-color: var(--admin-surface);
|
||||
@@ -89,15 +90,20 @@
|
||||
color: var(--action-color); background: var(--admin-surface);
|
||||
font-size: var(--admin-font-body); font-weight: 600; box-shadow: none;
|
||||
}
|
||||
:is(.admin-density, .admin-density-dialog) .density-action[data-tone="warning"] { --action-color: var(--admin-action-warning); --action-border: var(--admin-action-warning-border); }
|
||||
:is(.admin-density, .admin-density-dialog) .density-action[data-tone="purple"] { --action-color: var(--admin-action-purple); --action-border: var(--admin-action-purple-border); }
|
||||
:is(.admin-density, .admin-density-dialog) .density-action[data-tone="primary"] { --action-color: var(--admin-action-primary); --action-border: var(--admin-action-primary-border); }
|
||||
:is(.admin-density, .admin-density-dialog) .density-action[data-tone="success"] { --action-color: var(--admin-action-success); --action-border: var(--admin-action-success-border); }
|
||||
:is(.admin-density, .admin-density-dialog) .density-action[data-tone="danger"] { --action-color: var(--admin-action-danger); --action-border: var(--admin-action-danger-border); }
|
||||
:is(.admin-density, .admin-density-dialog) .el-button.density-action:hover:not(:disabled) { color: var(--action-color); border-color: var(--action-color); background: var(--admin-label-bg); box-shadow: none; }
|
||||
:is(.admin-density, .admin-density-dialog) .density-action[data-tone="warning"] { --action-color: var(--admin-action-warning); --action-border: var(--admin-action-warning-border); --action-hover-bg: var(--admin-action-warning-hover); }
|
||||
:is(.admin-density, .admin-density-dialog) .density-action[data-tone="purple"] { --action-color: var(--admin-action-purple); --action-border: var(--admin-action-purple-border); --action-hover-bg: var(--admin-action-purple-hover); }
|
||||
:is(.admin-density, .admin-density-dialog) .density-action[data-tone="primary"] { --action-color: var(--admin-action-primary); --action-border: var(--admin-action-primary-border); --action-hover-bg: var(--admin-action-primary-hover); }
|
||||
:is(.admin-density, .admin-density-dialog) .density-action[data-tone="success"] { --action-color: var(--admin-action-success); --action-border: var(--admin-action-success-border); --action-hover-bg: var(--admin-action-success-hover); }
|
||||
:is(.admin-density, .admin-density-dialog) .density-action[data-tone="danger"] { --action-color: var(--admin-action-danger); --action-border: var(--admin-action-danger-border); --action-hover-bg: var(--admin-action-danger-hover); }
|
||||
:is(.admin-density, .admin-density-dialog) .el-button.density-action:hover:not(:disabled) { color: var(--action-color); border-color: var(--action-color); background: var(--action-hover-bg); box-shadow: none; }
|
||||
:is(.admin-density, .admin-density-dialog) .el-button.density-action:disabled { color: var(--el-button-disabled-text-color); border-color: var(--el-button-disabled-border-color); background: var(--admin-surface); }
|
||||
:is(.admin-density, .admin-density-dialog) .el-button.density-action:focus-visible { outline: 2px solid var(--action-color); outline-offset: 2px; }
|
||||
|
||||
/* Back navigation stays in the detail heading, beside its business identifier. */
|
||||
.admin-density .el-button.density-back { height: var(--admin-control-height); padding: 0; border: 0; background: transparent; color: var(--cc-muted); font-size: var(--admin-font-body); font-weight: 400; box-shadow: none; }
|
||||
.admin-density .el-button.density-back:hover { color: var(--cc-primary); background: transparent; }
|
||||
.admin-density .el-button.density-back:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: 2px; }
|
||||
|
||||
/* Navigation tabs shared by customer/order details. Layout-specific borders stay local. */
|
||||
.admin-density .density-tabs { display: flex; gap: 0; overflow-x: auto; overflow-y: hidden; background: var(--admin-surface); scrollbar-width: thin; }
|
||||
.admin-density .density-tabs > button { position: relative; display: inline-flex; min-width: max-content; height: var(--admin-tab-height); min-height: var(--admin-tab-height); flex: 0 0 auto; align-items: center; justify-content: center; gap: var(--admin-space-2); padding: 0 var(--admin-space-6); border: 0; background: transparent; color: #475467; font-size: var(--admin-font-body); font-weight: 500; white-space: nowrap; cursor: pointer; }
|
||||
@@ -113,9 +119,36 @@
|
||||
.admin-density-drawer .el-drawer__body { padding: var(--admin-space-5) var(--admin-space-6); }
|
||||
|
||||
/* Reusable row action group; no business action names in the shared layer. */
|
||||
.admin-density .data-table .density-actions { display: flex; align-items: center; gap: var(--admin-space-2); }
|
||||
.admin-density .data-table .density-actions { --cc-list-action-sibling-margin: 0px; display: flex; align-items: center; gap: var(--admin-space-2); }
|
||||
.admin-density .data-table .density-actions > :is(.el-button, .el-dropdown) { margin-left: 0; }
|
||||
|
||||
/* The same action spacing also applies to native tables and teleported content. */
|
||||
:is(.admin-density, .admin-density-dialog) .density-actions { display: flex; align-items: center; gap: var(--admin-space-2); }
|
||||
:is(.admin-density, .admin-density-dialog) .density-actions > :is(.el-button, .el-dropdown) { margin-left: 0; }
|
||||
|
||||
/* Element tabs retain their keyboard handling and use the shared detail-tab scale. */
|
||||
.admin-density .density-element-tabs { min-width: 0; background: var(--admin-surface); border-bottom: 1px solid var(--admin-table-border); }
|
||||
.admin-density .density-element-tabs > .el-tabs__header { margin: 0; }
|
||||
.admin-density .density-element-tabs > .el-tabs__header .el-tabs__item { height: var(--admin-tab-height); padding: 0 var(--admin-space-6); font-size: var(--admin-font-body); font-weight: 500; color: #475467; }
|
||||
.admin-density .density-element-tabs > .el-tabs__header .el-tabs__item.is-active { color: var(--cc-primary); font-weight: 600; }
|
||||
.admin-density .density-element-tabs > .el-tabs__header .el-tabs__item:hover { color: var(--cc-primary); }
|
||||
.admin-density .density-element-tabs > .el-tabs__header .el-tabs__active-bar { background-color: var(--cc-primary); }
|
||||
.admin-density .density-element-tabs > .el-tabs__content { display: none; }
|
||||
|
||||
/* Opted-in grouped forms use the same labels and spacing on pages and in dialogs. */
|
||||
:is(.admin-density, .admin-density-dialog) .density-form,
|
||||
.admin-density.density-form { --el-form-label-font-size: var(--admin-font-body); }
|
||||
:is(.admin-density, .admin-density-dialog) .density-form .el-form-item,
|
||||
.admin-density.density-form .el-form-item { margin-bottom: var(--admin-space-5); }
|
||||
|
||||
/* Existing native data tables keep their business column widths and sticky cells. */
|
||||
:is(.admin-density, .admin-density-dialog) .density-table { width: 100%; border-collapse: collapse; font-size: var(--admin-font-body); }
|
||||
:is(.admin-density, .admin-density-dialog) .density-table :is(th, td) { height: var(--admin-row-height); padding: var(--admin-space-1) var(--admin-space-3); border: 1px solid var(--admin-table-border); text-align: left; vertical-align: middle; overflow-wrap: anywhere; }
|
||||
:is(.admin-density, .admin-density-dialog) .density-table th { background: var(--admin-label-bg); color: var(--cc-muted); font-weight: 600; }
|
||||
:is(.admin-density, .admin-density-dialog) .density-table td { background: var(--admin-surface); }
|
||||
:is(.admin-density, .admin-density-dialog) .density-table tbody tr:nth-child(even) td { background: var(--admin-row-stripe); }
|
||||
:is(.admin-density, .admin-density-dialog) .density-table tbody tr:hover td { background: var(--admin-row-hover); }
|
||||
|
||||
/* Continuous description grid. Page owns column count and wide-field spans only. */
|
||||
:is(.admin-density, .admin-density-dialog) .density-fields { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0; margin: 0; border-top: 1px solid var(--cc-border); border-left: 1px solid var(--cc-border); background: var(--admin-surface); }
|
||||
:is(.admin-density, .admin-density-dialog) .density-fields > div { display: grid; grid-template-columns: var(--admin-field-label-width, var(--admin-label-width)) minmax(0, 1fr); min-width: 0; min-height: var(--admin-row-height); gap: 0; padding: 0; border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); border-radius: 0; background: var(--admin-surface); }
|
||||
|
||||
@@ -4,7 +4,19 @@ import { describe, expect, it } from 'vitest'
|
||||
import { compileStyle, parse } from 'vue/compiler-sfc'
|
||||
|
||||
// Approved entry/consumer inventory. Do not discover unrelated menus recursively.
|
||||
const cargoConsumers = [
|
||||
'ocean-bookings/pages/OceanBookingsView.vue', 'ocean-bookings/pages/OceanBookingDetailView.vue',
|
||||
'ocean-bookings/components/OceanBookingDocumentsPanel.vue', 'ocean-bookings/components/OceanBookingPrintDialog.vue',
|
||||
'trucking/pages/TruckAppointmentsView.vue', 'trucking/pages/TruckAppointmentCreateView.vue',
|
||||
'trucking/pages/TruckAppointmentDetailView.vue', 'trucking/components/TruckAppointmentForm.vue',
|
||||
'trucking/components/TruckAttachmentDialog.vue', 'container-loading/pages/ContainersView.vue',
|
||||
'container-loading/pages/ContainerDetailView.vue', 'container-loading/pages/SmartLoadingWorkbenchView.vue',
|
||||
'container-loading/components/OceanBookingLinkDialog.vue', 'container-loading/components/ContainerOverviewPanel.vue',
|
||||
'container-loading/components/ContainerCustomsPanel.vue', 'container-loading/components/ContainerLastLegFeesPanel.vue',
|
||||
'loading-lists/components/ContainerFeesPanel.vue', 'port-schedules/components/CarrierScheduleQueryPanel.vue',
|
||||
]
|
||||
const consumers = [
|
||||
...cargoConsumers,
|
||||
'customers/pages/CustomersView.vue', 'customers/pages/CustomerDetailView.vue',
|
||||
'customers/pages/OverseasStorageFeesView.vue', 'customers/components/CustomerBillingRulesPanel.vue',
|
||||
'customers/CustomerOverseasStorageFeePanel.vue', 'unified-orders/pages/UnifiedOrdersView.vue',
|
||||
@@ -28,19 +40,25 @@ const domesticPages = [
|
||||
{ title: '托盘管理', path: '/master-data/pallets', file: 'warehouse-resources/pallets/pages/PalletsView.vue' },
|
||||
{ title: '整柜业务', path: '/domestic-containers', file: 'domestic-containers/pages/DomesticContainersView.vue' },
|
||||
]
|
||||
const customerPages = [
|
||||
{ title: '客户列表', path: '/customers', file: 'customers/pages/CustomersView.vue' },
|
||||
{ title: '海外仓阶梯费用', path: '/customers/overseas-storage-fees', file: 'customers/pages/OverseasStorageFeesView.vue' },
|
||||
]
|
||||
const densityPages = [...new Set([
|
||||
...cargoConsumers.filter(file => file.includes('/pages/') || ['ContainerOverviewPanel.vue', 'ContainerCustomsPanel.vue', 'ContainerLastLegFeesPanel.vue', 'ContainerFeesPanel.vue', 'OceanBookingDocumentsPanel.vue'].some(name => file.endsWith(name))),
|
||||
...domesticPages.map(page => page.file), 'loading-lists/pages/PreloadPlanDetailView.vue',
|
||||
'loading-lists/components/LoadingListDetailDrawer.vue', 'domestic-containers/pages/DomesticContainerInventoryDetailView.vue',
|
||||
'customers/pages/CustomersView.vue', 'customers/pages/CustomerDetailView.vue', 'customers/pages/OverseasStorageFeesView.vue',
|
||||
...customerPages.map(page => page.file), 'customers/pages/CustomerDetailView.vue', 'audit/components/CustomerAuditLogsPanel.vue',
|
||||
])]
|
||||
type TemplateNode = NonNullable<NonNullable<ReturnType<typeof parse>['descriptor']['template']>['ast']>['children'][number]
|
||||
type ElementNode = Extract<TemplateNode, { type: 1 }>
|
||||
const classesOf = (node: ElementNode, preload = true): string[] => node.props.flatMap(prop => {
|
||||
const classesOf = (node: ElementNode, preload = true, dense = preload, workbench = 'DOMESTIC'): string[] => node.props.flatMap(prop => {
|
||||
if (prop.type === 6 && prop.name === 'class') return prop.value?.content.split(/\s+/) ?? []
|
||||
if (prop.type !== 7 || prop.name !== 'bind' || prop.arg?.type !== 4 || prop.arg.content !== 'class' || prop.exp?.type !== 4 || !prop.exp.content.includes('density')) return []
|
||||
// Evaluate only the authored density class binding with the shared component's two modes.
|
||||
const value = new Function('mode', 'isPreload', 'dense', `return (${prop.exp.content})`)(preload ? 'preload' : 'confirmed', preload, preload) as Record<string, boolean>
|
||||
return Object.entries(value).filter(([, enabled]) => enabled).flatMap(([names]) => names.split(/\s+/))
|
||||
const value = new Function('mode', 'isPreload', 'dense', 'isDomestic', 'isOverseas', `return (${prop.exp.content})`)(preload ? 'preload' : 'confirmed', preload, dense, workbench === 'DOMESTIC', workbench === 'OVERSEAS') as Record<string, boolean>
|
||||
const flatten = (value: unknown): string[] => typeof value === 'string' ? value.split(/\s+/) : Array.isArray(value) ? value.flatMap(flatten) : value && typeof value === 'object' ? Object.entries(value).filter(([, enabled]) => enabled).flatMap(([names]) => names.split(/\s+/)) : []
|
||||
return flatten(value)
|
||||
})
|
||||
const styles = [
|
||||
...consumers.flatMap(file => parse(read(`modules/${file}`)).descriptor.styles.map(style => ({ file, css: style.content }))),
|
||||
@@ -55,6 +73,60 @@ const cssRoot = (css: string) => {
|
||||
}
|
||||
|
||||
describe('approved Admin density ownership', () => {
|
||||
it('covers four cargo menus, independent detail routes and both workbench modes', () => {
|
||||
const navigation = read('core/navigation/adminNavigation.ts')
|
||||
for (const path of ['/ocean-bookings', '/trucking', '/container-loading/domestic', '/container-loading/overseas']) expect(navigation).toContain(`path: '${path}'`)
|
||||
for (const [prefix, workbench] of [['Domestic', 'DOMESTIC'], ['Overseas', 'OVERSEAS']]) {
|
||||
for (const [suffix, component] of [['ContainersView', 'ContainersView'], ['ContainerDetailView', 'ContainerDetailView']]) {
|
||||
expect(read(`modules/container-loading/pages/${prefix}${suffix}.vue`)).toContain(`<${component} workbench="${workbench}"`)
|
||||
const ast = parse(read(`modules/container-loading/pages/${component}.vue`)).descriptor.template!.ast!
|
||||
const root = ast.children.find(node => node.type === 1) as ElementNode
|
||||
expect(classesOf(root, false, true, workbench)).toContain('admin-density')
|
||||
}
|
||||
}
|
||||
const detail = read('modules/container-loading/pages/ContainerDetailView.vue')
|
||||
for (const component of ['CarrierScheduleQueryPanel', 'LoadingListDetailDrawer', 'PalletLabelPrintDialog']) expect(detail).toContain(`<${component} dense`)
|
||||
expect(read('modules/container-loading/pages/ContainersView.vue')).toContain('<ContainerSimulationCreate dense')
|
||||
for (const file of cargoConsumers) expect(read(`modules/${file}`), `${file}: direct entry`).toContain('@/styles/adminDensity.css')
|
||||
})
|
||||
|
||||
it('enables confirmed loading content and schedule overlays only for opted-in callers', () => {
|
||||
for (const file of ['loading-lists/components/LoadingListDetailDrawer.vue', 'port-schedules/components/CarrierScheduleQueryPanel.vue']) {
|
||||
const ast = parse(read(`modules/${file}`)).descriptor.template!.ast!
|
||||
const root = ast.children.find(node => node.type === 1) as ElementNode
|
||||
expect(classesOf(root, false, true)).toContain('admin-density')
|
||||
expect(classesOf(root, false, false)).not.toContain('admin-density')
|
||||
if (file.includes('CarrierSchedule')) {
|
||||
const dialog = root.children.find(node => node.type === 1 && node.tag === 'AppDialog') as ElementNode
|
||||
expect(classesOf(dialog, false, true)).toContain('admin-density-dialog')
|
||||
expect(classesOf(dialog, false, false)).not.toContain('admin-density-dialog')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps cargo native tables, row actions and detail tabs connected to shared styles', () => {
|
||||
for (const file of cargoConsumers) {
|
||||
const ast = parse(read(`modules/${file}`)).descriptor.template!.ast!
|
||||
const visit = (nodes: typeof ast.children, inCell = false, insideDensity = false) => {
|
||||
for (const node of nodes) {
|
||||
if (node.type !== 1) continue
|
||||
const classes = classesOf(node)
|
||||
const enabled = insideDensity || classes.some(name => ['admin-density', 'admin-density-dialog'].includes(name))
|
||||
if (node.tag === 'table') {
|
||||
expect(classes, `${file}: native table`).toContain('density-table')
|
||||
expect(insideDensity, `${file}: native table ancestor`).toBe(true)
|
||||
}
|
||||
if (node.tag === 'el-tabs') expect(classes).toContain('density-element-tabs')
|
||||
if (inCell && node.tag === 'el-button') {
|
||||
expect(classes, `${file}: native row action`).toContain('density-action')
|
||||
expect(node.children.some(child => child.type === 2 && child.content.trim() || child.type === 5), `${file}: empty action`).toBe(true)
|
||||
}
|
||||
visit(node.children, inCell || node.tag === 'td', enabled)
|
||||
}
|
||||
}
|
||||
visit(ast.children)
|
||||
}
|
||||
})
|
||||
it('keeps shared list actions and detail content inside a density-enabled page ancestor', () => {
|
||||
for (const file of densityPages) {
|
||||
const template = parse(read(`modules/${file}`)).descriptor.template!.ast!
|
||||
@@ -63,7 +135,7 @@ describe('approved Admin density ownership', () => {
|
||||
for (const node of nodes) {
|
||||
if (node.type !== 1) continue
|
||||
const classes = classesOf(node)
|
||||
if (node.tag === 'DataTable' || node.tag === 'LoadingListDetailDrawer' || classes.some(name => ['density-action', 'density-actions', 'density-tabs', 'density-fields', 'density-tab-icon'].includes(name))) {
|
||||
if (node.tag === 'DataTable' || node.tag === 'LoadingListDetailDrawer' || classes.some(name => ['density-action', 'density-actions', 'density-tabs', 'density-fields', 'density-tab-icon', 'density-back'].includes(name))) {
|
||||
expect(insideDensity, `${file}: ${classes.join(' ')} lacks its density ancestor`).toBe(true)
|
||||
checked += 1
|
||||
}
|
||||
@@ -87,15 +159,27 @@ describe('approved Admin density ownership', () => {
|
||||
for (const file of densityPages) expect(read(`modules/${file}`), `${file}: direct entry CSS`).toContain('@/styles/adminDensity.css')
|
||||
})
|
||||
|
||||
it('covers both customer menu entries and their shared detail primitives', () => {
|
||||
const navigation = read('core/navigation/adminNavigation.ts')
|
||||
const customers = navigation.slice(navigation.indexOf("key: 'customers'"), navigation.indexOf("key: 'domestic-warehouse'"))
|
||||
expect([...customers.matchAll(/path: '/g)]).toHaveLength(customerPages.length)
|
||||
for (const page of customerPages) {
|
||||
expect(customers).toContain(`title: '${page.title}'`)
|
||||
expect(customers).toContain(`path: '${page.path}'`)
|
||||
}
|
||||
expect(read('modules/customers/pages/CustomerDetailView.vue')).toContain('class="back-button density-back"')
|
||||
})
|
||||
|
||||
it('uses shared outlined actions across domestic tables and explicit classes on teleported dialogs', () => {
|
||||
for (const file of densityPages) {
|
||||
for (const file of [...new Set([...densityPages, ...consumers])]) {
|
||||
const template = parse(read(`modules/${file}`)).descriptor.template!.ast!
|
||||
const visit = (nodes: typeof template.children, actionSlot = false, parentTag = '') => {
|
||||
for (const node of nodes) {
|
||||
if (node.type !== 1) continue
|
||||
const isActionSlot = actionSlot || (parentTag === 'DataTable' && node.props.some(prop => prop.type === 7 && prop.name === 'slot' && prop.arg?.type === 4 && prop.arg.content === 'actions'))
|
||||
if (isActionSlot && node.tag === 'el-button') expect(classesOf(node), `${file}: row action`).toContain('density-action')
|
||||
if (densityPages.includes(file) && isActionSlot && node.tag === 'el-button') expect(classesOf(node), `${file}: row action`).toContain('density-action')
|
||||
if (['AppDialog', 'el-dialog'].includes(node.tag)) expect(classesOf(node), `${file}: teleported dialog`).toContain('admin-density-dialog')
|
||||
if (['AppDrawer', 'el-drawer'].includes(node.tag)) expect(classesOf(node), `${file}: teleported drawer`).toContain('admin-density-drawer')
|
||||
visit(node.children, isActionSlot, node.tag)
|
||||
}
|
||||
}
|
||||
@@ -121,6 +205,22 @@ describe('approved Admin density ownership', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('connects configurable surface colors and semantic hover states to their consumers', () => {
|
||||
const root = cssRoot(shared)
|
||||
const rules = new Map<string, Record<string, string>>()
|
||||
root.walkRules(rule => {
|
||||
const declarations: Record<string, string> = {}
|
||||
rule.walkDecls(decl => { declarations[decl.prop] = decl.value })
|
||||
rules.set(rule.selector, declarations)
|
||||
})
|
||||
expect(rules.get('.admin-density .el-table__body tr.el-table__row--striped td.el-table__cell')?.background).toBe('var(--admin-row-stripe)')
|
||||
expect(rules.get('.admin-density-dialog .el-descriptions__label.el-descriptions__cell')?.background).toBe('var(--admin-label-bg)')
|
||||
for (const tone of ['warning', 'purple', 'primary', 'success', 'danger']) {
|
||||
expect(rules.get(`:is(.admin-density, .admin-density-dialog) .density-action[data-tone="${tone}"]`)?.['--action-hover-bg']).toBe(`var(--admin-action-${tone}-hover)`)
|
||||
}
|
||||
expect(rules.get(':is(.admin-density, .admin-density-dialog) .el-button.density-action:hover:not(:disabled)')?.background).toBe('var(--action-hover-bg)')
|
||||
})
|
||||
|
||||
it('prevents page copies of action paint, tab primitives and desktop density dimensions', () => {
|
||||
for (const { file, css } of styles) cssRoot(css).walkRules(rule => {
|
||||
expect(rule.selector, file).not.toMatch(/customer-action-button|order-action-button|\.density-action|\.density-tab-icon/)
|
||||
@@ -137,9 +237,22 @@ describe('approved Admin density ownership', () => {
|
||||
})
|
||||
|
||||
it('keeps conditional shared consumers opt-in instead of enabling other menus', () => {
|
||||
expect(read('shared/components/DataTable.vue')).toContain('margin-left: var(--cc-list-action-sibling-margin, 8px)')
|
||||
expect(shared).toContain('.density-actions { --cc-list-action-sibling-margin: 0px;')
|
||||
expect(read('modules/loading-lists/components/LoadingListsView.vue')).toContain("'admin-density': isPreload")
|
||||
expect(read('modules/loading-lists/components/PalletLabelPrintDialog.vue')).toContain("'admin-density-dialog': dense")
|
||||
expect(read('modules/container-loading/components/ContainerSimulationCreate.vue')).toContain("'admin-density-dialog': dense")
|
||||
for (const file of ['loading-lists/components/LoadingListsView.vue', 'loading-lists/components/LoadingListDetailDrawer.vue', 'loading-lists/components/PalletLabelPrintDialog.vue', 'container-loading/components/ContainerSimulationCreate.vue']) {
|
||||
const template = parse(read(`modules/${file}`)).descriptor.template!.ast!
|
||||
const visit = (nodes: typeof template.children) => {
|
||||
for (const node of nodes) {
|
||||
if (node.type !== 1) continue
|
||||
expect(classesOf(node, false).filter(name => /^(admin-density|density-)/.test(name)), `${file}: unopted consumer`).toEqual([])
|
||||
visit(node.children)
|
||||
}
|
||||
}
|
||||
visit(template.children)
|
||||
}
|
||||
for (const file of ['customers/pages/CustomersView.vue', 'customers/pages/OverseasStorageFeesView.vue', 'unified-orders/pages/UnifiedOrdersView.vue']) {
|
||||
expect(read(`modules/${file}`), file).toContain('density-action')
|
||||
}
|
||||
|
||||
@@ -31,12 +31,17 @@
|
||||
--admin-action-neutral-border: #dfe3ee;
|
||||
--admin-action-warning: #c76500;
|
||||
--admin-action-warning-border: #fed7aa;
|
||||
--admin-action-warning-hover: #fff7ed;
|
||||
--admin-action-purple: #7c3aed;
|
||||
--admin-action-purple-border: #ddd6fe;
|
||||
--admin-action-purple-hover: #f5f3ff;
|
||||
--admin-action-primary: #1677cf;
|
||||
--admin-action-primary-border: #bfdbfe;
|
||||
--admin-action-primary-hover: #eff6ff;
|
||||
--admin-action-success: #15803d;
|
||||
--admin-action-success-border: #bbf7d0;
|
||||
--admin-action-success-hover: #f0fdf4;
|
||||
--admin-action-danger: #dc2626;
|
||||
--admin-action-danger-border: #fecaca;
|
||||
--admin-action-danger-hover: #fef2f2;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user