This commit is contained in:
2026-08-13 19:19:34 +08:00
parent 0f0ae612ca
commit df1471dc30
15 changed files with 152 additions and 273 deletions
@@ -9,14 +9,106 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getDomesticContainerInventory } from '../api/domesticContainers'
import type { InventoryDetail, TransactionRecord } from '../types'
import type { InventoryDetail, InventoryStatus, TransactionRecord } from '../types'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const error = ref<ApiErrorDetails | null>(null)
const detail = ref<InventoryDetail | null>(null)
const transactionColumns: DataTableColumn[] = [
{ prop: 'businessType', label: '业务类型', width: 110, slotName: 'businessType' },
{ prop: 'orderNo', label: '单号', minWidth: 210 },
{ prop: 'quantity', label: '数量', width: 110, align: 'right', formatter: (row: TransactionRecord) => `${row.quantity} 箱` },
{ prop: 'operationTime', label: '操作时间', width: 170, formatter: (row: TransactionRecord) => displayTime(row.operationTime) },
{ prop: 'remark', label: '备注', minWidth: 180, formatter: (row: TransactionRecord) => row.remark || '—' },
]
function inventoryStatus(status: InventoryStatus) {
return ({ IN_STOCK: '在库', PARTIALLY_OUTBOUND: '部分出库', OUTBOUND: '已出库' } as const)[status]
}
function displayTime(value: string) {
return value ? value.replace('T', ' ').slice(0, 16) : '—'
}
function back() {
void router.push({ name: 'domestic-containers' })
}
async function load() {
loading.value = true
error.value = null
try {
detail.value = await getDomesticContainerInventory(String(route.params.inventoryId))
} catch (cause) {
error.value = getApiErrorDetails(cause, '整柜库存详情加载失败')
} finally {
loading.value = false
}
}
const route = useRoute(); const router = useRouter(); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null); const detail = ref<InventoryDetail | null>(null)
const transactionColumns: DataTableColumn[] = [{ prop: 'businessType', label: '类型', width: 100, slotName: 'businessType' }, { prop: 'orderNo', label: '单号', minWidth: 160 }, { prop: 'quantity', label: '数量', width: 100, align: 'right', formatter: (row: TransactionRecord) => `${row.quantity} 箱` }, { prop: 'operationTime', label: '操作时间', minWidth: 170, formatter: (row: TransactionRecord) => row.operationTime.replace('T', ' ').slice(0, 16) }, { prop: 'remark', label: '备注', minWidth: 180, formatter: (row: TransactionRecord) => row.remark || '—' }]
function inventoryStatus(status: string) { return ({ IN_STOCK: '在库', PARTIALLY_OUTBOUND: '部分出库', OUTBOUND: '已出库' } as Record<string, string>)[status] || status }
async function load() { loading.value = true; error.value = null; try { detail.value = await getDomesticContainerInventory(String(route.params.inventoryId)) } catch (cause) { error.value = getApiErrorDetails(cause, '整柜库存详情加载失败') } finally { loading.value = false } }
function back() { void router.push({ name: 'domestic-containers' }) }
onMounted(load)
</script>
<template><AdminPage as="div" class="inventory-detail-page"><header class="detail-header"><div><el-button aria-label="返回整柜业务" @click="back"><ArrowLeft :size="16" />返回</el-button><h1>{{ detail?.inventory.containerNo || '整柜库存详情' }}</h1><StatusBadge v-if="detail" :status="inventoryStatus(detail.inventory.inventoryStatus)" /></div><p>查看整柜库存快照与不可变交易记录</p></header><LoadErrorState v-if="error" title="整柜库存详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><template v-else-if="detail"><section class="detail-grid"><div class="surface-panel detail-section"><h2>货物信息</h2><dl><div><dt>货物名称</dt><dd>{{ detail.inventory.cargoName }}</dd></div><div><dt>集装箱号</dt><dd>{{ detail.inventory.containerNo }}</dd></div><div><dt>国内仓</dt><dd>{{ detail.inventory.warehouseName }}</dd></div><div><dt>入库单数量</dt><dd>{{ detail.inboundOrderCount }}</dd></div><div><dt>出库单数量</dt><dd>{{ detail.outboundOrderCount }}</dd></div><div><dt>累计入库体积</dt><dd>{{ detail.inventory.inboundVolumeCbm.toFixed(2) }} m³</dd></div></dl></div><div class="surface-panel detail-section"><h2>库存信息</h2><dl><div><dt>入库箱数</dt><dd>{{ detail.inventory.inboundBoxes }} 箱</dd></div><div><dt>已出库箱数</dt><dd>{{ detail.inventory.outboundBoxes }} 箱</dd></div><div><dt>当前库存</dt><dd>{{ detail.inventory.availableBoxes }} 箱</dd></div><div><dt>库存状态</dt><dd>{{ inventoryStatus(detail.inventory.inventoryStatus) }}</dd></div><div><dt>最后更新时间</dt><dd>{{ detail.inventory.updatedAt.replace('T', ' ').slice(0, 16) }}</dd></div><div><dt>库存版本</dt><dd>{{ detail.inventory.version }}</dd></div></dl></div></section><section class="surface-panel table-panel transactions"><h2>进销存交易记录</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="surface-panel detail-loading" v-loading="loading">正在加载整柜库存详情</div></AdminPage></template>
<style scoped>.inventory-detail-page{display:grid;min-width:0;gap:16px}.detail-header{padding:18px 20px;background:#fff;border:1px solid var(--cc-border)}.detail-header>div{display:flex;align-items:center;flex-wrap:wrap;gap:12px}.detail-header h1{margin:0;font-size:20px;letter-spacing:0}.detail-header p{margin:10px 0 0;color:var(--cc-muted)}.detail-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.detail-section{padding:18px}.detail-section h2,.transactions h2{margin:0 0 16px;font-size:16px;letter-spacing:0}.detail-section dl{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));margin:0;border-top:1px solid var(--cc-border);border-left:1px solid var(--cc-border)}.detail-section dl>div{min-width:0;padding:12px;border-right:1px solid var(--cc-border);border-bottom:1px solid var(--cc-border)}dt{font-size:12px;color:var(--cc-muted)}dd{margin:5px 0 0;overflow-wrap:anywhere}.transactions{padding:18px}.detail-loading{min-height:180px;padding:24px;color:var(--cc-muted)}@media(max-width:720px){.detail-grid{grid-template-columns:1fr}}@media(max-width:360px){.detail-header,.detail-section,.transactions{padding:12px}.detail-section dl{grid-template-columns:1fr}.detail-header h1{font-size:18px;width:100%}}</style>
<template>
<AdminPage as="div" class="inventory-detail-page">
<header class="detail-header">
<el-button aria-label="返回整柜业务" @click="back"><ArrowLeft :size="16" />返回</el-button>
<h1>{{ detail?.inventory.containerNo || '整柜库存详情' }}</h1>
<StatusBadge v-if="detail" :status="inventoryStatus(detail.inventory.inventoryStatus)" />
</header>
<LoadErrorState v-if="error" title="整柜库存详情加载失败" :message="error.message" :loading="loading" @retry="load">
<template #details><ApiErrorMeta :details="error" /></template>
</LoadErrorState>
<template v-else-if="detail">
<section class="detail-section" aria-labelledby="inventory-basic-heading">
<h2 id="inventory-basic-heading">整柜信息</h2>
<dl class="detail-fields">
<div><dt>货物名称</dt><dd>{{ detail.inventory.cargoName }}</dd></div>
<div><dt>集装箱号</dt><dd>{{ detail.inventory.containerNo }}</dd></div>
<div><dt>国内仓</dt><dd>{{ detail.inventory.warehouseName }}</dd></div>
<div><dt>库存状态</dt><dd>{{ inventoryStatus(detail.inventory.inventoryStatus) }}</dd></div>
<div><dt>入库单数量</dt><dd>{{ detail.inboundOrderCount }}</dd></div>
<div><dt>出库单数量</dt><dd>{{ detail.outboundOrderCount }}</dd></div>
<div><dt>入库箱数</dt><dd>{{ detail.inventory.inboundBoxes }} 箱</dd></div>
<div><dt>已出库箱数</dt><dd>{{ detail.inventory.outboundBoxes }} 箱</dd></div>
<div><dt>当前库存</dt><dd>{{ detail.inventory.availableBoxes }} 箱</dd></div>
<div><dt>累计入库体积</dt><dd>{{ detail.inventory.inboundVolumeCbm.toFixed(2) }} m³</dd></div>
<div><dt>最后更新时间</dt><dd>{{ displayTime(detail.inventory.updatedAt) }}</dd></div>
<div><dt>库存版本</dt><dd>{{ detail.inventory.version }}</dd></div>
</dl>
</section>
<section 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: 16px; }
.detail-header { display: flex; align-items: center; gap: 12px; min-height: 40px; }
.detail-header h1 { margin: 0; font-size: 20px; letter-spacing: 0; }
.detail-section, .transactions { min-width: 0; border: 1px solid var(--cc-border); background: #fff; }
.detail-section { padding: 16px 18px 18px; }
.detail-section h2, .transactions h2 { margin: 0; font-size: 15px; letter-spacing: 0; }
.detail-fields { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 14px 0 0; border-top: 1px solid var(--cc-border); border-left: 1px solid var(--cc-border); }
.detail-fields > div { min-width: 0; min-height: 58px; padding: 10px 12px; border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }
.detail-fields dt { color: var(--cc-muted); font-size: 12px; }
.detail-fields dd { margin: 4px 0 0; overflow-wrap: anywhere; }
.transactions { padding-top: 16px; overflow: hidden; }
.transactions h2 { margin: 0 18px 14px; }
.detail-loading { min-height: 180px; padding: 24px; border: 1px solid var(--cc-border); background: #fff; color: var(--cc-muted); }
@media (max-width: 960px) { .detail-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 520px) { .detail-header { flex-wrap: wrap; } .detail-header h1 { width: 100%; font-size: 18px; } .detail-fields { grid-template-columns: 1fr; } }
</style>
@@ -68,4 +68,4 @@ watch([page, pageSize], load); onMounted(async () => { await Promise.all([loadWa
<ListPageLayout :aria-label="`${activeTab === 'inbound' ? '整柜入库单' : activeTab === 'outbound' ? '整柜出库单' : '整柜库存'}列表`"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="整柜业务筛选" :fields="queryFields" :preference-key="`domestic-containers-${activeTab}-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><section v-if="!loadError" class="surface-panel table-panel"><div v-if="activeTab === 'inventory'" class="inventory-summary"><div><span>入库箱数</span><strong>{{ summary.inboundBoxes }}</strong></div><div><span>已出库箱数</span><strong>{{ summary.outboundBoxes }}</strong></div><div><span>当前库存</span><strong>{{ summary.availableBoxes }}</strong></div><div><span>累计入库体积</span><strong>{{ summary.inboundVolumeCbm.toFixed(2) }} m³</strong></div></div><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" stripe row-key="id" :preference-key="`domestic-containers-${activeTab}-table`" :empty-text="activeTab === 'inbound' ? '暂无整柜入库单' : activeTab === 'outbound' ? '暂无整柜出库单' : '暂无整柜库存'"><template #toolbar-actions><el-button v-if="activeTab === 'inbound' && access.can('platform:domestic-container:inbound:create')" type="primary" @click="openInbound"><Plus :size="16" />新建入库单</el-button><el-button v-if="activeTab === 'outbound' && access.can('platform:domestic-container:outbound:create')" type="primary" @click="openOutbound"><Plus :size="16" />新建出库单</el-button></template><template #actions="{ row }"><el-button size="small" type="primary" plain @click="viewInventory(row as ContainerOrder | InventoryRecord)"><Eye :size="15" />查看</el-button></template><template #status="{ row }"><StatusBadge :status="statusLabel(row as ContainerOrder | InventoryRecord)" /></template></DataTable></section></ListPageLayout>
<AppDialog v-model="dialogOpen" class="domestic-container-create-dialog" :title="dialogType === 'inbound' ? '新建整柜入库单' : '新建整柜出库单'" width="min(720px, calc(100vw - 28px))" :confirm-loading="saving" :close-on-click-modal="false" :confirm-text="dialogType === 'inbound' ? '创建入库单' : '创建出库单'" @confirm="submit"><LoadErrorState v-if="submitError" compact title="创建失败" :message="submitError.message" :show-retry="false"><template #details><ApiErrorMeta :details="submitError" /></template></LoadErrorState><el-form ref="formRef" :model="currentForm" :rules="rules" label-position="top"><el-row :gutter="16"><template v-if="dialogType === 'inbound'"><el-col :xs="24" :sm="12"><el-form-item label="国内仓" prop="warehouseId"><el-select v-model="inboundForm.warehouseId" :loading="warehouseLoading" :disabled="Boolean(warehouseError)" placeholder="请选择启用的国内仓"><el-option v-for="item in warehouseOptions" :key="String(item.value)" :label="item.label" :value="String(item.value)" /></el-select></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="货物名称" prop="cargoName"><el-input v-model="inboundForm.cargoName" maxlength="128" placeholder="请输入货物名称" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="集装箱号" prop="containerNo"><el-input v-model="inboundForm.containerNo" maxlength="32" placeholder="如 MSCU1234567" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="箱子数量" prop="boxCount"><el-input-number v-model="inboundForm.boxCount" :min="1" :precision="0" controls-position="right" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="体积(m³)" prop="volumeCbm"><el-input-number v-model="inboundForm.volumeCbm" :min="0.0001" :precision="4" controls-position="right" /></el-form-item></el-col></template><template v-else><el-col :xs="24" :sm="12"><el-form-item label="国内仓" prop="warehouseId"><el-select v-model="outboundForm.warehouseId" :loading="warehouseLoading" :disabled="Boolean(warehouseError)" placeholder="请选择启用的国内仓" @change="loadCandidates"><el-option v-for="item in warehouseOptions" :key="String(item.value)" :label="item.label" :value="String(item.value)" /></el-select></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="绑定集装箱号" prop="containerNo"><el-select v-model="outboundForm.inventoryId" :loading="candidateLoading" :disabled="!outboundForm.warehouseId || Boolean(candidateError)" placeholder="请选择已有集装箱号" @change="selectInventory"><el-option v-for="item in inventoryCandidates" :key="String(item.id)" :label="`${item.containerNo} · 可用 ${item.availableBoxes} 箱`" :value="String(item.id)" /></el-select><input v-model="outboundForm.containerNo" class="sr-only" tabindex="-1"></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="货物名称"><el-input v-model="outboundForm.cargoName" readonly /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="出库箱数" prop="boxCount"><el-input-number v-model="outboundForm.boxCount" :min="1" :precision="0" controls-position="right" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="可用库存"><el-input :model-value="`${outboundForm.availableBoxes} 箱`" readonly /></el-form-item></el-col><el-col v-if="candidateError" :xs="24"><LoadErrorState compact title="可出库库存加载失败" :message="candidateError.message" @retry="loadCandidates"><template #details><ApiErrorMeta :details="candidateError" /></template></LoadErrorState></el-col></template><el-col :xs="24" :sm="12"><el-form-item :label="dialogType === 'inbound' ? '入库时间' : '出库时间'" prop="operationTime"><el-date-picker v-model="currentForm.operationTime" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="状态" prop="status"><el-select v-model="currentForm.status"><el-option :label="dialogType === 'inbound' ? '已入库' : '已出库'" value="COMPLETED" /><el-option :label="dialogType === 'inbound' ? '待入库' : '待出库'" value="PENDING" /></el-select></el-form-item></el-col><el-col :xs="24"><el-form-item label="备注"><el-input v-model="currentForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注" /></el-form-item></el-col></el-row></el-form></AppDialog></AdminPage></template>
<style scoped>.domestic-containers-page{min-width:0}.business-tabs{margin-bottom:16px;background:#fff;border:1px solid var(--cc-border);padding:0 16px}.inventory-summary{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:1px;margin:0 0 12px;background:var(--cc-border);border:1px solid var(--cc-border)}.inventory-summary>div{display:grid;gap:6px;padding:14px 16px;background:#fff}.inventory-summary span{font-size:12px;color:var(--cc-muted)}.inventory-summary strong{font-size:20px}.domestic-container-create-dialog :deep(.el-select),.domestic-container-create-dialog :deep(.el-date-editor),.domestic-container-create-dialog :deep(.el-input-number){width:100%}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}@media(max-width:720px){.inventory-summary{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:360px){.business-tabs{padding:0 8px}.business-tabs :deep(.el-tabs__item){padding:0 8px;font-size:13px}.inventory-summary{grid-template-columns:1fr}.inventory-summary strong{font-size:18px}}</style>
<style scoped>.domestic-containers-page{min-width:0}.business-tabs{margin-bottom:16px}.business-tabs :deep(.el-tabs__header){margin:0}.inventory-summary{display:flex;align-items:center;gap:0;margin:0 16px 12px;border:1px solid var(--cc-border);background:#f8fafc}.inventory-summary>div{display:flex;align-items:baseline;gap:8px;min-width:0;padding:9px 16px;border-right:1px solid var(--cc-border)}.inventory-summary>div:last-child{border-right:0}.inventory-summary span{color:var(--cc-muted);font-size:12px}.inventory-summary strong{font-size:15px}.domestic-container-create-dialog :deep(.el-select),.domestic-container-create-dialog :deep(.el-date-editor),.domestic-container-create-dialog :deep(.el-input-number){width:100%}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}@media(max-width:720px){.inventory-summary{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.inventory-summary>div:nth-child(2){border-right:0}.inventory-summary>div:nth-child(-n+2){border-bottom:1px solid var(--cc-border)}}@media(max-width:360px){.business-tabs :deep(.el-tabs__item){padding:0 8px;font-size:13px}.inventory-summary{grid-template-columns:1fr}.inventory-summary>div{border-right:0;border-bottom:1px solid var(--cc-border)}.inventory-summary>div:last-child{border-bottom:0}}</style>