This commit is contained in:
2026-08-13 11:57:44 +08:00
parent 7beca3f985
commit 835ac0ad12
87 changed files with 3721 additions and 295 deletions
@@ -12,7 +12,7 @@ import { addFeeTier, removeFeeTier, validateFeeTiers } from './overseasStorageFe
const props = defineProps<{ customerId: string; customerName: string }>()
const access = useAccessPreviewStore()
const canView = computed(() => access.can('platform:customer:view'))
const canView = computed(() => access.can('platform:customer:list'))
const canEdit = computed(() => access.can('platform:customer:edit'))
const config = ref<OverseasStorageFeeConfig | null>(null)
const loading = ref(false)
@@ -108,7 +108,7 @@ describe('CustomerDetailView', () => {
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 人正常使用')
@@ -37,8 +37,10 @@ import PageToolbar from '@/shared/components/PageToolbar.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import CustomerBillingRulesPanel from '@/modules/customers/components/CustomerBillingRulesPanel.vue'
import CustomerOverseasStorageFeePanel from '@/modules/customers/CustomerOverseasStorageFeePanel.vue'
import { CustomerStatementsPanel } from '@/modules/finance'
import { CustomerAuditLogsPanel } from '@/modules/audit'
type DetailTab = 'basic' | 'business' | 'orders' | 'billing'
type DetailTab = 'basic' | 'business' | 'orders' | 'statements' | 'billing' | 'logs'
type PreviewState = { url: string; kind: 'image' | 'pdf'; loading: boolean; failed: boolean }
const route = useRoute()
@@ -74,7 +76,9 @@ const tabs: Array<{ key: DetailTab; label: string; icon: string }> = [
{ key: 'basic', label: '基本信息', icon: '📋' },
{ key: 'business', label: '业务概况', icon: '📊' },
{ key: 'orders', label: '运单记录', icon: '📦' },
{ key: 'statements', label: '财务账单', icon: '🧾' },
{ key: 'billing', label: '计费规则', icon: '⚖️' },
{ key: 'logs', label: '操作日志', icon: '📝' },
]
const approvedQualifications = computed(() => qualifications.value.filter((item) => item.status === 'APPROVED').length)
@@ -425,6 +429,8 @@ watch(orderPage, (page, previousPage) => {
<div v-else class="module-empty"><Ship :size="30" /><strong>暂无运单查看权限</strong><p>请联系管理员申请头程订单查看权限。</p></div>
</section>
<section v-else-if="activeTab === 'statements'" class="detail-panel module-panel"><CustomerStatementsPanel :customer-id="tenantId" :customer-name="customer.tenantName" /></section>
<section v-else-if="activeTab === 'logs'" class="detail-panel module-panel"><CustomerAuditLogsPanel :customer-id="tenantId" :customer-name="customer.tenantName" /></section>
<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>
</div>
</DetailPageLayout>
@@ -391,6 +391,37 @@ describe('CustomersView', () => {
expect(document.body.textContent).toContain('Temporary#2026')
})
it('loads and previews authenticated qualification files in the review dialog', async () => {
const auth = useAuthStore()
auth.permissions = [...auth.permissions, 'platform:customer:review', 'platform:customer:approve']
useAccessPreviewStore().initializeAfterLogin()
vi.mocked(listPlatformTenants).mockResolvedValue({
records: [{ ...fullTenant, status: 'PENDING' }], page: 1, size: 10, total: 1, pages: 1,
})
vi.mocked(listEnterpriseReviews).mockResolvedValue([pendingReview])
vi.mocked(listTenantQualifications).mockResolvedValue([
{ id: 'qualification-license', tenantId: fullTenant.id, qualificationType: 'BUSINESS_LICENSE', certificateNo: fullTenant.unifiedSocialCreditCode, documentUrl: '/files/license.png', status: 'PENDING', reviewComment: null, validTo: null },
{ id: 'qualification-legal', tenantId: fullTenant.id, qualificationType: 'LEGAL_PERSON_ID', certificateNo: null, documentUrl: '/files/legal-person.png', status: 'PENDING', reviewComment: null, validTo: null },
])
vi.mocked(downloadTenantQualification).mockResolvedValue(new Blob(['image'], { type: 'image/png' }))
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
tableButtonByText('审核')?.click()
await flushPromises()
expect(listTenantQualifications).toHaveBeenCalledWith(fullTenant.id)
expect(document.body.textContent).toContain('营业执照')
expect(document.body.textContent).toContain('法人身份证')
const previewButton = [...document.querySelectorAll<HTMLButtonElement>('.review-document-actions button')]
.find((button) => button.textContent?.trim() === '预览')
previewButton?.click()
await flushPromises()
expect(downloadTenantQualification).toHaveBeenCalledWith(fullTenant.id, '/files/license.png')
expect(document.querySelector<HTMLImageElement>('img[alt="license.png预览"]')?.src).toBe('blob:qualification-preview')
})
it('keeps the client registration account as administrator after approval', async () => {
const auth = useAuthStore()
auth.permissions = [...auth.permissions, 'platform:customer:review', 'platform:customer:approve']
+84 -6
View File
@@ -80,6 +80,14 @@ const reviewLoadError = ref<ApiErrorDetails | null>(null)
const selectedReview = ref<EnterpriseReview | null>(null)
const decisionOpen = ref(false)
const reviewSubmitting = ref(false)
const reviewQualifications = ref<PlatformTenantQualification[]>([])
const reviewQualificationsLoading = ref(false)
const reviewQualificationsError = ref<ApiErrorDetails | null>(null)
const reviewPreviewOpen = ref(false)
const reviewPreviewName = ref('')
const reviewPreviewUrl = ref('')
const reviewPreviewKind = ref<'image' | 'pdf'>('image')
const reviewDocumentLoading = ref('')
const approvalResultOpen = ref(false)
const approvalResult = ref<EnterpriseApprovalResponse | null>(null)
const credentialSource = ref<'creation' | 'approval'>('approval')
@@ -252,6 +260,12 @@ const impersonationRules: FormRules = {
}
function displayText(value?: string | null) { return value?.trim() || '—' }
function formatDate(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
function qualificationLabel(type?: string | null) {
return ({ BUSINESS_LICENSE: '营业执照', LEGAL_PERSON_ID: '法人身份证', FREIGHT_FORWARDER_CERTIFICATE: '货代资质证书', BANK_ACCOUNT_LICENSE: '银行开户许可证' } as Record<string, string>)[type || ''] || type || '企业资质'
}
function qualificationName(item: PlatformTenantQualification) {
try { return qualificationFileName(item.documentUrl) } catch { return qualificationLabel(item.qualificationType) }
}
function statusMeta(status: PlatformTenantStatus) {
if (status === 'ACTIVE') return { label: '启用', type: 'success' as const }
if (status === 'PROFILE_REQUIRED') return { label: '待完善', type: 'info' as const }
@@ -819,7 +833,7 @@ async function submitArchive() {
}
}
function openDecision(row: PlatformTenantResource, type: 'approve' | 'reject') {
async function openDecision(row: PlatformTenantResource, type: 'approve' | 'reject') {
if (reviewSubmitting.value) return
const review = pendingReviewByTenantId.value.get(String(row.id))
if (!review) {
@@ -830,9 +844,60 @@ function openDecision(row: PlatformTenantResource, type: 'approve' | 'reject') {
decision.type = type
decision.comment = type === 'approve' ? '企业资料与必填资质完整、清晰,审核通过' : ''
decisionOpen.value = true
await loadReviewQualifications()
void nextTick(() => decisionFormRef.value?.clearValidate('comment'))
}
async function loadReviewQualifications() {
const review = selectedReview.value
if (!review) return
reviewQualifications.value = []
reviewQualificationsError.value = null
reviewQualificationsLoading.value = true
try {
reviewQualifications.value = await listTenantQualifications(review.tenantId)
} catch (error) {
reviewQualificationsError.value = getApiErrorDetails(error, '客户资质加载失败,请重试后再审核')
} finally {
reviewQualificationsLoading.value = false
}
}
function clearReviewPreview() {
if (reviewPreviewUrl.value) URL.revokeObjectURL(reviewPreviewUrl.value)
reviewPreviewOpen.value = false
reviewPreviewUrl.value = ''
reviewPreviewName.value = ''
}
async function loadReviewDocument(item: PlatformTenantQualification, download: boolean) {
const review = selectedReview.value
if (!review || reviewDocumentLoading.value) return
const fileName = qualificationName(item)
reviewDocumentLoading.value = String(item.id)
try {
const blob = await downloadTenantQualification(review.tenantId, item.documentUrl)
const url = URL.createObjectURL(blob)
if (download) {
const anchor = document.createElement('a')
anchor.href = url
anchor.download = fileName
anchor.click()
URL.revokeObjectURL(url)
return
}
clearReviewPreview()
reviewPreviewName.value = fileName
reviewPreviewKind.value = blob.type === 'application/pdf' || fileName.toLowerCase().endsWith('.pdf') ? 'pdf' : 'image'
reviewPreviewUrl.value = url
reviewPreviewOpen.value = true
} catch (error) {
ElMessage.error(getApiErrorMessage(error, '资质文件读取失败,请稍后重试'))
} finally {
reviewDocumentLoading.value = ''
}
}
async function submitDecision() {
const review = selectedReview.value
if (!review || !decisionFormRef.value || reviewSubmitting.value) return
@@ -931,6 +996,7 @@ watch(() => decision.type, (type) => {
})
onBeforeUnmount(() => {
clearQualificationPreviews()
clearReviewPreview()
impersonationCleanup?.()
})
onMounted(() => { void loadCustomers() })
@@ -951,8 +1017,8 @@ onMounted(() => { void loadCustomers() })
<template #toolbar-actions><div class="customers-table-actions"><el-button v-if="access.can('platform:customer:export') && access.can('platform:download:list')" :loading="exportSubmitting" :disabled="loading || exportSubmitting" @click="exportCustomers"><Download :size="16" />导出Excel</el-button><el-button v-if="access.can('platform:customer:create')" type="primary" :disabled="loading" @click="openCreate"><Plus :size="16" />新增客户</el-button></div></template>
<template #actions="{ row }">
<div class="table-actions">
<el-button v-if="access.can('platform:customer:view')" class="customer-action-button customer-action-detail" size="small" @click="showDetail(row as PlatformTenantResource)"><Eye :size="14" />详情</el-button>
<el-button v-if="access.can('platform:customer:view') && access.can('platform:customer:edit')" class="customer-action-button customer-action-edit" size="small" @click="openEdit(row as PlatformTenantResource)"><Pencil :size="14" />编辑</el-button>
<el-button v-if="access.can('platform:customer:list')" class="customer-action-button customer-action-detail" size="small" @click="showDetail(row as PlatformTenantResource)"><Eye :size="14" />详情</el-button>
<el-button v-if="access.can('platform:customer:edit')" class="customer-action-button customer-action-edit" size="small" @click="openEdit(row as PlatformTenantResource)"><Pencil :size="14" />编辑</el-button>
<el-button v-if="hasReviewAction(row as PlatformTenantResource)" class="customer-action-button customer-action-review" size="small" :disabled="reviewSubmitting" @click="openDecision(row as PlatformTenantResource, access.can('platform:customer:approve') ? 'approve' : 'reject')"><ShieldCheck :size="14" />审核</el-button>
<el-button v-for="action in customerSecondaryActions(row as PlatformTenantResource)" :key="action" :class="[`customer-action-button`, `customer-action-${action}`, { 'is-enable': action === 'status' && (row as PlatformTenantResource).status !== 'ACTIVE', 'is-disable': action === 'status' && (row as PlatformTenantResource).status === 'ACTIVE' }]" size="small" :disabled="customerActionDisabled(action)" @click="handleMoreAction(row as PlatformTenantResource, action)"><component :is="customerRowActionIcons[action]" :size="14" />{{ customerActionLabel(row as PlatformTenantResource, action) }}</el-button>
</div>
@@ -1043,9 +1109,12 @@ onMounted(() => { void loadCustomers() })
</section>
<section class="review-dialog-section">
<h3>资质文件</h3>
<div v-if="reviewQualificationsLoading" class="review-document-state">正在加载资质文件...</div>
<div v-else-if="reviewQualificationsError" class="review-document-error" role="alert"><span>{{ reviewQualificationsError.message }}</span><el-button link type="primary" @click="loadReviewQualifications">重试</el-button></div>
<div class="review-document-table" role="table" aria-label="待审核客户资质">
<div class="review-document-header" role="row"><span role="columnheader">文件类型</span><span role="columnheader">证书编号</span><span role="columnheader">状态</span></div>
<div class="review-document-row" role="row"><span role="cell">{{ selectedReview.qualificationType || '企业资质' }}</span><span role="cell">{{ selectedReview.certificateNo || '—' }}</span><span role="cell">待审核</span></div>
<div class="review-document-header" role="row"><span role="columnheader">文件类型</span><span role="columnheader">文件名称</span><span role="columnheader">操作</span></div>
<div v-for="item in reviewQualifications" :key="String(item.id)" class="review-document-row" role="row"><span role="cell">{{ qualificationLabel(item.qualificationType) }}</span><span role="cell">{{ qualificationName(item) }}</span><span role="cell" class="review-document-actions"><el-button link type="primary" :loading="reviewDocumentLoading === String(item.id)" @click="loadReviewDocument(item, false)"><Eye :size="14" />预览</el-button><el-button link :disabled="Boolean(reviewDocumentLoading)" @click="loadReviewDocument(item, true)"><Download :size="14" />下载</el-button></span></div>
<div v-if="!reviewQualificationsLoading && !reviewQualificationsError && reviewQualifications.length === 0" class="review-document-empty">暂无资质文件</div>
</div>
</section>
</div>
@@ -1056,6 +1125,10 @@ onMounted(() => { void loadCustomers() })
<template #footer><div class="drawer-footer"><el-button :disabled="reviewSubmitting" @click="decisionOpen = false">返回</el-button><el-button v-if="access.can('platform:customer:reject') && decision.type === 'reject'" type="danger" :loading="reviewSubmitting" @click="submitDecision">确认驳回</el-button><el-button v-if="access.can('platform:customer:approve') && decision.type === 'approve'" type="primary" :loading="reviewSubmitting" @click="submitDecision">审核通过</el-button></div></template>
</el-dialog>
<AppDialog v-model="reviewPreviewOpen" :title="reviewPreviewName || '查看资质文件'" width="min(860px, calc(100vw - 28px))" destroy-on-close :show-confirm="false" cancel-text="关闭预览" @closed="clearReviewPreview">
<div class="qualification-preview"><img v-if="reviewPreviewUrl && reviewPreviewKind === 'image'" :src="reviewPreviewUrl" :alt="`${reviewPreviewName}预览`" /><iframe v-else-if="reviewPreviewUrl" :src="reviewPreviewUrl" :title="`${reviewPreviewName} PDF 预览`" /></div>
</AppDialog>
<el-dialog v-model="passwordDialogOpen" title="重置客户密码" width="min(520px, calc(100vw - 28px))" class="customer-password-dialog" :close-on-click-modal="false" :close-on-press-escape="!passwordSubmitting" :show-close="!passwordSubmitting">
<div v-if="passwordTenant" class="decision-summary"><div><strong>正在重置企业管理员登录密码</strong><span>{{ passwordTenant.tenantName }}</span></div><span>客户编号:{{ passwordTenant.tenantCode }}</span></div>
<el-alert v-if="passwordError" type="error" :closable="false" show-icon role="alert"><template #title><div class="api-error-alert-title"><span>{{ passwordError.message }}</span><ApiErrorMeta :details="passwordError" /></div></template></el-alert>
@@ -1152,11 +1225,16 @@ onMounted(() => { void loadCustomers() })
.review-info-grid dt { color:#64748b; font-size:12px; }
.review-info-grid dd { min-width:0; margin:0; overflow-wrap:anywhere; color:#1e293b; font-size:13px; }
.review-document-table { overflow:hidden; border:1px solid #e4e4e7; border-radius:6px; }
.review-document-header,.review-document-row { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr) 76px; }
.review-document-header,.review-document-row { display:grid; grid-template-columns:minmax(120px,.8fr) minmax(140px,1fr) 150px; }
.review-document-header { background:rgba(102,126,234,.08); color:#52525b; font-size:12px; font-weight:600; }
.review-document-header > span,.review-document-row > span { min-width:0; padding:9px 10px; overflow-wrap:anywhere; border-right:1px solid #e4e4e7; }
.review-document-header > span:last-child,.review-document-row > span:last-child { border-right:0; }
.review-document-row { border-top:1px solid #e4e4e7; color:#334155; font-size:13px; }
.review-document-actions { display:flex; align-items:center; gap:8px; }
.review-document-actions :deep(.el-button + .el-button) { margin-left:0; }
.review-document-state,.review-document-error,.review-document-empty { padding:14px; color:var(--cc-muted); font-size:12px; }
.review-document-error { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:8px; border:1px solid #fecdca; border-radius:6px; background:#fffafa; color:#b42318; }
.review-document-empty { text-align:center; }
.decision-summary > div > span { display:block; margin-top:4px; }.decision-type { margin:0 0 16px; }.password-input-row { display:grid; grid-template-columns:minmax(0,1fr) auto; width:100%; gap:8px; }.dialog-hint { margin:12px 0 0; color:var(--cc-muted); font-size:12px; line-height:1.6; }.archive-confirm { display:grid; gap:8px; margin:16px 0; }.archive-confirm strong { color:var(--cc-text); }.archive-confirm span { color:var(--cc-muted); font-size:12px; }
.approval-result-warning { display:flex; align-items:flex-start; gap:10px; padding:12px 14px; border:1px solid #fecdca; border-radius:8px; background:#fffafa; color:#b42318; } .approval-result-warning strong,.approval-result-warning span { display:block; } .approval-result-warning strong { font-size:14px; } .approval-result-warning span { margin-top:4px; font-size:12px; line-height:1.5; } .approval-credentials { margin:18px 0 0; border:1px solid var(--cc-border); border-radius:8px; } .approval-credentials > div { display:grid; grid-template-columns:90px minmax(0,1fr); gap:12px; padding:12px 14px; } .approval-credentials > div + div { border-top:1px solid var(--cc-border); } .approval-credentials dt { color:var(--cc-muted); font-size:12px; } .approval-credentials dd { display:flex; min-width:0; align-items:center; gap:10px; margin:0; } .approval-credentials code { min-width:0; flex:1; overflow-wrap:anywhere; color:var(--cc-text); font-size:14px; font-weight:700; }
@media (max-width:720px) { .customer-editor-footer,.review-sync-error { align-items:stretch; flex-direction:column; } .customers-table-actions { display:grid; grid-template-columns:1fr 1fr; width:100%; } .customers-table-actions :deep(.el-button) { min-height:40px; margin:0; } .qualification-table { overflow-x:auto; } .qualification-row { min-width:640px; } .customer-editor-footer > div { display:grid; grid-template-columns:repeat(auto-fit,minmax(110px,1fr)); width:100%; } .customer-editor-footer :deep(.el-button) { min-height:40px; margin:0; } .decision-summary { align-items:flex-start; flex-direction:column; }.review-dialog-grid { grid-template-columns:1fr; gap:18px; }.review-info-grid > div { grid-template-columns:92px minmax(0,1fr); }.password-input-row { grid-template-columns:1fr; }.table-actions :deep(.el-button) { min-height:40px; } }
@@ -71,7 +71,7 @@ onMounted(() => { void load() })
<template v-if="error" #state><section class="surface-panel error-state" role="alert"><div><strong>{{ error.message }}</strong><ApiErrorMeta :details="error" /></div><el-button type="primary" @click="load">重新加载</el-button></section></template>
<section v-if="!error" class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-storage-fees-table" row-key="id" border stripe empty-text="暂无海外仓阶梯费用配置" @update:page="load" @update:page-size="load">
<template #actions="{ row }"><el-button v-if="access.can('platform:customer:edit')" size="small" plain type="primary" @click="openEdit(row as OverseasStorageFeeConfig)"><Pencil :size="15" />编辑</el-button></template>
<template #actions="{ row }"><el-button v-if="access.can('platform:overseas-storage-fee:edit')" size="small" plain type="primary" @click="openEdit(row as OverseasStorageFeeConfig)"><Pencil :size="15" />编辑</el-button></template>
<template #storage="{ row }"><div class="tier-list"><div v-for="tier in tiersFor(row as OverseasStorageFeeConfig, 'STORAGE')" :key="tier.id" class="tier-item"><span>{{ label(tier) }}</span><strong>{{ tier.unitPrice }} {{ (row as OverseasStorageFeeConfig).currency }}</strong></div></div></template>
<template #returnStorage="{ row }"><div class="tier-list"><div v-for="tier in tiersFor(row as OverseasStorageFeeConfig, 'RETURN_STORAGE')" :key="tier.id" class="tier-item"><span>{{ label(tier) }}</span><strong>{{ tier.unitPrice }} {{ (row as OverseasStorageFeeConfig).currency }}</strong></div></div></template>
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 个客户</span></template>
+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:view', showPageHeading: false },
meta: { title: '客户详情', description: '查看企业资料、关联用户和业务记录', permission: 'platform:customer:list', permissionAnyOf: ['platform:customer:view'], showPageHeading: false },
},
{
path: 'customers/reviews',