2026-08-04 13:32:08 +08:00
< script setup lang = "ts" >
2026-08-07 20:07:37 +08:00
import { computed , onBeforeUnmount , ref , watch } from 'vue'
2026-08-04 13:32:08 +08:00
import { useRoute , useRouter } from 'vue-router'
import {
2026-08-11 19:59:15 +08:00
ArrowLeft , BarChart3 , Box , Building2 , ClipboardList , Eye ,
FileCheck2 , FileText , Pencil , Power ,
2026-08-04 13:32:08 +08:00
Scale , Ship , UserRound , UsersRound ,
} from '@lucide/vue'
import { ElMessage , ElMessageBox } from 'element-plus'
import {
changePlatformTenantStatus ,
downloadTenantQualification ,
getPlatformTenant ,
2026-08-07 09:06:22 +08:00
listPlatformTenantMembers ,
2026-08-04 13:32:08 +08:00
listPlatformTenantOrderCounts ,
listTenantQualifications ,
qualificationFileName ,
type PlatformTenantQualification ,
2026-08-07 09:06:22 +08:00
type PlatformTenantMember ,
2026-08-04 13:32:08 +08:00
type PlatformTenantResource ,
type PlatformTenantStatus ,
} from '@/modules/iam'
2026-08-07 18:03:59 +08:00
import {
listPlatformFirstLegOrders ,
type PlatformFirstLegOrderResource ,
type PlatformFirstLegOrderStatus ,
} from '@/modules/first-leg-orders'
2026-08-04 13:32:08 +08:00
import { getApiErrorDetails , getApiErrorMessage , type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
2026-08-07 18:03:59 +08:00
import DataTable , { type DataTableColumn } from '@/shared/components/DataTable.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
2026-08-10 16:24:54 +08:00
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'
2026-08-04 13:32:08 +08:00
import { useAccessPreviewStore } from '@/core/access/accessPreview'
2026-08-05 15:54:59 +08:00
import CustomerBillingRulesPanel from '@/modules/customers/components/CustomerBillingRulesPanel.vue'
2026-08-08 13:32:27 +08:00
import CustomerOverseasStorageFeePanel from '@/modules/customers/CustomerOverseasStorageFeePanel.vue'
2026-08-04 13:32:08 +08:00
2026-08-11 19:59:15 +08:00
type DetailTab = 'basic' | 'business' | 'orders' | 'billing'
2026-08-04 13:32:08 +08:00
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 [] >([])
2026-08-07 09:06:22 +08:00
const members = ref < PlatformTenantMember [] >([])
2026-08-04 13:32:08 +08:00
const orderCount = ref < number | null >( null )
2026-08-07 18:03:59 +08:00
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 )
2026-08-07 18:15:56 +08:00
let skipNextOrderPageLoad = false
2026-08-07 20:07:37 +08:00
let detailRequestSerial = 0
let orderRequestSerial = 0
let disposed = false
2026-08-04 13:32:08 +08:00
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 : 'billing' , label : '计费规则' , icon : Scale },
]
const approvedQualifications = computed (() => qualifications . value . filter (( item ) => item . status === 'APPROVED' ). length )
2026-08-07 09:06:22 +08:00
const enterpriseMembers = computed (() => members . value . filter (( member ) => [ 'OWNER' , 'EMPLOYEE' ]. includes ( member . memberType )))
const activeEnterpriseMembers = computed (() => enterpriseMembers . value . filter (( member ) => member . status === 'ACTIVE' ). length )
2026-08-07 18:03:59 +08:00
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' },
]
2026-08-04 13:32:08 +08:00
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 ) } 文件` }
}
2026-08-07 18:03:59 +08:00
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 ) : '—' }
2026-08-07 20:07:37 +08:00
async function loadCustomerOrders ( page = orderPage . value , requestedTenantId = tenantId . value ) {
2026-08-07 18:03:59 +08:00
if ( ! access . can ( 'platform:order:list' )) return
2026-08-07 20:07:37 +08:00
const serial = ++ orderRequestSerial
2026-08-07 18:03:59 +08:00
customerOrdersLoading . value = true
customerOrdersError . value = null
try {
2026-08-07 20:07:37 +08:00
const result = await listPlatformFirstLegOrders ({ tenantId : requestedTenantId , page , size : orderPageSize . value })
if ( disposed || serial !== orderRequestSerial || requestedTenantId !== tenantId . value ) return
2026-08-07 18:03:59 +08:00
customerOrders . value = result . records
customerOrderTotal . value = result . total
if ( orderCount . value === null ) orderCount . value = result . total
} catch ( error ) {
2026-08-07 20:07:37 +08:00
if ( disposed || serial !== orderRequestSerial || requestedTenantId !== tenantId . value ) return
2026-08-07 18:03:59 +08:00
customerOrders . value = []
customerOrdersError . value = getApiErrorDetails ( error , '运单记录加载失败,请稍后重试' )
} finally {
2026-08-07 20:07:37 +08:00
if ( ! disposed && serial === orderRequestSerial ) customerOrdersLoading . value = false
2026-08-07 18:03:59 +08:00
}
}
2026-08-04 13:32:08 +08:00
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 () {
2026-08-07 20:07:37 +08:00
const requestedTenantId = tenantId . value
const serial = ++ detailRequestSerial
2026-08-04 13:32:08 +08:00
loading . value = true
loadError . value = null
clearPreviews ()
2026-08-07 20:07:37 +08:00
customer . value = null
qualifications . value = []
members . value = []
orderCount . value = null
customerOrders . value = []
customerOrderTotal . value = 0
customerOrdersError . value = null
2026-08-04 13:32:08 +08:00
try {
2026-08-07 09:06:22 +08:00
const [ tenantResult , qualificationResult , memberResult ] = await Promise . all ([
2026-08-07 20:07:37 +08:00
getPlatformTenant ( requestedTenantId ),
listTenantQualifications ( requestedTenantId ),
listPlatformTenantMembers ( requestedTenantId ),
2026-08-04 13:32:08 +08:00
])
2026-08-07 20:07:37 +08:00
if ( disposed || serial !== detailRequestSerial || requestedTenantId !== tenantId . value ) return
2026-08-04 13:32:08 +08:00
customer . value = tenantResult
qualifications . value = qualificationResult
2026-08-07 09:06:22 +08:00
members . value = memberResult
2026-08-04 13:32:08 +08:00
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' )) {
2026-08-07 18:15:56 +08:00
if ( orderPage . value !== 1 ) skipNextOrderPageLoad = true
2026-08-07 18:03:59 +08:00
orderPage . value = 1
2026-08-07 20:07:37 +08:00
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 ))
2026-08-07 18:03:59 +08:00
} else {
customerOrdersLoading . value = false
customerOrdersError . value = null
customerOrders . value = []
customerOrderTotal . value = 0
2026-08-04 13:32:08 +08:00
}
await Promise . all ( optional )
} catch ( error ) {
2026-08-07 20:07:37 +08:00
if ( disposed || serial !== detailRequestSerial || requestedTenantId !== tenantId . value ) return
2026-08-04 13:32:08 +08:00
customer . value = null
qualifications . value = []
2026-08-07 09:06:22 +08:00
members . value = []
2026-08-04 13:32:08 +08:00
loadError . value = getApiErrorDetails ( error , '客户详情加载失败,请稍后重试' )
2026-08-07 20:07:37 +08:00
} finally {
if ( ! disposed && serial === detailRequestSerial ) loading . value = false
}
2026-08-04 13:32:08 +08:00
}
2026-08-07 18:03:59 +08:00
function openOrder ( order : PlatformFirstLegOrderResource ) {
void router . push ({ name : 'unified-order-detail' , params : { orderId : String ( order . id ) }, query : { tenantId : tenantId . value } })
}
2026-08-04 13:32:08 +08:00
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 )
}
2026-08-07 20:07:37 +08:00
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 ()
})
2026-08-07 18:03:59 +08:00
watch ( orderPage , ( page , previousPage ) => {
2026-08-07 18:15:56 +08:00
if ( skipNextOrderPageLoad ) {
skipNextOrderPageLoad = false
return
}
2026-08-07 18:03:59 +08:00
if ( page !== previousPage ) void loadCustomerOrders ( page )
})
2026-08-04 13:32:08 +08:00
</ 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
-->
2026-08-10 16:24:54 +08:00
< 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 >
2026-08-04 13:32:08 +08:00
2026-08-10 16:24:54 +08:00
< DetailPageLayout v-else v-loading = "loading" aria-label="客户详情" >
< template # header >
< header class = "detail-header" >
2026-08-04 13:32:08 +08:00
< 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 >
2026-08-10 16:24:54 +08:00
< 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 >
2026-08-04 13:32:08 +08:00
2026-08-10 16:24:54 +08:00
< template # summary >
2026-08-04 13:32:08 +08:00
< 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 >
2026-08-07 09:06:22 +08:00
< 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 >
2026-08-04 13:32:08 +08:00
< 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 >
2026-08-10 16:24:54 +08:00
</ template >
2026-08-04 13:32:08 +08:00
2026-08-10 16:24:54 +08:00
< div ref = "tabList" class = "detail-tabs" role = "tablist" aria -label =" 客户详情模块 " >
2026-08-04 13:32:08 +08:00
< 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" >
2026-08-11 19:59:15 +08:00
< div class = "panel-title" >< div >< h2 > 企业用户 </ h2 >< p > 当前客户的登录账号 </ p ></ div >< UsersRound :size = "19" /></ div >
2026-08-07 09:06:22 +08:00
< 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 >
2026-08-04 13:32:08 +08:00
</ 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 >
2026-08-07 09:06:22 +08:00
< 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 >
2026-08-04 13:32:08 +08:00
< div class = "contract-note" >< strong > 趋势 、 港口与收入分析暂未接入 </ strong >< p > 原型中的近 6 个月趋势 、 运输方式分布 、 目的港和收入贡献依赖后续统计与财务服务 ; 当前不展示模拟图表 。 </ p ></ div >
</ section >
< section v-else-if = "activeTab === 'orders'" class="detail-panel module-panel" >
2026-08-07 18:03:59 +08:00
< 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 >
2026-08-04 13:32:08 +08:00
</ section >
2026-08-08 13:32:27 +08:00
< 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 >
2026-08-04 13:32:08 +08:00
</ div >
2026-08-10 16:24:54 +08:00
</ DetailPageLayout >
2026-08-04 13:32:08 +08:00
2026-08-10 16:24:54 +08:00
< AppDialog v-model = "previewOpen" :title="previewName" width="min(920px, calc(100vw - 28px))" append-to-body :show-footer="false" >
2026-08-04 13:32:08 +08:00
< div class = "qualification-preview" >< img v-if = "previewKind === 'image'" :src="previewUrl" :alt="`${previewName}预览`" /><iframe v-else :src="previewUrl" :title="`${previewName}预览`" /></ div >
2026-08-10 16:24:54 +08:00
</ AppDialog >
</ AdminPage >
2026-08-04 13:32:08 +08:00
</ template >
< style scoped >
. customer - detail - page { min - width : 0 ; }
2026-08-10 16:24:54 +08:00
. detail - header { display : flex ; min - width : 0 ; align - items : center ; gap : 12 px ; }
2026-08-04 13:32:08 +08:00
. back - button { flex : 0 0 auto ; }
. company - mark { display : grid ; width : 44 px ; height : 44 px ; flex : 0 0 auto ; place - items : center ; border - radius : 12 px ; background : # eef1ff ; color : var ( -- cc - primary ); }
2026-08-10 16:24:54 +08:00
. detail - page - toolbar { min - width : 0 ; flex : 1 ; }
2026-08-04 13:32:08 +08:00
. header - actions { display : flex ; flex : 0 0 auto ; gap : 8 px ; }
. header - actions : deep (. el - button + . el - button ) { margin - left : 0 ; }
2026-08-10 16:24:54 +08:00
. overview - strip { display : grid ; grid - template - columns : repeat ( 3 , minmax ( 0 , 1 fr )); overflow : hidden ; border - radius : 14 px ; background : # fff ; box - shadow : 0 8 px 22 px rgba ( 31 , 43 , 74 , .08 ); }
2026-08-04 13:32:08 +08:00
. overview - strip article { display : flex ; min - width : 0 ; align - items : center ; gap : 14 px ; padding : 22 px 24 px ; border - right : 1 px solid var ( -- cc - border ); }
. overview - strip article : last - child { border - right : 0 ; }
. metric - icon { display : grid ; width : 44 px ; height : 44 px ; flex : 0 0 auto ; place - items : center ; border - radius : 12 px ; }
. metric - icon . is - blue { background : # eef1ff ; color : # 596 fde ; }. 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 : 12 px ; }
. overview - strip strong { display : block ; margin : 5 px 0 4 px ; color : # 172033 ; font - size : 26 px ; line - height : 1 ; }
. overview - strip small { margin - left : 5 px ; color : # 475467 ; font - size : 12 px ; font - weight : 600 ; }
2026-08-11 19:59:15 +08:00
. detail - tabs { display : grid ; grid - template - columns : repeat ( 4 , minmax ( 0 , 1 fr )); overflow - x : auto ; border - radius : 14 px 14 px 0 0 ; background : # fff ; box - shadow : 0 8 px 22 px rgba ( 31 , 43 , 74 , .06 ); }
2026-08-04 13:32:08 +08:00
. detail - tabs button { position : relative ; display : flex ; min - width : 128 px ; min - height : 64 px ; align - items : center ; justify - content : center ; gap : 8 px ; padding : 0 14 px ; border : 0 ; background : transparent ; color : # 667085 ; font - weight : 650 ; cursor : pointer ; }
. detail - tabs button :: after { position : absolute ; right : 0 ; bottom : 0 ; left : 0 ; height : 3 px ; 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 : 400 px ; padding - top : 18 px ; }
. basic - layout { display : grid ; grid - template - columns : minmax ( 0 , 1.1 fr ) minmax ( 0 , .9 fr ); gap : 18 px ; align - items : start ; }
. detail - panel { min - width : 0 ; padding : 22 px ; border - radius : 14 px ; background : # fff ; box - shadow : 0 8 px 22 px 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 : 16 px ; margin - bottom : 18 px ; padding - bottom : 14 px ; border - bottom : 1 px solid var ( -- cc - border ); }
. panel - title h2 { margin : 0 ; color : # 344054 ; font - size : 16 px ; }. panel - title p { margin : 5 px 0 0 ; color : var ( -- cc - muted ); font - size : 11 px ; }. panel - title > svg { color : var ( -- cc - primary ); }
. info - grid { display : grid ; grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr )); gap : 10 px ; margin : 0 ; }
. info - grid > div { min - width : 0 ; padding : 12 px 14 px ; border - radius : 10 px ; background : # f8f9fc ; }. info - grid > div . wide { grid - column : 1 / - 1 ; }
. info - grid dt { margin - bottom : 5 px ; color : var ( -- cc - muted ); font - size : 11 px ; }. info - grid dd { margin : 0 ; color : # 25324 a ; font - size : 13 px ; font - weight : 650 ; line - height : 1.55 ; overflow - wrap : anywhere ; }
. qualification - list { display : flex ; flex - direction : column ; gap : 8 px ; }
. qualification - list button { display : grid ; width : 100 % ; grid - template - columns : 38 px minmax ( 0 , 1 fr ) auto auto 20 px ; align - items : center ; gap : 10 px ; padding : 10 px ; border : 1 px solid var ( -- cc - border ); border - radius : 10 px ; 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 : 38 px ; height : 38 px ; place - items : center ; border - radius : 9 px ; background : # eef1ff ; color : var ( -- cc - primary ); }
. qualification - list strong ,. qualification - list small { display : block ; }. qualification - list strong { font - size : 12 px ; }. qualification - list small ,. file - date { margin - top : 3 px ; color : var ( -- cc - muted ); font - size : 10 px ; }. file - date { margin : 0 ; white - space : nowrap ; }
. section - empty ,. module - empty { display : flex ; min - height : 150 px ; align - items : center ; justify - content : center ; flex - direction : column ; gap : 7 px ; color : # 98 a2b3 ; text - align : center ; }
. section - empty strong ,. module - empty strong { color : # 475467 ; font - size : 13 px ; }. section - empty span ,. module - empty p { max - width : 600 px ; margin : 0 ; color : var ( -- cc - muted ); font - size : 11 px ; 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 : 720 px ; border - collapse : collapse ; font - size : 12 px ; } th { padding : 10 px 12 px ; background : # f8fafc ; color : # 667085 ; text - align : left ; font - weight : 650 ; } td { padding : 12 px ; border - top : 1 px solid # edf0f4 ; color : # 344054 ; }
. user - identity { display : flex ; align - items : center ; gap : 9 px ; }. user - identity > span { display : grid ; width : 34 px ; height : 34 px ; 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 : 3 px ; color : var ( -- cc - muted ); font - size : 10 px ; }. role - list { display : flex ; flex - wrap : wrap ; gap : 5 px ; }
2026-08-07 09:06:22 +08:00
. member - contact span ,. member - contact small { display : block ; }. member - contact small { margin - top : 3 px ; color : var ( -- cc - muted ); font - size : 10 px ; }
2026-08-04 13:32:08 +08:00
. module - panel { min - height : 360 px ; }. module - empty { min - height : 260 px ; }. module - empty > svg { color : # 8797 b2 ; }. module - empty p { max - width : 520 px ; font - size : 12 px ; }
. business - summary { display : grid ; grid - template - columns : repeat ( 3 , minmax ( 0 , 1 fr )); overflow : hidden ; border : 1 px solid var ( -- cc - border ); border - radius : 12 px ; }. business - summary > div { display : grid ; min - height : 130 px ; place - items : center ; align - content : center ; gap : 8 px ; border - right : 1 px 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 : 11 px ; }. business - summary strong { color : # 25324 a ; font - size : 20 px ; }
. contract - note { margin - top : 18 px ; padding : 16 px 18 px ; border - radius : 10 px ; background : # f8fafc ; }. contract - note strong { color : # 344054 ; font - size : 12 px ; }. contract - note p { margin : 5 px 0 0 ; color : var ( -- cc - muted ); font - size : 11 px ; line - height : 1.7 ; }
. qualification - preview { display : grid ; min - height : 320 px ; max - height : 72 vh ; overflow : auto ; place - items : center ; border - radius : 10 px ; background : # f8fafc ; }. qualification - preview img { display : block ; max - width : 100 % ; height : auto ; }. qualification - preview iframe { width : 100 % ; min - height : 68 vh ; border : 0 ; background : # fff ; }
2026-08-11 19:59:15 +08:00
@ media ( max - width : 960 px ) { . basic - layout { grid - template - columns : 1 fr ; }. contact - panel ,. user - panel { grid - column : auto ; }. detail - tabs { grid - template - columns : repeat ( 4 , 150 px ); }. qualification - panel { order : 3 ; }. user - panel { order : 4 ; } }
2026-08-10 16:24:54 +08:00
@ media ( max - width : 720 px ) { . detail - header { align - items : flex - start ; flex - wrap : wrap ; }. detail - page - toolbar { width : 100 % ; flex - basis : 100 % ; }. company - mark { width : 40 px ; height : 40 px ; }. header - actions { display : grid ; grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr )); width : 100 % ; }. header - actions : deep (. el - tag ) { grid - column : 1 / - 1 ; justify - self : start ; }. header - actions : deep (. el - button ) { min - height : 44 px ; }. overview - strip { grid - template - columns : 1 fr ; }. overview - strip article { border - right : 0 ; border - bottom : 1 px solid var ( -- cc - border ); }. overview - strip article : last - child { border - bottom : 0 ; }. detail - panel { padding : 16 px ; }. info - grid { grid - template - columns : 1 fr ; }. info - grid > div . wide { grid - column : auto ; }. business - summary { grid - template - columns : 1 fr ; }. business - summary > div { min - height : 100 px ; border - right : 0 ; border - bottom : 1 px solid var ( -- cc - border ); }. business - summary > div : last - child { border - bottom : 0 ; } }
@ media ( max - width : 420 px ) { . company - mark { display : none ; }. header - actions { grid - template - columns : 1 fr ; }. header - actions : deep (. el - tag ) { grid - column : auto ; }. qualification - list button { grid - template - columns : 36 px minmax ( 0 , 1 fr ) auto ; }. qualification - list . file - date ,. qualification - list button > svg { display : none ; } }
2026-08-04 13:32:08 +08:00
</ style >