Release/test 合并上生产 #2
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -4,10 +4,10 @@ import { computed } from 'vue'
|
||||
const props = defineProps<{ status: string }>()
|
||||
|
||||
const tone = computed(() => {
|
||||
if (['正常', '启用', '已完成'].includes(props.status)) return 'success'
|
||||
if (['待审核', '草稿', '待处理'].includes(props.status)) return 'warning'
|
||||
if (['已受理', '已订舱', '运输中', '处理中'].includes(props.status)) return 'info'
|
||||
if (['停用', '已取消', '已驳回', '失败'].includes(props.status)) return 'danger'
|
||||
if (['正常', '启用', '已完成', '已审核', '审核通过', '已入库', '装柜完成', '派送完成'].includes(props.status)) return 'success'
|
||||
if (['待审核', '草稿', '待处理', '未提交', '未录入', '待测量数据', '待分柜'].includes(props.status)) return 'warning'
|
||||
if (['已受理', '已订舱', '运输中', '处理中', '装柜中'].includes(props.status)) return 'info'
|
||||
if (['停用', '已取消', '已驳回', '审核驳回', '失败'].includes(props.status)) return 'danger'
|
||||
return 'neutral'
|
||||
})
|
||||
</script>
|
||||
@@ -15,4 +15,3 @@ const tone = computed(() => {
|
||||
<template>
|
||||
<span class="status-badge" :class="`is-${tone}`"><i aria-hidden="true" />{{ status }}</span>
|
||||
</template>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user