修复bug

This commit is contained in:
2026-08-03 20:50:32 +08:00
parent fed46f42ef
commit 2d38c90d1c
17 changed files with 499 additions and 62 deletions
@@ -26,6 +26,10 @@ test('supports tenant profile updates and authenticated qualification downloads'
assert.match(source, /apiBlobRequest\(\{ method: 'GET', url \}\)/)
assert.match(source, /url\.startsWith\('\/merchant\/v1\/files\/qualifications\/'\)/)
assert.match(storeSource, /await updateTenantProfile/)
assert.match(source, /url: '\/merchant\/v1\/iam\/tenant\/qualifications\/submit-review'/)
assert.match(source, /data\.append\('qualificationType', qualificationType\)/)
assert.match(source, /'SUPERSEDED'/)
assert.match(storeSource, /normalizeQualificationStatus\(item\.status\)/)
})
test('uses membershipId and full replacement arrays in authorization writes', () => {
@@ -1,6 +1,7 @@
import { apiBlobRequest, apiRequest } from '@/api/http'
import type { MerchantTenantStatus } from '../contracts'
export type QualificationStatus = 'PENDING' | 'APPROVED' | 'REJECTED'
export type QualificationStatus = 'PENDING' | 'APPROVED' | 'REJECTED' | 'SUPERSEDED'
export type MerchantQualificationType = 'BUSINESS_LICENSE' | 'LEGAL_PERSON_ID' | 'FREIGHT_FORWARDER_CERTIFICATE' | 'BANK_ACCOUNT_LICENSE'
export interface MerchantTenantProfile {
id: number | string
@@ -25,7 +26,7 @@ export interface UpdateMerchantTenantProfileRequest {
export interface MerchantQualification {
id: number | string
tenantId: number | string
qualificationType: 'BUSINESS_LICENSE'
qualificationType: MerchantQualificationType
certificateNo: string | null
documentUrl: string
status: QualificationStatus
@@ -48,8 +49,8 @@ export interface MerchantPermissionResource {
export interface QualificationUploadResult {
qualificationId: number | string
reviewId: number | string
tenantStatus: 'PENDING'
reviewId: number | string | null
tenantStatus: MerchantTenantStatus
documentUrl: string
}
@@ -142,13 +143,24 @@ export function updateMerchantPersonalProfile(payload: Pick<MerchantPersonalProf
return apiRequest<MerchantPersonalProfile>({ method: 'PUT', url: '/merchant/v1/iam/profile', data: payload })
}
export function uploadBusinessLicense(file: File, certificateNo?: string) {
export function uploadMerchantQualification(
file: File,
qualificationType: MerchantQualificationType,
certificateNo?: string,
replace = false,
) {
const data = new FormData()
data.append('file', file)
data.append('qualificationType', qualificationType)
if (certificateNo) data.append('certificateNo', certificateNo)
data.append('replace', String(replace))
return apiRequest<QualificationUploadResult>({ method: 'POST', url: '/merchant/v1/files/qualifications', data })
}
export function submitTenantQualificationReview() {
return apiRequest<MerchantTenantProfile>({ method: 'POST', url: '/merchant/v1/iam/tenant/qualifications/submit-review' })
}
export function downloadQualificationImage(documentUrl: string) {
const url = documentUrl.startsWith('/api/') ? documentUrl.slice(4) : documentUrl
if (!url.startsWith('/merchant/v1/files/qualifications/')) throw new Error('资质文件地址无效')
@@ -25,6 +25,7 @@ import {
TENANT_PERMISSION_GROUPS,
type MerchantTenantStatus,
} from '@/modules/tenant-management/contracts'
import { normalizeQualificationStatus, type TenantQualificationStatus } from '@/modules/tenant-management/utils/qualificationStatus'
export type TenantRecordStatus = 'ACTIVE' | 'INACTIVE'
@@ -42,7 +43,7 @@ export interface TenantQualification {
id: string
type: string
fileName: string
status: 'VERIFIED' | 'PENDING' | 'REJECTED' | 'EXPIRED'
status: TenantQualificationStatus
expiresAt: string
updatedAt: string
documentUrl: string
@@ -93,8 +94,6 @@ export const useTenantAdministrationStore = defineStore('tenant-administration',
const activeMemberCount = computed(() => members.value.filter((member) => member.status === 'ACTIVE').length)
async function loadCompanyContext() {
company.value = { ...emptyCompany }
qualifications.value = []
const [profile, qualificationRecords] = await Promise.all([
getTenantProfile(),
getTenantQualifications(),
@@ -110,9 +109,14 @@ export const useTenantAdministrationStore = defineStore('tenant-administration',
}
qualifications.value = qualificationRecords.map((item) => ({
id: String(item.id),
type: item.qualificationType === 'BUSINESS_LICENSE' ? '营业执照' : item.qualificationType,
fileName: item.documentUrl.split('/').pop() || '营业执照',
status: item.status === 'APPROVED' ? 'VERIFIED' : item.status,
type: ({
BUSINESS_LICENSE: '营业执照',
LEGAL_PERSON_ID: '法人身份证',
FREIGHT_FORWARDER_CERTIFICATE: '货代资质证书',
BANK_ACCOUNT_LICENSE: '银行开户许可证',
} as const)[item.qualificationType],
fileName: item.documentUrl.split('/').pop() || '资质文件',
status: normalizeQualificationStatus(item.status),
expiresAt: '-',
updatedAt: '-',
documentUrl: item.documentUrl,
@@ -2,13 +2,14 @@ import assert from 'node:assert/strict'
import test from 'node:test'
import { formatQualificationFileSize, QUALIFICATION_MAX_FILE_SIZE, validateQualificationFile } from './qualificationFile.ts'
test('accepts the backend-confirmed JPG and PNG formats up to 5MB', () => {
test('accepts the backend-confirmed PDF, JPG and PNG formats up to 5MB', () => {
assert.equal(validateQualificationFile({ name: '营业执照.jpeg', size: QUALIFICATION_MAX_FILE_SIZE, type: 'image/jpeg' }), '')
assert.equal(validateQualificationFile({ name: '营业执照.PNG', size: 2048, type: 'image/png' }), '')
assert.equal(validateQualificationFile({ name: '货代资质.pdf', size: 2048, type: 'application/pdf' }), '')
})
test('rejects PDF, empty, mismatched, and oversized files', () => {
assert.match(validateQualificationFile({ name: '营业执照.pdf', size: 1024, type: 'application/pdf' }), /格式不支持/)
test('rejects unsupported, empty, mismatched, and oversized files', () => {
assert.match(validateQualificationFile({ name: '营业执照.svg', size: 1024, type: 'image/svg+xml' }), /格式不支持/)
assert.match(validateQualificationFile({ name: '伪装图片.png', size: 1024, type: 'text/plain' }), /内容类型/)
assert.match(validateQualificationFile({ name: '空文件.png', size: 0, type: 'image/png' }), /内容为空/)
assert.match(validateQualificationFile({ name: '超大文件.jpg', size: QUALIFICATION_MAX_FILE_SIZE + 1, type: 'image/jpeg' }), /超过 5MB/)
@@ -1,7 +1,7 @@
export const QUALIFICATION_MAX_FILE_SIZE = 5 * 1024 * 1024
const allowedExtensions = new Set(['jpg', 'jpeg', 'png'])
const allowedMimeTypes = new Set(['image/jpeg', 'image/png'])
const allowedExtensions = new Set(['jpg', 'jpeg', 'png', 'pdf'])
const allowedMimeTypes = new Set(['image/jpeg', 'image/png', 'application/pdf'])
export interface QualificationFileLike {
name: string
@@ -11,7 +11,7 @@ export interface QualificationFileLike {
export function validateQualificationFile(file: QualificationFileLike) {
const extension = file.name.includes('.') ? file.name.split('.').pop()?.toLowerCase() ?? '' : ''
if (!allowedExtensions.has(extension)) return '文件格式不支持,请选择 JPG 或 PNG 图片'
if (!allowedExtensions.has(extension)) return '文件格式不支持,请选择 PDF、JPG 或 PNG 文件'
if (file.type && !allowedMimeTypes.has(file.type)) return '文件内容类型与支持的资质格式不一致'
if (file.size <= 0) return '文件内容为空,请重新选择'
if (file.size > QUALIFICATION_MAX_FILE_SIZE) return '文件超过 5MB,请压缩后重新选择'
@@ -0,0 +1,14 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { normalizeQualificationStatus, qualificationStatusMeta } from './qualificationStatus.ts'
test('maps backend qualification states without treating replaced files as expired', () => {
assert.equal(normalizeQualificationStatus('APPROVED'), 'VERIFIED')
assert.equal(normalizeQualificationStatus('SUPERSEDED'), 'SUPERSEDED')
assert.deepEqual(qualificationStatusMeta('SUPERSEDED'), { label: '已替换', type: 'info' })
})
test('keeps a safe expired fallback for unknown legacy states', () => {
assert.equal(normalizeQualificationStatus('UNKNOWN'), 'EXPIRED')
assert.deepEqual(qualificationStatusMeta('EXPIRED'), { label: '已过期', type: 'info' })
})
@@ -0,0 +1,15 @@
export type TenantQualificationStatus = 'VERIFIED' | 'PENDING' | 'REJECTED' | 'SUPERSEDED' | 'EXPIRED'
export function normalizeQualificationStatus(status: string): TenantQualificationStatus {
if (status === 'APPROVED') return 'VERIFIED'
if (status === 'PENDING' || status === 'REJECTED' || status === 'SUPERSEDED') return status
return 'EXPIRED'
}
export function qualificationStatusMeta(status: TenantQualificationStatus) {
if (status === 'VERIFIED') return { label: '已验证', type: 'success' as const }
if (status === 'PENDING') return { label: '待审核', type: 'warning' as const }
if (status === 'REJECTED') return { label: '已驳回', type: 'danger' as const }
if (status === 'SUPERSEDED') return { label: '已替换', type: 'info' as const }
return { label: '已过期', type: 'info' as const }
}
@@ -7,17 +7,23 @@ import DataTable, { type DataTableColumn } from '@/components/common/DataTable.v
import { useTenantAdministrationStore, type TenantCompanyProfile, type TenantQualification } from '@/modules/tenant-management/store/tenantAdministration'
import { useAuthStore } from '@/modules/auth/public'
import { formatQualificationFileSize, validateQualificationFile } from '@/modules/tenant-management/utils/qualificationFile'
import { downloadQualificationImage, uploadBusinessLicense } from '@/modules/tenant-management/api/merchantIam'
import { qualificationStatusMeta } from '@/modules/tenant-management/utils/qualificationStatus'
import {
downloadQualificationImage,
submitTenantQualificationReview,
uploadMerchantQualification,
type MerchantQualificationType,
} from '@/modules/tenant-management/api/merchantIam'
import { ApiError } from '@/api/http'
const store = useTenantAdministrationStore()
const auth = useAuthStore()
const companyApproved = computed(() => store.company.auditStatus === 'ACTIVE')
const auditStatusCopy = computed(() => ({
PROFILE_REQUIRED: { title: '请上传营业执照', detail: '营业执照提交审核前不能提交头程订单。' },
PROFILE_REQUIRED: { title: '请完善企业资质', detail: '四类必填资质提交审核前不能提交头程订单。' },
PENDING: { title: '企业资料正在等待平台审核', detail: '审核期间仍可维护资料和保存订单草稿;审核通过前不能提交订单。' },
ACTIVE: { title: '企业资料已审核,下单资格已开通', detail: '您可以创建、保存并提交头程订单。' },
REJECTED: { title: '企业资料审核未通过', detail: '请根据平台反馈修正资料;当前后端暂不支持同一企业重新上传。' },
REJECTED: { title: '企业资料审核未通过', detail: '请根据平台反馈替换对应资质,四类文件齐全后可重新提交审核。' },
DISABLED: { title: '企业账号已被平台停用', detail: '当前不能提交订单,请联系平台客服核实。' },
})[store.company.auditStatus])
const activeTab = ref('basic')
@@ -26,6 +32,7 @@ const qualificationOpen = ref(false)
const previewOpen = ref(false)
const previewRecord = ref<TenantQualification | null>(null)
const previewUrl = ref('')
const previewKind = ref<'image' | 'pdf'>('image')
const previewError = ref('')
const previewLoading = ref(false)
const profileFormRef = ref<FormInstance>()
@@ -37,13 +44,31 @@ const selectedFileSize = ref(0)
const selectedFileType = ref('')
const qualificationFileError = ref('')
const qualificationSubmitting = ref(false)
const reviewSubmitting = ref(false)
const profileSubmitting = ref(false)
const loading = ref(false)
const loadError = ref('')
const selectedFile = ref<File | null>(null)
const draft = reactive<TenantCompanyProfile>({ ...store.company })
const qualificationDraft = reactive({ type: '营业执照', fileName: '', certificateNo: '' })
const qualificationTypes: Array<{ value: MerchantQualificationType; label: string }> = [
{ value: 'BUSINESS_LICENSE', label: '营业执照' },
{ value: 'LEGAL_PERSON_ID', label: '法人身份证' },
{ value: 'FREIGHT_FORWARDER_CERTIFICATE', label: '货代资质证书' },
{ value: 'BANK_ACCOUNT_LICENSE', label: '银行开户许可证' },
]
const qualificationDraft = reactive({ type: 'BUSINESS_LICENSE' as MerchantQualificationType, fileName: '', certificateNo: '' })
const canUploadQualification = computed(() => ['PROFILE_REQUIRED', 'REJECTED'].includes(store.company.auditStatus))
const uploadedQualificationTypes = computed(() => new Set(store.qualifications
.filter((item) => item.status === 'PENDING')
.map((item) => qualificationTypes.find((type) => type.label === item.type)?.value)
.filter((type): type is MerchantQualificationType => Boolean(type))))
const canSubmitQualificationReview = computed(() => canUploadQualification.value
&& qualificationTypes.every((type) => uploadedQualificationTypes.value.has(type.value)))
const qualificationReviewDisabledReason = computed(() => {
if (!canUploadQualification.value) return qualificationUploadDisabledReason.value
const missing = qualificationTypes.filter((type) => !uploadedQualificationTypes.value.has(type.value)).map((type) => type.label)
return missing.length ? `请先上传:${missing.join('、')}` : ''
})
const qualificationUploadDisabledReason = computed(() => {
if (store.company.auditStatus === 'PENDING') return '企业资料正在审核,暂不能再次提交资质文件。'
if (store.company.auditStatus === 'ACTIVE') return '当前企业已审核通过,资质替换请联系平台管理员处理。'
@@ -51,7 +76,7 @@ const qualificationUploadDisabledReason = computed(() => {
return ''
})
const qualificationPanelHint = computed(() => canUploadQualification.value
? '一期仅支持提交营业执照;提交后将进入平台审核。'
? '请先上传四类必填资质;文件上传不会自动送审,全部齐全后再提交企业审核。'
: qualificationUploadDisabledReason.value)
const profileFormRules: FormRules<TenantCompanyProfile> = {
companyName: [{ required: true, whitespace: true, message: '请输入公司名称', trigger: 'blur' }],
@@ -61,7 +86,7 @@ const profileFormRules: FormRules<TenantCompanyProfile> = {
contactEmail: [{ type: 'email', message: '请输入正确的联系邮箱', trigger: 'blur' }],
}
const qualificationFormRules: FormRules = {
fileName: [{ required: true, message: '请选择营业执照图片', trigger: 'change' }],
fileName: [{ required: true, message: '请选择资质文件', trigger: 'change' }],
}
const qualificationColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
@@ -76,19 +101,24 @@ const pagedQualifications = computed(() => {
return store.qualifications.slice(start, start + qualificationPageSize.value)
})
async function loadCompanyContext() {
loading.value = true
async function refreshCompanyContext() {
loadError.value = ''
try {
await store.loadCompanyContext()
Object.assign(draft, store.company)
return true
} catch (error) {
loadError.value = error instanceof Error ? error.message : '企业资料加载失败'
} finally {
loading.value = false
return false
}
}
async function loadCompanyContext() {
loading.value = true
await refreshCompanyContext()
loading.value = false
}
onMounted(() => void loadCompanyContext())
function startEdit() {
@@ -163,7 +193,7 @@ function removeSelectedFile() {
function resetQualificationDraft() {
uploadRef.value?.clearFiles()
qualificationDraft.type = '营业执照'
qualificationDraft.type = 'BUSINESS_LICENSE'
qualificationDraft.fileName = ''
qualificationDraft.certificateNo = store.company.creditCode
selectedFileSize.value = 0
@@ -183,7 +213,7 @@ async function submitQualification() {
}
const valid = await qualificationFormRef.value?.validate().then(() => true).catch(() => false)
if (!valid || !selectedFile.value) {
qualificationFileError.value = '请选择营业执照图片'
qualificationFileError.value = '请选择资质文件'
return
}
if (qualificationFileError.value) {
@@ -192,23 +222,54 @@ async function submitQualification() {
}
qualificationSubmitting.value = true
try {
await uploadBusinessLicense(selectedFile.value, qualificationDraft.certificateNo.trim() || undefined)
await store.loadCompanyContext()
qualificationOpen.value = false
ElMessage.success('营业执照已提交审核')
} catch (error) {
if (error instanceof ApiError && error.code === 'A0409') {
await loadCompanyContext()
try {
const existing = store.qualifications.find((item) => (
item.type === qualificationTypes.find((type) => type.value === qualificationDraft.type)?.label
&& item.status !== 'SUPERSEDED'
))
await uploadMerchantQualification(
selectedFile.value,
qualificationDraft.type,
qualificationDraft.type === 'BUSINESS_LICENSE' ? qualificationDraft.certificateNo.trim() || undefined : undefined,
Boolean(existing),
)
qualificationOpen.value = false
ElMessage.warning('企业资料正在审核,不能再次提交资质文件')
} catch (error) {
if (error instanceof ApiError && error.code === 'A0409') {
await loadCompanyContext()
qualificationOpen.value = false
ElMessage.warning('企业资料正在审核,不能再次提交资质文件')
return
}
ElMessage.error(error instanceof Error ? error.message : '资质文件上传失败')
return
}
ElMessage.error(error instanceof Error ? error.message : '营业执照上传失败')
const refreshed = await refreshCompanyContext()
if (refreshed) ElMessage.success('资质文件已上传,尚未提交审核')
else ElMessage.warning('资质文件已上传,但最新状态刷新失败,请点击“重新加载”确认状态')
} finally {
qualificationSubmitting.value = false
}
}
async function submitQualificationReview() {
if (!canSubmitQualificationReview.value || reviewSubmitting.value) return
reviewSubmitting.value = true
try {
try {
await submitTenantQualificationReview()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '企业审核提交失败')
return
}
const refreshed = await refreshCompanyContext()
if (refreshed) ElMessage.success('企业资料已提交审核')
else ElMessage.warning('企业资料已提交审核,但最新状态刷新失败,请点击“重新加载”确认状态')
} finally {
reviewSubmitting.value = false
}
}
async function previewQualification(record: TenantQualification) {
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
previewRecord.value = record
@@ -222,6 +283,7 @@ async function previewQualification(record: TenantQualification) {
previewLoading.value = true
try {
const blob = await downloadQualificationImage(record.documentUrl)
previewKind.value = blob.type === 'application/pdf' || record.fileName.toLowerCase().endsWith('.pdf') ? 'pdf' : 'image'
previewUrl.value = URL.createObjectURL(blob)
} catch (error) {
previewError.value = error instanceof Error && error.message === '资质文件地址无效'
@@ -236,13 +298,11 @@ async function previewQualification(record: TenantQualification) {
function closeQualificationPreview() {
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
previewUrl.value = ''
previewKind.value = 'image'
previewError.value = ''
previewRecord.value = null
}
function qualificationStatus(status: string) {
return status === 'VERIFIED' ? '已验证' : status === 'PENDING' ? '待审核' : status === 'REJECTED' ? '已驳回' : '已过期'
}
</script>
<template>
@@ -287,9 +347,10 @@ function qualificationStatus(status: string) {
<div class="client-card qualification-panel">
<header>
<div><h3>企业资质</h3><p>{{ qualificationPanelHint }}</p></div>
<el-tooltip v-if="auth.hasPermission('company:qualification:upload')" :disabled="canUploadQualification" :content="qualificationUploadDisabledReason" placement="top">
<span class="qualification-update-control"><el-button type="primary" :disabled="!canUploadQualification" @click="openQualificationDialog"><Upload :size="16" />更新资质</el-button></span>
</el-tooltip>
<div v-if="auth.hasPermission('company:qualification:upload')" class="qualification-panel-actions">
<el-tooltip :disabled="canUploadQualification" :content="qualificationUploadDisabledReason" placement="top"><span class="qualification-update-control"><el-button :disabled="!canUploadQualification" @click="openQualificationDialog"><Upload :size="16" />上传资质</el-button></span></el-tooltip>
<el-tooltip :disabled="canSubmitQualificationReview" :content="qualificationReviewDisabledReason" placement="top"><span class="qualification-update-control"><el-button type="primary" :loading="reviewSubmitting" :disabled="!canSubmitQualificationReview" @click="submitQualificationReview"><FileCheck2 :size="16" />提交企业审核</el-button></span></el-tooltip>
</div>
</header>
<DataTable
v-model:page="qualificationPage"
@@ -303,11 +364,11 @@ function qualificationStatus(status: string) {
row-key="id"
>
<template #actions="{ row }"><el-button link type="primary" @click="previewQualification(row as TenantQualification)">查看</el-button></template>
<template #status="{ row }"><el-tag :type="row.status === 'VERIFIED' ? 'success' : row.status === 'PENDING' ? 'warning' : 'danger'" effect="light">{{ qualificationStatus(row.status) }}</el-tag></template>
<template #status="{ row }"><el-tag :type="qualificationStatusMeta(row.status).type" effect="light">{{ qualificationStatusMeta(row.status).label }}</el-tag></template>
</DataTable>
<div class="mobile-qualification-list">
<article v-for="record in store.qualifications" :key="record.id">
<header><strong>{{ record.type }}</strong><el-tag :type="record.status === 'VERIFIED' ? 'success' : record.status === 'PENDING' ? 'warning' : 'danger'" effect="light">{{ qualificationStatus(record.status) }}</el-tag></header>
<header><strong>{{ record.type }}</strong><el-tag :type="qualificationStatusMeta(record.status).type" effect="light">{{ qualificationStatusMeta(record.status).label }}</el-tag></header>
<div class="qualification-file-name"><FileText :size="16" /><span>{{ record.fileName }}</span></div>
<dl><div><dt>有效期</dt><dd>{{ record.expiresAt }}</dd></div><div><dt>更新时间</dt><dd>{{ record.updatedAt }}</dd></div></dl>
<el-button link type="primary" @click="previewQualification(record)">查看文件信息</el-button>
@@ -338,11 +399,11 @@ function qualificationStatus(status: string) {
<el-dialog v-model="qualificationOpen" title="更新资质文件" width="min(520px, 92vw)" @closed="resetQualificationDraft">
<el-form ref="qualificationFormRef" :model="qualificationDraft" :rules="qualificationFormRules" label-position="top">
<el-form-item label="文件类型"><el-input v-model="qualificationDraft.type" disabled /></el-form-item>
<el-form-item label="文件类型" required><el-select v-model="qualificationDraft.type"><el-option v-for="type in qualificationTypes" :key="type.value" :label="type.label" :value="type.value" /></el-select></el-form-item>
<el-form-item label="选择文件" prop="fileName" required :error="qualificationFileError">
<el-upload ref="uploadRef" :auto-upload="false" :limit="1" :show-file-list="false" accept=".jpg,.jpeg,.png" @change="handleFileChange">
<el-upload ref="uploadRef" :auto-upload="false" :limit="1" :show-file-list="false" accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png" @change="handleFileChange">
<el-button><Upload :size="16" />选择文件</el-button>
<template #tip><div class="upload-tip">仅支持 JPG、PNG,单张图片不超过 5MB。</div></template>
<template #tip><div class="upload-tip">支持 PDF、JPG、PNG,单个文件不超过 5MB。</div></template>
</el-upload>
<div v-if="qualificationDraft.fileName" class="selected-qualification-file" aria-live="polite">
<FileText :size="20" />
@@ -350,17 +411,18 @@ function qualificationStatus(status: string) {
<el-button link type="danger" aria-label="移除所选资质文件" @click="removeSelectedFile"><Trash2 :size="16" />移除</el-button>
</div>
</el-form-item>
<el-form-item label="统一社会信用代码"><el-input v-model="qualificationDraft.certificateNo" maxlength="18" placeholder="建议填写 18 位统一社会信用代码" /></el-form-item>
<el-form-item v-if="qualificationDraft.type === 'BUSINESS_LICENSE'" label="统一社会信用代码"><el-input v-model="qualificationDraft.certificateNo" maxlength="18" placeholder="建议填写 18 位统一社会信用代码" /></el-form-item>
</el-form>
<template #footer><el-button :disabled="qualificationSubmitting" @click="qualificationOpen = false">取消</el-button><el-button type="primary" :loading="qualificationSubmitting" @click="submitQualification">提交审核</el-button></template>
<template #footer><el-button :disabled="qualificationSubmitting" @click="qualificationOpen = false">取消</el-button><el-button type="primary" :loading="qualificationSubmitting" @click="submitQualification">保存资质文件</el-button></template>
</el-dialog>
<el-dialog v-model="previewOpen" :title="`${previewRecord?.type || '资质文件'}详情`" width="min(560px, 92vw)" @closed="closeQualificationPreview">
<div v-if="previewRecord" v-loading="previewLoading" class="document-preview">
<img v-if="previewUrl" :src="previewUrl" :alt="`${previewRecord.type}预览`" class="qualification-preview-image" />
<img v-if="previewUrl && previewKind === 'image'" :src="previewUrl" :alt="`${previewRecord.type}预览`" class="qualification-preview-image" />
<iframe v-else-if="previewUrl && previewKind === 'pdf'" :src="previewUrl" :title="`${previewRecord.type} PDF 预览`" class="qualification-preview-frame" />
<FileCheck2 :size="42" />
<strong>{{ previewRecord.fileName }}</strong>
<el-tag :type="previewRecord.status === 'VERIFIED' ? 'success' : previewRecord.status === 'PENDING' ? 'warning' : 'danger'" effect="light">{{ qualificationStatus(previewRecord.status) }}</el-tag>
<el-tag :type="qualificationStatusMeta(previewRecord.status).type" effect="light">{{ qualificationStatusMeta(previewRecord.status).label }}</el-tag>
<dl><div><dt>文件类型</dt><dd>{{ previewRecord.type }}</dd></div><div><dt>有效期</dt><dd>{{ previewRecord.expiresAt }}</dd></div><div><dt>更新时间</dt><dd>{{ previewRecord.updatedAt }}</dd></div></dl>
<p v-if="previewError" class="document-preview-error" role="alert">{{ previewError }}</p>
<span v-else>{{ previewUrl ? '文件通过当前商户会话临时加载,关闭弹窗后立即释放。' : '当前文件暂不可预览。' }}</span>
@@ -382,6 +444,7 @@ function qualificationStatus(status: string) {
.company-audit-notice span { color: var(--muted); font-size: 12px; line-height: 1.6; }
.company-audit-notice.is-approved { border-color: #bcd9d2; background: #eff8f5; color: #20745f; }
.qualification-preview-image { display: block; max-width: 100%; max-height: 360px; margin: 0 auto 14px; object-fit: contain; }
.qualification-preview-frame { display:block; width:100%; min-height:min(60vh,560px); margin:0 0 14px; border:0; background:#fff; }
.company-audit-notice.is-pending { border-color: #e5d2a5; background: #fff9eb; color: #9a6a12; }
.qualification-panel header p { margin: 6px 0 0; color: var(--muted); font-size: 12px; }
.profile-tabs :deep(.el-tabs__header) { margin-bottom: 14px; padding: 0 16px; border: 1px solid var(--border); background: #fff; }
@@ -399,6 +462,7 @@ function qualificationStatus(status: string) {
.qualification-panel { padding: 0; overflow: hidden; }
.qualification-panel > header { padding: 20px 22px; border-bottom: 1px solid var(--border); }
.qualification-panel h3 { margin: 0; }
.qualification-panel-actions { display:flex; flex-wrap:wrap; justify-content:flex-end; gap:8px; }
.qualification-update-control { display: inline-flex; }
.mobile-qualification-list { display: none; }
.profile-form h3 { margin: 4px 0 18px; padding-bottom: 10px; border-bottom: 1px solid var(--border); font-size: 15px; }
@@ -424,6 +488,8 @@ function qualificationStatus(status: string) {
.profile-grid, .form-grid-two { grid-template-columns: 1fr; }
.profile-grid > div:nth-child(odd) { border-right: 0; }
.qualification-panel > header { align-items: flex-start; gap: 12px; }
.qualification-panel-actions { display:grid; width:100%; grid-template-columns:1fr; }
.qualification-panel-actions .qualification-update-control,.qualification-panel-actions :deep(.el-button) { width:100%; }
.qualification-panel > header .el-button { flex: 0 0 auto; }
.desktop-qualification-table { display: none; }
.mobile-qualification-list { display: grid; gap: 10px; padding: 10px; }
@@ -39,7 +39,13 @@ test('implements company details, contact information and qualification workflow
assert.match(profileSource, /prop="companyName" required><el-input v-model="draft\.companyName" placeholder="请输入公司名称"/)
assert.match(profileSource, /prop="fileName" required :error="qualificationFileError"/)
assert.match(profileSource, /const valid = await profileFormRef\.value\?\.validate\(\)/)
assert.match(profileSource, /qualificationFileError\.value = '请选择营业执照图片'/)
assert.match(profileSource, /qualificationFileError\.value = '请选择资质文件'/)
assert.match(profileSource, /submitTenantQualificationReview/)
assert.match(profileSource, /提交企业审核/)
assert.match(profileSource, /资质文件已上传,尚未提交审核/)
assert.match(profileSource, /资质文件已上传,但最新状态刷新失败/)
assert.match(profileSource, /企业资料已提交审核,但最新状态刷新失败/)
assert.match(profileSource, /qualificationStatusMeta/)
assert.match(profileSource, /const canUploadQualification = computed/)
assert.match(profileSource, /:disabled="!canUploadQualification"/)
assert.match(profileSource, /error\.code === 'A0409'/)