Files
client-web/src/modules/tenant-management/views/CompanyProfileView.vue
T
2026-08-05 09:11:41 +08:00

736 lines
41 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { ElMessage } 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'
import { useAuthStore } from '@/modules/auth/public'
import { formatQualificationFileSize, qualificationUploadErrorMessage, validateQualificationFile } from '@/modules/tenant-management/utils/qualificationFile'
import { qualificationStatusMeta } from '@/modules/tenant-management/utils/qualificationStatus'
import {
downloadQualificationImage,
submitTenantQualificationReview,
uploadMerchantQualification,
uploadMerchantRequiredQualificationPackage,
type MerchantQualificationType,
} from '@/modules/tenant-management/api/merchantIam'
const store = useTenantAdministrationStore()
const auth = useAuthStore()
const companyApproved = computed(() => store.company.auditStatus === 'ACTIVE')
const auditStatusCopy = computed(() => ({
PROFILE_REQUIRED: { title: '请完善企业资质', detail: '上传营业执照和法人身份证并提交审核后,才能提交头程订单。' },
PENDING: { title: '企业资料正在等待平台审核', detail: '审核期间仍可维护资料和保存订单草稿;审核通过前不能提交订单。' },
ACTIVE: { title: '企业资料已审核,下单资格已开通', detail: '您可以创建、保存并提交头程订单。' },
REJECTED: { title: '企业资料审核未通过', detail: '请根据平台反馈替换对应资质,必填文件齐全后可重新提交审核。' },
DISABLED: { title: '企业账号已被平台停用', detail: '当前不能提交订单,请联系平台客服核实。' },
})[store.company.auditStatus])
const activeTab = ref('basic')
const editing = ref(false)
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>()
const qualificationPage = ref(1)
const qualificationPageSize = ref(10)
const qualificationSubmitting = ref(false)
const reviewSubmitting = ref(false)
const profileSubmitting = ref(false)
const loading = ref(false)
const loadError = ref('')
const draft = reactive<TenantCompanyProfile>({ ...store.company })
type QualificationUploadState = 'idle' | 'uploading' | 'uploaded' | 'error'
type QualificationTypeDefinition = { value: MerchantQualificationType; label: string; description: string; required: boolean }
type QualificationDraftItem = QualificationTypeDefinition & {
file: File | null
fileName: string
fileSize: number
fileType: string
previewUrl: string
inputVersion: number
error: string
uploadState: QualificationUploadState
}
const qualificationTypes: QualificationTypeDefinition[] = [
{ value: 'BUSINESS_LICENSE', label: '营业执照', description: '上传清晰完整的营业执照文件。', required: true },
{ value: 'LEGAL_PERSON_ID', label: '法人身份证', description: '正反面请合并为一个 PDF、JPG 或 PNG 文件。', required: true },
{ value: 'FREIGHT_FORWARDER_CERTIFICATE', label: '货代资质证书', description: '如有,可上传当前有效的货代资质证明。', required: false },
{ value: 'BANK_ACCOUNT_LICENSE', label: '银行开户许可证', description: '如有,可上传企业对公账户开户证明。', required: false },
]
const requiredQualificationTypes = qualificationTypes.filter((type) => type.required)
const qualificationDrafts = reactive<QualificationDraftItem[]>(qualificationTypes.map((type) => ({
...type,
file: null,
fileName: '',
fileSize: 0,
fileType: '',
previewUrl: '',
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 draftCreditCodeValid = computed(() => unifiedSocialCreditCodePattern.test(normalizedQualificationCertificateNo.value))
const businessLicenseFileSelected = computed(() => Boolean(qualificationDraftItem('BUSINESS_LICENSE')?.file))
const legalPersonIdFileSelected = computed(() => Boolean(qualificationDraftItem('LEGAL_PERSON_ID')?.file))
const requiredPackageChangeSelected = computed(() => businessLicenseFileSelected.value || legalPersonIdFileSelected.value)
const requiredPackageFilesSelected = computed(() => businessLicenseFileSelected.value && legalPersonIdFileSelected.value)
const qualificationCreditCodeReady = computed(() => requiredPackageChangeSelected.value
? requiredPackageFilesSelected.value && (companyCreditCodeValid.value || draftCreditCodeValid.value)
: companyCreditCodeValid.value)
const qualificationCreditCodeError = computed(() => {
if (qualificationCreditCodeReady.value) return ''
if (draftCreditCodeValid.value && !requiredPackageFilesSelected.value) return '统一社会信用代码必须随营业执照和法人身份证共同保存'
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 requiredPackageFilesReady = computed(() => requiredPackageChangeSelected.value
? requiredPackageFilesSelected.value
: requiredQualificationTypes.every((type) => uploadedQualificationTypes.value.has(type.value)))
const requiredPackageComplete = computed(() => qualificationCreditCodeReady.value && requiredPackageFilesReady.value)
const missingRequiredPackageItems = computed(() => {
const missing = requiredQualificationTypes
.filter((type) => requiredPackageChangeSelected.value
? !qualificationDraftItem(type.value)?.file
: !uploadedQualificationTypes.value.has(type.value))
.map((type) => type.label)
if (!qualificationCreditCodeReady.value) missing.splice(1, 0, '18位统一社会信用代码')
return missing
})
const qualificationUploadDisabledReason = computed(() => {
if (store.company.auditStatus === 'PENDING') return '企业资料正在审核,暂不能再次提交资质文件。'
if (store.company.auditStatus === 'ACTIVE') return '当前企业已审核通过,资质替换请联系平台管理员处理。'
if (store.company.auditStatus === 'DISABLED') return '企业账号已停用,暂不能提交资质文件。'
return ''
})
const selectedQualificationCount = computed(() => qualificationDrafts.filter((item) => Boolean(item.file)).length)
const canSaveQualificationBatch = computed(() => canUploadQualification.value
&& selectedQualificationCount.value > 0
&& requiredPackageComplete.value)
const qualificationSaveDisabledReason = computed(() => {
if (!canUploadQualification.value) return qualificationUploadDisabledReason.value
if (!requiredPackageComplete.value) return `请完整保存必填业务项:${missingRequiredPackageItems.value.join('、')}`
if (!selectedQualificationCount.value) return '当前没有需要保存的变更'
return ''
})
const qualificationDialogHint = computed(() => qualificationSaveDisabledReason.value === qualificationCreditCodeError.value
? ''
: qualificationSaveDisabledReason.value)
const canSubmitQualificationReview = computed(() => canUploadQualification.value
&& companyCreditCodeValid.value
&& requiredQualificationTypes.every((type) => uploadedQualificationTypes.value.has(type.value)))
const qualificationReviewDisabledReason = computed(() => {
if (!canUploadQualification.value) return qualificationUploadDisabledReason.value
if (!companyCreditCodeValid.value) return '请先通过营业执照填写18位统一社会信用代码'
const missing = requiredQualificationTypes.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' }],
legalRepresentative: [{ required: true, whitespace: true, message: '请输入法人姓名', trigger: 'blur' }],
contactName: [{ required: true, whitespace: true, message: '请输入联系人姓名', trigger: 'blur' }],
contactPhone: [{ required: true, whitespace: true, message: '请输入联系电话', trigger: 'blur' }],
contactEmail: [{ type: 'email', message: '请输入正确的联系邮箱', trigger: 'blur' }],
}
const qualificationColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
{ key: 'type', prop: 'type', label: '文件类型', minWidth: 150 },
{ key: 'fileName', prop: 'fileName', label: '文件名称', minWidth: 220, showOverflowTooltip: true },
{ key: 'status', label: '状态', width: 110, slotName: 'status' },
{ key: 'expiresAt', prop: 'expiresAt', label: '有效期', width: 130 },
{ key: 'updatedAt', prop: 'updatedAt', label: '更新时间', minWidth: 160 },
]
const pagedQualifications = computed(() => {
const start = (qualificationPage.value - 1) * qualificationPageSize.value
return store.qualifications.slice(start, start + qualificationPageSize.value)
})
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 : '企业资料加载失败'
return false
}
}
async function loadCompanyContext() {
loading.value = true
await refreshCompanyContext()
loading.value = false
}
onMounted(() => void loadCompanyContext())
onBeforeUnmount(() => qualificationDrafts.forEach(releaseQualificationPreview))
function startEdit() {
Object.assign(draft, store.company)
profileFormRef.value?.clearValidate()
editing.value = true
}
function cancelEdit() {
Object.assign(draft, store.company)
editing.value = false
profileFormRef.value?.clearValidate()
}
async function saveProfile() {
if (profileSubmitting.value) return
const valid = await profileFormRef.value?.validate().then(() => true).catch(() => false)
if (!valid) return
profileSubmitting.value = true
try {
await store.updateCompany({ ...draft })
Object.assign(draft, store.company)
editing.value = false
ElMessage.success('企业资料已保存')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '企业资料保存失败')
} finally {
profileSubmitting.value = false
}
}
function openQualificationDialog() {
if (!canUploadQualification.value) {
ElMessage.warning(qualificationUploadDisabledReason.value)
return
}
resetQualificationDraft()
qualificationOpen.value = true
}
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 releaseQualificationPreview(item: QualificationDraftItem) {
if (!item.previewUrl) return
URL.revokeObjectURL(item.previewUrl)
item.previewUrl = ''
}
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 item.required ? '待上传' : '选填'
}
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 item.required ? 'warning' : 'info'
}
function handleQualificationFileChange(type: MerchantQualificationType, file: UploadFile) {
const item = qualificationDraftItem(type)
if (!item) return
releaseQualificationPreview(item)
const raw = file.raw
if (!raw) {
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) {
item.error = error
item.uploadState = 'error'
item.fileName = ''
item.fileSize = 0
item.fileType = ''
item.file = null
item.inputVersion += 1
return
}
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() || '文件'
if (raw.type.startsWith('image/') || /\.(jpe?g|png)$/i.test(raw.name)) item.previewUrl = URL.createObjectURL(raw)
}
function removeSelectedFile(type: MerchantQualificationType) {
const item = qualificationDraftItem(type)
if (!item) return
releaseQualificationPreview(item)
item.fileName = ''
item.fileSize = 0
item.fileType = ''
item.error = ''
item.file = null
item.inputVersion += 1
item.uploadState = 'idle'
}
function resetQualificationDraft() {
qualificationDrafts.forEach((item) => {
releaseQualificationPreview(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() {
if (qualificationSubmitting.value) return
if (!canUploadQualification.value) {
qualificationOpen.value = false
await loadCompanyContext()
ElMessage.warning(qualificationUploadDisabledReason.value)
return
}
if (!requiredPackageComplete.value) {
const businessLicense = qualificationDraftItem('BUSINESS_LICENSE')
if (businessLicense && !qualificationCreditCodeReady.value) businessLicense.error = qualificationCreditCodeError.value
ElMessage.warning(`请完整保存必填业务项:${missingRequiredPackageItems.value.join('、')}`)
return
}
const optionalItems = qualificationDrafts.filter((item) => !item.required && item.file)
qualificationSubmitting.value = true
let uploadedCount = 0
const failures: string[] = []
try {
if (requiredPackageChangeSelected.value) {
const businessLicense = qualificationDraftItem('BUSINESS_LICENSE')
const legalPersonId = qualificationDraftItem('LEGAL_PERSON_ID')
for (const item of [businessLicense, legalPersonId]) {
if (!item) continue
item.error = ''
item.uploadState = 'uploading'
}
try {
await uploadMerchantRequiredQualificationPackage(
businessLicense?.file as File,
legalPersonId?.file as File,
normalizedQualificationCertificateNo.value,
Boolean(replaceableQualification('BUSINESS_LICENSE')),
Boolean(replaceableQualification('LEGAL_PERSON_ID')),
)
for (const item of [businessLicense, legalPersonId]) {
if (!item) continue
releaseQualificationPreview(item)
item.file = null
item.fileName = ''
item.fileSize = 0
item.fileType = ''
item.inputVersion += 1
item.uploadState = 'uploaded'
}
uploadedCount += 2
} catch (error) {
const message = qualificationUploadErrorMessage(error)
if (businessLicense) { businessLicense.error = message; businessLicense.uploadState = 'error' }
if (legalPersonId) { legalPersonId.error = message; legalPersonId.uploadState = 'error' }
failures.push(`必填资质包:${message}`)
}
}
for (const item of failures.length ? [] : optionalItems) {
const file = item.file
if (!file) continue
item.error = ''
item.uploadState = 'uploading'
try {
await uploadMerchantQualification(
file,
item.value,
undefined,
Boolean(replaceableQualification(item.value)),
)
releaseQualificationPreview(item)
item.file = null
item.fileName = ''
item.fileSize = 0
item.fileType = ''
item.inputVersion += 1
item.uploadState = 'uploaded'
uploadedCount += 1
} catch (error) {
const message = qualificationUploadErrorMessage(error)
item.error = message
item.uploadState = 'error'
failures.push(`${item.label}:${message}`)
}
}
const refreshed = await refreshCompanyContext()
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('营业执照、统一社会信用代码和法人身份证已完整保存,尚未提交审核')
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
previewOpen.value = true
previewUrl.value = ''
previewError.value = ''
if (!record.documentUrl) {
previewError.value = '当前资质记录没有可查看的文件地址,请联系平台管理员处理。'
return
}
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 === '资质文件地址无效'
? '当前资质文件尚未迁移到安全预览地址,请联系平台管理员处理。'
: '资质文件加载失败,请稍后重试。'
ElMessage.error(previewError.value)
} finally {
previewLoading.value = false
}
}
function closeQualificationPreview() {
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
previewUrl.value = ''
previewKind.value = 'image'
previewError.value = ''
previewRecord.value = null
}
</script>
<template>
<section v-loading="loading" class="profile-page">
<div v-if="loadError" class="client-card profile-load-error" role="alert">
<div><strong>企业资料加载失败</strong><span>{{ loadError }}</span></div>
<el-button :loading="loading" @click="loadCompanyContext">重新加载</el-button>
</div>
<template v-else>
<div class="company-audit-notice" :class="companyApproved ? 'is-approved' : 'is-pending'" role="status">
<component :is="companyApproved ? CheckCircle2 : Clock3" :size="21" />
<div><strong>{{ auditStatusCopy.title }}</strong><span>{{ auditStatusCopy.detail }}</span></div>
</div>
<el-tabs v-model="activeTab" class="profile-tabs">
<el-tab-pane name="basic">
<template #label><span class="tab-label"><Building2 :size="16" />企业基本信息</span></template>
<div class="client-card info-panel">
<header class="info-panel-header"><h3>企业基本信息</h3><el-button v-if="auth.hasPermission('company:profile:edit')" type="primary" @click="startEdit"><Pencil :size="16" />编辑企业资料</el-button></header>
<dl class="profile-grid">
<div><dt>公司名称</dt><dd>{{ store.company.companyName || '-' }}</dd></div>
<div><dt>统一社会信用代码</dt><dd>{{ store.company.creditCode || '-' }}</dd></div>
<div><dt>法人姓名</dt><dd>{{ store.company.legalRepresentative || '-' }}</dd></div>
</dl>
</div>
</el-tab-pane>
<el-tab-pane name="contact">
<template #label><span class="tab-label"><UserRound :size="16" />联系信息</span></template>
<div class="client-card info-panel">
<header class="info-panel-header"><h3>联系信息</h3></header>
<dl class="profile-grid">
<div><dt>联系人</dt><dd>{{ store.company.contactName || '-' }}</dd></div>
<div><dt>联系电话</dt><dd>{{ store.company.contactPhone || '-' }}</dd></div>
<div><dt>联系邮箱</dt><dd>{{ store.company.contactEmail || '-' }}</dd></div>
</dl>
</div>
</el-tab-pane>
<el-tab-pane name="qualification">
<template #label><span class="tab-label"><FileCheck2 :size="16" />资质文件</span></template>
<div class="client-card qualification-panel">
<header>
<div><h3>企业资质</h3><p>{{ qualificationPanelHint }}</p></div>
<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"
v-model:page-size="qualificationPageSize"
class="desktop-qualification-table"
:columns="qualificationColumns"
:data="pagedQualifications"
:empty-text="'暂无企业资质记录'"
:page-sizes="[10, 20, 50]"
:total="store.qualifications.length"
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="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="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>
</article>
<div v-if="store.qualifications.length === 0" class="client-empty">暂无企业资质记录。</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
<el-drawer v-model="editing" title="编辑企业资料" size="min(620px, 94vw)" @closed="cancelEdit">
<el-form ref="profileFormRef" :model="draft" :rules="profileFormRules" label-position="top" class="profile-form">
<h3>企业基本信息</h3>
<div class="form-grid-two">
<el-form-item label="公司名称" prop="companyName" required><el-input v-model="draft.companyName" placeholder="请输入公司名称" /></el-form-item>
<el-form-item label="统一社会信用代码"><el-input v-model="draft.creditCode" disabled /></el-form-item>
<el-form-item label="法人姓名" prop="legalRepresentative" required><el-input v-model="draft.legalRepresentative" placeholder="请输入法定代表人姓名" /></el-form-item>
</div>
<h3>联系信息</h3>
<div class="form-grid-two">
<el-form-item label="联系人" prop="contactName" required><el-input v-model="draft.contactName" placeholder="请输入联系人姓名" /></el-form-item>
<el-form-item label="联系电话" prop="contactPhone" required><el-input v-model="draft.contactPhone" placeholder="请输入联系电话" /></el-form-item>
<el-form-item label="联系邮箱" prop="contactEmail"><el-input v-model="draft.contactEmail" placeholder="请输入联系邮箱(选填)" /></el-form-item>
</div>
</el-form>
<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(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>营业执照、18位统一社会信用代码、法人身份证需共同完整保存;其余两项可按需上传。</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 }}({{ item.required ? '必填' : '选填' }})</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="item.fileName" class="selected-qualification-file" aria-live="polite">
<a v-if="item.previewUrl" class="selected-file-preview" :href="item.previewUrl" target="_blank" rel="noopener" :aria-label="`查看${item.label}图片预览`">
<img :src="item.previewUrl" :alt="`${item.label}待上传图片预览`" />
</a>
<div v-else class="selected-file-type" aria-hidden="true"><FileText :size="18" /><span>PDF</span></div>
<div class="selected-file-meta"><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>
<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 :error="qualificationCreditCodeReady ? '' : qualificationCreditCodeError">
<el-input v-model="qualificationCertificateNo" maxlength="18" :disabled="companyCreditCodeValid" placeholder="请输入18位统一社会信用代码" @input="qualificationCertificateNo = String($event).toUpperCase()" />
<small class="qualification-credit-code-hint">{{ companyCreditCodeValid ? '已登记的统一社会信用代码不可直接修改。' : '与营业执照一致,保存后不可直接修改。' }}</small>
</el-form-item>
<p v-if="qualificationDialogHint" class="qualification-dialog-hint">{{ qualificationDialogHint }}</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">
<div v-if="previewRecord" v-loading="previewLoading" class="document-preview">
<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="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>
</div>
</el-dialog>
</template>
</section>
</template>
<style scoped>
.profile-page { display: flex; flex-direction: column; gap: 16px; }
.profile-load-error { display: flex; min-height: 180px; align-items: center; justify-content: center; gap: 22px; text-align: left; }
.profile-load-error > div { display: flex; max-width: 560px; flex-direction: column; gap: 6px; }
.profile-load-error span { color: var(--muted); font-size: 12px; line-height: 1.6; }
.company-audit-notice { display: flex; align-items: flex-start; gap: 12px; padding: 15px 17px; border: 1px solid; }
.company-audit-notice svg { flex: 0 0 21px; margin-top: 1px; }
.company-audit-notice div { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
.company-audit-notice strong { color: var(--navy); font-size: 13px; }
.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; }
.profile-tabs :deep(.el-tabs__nav-wrap::after) { display: none; }
.tab-label { display: inline-flex; align-items: center; gap: 7px; }
.info-panel { padding: 0; }
.info-panel-header { min-height: 66px; padding: 14px 22px; border-bottom: 1px solid var(--border); }
.info-panel-header h3 { margin: 0; font-size: 15px; }
.profile-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; }
.profile-grid > div { min-height: 86px; padding: 20px 22px; border-bottom: 1px solid var(--border); }
.profile-grid > div:nth-child(odd) { border-right: 1px solid var(--border); }
.profile-grid dt { color: var(--muted); font-size: 11px; }
.profile-grid dd { margin: 9px 0 0; color: var(--navy); font-size: 14px; font-weight: 650; }
.profile-grid .full-row { grid-column: 1 / -1; border-right: 0; }
.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; }
.profile-form h3:not(:first-child) { margin-top: 22px; }
.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-file-preview { display: block; width: 54px; height: 54px; flex: 0 0 54px; overflow: hidden; border: 1px solid #bfd4cf; background: #fff; }
.selected-file-preview img { display: block; width: 100%; height: 100%; object-fit: cover; }
.selected-file-preview:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
.selected-file-type { display: grid; width: 54px; height: 54px; flex: 0 0 54px; place-items: center; align-content: center; gap: 2px; border: 1px solid #cfe1dd; background: #fff; }
.selected-file-type span { color: var(--muted); font-size: 9px; font-weight: 700; }
.selected-file-meta { 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-credit-code-hint { display:block; margin-top:6px; color:var(--muted); font-size:11px; line-height:1.5; }
.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); }
.document-preview dl div { min-width: 0; padding: 12px; text-align: center; }
.document-preview dt { color: var(--muted); font-size: 10px; }
.document-preview dd { margin: 5px 0 0; overflow-wrap: anywhere; color: var(--navy); font-size: 12px; font-weight: 650; }
.document-preview > span { max-width: 440px; padding: 0 16px; color: var(--muted); font-size: 11px; line-height: 1.6; text-align: center; }
.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; }
.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; }
.mobile-qualification-list article { min-width: 0; padding: 14px; border: 1px solid var(--border); background: #fff; }
.mobile-qualification-list article > header { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.qualification-file-name { display: flex; min-width: 0; align-items: center; gap: 8px; margin: 14px 0; color: var(--primary); }
.qualification-file-name svg { flex: 0 0 16px; }
.qualification-file-name span { min-width: 0; overflow: hidden; color: var(--navy); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.mobile-qualification-list dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin: 0 0 10px; padding-top: 12px; border-top: 1px solid var(--border); }
.mobile-qualification-list dt { color: var(--muted); font-size: 10px; }
.mobile-qualification-list dd { margin: 4px 0 0; overflow-wrap: anywhere; font-size: 11px; font-weight: 600; }
.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: 64px; }
}
</style>