diff --git a/src/modules/customers/pages/CustomerDetailView.test.ts b/src/modules/customers/pages/CustomerDetailView.test.ts index de7b5ed4..2c9d27e6 100644 --- a/src/modules/customers/pages/CustomerDetailView.test.ts +++ b/src/modules/customers/pages/CustomerDetailView.test.ts @@ -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票') diff --git a/src/modules/customers/pages/CustomerDetailView.vue b/src/modules/customers/pages/CustomerDetailView.vue index 4f446371..675cc246 100644 --- a/src/modules/customers/pages/CustomerDetailView.vue +++ b/src/modules/customers/pages/CustomerDetailView.vue @@ -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(null) const customer = ref(null) const qualifications = ref([]) +const members = ref([]) const orderCount = ref(null) const previews = ref>({}) 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)

累计运单

{{ orderCount ?? '—' }}票{{ access.can('platform:order:list') ? '来自头程订单服务' : '暂无运单查看权限' }}
-

企业用户

—人企业成员接口待接入
+

企业用户

{{ enterpriseMembers.length }}人{{ activeEnterpriseMembers }} 人正常使用

资质文件

{{ qualifications.length }}份{{ approvedQualifications }} 份已通过
@@ -263,14 +271,25 @@ onBeforeUnmount(clearPreviews)

企业用户

当前客户租户下的登录账号

-
企业成员数据暂不可用当前平台用户接口仅返回平台内部账号,不能据此判断该客户是否已有企业用户
+
+ + + + + + + + +
账号成员类型联系方式角色状态
{{ (member.displayName || member.username || '?').slice(0, 1) }}
{{ displayText(member.username) }}{{ displayText(member.displayName) }}
{{ member.memberType === 'OWNER' ? '企业管理员' : '企业员工' }}
{{ displayText(member.phone) }}{{ displayText(member.email) }}
{{ role }}
—
{{ member.status === 'ACTIVE' ? '启用' : '停用' }}
+
+
暂无企业用户当前客户尚未创建企业成员账号

业务概况

基于当前已接入服务的客户业务摘要

-
累计运单{{ orderCount ?? '—' }} 票
活跃企业用户— 人
有效资质{{ approvedQualifications }} 份
+
累计运单{{ orderCount ?? '—' }} 票
活跃企业用户{{ activeEnterpriseMembers }} 人
有效资质{{ approvedQualifications }} 份
趋势、港口与收入分析暂未接入

原型中的近 6 个月趋势、运输方式分布、目的港和收入贡献依赖后续统计与财务服务;当前不展示模拟图表。

@@ -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; } diff --git a/src/modules/iam/api/platformIam.ts b/src/modules/iam/api/platformIam.ts index 1464289f..aae4028d 100644 --- a/src/modules/iam/api/platformIam.ts +++ b/src/modules/iam/api/platformIam.ts @@ -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 + 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(`/platform/v1/iam/tenants/${tenantId}/qualifications`) } +export function listPlatformTenantMembers(tenantId: number | string) { + return getData(`/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) diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.vue b/src/modules/unified-orders/pages/UnifiedOrdersView.vue index 0536d4e7..48c1f37d 100644 --- a/src/modules/unified-orders/pages/UnifiedOrdersView.vue +++ b/src/modules/unified-orders/pages/UnifiedOrdersView.vue @@ -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(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)[value || ''] || value || '未录入' + return ({ NOT_SUBMITTED: '未提交', UNRECORDED: '未录入', PENDING: '待审核', APPROVED: '审核通过', REJECTED: '审核驳回' } as Record)[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)[value || ''] || value || '—' } function nextActionBoundary(row: UnifiedOrderResource) { @@ -731,6 +738,8 @@ onMounted(() => { {{ nextActionBoundary(row as UnifiedOrderResource) }} + + diff --git a/src/shared/components/StatusBadge.vue b/src/shared/components/StatusBadge.vue index 1fa0a4e1..09991de3 100644 --- a/src/shared/components/StatusBadge.vue +++ b/src/shared/components/StatusBadge.vue @@ -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' }) @@ -15,4 +15,3 @@ const tone = computed(() => { -