This commit is contained in:
2026-08-08 13:46:21 +08:00
parent a9a26faf0e
commit 4011f16b4c
11 changed files with 61 additions and 44 deletions
+1 -1
View File
@@ -62,5 +62,5 @@ export async function updateCustomerOverseasStorageFees(tenantId: string, reques
export async function listOverseasStorageFees(page = 1, size = 20, customerName?: string) {
const response = await http.get<never, ApiEnvelope<{ records: RawConfig[]; total: number; page: number; size: number }>>('/platform/v1/overseas-storage-fees', { params: { page, size, customerName } })
const result = unwrapApiResponse(response)
return { ...result, records: result.records.map(normalize) }
return { ...result, records: (result.records || []).map(normalize), total: Number(result.total || 0), page: Number(result.page || page), size: Number(result.size || size) }
}
@@ -1,8 +1,10 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Edit, RefreshCw, Search } from '@lucide/vue'
import { Edit } from '@lucide/vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import DataTable from '@/shared/components/DataTable.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -23,6 +25,16 @@ const form = reactive({ currency: 'USD', version: 0, tiers: [] as OverseasStorag
const storageTiers = computed(() => form.tiers.filter((tier) => tier.feeType === 'STORAGE'))
const returnTiers = computed(() => form.tiers.filter((tier) => tier.feeType === 'RETURN_STORAGE'))
const filters = reactive({ keyword: '' })
const queryFields = [{ key: 'keyword', label: '客户', type: 'input' as const, placeholder: '搜索客户名称' }]
const columns = [
{ key: 'actions', slotName: 'actions', label: '操作', width: 100, fixed: 'left' as const },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 180 },
{ key: 'customerId', prop: 'customerId', label: '客户ID', minWidth: 180 },
{ key: 'currency', prop: 'currency', label: '币种', width: 90 },
{ key: 'storage', slotName: 'storage', label: '仓租', minWidth: 260 },
{ key: 'returnStorage', slotName: 'returnStorage', label: '退货仓储费', minWidth: 260 },
]
function clone(tiers: OverseasStorageFeeTier[]) { return tiers.map((tier) => ({ ...tier })) }
function label(tier: OverseasStorageFeeTier) { return tier.endDay === null ? `${tier.startDay}天以上` : `${tier.startDay}-${tier.endDay}天` }
@@ -33,7 +45,7 @@ async function load() {
catch (cause) { error.value = getApiErrorDetails(cause, '海外仓阶梯费用加载失败,请稍后重试') }
finally { loading.value = false }
}
function search() { page.value = 1; void load() }
function search() { keyword.value = filters.keyword; page.value = 1; void load() }
function openEdit(row: OverseasStorageFeeConfig) { editing.value = row; form.currency = row.currency || 'USD'; form.version = row.version; form.tiers = clone(row.tiers); dialogOpen.value = true }
async function save() {
if (!editing.value || storageTiers.value.length !== 5 || returnTiers.value.length !== 4) { ElMessage.error('费用配置必须包含仓租5档和退货仓储费4档'); return }
@@ -47,17 +59,10 @@ onMounted(() => { void load() })
<template>
<div class="business-page overseas-fees-page">
<section class="page-header"><div><h1>海外仓阶梯费用</h1><p>维护客户仓租和退货仓储费,初始化后可调整天数、价格和币种。</p></div><el-button :loading="loading" @click="load"><RefreshCw :size="16" />刷新</el-button></section>
<section v-if="error" class="surface-panel error-state" role="alert"><div><strong>{{ error.message }}</strong><ApiErrorMeta :details="error" /></div><el-button type="primary" @click="load">重新加载</el-button></section>
<section v-else class="surface-panel">
<div class="toolbar"><el-input v-model="keyword" clearable placeholder="搜索客户名称" @keyup.enter="search"><template #prefix><Search :size="16" /></template></el-input><el-button type="primary" @click="search">查询</el-button></div>
<el-table :data="rows" v-loading="loading" row-key="id" empty-text="暂无海外仓阶梯费用配置">
<el-table-column label="操作" width="100" fixed="left"><template #default="{ row }"><el-button v-if="access.can('platform:customer:edit')" link type="primary" @click="openEdit(row)"><Edit :size="15" />编辑</el-button></template></el-table-column>
<el-table-column prop="customerName" label="客户" min-width="180" /><el-table-column prop="customerId" label="客户ID" min-width="180" />
<el-table-column prop="currency" label="币种" width="90" /><el-table-column label="仓租" min-width="260"><template #default="{ row }">{{ summary(row as OverseasStorageFeeConfig, 'STORAGE') }}</template></el-table-column>
<el-table-column label="退货仓储费" min-width="260"><template #default="{ row }">{{ summary(row as OverseasStorageFeeConfig, 'RETURN_STORAGE') }}</template></el-table-column>
</el-table>
<div class="pagination"><el-pagination v-model:current-page="page" v-model:page-size="size" :total="total" layout="total, sizes, prev, pager, next" @current-change="load" @size-change="search" /></div>
<QueryToolbar v-else v-model="filters" class="surface-panel" :fields="queryFields" ariaLabel="海外仓阶梯费用筛选" preference-key="overseas-storage-fees-query" @search="search" @reset="() => { filters.keyword = ''; search() }" />
<section v-if="!error" class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-storage-fees-table" row-key="id" empty-text="暂无海外仓阶梯费用配置" @update:page="load" @update:page-size="load"><template #toolbar-start><div class="table-heading"><span>共 {{ total }} 个客户</span></div></template><template #actions="{ row }"><el-button v-if="access.can('platform:customer:edit')" link type="primary" @click="openEdit(row as OverseasStorageFeeConfig)"><Edit :size="15" />编辑</el-button></template><template #storage="{ row }">{{ summary(row as OverseasStorageFeeConfig, 'STORAGE') }}</template><template #returnStorage="{ row }">{{ summary(row as OverseasStorageFeeConfig, 'RETURN_STORAGE') }}</template></DataTable>
</section>
<AppDialog v-model="dialogOpen" title="编辑海外仓阶梯费用" width="min(1120px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save">
<el-form label-position="top"><el-form-item label="币种"><el-select v-model="form.currency" style="width:180px"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /><el-option label="EUR" value="EUR" /></el-select></el-form-item><div class="groups"><div v-for="(group, index) in [storageTiers, returnTiers]" :key="index"><h3>{{ index === 0 ? '仓租(CBM/天)' : '退货仓储费(托/天)' }}</h3><el-table :data="group" size="small"><el-table-column label="开始天数"><template #default="{ row }"><el-input-number v-model="row.startDay" :min="1" :controls="false" /></template></el-table-column><el-table-column label="结束天数"><template #default="{ row }"><el-input-number v-if="row.endDay !== null" v-model="row.endDay" :min="1" :controls="false" /><span v-else>以上</span></template></el-table-column><el-table-column label="价格"><template #default="{ row }"><el-input-number v-model="row.unitPrice" :min="0" :precision="6" :controls="false" /></template></el-table-column></el-table></div></div></el-form>
@@ -10,9 +10,11 @@ export async function listOceanBookings(query: OceanBookingQuery) {
return { ...result, records: (result.records || []).map(booking), page: pageNumber(result.page, query.page), size: pageNumber(result.size, query.size), total: pageNumber(result.total, 0), pages: pageNumber(result.pages, 0) } satisfies OceanBookingPage
}
export async function getOceanBooking(id: string) { const response = await http.get<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(`${basePath}/${encodeURIComponent(id)}`); const raw = unwrapApiResponse(response); return { ...raw, ...booking(raw), statusHistory: (raw.statusHistory || []).map((event) => ({ ...event, id: String(event.id) })) } }
export async function createOceanBooking(payload: OceanBookingSaveRequest) { const response = await http.post<ApiEnvelope<string>, ApiEnvelope<string>>(basePath, payload); return unwrapApiResponse(response) }
export async function updateOceanBooking(id: string, payload: OceanBookingSaveRequest) { const response = await http.put<ApiEnvelope<void>, ApiEnvelope<void>>(`${basePath}/${encodeURIComponent(id)}`, payload); return unwrapApiResponse(response) }
function idempotencyKey() { return `ocean-booking-${Date.now()}-${Math.random().toString(36).slice(2)}` }
export async function createOceanBooking(payload: OceanBookingSaveRequest) { const response = await http.post<ApiEnvelope<string>, ApiEnvelope<string>>(basePath, payload, { headers: { 'Idempotency-Key': idempotencyKey() } }); return unwrapApiResponse(response) }
export async function updateOceanBooking(id: string, payload: OceanBookingSaveRequest) { const response = await http.put<ApiEnvelope<void>, ApiEnvelope<void>>(`${basePath}/${encodeURIComponent(id)}`, payload, { headers: { 'Idempotency-Key': idempotencyKey() } }); return unwrapApiResponse(response) }
export type OceanBookingAction = 'submit' | 'confirm' | 'release' | 'fail' | 'cancel' | 'change-request' | 'change-confirm' | 'change-fail'
export async function performOceanBookingAction(id: string, action: OceanBookingAction, reason?: string) { const response = await http.post<ApiEnvelope<void>, ApiEnvelope<void>>(`${basePath}/${encodeURIComponent(id)}/${action}`, reason ? { reason } : undefined); return unwrapApiResponse(response) }
export interface OceanBookingActionRequest { expectedVersion: number | null; reason?: string | null }
export async function performOceanBookingAction(id: string, action: OceanBookingAction, request: OceanBookingActionRequest) { const response = await http.post<ApiEnvelope<void>, ApiEnvelope<void>>(`${basePath}/${encodeURIComponent(id)}/${action}`, request, { headers: { 'Idempotency-Key': idempotencyKey() } }); return unwrapApiResponse(response) }
export async function printOceanBooking(id: string, type: 'SHIPPING_INSTRUCTION' | 'BILL_CONFIRMATION') { return http.get<Blob, Blob>(`${basePath}/${encodeURIComponent(id)}/print`, { params: { type }, responseType: 'blob' }) }
export type { OceanBookingResource, OceanBookingStatus }
@@ -4,17 +4,18 @@ import { useRoute, useRouter } from 'vue-router'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getApiErrorDetails } from '@/shared/api/http'
import { performOceanBookingAction, createOceanBooking, getOceanBooking, updateOceanBooking, type OceanBookingAction } from '../api/platformOceanBookings'
import type { OceanBookingDetail, OceanBookingSaveRequest, OceanBookingStatus } from '../types'
import { createOceanBooking, getOceanBooking, performOceanBookingAction, updateOceanBooking, type OceanBookingAction } from '../api/platformOceanBookings'
import type { OceanBookingDetail, OceanBookingDraftData, OceanBookingSaveRequest, OceanBookingStatus } from '../types'
const route = useRoute(); const router = useRouter(); const id = computed(() => String(route.params.bookingId || '')); const detail = ref<OceanBookingDetail | null>(null); const loading = ref(false); const saving = ref(false); const error = ref<ReturnType<typeof getApiErrorDetails> | null>(null); const actionError = ref<ReturnType<typeof getApiErrorDetails> | null>(null)
const form = reactive<OceanBookingSaveRequest>({ containers: [{ containerType: '40HC', quantity: 1 }], currency: 'USD' })
const blankData = (): OceanBookingDraftData => ({ customerId: '', customerName: '', parties: [], containerRequirements: [{ containerType: '40HC', quantity: 1 }] })
const form = reactive<OceanBookingSaveRequest>({ expectedVersion: null, data: blankData() })
const actionStatuses: OceanBookingStatus[] = ['SUBMITTED', 'CONFIRMED', 'RELEASED', 'FAILED', 'CHANGE_PENDING', 'CANCELLED']
const currentStatus = computed(() => detail.value?.status)
async function load(){ if(!id.value || id.value==='new') return; loading.value=true; try { detail.value=await getOceanBooking(id.value); Object.assign(form, detail.value) } catch(e){error.value=getApiErrorDetails(e)} finally{loading.value=false} }
async function save(){ saving.value=true; actionError.value=null; try { if(id.value==='new') { const created=await createOceanBooking(form); await router.replace(`/ocean-bookings/${created}`) } else await updateOceanBooking(id.value, form); await load() } catch(e){actionError.value=getApiErrorDetails(e)} finally{saving.value=false} }
const actionByStatus: Partial<Record<OceanBookingStatus, OceanBookingAction>> = { SUBMITTED: 'submit', CONFIRMED: 'confirm', RELEASED: 'release', FAILED: 'fail', CHANGE_PENDING: 'change-request', CANCELLED: 'cancel' }
async function action(status: OceanBookingStatus){ const actionName = actionByStatus[status]; if (!actionName) return; actionError.value=null; try { await performOceanBookingAction(id.value, actionName); await load() } catch(e){actionError.value=getApiErrorDetails(e)} }
async function load(){ if(!id.value || id.value==='new') return; loading.value=true; error.value=null; try { detail.value=await getOceanBooking(id.value); const source=detail.value.oceanBooking || detail.value; form.expectedVersion = Number((source as OceanBookingResourceWithVersion).expectedVersion ?? 0); form.data = { ...blankData(), ...(source as unknown as Partial<OceanBookingDraftData>), customerId: String(source.customerId || ''), customerName: source.customerName || '', parties: detail.value.parties || [], containerRequirements: detail.value.containerRequirements || [] } } catch(e){error.value=getApiErrorDetails(e)} finally{loading.value=false} }
type OceanBookingResourceWithVersion = { expectedVersion?: number | string | null }
async function save(){ saving.value=true; actionError.value=null; try { if(id.value==='new') { const created=await createOceanBooking(form); await router.replace(`/ocean-bookings/${created}`) } else await updateOceanBooking(id.value, form); await load() } catch(e){actionError.value=getApiErrorDetails(e)} finally{saving.value=false} }
async function action(status: OceanBookingStatus){ const actionName=actionByStatus[status]; if(!actionName) return; actionError.value=null; try { await performOceanBookingAction(id.value, actionName, { expectedVersion: form.expectedVersion, reason: null }); await load() } catch(e){actionError.value=getApiErrorDetails(e)} }
onMounted(load)
</script>
<template><div class="business-page booking-detail"><div class="detail-heading"><el-button @click="router.push('/ocean-bookings')">返回列表</el-button><div><h1>{{ detail?.bookingNo || '新增订舱' }}</h1><p v-if="detail">{{ detail.customerName || '—' }} · {{ detail.pol || '—' }} → {{ detail.pod || '—' }}</p></div><div v-if="detail" class="heading-status"><StatusBadge :status="currentStatus || 'DRAFT'" /></div></div><LoadErrorState v-if="error" title="订舱详情加载失败" :message="error.message" @retry="load" /><template v-else><el-alert v-if="actionError" type="error" :closable="false" show-icon>{{ actionError.message }}<span v-if="actionError.requestId">(requestId: {{ actionError.requestId }})</span></el-alert><section class="surface-panel form-panel"><div class="section-title"><h2>订舱信息</h2><div class="actions"><template v-for="nextStatus in actionStatuses" :key="nextStatus"><el-button v-if="detail && detail.status !== nextStatus" size="small" @click="action(nextStatus)">{{ nextStatus }}</el-button></template><el-button type="primary" :loading="saving" @click="save">保存</el-button></div></div><el-form :model="form" label-position="top" class="form-grid"><el-form-item label="客户"><el-input v-model="form.customerId" /></el-form-item><el-form-item label="关联订单号"><el-input v-model="form.orderNo" /></el-form-item><el-form-item label="船公司"><el-input v-model="form.carrierName" /></el-form-item><el-form-item label="订舱代理"><el-input v-model="form.bookingAgent" /></el-form-item><el-form-item label="船名"><el-input v-model="form.vesselName" /></el-form-item><el-form-item label="航次"><el-input v-model="form.voyageNo" /></el-form-item><el-form-item label="起运港 POL"><el-input v-model="form.pol" /></el-form-item><el-form-item label="目的港 POD"><el-input v-model="form.pod" /></el-form-item><el-form-item label="MB/L 货人"><el-input v-model="form.mblShipper" /></el-form-item><el-form-item label="MB/L 收人"><el-input v-model="form.mblConsignee" /></el-form-item><el-form-item label="S/O号"><el-input v-model="form.soNo" /></el-form-item><el-form-item label="截关时间"><el-input v-model="form.cutoffAt" /></el-form-item><el-form-item label="预计开船"><el-input v-model="form.etd" /></el-form-item><el-form-item label="预计到港"><el-input v-model="form.eta" /></el-form-item><el-form-item label="件数"><el-input v-model.number="form.packageCount" /></el-form-item><el-form-item label="金额(Decimal 字符串)"><el-input v-model="form.amount" /></el-form-item><el-form-item label="币种"><el-input v-model="form.currency" /></el-form-item><el-form-item class="wide" label="货物描述"><el-input v-model="form.goodsDescription" type="textarea" /></el-form-item><el-form-item class="wide" label="备注"><el-input v-model="form.remark" type="textarea" /></el-form-item></el-form></section><section v-if="detail" class="surface-panel"><h2>状态历史</h2><el-timeline><el-timeline-item v-for="event in detail.statusHistory" :key="event.id" :timestamp="event.occurredAt">{{ event.fromStatus || '—' }} → {{ event.toStatus }} · {{ event.operatorName || '—' }}</el-timeline-item></el-timeline></section></template></div></template>
<style scoped>.booking-detail{display:grid;gap:16px}.detail-heading{display:flex;align-items:center;gap:16px}.detail-heading h1{margin:0;font-size:20px}.detail-heading p{margin:5px 0 0;color:var(--cc-muted);font-size:13px}.heading-status{margin-left:auto}.surface-panel{padding:20px}.section-title{display:flex;justify-content:space-between;gap:12px;align-items:center;margin-bottom:18px}.section-title h2,.surface-panel>h2{margin:0;font-size:16px}.actions{display:flex;flex-wrap:wrap;gap:8px}.form-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:0 16px}.form-grid .wide{grid-column:1/-1}@media(max-width:900px){.form-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:600px){.detail-heading{align-items:flex-start;flex-wrap:wrap}.heading-status{margin-left:0}.form-grid{grid-template-columns:1fr}.form-grid .wide{grid-column:auto}.section-title{align-items:flex-start;flex-direction:column}.actions{width:100%}.actions :deep(.el-button){min-height:40px}}</style>
<template><div class="business-page booking-detail"><div class="detail-heading"><el-button @click="router.push('/ocean-bookings')">返回列表</el-button><div><h1>{{ detail?.bookingNo || '新增订舱' }}</h1><p v-if="detail">{{ detail.customerName || '—' }} · {{ detail.pol || '—' }} → {{ detail.pod || '—' }}</p></div><div v-if="detail" class="heading-status"><StatusBadge :status="detail.status" /></div></div><LoadErrorState v-if="error" title="订舱详情加载失败" :message="error.message" @retry="load" /><template v-else><el-alert v-if="actionError" type="error" :closable="false" show-icon>{{ actionError.message }}</el-alert><section class="surface-panel"><div class="section-title"><h2>订舱信息</h2><div class="actions"><template v-for="nextStatus in actionStatuses" :key="nextStatus"><el-button v-if="detail && detail.status !== nextStatus" size="small" @click="action(nextStatus)">{{ nextStatus }}</el-button></template><el-button type="primary" :loading="saving" @click="save">保存</el-button></div></div><el-form :model="form.data" label-position="top" class="form-grid"><el-form-item label="客户名称"><el-input v-model="form.data.customerName" /></el-form-item><el-form-item label="客户 ID"><el-input v-model="form.data.customerId" /></el-form-item><el-form-item label="关联订单号"><el-input v-model="form.data.orderNo" /></el-form-item><el-form-item label="船公司名称"><el-input v-model="form.data.shippingCompanyName" /></el-form-item><el-form-item label="船公司 ID"><el-input v-model="form.data.shippingCompanyId" /></el-form-item><el-form-item label="订舱代理"><el-input v-model="form.data.bookingAgentName" /></el-form-item><el-form-item label="起运港"><el-input v-model="form.data.departurePortName" /></el-form-item><el-form-item label="目的港"><el-input v-model="form.data.destinationPortName" /></el-form-item><el-form-item label="船名/航次"><el-input v-model="form.data.vesselName" /><el-input v-model="form.data.voyageNo" class="mt" /></el-form-item><el-form-item label="预计开船"><el-input v-model="form.data.estimatedSailingAt" /></el-form-item><el-form-item label="预计到港"><el-input v-model="form.data.estimatedArrivalAt" /></el-form-item><el-form-item label="S/O号"><el-input v-model="form.data.soNo" /></el-form-item><el-form-item class="wide" label="货物描述"><el-input v-model="form.data.goodsDescription" type="textarea" /></el-form-item><el-form-item class="wide" label="备注"><el-input v-model="form.data.remark" type="textarea" /></el-form-item></el-form></section><section v-if="detail" class="surface-panel"><h2>状态历史</h2><el-timeline><el-timeline-item v-for="event in detail.histories" :key="event.id" :timestamp="event.occurredAt">{{ event.fromStatus || '—' }} → {{ event.toStatus }} · {{ event.operatorName || '—' }}</el-timeline-item></el-timeline></section></template></div></template>
<style scoped>.booking-detail{display:grid;gap:16px}.detail-heading{display:flex;align-items:center;gap:16px}.detail-heading h1{margin:0;font-size:20px}.detail-heading p{margin:5px 0 0;color:var(--cc-muted);font-size:13px}.heading-status{margin-left:auto}.surface-panel{padding:20px}.section-title{display:flex;justify-content:space-between;gap:12px;align-items:center;margin-bottom:18px}.section-title h2,.surface-panel>h2{margin:0;font-size:16px}.actions{display:flex;flex-wrap:wrap;gap:8px}.form-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:0 16px}.form-grid .wide{grid-column:1/-1}.mt{margin-top:8px}@media(max-width:900px){.form-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:600px){.detail-heading{align-items:flex-start;flex-wrap:wrap}.heading-status{margin-left:0}.form-grid{grid-template-columns:1fr}.form-grid .wide{grid-column:auto}.section-title{align-items:flex-start;flex-direction:column}.actions{width:100%}.actions :deep(.el-button){min-height:40px}}</style>
@@ -18,5 +18,5 @@ async function load() { loading.value = true; error.value = null; try { const re
function search() { page.value = 1; void load() }; function open(id?: string) { void router.push(id ? `/ocean-bookings/${id}` : '/ocean-bookings/new') }
watch([page, pageSize], () => void load()); onMounted(load)
</script>
<template><div class="business-page ocean-bookings-page"><div class="page-heading"><div><h1>海运订舱</h1><p>维护订舱申请、船期及放舱信息</p></div><el-button type="primary" @click="open()">新增订舱</el-button></div><QueryToolbar v-model="query" aria-label="订舱查询" :fields="fields" @search="search" /><LoadErrorState v-if="error" title="订舱列表加载失败" :message="error.message" @retry="load" /><DataTable v-else v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="ocean-bookings"><template #actions="{ row }"><el-button link type="primary" @click="open(row.id)">查看</el-button><el-button link @click="open(row.id)">编辑</el-button></template><template #vessel="{ row }">{{ row.vesselName || '—' }}<span v-if="row.voyageNo"> / {{ row.voyageNo }}</span></template><template #status="{ row }"><StatusBadge :status="row.status" /></template></DataTable></div></template>
<template><div class="business-page ocean-bookings-page"><div class="page-heading"><div><h1>海运订舱</h1><p>维护订舱申请、船期及放舱信息</p></div><el-button type="primary" @click="open()">新增订舱</el-button></div><QueryToolbar v-model="query" ariaLabel="订舱查询" :fields="fields" @search="search" /><LoadErrorState v-if="error" title="订舱列表加载失败" :message="error.message" @retry="load" /><DataTable v-else v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="ocean-bookings"><template #actions="{ row }"><el-button link type="primary" @click="open(row.id)">查看</el-button><el-button link @click="open(row.id)">编辑</el-button></template><template #vessel="{ row }">{{ row.vesselName || '—' }}<span v-if="row.voyageNo"> / {{ row.voyageNo }}</span></template><template #status="{ row }"><StatusBadge :status="row.status" /></template></DataTable></div></template>
<style scoped>.ocean-bookings-page{display:grid;gap:16px}.page-heading{display:flex;align-items:center;justify-content:space-between;gap:16px}.page-heading h1{margin:0;font-size:20px}.page-heading p{margin:6px 0 0;color:var(--cc-muted);font-size:13px}@media(max-width:600px){.page-heading{align-items:flex-start;flex-direction:column}.page-heading :deep(.el-button){width:100%;min-height:40px}}</style>
+13 -3
View File
@@ -1,6 +1,16 @@
export type OceanBookingStatus = 'DRAFT' | 'SUBMITTED' | 'CONFIRMED' | 'RELEASED' | 'FAILED' | 'CHANGE_PENDING' | 'CANCELLED'
export interface OceanBookingContainer { id: string; containerType: string; quantity: number; containerKind?: string | null }
export interface OceanBookingParty { partyType: string; companyName: string; contactName?: string | null; phone?: string | null; email?: string | null; address?: string | null }
export interface OceanBookingContainer { id?: string; containerType: string; quantity: number; containerKind?: string | null }
export interface OceanBookingContainerAllocation extends OceanBookingContainer { allocatedQuantity?: number | null }
export interface OceanBookingDraftData {
customerId: string; customerName: string; parties: OceanBookingParty[]; containerRequirements: OceanBookingContainer[]
shippingCompanyId?: string | null; shippingCompanyName?: string | null; bookingAgentId?: string | null; bookingAgentName?: string | null
departurePortCode?: string | null; departurePortName?: string | null; destinationPortCode?: string | null; destinationPortName?: string | null
estimatedSailingAt?: string | null; estimatedArrivalAt?: string | null; soNo?: string | null; vesselName?: string | null; voyageNo?: string | null
cutoffAt?: string | null; goodsDescription?: string | null; packageCount?: number | null; grossWeight?: string | null; volume?: string | null
amount?: string | null; currency?: string | null; remark?: string | null; orderNo?: string | null
}
export interface OceanBookingResource {
id: string; bookingNo: string | null; orderNo: string | null; customerId: string | null; customerName: string | null
status: OceanBookingStatus; carrierName: string | null; bookingAgent: string | null; vesselName: string | null; voyageNo: string | null
@@ -9,8 +19,8 @@ export interface OceanBookingResource {
containerSummary: string | null; containers: OceanBookingContainer[]; amount: string | null; currency: string | null
remark: string | null; createdAt: string | null; updatedAt: string | null
}
export interface OceanBookingDetail extends OceanBookingResource { goodsDescription: string | null; packageCount: number | null; grossWeight: string | null; volume: string | null; statusHistory: OceanBookingStatusEvent[] }
export interface OceanBookingDetail extends OceanBookingResource { oceanBooking?: OceanBookingResource; parties: OceanBookingParty[]; containerRequirements: OceanBookingContainer[]; containerAllocations: OceanBookingContainerAllocation[]; histories: OceanBookingStatusEvent[]; goodsDescription: string | null; packageCount: number | null; grossWeight: string | null; volume: string | null; statusHistory: OceanBookingStatusEvent[] }
export interface OceanBookingStatusEvent { id: string; fromStatus: OceanBookingStatus | null; toStatus: OceanBookingStatus; action: string; operatorName: string | null; occurredAt: string }
export interface OceanBookingPage { records: OceanBookingResource[]; page: number; size: number; total: number; pages: number }
export interface OceanBookingQuery { page: number; size: number; keyword?: string; status?: OceanBookingStatus; pol?: string; pod?: string; soNo?: string; customerId?: string }
export interface OceanBookingSaveRequest { customerId?: string | null; orderNo?: string | null; carrierName?: string | null; bookingAgent?: string | null; vesselName?: string | null; voyageNo?: string | null; pol?: string | null; pod?: string | null; mblShipper?: string | null; mblConsignee?: string | null; soNo?: string | null; cutoffAt?: string | null; etd?: string | null; eta?: string | null; goodsDescription?: string | null; packageCount?: number | null; grossWeight?: string | null; volume?: string | null; amount?: string | null; currency?: string | null; remark?: string | null; containers: Array<{ containerType: string; quantity: number; containerKind?: string | null }> }
export interface OceanBookingSaveRequest { expectedVersion: number | null; data: OceanBookingDraftData }
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { changeTruckAppointmentStatus, createTruckAppointment, getTruckAppointment, listTruckAppointments } from './platformTrucking'
import { createTruckAppointment, getTruckAppointment, listTruckAppointments, performTruckAppointmentAction } from './platformTrucking'
vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn() }, unwrapApiResponse: (response: { data: unknown }) => response.data }))
@@ -9,17 +9,17 @@ describe('truck appointment API', () => {
it('normalizes page values and maps filters', async () => {
vi.mocked(http.get).mockResolvedValue({ data: { records: [], page: '2', size: '20', total: '21', pages: '2' } })
await expect(listTruckAppointments({ page: 2, size: 20, keyword: ' BY ', status: 'DISPATCHED', containerType: '40HC' })).resolves.toMatchObject({ page: 2, total: 21, pages: 2 })
expect(http.get).toHaveBeenCalledWith('/platform/v1/truck-appointments', { params: { page: 2, size: 20, keyword: 'BY', status: 'DISPATCHED', containerType: '40HC' } })
await expect(listTruckAppointments({ page: 2, size: 20, keyword: ' BY ', status: 'DISPATCHED', containerBookingId: '77' })).resolves.toMatchObject({ page: 2, total: 21, pages: 2 })
expect(http.get).toHaveBeenCalledWith('/platform/v1/truck-appointments', { params: { page: 2, size: 20, keyword: 'BY', status: 'DISPATCHED', containerBookingId: '77' } })
})
it('uses detail, create, and status contracts', async () => {
vi.mocked(http.get).mockResolvedValue({ data: { id: '9001', status: 'DRAFT' } })
vi.mocked(http.post).mockResolvedValue({ data: '9001' })
vi.mocked(http.patch).mockResolvedValue({ data: null })
vi.mocked(http.post).mockResolvedValue({ data: { id: '9001', status: 'DISPATCHED' } })
await expect(getTruckAppointment('9001')).resolves.toMatchObject({ id: '9001' })
await expect(createTruckAppointment({ expectedLoadingDate: '2026-08-10' })).resolves.toBe('9001')
await expect(changeTruckAppointmentStatus('9001', 'DISPATCHED')).resolves.toBeNull()
expect(http.patch).toHaveBeenCalledWith('/platform/v1/truck-appointments/9001/status', { status: 'DISPATCHED' })
await expect(createTruckAppointment({ plannedLoadingAt: '2026-08-10T10:00:00', expectedVersion: 0 })).resolves.toMatchObject({ id: '9001' })
await expect(performTruckAppointmentAction('9001', 'DISPATCH', 2)).resolves.toMatchObject({ status: 'DISPATCHED' })
expect(http.post).toHaveBeenCalledWith('/platform/v1/truck-appointments/9001/dispatch', { expectedVersion: 2, reason: null }, { headers: {} })
})
})
@@ -11,7 +11,6 @@ watch(() => props.modelValue, (value) => Object.assign(draft, value), { deep: tr
watch(draft, (value) => emit('update:modelValue', { ...value }), { deep: true })
const rules: FormRules = {
plannedLoadingAt: [{ required: true, message: '请选择预计装箱时间', trigger: 'change' }],
carrierId: [{ required: true, message: '请输入承运商 ID', trigger: 'blur' }],
}
const relationText = computed(() => [props.resource?.customerName, props.resource?.outboundOrderId, props.resource?.containerBookingId].filter(Boolean).join(' / ') || '由服务端关联资源返回')
async function validate() { return formRef.value?.validate() }
@@ -11,7 +11,7 @@ import { getTruckAppointment, performTruckAppointmentAction, rescheduleTruckAppo
import TruckAppointmentForm from '../components/TruckAppointmentForm.vue'
import TruckAttachmentDialog from '../components/TruckAttachmentDialog.vue'
import { TRUCK_APPOINTMENT_STATUSES, truckAppointmentStatusLabels, type SaveTruckAppointmentRequest, type TruckAppointmentResource, type TruckAppointmentStatus, type TruckAttachmentReference } from '../types'
const route = useRoute(); const router = useRouter(); const open = ref(true); const loading = ref(false); const saving = ref(false); const statusSaving = ref(false); const attachmentOpen = ref(false); const resource = ref<TruckAppointmentResource | null>(null); const error = ref<ApiErrorDetails | null>(null); const form = reactive<SaveTruckAppointmentRequest>({})
const route = useRoute(); const router = useRouter(); const open = ref(true); const loading = ref(false); const saving = ref(false); const statusSaving = ref(false); const attachmentOpen = ref(false); const resource = ref<TruckAppointmentResource | null>(null); const error = ref<ApiErrorDetails | null>(null); const form = reactive<SaveTruckAppointmentRequest>({ expectedVersion: 0 })
const access = useAccessPreviewStore(); const editing = computed(() => route.query.edit === '1'); const id = computed(() => String(route.params.id)); const canEdit = computed(() => access.can('platform:firstleg:truck-appointment:edit') && !['COMPLETED','CANCELLED'].includes(resource.value?.status || '') && editing.value); const canChangeStatus = computed(() => access.can('platform:firstleg:truck-appointment:operate'))
async function load() { loading.value = true; error.value = null; try { const result = await getTruckAppointment(id.value); resource.value = result; Object.assign(form, { ...result, expectedVersion: result.version ?? 0, attachmentRefs: result.attachments || [] }) } catch (err) { error.value = getApiErrorDetails(err, '详情加载失败,请稍后重试') } finally { loading.value = false } }
async function submit() { saving.value = true; error.value = null; try { await updateTruckAppointment(id.value, form); ElMessage.success('拖车预约已保存'); await load() } catch (err) { error.value = getApiErrorDetails(err, '保存失败,请稍后重试') } finally { saving.value = false } }
@@ -8,13 +8,12 @@ import type { TruckAppointmentResource } from '../types'
vi.mock('../api/platformTrucking', () => ({
listTruckAppointments: vi.fn(),
exportTruckAppointments: vi.fn(),
}))
const row: TruckAppointmentResource = {
id: '9001', appointmentNo: 'TA-9001', status: 'DISPATCHED', customerName: '示例企业', orderNo: 'ORD-1',
containerNo: 'CONT-1', containerType: '40HC', transportCompanyName: '宁波运输', driverName: '王强', tractorNo: '浙B12345',
expectedLoadingDate: '2026-08-10', payableAmount: '3200.00', receivableAmount: '3800.00', attachments: [],
id: '9001', appointmentNo: 'TA-9001', status: 'DISPATCHED', customerName: '示例企业', orderNo: 'ORD-1', containerBookingId: '77',
containerNo: 'CONT-1', carrierName: '宁波运输', driverName: '王强', vehicleNo: '浙B12345',
plannedLoadingAt: '2026-08-10T10:00:00', payableAmount: '3200.00', receivableAmount: '3800.00', attachments: [],
}
describe('TruckAppointmentsView', () => {
@@ -26,9 +25,9 @@ describe('TruckAppointmentsView', () => {
it('loads the real list and renders relationship snapshots', async () => {
const wrapper = mount(TruckAppointmentsView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(listTruckAppointments).toHaveBeenCalledWith({ page: 1, size: 20, keyword: '', status: '', containerType: '' })
expect(listTruckAppointments).toHaveBeenCalledWith({ page: 1, size: 20, keyword: '', status: '', containerBookingId: '' })
expect(wrapper.text()).toContain('TA-9001')
expect(wrapper.text()).toContain('40HC')
expect(wrapper.text()).toContain('柜体 77')
expect(wrapper.text()).toContain('宁波运输')
expect(wrapper.find('button[aria-label="查看 TA-9001"]').exists()).toBe(true)
})
+1
View File
@@ -62,6 +62,7 @@ export interface TruckAppointmentResource {
storeCity?: string | null
storeZipcode?: string | null
plannedLoadingAt?: string | null
actualLoadingAt?: string | null
transitMinutes?: number | string | null
packageCount?: number | string | null
grossWeightKg?: string | null