This commit is contained in:
2026-08-08 13:32:27 +08:00
parent 24af934198
commit f17a6a60a4
27 changed files with 867 additions and 5 deletions
+4
View File
@@ -19,6 +19,8 @@ import { customerRoutes } from '@/modules/customers/routes'
import { dashboardRoutes } from '@/modules/dashboard/routes'
import { warehouseRoutes } from '@/modules/warehouses/routes'
import { prototypeModuleRoutes } from '@/prototype/routes'
import { oceanBookingRoutes } from '@/modules/ocean-bookings/routes'
import { truckingRoutes } from '@/modules/trucking/routes'
const router = createRouter({
history: createWebHistory(),
@@ -42,6 +44,8 @@ const router = createRouter({
},
...dashboardRoutes,
...prototypeModuleRoutes,
...oceanBookingRoutes,
...truckingRoutes,
...customerRoutes,
...warehouseRoutes,
...unifiedOrderRoutes,
+3 -4
View File
@@ -44,7 +44,7 @@ export const adminNavigation: AdminNavigationNode[] = [
iconColor: '#52c41a',
children: [
{ key: 'customers-list', title: '客户列表', icon: Building2, path: '/customers', permission: 'platform:customer:list', permissionAnyOf: ['platform:customer:review'] },
{ key: 'warehouse-tier-fee', title: '海外仓阶梯费用', icon: Building2, path: '/prototype/customer-management/warehouse-tier-fee', hidden: true },
{ key: 'warehouse-tier-fee', title: '海外仓阶梯费用', icon: Building2, path: '/customers/overseas-storage-fees', permission: 'platform:overseas-storage-fee:list' },
],
},
{
@@ -76,10 +76,9 @@ export const adminNavigation: AdminNavigationNode[] = [
key: 'service-center',
title: '服务中心',
icon: ClipboardList,
hidden: true,
children: [
{ key: 'booking-list', title: '订舱列表', icon: ClipboardList, path: '/prototype/service-center/booking-list' },
{ key: 'truck-list', title: '拖车列表', icon: Truck, path: '/prototype/service-center/truck-list' },
{ key: 'booking-list', title: '订舱列表', icon: ClipboardList, path: '/ocean-bookings', permission: 'platform:firstleg:ocean-booking:list' },
{ key: 'truck-list', title: '拖车列表', icon: Truck, path: '/trucking', permission: 'platform:firstleg:truck-appointment:list' },
],
},
{
@@ -0,0 +1,97 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance } from 'element-plus'
import { Pencil, RefreshCw } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import { getCustomerOverseasStorageFees, updateCustomerOverseasStorageFees, type OverseasStorageFeeConfig, type OverseasStorageFeeTier } from './overseasStorageFee'
const props = defineProps<{ customerId: string; customerName: string }>()
const access = useAccessPreviewStore()
const canView = computed(() => access.can('platform:customer:view'))
const canEdit = computed(() => access.can('platform:customer:edit'))
const config = ref<OverseasStorageFeeConfig | null>(null)
const loading = ref(false)
const saving = ref(false)
const error = ref<ApiErrorDetails | null>(null)
const visible = ref(false)
const dirty = ref(false)
const formRef = ref<FormInstance>()
const form = reactive<{ currency: string; tiers: OverseasStorageFeeTier[]; version: number }>({ currency: 'USD', tiers: [], version: 0 })
const currencies = ['USD', 'CNY', 'HKD', 'EUR', 'GBP']
const storageTiers = computed(() => form.tiers.filter((tier) => tier.feeType === 'STORAGE'))
const returnTiers = computed(() => form.tiers.filter((tier) => tier.feeType === 'RETURN_STORAGE'))
const { captureBaseline } = useUnsavedWorkRegistration({ key: `customer-overseas-storage-fee-${props.customerId}`, label: computed(() => `${props.customerName}的海外仓费用`), isOpen: visible, model: form, requiredPermission: computed(() => 'platform:customer:edit') })
watch(form, () => { if (visible.value) dirty.value = true }, { deep: true })
watch(() => props.customerId, () => { visible.value = false; void load() })
async function load() {
if (!canView.value) return
loading.value = true; error.value = null
try { config.value = await getCustomerOverseasStorageFees(props.customerId) }
catch (cause) { config.value = null; error.value = getApiErrorDetails(cause, '海外仓费用加载失败,请稍后重试') }
finally { loading.value = false }
}
function cloneTiers(tiers: OverseasStorageFeeTier[]) { return tiers.map((tier) => ({ ...tier })) }
async function openEditor() {
if (!config.value) return
form.currency = config.value.currency || 'USD'; form.tiers = cloneTiers(config.value.tiers); form.version = config.value.version
await nextTick(); dirty.value = false; visible.value = true; await nextTick(); captureBaseline(); formRef.value?.clearValidate()
}
async function closeEditor() {
if (saving.value) return
if (dirty.value) { try { await ElMessageBox.confirm('海外仓费用尚未保存,确定放弃修改吗?', '放弃编辑', { confirmButtonText: '放弃', cancelButtonText: '继续编辑', type: 'warning' }) } catch { return } }
visible.value = false
}
function validate() {
if (storageTiers.value.length !== 5 || returnTiers.value.length !== 4) { ElMessage.error('仓租必须为5档,退货仓储费必须为4档'); return false }
for (const [label, tiers] of [['仓租', storageTiers.value], ['退货仓储费', returnTiers.value] ] as const) {
let expected = 1
for (const [index, tier] of tiers.entries()) {
if (!Number.isInteger(tier.startDay) || tier.startDay !== expected || tier.startDay < 1) { ElMessage.error(`${label}第${index + 1}档天数必须连续且从第1天开始`); return false }
if (!Number.isFinite(tier.unitPrice) || tier.unitPrice < 0) { ElMessage.error(`${label}第${index + 1}档价格不能小于0`); return false }
if (index === tiers.length - 1) { if (tier.endDay !== null) { ElMessage.error(`${label}最后一档必须为“以上”`); return false } }
else if (!Number.isInteger(tier.endDay) || tier.endDay! < tier.startDay) { ElMessage.error(`${label}第${index + 1}档结束天数无效`); return false }
if (tier.endDay !== null) expected = tier.endDay + 1
}
}
return true
}
async function save() {
if (!config.value || !validate()) return
saving.value = true
try {
await updateCustomerOverseasStorageFees(props.customerId, { customerId: props.customerId, customerName: props.customerName, currency: form.currency, version: form.version, tiers: form.tiers.map(({ feeType, billingUnit, startDay, endDay, unitPrice }) => ({ feeType, billingUnit, startDay, endDay, unitPrice })) })
await load(); dirty.value = false; visible.value = false; ElMessage.success('海外仓费用已保存')
} catch (cause) { ElMessage.error(getApiErrorDetails(cause, '海外仓费用保存失败,请刷新后重试').message) }
finally { saving.value = false }
}
function dayLabel(tier: OverseasStorageFeeTier) { return tier.endDay === null ? `第${tier.startDay}天以上` : `第${tier.startDay}-${tier.endDay}天` }
onMounted(load)
</script>
<template>
<section class="overseas-fee-panel">
<header class="panel-heading"><div><h2>海外仓费用</h2><p>客户专属仓租和退货仓储费阶梯</p></div><el-button v-if="canEdit && config" type="primary" :loading="loading" @click="openEditor"><Pencil :size="16" />编辑费用</el-button></header>
<div v-if="error" class="fee-error" role="alert"><div><strong>海外仓费用加载失败</strong><p>{{ error.message }}</p><ApiErrorMeta :details="error" /></div><el-button plain type="primary" :loading="loading" @click="load"><RefreshCw :size="16" />重新加载</el-button></div>
<div v-else-if="loading && !config" v-loading="true" class="fee-loading" />
<template v-else-if="config">
<div class="fee-meta"><span>币种:<strong>{{ config.currency }}</strong></span><span>按整组保存,版本 {{ config.version }}</span></div>
<div class="fee-groups"><div><h3>仓租({{ config.currency }}/CBM/天)</h3><table><thead><tr><th>天数</th><th>价格</th></tr></thead><tbody><tr v-for="tier in config.tiers.filter((item) => item.feeType === 'STORAGE')" :key="tier.id"><td>{{ dayLabel(tier) }}</td><td>{{ tier.unitPrice }}</td></tr></tbody></table></div><div><h3>退货仓储费({{ config.currency }}/托/天)</h3><table><thead><tr><th>天数</th><th>价格</th></tr></thead><tbody><tr v-for="tier in config.tiers.filter((item) => item.feeType === 'RETURN_STORAGE')" :key="tier.id"><td>{{ dayLabel(tier) }}</td><td>{{ tier.unitPrice }}</td></tr></tbody></table></div></div>
</template>
<div v-else class="fee-empty">当前客户暂无海外仓费用配置</div>
<AppDialog v-model="visible" title="编辑海外仓费用" width="min(1120px, calc(100vw - 28px))" :confirm-loading="saving" :show-cancel="false" @confirm="save">
<el-form ref="formRef" :model="form" label-position="top"><el-form-item label="币种"><el-select v-model="form.currency" style="width:180px"><el-option v-for="currency in currencies" :key="currency" :label="currency" :value="currency" /></el-select></el-form-item><div class="edit-groups"><div v-for="(group, groupIndex) in [storageTiers, returnTiers]" :key="groupIndex"><h3>{{ groupIndex === 0 ? '仓租(CBM/天)' : '退货仓储费(托/天)' }}</h3><table><thead><tr><th>开始天数</th><th>结束天数</th><th>价格</th></tr></thead><tbody><tr v-for="tier in group" :key="tier.id"><td><el-input-number v-model="tier.startDay" :min="1" :max="36500" :controls="false" /></td><td><el-input-number v-if="tier.endDay !== null" v-model="tier.endDay" :min="1" :max="36500" :controls="false" /><span v-else>以上</span></td><td><el-input-number v-model="tier.unitPrice" :min="0" :precision="6" :controls="false" /></td></tr></tbody></table></div></div></el-form>
<template #footer><div class="dialog-actions"><el-button :disabled="saving" @click="closeEditor">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存费用</el-button></div></template>
</AppDialog>
</section>
</template>
<style scoped>
.overseas-fee-panel { margin-top: 18px; border: 1px solid var(--cc-border); background: #fff; }.panel-heading { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:18px; border-bottom:1px solid var(--cc-border); }.panel-heading h2,.fee-groups h3,.edit-groups h3 { margin:0; color:var(--cc-text); font-size:16px; }.panel-heading p { margin:5px 0 0; color:var(--cc-muted); font-size:12px; }.fee-meta { display:flex; flex-wrap:wrap; gap:24px; padding:12px 18px; color:var(--cc-muted); font-size:12px; background:#f8fafc; }.fee-meta strong { color:var(--cc-text); }.fee-groups,.edit-groups { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:18px; padding:18px; }.fee-groups table,.edit-groups table { width:100%; border-collapse:collapse; font-size:12px; }.fee-groups th,.fee-groups td,.edit-groups th,.edit-groups td { padding:9px 10px; border-bottom:1px solid var(--cc-border); text-align:left; }.fee-groups th,.edit-groups th { color:var(--cc-muted); font-weight:650; }.edit-groups :deep(.el-input-number) { width:130px; }.fee-error { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:20px; background:#fff4f3; }.fee-error p { margin:5px 0; color:#b42318; font-size:12px; }.fee-loading { min-height:220px; }.fee-empty { min-height:160px; display:grid; place-items:center; color:var(--cc-muted); }.dialog-actions { display:flex; justify-content:flex-end; gap:8px; }.dialog-actions :deep(.el-button + .el-button) { margin-left:0; }
@media (max-width:760px) { .panel-heading,.fee-error { align-items:stretch; flex-direction:column; }.fee-groups,.edit-groups { grid-template-columns:1fr; padding:12px; }.edit-groups { overflow:auto; }.edit-groups > div { min-width:620px; }.panel-heading :deep(.el-button) { min-height:44px; } }
</style>
@@ -0,0 +1,31 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getCustomerOverseasStorageFees, updateCustomerOverseasStorageFees } from './overseasStorageFee'
import { http } from '@/shared/api/http'
vi.mock('@/shared/api/http', () => ({
http: { get: vi.fn(), put: vi.fn() },
unwrapApiResponse: <T>(response: { data: T }) => response.data,
}))
const config = { id: 12, customerId: 9007199254740993n.toString(), customerName: '客户', currency: 'USD', tiers: [], version: 0, createdAt: null, updatedAt: null }
describe('customer overseas storage fee api', () => {
beforeEach(() => vi.clearAllMocks())
it('keeps customer ids as strings and uses the customer detail path', async () => {
vi.mocked(http.get).mockResolvedValue({ data: config })
const result = await getCustomerOverseasStorageFees(config.customerId)
expect(http.get).toHaveBeenCalledWith(`/platform/v1/iam/tenants/${config.customerId}/overseas-storage-fees`)
expect(result.id).toBe('12')
expect(result.customerId).toBe(config.customerId)
})
it('submits the complete group and reloads the new version', async () => {
vi.mocked(http.put).mockResolvedValue({ data: null })
vi.mocked(http.get).mockResolvedValue({ data: { ...config, version: 1 } })
const request = { customerId: config.customerId, customerName: '客户', currency: 'CNY', version: 0, tiers: [{ feeType: 'STORAGE' as const, billingUnit: 'CBM' as const, startDay: 1, endDay: null, unitPrice: 0 }] }
const result = await updateCustomerOverseasStorageFees(config.customerId, request)
expect(http.put).toHaveBeenCalledWith(`/platform/v1/iam/tenants/${config.customerId}/overseas-storage-fees`, request)
expect(result.version).toBe(1)
})
})
@@ -0,0 +1,66 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export type OverseasFeeType = 'STORAGE' | 'RETURN_STORAGE'
export type OverseasBillingUnit = 'CBM' | 'PALLET'
export interface OverseasStorageFeeTier {
id: string
startDay: number
endDay: number | null
unitPrice: number
sortNo: number
feeType: OverseasFeeType
billingUnit: OverseasBillingUnit
}
export interface OverseasStorageFeeConfig {
id: string
customerId: string
customerName: string
currency: string
tiers: OverseasStorageFeeTier[]
version: number
createdAt: string | null
updatedAt: string | null
}
export interface OverseasStorageFeeUpdateRequest {
customerId: string
customerName: string
currency: string
tiers: Array<Pick<OverseasStorageFeeTier, 'feeType' | 'billingUnit' | 'startDay' | 'endDay' | 'unitPrice'>>
version: number
}
const path = (tenantId: string) => `/platform/v1/iam/tenants/${encodeURIComponent(tenantId)}/overseas-storage-fees`
type RawConfig = Omit<OverseasStorageFeeConfig, 'id' | 'customerId' | 'version' | 'tiers'> & {
id: string | number
customerId: string | number
version: string | number
tiers: Array<Omit<OverseasStorageFeeTier, 'id'> & { id: string | number; unitPrice: number | string }>
}
function normalize(data: RawConfig): OverseasStorageFeeConfig {
return {
...data,
id: String(data.id), customerId: String(data.customerId), version: Number(data.version),
tiers: data.tiers.map((tier) => ({ ...tier, id: String(tier.id), unitPrice: Number(tier.unitPrice), feeType: tier.feeType as OverseasFeeType, billingUnit: tier.billingUnit as OverseasBillingUnit })),
}
}
export async function getCustomerOverseasStorageFees(tenantId: string) {
const response = await http.get<never, ApiEnvelope<RawConfig>>(path(tenantId))
return normalize(unwrapApiResponse(response))
}
export async function updateCustomerOverseasStorageFees(tenantId: string, request: OverseasStorageFeeUpdateRequest) {
await http.put<never, ApiEnvelope<null>>(path(tenantId), request)
return getCustomerOverseasStorageFees(tenantId)
}
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) }
}
@@ -31,6 +31,7 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import CustomerBillingRulesPanel from '@/modules/customers/components/CustomerBillingRulesPanel.vue'
import CustomerOverseasStorageFeePanel from '@/modules/customers/CustomerOverseasStorageFeePanel.vue'
type DetailTab = 'basic' | 'business' | 'orders' | 'finance' | 'logs' | 'billing'
type PreviewState = { url: string; kind: 'image' | 'pdf'; loading: boolean; failed: boolean }
@@ -423,7 +424,7 @@ watch(orderPage, (page, previousPage) => {
<section v-else-if="activeTab === 'finance'" class="detail-panel module-panel"><div class="panel-title"><div><h2>财务账单</h2><p>客户应收、回款与对账记录</p></div><ReceiptText :size="19" /></div><div class="module-empty"><ReceiptText :size="30" /><strong>财务账单尚未接入</strong><p>当前阶段没有可用的客户账单 API,页面不会使用原型中的演示金额。</p></div></section>
<section v-else-if="activeTab === 'logs'" class="detail-panel module-panel"><div class="panel-title"><div><h2>操作日志</h2><p>客户档案与状态变更记录</p></div><History :size="19" /></div><div class="module-empty"><History :size="30" /><strong>操作日志尚未接入</strong><p>等待审计服务提供按租户查询的只读接口后展示真实操作记录。</p></div></section>
<section v-else class="detail-panel module-panel"><CustomerBillingRulesPanel :customer-id="tenantId" :customer-name="customer.tenantName" :customer-code="customer.tenantCode" /></section>
<section v-else class="detail-panel module-panel"><CustomerBillingRulesPanel :customer-id="tenantId" :customer-name="customer.tenantName" :customer-code="customer.tenantCode" /><CustomerOverseasStorageFeePanel :customer-id="tenantId" :customer-name="customer.tenantName" /></section>
</div>
</div>
</template>
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Edit, RefreshCw, Search } from '@lucide/vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listOverseasStorageFees, updateCustomerOverseasStorageFees, type OverseasStorageFeeConfig, type OverseasStorageFeeTier } from '../overseasStorageFee'
const access = useAccessPreviewStore()
const loading = ref(false)
const error = ref<ReturnType<typeof getApiErrorDetails> | null>(null)
const rows = ref<OverseasStorageFeeConfig[]>([])
const page = ref(1)
const size = ref(20)
const total = ref(0)
const keyword = ref('')
const dialogOpen = ref(false)
const saving = ref(false)
const editing = ref<OverseasStorageFeeConfig | null>(null)
const form = reactive({ currency: 'USD', version: 0, tiers: [] as OverseasStorageFeeTier[] })
const storageTiers = computed(() => form.tiers.filter((tier) => tier.feeType === 'STORAGE'))
const returnTiers = computed(() => form.tiers.filter((tier) => tier.feeType === 'RETURN_STORAGE'))
function clone(tiers: OverseasStorageFeeTier[]) { return tiers.map((tier) => ({ ...tier })) }
function label(tier: OverseasStorageFeeTier) { return tier.endDay === null ? `${tier.startDay}天以上` : `${tier.startDay}-${tier.endDay}天` }
function summary(row: OverseasStorageFeeConfig, feeType: OverseasStorageFeeTier['feeType']) { return row.tiers.filter((tier) => tier.feeType === feeType).map((tier) => `${label(tier)} ${tier.unitPrice}`).join(' / ') }
async function load() {
loading.value = true; error.value = null
try { const result = await listOverseasStorageFees(page.value, size.value, keyword.value.trim() || undefined); rows.value = result.records; total.value = Number(result.total) }
catch (cause) { error.value = getApiErrorDetails(cause, '海外仓阶梯费用加载失败,请稍后重试') }
finally { loading.value = false }
}
function search() { 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 }
saving.value = true
try { await updateCustomerOverseasStorageFees(editing.value.customerId, { customerId: editing.value.customerId, customerName: editing.value.customerName, currency: form.currency, version: form.version, tiers: form.tiers.map(({ feeType, billingUnit, startDay, endDay, unitPrice }) => ({ feeType, billingUnit, startDay, endDay, unitPrice })) }); dialogOpen.value = false; ElMessage.success('海外仓费用已保存'); await load() }
catch (cause) { ElMessage.error(getApiErrorDetails(cause, '保存失败,请刷新后重试').message) }
finally { saving.value = false }
}
onMounted(() => { void load() })
</script>
<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>
</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>
</AppDialog>
</div>
</template>
<style scoped>
.page-header,.toolbar,.pagination { display:flex; align-items:center; justify-content:space-between; gap:12px; }.page-header { margin-bottom:16px; }.page-header h1 { margin:0; font-size:22px; }.page-header p { margin:6px 0 0; color:var(--cc-muted); font-size:13px; }.toolbar { justify-content:flex-start; padding:16px; border-bottom:1px solid var(--cc-border); }.toolbar :deep(.el-input) { width:min(320px,100%); }.pagination { justify-content:flex-end; padding:16px; }.error-state { display:flex; justify-content:space-between; padding:20px; color:#b42318; }.groups { display:grid; grid-template-columns:1fr 1fr; gap:20px; }.groups h3 { margin:0 0 10px; font-size:15px; } @media (max-width:720px) { .groups { grid-template-columns:1fr; }.page-header { align-items:flex-start; flex-direction:column; } }
</style>
+6
View File
@@ -7,6 +7,12 @@ export const customerRoutes = [
component: () => import('@/modules/customers/pages/CustomersView.vue'),
meta: { title: '客户管理', description: '维护客户企业资料、合作状态和注册审核', permission: 'platform:customer:list', permissionAnyOf: ['platform:customer:review'] },
},
{
path: 'customers/overseas-storage-fees',
name: 'overseas-storage-fees',
component: () => import('@/modules/customers/pages/OverseasStorageFeesView.vue'),
meta: { title: '海外仓阶梯费用', description: '维护客户仓租和退货仓储费阶梯', permission: 'platform:overseas-storage-fee:list' },
},
{
path: 'customers/:tenantId',
name: 'customer-detail',
@@ -0,0 +1,4 @@
import { describe, expect, it, vi } from 'vitest'
import { listOceanBookings } from './platformOceanBookings'
import { http } from '@/shared/api/http'
describe('ocean bookings api', () => { it('normalizes pagination and long ids', async () => { vi.spyOn(http, 'get').mockResolvedValueOnce({ code: '200', message: 'ok', data: { records: [{ id: 123, customerId: 456, containers: [] }], page: '2', size: '20', total: '21', pages: '2' } }); const result = await listOceanBookings({ page: 1, size: 10 }); expect(result.page).toBe(2); expect(result.total).toBe(21); expect(result.records[0]?.id).toBe('123'); expect(result.records[0]?.customerId).toBe('456') }) })
@@ -0,0 +1,18 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { OceanBookingDetail, OceanBookingPage, OceanBookingQuery, OceanBookingResource, OceanBookingSaveRequest, OceanBookingStatus } from '../types'
const basePath = '/platform/v1/ocean-bookings'
function pageNumber(value: number | string, fallback: number) { const n = Number(value); return Number.isFinite(n) ? Math.trunc(n) : fallback }
function booking(raw: OceanBookingResource): OceanBookingResource { return { ...raw, id: String(raw.id), customerId: raw.customerId == null ? null : String(raw.customerId), containers: (raw.containers || []).map((item) => ({ ...item, id: String(item.id) })) } }
export async function listOceanBookings(query: OceanBookingQuery) {
const response = await http.get<ApiEnvelope<OceanBookingPage>, ApiEnvelope<OceanBookingPage>>(basePath, { params: query })
const result = unwrapApiResponse(response)
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) }
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 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 }
+2
View File
@@ -0,0 +1,2 @@
export { oceanBookingRoutes } from './routes'
export * from './types'
@@ -0,0 +1,20 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
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'
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 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)} }
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>
@@ -0,0 +1,4 @@
import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
describe('OceanBookingsView', () => { const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingsView.vue'), 'utf8'); it('keeps the operation column first and left fixed', () => { expect(source.indexOf("key: 'actions'")).toBeLessThan(source.indexOf("prop: 'bookingNo'")); expect(source).toContain("fixed: 'left'") }); it('renders loading, empty and error through shared components', () => { expect(source).toContain('LoadErrorState'); expect(source).toContain(':loading="loading"'); expect(source).toContain(':total="total"') }) })
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { onMounted, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import DataTable from '@/shared/components/DataTable.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails } from '@/shared/api/http'
import { listOceanBookings, type OceanBookingResource } from '../api/platformOceanBookings'
import type { OceanBookingQuery, OceanBookingStatus } from '../types'
const router = useRouter(); const rows = ref<OceanBookingResource[]>([]); const loading = ref(false); const error = ref<ReturnType<typeof getApiErrorDetails> | null>(null); const page = ref(1); const pageSize = ref(20); const total = ref(0)
const query = reactive<QueryModel>({ keyword: '', status: '', pol: '', pod: '', soNo: '' })
const statuses: Array<{ label: string; value: string }> = [{ label: '草稿', value: 'DRAFT' }, { label: '已提交', value: 'SUBMITTED' }, { label: '已确认', value: 'CONFIRMED' }, { label: '已放舱', value: 'RELEASED' }, { label: '失败', value: 'FAILED' }, { label: '变更中', value: 'CHANGE_PENDING' }, { label: '已取消', value: 'CANCELLED' }]
const fields: QueryField[] = [{ key: 'keyword', type: 'input', label: '关键词', placeholder: '订舱号/订单号/客户', width: 220 }, { key: 'status', type: 'select', label: '状态', options: statuses }, { key: 'pol', type: 'input', label: '起运港', width: 150 }, { key: 'pod', type: 'input', label: '目的港', width: 150 }, { key: 'soNo', type: 'input', label: 'S/O号', width: 150 }]
const columns = [{ key: 'actions', label: '操作', fixed: 'left', width: 170, slotName: 'actions', hideable: false, movable: false }, { prop: 'bookingNo', label: '订舱编号', minWidth: 150 }, { prop: 'orderNo', label: '关联订单', minWidth: 140 }, { prop: 'customerName', label: '客户', minWidth: 140 }, { prop: 'pol', label: '起运港', minWidth: 110 }, { prop: 'pod', label: '目的港', minWidth: 110 }, { prop: 'vesselName', label: '船名/航次', minWidth: 150, slotName: 'vessel' }, { prop: 'soNo', label: 'S/O号', minWidth: 130 }, { prop: 'cutoffAt', label: '截关时间', minWidth: 150 }, { prop: 'status', label: '状态', minWidth: 110, slotName: 'status' }]
async function load() { loading.value = true; error.value = null; try { const result = await listOceanBookings({ page: page.value, size: pageSize.value, keyword: String(query.keyword || '') || undefined, status: (query.status || undefined) as OceanBookingStatus | undefined, pol: String(query.pol || '') || undefined, pod: String(query.pod || '') || undefined, soNo: String(query.soNo || '') || undefined } satisfies OceanBookingQuery); rows.value = result.records; total.value = result.total } catch (e) { error.value = getApiErrorDetails(e) } finally { loading.value = false } }
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>
<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>
+2
View File
@@ -0,0 +1,2 @@
import type { RouteRecordRaw } from 'vue-router'
export const oceanBookingRoutes = [{ path: 'ocean-bookings', name: 'ocean-bookings', component: () => import('./pages/OceanBookingsView.vue'), meta: { title: '海运订舱', description: '维护海运订舱申请、船期与放舱状态', permission: 'platform:firstleg:ocean-booking:list' } }, { path: 'ocean-bookings/:bookingId', name: 'ocean-booking-detail', component: () => import('./pages/OceanBookingDetailView.vue'), meta: { title: '订舱详情', description: '查看和维护订舱详情', permission: 'platform:firstleg:ocean-booking:view' } }] satisfies RouteRecordRaw[]
+16
View File
@@ -0,0 +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 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
pol: string | null; pod: string | null; mblShipper: string | null; mblConsignee: string | null; soNo: string | null
cutoffAt: string | null; etd: string | null; atd: string | null; eta: string | null; ata: string | null
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 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 }> }
@@ -0,0 +1,25 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { changeTruckAppointmentStatus, createTruckAppointment, getTruckAppointment, listTruckAppointments } 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 }))
describe('truck appointment API', () => {
beforeEach(() => { vi.clearAllMocks() })
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' } })
})
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 })
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' })
})
})
@@ -0,0 +1,84 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type {
SaveTruckAppointmentRequest,
TruckAppointmentPage,
TruckAppointmentQuery,
TruckAppointmentResource,
} from '../types'
const basePath = '/platform/v1/truck-appointments'
function dtoPayload(payload: SaveTruckAppointmentRequest) {
const { attachmentRefs: _attachmentRefs, ...dto } = payload
return dto
}
type PagePayload = Omit<TruckAppointmentPage, 'page' | 'size' | 'total' | 'pages'> & {
page: number | string
size: number | string
total: number | string
pages?: number | string
}
function normalizePage(payload: PagePayload): TruckAppointmentPage {
const page = Number(payload.page || 1)
const size = Number(payload.size || 20)
const total = Number(payload.total || 0)
return { records: (payload.records || []).map(normalizeResource), page, size, total, pages: Number(payload.pages || Math.max(Math.ceil(total / size), 1)) }
}
function normalizeResource(resource: TruckAppointmentResource): TruckAppointmentResource {
const idKeys = ['id', 'outboundOrderId', 'oceanBookingId', 'containerBookingId', 'loadingPlanId', 'warehouseId', 'customerId', 'carrierId']
const normalized = { ...resource } as Record<string, unknown>
for (const key of idKeys) {
if (normalized[key] !== null && normalized[key] !== undefined) normalized[key] = String(normalized[key])
}
return normalized as unknown as TruckAppointmentResource
}
export async function listTruckAppointments(query: TruckAppointmentQuery = {}) {
const response = await http.get<ApiEnvelope<PagePayload>, ApiEnvelope<PagePayload>>(basePath, {
params: {
...query,
keyword: query.keyword?.trim() || undefined,
status: query.status || undefined,
containerBookingId: query.containerBookingId || undefined,
},
})
return normalizePage(unwrapApiResponse(response))
}
export async function getTruckAppointment(id: string | number) {
const response = await http.get<ApiEnvelope<TruckAppointmentResource>, ApiEnvelope<TruckAppointmentResource>>(`${basePath}/${id}`)
return normalizeResource(unwrapApiResponse(response))
}
export async function createTruckAppointment(payload: SaveTruckAppointmentRequest) {
const response = await http.post<ApiEnvelope<TruckAppointmentResource>, ApiEnvelope<TruckAppointmentResource>>(basePath, dtoPayload(payload))
return normalizeResource(unwrapApiResponse(response))
}
export async function updateTruckAppointment(id: string | number, payload: SaveTruckAppointmentRequest) {
const response = await http.put<ApiEnvelope<TruckAppointmentResource>, ApiEnvelope<TruckAppointmentResource>>(`${basePath}/${id}`, dtoPayload(payload))
return unwrapApiResponse(response)
}
export type TruckAppointmentAction = 'SUBMIT' | 'DISPATCH' | 'PICKUP' | 'ARRIVE' | 'LOAD' | 'DEPART' | 'COMPLETE' | 'EXCEPTION' | 'CANCEL'
export async function performTruckAppointmentAction(
id: string | number,
action: TruckAppointmentAction,
expectedVersion: number,
reason?: string,
idempotencyKey?: string,
requestHash?: string,
) {
const response = await http.post<ApiEnvelope<TruckAppointmentResource>, ApiEnvelope<TruckAppointmentResource>>(`${basePath}/${id}/${action.toLowerCase()}`, { expectedVersion, reason: reason || null }, {
headers: { ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}), ...(requestHash ? { 'X-Request-Hash': requestHash } : {}) },
})
return unwrapApiResponse(response)
}
export async function rescheduleTruckAppointment(id: string | number, plannedLoadingAt: string, expectedVersion: number, reason?: string) {
const response = await http.post<ApiEnvelope<TruckAppointmentResource>, ApiEnvelope<TruckAppointmentResource>>(`${basePath}/${id}/reschedule`, { expectedVersion, reason: reason || null }, { params: { plannedLoadingAt } })
return unwrapApiResponse(response)
}
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import { truckAppointmentStatusLabels, type SaveTruckAppointmentRequest, type TruckAppointmentResource } from '../types'
const props = defineProps<{ modelValue: SaveTruckAppointmentRequest; resource?: TruckAppointmentResource | null; readonly?: boolean; submitting?: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [SaveTruckAppointmentRequest]; submit: []; attachments: [] }>()
const formRef = ref<FormInstance>()
const draft = reactive<SaveTruckAppointmentRequest>({ ...props.modelValue })
watch(() => props.modelValue, (value) => Object.assign(draft, value), { deep: true })
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() }
defineExpose({ validate })
</script>
<template>
<el-form ref="formRef" :model="draft" :rules="rules" label-position="top" :disabled="readonly || submitting" class="truck-form">
<el-alert v-if="resource" type="info" :closable="false" class="relation-alert">关联资源:{{ relationText }};柜型由关联柜体返回(只读)</el-alert>
<el-row :gutter="16">
<el-col :xs="24" :sm="12"><el-form-item label="承运商 ID" prop="carrierId"><el-input v-model="draft.carrierId" placeholder="请输入后端资源 ID" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="车队联系人"><el-input v-model="draft.fleetContact" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="车队电话"><el-input v-model="draft.fleetPhone" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="司机"><el-input v-model="draft.driverName" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="司机电话"><el-input v-model="draft.driverPhone" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="车头号"><el-input v-model="draft.vehicleNo" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="预计装箱时间" prop="plannedLoadingAt"><el-date-picker v-model="draft.plannedLoadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" style="width:100%" /></el-form-item></el-col>
<el-col v-if="resource" :xs="24" :sm="12"><el-form-item label="实际装箱时间"><el-input :model-value="resource.actualLoadingAt || ''" disabled /></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="件数"><el-input v-model="draft.packageCount" inputmode="numeric" /></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="毛重(kg)"><el-input v-model="draft.grossWeightKg" /></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="体积(m³)"><el-input v-model="draft.volumeCbm" /></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="托盘数量"><el-input v-model="draft.palletCount" inputmode="numeric" /></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="应付拖车费"><el-input v-model="draft.payableAmount"><template #append>RMB</template></el-input></el-form-item></el-col>
<el-col :xs="24" :sm="8"><el-form-item label="应收拖车费"><el-input v-model="draft.receivableAmount"><template #append>RMB</template></el-input></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="门店名称"><el-input v-model="draft.storeName" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="门店地址"><el-input v-model="draft.storeAddress" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="城市"><el-input v-model="draft.storeCity" /></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="邮编"><el-input v-model="draft.storeZipcode" /></el-form-item></el-col>
<el-col :xs="24"><el-form-item label="PO"><el-input v-model="draft.poNo" /></el-form-item></el-col>
<el-col :xs="24"><el-form-item label="备注"><el-input v-model="draft.remark" type="textarea" :rows="3" /></el-form-item></el-col>
</el-row>
<div class="form-actions"><el-button :disabled="readonly || submitting" @click="emit('attachments')">管理附件引用</el-button><el-button v-if="!readonly" type="primary" :loading="submitting" @click="emit('submit')">保存</el-button></div>
</el-form>
</template>
<style scoped>
.truck-form { padding-bottom: 12px; }
.relation-alert { margin-bottom: 16px; }
.form-actions { display: flex; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
</style>
@@ -0,0 +1,27 @@
<script setup lang="ts">
import { reactive, watch } from 'vue'
import type { TruckAttachmentReference } from '../types'
const props = defineProps<{ modelValue: boolean; attachments: TruckAttachmentReference[]; readonly?: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [boolean]; save: [TruckAttachmentReference[]] }>()
const draft = reactive<TruckAttachmentReference[]>([])
watch(() => props.attachments, (value) => { draft.splice(0, draft.length, ...(value || []).map((item) => ({ ...item }))) }, { immediate: true, deep: true })
function add() { draft.push({ fileId: '', fileName: '' }) }
function remove(index: number) { draft.splice(index, 1) }
function save() { emit('save', draft.filter((item) => item.fileId.trim() && item.fileName.trim()).map((item) => ({ ...item }))); emit('update:modelValue', false) }
</script>
<template>
<el-dialog :model-value="props.modelValue" title="附件引用" width="min(640px, 94vw)" @update:model-value="emit('update:modelValue', $event)">
<el-alert type="info" :closable="false" title="此处仅维护文件服务返回的引用,不会模拟上传成功。" />
<div v-for="(item, index) in draft" :key="index" class="attachment-row">
<el-input v-model="item.fileId" placeholder="文件 ID" :disabled="readonly" />
<el-input v-model="item.fileName" placeholder="文件名" :disabled="readonly" />
<el-button text type="danger" :disabled="readonly" @click="remove(index)">删除</el-button>
</div>
<el-button v-if="!readonly" plain @click="add">新增引用</el-button>
<template #footer><el-button @click="emit('update:modelValue', false)">关闭</el-button><el-button v-if="!readonly" type="primary" @click="save">保存引用</el-button></template>
</el-dialog>
</template>
<style scoped>
.attachment-row { display: grid; grid-template-columns: 1fr 1fr auto; gap: 8px; margin: 14px 0; }
@media (max-width: 560px) { .attachment-row { grid-template-columns: 1fr; } }
</style>
+2
View File
@@ -0,0 +1,2 @@
export * from './types'
export * from './api/platformTrucking'
@@ -0,0 +1,17 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import TruckAppointmentForm from '../components/TruckAppointmentForm.vue'
import TruckAttachmentDialog from '../components/TruckAttachmentDialog.vue'
import { createTruckAppointment } from '../api/platformTrucking'
import type { SaveTruckAppointmentRequest, TruckAttachmentReference } from '../types'
const router = useRouter(); const open = ref(true); const saving = ref(false); const attachmentOpen = ref(false); const error = ref<ApiErrorDetails | null>(null)
const form = reactive<SaveTruckAppointmentRequest>({ attachmentRefs: [], expectedLoadingDate: null, payableAmount: '0.00', receivableAmount: '0.00' })
async function submit() { saving.value = true; error.value = null; try { await createTruckAppointment({ ...form, attachmentRefs: form.attachmentRefs || [] }); ElMessage.success('拖车预约已创建'); router.push({ name: 'trucking' }) } catch (err) { error.value = getApiErrorDetails(err, '创建失败,请稍后重试') } finally { saving.value = false } }
function saveAttachments(items: TruckAttachmentReference[]) { form.attachmentRefs = items }
</script>
<template><AppDrawer v-model="open" title="新增拖车预约" :confirm-loading="saving" :show-footer="false" @cancel="router.push({ name: 'trucking' })"><ApiErrorMeta v-if="error" :details="error" /><TruckAppointmentForm v-model="form" :submitting="saving" @submit="submit" @attachments="attachmentOpen = true" /><TruckAttachmentDialog v-model="attachmentOpen" :attachments="form.attachmentRefs || []" @save="saveAttachments" /></AppDrawer></template>
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getTruckAppointment, performTruckAppointmentAction, rescheduleTruckAppointment, updateTruckAppointment, type TruckAppointmentAction } from '../api/platformTrucking'
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 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 } }
const actionByTarget: Partial<Record<TruckAppointmentStatus, TruckAppointmentAction>> = { PENDING_DISPATCH: 'SUBMIT', DISPATCHED: 'DISPATCH', PICKUP_IN_PROGRESS: 'PICKUP', ARRIVED_WAREHOUSE: 'ARRIVE', LOADING: 'LOAD', DEPARTED: 'DEPART', COMPLETED: 'COMPLETE', EXCEPTION: 'EXCEPTION', CANCELLED: 'CANCEL' }
async function changeStatus(status: TruckAppointmentStatus) { if (!resource.value || status === resource.value.status) return; try { await ElMessageBox.confirm(`确认将状态改为“${truckAppointmentStatusLabels[status]}”?`, '状态变更确认', { type: 'warning' }); statusSaving.value = true; if (status === 'RESCHEDULED') { await rescheduleTruckAppointment(id.value, form.plannedLoadingAt || '', form.expectedVersion || 0, '后台改约') } else { const action = actionByTarget[status]; if (!action) throw new Error('当前状态没有可用动作'); await performTruckAppointmentAction(id.value, action, form.expectedVersion || 0) } ElMessage.success('状态已更新'); await load() } catch (err) { if (err !== 'cancel' && !(err instanceof Error && err.message === 'cancel')) error.value = getApiErrorDetails(err, '状态更新失败,请稍后重试') } finally { statusSaving.value = false } }
function saveAttachments(items: TruckAttachmentReference[]) { form.attachmentRefs = items }
onMounted(load)
</script>
<template>
<AppDrawer v-model="open" :title="resource?.appointmentNo ? `拖车预约 ${resource.appointmentNo}` : '拖车预约详情'" :loading="loading" :show-footer="false" @cancel="router.push({ name: 'trucking' })">
<LoadErrorState v-if="error && !resource" title="详情加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-else-if="resource"><div class="status-bar"><span>当前状态:<strong>{{ truckAppointmentStatusLabels[resource.status] }}</strong></span><el-select :model-value="resource.status" :loading="statusSaving" :disabled="statusSaving || !canChangeStatus" style="width:170px" @change="changeStatus"><el-option v-for="status in TRUCK_APPOINTMENT_STATUSES" :key="status" :value="status" :label="truckAppointmentStatusLabels[status]" /></el-select></div><ApiErrorMeta v-if="error" :details="error" /><TruckAppointmentForm v-model="form" :resource="resource" :readonly="!canEdit" :submitting="saving" @submit="submit" @attachments="attachmentOpen = true" /><TruckAttachmentDialog v-model="attachmentOpen" :attachments="form.attachmentRefs || []" :readonly="!canEdit" @save="saveAttachments" /></template>
</AppDrawer>
</template>
<style scoped>
.status-bar { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; margin-bottom:16px; padding:12px; background:var(--el-fill-color-light); border-radius:6px; }
</style>
@@ -0,0 +1,35 @@
import ElementPlus from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import TruckAppointmentsView from './TruckAppointmentsView.vue'
import { listTruckAppointments } from '../api/platformTrucking'
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: [],
}
describe('TruckAppointmentsView', () => {
beforeEach(() => {
vi.mocked(listTruckAppointments).mockReset().mockResolvedValue({ records: [row], page: 1, size: 20, total: 1, pages: 1 })
setActivePinia(createPinia())
})
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(wrapper.text()).toContain('TA-9001')
expect(wrapper.text()).toContain('40HC')
expect(wrapper.text()).toContain('宁波运输')
expect(wrapper.find('button[aria-label="查看 TA-9001"]').exists()).toBe(true)
})
})
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Eye, Pencil, Plus, RefreshCw } 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'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listTruckAppointments } from '../api/platformTrucking'
import { TRUCK_APPOINTMENT_STATUSES, truckAppointmentStatusLabels, type TruckAppointmentQuery, type TruckAppointmentResource, type TruckAppointmentStatus } from '../types'
const router = useRouter(); const rows = ref<TruckAppointmentResource[]>([]); const loading = ref(false); const loaded = ref(false); const page = ref(1); const pageSize = ref(20); const total = ref(0); const loadError = ref<ApiErrorDetails | null>(null)
const access = useAccessPreviewStore()
const filters = ref({ keyword: '', status: '' as TruckAppointmentStatus | '', containerBookingId: '' }); const applied = ref({ ...filters.value })
const fields = computed<QueryField[]>(() => [
{ key: 'keyword', type: 'input', label: '关键词', placeholder: '委托单位、预约单号、车牌' },
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: TRUCK_APPOINTMENT_STATUSES.map((value) => ({ value, label: truckAppointmentStatusLabels[value] })) },
{ key: 'containerBookingId', type: 'input', label: '柜体 ID', placeholder: '按关联柜体筛选' },
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 150, fixed: 'left', slotName: 'actions' },
{ key: 'appointmentNo', prop: 'appointmentNo', label: '预约单号', minWidth: 170 },
{ key: 'relation', label: '关联资源', minWidth: 220, slotName: 'relation' },
{ key: 'company', label: '承运商', minWidth: 150, slotName: 'company' },
{ key: 'driver', label: '司机/车头', minWidth: 150, slotName: 'driver' },
{ key: 'store', label: '门店', minWidth: 130, slotName: 'store' },
{ key: 'plannedLoadingAt', prop: 'plannedLoadingAt', label: '预计装箱时间', minWidth: 170 },
{ key: 'payableAmount', label: '应付拖车费', minWidth: 130, formatter: (row: TruckAppointmentResource) => row.payableAmount ? `¥${row.payableAmount}` : '—' },
{ key: 'receivableAmount', label: '应收拖车费', minWidth: 130, formatter: (row: TruckAppointmentResource) => row.receivableAmount ? `¥${row.receivableAmount}` : '—' },
{ key: 'status', label: '状态', width: 130, slotName: 'status' },
{ key: 'attachments', label: '附件', width: 90, formatter: (row: TruckAppointmentResource) => String(row.attachments?.length || 0) },
]
function statusLabel(value: unknown) {
return typeof value === 'string' && value in truckAppointmentStatusLabels
? truckAppointmentStatusLabels[value as TruckAppointmentStatus]
: String(value || '—')
}
async function load() { loading.value = true; loadError.value = null; try { const result = await listTruckAppointments({ page: page.value, size: pageSize.value, ...applied.value } as TruckAppointmentQuery); rows.value = result.records; total.value = result.total; loaded.value = true } catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '拖车预约加载失败,请稍后重试') } finally { loading.value = false } }
function search(model: QueryModel) { applied.value = { keyword: String(model.keyword || ''), status: (model.status || '') as TruckAppointmentStatus | '', containerBookingId: String(model.containerBookingId || '') }; page.value = 1; void load() }
function reset() { filters.value = { keyword: '', status: '', containerBookingId: '' }; search(filters.value) }
onMounted(load)
</script>
<template>
<section class="page-shell">
<div class="page-heading"><div><h1>拖车预约</h1><p>维护拖车预约、关联资源与派车状态</p></div><div class="heading-actions"><el-button v-if="access.can('platform:firstleg:truck-appointment:create')" type="primary" @click="router.push({ name: 'trucking-new' })"><Plus :size="16" />新增预约</el-button></div></div>
<LoadErrorState v-if="loadError" title="拖车预约加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<QueryToolbar v-model="filters" ariaLabel="拖车预约查询" :fields="fields" @search="search" @reset="reset" />
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="trucking-appointments">
<template #relation="{ row }"><div>{{ row.customerName || '—' }}</div><small>出仓单 {{ row.outboundOrderId || '—' }} · 柜体 {{ row.containerBookingId || '—' }}</small></template>
<template #company="{ row }">{{ row.carrierName || row.carrierId || '—' }}</template>
<template #driver="{ row }">{{ row.driverName || '—' }}<small>{{ row.vehicleNo || '—' }}</small></template>
<template #store="{ row }">{{ row.storeName || '—' }}</template>
<template #status="{ row }"><StatusBadge :status="statusLabel(row.status)" /></template>
<template #actions="{ row }"><el-button text :aria-label="`查看 ${row.appointmentNo || row.id}`" @click="router.push({ name: 'trucking-detail', params: { id: String(row.id) } })"><Eye :size="16" />查看</el-button><el-button v-if="access.can('platform:firstleg:truck-appointment:edit')" text :aria-label="`编辑 ${row.appointmentNo || row.id}`" @click="router.push({ name: 'trucking-detail', params: { id: String(row.id) }, query: { edit: '1' } })"><Pencil :size="16" />编辑</el-button></template>
</DataTable>
<div v-if="!loading && loaded && rows.length === 0 && !loadError" class="empty-hint"><RefreshCw :size="16" />暂无拖车预约</div>
</section>
</template>
<style scoped>
.page-shell { min-width: 0; padding: 24px; }
.page-heading { display:flex; justify-content:space-between; gap:16px; align-items:flex-start; margin-bottom:18px; flex-wrap:wrap; }
h1 { margin:0; font-size:24px; }.page-heading p { margin:6px 0 0; color:var(--el-text-color-secondary); }.heading-actions { display:flex; gap:8px; flex-wrap:wrap; }.heading-actions :deep(.el-button) { margin:0; }.heading-actions :deep(svg) { vertical-align:middle; margin-right:5px; }.empty-hint { display:flex; justify-content:center; gap:8px; color:var(--el-text-color-secondary); padding:24px; }
small { display:block; color:var(--el-text-color-secondary); margin-top:3px; }
@media (max-width:600px) { .page-shell { padding:16px 12px; } h1 { font-size:20px; } }
</style>
+23
View File
@@ -0,0 +1,23 @@
import type { RouteRecordRaw } from 'vue-router'
export const truckingRoutes = [
{
path: 'trucking',
name: 'trucking',
component: () => import('./pages/TruckAppointmentsView.vue'),
meta: { title: '拖车预约', description: '维护拖车预约及派车状态', permission: 'platform:firstleg:truck-appointment:list' },
},
{
path: 'trucking/new',
name: 'trucking-new',
component: () => import('./pages/TruckAppointmentCreateView.vue'),
meta: { title: '新增拖车预约', description: '创建拖车预约', permission: 'platform:firstleg:truck-appointment:create' },
},
{
path: 'trucking/:id',
name: 'trucking-detail',
component: () => import('./pages/TruckAppointmentDetailView.vue'),
props: true,
meta: { title: '拖车预约详情', description: '查看和编辑拖车预约', permission: 'platform:firstleg:truck-appointment:view' },
},
] satisfies RouteRecordRaw[]
+134
View File
@@ -0,0 +1,134 @@
export const TRUCK_APPOINTMENT_STATUSES = [
'DRAFT',
'PENDING_DISPATCH',
'DISPATCHED',
'PICKUP_IN_PROGRESS',
'ARRIVED_WAREHOUSE',
'LOADING',
'DEPARTED',
'COMPLETED',
'RESCHEDULED',
'EXCEPTION',
'CANCELLED',
] as const
export type TruckAppointmentStatus = typeof TRUCK_APPOINTMENT_STATUSES[number]
export const truckAppointmentStatusLabels: Record<TruckAppointmentStatus, string> = {
DRAFT: '草稿',
PENDING_DISPATCH: '待派车',
DISPATCHED: '已派车',
PICKUP_IN_PROGRESS: '提柜中',
ARRIVED_WAREHOUSE: '已到仓',
LOADING: '装柜中',
DEPARTED: '已出发',
COMPLETED: '已完成',
RESCHEDULED: '已改期',
EXCEPTION: '异常',
CANCELLED: '已取消',
}
export interface TruckAttachmentReference {
fileId: string
fileName: string
objectKey?: string | null
}
export interface TruckAppointmentResource {
id: string
appointmentNo?: string | null
status: TruckAppointmentStatus
version?: number | null
revision?: number | null
outboundOrderId?: string | null
oceanBookingId?: string | null
containerBookingId?: string | null
loadingPlanId?: string | null
warehouseId?: string | null
customerId?: string | null
customerName?: string | null
carrierId?: string | null
carrierName?: string | null
fleetContact?: string | null
fleetPhone?: string | null
driverName?: string | null
driverPhone?: string | null
driverIdCard?: string | null
vehicleNo?: string | null
storeName?: string | null
storeContact?: string | null
storePhone?: string | null
storeAddress?: string | null
storeCity?: string | null
storeZipcode?: string | null
plannedLoadingAt?: string | null
transitMinutes?: number | string | null
packageCount?: number | string | null
grossWeightKg?: string | null
volumeCbm?: string | null
palletCount?: number | string | null
payableAmount?: string | null
receivableAmount?: string | null
currency?: string | null
poNo?: string | null
remark?: string | null
orderId?: string | null
orderNo?: string | null
containerNo?: string | null
attachments?: TruckAttachmentReference[] | null
createdAt?: string | null
updatedAt?: string | null
}
export interface SaveTruckAppointmentRequest {
outboundOrderId?: string | null
oceanBookingId?: string | null
containerBookingId?: string | null
loadingPlanId?: string | null
warehouseId?: string | null
customerId?: string | null
customerName?: string | null
carrierId?: string | null
carrierName?: string | null
fleetContact?: string | null
fleetPhone?: string | null
driverName?: string | null
driverPhone?: string | null
driverIdCard?: string | null
vehicleNo?: string | null
storeName?: string | null
storeContact?: string | null
storePhone?: string | null
storeAddress?: string | null
storeCity?: string | null
storeZipcode?: string | null
plannedLoadingAt?: string | null
transitMinutes?: number | string | null
packageCount?: number | string | null
grossWeightKg?: string | null
volumeCbm?: string | null
palletCount?: number | string | null
payableAmount?: string | null
receivableAmount?: string | null
currency?: string | null
poNo?: string | null
remark?: string | null
attachmentRefs?: TruckAttachmentReference[]
expectedVersion: number
}
export interface TruckAppointmentQuery {
page?: number
size?: number
keyword?: string
status?: TruckAppointmentStatus | ''
containerBookingId?: string | ''
}
export interface TruckAppointmentPage {
records: TruckAppointmentResource[]
page: number
size: number
total: number
pages: number
}