491 lines
36 KiB
Vue
491 lines
36 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import {
|
||
ArrowLeft, BarChart3, Box, Building2, CircleDollarSign, ClipboardList, Eye,
|
||
FileCheck2, FileText, History, Pencil, Power, ReceiptText,
|
||
Scale, Ship, UserRound, UsersRound,
|
||
} from '@lucide/vue'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import {
|
||
changePlatformTenantStatus,
|
||
downloadTenantQualification,
|
||
getPlatformTenant,
|
||
listPlatformTenantMembers,
|
||
listPlatformTenantOrderCounts,
|
||
listTenantQualifications,
|
||
qualificationFileName,
|
||
type PlatformTenantQualification,
|
||
type PlatformTenantMember,
|
||
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'
|
||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||
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'
|
||
|
||
type DetailTab = 'basic' | 'business' | 'orders' | 'finance' | 'logs' | 'billing'
|
||
type PreviewState = { url: string; kind: 'image' | 'pdf'; loading: boolean; failed: boolean }
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const access = useAccessPreviewStore()
|
||
const tenantId = computed(() => String(route.params.tenantId ?? ''))
|
||
const activeTab = ref<DetailTab>('basic')
|
||
const loading = ref(true)
|
||
const statusChanging = ref(false)
|
||
const loadError = ref<ApiErrorDetails | null>(null)
|
||
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)
|
||
let skipNextOrderPageLoad = false
|
||
let detailRequestSerial = 0
|
||
let orderRequestSerial = 0
|
||
let disposed = false
|
||
const previews = ref<Record<string, PreviewState>>({})
|
||
const previewOpen = ref(false)
|
||
const previewName = ref('')
|
||
const previewUrl = ref('')
|
||
const previewKind = ref<'image' | 'pdf'>('image')
|
||
const tabList = ref<HTMLElement | null>(null)
|
||
|
||
const tabs: Array<{ key: DetailTab; label: string; icon: typeof ClipboardList }> = [
|
||
{ key: 'basic', label: '基本信息', icon: ClipboardList },
|
||
{ key: 'business', label: '业务概况', icon: BarChart3 },
|
||
{ key: 'orders', label: '运单记录', icon: Box },
|
||
{ key: 'finance', label: '财务账单', icon: CircleDollarSign },
|
||
{ key: 'logs', label: '操作日志', icon: History },
|
||
{ key: 'billing', label: '计费规则', icon: Scale },
|
||
]
|
||
|
||
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) : '—' }
|
||
function formatCapital(value: number | string | null, currency: string | null) {
|
||
if (value === null || value === '') return '—'
|
||
const numericValue = Number(value)
|
||
if (!Number.isFinite(numericValue)) return '—'
|
||
const label = currency === 'USD' ? '美元' : currency === 'HKD' ? '港币' : '人民币'
|
||
return `${numericValue.toLocaleString('zh-CN')} ${label}`
|
||
}
|
||
function statusMeta(status: PlatformTenantStatus) {
|
||
if (status === 'ACTIVE') return { label: '启用', type: 'success' as const }
|
||
if (status === 'PROFILE_REQUIRED') return { label: '待完善', type: 'info' as const }
|
||
if (status === 'PENDING') return { label: '待审核', type: 'warning' as const }
|
||
if (status === 'REJECTED') return { label: '已驳回', type: 'danger' as const }
|
||
return { label: '已停用', type: 'info' as const }
|
||
}
|
||
function qualificationLabel(type: string) {
|
||
return ({ BUSINESS_LICENSE: '营业执照', LEGAL_PERSON_ID: '法人身份证', FREIGHT_FORWARDER_CERTIFICATE: '货代资质证书', BANK_ACCOUNT_LICENSE: '银行开户许可证' } as Record<string, string>)[type] || type
|
||
}
|
||
function qualificationStatus(status: PlatformTenantQualification['status']) {
|
||
if (status === 'APPROVED') return { label: '已通过', type: 'success' as const }
|
||
if (status === 'REJECTED') return { label: '已驳回', type: 'danger' as const }
|
||
if (status === 'SUPERSEDED') return { label: '已替换', type: 'info' as const }
|
||
return { label: '待审核', type: 'warning' as const }
|
||
}
|
||
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, 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
|
||
if (orderCount.value === null) orderCount.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 = {}
|
||
previewOpen.value = false
|
||
}
|
||
|
||
async function loadPreview(item: PlatformTenantQualification, notify = true) {
|
||
const state = previews.value[String(item.id)]
|
||
if (!state || state.loading) return false
|
||
state.loading = true
|
||
try {
|
||
const blob = await downloadTenantQualification(tenantId.value, item.documentUrl)
|
||
if (!blob.size) throw new Error('资质文件内容为空')
|
||
if (state.url) URL.revokeObjectURL(state.url)
|
||
state.url = URL.createObjectURL(blob)
|
||
state.kind = blob.type === 'application/pdf' || qualificationName(item).toLowerCase().endsWith('.pdf') ? 'pdf' : 'image'
|
||
state.failed = false
|
||
return true
|
||
} catch (error) {
|
||
state.failed = true
|
||
if (notify) ElMessage.error(getApiErrorMessage(error, '资质文件预览加载失败'))
|
||
return false
|
||
} finally { state.loading = false }
|
||
}
|
||
|
||
async function openPreview(item: PlatformTenantQualification) {
|
||
const state = previews.value[String(item.id)]
|
||
if (!state) return
|
||
if (!state.url) await loadPreview(item)
|
||
if (!state.url) return
|
||
previewName.value = qualificationName(item)
|
||
previewUrl.value = state.url
|
||
previewKind.value = state.kind
|
||
previewOpen.value = true
|
||
}
|
||
|
||
async function loadDetail() {
|
||
const requestedTenantId = tenantId.value
|
||
const serial = ++detailRequestSerial
|
||
loading.value = true
|
||
loadError.value = null
|
||
clearPreviews()
|
||
customer.value = null
|
||
qualifications.value = []
|
||
members.value = []
|
||
orderCount.value = null
|
||
customerOrders.value = []
|
||
customerOrderTotal.value = 0
|
||
customerOrdersError.value = null
|
||
try {
|
||
const [tenantResult, qualificationResult, memberResult] = await Promise.all([
|
||
getPlatformTenant(requestedTenantId),
|
||
listTenantQualifications(requestedTenantId),
|
||
listPlatformTenantMembers(requestedTenantId),
|
||
])
|
||
if (disposed || serial !== detailRequestSerial || requestedTenantId !== tenantId.value) return
|
||
customer.value = tenantResult
|
||
qualifications.value = qualificationResult
|
||
members.value = memberResult
|
||
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]))
|
||
|
||
const optional: Promise<void>[] = []
|
||
if (access.can('platform:order:list')) {
|
||
if (orderPage.value !== 1) skipNextOrderPageLoad = true
|
||
orderPage.value = 1
|
||
optional.push(listPlatformTenantOrderCounts([requestedTenantId]).then((records) => {
|
||
if (disposed || serial !== detailRequestSerial || requestedTenantId !== tenantId.value) return
|
||
orderCount.value = Number(records.find((item) => String(item.tenantId) === requestedTenantId)?.orderCount ?? 0)
|
||
}).catch(() => {
|
||
if (!disposed && serial === detailRequestSerial && requestedTenantId === tenantId.value) orderCount.value = null
|
||
}))
|
||
optional.push(loadCustomerOrders(1, requestedTenantId))
|
||
} else {
|
||
customerOrdersLoading.value = false
|
||
customerOrdersError.value = null
|
||
customerOrders.value = []
|
||
customerOrderTotal.value = 0
|
||
}
|
||
await Promise.all(optional)
|
||
} catch (error) {
|
||
if (disposed || serial !== detailRequestSerial || requestedTenantId !== tenantId.value) return
|
||
customer.value = null
|
||
qualifications.value = []
|
||
members.value = []
|
||
loadError.value = getApiErrorDetails(error, '客户详情加载失败,请稍后重试')
|
||
} finally {
|
||
if (!disposed && serial === detailRequestSerial) 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'
|
||
const verb = next === 'ACTIVE' ? '启用' : '停用'
|
||
try {
|
||
await ElMessageBox.confirm(`确认${verb}客户“${customer.value.tenantName}”吗?`, `${verb}客户`, {
|
||
type: 'warning', confirmButtonText: `确认${verb}`, cancelButtonText: '取消',
|
||
})
|
||
statusChanging.value = true
|
||
customer.value = await changePlatformTenantStatus(tenantId.value, next)
|
||
ElMessage.success(`客户已${verb}`)
|
||
} catch (error) {
|
||
if (error !== 'cancel' && error !== 'close') ElMessage.error(getApiErrorMessage(error, `客户${verb}失败`))
|
||
} finally { statusChanging.value = false }
|
||
}
|
||
|
||
function goEdit() { void router.push({ name: 'customers', query: { edit: tenantId.value } }) }
|
||
function selectTab(key: DetailTab, button?: HTMLElement) {
|
||
activeTab.value = key
|
||
button?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })
|
||
}
|
||
function handleTabKeydown(event: KeyboardEvent, key: DetailTab) {
|
||
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return
|
||
event.preventDefault()
|
||
const current = tabs.findIndex((item) => item.key === key)
|
||
const next = event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : event.key === 'ArrowRight' ? (current + 1) % tabs.length : (current - 1 + tabs.length) % tabs.length
|
||
const button = tabList.value?.querySelectorAll<HTMLButtonElement>('[role="tab"]')[next]
|
||
button?.focus()
|
||
selectTab(tabs[next].key, button)
|
||
}
|
||
watch(tenantId, (value) => {
|
||
activeTab.value = 'basic'
|
||
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: 客户身份与动作居首,三项真实概况紧随其后,六页签固定形成内容索引。
|
||
FORM: 精确继承原型的详情工作台结构,以现有后台组件体系实现。
|
||
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md
|
||
-->
|
||
<AdminPage as="div" class="customer-detail-page">
|
||
<LoadErrorState v-if="loadError" title="客户详情暂时无法加载" :message="loadError.message" :loading="loading" @retry="loadDetail">
|
||
<template #details><ApiErrorMeta :details="loadError" /></template>
|
||
</LoadErrorState>
|
||
|
||
<DetailPageLayout v-else v-loading="loading" aria-label="客户详情">
|
||
<template #header>
|
||
<header class="detail-header">
|
||
<el-button class="back-button" @click="router.push({ name: 'customers' })"><ArrowLeft :size="17" />返回列表</el-button>
|
||
<div class="company-mark" aria-hidden="true"><Building2 :size="23" /></div>
|
||
<PageToolbar class="detail-page-toolbar" as="section" :title="customer?.tenantName || '客户详情'" :description="customer?.tenantCode || tenantId">
|
||
<template #actions>
|
||
<div v-if="customer" class="header-actions">
|
||
<el-tag :type="statusMeta(customer.status).type" effect="light" round>{{ statusMeta(customer.status).label }}</el-tag>
|
||
<el-button v-if="access.can('platform:customer:edit')" @click="goEdit"><Pencil :size="16" />编辑信息</el-button>
|
||
<el-button v-if="access.can('platform:customer:status') && ['ACTIVE', 'DISABLED'].includes(customer.status)" :type="customer.status === 'ACTIVE' ? 'danger' : 'success'" :loading="statusChanging" @click="changeStatus"><Power :size="16" />{{ customer.status === 'ACTIVE' ? '停用账号' : '启用账号' }}</el-button>
|
||
</div>
|
||
</template>
|
||
</PageToolbar>
|
||
</header>
|
||
</template>
|
||
|
||
<template #summary>
|
||
<section class="overview-strip" aria-label="客户概况">
|
||
<article><span class="metric-icon is-blue"><Ship :size="20" /></span><div><p>累计运单</p><strong>{{ orderCount ?? '—' }}<small>票</small></strong><span>{{ access.can('platform:order:list') ? '来自头程订单服务' : '暂无运单查看权限' }}</span></div></article>
|
||
<article><span class="metric-icon is-green"><UsersRound :size="20" /></span><div><p>企业用户</p><strong>{{ enterpriseMembers.length }}<small>人</small></strong><span>{{ activeEnterpriseMembers }} 人正常使用</span></div></article>
|
||
<article><span class="metric-icon is-amber"><FileCheck2 :size="20" /></span><div><p>资质文件</p><strong>{{ qualifications.length }}<small>份</small></strong><span>{{ approvedQualifications }} 份已通过</span></div></article>
|
||
</section>
|
||
</template>
|
||
|
||
<div ref="tabList" class="detail-tabs" role="tablist" aria-label="客户详情模块">
|
||
<button v-for="tab in tabs" :id="`customer-tab-${tab.key}`" :key="tab.key" type="button" role="tab" :class="{ active: activeTab === tab.key }" :aria-selected="activeTab === tab.key" :aria-controls="`customer-panel-${tab.key}`" :tabindex="activeTab === tab.key ? 0 : -1" @click="selectTab(tab.key, $event.currentTarget as HTMLElement)" @keydown="handleTabKeydown($event, tab.key)"><component :is="tab.icon" :size="18" /><span>{{ tab.label }}</span></button>
|
||
</div>
|
||
|
||
<div v-if="customer" :id="`customer-panel-${activeTab}`" class="tab-panel" role="tabpanel" :aria-labelledby="`customer-tab-${activeTab}`" tabindex="0">
|
||
<template v-if="activeTab === 'basic'">
|
||
<div class="basic-layout">
|
||
<section class="detail-panel company-panel">
|
||
<div class="panel-title"><div><h2>企业基本信息</h2><p>工商档案与合作配置</p></div><Building2 :size="19" /></div>
|
||
<dl class="info-grid">
|
||
<div><dt>公司名称</dt><dd>{{ displayText(customer.legalName || customer.tenantName) }}</dd></div>
|
||
<div><dt>统一社会信用代码</dt><dd>{{ displayText(customer.unifiedSocialCreditCode) }}</dd></div>
|
||
<div><dt>企业类型</dt><dd>{{ displayText(customer.enterpriseType) }}</dd></div>
|
||
<div><dt>法定代表人</dt><dd>{{ displayText(customer.legalRepresentative) }}</dd></div>
|
||
<div><dt>成立日期</dt><dd>{{ formatDate(customer.establishedDate).slice(0, 10) }}</dd></div>
|
||
<div><dt>注册资本</dt><dd>{{ formatCapital(customer.registeredCapital, customer.registeredCapitalCurrency) }}</dd></div>
|
||
<div class="wide"><dt>注册地址</dt><dd>{{ displayText(customer.registeredAddress) }}</dd></div>
|
||
</dl>
|
||
</section>
|
||
<section class="detail-panel qualification-panel">
|
||
<div class="panel-title"><div><h2>资质文件</h2><p>共 {{ qualifications.length }} 份</p></div><FileText :size="19" /></div>
|
||
<div v-if="qualifications.length" class="qualification-list">
|
||
<button v-for="item in qualifications" :key="String(item.id)" type="button" :disabled="previews[String(item.id)]?.loading" @click="openPreview(item)">
|
||
<span class="file-icon"><FileText :size="18" /></span><span><strong>{{ qualificationLabel(item.qualificationType) }}</strong><small>{{ qualificationName(item) }}</small></span><el-tag :type="qualificationStatus(item.status).type" effect="light" size="small">{{ qualificationStatus(item.status).label }}</el-tag><span class="file-date">{{ item.validTo || '长期有效' }}</span><Eye :size="17" />
|
||
</button>
|
||
</div>
|
||
<div v-else class="section-empty"><FileCheck2 :size="22" /><strong>暂无资质文件</strong><span>客户尚未上传企业资质</span></div>
|
||
</section>
|
||
<section class="detail-panel contact-panel">
|
||
<div class="panel-title"><div><h2>联系与合作信息</h2><p>客户联络及内部跟进信息</p></div><UserRound :size="19" /></div>
|
||
<dl class="info-grid">
|
||
<div><dt>联系人</dt><dd>{{ displayText(customer.contactName) }}</dd></div><div><dt>联系电话</dt><dd>{{ displayText(customer.contactPhone) }}</dd></div>
|
||
<div><dt>联系邮箱</dt><dd>{{ displayText(customer.contactEmail) }}</dd></div><div><dt>联系微信</dt><dd>{{ displayText(customer.contactWechat) }}</dd></div>
|
||
<div><dt>客户套餐</dt><dd>{{ displayText(customer.servicePackage) }}</dd></div><div><dt>账期</dt><dd>{{ customer.paymentTermDays ?? 0 }} 天</dd></div>
|
||
<div><dt>销售人员</dt><dd>{{ displayText(customer.salesOwnerName) }}</dd></div><div><dt>入驻时间</dt><dd>{{ formatDate(customer.createdAt) }}</dd></div>
|
||
<div class="wide"><dt>内部备注</dt><dd>{{ displayText(customer.internalRemark) }}</dd></div>
|
||
</dl>
|
||
</section>
|
||
<section class="detail-panel user-panel">
|
||
<div class="panel-title"><div><h2>企业用户</h2><p>当前客户租户下的登录账号</p></div><UsersRound :size="19" /></div>
|
||
<div v-if="enterpriseMembers.length" class="user-table-wrap">
|
||
<table><thead><tr><th>账号</th><th>成员类型</th><th>联系方式</th><th>角色</th><th>状态</th></tr></thead>
|
||
<tbody><tr v-for="member in enterpriseMembers" :key="String(member.membershipId)">
|
||
<td><div class="user-identity"><span>{{ (member.displayName || member.username || '?').slice(0, 1) }}</span><div><strong>{{ displayText(member.username) }}</strong><small>{{ displayText(member.displayName) }}</small></div></div></td>
|
||
<td>{{ member.memberType === 'OWNER' ? '企业管理员' : '企业员工' }}</td>
|
||
<td><div class="member-contact"><span>{{ displayText(member.phone) }}</span><small>{{ displayText(member.email) }}</small></div></td>
|
||
<td><div v-if="member.roleNames?.length" class="role-list"><el-tag v-for="role in member.roleNames" :key="role" size="small" effect="plain">{{ role }}</el-tag></div><span v-else>—</span></td>
|
||
<td><el-tag :type="member.status === 'ACTIVE' ? 'success' : 'info'" effect="light" size="small">{{ member.status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></td>
|
||
</tr></tbody>
|
||
</table>
|
||
</div>
|
||
<div v-else class="section-empty"><UsersRound :size="22" /><strong>暂无企业用户</strong><span>当前客户尚未创建企业成员账号</span></div>
|
||
</section>
|
||
</div>
|
||
</template>
|
||
|
||
<section v-else-if="activeTab === 'business'" class="detail-panel module-panel">
|
||
<div class="panel-title"><div><h2>业务概况</h2><p>基于当前已接入服务的客户业务摘要</p></div><BarChart3 :size="19" /></div>
|
||
<div class="business-summary"><div><Ship :size="20" /><span>累计运单</span><strong>{{ orderCount ?? '—' }} 票</strong></div><div><UsersRound :size="20" /><span>活跃企业用户</span><strong>{{ activeEnterpriseMembers }} 人</strong></div><div><FileCheck2 :size="20" /><span>有效资质</span><strong>{{ approvedQualifications }} 份</strong></div></div>
|
||
<div class="contract-note"><strong>趋势、港口与收入分析暂未接入</strong><p>原型中的近 6 个月趋势、运输方式分布、目的港和收入贡献依赖后续统计与财务服务;当前不展示模拟图表。</p></div>
|
||
</section>
|
||
|
||
<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 === '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" /><CustomerOverseasStorageFeePanel :customer-id="tenantId" :customer-name="customer.tenantName" /></section>
|
||
</div>
|
||
</DetailPageLayout>
|
||
|
||
<AppDialog v-model="previewOpen" :title="previewName" width="min(920px, calc(100vw - 28px))" append-to-body :show-footer="false">
|
||
<div class="qualification-preview"><img v-if="previewKind === 'image'" :src="previewUrl" :alt="`${previewName}预览`" /><iframe v-else :src="previewUrl" :title="`${previewName}预览`" /></div>
|
||
</AppDialog>
|
||
</AdminPage>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.customer-detail-page { min-width: 0; }
|
||
.detail-header { display:flex; min-width:0; align-items:center; gap:12px; }
|
||
.back-button { flex:0 0 auto; }
|
||
.company-mark { display:grid; width:44px; height:44px; flex:0 0 auto; place-items:center; border-radius:12px; background:#eef1ff; color:var(--cc-primary); }
|
||
.detail-page-toolbar { min-width:0; flex:1; }
|
||
.header-actions { display:flex; flex:0 0 auto; gap:8px; }
|
||
.header-actions :deep(.el-button + .el-button) { margin-left:0; }
|
||
.overview-strip { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); overflow:hidden; border-radius:14px; background:#fff; box-shadow:0 8px 22px rgba(31,43,74,.08); }
|
||
.overview-strip article { display:flex; min-width:0; align-items:center; gap:14px; padding:22px 24px; border-right:1px solid var(--cc-border); }
|
||
.overview-strip article:last-child { border-right:0; }
|
||
.metric-icon { display:grid; width:44px; height:44px; flex:0 0 auto; place-items:center; border-radius:12px; }
|
||
.metric-icon.is-blue { background:#eef1ff; color:#596fde; }.metric-icon.is-green { background:#eaf8f1; color:#159260; }.metric-icon.is-amber { background:#fff5e8; color:#c87519; }
|
||
.overview-strip p,.overview-strip span { margin:0; color:var(--cc-muted); font-size:12px; }
|
||
.overview-strip strong { display:block; margin:5px 0 4px; color:#172033; font-size:26px; line-height:1; }
|
||
.overview-strip small { margin-left:5px; color:#475467; font-size:12px; font-weight:600; }
|
||
.detail-tabs { display:grid; grid-template-columns:repeat(6,minmax(0,1fr)); overflow-x:auto; border-radius:14px 14px 0 0; background:#fff; box-shadow:0 8px 22px rgba(31,43,74,.06); }
|
||
.detail-tabs button { position:relative; display:flex; min-width:128px; min-height:64px; align-items:center; justify-content:center; gap:8px; padding:0 14px; border:0; background:transparent; color:#667085; font-weight:650; cursor:pointer; }
|
||
.detail-tabs button::after { position:absolute; right:0; bottom:0; left:0; height:3px; background:transparent; content:""; }
|
||
.detail-tabs button:hover { background:#fafbff; color:#344054; }.detail-tabs button.active { background:#f2f3ff; color:var(--cc-primary); }.detail-tabs button.active::after { background:var(--cc-primary); }
|
||
.tab-panel { min-height:400px; padding-top:18px; }
|
||
.basic-layout { display:grid; grid-template-columns:minmax(0,1.1fr) minmax(0,.9fr); gap:18px; align-items:start; }
|
||
.detail-panel { min-width:0; padding:22px; border-radius:14px; background:#fff; box-shadow:0 8px 22px rgba(31,43,74,.06); }
|
||
.contact-panel,.user-panel { grid-column:1 / -1; }
|
||
.panel-title { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; margin-bottom:18px; padding-bottom:14px; border-bottom:1px solid var(--cc-border); }
|
||
.panel-title h2 { margin:0; color:#344054; font-size:16px; }.panel-title p { margin:5px 0 0; color:var(--cc-muted); font-size:11px; }.panel-title > svg { color:var(--cc-primary); }
|
||
.info-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; margin:0; }
|
||
.info-grid > div { min-width:0; padding:12px 14px; border-radius:10px; background:#f8f9fc; }.info-grid > div.wide { grid-column:1 / -1; }
|
||
.info-grid dt { margin-bottom:5px; color:var(--cc-muted); font-size:11px; }.info-grid dd { margin:0; color:#25324a; font-size:13px; font-weight:650; line-height:1.55; overflow-wrap:anywhere; }
|
||
.qualification-list { display:flex; flex-direction:column; gap:8px; }
|
||
.qualification-list button { display:grid; width:100%; grid-template-columns:38px minmax(0,1fr) auto auto 20px; align-items:center; gap:10px; padding:10px; border:1px solid var(--cc-border); border-radius:10px; background:#fff; color:inherit; text-align:left; cursor:pointer; }
|
||
.qualification-list button:hover { border-color:#b9c3f5; background:#fafbff; }.qualification-list button:disabled { cursor:wait; opacity:.65; }
|
||
.file-icon { display:grid; width:38px; height:38px; place-items:center; border-radius:9px; background:#eef1ff; color:var(--cc-primary); }
|
||
.qualification-list strong,.qualification-list small { display:block; }.qualification-list strong { font-size:12px; }.qualification-list small,.file-date { margin-top:3px; color:var(--cc-muted); font-size:10px; }.file-date { margin:0; white-space:nowrap; }
|
||
.section-empty,.module-empty { display:flex; min-height:150px; align-items:center; justify-content:center; flex-direction:column; gap:7px; color:#98a2b3; text-align:center; }
|
||
.section-empty strong,.module-empty strong { color:#475467; font-size:13px; }.section-empty span,.module-empty p { max-width:600px; margin:0; color:var(--cc-muted); font-size:11px; line-height:1.7; }
|
||
.section-empty button { border:0; background:transparent; color:var(--cc-primary); cursor:pointer; }.section-empty.is-error svg { color:#d92d20; }
|
||
.user-table-wrap { overflow-x:auto; }
|
||
table { width:100%; min-width:720px; border-collapse:collapse; font-size:12px; }th { padding:10px 12px; background:#f8fafc; color:#667085; text-align:left; font-weight:650; }td { padding:12px; border-top:1px solid #edf0f4; color:#344054; }
|
||
.user-identity { display:flex; align-items:center; gap:9px; }.user-identity > span { display:grid; width:34px; height:34px; flex:0 0 auto; place-items:center; border-radius:50%; background:#eef1ff; color:var(--cc-primary); font-weight:700; }.user-identity strong,.user-identity small { display:block; }.user-identity small { margin-top:3px; color:var(--cc-muted); font-size:10px; }.role-list { display:flex; flex-wrap:wrap; gap:5px; }
|
||
.member-contact span,.member-contact small { display:block; }.member-contact small { margin-top:3px; color:var(--cc-muted); font-size:10px; }
|
||
.module-panel { min-height:360px; }.module-empty { min-height:260px; }.module-empty > svg { color:#8797b2; }.module-empty p { max-width:520px; font-size:12px; }
|
||
.business-summary { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); overflow:hidden; border:1px solid var(--cc-border); border-radius:12px; }.business-summary > div { display:grid; min-height:130px; place-items:center; align-content:center; gap:8px; border-right:1px solid var(--cc-border); }.business-summary > div:last-child { border-right:0; }.business-summary svg { color:var(--cc-primary); }.business-summary span { color:var(--cc-muted); font-size:11px; }.business-summary strong { color:#25324a; font-size:20px; }
|
||
.contract-note { margin-top:18px; padding:16px 18px; border-radius:10px; background:#f8fafc; }.contract-note strong { color:#344054; font-size:12px; }.contract-note p { margin:5px 0 0; color:var(--cc-muted); font-size:11px; line-height:1.7; }
|
||
.qualification-preview { display:grid; min-height:320px; max-height:72vh; overflow:auto; place-items:center; border-radius:10px; background:#f8fafc; }.qualification-preview img { display:block; max-width:100%; height:auto; }.qualification-preview iframe { width:100%; min-height:68vh; border:0; background:#fff; }
|
||
@media (max-width:960px) { .basic-layout { grid-template-columns:1fr; }.contact-panel,.user-panel { grid-column:auto; }.detail-tabs { grid-template-columns:repeat(6,150px); }.qualification-panel { order:3; }.user-panel { order:4; } }
|
||
@media (max-width:720px) { .detail-header { align-items:flex-start; flex-wrap:wrap; }.detail-page-toolbar { width:100%; flex-basis:100%; }.company-mark { width:40px; height:40px; }.header-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); width:100%; }.header-actions :deep(.el-tag) { grid-column:1 / -1; justify-self:start; }.header-actions :deep(.el-button) { min-height:44px; }.overview-strip { grid-template-columns:1fr; }.overview-strip article { border-right:0; border-bottom:1px solid var(--cc-border); }.overview-strip article:last-child { border-bottom:0; }.detail-panel { padding:16px; }.info-grid { grid-template-columns:1fr; }.info-grid > div.wide { grid-column:auto; }.business-summary { grid-template-columns:1fr; }.business-summary > div { min-height:100px; border-right:0; border-bottom:1px solid var(--cc-border); }.business-summary > div:last-child { border-bottom:0; } }
|
||
@media (max-width:420px) { .company-mark { display:none; }.header-actions { grid-template-columns:1fr; }.header-actions :deep(.el-tag) { grid-column:auto; }.qualification-list button { grid-template-columns:36px minmax(0,1fr) auto; }.qualification-list .file-date,.qualification-list button > svg { display:none; } }
|
||
</style>
|