This commit is contained in:
2026-09-11 16:17:21 +08:00
parent 80a4b3ab5e
commit 737dd41ff1
24 changed files with 202 additions and 229 deletions
@@ -4,7 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
import {
ArrowLeft, Building2, Eye,
FileCheck2, FileText, Pencil, Power,
Ship, UserRound, UsersRound,
UserRound, UsersRound,
} from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
@@ -19,15 +19,8 @@ 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 AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
@@ -36,10 +29,9 @@ import PageToolbar from '@/shared/components/PageToolbar.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import CustomerBillingRulesPanel from '@/modules/customers/components/CustomerBillingRulesPanel.vue'
import CustomerOverseasStorageFeePanel from '@/modules/customers/CustomerOverseasStorageFeePanel.vue'
import { CustomerStatementsPanel } from '@/modules/finance'
import { CustomerAuditLogsPanel } from '@/modules/audit'
type DetailTab = 'basic' | 'orders' | 'statements' | 'billing' | 'logs'
type DetailTab = 'basic' | 'billing' | 'logs'
type PreviewState = { url: string; kind: 'image' | 'pdf'; loading: boolean; failed: boolean }
const route = useRoute()
@@ -53,15 +45,7 @@ const loadError = ref<ApiErrorDetails | null>(null)
const customer = ref<PlatformTenantResource | null>(null)
const qualifications = ref<PlatformTenantQualification[]>([])
const members = ref<PlatformTenantMember[]>([])
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)
let skipNextOrderPageLoad = false
let detailRequestSerial = 0
let orderRequestSerial = 0
let disposed = false
const previews = ref<Record<string, PreviewState>>({})
const previewOpen = ref(false)
@@ -72,24 +56,11 @@ const tabList = ref<HTMLElement | null>(null)
const tabs: Array<{ key: DetailTab; label: string; icon: string }> = [
{ key: 'basic', label: '基本信息', icon: '📋' },
{ key: 'orders', label: '运单记录', icon: '📦' },
{ key: 'statements', label: '财务账单', icon: '🧾' },
{ key: 'billing', label: '计费规则', icon: '⚖️' },
{ key: 'logs', label: '操作日志', icon: '📝' },
]
const enterpriseMembers = computed(() => members.value.filter((member) => ['OWNER', 'EMPLOYEE'].includes(member.memberType)))
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) : '—' }
function formatCapital(value: number | string | null, currency: string | null) {
@@ -120,36 +91,6 @@ 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] || '未知状态'
}
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, requestedTenantId = tenantId.value) {
if (!access.can('platform:order:list')) return
const serial = ++orderRequestSerial
customerOrdersLoading.value = true
customerOrdersError.value = null
try {
const result = await listPlatformFirstLegOrders({ tenantId: requestedTenantId, page, size: orderPageSize.value })
if (disposed || serial !== orderRequestSerial || requestedTenantId !== tenantId.value) return
customerOrders.value = result.records
customerOrderTotal.value = result.total
} catch (error) {
if (disposed || serial !== orderRequestSerial || requestedTenantId !== tenantId.value) return
customerOrders.value = []
customerOrdersError.value = getApiErrorDetails(error, '运单记录加载失败,请稍后重试')
} finally {
if (!disposed && serial === orderRequestSerial) customerOrdersLoading.value = false
}
}
function clearPreviews() {
for (const preview of Object.values(previews.value)) if (preview.url) URL.revokeObjectURL(preview.url)
previews.value = {}
@@ -195,9 +136,6 @@ async function loadDetail() {
customer.value = null
qualifications.value = []
members.value = []
customerOrders.value = []
customerOrderTotal.value = 0
customerOrdersError.value = null
try {
const [tenantResult, qualificationResult, memberResult] = await Promise.all([
getPlatformTenant(requestedTenantId),
@@ -211,17 +149,6 @@ async function loadDetail() {
previews.value = Object.fromEntries(qualificationResult.map((item) => [String(item.id), {
url: '', kind: qualificationName(item).toLowerCase().endsWith('.pdf') ? 'pdf' : 'image', loading: false, failed: false,
} satisfies PreviewState]))
if (access.can('platform:order:list')) {
if (orderPage.value !== 1) skipNextOrderPageLoad = true
orderPage.value = 1
void loadCustomerOrders(1, requestedTenantId)
} else {
customerOrdersLoading.value = false
customerOrdersError.value = null
customerOrders.value = []
customerOrderTotal.value = 0
}
} catch (error) {
if (disposed || serial !== detailRequestSerial || requestedTenantId !== tenantId.value) return
customer.value = null
@@ -233,10 +160,6 @@ async function loadDetail() {
}
}
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'
@@ -272,35 +195,25 @@ watch(tenantId, (value) => {
if (value) void loadDetail()
else {
detailRequestSerial += 1
orderRequestSerial += 1
loading.value = false
customer.value = null
qualifications.value = []
members.value = []
customerOrders.value = []
}
}, { immediate: true })
onBeforeUnmount(() => {
disposed = true
detailRequestSerial += 1
orderRequestSerial += 1
clearPreviews()
})
watch(orderPage, (page, previousPage) => {
if (skipNextOrderPageLoad) {
skipNextOrderPageLoad = false
return
}
if (page !== previousPage) void loadCustomerOrders(page)
})
</script>
<template>
<!--
THESIS: 客户档案是一张可核验的运营工作台,不是被抽屉压缩的字段清单。
OWN-WORLD: 延续后台冷灰底、白色数据面板、紫蓝主色与克制的状态色。
STORY: 运营先确认企业状态,再从六个业务入口查看资料、用户和后续记录。
FIRST VIEWPORT: 客户身份与动作居首,三项真实概况紧随其后,六页签固定形成内容索引。
STORY: 运营先确认企业状态,再查看客户资料、计费规则和操作日志。
FIRST VIEWPORT: 客户身份与动作居首,三个页签形成稳定内容索引。
FORM: 精确继承原型的详情工作台结构,以现有后台组件体系实现。
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md
-->
@@ -391,26 +304,6 @@ watch(orderPage, (page, previousPage) => {
</div>
</template>
<section v-else-if="activeTab === 'orders'" class="detail-panel module-panel">
<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 === 'statements'" class="detail-panel module-panel"><CustomerStatementsPanel :customer-id="tenantId" :customer-name="customer.tenantName" /></section>
<section v-else-if="activeTab === 'logs'" class="detail-panel module-panel"><CustomerAuditLogsPanel :customer-id="tenantId" :customer-name="customer.tenantName" /></section>
<section v-else class="detail-panel module-panel"><CustomerBillingRulesPanel :customer-id="tenantId" :customer-name="customer.tenantName" :customer-code="customer.tenantCode" :dimension-initialization-status="customer.dimensionInitializationStatus" /><CustomerOverseasStorageFeePanel :customer-id="tenantId" :customer-name="customer.tenantName" /></section>
</div>