This commit is contained in:
2026-08-13 12:22:41 +08:00
parent 835ac0ad12
commit 8a8c4fa190
26 changed files with 572 additions and 148 deletions
@@ -0,0 +1,26 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import CustomerOperatingMetricCard from './CustomerOperatingMetricCard.vue'
describe('CustomerOperatingMetricCard', () => {
const props = { title: '累计应收', value: '12.3456', unit: '万元', detail: '未结清 0.125 万元', tone: 'green' as const }
it('keeps the supplied decimal precision', () => {
const wrapper = mount(CustomerOperatingMetricCard, { props })
expect(wrapper.text()).toContain('12.3456万元')
expect(wrapper.text()).toContain('未结清 0.125 万元')
})
it('shows a dash instead of zero for failures', () => {
const wrapper = mount(CustomerOperatingMetricCard, { props: { ...props, failed: true } })
expect(wrapper.text()).toContain('—')
expect(wrapper.text()).toContain('加载失败')
expect(wrapper.text()).not.toContain('12.3456')
})
it('shows the permission state without exposing a value', () => {
const wrapper = mount(CustomerOperatingMetricCard, { props: { ...props, unauthorized: true } })
expect(wrapper.text()).toContain('暂无查看权限')
expect(wrapper.text()).not.toContain('12.3456')
})
})
@@ -0,0 +1,41 @@
<script setup lang="ts">
defineProps<{
title: string
value: string
unit: string
detail: string
loading?: boolean
failed?: boolean
unauthorized?: boolean
tone: 'blue' | 'green' | 'amber'
}>()
</script>
<template>
<article class="operating-metric-card" :aria-label="title" :aria-busy="loading || undefined">
<span class="metric-icon" :class="`is-${tone}`" aria-hidden="true"><slot name="icon" /></span>
<div class="metric-content">
<p>{{ title }}</p>
<strong v-if="loading" class="metric-loading" aria-label="加载中">—</strong>
<strong v-else-if="failed || unauthorized">—</strong>
<strong v-else>{{ value }}<small>{{ unit }}</small></strong>
<span :class="{ 'is-error': failed }">{{ unauthorized ? '暂无查看权限' : failed ? '加载失败' : loading ? '加载中…' : detail }}</span>
</div>
</article>
</template>
<style scoped>
.operating-metric-card { display:flex; min-width:0; align-items:center; gap:14px; padding:22px 24px; border-right:1px solid var(--cc-border); }
.metric-icon { display:grid; width:44px; height:44px; flex:0 0 auto; place-items:center; border-radius:12px; }
.metric-icon.is-blue { background:linear-gradient(135deg,rgba(102,126,234,.15),rgba(118,75,162,.1)); color:#667eea; }
.metric-icon.is-green { background:linear-gradient(135deg,rgba(34,197,94,.15),rgba(22,163,74,.1)); color:#16a34a; }
.metric-icon.is-amber { background:linear-gradient(135deg,rgba(245,158,11,.15),rgba(234,88,12,.1)); color:#ea580c; }
.metric-content { min-width:0; }
p,span { margin:0; color:var(--cc-muted); font-size:12px; }
strong { display:block; min-width:0; margin:5px 0 4px; color:#172033; font-size:26px; line-height:1; overflow-wrap:anywhere; }
small { margin-left:5px; color:#475467; font-size:12px; font-weight:600; white-space:nowrap; }
.is-error { color:#d92d20; }
.metric-loading { color:#98a2b3; }
@media (max-width:720px) { .operating-metric-card { border-right:0; border-bottom:1px solid var(--cc-border); } }
@media (max-width:360px) { .operating-metric-card { gap:10px; padding:18px 14px; }.metric-icon { width:40px; height:40px; }strong { font-size:23px; } }
</style>
@@ -6,11 +6,12 @@ import {
downloadTenantQualification,
getPlatformTenant,
listPlatformTenantMembers,
listPlatformTenantOrderCounts,
listTenantQualifications,
type PlatformTenantResource,
} from '@/modules/iam'
import { listPlatformFirstLegOrders, type PlatformFirstLegOrderResource } from '@/modules/first-leg-orders'
import { getPlatformTenantOrderStatistics, listPlatformFirstLegOrders, type PlatformFirstLegOrderResource } from '@/modules/first-leg-orders'
import { getFinanceCustomerAccountSummary } from '@/modules/finance'
import { getCustomerContainerTransitSummary } from '@/modules/container-loading'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import CustomerDetailView from './CustomerDetailView.vue'
@@ -36,15 +37,22 @@ vi.mock('@/modules/iam', () => ({
downloadTenantQualification: vi.fn(),
getPlatformTenant: vi.fn(),
listPlatformTenantMembers: vi.fn(),
listPlatformTenantOrderCounts: vi.fn(),
listTenantQualifications: vi.fn(),
qualificationFileName: vi.fn((documentUrl: string) => documentUrl.split('/').at(-1) || '资质文件'),
}))
vi.mock('@/modules/first-leg-orders', () => ({
getPlatformTenantOrderStatistics: vi.fn(),
listPlatformFirstLegOrders: vi.fn(),
}))
vi.mock('@/modules/finance', () => ({
CustomerStatementsPanel: { template: '<div>财务账单</div>' },
getFinanceCustomerAccountSummary: vi.fn(),
}))
vi.mock('@/modules/container-loading', () => ({ getCustomerContainerTransitSummary: vi.fn() }))
class ResizeObserverStub implements ResizeObserver {
observe() {}
unobserve() {}
@@ -77,7 +85,7 @@ describe('CustomerDetailView', () => {
setActivePinia(pinia)
const auth = useAuthStore()
auth.token = 'token'
auth.permissions = ['platform:customer:view', 'platform:user:list', 'platform:order:list']
auth.permissions = ['platform:customer:list', 'platform:user:list', 'platform:order:list', 'platform:finance:customer-account:list', 'platform:firstleg:container:list']
useAccessPreviewStore().initializeAfterLogin()
vi.mocked(getPlatformTenant).mockResolvedValue(tenant)
vi.mocked(listTenantQualifications).mockResolvedValue([{
@@ -88,7 +96,9 @@ describe('CustomerDetailView', () => {
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(getPlatformTenantOrderStatistics).mockResolvedValue({ tenantId: String(tenant.id), totalSubmittedOrders: 12, currentMonthSubmittedOrders: 3, monthStart: '2026-08-01T00:00:00+08:00', nextMonthStart: '2026-09-01T00:00:00+08:00', zoneId: 'Asia/Shanghai', asOf: '2026-08-13T12:00:00+08:00' })
vi.mocked(getFinanceCustomerAccountSummary).mockResolvedValue({ customerId: String(tenant.id), currency: 'CNY', totalReceivable: '123456.78', receivedAmount: '100000', outstandingReceivable: '23456.78', pendingBilling: '0', outstandingPayable: '0', asOf: '2026-08-13T04:00:00Z', lastChangedAt: null })
vi.mocked(getCustomerContainerTransitSummary).mockResolvedValue({ customerId: String(tenant.id), containerCount: 2, totalTeu: '2.25', unknownContainerCount: 0, asOf: '2026-08-13T12:00:00', definition: '已开船未到仓' })
vi.mocked(listPlatformFirstLegOrders).mockResolvedValue({
records: [{
id: 'order-1', tenantId: tenant.id, orderNo: 'BY20260807001', customerId: null, customerName: tenant.tenantName,
@@ -103,20 +113,25 @@ describe('CustomerDetailView', () => {
vi.mocked(downloadTenantQualification).mockResolvedValue(new Blob(['image'], { type: 'image/png' }))
})
it('renders enterprise users and includes the owner in customer statistics', async () => {
it('renders three operating metrics and keeps enterprise users in detail content', async () => {
mount(CustomerDetailView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
expect([...document.querySelectorAll('.detail-tabs button')].map((button) => button.getAttribute('aria-label'))).toEqual([
'基本信息', '业务概况', '运单记录', '财务账单', '计费规则', '操作日志',
])
expect(document.body.textContent).toContain('企业用户1人')
expect(document.body.textContent).toContain('1 人正常使用')
expect(document.body.textContent).toContain('累计运单12票本月 3 票')
expect(document.body.textContent).toContain('累计应收12.345678万元未结清 2.345678 万元')
expect(document.body.textContent).toContain('在途柜量2.25TEU2 柜')
expect([...document.querySelectorAll('.overview-strip article')]).toHaveLength(3)
expect(document.querySelector('.overview-strip')?.textContent).not.toContain('企业用户')
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票')
expect(getPlatformTenantOrderStatistics).toHaveBeenCalledWith(tenant.id)
expect(getFinanceCustomerAccountSummary).toHaveBeenCalledWith(tenant.id)
expect(getCustomerContainerTransitSummary).toHaveBeenCalledWith(tenant.id)
const ordersTab = document.querySelector<HTMLButtonElement>('.detail-tabs button[aria-label="运单记录"]')
ordersTab?.click()
@@ -190,7 +205,7 @@ describe('CustomerDetailView', () => {
vi.mocked(getPlatformTenant).mockImplementation((tenantId) => tenantId === '8' ? firstTenantRequest : Promise.resolve(secondTenant))
vi.mocked(listTenantQualifications).mockResolvedValue([])
vi.mocked(listPlatformTenantMembers).mockResolvedValue([])
vi.mocked(listPlatformTenantOrderCounts).mockImplementation(async (tenantIds) => [{ tenantId: tenantIds[0], orderCount: 0 }])
vi.mocked(getPlatformTenantOrderStatistics).mockImplementation(async (tenantId) => ({ tenantId, totalSubmittedOrders: 0, currentMonthSubmittedOrders: 0, monthStart: '2026-08-01', nextMonthStart: '2026-09-01', zoneId: 'Asia/Shanghai', asOf: '2026-08-13' }))
vi.mocked(listPlatformFirstLegOrders).mockResolvedValue({ records: [], page: 1, size: 10, total: 0, pages: 0 })
mount(CustomerDetailView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
@@ -206,4 +221,31 @@ describe('CustomerDetailView', () => {
expect(document.body.textContent).toContain('远航供应链')
expect(document.body.textContent).not.toContain('柏悦物流')
})
it('does not request metrics without their independent permissions', async () => {
const auth = useAuthStore()
auth.permissions = ['platform:customer:list', 'platform:user:list']
useAccessPreviewStore().initializeAfterLogin()
mount(CustomerDetailView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
expect(getPlatformTenantOrderStatistics).not.toHaveBeenCalled()
expect(getFinanceCustomerAccountSummary).not.toHaveBeenCalled()
expect(getCustomerContainerTransitSummary).not.toHaveBeenCalled()
expect(document.querySelector('.overview-strip')?.textContent?.match(/暂无查看权限/g)).toHaveLength(3)
})
it('keeps metric failures independent and never renders them as zero', async () => {
vi.mocked(getFinanceCustomerAccountSummary).mockRejectedValueOnce(new Error('finance unavailable'))
mount(CustomerDetailView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
const cards = [...document.querySelectorAll('.overview-strip article')]
expect(cards[0]?.textContent).toContain('12票')
expect(cards[1]?.textContent).toContain('—加载失败')
expect(cards[1]?.textContent).not.toContain('0万元')
expect(cards[2]?.textContent).toContain('2.25TEU')
})
})
@@ -3,7 +3,7 @@ import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
ArrowLeft, BarChart3, Building2, Eye,
FileCheck2, FileText, Pencil, Power,
CircleDollarSign, Container, FileCheck2, FileText, Pencil, Power,
Ship, UserRound, UsersRound,
} from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
@@ -12,7 +12,6 @@ import {
downloadTenantQualification,
getPlatformTenant,
listPlatformTenantMembers,
listPlatformTenantOrderCounts,
listTenantQualifications,
qualificationFileName,
type PlatformTenantQualification,
@@ -21,10 +20,13 @@ import {
type PlatformTenantStatus,
} from '@/modules/iam'
import {
getPlatformTenantOrderStatistics,
listPlatformFirstLegOrders,
type PlatformFirstLegOrderResource,
type PlatformFirstLegOrderStatus,
} from '@/modules/first-leg-orders'
import { getFinanceCustomerAccountSummary } from '@/modules/finance'
import { getCustomerContainerTransitSummary } from '@/modules/container-loading'
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'
@@ -39,9 +41,11 @@ import CustomerBillingRulesPanel from '@/modules/customers/components/CustomerBi
import CustomerOverseasStorageFeePanel from '@/modules/customers/CustomerOverseasStorageFeePanel.vue'
import { CustomerStatementsPanel } from '@/modules/finance'
import { CustomerAuditLogsPanel } from '@/modules/audit'
import CustomerOperatingMetricCard from '@/modules/customers/components/CustomerOperatingMetricCard.vue'
type DetailTab = 'basic' | 'business' | 'orders' | 'statements' | 'billing' | 'logs'
type PreviewState = { url: string; kind: 'image' | 'pdf'; loading: boolean; failed: boolean }
type MetricState<T> = { loading: boolean; failed: boolean; data: T | null }
const route = useRoute()
const router = useRouter()
@@ -54,7 +58,9 @@ 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 orderStatistics = ref<MetricState<Awaited<ReturnType<typeof getPlatformTenantOrderStatistics>>>>({ loading: false, failed: false, data: null })
const financeStatistics = ref<MetricState<Awaited<ReturnType<typeof getFinanceCustomerAccountSummary>>>>({ loading: false, failed: false, data: null })
const transitStatistics = ref<MetricState<Awaited<ReturnType<typeof getCustomerContainerTransitSummary>>>>({ loading: false, failed: false, data: null })
const customerOrders = ref<PlatformFirstLegOrderResource[]>([])
const customerOrderTotal = ref(0)
const orderPage = ref(1)
@@ -81,9 +87,7 @@ const tabs: Array<{ key: DetailTab; label: string; icon: string }> = [
{ key: 'logs', label: '操作日志', icon: '📝' },
]
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' },
@@ -134,6 +138,25 @@ function transportLabel(order: PlatformFirstLegOrderResource) {
return '陆运'
}
function formattedDate(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
function decimalText(value: number | string | null | undefined) {
const raw = String(value ?? '').trim()
if (!/^-?\d+(\.\d+)?$/.test(raw)) return '—'
const normalized = raw.replace(/^(-?)0+(?=\d)/, '$1').replace(/\.0+$/, '').replace(/(\.\d*?)0+$/, '$1')
return normalized === '-0' ? '0' : normalized
}
function cnyWan(value: number | string | null | undefined) {
const raw = decimalText(value)
if (raw === '—') return raw
const negative = raw.startsWith('-')
const [integer, fraction = ''] = (negative ? raw.slice(1) : raw).split('.')
const digits = `${integer}${fraction}`.replace(/^0+(?=\d)/, '') || '0'
const scale = fraction.length + 4
const padded = digits.padStart(scale + 1, '0')
const whole = padded.slice(0, -scale) || '0'
const decimals = padded.slice(-scale).replace(/0+$/, '')
const result = `${negative && digits !== '0' ? '-' : ''}${whole}${decimals ? `.${decimals}` : ''}`
return result
}
async function loadCustomerOrders(page = orderPage.value, requestedTenantId = tenantId.value) {
if (!access.can('platform:order:list')) return
@@ -145,7 +168,6 @@ async function loadCustomerOrders(page = orderPage.value, requestedTenantId = te
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 = []
@@ -155,6 +177,30 @@ async function loadCustomerOrders(page = orderPage.value, requestedTenantId = te
}
}
async function loadOperatingStatistics(requestedTenantId: string, serial: number) {
const tasks: Promise<void>[] = []
const active = () => !disposed && serial === detailRequestSerial && requestedTenantId === tenantId.value
if (access.can('platform:order:list')) {
orderStatistics.value = { loading: true, failed: false, data: null }
tasks.push(getPlatformTenantOrderStatistics(requestedTenantId).then((data) => {
if (active()) orderStatistics.value = { loading: false, failed: false, data }
}).catch(() => { if (active()) orderStatistics.value = { loading: false, failed: true, data: null } }))
}
if (access.can('platform:finance:customer-account:list')) {
financeStatistics.value = { loading: true, failed: false, data: null }
tasks.push(getFinanceCustomerAccountSummary(requestedTenantId).then((data) => {
if (active()) financeStatistics.value = { loading: false, failed: false, data }
}).catch(() => { if (active()) financeStatistics.value = { loading: false, failed: true, data: null } }))
}
if (access.can('platform:firstleg:container:list')) {
transitStatistics.value = { loading: true, failed: false, data: null }
tasks.push(getCustomerContainerTransitSummary(requestedTenantId).then((data) => {
if (active()) transitStatistics.value = { loading: false, failed: false, data }
}).catch(() => { if (active()) transitStatistics.value = { loading: false, failed: true, data: null } }))
}
await Promise.all(tasks)
}
function clearPreviews() {
for (const preview of Object.values(previews.value)) if (preview.url) URL.revokeObjectURL(preview.url)
previews.value = {}
@@ -200,7 +246,9 @@ async function loadDetail() {
customer.value = null
qualifications.value = []
members.value = []
orderCount.value = null
orderStatistics.value = { loading: false, failed: false, data: null }
financeStatistics.value = { loading: false, failed: false, data: null }
transitStatistics.value = { loading: false, failed: false, data: null }
customerOrders.value = []
customerOrderTotal.value = 0
customerOrdersError.value = null
@@ -218,24 +266,17 @@ async function loadDetail() {
url: '', kind: qualificationName(item).toLowerCase().endsWith('.pdf') ? 'pdf' : 'image', loading: false, failed: false,
} satisfies PreviewState]))
const optional: Promise<void>[] = []
void loadOperatingStatistics(requestedTenantId, serial)
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))
void 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
@@ -342,9 +383,9 @@ watch(orderPage, (page, previousPage) => {
<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>
<CustomerOperatingMetricCard title="累计运单" :value="String(orderStatistics.data?.totalSubmittedOrders ?? '')" unit="票" :detail="`本月 ${orderStatistics.data?.currentMonthSubmittedOrders ?? 0} 票`" tone="blue" :loading="orderStatistics.loading" :failed="orderStatistics.failed" :unauthorized="!access.can('platform:order:list')"><template #icon><Ship :size="20" /></template></CustomerOperatingMetricCard>
<CustomerOperatingMetricCard title="累计应收" :value="cnyWan(financeStatistics.data?.totalReceivable)" unit="万元" :detail="`未结清 ${cnyWan(financeStatistics.data?.outstandingReceivable)} 万元`" tone="green" :loading="financeStatistics.loading" :failed="financeStatistics.failed" :unauthorized="!access.can('platform:finance:customer-account:list')"><template #icon><CircleDollarSign :size="20" /></template></CustomerOperatingMetricCard>
<CustomerOperatingMetricCard title="在途柜量" :value="decimalText(transitStatistics.data?.totalTeu)" unit="TEU" :detail="`${transitStatistics.data?.containerCount ?? 0} 柜${transitStatistics.data?.unknownContainerCount ? ` · 另有 ${transitStatistics.data.unknownContainerCount} 柜柜型未知,未计入TEU` : ''}`" tone="amber" :loading="transitStatistics.loading" :failed="transitStatistics.failed" :unauthorized="!access.can('platform:firstleg:container:list')"><template #icon><Container :size="20" /></template></CustomerOperatingMetricCard>
</section>
</template>
@@ -406,7 +447,6 @@ watch(orderPage, (page, previousPage) => {
<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>
@@ -450,13 +490,7 @@ watch(orderPage, (page, previousPage) => {
.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:1px solid rgba(255,255,255,.5); border-radius:12px; background:rgba(255,255,255,.95); box-shadow:0 3px 12px rgba(15,23,42,.05); }
.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:linear-gradient(135deg,rgba(102,126,234,.15),rgba(118,75,162,.1)); color:#667eea; }.metric-icon.is-green { background:linear-gradient(135deg,rgba(34,197,94,.15),rgba(22,163,74,.1)); color:#16a34a; }.metric-icon.is-amber { background:linear-gradient(135deg,rgba(245,158,11,.15),rgba(234,88,12,.1)); color:#ea580c; }
.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; }
.overview-strip :deep(.operating-metric-card:last-child) { border-right:0; }
.detail-tabs { display:flex; gap:0; overflow-x:auto; overflow-y:hidden; border-bottom:1px solid rgba(228,228,231,.5); border-radius:20px 20px 0 0; background:rgba(255,255,255,.95); box-shadow:0 -4px 16px rgba(0,0,0,.04); scrollbar-width:thin; }
.detail-tabs button { display:inline-flex; min-width:max-content; min-height:0; flex:0 0 auto; align-items:center; justify-content:center; gap:8px; padding:16px 28px; border:0; border-bottom:3px solid transparent; background:transparent; color:#71717a; font-size:15px; font-weight:600; white-space:nowrap; cursor:pointer; transition:all .3s cubic-bezier(.4,0,.2,1); }
.detail-tab-icon { font-size:18px; line-height:1; }
@@ -484,10 +518,9 @@ table { width:100%; min-width:720px; border-collapse:collapse; font-size:12px; }
.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; }
.contract-note { 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; }.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: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 :deep(.operating-metric-card:last-child) { border-bottom:0; }.detail-panel { padding:16px; }.info-grid { grid-template-columns:1fr; }.info-grid > div.wide { grid-column:auto; } }
@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>
@@ -130,7 +130,7 @@ describe('CustomersView', () => {
setActivePinia(pinia)
const auth = useAuthStore()
auth.token = 'token'
auth.permissions = ['platform:customer:list', 'platform:customer:view', 'platform:customer:edit', 'platform:customer:export', 'platform:download:list']
auth.permissions = ['platform:customer:list', 'platform:customer:edit', 'platform:customer:export', 'platform:download:list']
useAccessPreviewStore().initializeAfterLogin()
vi.mocked(listPlatformTenants).mockResolvedValue({
records: [{ ...fullTenant, contactPhone: '138****8000', contactEmail: 'o***@example.com' }],
@@ -339,7 +339,7 @@ describe('CustomersView', () => {
it('hides customer export when the stable export permission is absent', async () => {
const auth = useAuthStore()
auth.permissions = ['platform:customer:list', 'platform:customer:view']
auth.permissions = ['platform:customer:list']
useAccessPreviewStore().initializeAfterLogin()
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
+1 -1
View File
@@ -17,7 +17,7 @@ export const customerRoutes = [
path: 'customers/:tenantId',
name: 'customer-detail',
component: () => import('@/modules/customers/pages/CustomerDetailView.vue'),
meta: { title: '客户详情', description: '查看企业资料、关联用户和业务记录', permission: 'platform:customer:list', permissionAnyOf: ['platform:customer:view'], showPageHeading: false },
meta: { title: '客户详情', description: '查看企业资料、关联用户和业务记录', permission: 'platform:customer:list', showPageHeading: false },
},
{
path: 'customers/reviews',