This commit is contained in:
2026-10-20 23:34:11 +08:00
parent f155976f52
commit 13e4aa56ec
321 changed files with 6756 additions and 2110 deletions
@@ -1,5 +1,6 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import '@/styles/adminWorkbench.css'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
@@ -16,7 +17,7 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import PageToolbar from '@/shared/components/PageToolbar.vue'
import { CONTAINER_SPECIFICATIONS } from '@/shared/domain/containerSpecifications'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listPlatformTenants, useActiveCustomerOptions } from '@/modules/iam'
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data'
import { listPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
import { CarrierScheduleQueryPanel } from '@/modules/port-schedules'
@@ -70,7 +71,6 @@ const actionSaving = ref(false)
const error = ref<ReturnType<typeof getApiErrorDetails> | null>(null)
const actionError = ref<ReturnType<typeof getApiErrorDetails> | null>(null)
const formRef = ref<FormInstance>()
const customers = ref<PlatformTenantResource[]>([])
const partners = ref<PlatformPartnerResource[]>([])
const bookingAgents = ref<OceanBookingAgentOption[]>([])
const bookingAgentsLoading = ref(false)
@@ -106,6 +106,7 @@ function blankData(): OceanBookingDraftData {
}
}
const form = reactive<OceanBookingSaveRequest>({ expectedVersion: 0, data: blankData() })
const { customers, loading: customersLoading, error: customersError, search: loadCustomers, endReached: customerEndReached, retry: retryCustomers } = useActiveCustomerOptions(() => form.data.customerId, listPlatformTenants)
const savedDraftSignature = ref('')
const rules: FormRules = {}
const editable = computed(() => isNew.value || detail.value?.status === 'DRAFT')
@@ -127,12 +128,11 @@ async function loadOptions() {
optionsLoading.value = true
optionsError.value = ''
try {
const [customerResult, partnerResult, warehouseResult] = await Promise.all([
listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' }),
const [, partnerResult, warehouseResult] = await Promise.all([
loadCustomers(),
listPlatformPartners({ page: 1, size: 200, status: 'ACTIVE' }),
listPlatformWarehouses({ page: 1, size: 200, warehouseType: 'DOMESTIC', status: 'ACTIVE' }),
])
customers.value = customerResult.records.filter((customer) => customer.status === 'ACTIVE')
partners.value = partnerResult.records
if (form.data.shippingCompanyId && !form.data.carrierCode) {
const selected = partners.value.find((partner) => String(partner.id) === String(form.data.shippingCompanyId))
@@ -141,7 +141,7 @@ async function loadOptions() {
warehouses.value = warehouseResult.records.filter((warehouse) => warehouse.status === 'ACTIVE' && warehouse.warehouseType === 'DOMESTIC')
if (form.data.carrierCode) void loadBookingAgents(form.data.carrierCode)
} catch (cause) {
optionsError.value = getApiErrorDetails(cause, '客户、船公司或仓库选项加载失败').message
optionsError.value = getApiErrorDetails(cause, '船公司或仓库选项加载失败').message
} finally {
optionsLoading.value = false
}
@@ -371,7 +371,7 @@ onMounted(() => { void load(); void loadOptions() })
</script>
<template>
<AdminPage as="div" class="booking-detail admin-density">
<AdminPage as="div" class="booking-detail admin-density admin-workbench">
<OperationErrorToast :details="actionError" />
<DetailPageLayout aria-label="订舱详情">
<template #header>
@@ -385,7 +385,7 @@ onMounted(() => { void load(); void loadOptions() })
<LoadErrorState v-if="error" title="订舱详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-else>
<section class="surface-panel">
<section class="workbench-panel is-compact">
<div class="section-title">
<h2>订舱信息</h2>
<div class="actions">
@@ -400,7 +400,7 @@ onMounted(() => { void load(); void loadOptions() })
<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>
<el-form-item label="客户(选填)"><el-select :model-value="form.data.customerId" filterable clearable placeholder="单客户可选择,多客户可留空" no-data-text="暂无可选客户" :loading="optionsLoading" style="width:100%" @update:model-value="selectCustomer"><el-option v-if="form.data.customerId && !selectableCustomers.some((customer) => String(customer.id) === String(form.data.customerId))" :label="form.data.customerName || '客户名称缺失'" :value="String(form.data.customerId)" /><el-option v-for="customer in selectableCustomers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select></el-form-item>
<el-form-item label="客户(选填)"><el-select :model-value="form.data.customerId" filterable clearable placeholder="单客户可选择,多客户可留空" no-data-text="暂无可选客户" :loading="customersLoading" remote :remote-method="loadCustomers" @end-reached="customerEndReached" style="width:100%" @update:model-value="selectCustomer"><el-option v-if="form.data.customerId && !selectableCustomers.some((customer) => String(customer.id) === String(form.data.customerId))" :label="form.data.customerName || '客户名称缺失'" :value="String(form.data.customerId)" /><el-option v-for="customer in selectableCustomers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select><LoadErrorState v-if="customersError" title="客户选项加载失败" :message="customersError.message" :loading="customersLoading" compact @retry="retryCustomers"><template #details><ApiErrorMeta :details="customersError" /></template></LoadErrorState></el-form-item>
<el-form-item label="状态"><el-input :model-value="detail ? statusLabel(detail.status) : '保存后为草稿'" disabled /></el-form-item>
<el-form-item label="装柜仓库" prop="warehouseId"><el-select :model-value="form.data.warehouseId" filterable placeholder="请选择装柜仓库" :loading="optionsLoading" style="width:100%" @update:model-value="selectWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" /></el-select></el-form-item>
<el-form-item label="物流渠道" prop="channelCode"><el-select :model-value="form.data.channelCode" filterable :disabled="!formWritable || !form.data.warehouseId || channelsLoading || !!channelsError || channelUnavailable" :loading="channelsLoading" :placeholder="form.data.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="该仓库暂无有效物流渠道" style="width:100%" @update:model-value="selectChannel(String($event || ''))"><el-option v-if="form.data.channelCode && !channelGroups.some((channel) => channel.code === form.data.channelCode)" :label="selectedChannelName || form.data.channelCode" :value="form.data.channelCode" /><el-option v-for="channel in channelGroups" :key="channel.code" :label="channel.name" :value="channel.code" /></el-select><div v-if="channelsError" class="field-error"><span>{{ channelsError }}</span><el-button link type="primary" :loading="channelsLoading" @click="form.data.warehouseId && loadChannels(form.data.warehouseId)">重新加载</el-button></div><div v-else-if="channelUnavailable" class="field-hint">该仓库暂无有效物流渠道,请更换仓库或联系管理员。</div></el-form-item>
@@ -425,7 +425,7 @@ onMounted(() => { void load(); void loadOptions() })
<section class="booking-group" aria-label="柜型需求">
<h3>柜型需求</h3><div class="booking-group__body">
<div class="container-table-wrap" aria-label="柜型需求">
<table class="container-table density-table">
<div class="workbench-evidence"><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">
@@ -443,7 +443,7 @@ onMounted(() => { void load(); void loadOptions() })
<td v-if="detail?.status === 'RELEASED'"><span>{{ requirement.remainingQuantity ?? requirement.quantity }}</span></td>
</tr>
</tbody>
</table>
</table></div>
</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" /></el-form-item>
@@ -452,7 +452,7 @@ onMounted(() => { void load(); void loadOptions() })
</el-form>
</section>
<section class="surface-panel">
<section class="workbench-panel is-compact">
<h2>订舱关系方</h2>
<div class="party-grid">
<div v-for="(party, index) in form.data.parties" :key="party.partyType" class="subsection">
@@ -467,17 +467,17 @@ onMounted(() => { void load(); void loadOptions() })
<OceanBookingDocumentsPanel v-if="detail" :booking-id="detail.id" :booking-no="detail.bookingNo" :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 dense :ocean-booking-id="detail.id" /></div>
<div v-if="detail" class="workbench-panel is-compact"><CarrierScheduleQueryPanel dense :ocean-booking-id="detail.id" /></div>
<section v-if="detail" class="surface-panel history-panel">
<section v-if="detail" class="workbench-panel is-compact history-panel">
<div class="section-title"><div><h2>状态历史</h2><p>当前进度:{{ statusLabel(detail.status) }}</p></div></div>
<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>
<div class="workbench-evidence"><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></div>
</section>
</template>
</DetailPageLayout>
<OceanBookingPrintDialog v-if="detail" v-model="printOpen" :booking-id="detail.id" :booking-no="detail.bookingNo" :allowed="canPrint" />
<AppDialog class="admin-density-dialog" v-model="releaseDialogOpen" :title="bookingDialogTitle('确认放舱')" width="min(560px, 94vw)" :confirm-loading="actionSaving" :confirm-disabled="workflowBusy" confirm-text="确认放舱" cancel-text="取消" @confirm="submitRelease">
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="releaseDialogOpen" :title="bookingDialogTitle('确认放舱')" width="min(560px, 94vw)" :confirm-loading="actionSaving" :confirm-disabled="workflowBusy" confirm-text="确认放舱" cancel-text="取消" @confirm="submitRelease">
<div class="density-dialog-sections">
<el-alert title="放舱后资料将同步到关联货柜,不能修改、不能删除,请确认资料无误。" type="warning" :closable="false" show-icon />
<section class="density-dialog-section" data-tone="summary"><el-descriptions class="release-summary density-descriptions" :column="2" border>
@@ -493,6 +493,6 @@ onMounted(() => { void load(); void loadOptions() })
</template>
<style scoped>
.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)}
.channel-code{display:block;margin-left:var(--admin-space-3);color:var(--workbench-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(--workbench-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(--workbench-border);align-items:center}.booking-page-toolbar{min-width:0;flex:1}.section-title p,.history-panel p{margin:5px 0 0;color:var(--workbench-muted);font-size:var(--admin-font-body)}.heading-status{display:flex;align-items:center;gap:var(--admin-space-3)}.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(--workbench-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(--workbench-muted);white-space:nowrap}.container-actions{display:flex;align-items:center;gap:var(--admin-space-4);margin-bottom:var(--admin-space-5);color:var(--workbench-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(--workbench-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){.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>