This commit is contained in:
2026-08-07 18:03:59 +08:00
parent 54d6b9a8cc
commit 217768fb0b
3 changed files with 112 additions and 3 deletions
@@ -10,6 +10,7 @@ import {
listTenantQualifications,
type PlatformTenantResource,
} from '@/modules/iam'
import { listPlatformFirstLegOrders, type PlatformFirstLegOrderResource } from '@/modules/first-leg-orders'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import CustomerDetailView from './CustomerDetailView.vue'
@@ -32,6 +33,10 @@ vi.mock('@/modules/iam', () => ({
qualificationFileName: vi.fn((documentUrl: string) => documentUrl.split('/').at(-1) || '资质文件'),
}))
vi.mock('@/modules/first-leg-orders', () => ({
listPlatformFirstLegOrders: vi.fn(),
}))
class ResizeObserverStub implements ResizeObserver {
observe() {}
unobserve() {}
@@ -75,6 +80,17 @@ describe('CustomerDetailView', () => {
phone: '13800138000', email: 'ops@example.com', memberType: 'OWNER', memberNo: null, status: 'ACTIVE', roleIds: [], roleNames: ['企业管理员'],
}])
vi.mocked(listPlatformTenantOrderCounts).mockResolvedValue([{ tenantId: tenant.id, orderCount: 12 }])
vi.mocked(listPlatformFirstLegOrders).mockResolvedValue({
records: [{
id: 'order-1', tenantId: tenant.id, orderNo: 'BY20260807001', customerId: null, customerName: tenant.tenantName,
status: 'PROCESSING', transportMode: 'SEA', tradeTerm: null, destinationCountryCode: 'US', destinationWarehouseId: null,
destinationWarehouseName: '洛杉矶', expectedShipDate: '2026-08-08', customerRemark: null, pickupMethod: 'WAREHOUSE',
receivingWarehouseId: 'warehouse-1', receivingWarehouseName: '深圳头程收货仓', receivingWarehouseAddress: '深圳市', receivingTime: null,
channelCode: 'SEA_FCL', channelName: '海运整箱', taxMode: 'DDP', containerMode: 'FULL_CONTAINER', customsInspection: false,
destinationType: 'FBA', amazonWarehouseCode: null, shipmentId: null, referenceId: null, expressCompany: null, expressTrackingNo: null,
createdAt: '2026-08-07T10:00:00', updatedAt: '2026-08-07T10:00:00',
} satisfies PlatformFirstLegOrderResource], page: 1, size: 10, total: 1, pages: 1,
})
vi.mocked(downloadTenantQualification).mockResolvedValue(new Blob(['image'], { type: 'image/png' }))
})
@@ -93,6 +109,14 @@ describe('CustomerDetailView', () => {
expect(document.body.textContent).toContain('已替换')
expect(document.body.textContent).toContain('12票')
const ordersTab = [...document.querySelectorAll<HTMLButtonElement>('.detail-tabs button')]
.find((button) => button.textContent?.trim() === '运单记录')
ordersTab?.click()
await flushPromises()
expect(document.body.textContent).toContain('BY20260807001')
expect(document.body.textContent).toContain('海运整箱 FCL')
expect(listPlatformFirstLegOrders).toHaveBeenCalledWith({ tenantId: tenant.id, page: 1, size: 10 })
const billingTab = [...document.querySelectorAll<HTMLButtonElement>('.detail-tabs button')]
.find((button) => button.textContent?.trim() === '计费规则')
billingTab?.click()
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
ArrowLeft, BarChart3, Box, Building2, CircleDollarSign, ClipboardList, Eye,
@@ -20,8 +20,15 @@ import {
type PlatformTenantResource,
type PlatformTenantStatus,
} from '@/modules/iam'
import {
listPlatformFirstLegOrders,
type PlatformFirstLegOrderResource,
type PlatformFirstLegOrderStatus,
} from '@/modules/first-leg-orders'
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import CustomerBillingRulesPanel from '@/modules/customers/components/CustomerBillingRulesPanel.vue'
@@ -40,6 +47,12 @@ const customer = ref<PlatformTenantResource | null>(null)
const qualifications = ref<PlatformTenantQualification[]>([])
const members = ref<PlatformTenantMember[]>([])
const orderCount = ref<number | null>(null)
const customerOrders = ref<PlatformFirstLegOrderResource[]>([])
const customerOrderTotal = ref(0)
const orderPage = ref(1)
const orderPageSize = ref(10)
const customerOrdersLoading = ref(false)
const customerOrdersError = ref<ApiErrorDetails | null>(null)
const previews = ref<Record<string, PreviewState>>({})
const previewOpen = ref(false)
const previewName = ref('')
@@ -59,6 +72,16 @@ const tabs: Array<{ key: DetailTab; label: string; icon: typeof ClipboardList }>
const approvedQualifications = computed(() => qualifications.value.filter((item) => item.status === 'APPROVED').length)
const enterpriseMembers = computed(() => members.value.filter((member) => ['OWNER', 'EMPLOYEE'].includes(member.memberType)))
const activeEnterpriseMembers = computed(() => enterpriseMembers.value.filter((member) => member.status === 'ACTIVE').length)
const orderColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 92, fixed: 'left', slotName: 'actions' },
{ key: 'status', label: '状态', width: 106, slotName: 'status' },
{ key: 'orderNo', label: '运单号', minWidth: 180, slotName: 'orderNo' },
{ key: 'transport', label: '运输方式', minWidth: 150, slotName: 'transport' },
{ key: 'destination', label: '目的地', minWidth: 170, slotName: 'destination' },
{ key: 'receivingWarehouse', label: '平台收货仓', minWidth: 180, slotName: 'receivingWarehouse' },
{ key: 'expectedShipDate', label: '预计出运', width: 128, slotName: 'expectedShipDate' },
{ key: 'createdAt', label: '创建时间', width: 150, slotName: 'createdAt' },
]
function displayText(value?: string | null) { return value?.trim() || '—' }
function formatDate(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
@@ -89,6 +112,33 @@ function qualificationName(item: PlatformTenantQualification) {
try { return qualificationFileName(item.documentUrl) }
catch { return `${qualificationLabel(item.qualificationType)}文件` }
}
function orderStatusLabel(status: PlatformFirstLegOrderStatus) {
return ({ DRAFT: '草稿', SUBMITTED: '待审核', APPROVED: '已审核', ACCEPTED: '已受理', PROCESSING: '处理中', COMPLETED: '已完成', CANCELLED: '已取消', REJECTED: '已驳回(旧契约)' } as Record<PlatformFirstLegOrderStatus, string>)[status] || status
}
function transportLabel(order: PlatformFirstLegOrderResource) {
if (order.transportMode === 'SEA') return order.containerMode === 'FULL_CONTAINER' ? '海运整箱 FCL' : '海运拼箱 LCL'
if (order.transportMode === 'AIR') return '空运'
if (order.transportMode === 'RAIL') return '铁路运输'
return '陆运'
}
function formattedDate(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
async function loadCustomerOrders(page = orderPage.value) {
if (!access.can('platform:order:list')) return
customerOrdersLoading.value = true
customerOrdersError.value = null
try {
const result = await listPlatformFirstLegOrders({ tenantId: tenantId.value, page, size: orderPageSize.value })
customerOrders.value = result.records
customerOrderTotal.value = result.total
if (orderCount.value === null) orderCount.value = result.total
} catch (error) {
customerOrders.value = []
customerOrdersError.value = getApiErrorDetails(error, '运单记录加载失败,请稍后重试')
} finally {
customerOrdersLoading.value = false
}
}
function clearPreviews() {
for (const preview of Object.values(previews.value)) if (preview.url) URL.revokeObjectURL(preview.url)
@@ -145,9 +195,18 @@ async function loadDetail() {
const optional: Promise<void>[] = []
if (access.can('platform:order:list')) {
orderPage.value = 1
customerOrders.value = []
customerOrderTotal.value = 0
optional.push(listPlatformTenantOrderCounts([tenantId.value]).then((records) => {
orderCount.value = Number(records.find((item) => String(item.tenantId) === tenantId.value)?.orderCount ?? 0)
}).catch(() => { orderCount.value = null }))
optional.push(loadCustomerOrders(1))
} else {
customerOrdersLoading.value = false
customerOrdersError.value = null
customerOrders.value = []
customerOrderTotal.value = 0
}
await Promise.all(optional)
} catch (error) {
@@ -158,6 +217,10 @@ async function loadDetail() {
} finally { loading.value = false }
}
function openOrder(order: PlatformFirstLegOrderResource) {
void router.push({ name: 'unified-order-detail', params: { orderId: String(order.id) }, query: { tenantId: tenantId.value } })
}
async function changeStatus() {
if (!customer.value || statusChanging.value) return
const next = customer.value.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE'
@@ -190,6 +253,9 @@ function handleTabKeydown(event: KeyboardEvent, key: DetailTab) {
}
onMounted(loadDetail)
onBeforeUnmount(clearPreviews)
watch(orderPage, (page, previousPage) => {
if (page !== previousPage) void loadCustomerOrders(page)
})
</script>
<template>
@@ -294,8 +360,22 @@ onBeforeUnmount(clearPreviews)
</section>
<section v-else-if="activeTab === 'orders'" class="detail-panel module-panel">
<div class="panel-title"><div><h2>运单记录</h2><p>客户关联头程运单</p></div><Ship :size="19" /></div>
<div class="module-empty"><Ship :size="30" /><strong>当前客户累计 {{ orderCount ?? '—' }} 票运单</strong><p>现有平台接口仅提供客户维度运单数量,尚未提供详情页专用明细查询契约。</p><el-button v-if="access.can('platform:order:list')" type="primary" plain @click="router.push('/first-leg-orders')">前往运单管理</el-button></div>
<div class="panel-title"><div><h2>运单记录</h2><p>客户关联头程运单,共 {{ customerOrderTotal }} 票</p></div><Ship :size="19" /></div>
<template v-if="access.can('platform:order:list')">
<el-alert v-if="customerOrdersError" type="error" :closable="false" show-icon role="alert"><template #title><div class="api-error-alert-title"><span>{{ customerOrdersError.message }}</span><ApiErrorMeta :details="customerOrdersError" /></div></template><el-button link type="danger" @click="loadDetail">重新加载</el-button></el-alert>
<DataTable v-else v-model:page="orderPage" v-model:page-size="orderPageSize" :data="customerOrders" :columns="orderColumns" :loading="customerOrdersLoading" :total="customerOrderTotal" :page-sizes="[10]" empty-text="当前客户暂无运单记录" row-key="id">
<template #actions="{ row }"><el-button link type="primary" @click="openOrder(row as PlatformFirstLegOrderResource)"><Eye :size="15" />详情</el-button></template>
<template #status="{ row }"><StatusBadge :status="orderStatusLabel((row as PlatformFirstLegOrderResource).status)" /></template>
<template #orderNo="{ row }"><strong>{{ (row as PlatformFirstLegOrderResource).orderNo || '—' }}</strong></template>
<template #transport="{ row }"><span>{{ transportLabel(row as PlatformFirstLegOrderResource) }}</span></template>
<template #destination="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).destinationWarehouseName || '—' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).destinationCountryCode || '—' }}</span></div></template>
<template #receivingWarehouse="{ row }"><span>{{ (row as PlatformFirstLegOrderResource).receivingWarehouseName || '—' }}</span></template>
<template #expectedShipDate="{ row }"><span>{{ formattedDate((row as PlatformFirstLegOrderResource).expectedShipDate).slice(0, 10) }}</span></template>
<template #createdAt="{ row }"><span>{{ formattedDate((row as PlatformFirstLegOrderResource).createdAt) }}</span></template>
<template #empty><el-empty description="当前客户暂无运单记录" /></template>
</DataTable>
</template>
<div v-else class="module-empty"><Ship :size="30" /><strong>暂无运单查看权限</strong><p>请联系管理员申请头程订单查看权限。</p></div>
</section>
<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>
+5
View File
@@ -0,0 +1,5 @@
export {
listPlatformFirstLegOrders,
type PlatformFirstLegOrderResource,
type PlatformFirstLegOrderStatus,
} from './api/platformFirstLegOrders'