This commit is contained in:
2026-08-04 09:05:19 +08:00
parent fa922ca96f
commit 815d4e8daf
21 changed files with 994 additions and 128 deletions
@@ -18,6 +18,7 @@ import {
import { createPlatformExportTask } from '@/modules/downloads'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import CustomersView from './CustomersView.vue'
vi.mock('@/modules/iam', () => ({
@@ -47,7 +48,7 @@ class ResizeObserverStub implements ResizeObserver {
const fullTenant: PlatformTenantResource = {
id: '8', tenantCode: 'TEN8', tenantName: '柏悦物流', status: 'PROFILE_REQUIRED',
legalName: '柏悦物流有限公司', unifiedSocialCreditCode: '91440300MA5ABCDE12',
legalName: '柏悦物流有限公司', legalRepresentative: '张三', unifiedSocialCreditCode: '91440300MA5ABCDE12',
contactName: '张三', contactPhone: '13800138000', contactEmail: 'ops@example.com',
servicePackage: '基础版', contactWechat: 'baiyue-ops', salesOwnerName: '李四', paymentTermDays: 30,
enterpriseType: '有限责任公司', establishedDate: '2020-01-01', registeredCapital: 1000,
@@ -83,7 +84,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']
auth.permissions = ['platform:customer:list', 'platform:customer:view', 'platform:customer:edit', 'platform:customer:export', 'platform:download:list']
useAccessPreviewStore().initializeAfterLogin()
vi.mocked(listPlatformTenants).mockResolvedValue({
records: [{ ...fullTenant, contactPhone: '138****8000', contactEmail: 'o***@example.com' }],
@@ -132,6 +133,33 @@ describe('CustomersView', () => {
expect(preview?.src).toBe('blob:qualification-preview')
})
it('does not count rejected or superseded history as a current qualification when resubmitting', async () => {
const rejectedTenant = { ...fullTenant, status: 'REJECTED' }
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [rejectedTenant], page: 1, size: 10, total: 1, pages: 1 })
vi.mocked(getPlatformTenant).mockResolvedValue(rejectedTenant)
vi.mocked(listTenantQualifications).mockResolvedValue([
{
id: 'qualification-old', tenantId: fullTenant.id, qualificationType: 'BUSINESS_LICENSE',
certificateNo: fullTenant.unifiedSocialCreditCode, documentUrl: '/files/old-license.png',
status: 'SUPERSEDED', reviewComment: '已替换', validTo: null,
},
{
id: 'qualification-rejected', tenantId: fullTenant.id, qualificationType: 'BUSINESS_LICENSE',
certificateNo: fullTenant.unifiedSocialCreditCode, documentUrl: '/files/rejected-license.png',
status: 'REJECTED', reviewComment: '请重新上传', validTo: null,
},
])
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
tableButtonByText('编辑')?.click()
await flushPromises()
expect(document.querySelector('.qualification-label [aria-label="已上传"]')).toBeNull()
expect(document.querySelector('.qualification-file-name')?.textContent).toContain('未选择文件')
expect(downloadTenantQualification).not.toHaveBeenCalled()
})
it('shows uploaded qualifications in customer detail and opens the full preview', async () => {
vi.mocked(listTenantQualifications).mockResolvedValue([{
id: 'qualification-1', tenantId: fullTenant.id, qualificationType: 'BUSINESS_LICENSE',
@@ -154,6 +182,52 @@ describe('CustomersView', () => {
expect(document.querySelector('.qualification-preview img[alt="license.png预览"]')).not.toBeNull()
})
it('does not let an older customer detail response overwrite a newer one', async () => {
const secondTenant = {
...fullTenant, id: '9', tenantCode: 'TEN9', tenantName: '第二家客户', legalName: '第二家客户有限公司',
}
vi.mocked(listPlatformTenants).mockResolvedValue({
records: [fullTenant, secondTenant], page: 1, size: 10, total: 2, pages: 1,
})
let resolveFirst!: (value: PlatformTenantResource) => void
let resolveSecond!: (value: PlatformTenantResource) => void
vi.mocked(getPlatformTenant)
.mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve }))
.mockImplementationOnce(() => new Promise((resolve) => { resolveSecond = resolve }))
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
const detailButtons = [...document.querySelectorAll<HTMLButtonElement>('.el-table__body button')]
.filter((button) => button.textContent?.trim() === '详情')
detailButtons[0]?.click()
detailButtons[1]?.click()
resolveSecond(secondTenant)
await flushPromises()
expect(document.querySelector('.customer-detail')?.textContent).toContain('第二家客户有限公司')
resolveFirst(fullTenant)
await flushPromises()
const detailText = document.querySelector('.customer-detail')?.textContent
expect(detailText).toContain('第二家客户有限公司')
expect(detailText).not.toContain('柏悦物流有限公司')
})
it('labels replaced qualification history without presenting it as pending review', async () => {
vi.mocked(listTenantQualifications).mockResolvedValue([{
id: 'qualification-old', tenantId: fullTenant.id, qualificationType: 'BUSINESS_LICENSE',
certificateNo: fullTenant.unifiedSocialCreditCode, documentUrl: '/files/old-license.pdf',
status: 'SUPERSEDED', reviewComment: '已替换', validTo: null,
}])
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
tableButtonByText('详情')?.click()
await flushPromises()
expect(document.querySelector('.detail-qualification-meta .el-tag')?.textContent).toContain('已替换')
})
it('saves customer data and selected qualifications with one action', async () => {
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
@@ -183,9 +257,12 @@ describe('CustomersView', () => {
})
it('submits the current filters to the backend export service without loading every customer page', async () => {
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
const wrapper = mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
wrapper.findComponent(QueryToolbar).vm.$emit('update:modelValue', { keyword: ' Acme ', status: 'ACTIVE' })
await wrapper.vm.$nextTick()
const exportButton = [...document.querySelectorAll<HTMLButtonElement>('button')]
.find((button) => button.textContent?.trim() === '导出Excel')
exportButton?.click()
@@ -193,11 +270,35 @@ describe('CustomersView', () => {
expect(createPlatformExportTask).toHaveBeenCalledWith({
exportType: 'CUSTOMER_LIST', format: 'XLSX', taskName: '客户列表',
parameters: { keyword: undefined, status: undefined },
parameters: { keyword: 'Acme', status: 'ACTIVE' },
})
expect(listPlatformTenants).toHaveBeenCalledTimes(1)
})
it('hides customer export when the stable export permission is absent', async () => {
const auth = useAuthStore()
auth.permissions = ['platform:customer:list', 'platform:customer:view']
useAccessPreviewStore().initializeAfterLogin()
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
expect([...document.querySelectorAll<HTMLButtonElement>('button')]
.some((button) => button.textContent?.trim() === '导出Excel')).toBe(false)
expect(createPlatformExportTask).not.toHaveBeenCalled()
})
it('does not create an unrecoverable export for a role without download-center access', async () => {
const auth = useAuthStore()
auth.permissions = ['platform:customer:list', 'platform:customer:export']
useAccessPreviewStore().initializeAfterLogin()
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
expect([...document.querySelectorAll<HTMLButtonElement>('button')]
.some((button) => button.textContent?.trim() === '导出Excel')).toBe(false)
expect(createPlatformExportTask).not.toHaveBeenCalled()
})
it('approves a pending enterprise directly from the customer list', async () => {
const auth = useAuthStore()
auth.permissions = [...auth.permissions, 'platform:customer:review', 'platform:customer:approve']
@@ -228,6 +329,35 @@ describe('CustomersView', () => {
expect(document.body.textContent).toContain('Temporary#2026')
})
it('keeps the client registration account as administrator after approval', 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(approveEnterpriseReview).mockResolvedValue({
tenantId: fullTenant.id,
administratorCreated: false,
administratorUsername: null,
temporaryPassword: null,
})
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
tableButtonByText('通过')?.click()
await flushPromises()
;[...document.querySelectorAll<HTMLButtonElement>('button')]
.find((button) => button.textContent?.trim() === '确认通过')?.click()
await flushPromises()
expect(approveEnterpriseReview).toHaveBeenCalledWith(pendingReview.id, '企业资料与必填资质完整、清晰,审核通过')
expect(rejectEnterpriseReview).not.toHaveBeenCalled()
expect(document.body.textContent).toContain('注册账号继续作为企业管理员')
expect(document.body.textContent).not.toContain('请立即保存以下一次性凭证')
})
it('keeps review-only roles on the merged customer page without calling the customer list endpoint', async () => {
const auth = useAuthStore()
auth.permissions = ['platform:customer:review', 'platform:customer:approve']
+60 -18
View File
@@ -105,7 +105,7 @@ const queryFields: QueryField[] = [
]
const emptyForm = (): CreatePlatformTenantRequest => ({
tenantName: '', legalName: '', unifiedSocialCreditCode: '', contactName: '', contactPhone: '',
tenantName: '', legalName: '', legalRepresentative: '', unifiedSocialCreditCode: '', contactName: '', contactPhone: '',
contactEmail: '', contactWechat: '', servicePackage: '基础版', salesOwnerName: '', paymentTermDays: 0,
enterpriseType: '', establishedDate: null, registeredCapital: null, registeredCapitalCurrency: 'CNY',
registeredAddress: '', internalRemark: '',
@@ -114,6 +114,7 @@ const form = ref(emptyForm())
const rules: FormRules = {
tenantName: [{ required: true, message: '请输入公司名称', trigger: 'blur' }],
legalName: [{ required: true, message: '请输入营业执照上的企业名称', trigger: 'blur' }],
legalRepresentative: [{ required: true, message: '请输入法定代表人姓名', trigger: 'blur' }],
unifiedSocialCreditCode: [
{ required: true, message: '请输入统一社会信用代码', trigger: 'blur' },
{ pattern: /^[0-9A-Z]{18}$/, message: '请输入 18 位大写字母或数字', trigger: 'blur' },
@@ -174,6 +175,8 @@ const customerColumns: DataTableColumn[] = [
let loadSequence = 0
let reviewLoadSequence = 0
let editorLoadSequence = 0
let detailLoadSequence = 0
const editorTitle = computed(() => editingId.value ? '编辑客户' : '新增客户')
const canUploadQualifications = computed(() => !editorStatus.value || ['PROFILE_REQUIRED', 'REJECTED'].includes(editorStatus.value))
const canSubmitReview = computed(() => Boolean(editingId.value) && ['PROFILE_REQUIRED', 'REJECTED'].includes(editorStatus.value || ''))
@@ -191,6 +194,13 @@ function statusMeta(status: PlatformTenantStatus) {
return { label: '已停用', type: 'info' as const }
}
function qualificationStatusMeta(status: PlatformTenantQualification['status']) {
if (status === 'APPROVED') return { label: '已通过', type: 'success' as const }
if (status === 'REJECTED') return { label: '已驳回', type: 'danger' as const }
if (status === 'SUPERSEDED') return { label: '已替换', type: 'info' as const }
return { label: '待审核', type: 'warning' as const }
}
async function loadPendingReviews() {
const sequence = ++reviewLoadSequence
if (!access.can('platform:customer:review')) {
@@ -227,6 +237,7 @@ function reviewToCustomer(review: EnterpriseReview): PlatformTenantResource {
tenantName: review.tenantName?.trim() || `待审核企业(${tenantId})`,
status: 'PENDING',
legalName: null,
legalRepresentative: null,
unifiedSocialCreditCode: null,
contactName: review.contactName ?? null,
contactPhone: review.contactPhone ?? null,
@@ -308,12 +319,13 @@ function resetEditor() {
form.value = emptyForm(); qualifications.value = newQualificationDrafts(); qualificationLoadError.value = ''; savedFormSnapshot.value = ''
}
function openCreate() {
editorLoadSequence += 1
editingId.value = null; editorStatus.value = null; resetEditor(); editorOpen.value = true
nextTick(() => formRef.value?.clearValidate())
}
function mapTenantToForm(row: PlatformTenantResource): CreatePlatformTenantRequest {
return {
tenantName: row.tenantName, legalName: row.legalName || '', unifiedSocialCreditCode: row.unifiedSocialCreditCode || '',
tenantName: row.tenantName, legalName: row.legalName || '', legalRepresentative: row.legalRepresentative || '', unifiedSocialCreditCode: row.unifiedSocialCreditCode || '',
contactName: row.contactName || '', contactPhone: row.contactPhone || '', contactEmail: row.contactEmail || '',
contactWechat: row.contactWechat || '', servicePackage: row.servicePackage || '基础版', salesOwnerName: row.salesOwnerName || '',
paymentTermDays: Number(row.paymentTermDays ?? 0), enterpriseType: row.enterpriseType || '', establishedDate: row.establishedDate || null,
@@ -322,12 +334,16 @@ function mapTenantToForm(row: PlatformTenantResource): CreatePlatformTenantReque
internalRemark: row.internalRemark || '',
}
}
async function loadEditorQualifications(tenantId: number | string) {
async function loadEditorQualifications(tenantId: number | string, sequence = editorLoadSequence) {
qualificationLoadError.value = ''
try {
const records = await listTenantQualifications(tenantId)
if (sequence !== editorLoadSequence || !editorOpen.value || editingId.value !== String(tenantId)) return
const acceptedStatus = ['ACTIVE', 'DISABLED'].includes(editorStatus.value || '') ? 'APPROVED' : 'PENDING'
for (const draft of qualifications.value) {
const item = [...records].reverse().find((record) => record.qualificationType === draft.type && record.status !== 'REJECTED')
const item = [...records].reverse().find((record) => (
record.qualificationType === draft.type && record.status === acceptedStatus
))
if (item) {
draft.uploaded = true
draft.validTo = item.validTo || ''
@@ -341,21 +357,23 @@ async function loadEditorQualifications(tenantId: number | string) {
} catch (error) { qualificationLoadError.value = getApiErrorMessage(error, '资质信息加载失败,可稍后重试') }
}
async function openEdit(row: PlatformTenantResource) {
editingId.value = String(row.id); editorStatus.value = row.status; resetEditor(); form.value = mapTenantToForm(row)
const id = String(row.id)
const sequence = ++editorLoadSequence
editingId.value = id; editorStatus.value = row.status; resetEditor(); form.value = mapTenantToForm(row)
editorOpen.value = true; editorLoading.value = true
try {
const tenant = await getPlatformTenant(row.id)
if (editingId.value !== String(row.id)) return
if (sequence !== editorLoadSequence || editingId.value !== id || !editorOpen.value) return
editorStatus.value = tenant.status
form.value = mapTenantToForm(tenant)
savedFormSnapshot.value = JSON.stringify(form.value)
await loadEditorQualifications(row.id)
await loadEditorQualifications(row.id, sequence)
await nextTick(() => formRef.value?.clearValidate())
} catch (error) {
if (editingId.value === String(row.id)) editorOpen.value = false
if (sequence === editorLoadSequence && editingId.value === id) editorOpen.value = false
ElMessage.error(getApiErrorMessage(error, '客户完整资料加载失败,请稍后重试'))
} finally {
if (editingId.value === String(row.id)) editorLoading.value = false
if (sequence === editorLoadSequence && editingId.value === id) editorLoading.value = false
}
}
@@ -367,7 +385,7 @@ function selectQualificationFile(event: Event, draft: QualificationDraft) {
const extensionValid = /\.(pdf|jpe?g|png)$/i.test(file.name)
const mimeValid = ['application/pdf', 'image/jpeg', 'image/png'].includes(file.type)
if (!extensionValid || !mimeValid) { draft.error = '仅支持 PDF、JPG、PNG 文件'; input.value = ''; return }
if (file.size > 10 * 1024 * 1024) { draft.error = '单个文件不能超过 10MB'; input.value = ''; return }
if (file.size > 5 * 1024 * 1024) { draft.error = '单个文件不能超过 5MB'; input.value = ''; return }
draft.file = file
draft.previewKind = file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf') ? 'pdf' : 'image'
draft.previewUrl = URL.createObjectURL(file)
@@ -545,10 +563,13 @@ async function submitReview() {
}
async function showDetail(row: PlatformTenantResource) {
const sequence = ++detailLoadSequence
const rowId = String(row.id)
clearDetailQualificationPreviews()
detail.value = row; detailQualifications.value = []; detailOpen.value = true; detailLoading.value = true
try {
const [tenant, records] = await Promise.all([getPlatformTenant(row.id), listTenantQualifications(row.id)])
if (sequence !== detailLoadSequence || !detailOpen.value || String(detail.value?.id) !== rowId) return
detail.value = tenant
detailQualifications.value = records
detailQualificationPreviews.value = Object.fromEntries(records.map((item) => {
@@ -558,8 +579,11 @@ async function showDetail(row: PlatformTenantResource) {
for (const item of records) {
if (detailQualificationPreviews.value[String(item.id)]?.kind === 'image') void loadDetailQualificationPreview(item, false)
}
} catch (error) { ElMessage.error(getApiErrorMessage(error, '客户详情加载失败')) }
finally { detailLoading.value = false }
} catch (error) {
if (sequence === detailLoadSequence) ElMessage.error(getApiErrorMessage(error, '客户详情加载失败'))
} finally {
if (sequence === detailLoadSequence) detailLoading.value = false
}
}
async function toggleStatus(row: PlatformTenantResource) {
const next = row.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE'; const verb = next === 'ACTIVE' ? '启用' : '停用'
@@ -601,7 +625,12 @@ async function submitDecision() {
}
decisionOpen.value = false
detailOpen.value = false
ElMessage.success(decision.type === 'approve' ? '企业资料已审核通过' : '企业资料已驳回')
const successMessage = decision.type === 'reject'
? '企业资料已驳回'
: approved?.administratorCreated
? '企业资料已审核通过,管理员账号已创建'
: '企业资料已审核通过,注册账号继续作为企业管理员'
ElMessage.success(successMessage)
if (approved?.administratorCreated) {
approvalResult.value = approved
approvalResultOpen.value = true
@@ -637,8 +666,18 @@ async function exportCustomers() {
}
watch([page, pageSize], () => { void loadCustomers() })
watch(editorOpen, (open) => { if (!open) clearQualificationPreviews() })
watch(detailOpen, (open) => { if (!open) clearDetailQualificationPreviews() })
watch(editorOpen, (open) => {
if (!open) {
editorLoadSequence += 1
clearQualificationPreviews()
}
})
watch(detailOpen, (open) => {
if (!open) {
detailLoadSequence += 1
clearDetailQualificationPreviews()
}
})
watch(
() => [access.can('platform:customer:approve'), access.can('platform:customer:reject')],
([canApprove, canReject]) => {
@@ -655,7 +694,7 @@ onMounted(loadCustomers)
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户筛选" :fields="queryFields" @search="applyFilters" />
<section v-if="loadError" class="surface-panel customers-error" role="alert"><div><strong>客户列表暂时无法加载</strong><p>{{ loadError.message }}</p><ApiErrorMeta :details="loadError" /></div><el-button type="primary" :loading="loading" @click="loadCustomers">重新加载</el-button></section>
<section v-else class="surface-panel table-panel" :aria-busy="loading">
<div class="customers-table-header"><div class="customers-table-heading"><h2>客户列表</h2><span>共 {{ result.total }} 个客户</span></div><div class="customers-table-actions"><el-button v-if="access.can('platform:customer:export')" :loading="exportSubmitting" :disabled="loading" @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></div>
<div class="customers-table-header"><div class="customers-table-heading"><h2>客户列表</h2><span>共 {{ result.total }} 个客户</span></div><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></div>
<div v-if="reviewLoadError" class="review-sync-error" role="alert"><div><ShieldX :size="17" /><span>{{ reviewLoadError.message }}</span></div><el-button link type="primary" @click="reloadPendingReviews">重新加载待审核客户</el-button></div>
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="customerColumns" :loading="loading" :total="result.total" aria-label="客户列表" row-key="id" empty-text="暂无符合筛选条件的客户">
<template #actions="{ row }"><div class="table-actions"><el-button v-if="access.can('platform:customer:view')" link type="primary" @click="showDetail(row as PlatformTenantResource)"><Eye :size="15" />详情</el-button><el-button v-if="access.can('platform:customer:view') && access.can('platform:customer:edit')" link type="primary" @click="openEdit(row as PlatformTenantResource)"><Pencil :size="15" />编辑</el-button><el-button v-if="pendingReviewByTenantId.has(String((row as PlatformTenantResource).id)) && access.can('platform:customer:approve')" link type="success" :disabled="reviewSubmitting" @click="openDecision(row as PlatformTenantResource, 'approve')"><ShieldCheck :size="15" />通过</el-button><el-button v-if="pendingReviewByTenantId.has(String((row as PlatformTenantResource).id)) && access.can('platform:customer:reject')" link type="danger" :disabled="reviewSubmitting" @click="openDecision(row as PlatformTenantResource, 'reject')"><ShieldX :size="15" />驳回</el-button><el-button v-if="access.can('platform:customer:status') && ['ACTIVE','DISABLED'].includes((row as PlatformTenantResource).status)" link :type="(row as PlatformTenantResource).status === 'ACTIVE' ? 'danger' : 'success'" :loading="rowActionId === String((row as PlatformTenantResource).id)" @click="toggleStatus(row as PlatformTenantResource)"><Power :size="15" />{{ (row as PlatformTenantResource).status === 'ACTIVE' ? '停用' : '启用' }}</el-button></div></template>
@@ -670,6 +709,7 @@ onMounted(loadCustomers)
<div class="form-section"><h3>基础资料</h3><div class="form-grid two-cols">
<el-form-item label="公司名称" prop="tenantName" required><el-input v-model="form.tenantName" placeholder="请输入工商登记公司名称" maxlength="128" /></el-form-item>
<el-form-item label="营业执照企业名称" prop="legalName" required><el-input v-model="form.legalName" placeholder="请输入营业执照上的企业名称" maxlength="128" /></el-form-item>
<el-form-item label="法定代表人" prop="legalRepresentative" required><el-input v-model="form.legalRepresentative" placeholder="请输入法定代表人姓名" maxlength="64" /></el-form-item>
<el-form-item label="统一社会信用代码" prop="unifiedSocialCreditCode" required><el-input v-model="form.unifiedSocialCreditCode" :disabled="Boolean(editingId)" placeholder="请输入18位大写字母或数字" maxlength="18" @input="form.unifiedSocialCreditCode = String($event).toUpperCase()" /></el-form-item>
<el-form-item label="企业类型" prop="enterpriseType"><el-input v-model="form.enterpriseType" placeholder="如:有限责任公司" maxlength="64" /></el-form-item>
<el-form-item label="成立日期" prop="establishedDate"><el-date-picker v-model="form.establishedDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择成立日期" :disabled-date="(date: Date) => date.getTime() > Date.now()" /></el-form-item>
@@ -703,7 +743,7 @@ onMounted(loadCustomers)
<div role="cell"><el-date-picker v-model="draft.validTo" type="date" value-format="YYYY-MM-DD" placeholder="请选择有效期" :disabled="!canUploadQualifications || draft.uploaded" /></div>
</div>
</div>
<p class="qualification-hint">支持 PDF、JPG、PNG,单个文件不超过 10MB;<span>*</span> 为提交审核必填项。</p>
<p class="qualification-hint">支持 PDF、JPG、PNG,单个文件不超过 5MB;<span>*</span> 为提交审核必填项。</p>
</div>
</el-form>
<template #footer><div class="drawer-footer customer-editor-footer"><span>保存资料时会一并上传已选资质;进入审核仍需点击“提交审核”并再次确认</span><div><el-button :disabled="editorLoading || saving || submitting || uploadingQualifications" @click="editorOpen = false">关闭</el-button><el-button :loading="saving" :disabled="editorLoading || submitting || uploadingQualifications" @click="saveCustomer">保存资料和资质</el-button><el-button v-if="canSubmitReview" type="primary" :loading="submitting" :disabled="editorLoading || saving || uploadingQualifications" @click="submitReview">提交审核</el-button></div></div></template>
@@ -740,6 +780,8 @@ onMounted(loadCustomers)
<section class="customer-detail-section" aria-labelledby="customer-profile-heading">
<h4 id="customer-profile-heading">企业资料</h4>
<dl class="customer-detail-grid">
<div class="customer-detail-item is-wide"><dt>营业执照企业名称</dt><dd>{{ displayText(detail.legalName) }}</dd></div>
<div class="customer-detail-item"><dt>法定代表人</dt><dd>{{ displayText(detail.legalRepresentative) }}</dd></div>
<div class="customer-detail-item is-wide"><dt>统一社会信用代码</dt><dd>{{ displayText(detail.unifiedSocialCreditCode) }}</dd></div>
<div class="customer-detail-item"><dt>联系人</dt><dd>{{ displayText(detail.contactName) }}</dd></div>
<div class="customer-detail-item"><dt>联系电话</dt><dd>{{ displayText(detail.contactPhone) }}</dd></div>
@@ -763,7 +805,7 @@ onMounted(loadCustomers)
<small v-if="detailQualificationPreviews[String(item.id)]?.kind === 'pdf'">PDF</small>
</span>
<span class="detail-qualification-copy"><strong>{{ qualifications.find((draft) => draft.type === item.qualificationType)?.label || item.qualificationType }}</strong><small class="detail-qualification-file-name">{{ detailQualificationName(item) }}</small><small>{{ item.validTo ? `有效期至 ${item.validTo}` : '长期有效或未设置有效期' }}</small></span>
<span class="detail-qualification-meta"><el-tag :type="item.status === 'APPROVED' ? 'success' : item.status === 'REJECTED' ? 'danger' : 'warning'" size="small" effect="light">{{ item.status === 'APPROVED' ? '已通过' : item.status === 'REJECTED' ? '已驳回' : '待审核' }}</el-tag><span><Eye :size="14" />{{ detailQualificationPreviews[String(item.id)]?.loading ? '读取中' : (detailQualificationPreviews[String(item.id)]?.failed ? '重试' : '查看') }}</span></span>
<span class="detail-qualification-meta"><el-tag :type="qualificationStatusMeta(item.status).type" size="small" effect="light">{{ qualificationStatusMeta(item.status).label }}</el-tag><span><Eye :size="14" />{{ detailQualificationPreviews[String(item.id)]?.loading ? '读取中' : (detailQualificationPreviews[String(item.id)]?.failed ? '重试' : '查看') }}</span></span>
</button>
</div>
<div v-else class="detail-empty-qualification"><FileCheck2 :size="20" /><span>暂未上传资质文件</span></div>