This commit is contained in:
2026-08-07 09:06:22 +08:00
parent e5f02c2530
commit 2449c07e5e
5 changed files with 74 additions and 18 deletions
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
downloadTenantQualification,
getPlatformTenant,
listPlatformTenantMembers,
listPlatformTenantOrderCounts,
listTenantQualifications,
type PlatformTenantResource,
@@ -25,6 +26,7 @@ vi.mock('@/modules/iam', () => ({
changePlatformTenantStatus: vi.fn(),
downloadTenantQualification: vi.fn(),
getPlatformTenant: vi.fn(),
listPlatformTenantMembers: vi.fn(),
listPlatformTenantOrderCounts: vi.fn(),
listTenantQualifications: vi.fn(),
qualificationFileName: vi.fn((documentUrl: string) => documentUrl.split('/').at(-1) || '资质文件'),
@@ -68,19 +70,26 @@ describe('CustomerDetailView', () => {
id: 'qualification-1', tenantId: tenant.id, qualificationType: 'BUSINESS_LICENSE', certificateNo: tenant.unifiedSocialCreditCode,
documentUrl: '/files/license.png', status: 'SUPERSEDED', reviewComment: '已替换', validTo: null,
}])
vi.mocked(listPlatformTenantMembers).mockResolvedValue([{
membershipId: 'member-1', userId: 'user-1', authUserId: 'auth-1', username: 'merchant_admin', displayName: '张三',
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(downloadTenantQualification).mockResolvedValue(new Blob(['image'], { type: 'image/png' }))
})
it('renders all six modules without inventing unavailable tenant users', async () => {
it('renders enterprise users and includes the owner in customer statistics', async () => {
mount(CustomerDetailView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
expect([...document.querySelectorAll('.detail-tabs button')].map((button) => button.textContent?.trim())).toEqual([
'基本信息', '业务概况', '运单记录', '财务账单', '操作日志', '计费规则',
])
expect(document.body.textContent).toContain('企业成员数据暂不可用')
expect(document.body.textContent).not.toContain('0 人正常使用')
expect(document.body.textContent).toContain('企业用户1人')
expect(document.body.textContent).toContain('1 人正常使用')
expect(document.body.textContent).toContain('merchant_admin')
expect(document.body.textContent).toContain('企业管理员')
expect(listPlatformTenantMembers).toHaveBeenCalledWith(tenant.id)
expect(document.body.textContent).toContain('已替换')
expect(document.body.textContent).toContain('12票')
@@ -11,10 +11,12 @@ import {
changePlatformTenantStatus,
downloadTenantQualification,
getPlatformTenant,
listPlatformTenantMembers,
listPlatformTenantOrderCounts,
listTenantQualifications,
qualificationFileName,
type PlatformTenantQualification,
type PlatformTenantMember,
type PlatformTenantResource,
type PlatformTenantStatus,
} from '@/modules/iam'
@@ -36,6 +38,7 @@ 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 previews = ref<Record<string, PreviewState>>({})
const previewOpen = ref(false)
@@ -54,6 +57,8 @@ 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)
function displayText(value?: string | null) { return value?.trim() || '—' }
function formatDate(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
@@ -126,12 +131,14 @@ async function loadDetail() {
loadError.value = null
clearPreviews()
try {
const [tenantResult, qualificationResult] = await Promise.all([
const [tenantResult, qualificationResult, memberResult] = await Promise.all([
getPlatformTenant(tenantId.value),
listTenantQualifications(tenantId.value),
listPlatformTenantMembers(tenantId.value),
])
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]))
@@ -146,6 +153,7 @@ async function loadDetail() {
} catch (error) {
customer.value = null
qualifications.value = []
members.value = []
loadError.value = getApiErrorDetails(error, '客户详情加载失败,请稍后重试')
} finally { loading.value = false }
}
@@ -219,7 +227,7 @@ onBeforeUnmount(clearPreviews)
<div v-loading="loading" class="detail-content">
<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>—<small>人</small></strong><span>企业成员接口待接入</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>
@@ -263,14 +271,25 @@ onBeforeUnmount(clearPreviews)
</section>
<section class="detail-panel user-panel">
<div class="panel-title"><div><h2>企业用户</h2><p>当前客户租户下的登录账号</p></div><UsersRound :size="19" /></div>
<div class="section-empty"><UsersRound :size="22" /><strong>企业成员数据暂不可用</strong><span>当前平台用户接口仅返回平台内部账号,不能据此判断该客户是否已有企业用户</span></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>— 人</strong></div><div><FileCheck2 :size="20" /><span>有效资质</span><strong>{{ approvedQualifications }} 份</strong></div></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>
@@ -338,6 +357,7 @@ onBeforeUnmount(clearPreviews)
.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; }
+19
View File
@@ -101,6 +101,21 @@ export interface PlatformTenantQualification {
validTo?: string | null
}
export interface PlatformTenantMember {
membershipId: number | string
userId: number | string
authUserId: number | string
username?: string | null
displayName: string | null
phone: string | null
email: string | null
memberType: string
memberNo: string | null
status: 'ACTIVE' | 'DISABLED' | string
roleIds?: Array<number | string>
roleNames?: string[]
}
export type PlatformQualificationType = 'BUSINESS_LICENSE' | 'LEGAL_PERSON_ID' | 'FREIGHT_FORWARDER_CERTIFICATE' | 'BANK_ACCOUNT_LICENSE'
export interface PlatformQualificationUploadResponse {
@@ -378,6 +393,10 @@ export function listTenantQualifications(tenantId: number | string) {
return getData<PlatformTenantQualification[]>(`/platform/v1/iam/tenants/${tenantId}/qualifications`)
}
export function listPlatformTenantMembers(tenantId: number | string) {
return getData<PlatformTenantMember[]>(`/platform/v1/iam/tenants/${tenantId}/members`)
}
export function qualificationFileName(documentUrl: string) {
const path = documentUrl.split(/[?#]/, 1)[0]
const encodedName = path.split('/').filter(Boolean).at(-1)
@@ -164,8 +164,8 @@ const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' },
{ key: 'selection', type: 'selection', width: 52, fixed: 'left', align: 'center', selectable: canBulkReview },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140, slotName: 'field' },
{ key: 'internalOrderStatus', prop: 'internalOrderStatus', label: '订单状态', minWidth: 130, slotName: 'field' },
{ key: 'measurementReviewStatus', prop: 'measurementReviewStatus', label: '测量数据审核状态', minWidth: 170, slotName: 'field' },
{ key: 'internalOrderStatus', prop: 'internalOrderStatus', label: '订单状态', minWidth: 130, slotName: 'orderStatus' },
{ key: 'measurementReviewStatus', prop: 'measurementReviewStatus', label: '测量数据审核状态', minWidth: 170, slotName: 'measurementReviewStatus' },
{ key: 'expectedArrivalAt', prop: 'expectedArrivalAt', label: '预计到达时间', minWidth: 150, slotName: 'field' },
{ key: 'feeReviewStatus', prop: 'feeReviewStatus', label: '费用审核状态', minWidth: 140, slotName: 'field' },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150, slotName: 'field' },
@@ -292,9 +292,7 @@ function displayField(row: UnifiedOrderResource, property: string | undefined) {
if (typeof value === 'number') return formatNumber(value)
if (property.endsWith('At') || property.endsWith('Date')) return formatDate(String(value))
if (property === 'measurementReviewStatus') return measurementStatusLabel(String(value))
if (property === 'internalOrderStatus') {
return ({ SUBMITTED: '待审核', APPROVED: '已审核', ACCEPTED: '已受理', PROCESSING: '处理中', COMPLETED: '已完成', CANCELLED: '已取消' } as Record<string, string>)[String(value)] || String(value)
}
if (property === 'internalOrderStatus') return orderStatusLabel(String(value))
return String(value)
}
@@ -303,7 +301,16 @@ function statusLabel(value: UnifiedOrderMainStatus | string | null) {
}
function measurementStatusLabel(value: string | null | undefined) {
return ({ UNRECORDED: '未录入', PENDING: '待审核', APPROVED: '审核通过', REJECTED: '审核驳回' } as Record<string, string>)[value || ''] || value || '未录入'
return ({ NOT_SUBMITTED: '未提交', UNRECORDED: '未录入', PENDING: '待审核', APPROVED: '审核通过', REJECTED: '审核驳回' } as Record<string, string>)[value || ''] || value || '未录入'
}
function orderStatusLabel(value: string | null | undefined) {
return ({
DRAFT: '草稿', SUBMITTED: '待审核', APPROVED: '已审核', ACCEPTED: '已受理', REJECTED: '已驳回',
PROCESSING: '处理中', COMPLETED: '已完成', CANCELLED: '已取消', PENDING_REVIEW: '待审核',
WAIT_MEASURE: '待测量数据', INBOUNDED: '已入库', WAIT_SPLIT: '待分柜', LOADING: '装柜中',
LOADED: '装柜完成', DELIVERED: '派送完成',
} as Record<string, string>)[value || ''] || value || '—'
}
function nextActionBoundary(row: UnifiedOrderResource) {
@@ -731,6 +738,8 @@ onMounted(() => {
<span v-if="nextActionBoundary(row as UnifiedOrderResource)" class="action-boundary">{{ nextActionBoundary(row as UnifiedOrderResource) }}</span>
</div>
</template>
<template #orderStatus="{ row }"><StatusBadge :status="orderStatusLabel((row as UnifiedOrderResource).internalOrderStatus || (row as UnifiedOrderResource).mainStatus)" /></template>
<template #measurementReviewStatus="{ row }"><StatusBadge :status="measurementStatusLabel((row as UnifiedOrderResource).measurement?.reviewStatus)" /></template>
<template #field="{ row, column }">{{ displayField(row as UnifiedOrderResource, column.property) }}</template>
<template #order="{ row }"><div class="primary-cell"><strong>{{ (row as UnifiedOrderResource).orderNo || `订单 ${(row as UnifiedOrderResource).orderId}` }}</strong><span v-if="(row as UnifiedOrderResource).inboundCode">入仓码 {{ (row as UnifiedOrderResource).inboundCode }}</span></div></template>
<template #mainStatus="{ row }"><StatusBadge :status="statusLabel((row as UnifiedOrderResource).mainStatus)" /></template>