This commit is contained in:
2026-08-04 09:05:01 +08:00
parent 2d38c90d1c
commit 198101c42c
7 changed files with 305 additions and 113 deletions
+3 -1
View File
@@ -41,7 +41,9 @@ test('implements every backend registration field and real submission', () => {
test('shows immediate login after registration and separates company review from account activation', () => {
assert.match(registerSource, /企业账号注册成功/)
assert.match(registerSource, /无需等待注册审核/)
assert.match(registerSource, /提交订单前还需完成一步/)
assert.match(registerSource, /提交订单前还需完成企业审核/)
assert.match(registerSource, /营业执照、法人身份证、货代资质证书和银行开户许可证/)
assert.match(registerSource, /一次补齐四类必填资质并提交审核/)
assert.match(registerSource, /立即登录/)
})
+2 -2
View File
@@ -116,7 +116,7 @@ async function submit() {
<span><RouterLink to="/legal/terms" target="_blank" rel="noopener">《用户服务协议》</RouterLink> 和 <RouterLink to="/legal/privacy" target="_blank" rel="noopener">《隐私政策》</RouterLink></span>
</div>
</el-form-item>
<p class="register-security-note">注册成功后账号可立即登录;首次登录需上传营业执照并等待平台审核,审核通过后才能提交订单。</p>
<p class="register-security-note">注册成功后账号可立即登录;首次登录需补齐营业执照、法人身份证、货代资质证书和银行开户许可证,提交平台审核通过后才能提交订单。</p>
<div class="register-actions"><el-button @click="$router.push('/login')">取消</el-button><el-button native-type="submit" type="primary" :loading="submitting">注册企业账号</el-button></div>
</el-form>
</template>
@@ -126,7 +126,7 @@ async function submit() {
<h1>企业账号注册成功</h1>
<p><strong>{{ form.username }}</strong> 现在可以直接登录客户工作台,无需等待注册审核。</p>
<dl><div><dt>企业名称</dt><dd>{{ form.companyName }}</dd></div><div><dt>登录账号</dt><dd>{{ form.username }}</dd></div><div><dt>注册时间</dt><dd>{{ submittedAt }}</dd></div><div><dt>账号状态</dt><dd><el-tag type="success" effect="light">可登录</el-tag></dd></div></dl>
<div class="registration-next-step" role="note"><strong>提交订单前还需完成一步</strong><span>登录后请在“企业资料”补充公司和资质信息,等待平台人工审核通过。审核期间仍可维护资料、成员和订单草稿。</span></div>
<div class="registration-next-step" role="note"><strong>提交订单前还需完成企业审核</strong><span>登录后请在“企业资料”一次补齐四类必填资质并提交审核。审核期间仍可维护资料、成员和订单草稿。</span></div>
<div class="registration-result-actions"><el-button type="primary" @click="router.push({ path: '/login', query: { account: form.username } })">立即登录</el-button></div>
</section>
</main>
@@ -27,7 +27,8 @@ test('supports tenant profile updates and authenticated qualification downloads'
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, /params: \{ qualificationType, certificateNo, replace \}/)
assert.doesNotMatch(source, /data\.append\('qualificationType'/)
assert.match(source, /'SUPERSEDED'/)
assert.match(storeSource, /normalizeQualificationStatus\(item\.status\)/)
})
@@ -9,6 +9,7 @@ export interface MerchantTenantProfile {
tenantName: string
status: MerchantTenantStatus
legalName: string
legalRepresentative: string
unifiedSocialCreditCode: string | null
contactName: string
contactPhone: string
@@ -18,6 +19,7 @@ export interface MerchantTenantProfile {
export interface UpdateMerchantTenantProfileRequest {
tenantName: string
legalName: string
legalRepresentative: string
contactName: string
contactPhone: string
contactEmail: string
@@ -151,10 +153,12 @@ export function uploadMerchantQualification(
) {
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 })
return apiRequest<QualificationUploadResult>({
method: 'POST',
url: '/merchant/v1/files/qualifications',
params: { qualificationType, certificateNo, replace },
data,
})
}
export function submitTenantQualificationReview() {
@@ -101,7 +101,7 @@ export const useTenantAdministrationStore = defineStore('tenant-administration',
company.value = {
companyName: profile.tenantName,
creditCode: profile.unifiedSocialCreditCode ?? '',
legalRepresentative: profile.legalName,
legalRepresentative: profile.legalRepresentative,
contactName: profile.contactName,
contactPhone: profile.contactPhone,
contactEmail: profile.contactEmail ?? '',
@@ -173,7 +173,8 @@ export const useTenantAdministrationStore = defineStore('tenant-administration',
async function updateCompany(payload: TenantCompanyProfile) {
const updated = await updateTenantProfile({
tenantName: payload.companyName.trim(),
legalName: payload.legalRepresentative.trim(),
legalName: payload.companyName.trim(),
legalRepresentative: payload.legalRepresentative.trim(),
contactName: payload.contactName.trim(),
contactPhone: payload.contactPhone.trim(),
contactEmail: payload.contactEmail.trim(),
@@ -181,7 +182,7 @@ export const useTenantAdministrationStore = defineStore('tenant-administration',
company.value = {
companyName: updated.tenantName,
creditCode: updated.unifiedSocialCreditCode ?? '',
legalRepresentative: updated.legalName,
legalRepresentative: updated.legalRepresentative,
contactName: updated.contactName,
contactPhone: updated.contactPhone,
contactEmail: updated.contactEmail ?? '',
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules, UploadFile, UploadInstance } from 'element-plus'
import type { FormInstance, FormRules, UploadFile } from 'element-plus'
import { Building2, CheckCircle2, Clock3, FileCheck2, FileText, Pencil, Trash2, Upload, UserRound } from '@lucide/vue'
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
import { useTenantAdministrationStore, type TenantCompanyProfile, type TenantQualification } from '@/modules/tenant-management/store/tenantAdministration'
@@ -14,7 +14,6 @@ import {
uploadMerchantQualification,
type MerchantQualificationType,
} from '@/modules/tenant-management/api/merchantIam'
import { ApiError } from '@/api/http'
const store = useTenantAdministrationStore()
const auth = useAuthStore()
@@ -36,47 +35,98 @@ const previewKind = ref<'image' | 'pdf'>('image')
const previewError = ref('')
const previewLoading = ref(false)
const profileFormRef = ref<FormInstance>()
const qualificationFormRef = ref<FormInstance>()
const uploadRef = ref<UploadInstance>()
const qualificationPage = ref(1)
const qualificationPageSize = ref(10)
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 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: '银行开户许可证' },
type QualificationUploadState = 'idle' | 'uploading' | 'uploaded' | 'error'
type QualificationTypeDefinition = { value: MerchantQualificationType; label: string; description: string }
type QualificationDraftItem = QualificationTypeDefinition & {
file: File | null
fileName: string
fileSize: number
fileType: string
inputVersion: number
error: string
uploadState: QualificationUploadState
}
const qualificationTypes: QualificationTypeDefinition[] = [
{ value: 'BUSINESS_LICENSE', label: '营业执照', description: '上传清晰完整的营业执照文件。' },
{ value: 'LEGAL_PERSON_ID', label: '法人身份证', description: '正反面请合并为一个 PDF、JPG 或 PNG 文件。' },
{ value: 'FREIGHT_FORWARDER_CERTIFICATE', label: '货代资质证书', description: '上传当前有效的货代资质证明。' },
{ value: 'BANK_ACCOUNT_LICENSE', label: '银行开户许可证', description: '上传企业对公账户开户证明。' },
]
const qualificationDraft = reactive({ type: 'BUSINESS_LICENSE' as MerchantQualificationType, fileName: '', certificateNo: '' })
const qualificationDrafts = reactive<QualificationDraftItem[]>(qualificationTypes.map((type) => ({
...type,
file: null,
fileName: '',
fileSize: 0,
fileType: '',
inputVersion: 0,
error: '',
uploadState: 'idle',
})))
const qualificationCertificateNo = ref('')
const unifiedSocialCreditCodePattern = /^[0-9A-Z]{18}$/
const companyCreditCodeValid = computed(() => unifiedSocialCreditCodePattern.test(store.company.creditCode))
const normalizedQualificationCertificateNo = computed(() => qualificationCertificateNo.value.trim().toUpperCase())
const businessLicenseFileSelected = computed(() => Boolean(
qualificationDrafts.find((item) => item.value === 'BUSINESS_LICENSE')?.file,
))
const qualificationCreditCodeReady = computed(() => companyCreditCodeValid.value
|| (businessLicenseFileSelected.value && unifiedSocialCreditCodePattern.test(normalizedQualificationCertificateNo.value)))
const qualificationCreditCodeError = computed(() => {
if (qualificationCreditCodeReady.value) return ''
if (!businessLicenseFileSelected.value) return '请重新选择营业执照,并填写18位统一社会信用代码'
return '请输入18位字母或数字组成的统一社会信用代码'
})
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 '当前企业已审核通过,资质替换请联系平台管理员处理。'
if (store.company.auditStatus === 'DISABLED') return '企业账号已停用,暂不能提交资质文件。'
return ''
})
const missingQualificationTypes = computed(() => qualificationTypes
.filter((type) => {
const draftItem = qualificationDrafts.find((item) => item.value === type.value)
return !uploadedQualificationTypes.value.has(type.value)
&& !draftItem?.file
&& draftItem?.uploadState !== 'uploaded'
})
.map((type) => type.label))
const selectedQualificationCount = computed(() => qualificationDrafts.filter((item) => Boolean(item.file)).length)
const canSaveQualificationBatch = computed(() => canUploadQualification.value
&& selectedQualificationCount.value > 0
&& missingQualificationTypes.value.length === 0
&& qualificationCreditCodeReady.value)
const qualificationSaveDisabledReason = computed(() => {
if (!canUploadQualification.value) return qualificationUploadDisabledReason.value
if (missingQualificationTypes.value.length) return `还需选择:${missingQualificationTypes.value.join('、')}`
if (!qualificationCreditCodeReady.value) return qualificationCreditCodeError.value
if (!selectedQualificationCount.value) return '请选择需要新增或替换的资质文件'
return ''
})
const canSubmitQualificationReview = computed(() => canUploadQualification.value
&& companyCreditCodeValid.value
&& qualificationTypes.every((type) => uploadedQualificationTypes.value.has(type.value)))
const qualificationReviewDisabledReason = computed(() => {
if (!canUploadQualification.value) return qualificationUploadDisabledReason.value
if (!companyCreditCodeValid.value) return '请先通过营业执照填写18位统一社会信用代码'
const missing = qualificationTypes.filter((type) => !uploadedQualificationTypes.value.has(type.value)).map((type) => type.label)
return missing.length ? `请先上传:${missing.join('、')}` : ''
})
const qualificationPanelHint = computed(() => canUploadQualification.value
? '请先上传四类必填资质;文件上传不会自动送审,全部齐全后再提交企业审核。'
? '一次选择四类必填资质;文件保存不会自动送审,全部齐全后再提交企业审核。'
: qualificationUploadDisabledReason.value)
const profileFormRules: FormRules<TenantCompanyProfile> = {
companyName: [{ required: true, whitespace: true, message: '请输入公司名称', trigger: 'blur' }],
@@ -85,9 +135,6 @@ const profileFormRules: FormRules<TenantCompanyProfile> = {
contactPhone: [{ required: true, whitespace: true, message: '请输入联系电话', trigger: 'blur' }],
contactEmail: [{ type: 'email', message: '请输入正确的联系邮箱', trigger: 'blur' }],
}
const qualificationFormRules: FormRules = {
fileName: [{ required: true, message: '请选择资质文件', trigger: 'change' }],
}
const qualificationColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
{ key: 'type', prop: 'type', label: '文件类型', minWidth: 150 },
@@ -159,48 +206,97 @@ function openQualificationDialog() {
qualificationOpen.value = true
}
function handleFileChange(file: UploadFile) {
function qualificationRecord(type: MerchantQualificationType) {
const label = qualificationTypes.find((item) => item.value === type)?.label
const records = store.qualifications.filter((item) => item.type === label && item.status !== 'SUPERSEDED')
return records.find((item) => item.status === 'PENDING')
?? records.find((item) => item.status === 'VERIFIED')
?? records.find((item) => item.status === 'REJECTED')
}
function replaceableQualification(type: MerchantQualificationType) {
const record = qualificationRecord(type)
return record && record.status !== 'REJECTED' ? record : undefined
}
function qualificationDraftItem(type: MerchantQualificationType) {
return qualificationDrafts.find((item) => item.value === type)
}
function qualificationSlotLabel(item: QualificationDraftItem) {
if (item.uploadState === 'uploading') return '上传中'
if (item.uploadState === 'uploaded') return '已保存'
if (item.uploadState === 'error') return '上传失败'
if (item.file) return '待保存'
const record = qualificationRecord(item.value)
if (record?.status === 'PENDING') return '已上传'
if (record?.status === 'REJECTED') return '需重新上传'
return '待上传'
}
function qualificationSlotTagType(item: QualificationDraftItem) {
if (item.file) return 'warning'
if (item.uploadState === 'uploaded' || qualificationRecord(item.value)?.status === 'PENDING') return 'success'
if (item.uploadState === 'error' || qualificationRecord(item.value)?.status === 'REJECTED') return 'danger'
return 'warning'
}
function handleQualificationFileChange(type: MerchantQualificationType, file: UploadFile) {
const item = qualificationDraftItem(type)
if (!item) return
const raw = file.raw
if (!raw) {
qualificationFileError.value = '未能读取所选文件,请重新选择'
item.error = '未能读取所选文件,请重新选择'
item.uploadState = 'error'
item.fileName = ''
item.fileSize = 0
item.fileType = ''
item.file = null
item.inputVersion += 1
return
}
const error = validateQualificationFile({ name: raw.name, size: raw.size, type: raw.type })
if (error) {
qualificationFileError.value = error
qualificationDraft.fileName = ''
selectedFileSize.value = 0
selectedFileType.value = ''
selectedFile.value = null
uploadRef.value?.clearFiles()
item.error = error
item.uploadState = 'error'
item.fileName = ''
item.fileSize = 0
item.fileType = ''
item.file = null
item.inputVersion += 1
return
}
qualificationFileError.value = ''
selectedFile.value = raw
qualificationDraft.fileName = raw.name
selectedFileSize.value = raw.size
selectedFileType.value = raw.type || raw.name.split('.').pop()?.toUpperCase() || '文件'
item.error = ''
item.uploadState = 'idle'
item.file = raw
item.fileName = raw.name
item.fileSize = raw.size
item.fileType = raw.type || raw.name.split('.').pop()?.toUpperCase() || '文件'
}
function removeSelectedFile() {
uploadRef.value?.clearFiles()
qualificationDraft.fileName = ''
selectedFileSize.value = 0
selectedFileType.value = ''
qualificationFileError.value = ''
selectedFile.value = null
function removeSelectedFile(type: MerchantQualificationType) {
const item = qualificationDraftItem(type)
if (!item) return
item.fileName = ''
item.fileSize = 0
item.fileType = ''
item.error = ''
item.file = null
item.inputVersion += 1
item.uploadState = 'idle'
}
function resetQualificationDraft() {
uploadRef.value?.clearFiles()
qualificationDraft.type = 'BUSINESS_LICENSE'
qualificationDraft.fileName = ''
qualificationDraft.certificateNo = store.company.creditCode
selectedFileSize.value = 0
selectedFileType.value = ''
qualificationFileError.value = ''
selectedFile.value = null
qualificationFormRef.value?.clearValidate()
qualificationDrafts.forEach((item) => {
item.fileName = ''
item.fileSize = 0
item.fileType = ''
item.error = ''
item.file = null
item.inputVersion += 1
item.uploadState = 'idle'
})
qualificationCertificateNo.value = store.company.creditCode
}
async function submitQualification() {
@@ -211,42 +307,64 @@ async function submitQualification() {
ElMessage.warning(qualificationUploadDisabledReason.value)
return
}
const valid = await qualificationFormRef.value?.validate().then(() => true).catch(() => false)
if (!valid || !selectedFile.value) {
qualificationFileError.value = '请选择资质文件'
if (missingQualificationTypes.value.length) {
qualificationDrafts.forEach((item) => {
if (!uploadedQualificationTypes.value.has(item.value) && !item.file) item.error = '请选择该项资质文件'
})
ElMessage.warning(`请补齐:${missingQualificationTypes.value.join('、')}`)
return
}
if (qualificationFileError.value) {
ElMessage.warning(qualificationFileError.value)
if (!qualificationCreditCodeReady.value) {
const businessLicense = qualificationDraftItem('BUSINESS_LICENSE')
if (businessLicense) businessLicense.error = qualificationCreditCodeError.value
ElMessage.warning(qualificationCreditCodeError.value)
return
}
const pendingItems = qualificationDrafts.filter((item) => item.file)
if (!pendingItems.length) {
ElMessage.warning('请选择需要新增或替换的资质文件')
return
}
qualificationSubmitting.value = true
let uploadedCount = 0
const failures: string[] = []
try {
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
} catch (error) {
if (error instanceof ApiError && error.code === 'A0409') {
await loadCompanyContext()
qualificationOpen.value = false
ElMessage.warning('企业资料正在审核,不能再次提交资质文件')
return
for (const item of pendingItems) {
const file = item.file
if (!file) continue
item.error = ''
item.uploadState = 'uploading'
try {
await uploadMerchantQualification(
file,
item.value,
item.value === 'BUSINESS_LICENSE' ? normalizedQualificationCertificateNo.value : undefined,
Boolean(replaceableQualification(item.value)),
)
item.file = null
item.fileName = ''
item.fileSize = 0
item.fileType = ''
item.inputVersion += 1
item.uploadState = 'uploaded'
uploadedCount += 1
} catch (error) {
const message = error instanceof Error ? error.message : '资质文件上传失败'
item.error = message
item.uploadState = 'error'
failures.push(`${item.label}:${message}`)
}
ElMessage.error(error instanceof Error ? error.message : '资质文件上传失败')
return
}
const refreshed = await refreshCompanyContext()
if (refreshed) ElMessage.success('资质文件已上传,尚未提交审核')
else ElMessage.warning('资质文件已上传,但最新状态刷新失败,请点击“重新加载”确认状态')
if (failures.length) {
if (!canUploadQualification.value) qualificationOpen.value = false
const uploadedCopy = uploadedCount ? `已保存 ${uploadedCount} 项;` : ''
ElMessage.error(`${uploadedCopy}${failures[0]}`)
return
}
qualificationOpen.value = false
if (refreshed) ElMessage.success(`已保存 ${uploadedCount} 项资质文件,尚未提交审核`)
else ElMessage.warning('资质文件已保存,但最新状态刷新失败,请点击“重新加载”确认状态')
} finally {
qualificationSubmitting.value = false
}
@@ -397,23 +515,55 @@ function closeQualificationPreview() {
<template #footer><div class="drawer-actions"><el-button :disabled="profileSubmitting" @click="cancelEdit">取消</el-button><el-button type="primary" :loading="profileSubmitting" @click="saveProfile">保存修改</el-button></div></template>
</el-drawer>
<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="文件类型" 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=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png" @change="handleFileChange">
<el-button><Upload :size="16" />选择文件</el-button>
<el-dialog
v-model="qualificationOpen"
title="更新资质文件"
width="min(760px, 92vw)"
:close-on-click-modal="!qualificationSubmitting"
:close-on-press-escape="!qualificationSubmitting"
:show-close="!qualificationSubmitting"
@closed="resetQualificationDraft"
>
<div class="qualification-dialog-intro">
<strong>一次补齐四类必填资质</strong>
<span>每类选择一个文件后统一保存;文件保存不会自动提交企业审核。</span>
</div>
<div class="qualification-upload-grid">
<article v-for="item in qualificationDrafts" :key="item.value" class="qualification-upload-item">
<header>
<div>
<strong>{{ item.label }}</strong>
<p>{{ item.description }}</p>
</div>
<el-tag :type="qualificationSlotTagType(item)" effect="light">{{ qualificationSlotLabel(item) }}</el-tag>
</header>
<div v-if="qualificationRecord(item.value)" class="existing-qualification-file">
<FileCheck2 :size="16" />
<span>当前文件:{{ qualificationRecord(item.value)?.fileName }}</span>
</div>
<el-upload
:key="`${item.value}-${item.inputVersion}`"
:auto-upload="false"
:show-file-list="false"
accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png"
@change="handleQualificationFileChange(item.value, $event)"
>
<el-button :disabled="qualificationSubmitting"><Upload :size="16" />{{ qualificationRecord(item.value) ? '替换文件' : '选择文件' }}</el-button>
<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" />
<div><strong>{{ qualificationDraft.fileName }}</strong><span>{{ selectedFileType }} · {{ formatQualificationFileSize(selectedFileSize) }}</span></div>
<el-button link type="danger" aria-label="移除所选资质文件" @click="removeSelectedFile"><Trash2 :size="16" />移除</el-button>
<div v-if="item.fileName" class="selected-qualification-file" aria-live="polite">
<FileText :size="18" />
<div><strong>{{ item.fileName }}</strong><span>{{ item.fileType }} · {{ formatQualificationFileSize(item.fileSize) }}</span></div>
<el-button link type="danger" :disabled="qualificationSubmitting" :aria-label="`移除${item.label}文件`" @click="removeSelectedFile(item.value)"><Trash2 :size="16" />移除</el-button>
</div>
</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>
<p v-if="item.error" class="qualification-upload-error" role="alert">{{ item.error }}</p>
</article>
</div>
<el-form-item label="统一社会信用代码" class="qualification-credit-code" :required="!companyCreditCodeValid" :error="qualificationCreditCodeReady ? '' : qualificationCreditCodeError">
<el-input v-model="qualificationCertificateNo" maxlength="18" placeholder="请输入18位统一社会信用代码" @input="qualificationCertificateNo = String($event).toUpperCase()" />
</el-form-item>
<p v-if="qualificationSaveDisabledReason" class="qualification-dialog-hint">{{ qualificationSaveDisabledReason }}</p>
<template #footer><el-button :disabled="qualificationSubmitting" @click="qualificationOpen = false">取消</el-button><el-button type="primary" :loading="qualificationSubmitting" :disabled="!canSaveQualificationBatch" @click="submitQualification">保存全部资质</el-button></template>
</el-dialog>
<el-dialog v-model="previewOpen" :title="`${previewRecord?.type || '资质文件'}详情`" width="min(560px, 92vw)" @closed="closeQualificationPreview">
@@ -470,12 +620,28 @@ function closeQualificationPreview() {
.form-grid-two { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }
.form-grid-two .full-row { grid-column: 1 / -1; }
.drawer-actions { display: flex; justify-content: flex-end; gap: 8px; }
.qualification-dialog-intro { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 16px; padding: 13px 15px; background: #f4f7f7; }
.qualification-dialog-intro strong { color: var(--navy); font-size: 13px; }
.qualification-dialog-intro span { max-width: 420px; color: var(--muted); font-size: 11px; line-height: 1.6; text-align: right; }
.qualification-upload-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); border-top: 1px solid var(--border); border-left: 1px solid var(--border); }
.qualification-upload-item { min-width: 0; padding: 16px; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); }
.qualification-upload-item > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
.qualification-upload-item > header > div { min-width: 0; }
.qualification-upload-item > header strong { color: var(--navy); font-size: 13px; }
.qualification-upload-item > header p { margin: 5px 0 0; color: var(--muted); font-size: 10px; line-height: 1.55; }
.qualification-upload-item > header .el-tag { flex: 0 0 auto; }
.existing-qualification-file { display: flex; min-width: 0; align-items: center; gap: 7px; margin-bottom: 12px; color: var(--primary); font-size: 10px; }
.existing-qualification-file svg { flex: 0 0 16px; }
.existing-qualification-file span { min-width: 0; overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; }
.upload-tip { margin-top: 8px; color: var(--muted); font-size: 11px; line-height: 1.55; }
.selected-qualification-file { display: flex; width: 100%; min-width: 0; align-items: center; gap: 10px; margin-top: 12px; padding: 12px; border: 1px solid #cfe1dd; background: #f3f8f7; color: var(--primary); }
.selected-qualification-file > svg { flex: 0 0 20px; }
.selected-qualification-file > div { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 3px; }
.selected-qualification-file strong { overflow: hidden; color: var(--navy); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.selected-qualification-file span { color: var(--muted); font-size: 10px; }
.qualification-upload-error { margin: 9px 0 0; color: #b42318; font-size: 11px; line-height: 1.5; }
.qualification-credit-code { max-width: 420px; margin: 18px 0 0; }
.qualification-dialog-hint { margin: 10px 0 0; color: var(--muted); font-size: 11px; line-height: 1.5; }
.document-preview { display: grid; min-height: 260px; place-items: center; align-content: center; gap: 12px; border: 1px dashed #b8c8c5; background: #f6faf9; color: var(--primary); }
.document-preview > strong { max-width: min(420px, calc(100% - 32px)); overflow-wrap: anywhere; color: var(--navy); text-align: center; }
.document-preview dl { display: grid; width: min(420px, calc(100% - 32px)); grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 6px 0; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
@@ -486,6 +652,9 @@ function closeQualificationPreview() {
.document-preview-error { max-width: 440px; margin: 0; padding: 0 16px; color: #9a5a09; font-size: 11px; line-height: 1.6; text-align: center; }
@media (max-width: 720px) {
.profile-grid, .form-grid-two { grid-template-columns: 1fr; }
.qualification-dialog-intro { flex-direction: column; gap: 5px; }
.qualification-dialog-intro span { text-align: left; }
.qualification-upload-grid { 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; }
@@ -504,6 +673,6 @@ function closeQualificationPreview() {
.document-preview dl { grid-template-columns: 1fr; }
.document-preview dl div + div { border-top: 1px solid var(--border); }
.selected-qualification-file { align-items: flex-start; flex-wrap: wrap; }
.selected-qualification-file > .el-button { margin-left: 30px; }
.selected-qualification-file > .el-button { margin-left: 28px; }
}
</style>
@@ -25,8 +25,24 @@ test('implements company details, contact information and qualification workflow
assert.match(profileSource, /async function saveProfile\(\)/)
assert.match(profileSource, /function submitQualification\(\)/)
assert.match(profileSource, /validateQualificationFile/)
assert.match(profileSource, /function removeSelectedFile\(\)/)
assert.match(profileSource, /function removeSelectedFile\(type: MerchantQualificationType\)/)
assert.match(profileSource, /qualificationSubmitting/)
assert.match(profileSource, /const qualificationDrafts = reactive<QualificationDraftItem\[\]>/)
assert.match(profileSource, /inputVersion: number/)
assert.match(profileSource, /:key="`\$\{item\.value\}-\$\{item\.inputVersion\}`"/)
assert.match(profileSource, /class="qualification-upload-grid"/)
assert.match(profileSource, /v-for="item in qualificationDrafts"/)
assert.match(profileSource, /保存全部资质/)
assert.match(profileSource, /for \(const item of pendingItems\)/)
assert.match(profileSource, /item\.fileName = ''[\s\S]*item\.uploadState = 'uploaded'/)
assert.match(profileSource, /const canSaveQualificationBatch = computed/)
assert.match(profileSource, /const qualificationCreditCodeReady = computed/)
assert.match(profileSource, /:close-on-click-modal="!qualificationSubmitting"/)
assert.match(profileSource, /:close-on-press-escape="!qualificationSubmitting"/)
assert.match(profileSource, /:show-close="!qualificationSubmitting"/)
assert.match(profileSource, /请重新选择营业执照,并填写18位统一社会信用代码/)
assert.match(profileSource, /companyCreditCodeValid\.value/)
assert.doesNotMatch(profileSource, /<el-select v-model="qualificationDraft\.type"/)
assert.match(profileSource, /class="mobile-qualification-list"/)
assert.match(profileSource, /import DataTable, \{ type DataTableColumn \} from '@\/components\/common\/DataTable\.vue'/)
assert.match(profileSource, /const qualificationColumns: DataTableColumn\[\]/)
@@ -37,18 +53,17 @@ test('implements company details, contact information and qualification workflow
assert.match(profileSource, /class="info-panel-header"[\s\S]*auth\.hasPermission\('company:profile:edit'\)/)
assert.match(profileSource, /ref="profileFormRef" :model="draft" :rules="profileFormRules"/)
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, /请选择该项资质文件/)
assert.match(profileSource, /submitTenantQualificationReview/)
assert.match(profileSource, /提交企业审核/)
assert.match(profileSource, /资质文件已上传,尚未提交审核/)
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'/)
assert.doesNotMatch(profileSource, /error\.code === 'A0409'\) break/)
assert.match(profileSource, /downloadQualificationImage/)
assert.match(profileSource, /当前资质文件尚未迁移到安全预览地址/)
assert.match(profileSource, /previewError/)