Files
client-web/src/modules/tenant-management/views/CompanyProfileView.vue
T

812 lines
46 KiB
Vue
Raw Normal View History

2026-07-30 17:59:42 +08:00
<script setup lang="ts">
2026-08-04 10:31:53 +08:00
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
2026-09-11 16:17:24 +08:00
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
2026-08-04 09:05:01 +08:00
import type { FormInstance, FormRules, UploadFile } from 'element-plus'
2026-07-30 17:59:42 +08:00
import { Building2, CheckCircle2, Clock3, FileCheck2, FileText, Pencil, Trash2, Upload, UserRound } from '@lucide/vue'
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
2026-08-03 18:52:30 +08:00
import { useTenantAdministrationStore, type TenantCompanyProfile, type TenantQualification } from '@/modules/tenant-management/store/tenantAdministration'
import { useAuthStore } from '@/modules/auth/public'
2026-08-04 14:15:24 +08:00
import { formatQualificationFileSize, qualificationUploadErrorMessage, validateQualificationFile } from '@/modules/tenant-management/utils/qualificationFile'
2026-08-03 20:50:32 +08:00
import { qualificationStatusMeta } from '@/modules/tenant-management/utils/qualificationStatus'
import {
downloadQualificationImage,
submitTenantQualificationReview,
uploadMerchantQualification,
2026-08-05 09:11:41 +08:00
uploadMerchantRequiredQualificationPackage,
2026-08-03 20:50:32 +08:00
type MerchantQualificationType,
} from '@/modules/tenant-management/api/merchantIam'
2026-07-30 17:59:42 +08:00
const store = useTenantAdministrationStore()
const auth = useAuthStore()
2026-09-11 16:17:24 +08:00
const route = useRoute()
2026-07-30 17:59:42 +08:00
const companyApproved = computed(() => store.company.auditStatus === 'ACTIVE')
const auditStatusCopy = computed(() => ({
PROFILE_REQUIRED: { title: '请完善企业资质', detail: '上传营业执照并提交审核后,才能提交头程订单;法人身份证可按需补充。' },
2026-08-03 10:51:53 +08:00
PENDING: { title: '企业资料正在等待平台审核', detail: '审核期间仍可维护资料和保存订单草稿;审核通过前不能提交订单。' },
ACTIVE: { title: '企业资料已审核,下单资格已开通', detail: '您可以创建、保存并提交头程订单。' },
2026-08-04 14:15:24 +08:00
REJECTED: { title: '企业资料审核未通过', detail: '请根据平台反馈替换对应资质,必填文件齐全后可重新提交审核。' },
2026-08-03 10:51:53 +08:00
DISABLED: { title: '企业账号已被平台停用', detail: '当前不能提交订单,请联系平台客服核实。' },
2026-08-14 16:06:33 +08:00
ARCHIVED: { title: '企业已归档', detail: '该企业已结束使用,资料和历史业务仅供查看,不能再提交或修改。' },
2026-07-30 17:59:42 +08:00
})[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('')
2026-08-03 20:50:32 +08:00
const previewKind = ref<'image' | 'pdf'>('image')
2026-08-03 18:15:34 +08:00
const previewError = ref('')
2026-07-30 17:59:42 +08:00
const previewLoading = ref(false)
const profileFormRef = ref<FormInstance>()
const qualificationPage = ref(1)
const qualificationPageSize = ref(10)
2026-07-30 17:59:42 +08:00
const qualificationSubmitting = ref(false)
2026-08-03 20:50:32 +08:00
const reviewSubmitting = ref(false)
2026-09-11 16:17:24 +08:00
const deletingQualificationId = ref('')
2026-07-30 17:59:42 +08:00
const profileSubmitting = ref(false)
const loading = ref(false)
2026-07-31 15:34:22 +08:00
const loadError = ref('')
2026-07-30 17:59:42 +08:00
const draft = reactive<TenantCompanyProfile>({ ...store.company })
2026-08-04 09:05:01 +08:00
type QualificationUploadState = 'idle' | 'uploading' | 'uploaded' | 'error'
2026-08-04 14:15:24 +08:00
type QualificationTypeDefinition = { value: MerchantQualificationType; label: string; description: string; required: boolean }
2026-08-04 09:05:01 +08:00
type QualificationDraftItem = QualificationTypeDefinition & {
file: File | null
fileName: string
fileSize: number
fileType: string
2026-08-04 10:31:53 +08:00
previewUrl: string
2026-08-04 09:05:01 +08:00
inputVersion: number
error: string
uploadState: QualificationUploadState
}
const qualificationTypes: QualificationTypeDefinition[] = [
2026-08-04 14:15:24 +08:00
{ value: 'BUSINESS_LICENSE', label: '营业执照', description: '上传清晰完整的营业执照文件。', required: true },
2026-09-11 16:17:24 +08:00
{ value: 'LEGAL_PERSON_ID', label: '法人身份证', description: '选填;如上传,正反面请合并为一个 PDF、JPG、PNG 或 BMP 文件。', required: false },
2026-08-04 14:15:24 +08:00
{ value: 'FREIGHT_FORWARDER_CERTIFICATE', label: '货代资质证书', description: '如有,可上传当前有效的货代资质证明。', required: false },
{ value: 'BANK_ACCOUNT_LICENSE', label: '银行开户许可证', description: '如有,可上传企业对公账户开户证明。', required: false },
2026-08-03 20:50:32 +08:00
]
2026-08-04 14:15:24 +08:00
const requiredQualificationTypes = qualificationTypes.filter((type) => type.required)
2026-08-04 09:05:01 +08:00
const qualificationDrafts = reactive<QualificationDraftItem[]>(qualificationTypes.map((type) => ({
...type,
file: null,
fileName: '',
fileSize: 0,
fileType: '',
2026-08-04 10:31:53 +08:00
previewUrl: '',
2026-08-04 09:05:01 +08:00
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())
2026-08-04 21:01:34 +08:00
const draftCreditCodeValid = computed(() => unifiedSocialCreditCodePattern.test(normalizedQualificationCertificateNo.value))
const businessLicenseFileSelected = computed(() => Boolean(qualificationDraftItem('BUSINESS_LICENSE')?.file))
2026-08-05 09:11:41 +08:00
const legalPersonIdFileSelected = computed(() => Boolean(qualificationDraftItem('LEGAL_PERSON_ID')?.file))
const requiredPackageChangeSelected = computed(() => businessLicenseFileSelected.value)
const requiredPackageFilesSelected = computed(() => businessLicenseFileSelected.value)
2026-08-05 09:11:41 +08:00
const qualificationCreditCodeReady = computed(() => requiredPackageChangeSelected.value
? requiredPackageFilesSelected.value && (companyCreditCodeValid.value || draftCreditCodeValid.value)
: companyCreditCodeValid.value)
2026-08-04 09:05:01 +08:00
const qualificationCreditCodeError = computed(() => {
if (qualificationCreditCodeReady.value) return ''
if (draftCreditCodeValid.value && !requiredPackageFilesSelected.value) return '统一社会信用代码必须随营业执照共同保存'
2026-08-04 09:05:01 +08:00
return '请输入18位字母或数字组成的统一社会信用代码'
})
2026-08-03 18:15:34 +08:00
const canUploadQualification = computed(() => ['PROFILE_REQUIRED', 'REJECTED'].includes(store.company.auditStatus))
2026-08-03 20:50:32 +08:00
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))))
2026-08-05 09:11:41 +08:00
const requiredPackageFilesReady = computed(() => requiredPackageChangeSelected.value
? requiredPackageFilesSelected.value
: requiredQualificationTypes.every((type) => uploadedQualificationTypes.value.has(type.value)))
2026-08-04 21:01:34 +08:00
const requiredPackageComplete = computed(() => qualificationCreditCodeReady.value && requiredPackageFilesReady.value)
const missingRequiredPackageItems = computed(() => {
const missing = requiredQualificationTypes
2026-08-05 09:11:41 +08:00
.filter((type) => requiredPackageChangeSelected.value
? !qualificationDraftItem(type.value)?.file
: !uploadedQualificationTypes.value.has(type.value))
2026-08-04 21:01:34 +08:00
.map((type) => type.label)
if (!qualificationCreditCodeReady.value) missing.splice(1, 0, '18位统一社会信用代码')
return missing
})
2026-08-03 18:15:34 +08:00
const qualificationUploadDisabledReason = computed(() => {
if (store.company.auditStatus === 'PENDING') return '企业资料正在审核,暂不能再次提交资质文件。'
if (store.company.auditStatus === 'ACTIVE') return '当前企业已审核通过,资质替换请联系平台管理员处理。'
if (store.company.auditStatus === 'DISABLED') return '企业账号已停用,暂不能提交资质文件。'
2026-08-14 16:06:33 +08:00
if (store.company.auditStatus === 'ARCHIVED') return '企业已归档,不能再提交资质文件。'
2026-08-03 18:15:34 +08:00
return ''
})
2026-08-04 09:05:01 +08:00
const selectedQualificationCount = computed(() => qualificationDrafts.filter((item) => Boolean(item.file)).length)
const canSaveQualificationBatch = computed(() => canUploadQualification.value
&& selectedQualificationCount.value > 0
2026-08-04 21:01:34 +08:00
&& requiredPackageComplete.value)
2026-08-04 09:05:01 +08:00
const qualificationSaveDisabledReason = computed(() => {
if (!canUploadQualification.value) return qualificationUploadDisabledReason.value
2026-08-04 21:01:34 +08:00
if (!requiredPackageComplete.value) return `请完整保存必填业务项:${missingRequiredPackageItems.value.join('、')}`
if (!selectedQualificationCount.value) return '当前没有需要保存的变更'
2026-08-04 09:05:01 +08:00
return ''
})
2026-08-04 10:31:53 +08:00
const qualificationDialogHint = computed(() => qualificationSaveDisabledReason.value === qualificationCreditCodeError.value
? ''
: qualificationSaveDisabledReason.value)
2026-08-04 09:05:01 +08:00
const canSubmitQualificationReview = computed(() => canUploadQualification.value
&& companyCreditCodeValid.value
2026-08-04 14:15:24 +08:00
&& requiredQualificationTypes.every((type) => uploadedQualificationTypes.value.has(type.value)))
2026-08-04 09:05:01 +08:00
const qualificationReviewDisabledReason = computed(() => {
if (!canUploadQualification.value) return qualificationUploadDisabledReason.value
if (!companyCreditCodeValid.value) return '请先通过营业执照填写18位统一社会信用代码'
2026-08-04 14:15:24 +08:00
const missing = requiredQualificationTypes.filter((type) => !uploadedQualificationTypes.value.has(type.value)).map((type) => type.label)
2026-08-04 09:05:01 +08:00
return missing.length ? `请先上传:${missing.join('、')}` : ''
})
2026-08-03 18:15:34 +08:00
const qualificationPanelHint = computed(() => canUploadQualification.value
? '营业执照为必填;法人身份证、货代资质证书、银行开户许可证可选填。文件保存后需另行提交审核。'
2026-08-03 18:15:34 +08:00
: 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' }],
2026-09-02 10:15:51 +08:00
contactPhone: [
{ required: true, whitespace: true, message: '请输入公司联系电话', trigger: 'blur' },
{ pattern: /^[0-9-]{4,32}$/, message: '公司联系电话仅支持 4–32 位数字或连字符', trigger: 'blur' },
],
contactEmail: [{ type: 'email', message: '请输入正确的联系邮箱', trigger: 'blur' }],
}
const qualificationColumns: DataTableColumn[] = [
2026-09-11 16:17:24 +08:00
{ key: 'actions', label: '操作', width: 132, 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)
})
2026-07-30 17:59:42 +08:00
2026-08-03 20:50:32 +08:00
async function refreshCompanyContext() {
2026-07-31 15:34:22 +08:00
loadError.value = ''
2026-07-30 17:59:42 +08:00
try {
await store.loadCompanyContext()
Object.assign(draft, store.company)
2026-08-03 20:50:32 +08:00
return true
2026-07-30 17:59:42 +08:00
} catch (error) {
2026-07-31 15:34:22 +08:00
loadError.value = error instanceof Error ? error.message : '企业资料加载失败'
2026-08-03 20:50:32 +08:00
return false
2026-07-30 17:59:42 +08:00
}
2026-07-31 15:34:22 +08:00
}
2026-08-03 20:50:32 +08:00
async function loadCompanyContext() {
loading.value = true
await refreshCompanyContext()
loading.value = false
}
2026-09-11 16:17:24 +08:00
onMounted(async () => {
await loadCompanyContext()
if (route.query.qualification === 'upload' && canUploadQualification.value) {
activeTab.value = 'qualification'
openQualificationDialog()
}
})
2026-08-04 10:31:53 +08:00
onBeforeUnmount(() => qualificationDrafts.forEach(releaseQualificationPreview))
2026-07-30 17:59:42 +08:00
function startEdit() {
2026-08-14 16:06:33 +08:00
if (store.company.auditStatus === 'ARCHIVED') return
2026-07-30 17:59:42 +08:00
Object.assign(draft, store.company)
profileFormRef.value?.clearValidate()
2026-07-30 17:59:42 +08:00
editing.value = true
}
function cancelEdit() {
Object.assign(draft, store.company)
editing.value = false
profileFormRef.value?.clearValidate()
2026-07-30 17:59:42 +08:00
}
async function saveProfile() {
if (profileSubmitting.value) return
const valid = await profileFormRef.value?.validate().then(() => true).catch(() => false)
if (!valid) return
2026-07-30 17:59:42 +08:00
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() {
2026-08-03 18:15:34 +08:00
if (!canUploadQualification.value) {
ElMessage.warning(qualificationUploadDisabledReason.value)
return
}
2026-07-30 17:59:42 +08:00
resetQualificationDraft()
qualificationOpen.value = true
}
2026-08-04 09:05:01 +08:00
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)
}
2026-08-04 10:31:53 +08:00
function releaseQualificationPreview(item: QualificationDraftItem) {
if (!item.previewUrl) return
URL.revokeObjectURL(item.previewUrl)
item.previewUrl = ''
}
2026-08-04 09:05:01 +08:00
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 '需重新上传'
2026-08-04 14:15:24 +08:00
return item.required ? '待上传' : '选填'
2026-08-04 09:05:01 +08:00
}
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'
2026-08-04 14:15:24 +08:00
return item.required ? 'warning' : 'info'
2026-08-04 09:05:01 +08:00
}
function handleQualificationFileChange(type: MerchantQualificationType, file: UploadFile) {
const item = qualificationDraftItem(type)
if (!item) return
2026-08-04 10:31:53 +08:00
releaseQualificationPreview(item)
2026-07-30 17:59:42 +08:00
const raw = file.raw
if (!raw) {
2026-08-04 09:05:01 +08:00
item.error = '未能读取所选文件,请重新选择'
item.uploadState = 'error'
item.fileName = ''
item.fileSize = 0
item.fileType = ''
item.file = null
item.inputVersion += 1
2026-07-30 17:59:42 +08:00
return
}
const error = validateQualificationFile({ name: raw.name, size: raw.size, type: raw.type })
if (error) {
2026-08-04 09:05:01 +08:00
item.error = error
item.uploadState = 'error'
item.fileName = ''
item.fileSize = 0
item.fileType = ''
item.file = null
item.inputVersion += 1
2026-07-30 17:59:42 +08:00
return
}
2026-08-04 09:05:01 +08:00
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() || '文件'
2026-09-11 16:17:24 +08:00
if (raw.type.startsWith('image/') || /\.(jpe?g|png|bmp)$/i.test(raw.name)) item.previewUrl = URL.createObjectURL(raw)
2026-07-30 17:59:42 +08:00
}
2026-08-04 09:05:01 +08:00
function removeSelectedFile(type: MerchantQualificationType) {
const item = qualificationDraftItem(type)
if (!item) return
2026-08-04 10:31:53 +08:00
releaseQualificationPreview(item)
2026-08-04 09:05:01 +08:00
item.fileName = ''
item.fileSize = 0
item.fileType = ''
item.error = ''
item.file = null
item.inputVersion += 1
item.uploadState = 'idle'
2026-07-30 17:59:42 +08:00
}
function resetQualificationDraft() {
2026-08-04 09:05:01 +08:00
qualificationDrafts.forEach((item) => {
2026-08-04 10:31:53 +08:00
releaseQualificationPreview(item)
2026-08-04 09:05:01 +08:00
item.fileName = ''
item.fileSize = 0
item.fileType = ''
item.error = ''
item.file = null
item.inputVersion += 1
item.uploadState = 'idle'
})
qualificationCertificateNo.value = store.company.creditCode
2026-07-30 17:59:42 +08:00
}
2026-09-11 16:17:24 +08:00
async function submitQualification(closeDialog = true) {
if (qualificationSubmitting.value) return false
2026-08-03 18:15:34 +08:00
if (!canUploadQualification.value) {
qualificationOpen.value = false
await loadCompanyContext()
ElMessage.warning(qualificationUploadDisabledReason.value)
2026-09-11 16:17:24 +08:00
return false
2026-08-03 18:15:34 +08:00
}
2026-08-04 21:01:34 +08:00
if (!requiredPackageComplete.value) {
2026-08-04 09:05:01 +08:00
const businessLicense = qualificationDraftItem('BUSINESS_LICENSE')
2026-08-04 21:01:34 +08:00
if (businessLicense && !qualificationCreditCodeReady.value) businessLicense.error = qualificationCreditCodeError.value
ElMessage.warning(`请完整保存必填业务项:${missingRequiredPackageItems.value.join('、')}`)
2026-09-11 16:17:24 +08:00
return false
2026-08-04 09:05:01 +08:00
}
2026-09-02 13:34:56 +08:00
const optionalItems = qualificationDrafts.filter((item) => !item.required
&& item.file
&& (item.value !== 'LEGAL_PERSON_ID' || !requiredPackageChangeSelected.value))
const includedLegalPersonId = legalPersonIdFileSelected.value
2026-07-30 17:59:42 +08:00
qualificationSubmitting.value = true
2026-08-04 09:05:01 +08:00
let uploadedCount = 0
const failures: string[] = []
2026-07-30 17:59:42 +08:00
try {
2026-08-05 09:11:41 +08:00
if (requiredPackageChangeSelected.value) {
const businessLicense = qualificationDraftItem('BUSINESS_LICENSE')
const legalPersonId = qualificationDraftItem('LEGAL_PERSON_ID')
for (const item of [businessLicense, ...(legalPersonId?.file ? [legalPersonId] : [])]) {
2026-08-05 09:11:41 +08:00
if (!item) continue
item.error = ''
item.uploadState = 'uploading'
}
try {
await uploadMerchantRequiredQualificationPackage(
businessLicense?.file as File,
legalPersonId?.file || undefined,
2026-08-05 09:11:41 +08:00
normalizedQualificationCertificateNo.value,
Boolean(replaceableQualification('BUSINESS_LICENSE')),
legalPersonId?.file ? Boolean(replaceableQualification('LEGAL_PERSON_ID')) : undefined,
2026-08-05 09:11:41 +08:00
)
const uploadedRequiredItems = [businessLicense, ...(legalPersonId?.file ? [legalPersonId] : [])]
for (const item of uploadedRequiredItems) {
2026-08-05 09:11:41 +08:00
if (!item) continue
releaseQualificationPreview(item)
item.file = null
item.fileName = ''
item.fileSize = 0
item.fileType = ''
item.inputVersion += 1
item.uploadState = 'uploaded'
}
uploadedCount += uploadedRequiredItems.filter(Boolean).length
2026-08-05 09:11:41 +08:00
} catch (error) {
const message = qualificationUploadErrorMessage(error)
if (businessLicense) { businessLicense.error = message; businessLicense.uploadState = 'error' }
if (legalPersonId?.file) { legalPersonId.error = message; legalPersonId.uploadState = 'error' }
2026-08-05 09:11:41 +08:00
failures.push(`必填资质包:${message}`)
}
}
for (const item of failures.length ? [] : optionalItems) {
2026-08-04 09:05:01 +08:00
const file = item.file
if (!file) continue
item.error = ''
item.uploadState = 'uploading'
try {
await uploadMerchantQualification(
file,
item.value,
2026-08-05 09:11:41 +08:00
undefined,
2026-08-04 09:05:01 +08:00
Boolean(replaceableQualification(item.value)),
)
2026-08-04 10:31:53 +08:00
releaseQualificationPreview(item)
2026-08-04 09:05:01 +08:00
item.file = null
item.fileName = ''
item.fileSize = 0
item.fileType = ''
item.inputVersion += 1
item.uploadState = 'uploaded'
uploadedCount += 1
} catch (error) {
2026-08-04 14:15:24 +08:00
const message = qualificationUploadErrorMessage(error)
2026-08-04 09:05:01 +08:00
item.error = message
item.uploadState = 'error'
failures.push(`${item.label}:${message}`)
2026-08-03 20:50:32 +08:00
}
2026-08-03 18:15:34 +08:00
}
2026-08-03 20:50:32 +08:00
const refreshed = await refreshCompanyContext()
2026-08-04 09:05:01 +08:00
if (failures.length) {
if (!canUploadQualification.value) qualificationOpen.value = false
const uploadedCopy = uploadedCount ? `已保存 ${uploadedCount} 项;` : ''
ElMessage.error(`${uploadedCopy}${failures[0]}`)
2026-09-11 16:17:24 +08:00
return false
2026-08-04 09:05:01 +08:00
}
2026-09-11 16:17:24 +08:00
if (closeDialog) qualificationOpen.value = false
if (refreshed) ElMessage.success(includedLegalPersonId
2026-09-02 13:34:56 +08:00
? (requiredPackageChangeSelected.value
? '营业执照、统一社会信用代码和法人身份证已保存,尚未提交审核'
: '法人身份证已保存,尚未提交审核')
: '资质文件已保存,尚未提交审核')
2026-08-04 09:05:01 +08:00
else ElMessage.warning('资质文件已保存,但最新状态刷新失败,请点击“重新加载”确认状态')
2026-09-11 16:17:24 +08:00
return refreshed
2026-07-30 17:59:42 +08:00
} finally {
qualificationSubmitting.value = false
}
}
2026-09-11 16:17:24 +08:00
async function submitQualificationAndReview() {
if (qualificationSubmitting.value || reviewSubmitting.value) return
if (selectedQualificationCount.value > 0 && !await submitQualification(false)) return
if (!canSubmitQualificationReview.value) {
ElMessage.warning(qualificationReviewDisabledReason.value)
return
}
await submitQualificationReview()
if (store.company.auditStatus === 'PENDING') qualificationOpen.value = false
}
2026-08-03 20:50:32 +08:00
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
}
}
2026-09-11 16:17:24 +08:00
function qualificationDeleteDisabled(record: TenantQualification) {
return deletingQualificationId.value === record.id
|| !auth.hasPermission('company:qualification:upload')
|| !canUploadQualification.value
}
async function deleteQualification(record: TenantQualification) {
if (qualificationDeleteDisabled(record)) return
try {
await ElMessageBox.confirm(
`确认删除资质文件“${record.fileName}”?删除后如需送审必须重新上传。`,
'删除资质文件',
{ type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' },
)
} catch {
return
}
deletingQualificationId.value = record.id
try {
await store.deleteQualification(record.id)
ElMessage.success('资质文件已删除')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '资质文件删除失败')
} finally {
deletingQualificationId.value = ''
}
}
2026-07-30 17:59:42 +08:00
async function previewQualification(record: TenantQualification) {
2026-08-03 18:15:34 +08:00
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
2026-07-30 17:59:42 +08:00
previewRecord.value = record
previewOpen.value = true
2026-08-03 18:15:34 +08:00
previewUrl.value = ''
previewError.value = ''
if (!record.documentUrl) {
previewError.value = '当前资质记录没有可查看的文件地址,请联系平台管理员处理。'
return
}
2026-07-30 17:59:42 +08:00
previewLoading.value = true
try {
const blob = await downloadQualificationImage(record.documentUrl)
2026-08-03 20:50:32 +08:00
previewKind.value = blob.type === 'application/pdf' || record.fileName.toLowerCase().endsWith('.pdf') ? 'pdf' : 'image'
2026-07-30 17:59:42 +08:00
previewUrl.value = URL.createObjectURL(blob)
} catch (error) {
2026-08-03 18:15:34 +08:00
previewError.value = error instanceof Error && error.message === '资质文件地址无效'
? '当前资质文件尚未迁移到安全预览地址,请联系平台管理员处理。'
: '资质文件加载失败,请稍后重试。'
ElMessage.error(previewError.value)
2026-07-30 17:59:42 +08:00
} finally {
previewLoading.value = false
}
}
function closeQualificationPreview() {
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
previewUrl.value = ''
2026-08-03 20:50:32 +08:00
previewKind.value = 'image'
2026-08-03 18:15:34 +08:00
previewError.value = ''
2026-07-30 17:59:42 +08:00
previewRecord.value = null
}
</script>
<template>
<section v-loading="loading" class="profile-page">
2026-07-31 15:34:22 +08:00
<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>
2026-07-30 17:59:42 +08:00
<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">
2026-08-14 16:06:33 +08:00
<header class="info-panel-header"><h3>企业基本信息</h3><el-button v-if="auth.hasPermission('company:profile:edit') && store.company.auditStatus !== 'ARCHIVED'" type="primary" @click="startEdit"><Pencil :size="16" />编辑企业资料</el-button></header>
2026-07-30 17:59:42 +08:00
<dl class="profile-grid">
2026-07-31 15:34:22 +08:00
<div><dt>公司名称</dt><dd>{{ store.company.companyName || '-' }}</dd></div>
<div><dt>统一社会信用代码</dt><dd>{{ store.company.creditCode || '-' }}</dd></div>
2026-08-17 19:52:08 +08:00
<div><dt>企业类型</dt><dd>{{ store.company.enterpriseType || '-' }}</dd></div>
<div><dt>注册地</dt><dd>{{ store.company.registeredAddress || '-' }}</dd></div>
<div><dt>成立日期</dt><dd>{{ store.company.establishedDate || '-' }}</dd></div>
<div><dt>注册资本</dt><dd>{{ store.company.registeredCapital == null ? '-' : `${store.company.registeredCapital} ${store.company.registeredCapitalCurrency}`.trim() }}</dd></div>
2026-07-31 15:34:22 +08:00
<div><dt>法人姓名</dt><dd>{{ store.company.legalRepresentative || '-' }}</dd></div>
2026-07-30 17:59:42 +08:00
</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">
2026-08-03 10:51:53 +08:00
<header class="info-panel-header"><h3>联系信息</h3></header>
2026-07-30 17:59:42 +08:00
<dl class="profile-grid">
2026-07-31 15:34:22 +08:00
<div><dt>联系人</dt><dd>{{ store.company.contactName || '-' }}</dd></div>
2026-09-02 10:04:21 +08:00
<div><dt>公司联系电话</dt><dd>{{ store.company.contactPhone || '-' }}</dd></div>
2026-07-31 15:34:22 +08:00
<div><dt>联系邮箱</dt><dd>{{ store.company.contactEmail || '-' }}</dd></div>
2026-08-17 19:52:08 +08:00
<div><dt>联系微信</dt><dd>{{ store.company.contactWechat || '-' }}</dd></div>
2026-07-30 17:59:42 +08:00
</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">
2026-08-03 18:15:34 +08:00
<header>
<div><h3>企业资质</h3><p>{{ qualificationPanelHint }}</p></div>
2026-08-03 20:50:32 +08:00
<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>
2026-08-03 18:15:34 +08:00
</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"
>
2026-09-11 16:17:24 +08:00
<template #actions="{ row }">
<el-button link type="primary" @click="previewQualification(row as TenantQualification)">查看</el-button>
<el-button link type="danger" :loading="deletingQualificationId === (row as TenantQualification).id" :disabled="qualificationDeleteDisabled(row as TenantQualification)" @click="deleteQualification(row as TenantQualification)">删除</el-button>
</template>
2026-08-03 20:50:32 +08:00
<template #status="{ row }"><el-tag :type="qualificationStatusMeta(row.status).type" effect="light">{{ qualificationStatusMeta(row.status).label }}</el-tag></template>
</DataTable>
2026-07-30 17:59:42 +08:00
<div class="mobile-qualification-list">
<article v-for="record in store.qualifications" :key="record.id">
2026-08-03 20:50:32 +08:00
<header><strong>{{ record.type }}</strong><el-tag :type="qualificationStatusMeta(record.status).type" effect="light">{{ qualificationStatusMeta(record.status).label }}</el-tag></header>
2026-07-30 17:59:42 +08:00
<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>
2026-09-11 16:17:24 +08:00
<div class="mobile-qualification-actions">
<el-button link type="primary" @click="previewQualification(record)">查看文件信息</el-button>
<el-button link type="danger" :loading="deletingQualificationId === record.id" :disabled="qualificationDeleteDisabled(record)" @click="deleteQualification(record)">删除</el-button>
</div>
2026-07-30 17:59:42 +08:00
</article>
2026-07-31 15:34:22 +08:00
<div v-if="store.qualifications.length === 0" class="client-empty">暂无企业资质记录。</div>
2026-07-30 17:59:42 +08:00
</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">
2026-07-30 17:59:42 +08:00
<h3>企业基本信息</h3>
<div class="form-grid-two">
<el-form-item label="公司名称" prop="companyName" required><el-input v-model="draft.companyName" placeholder="请输入公司名称" /></el-form-item>
2026-07-30 17:59:42 +08:00
<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>
2026-07-30 17:59:42 +08:00
</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>
2026-09-02 10:15:51 +08:00
<el-form-item label="公司联系电话" prop="contactPhone" required><el-input v-model="draft.contactPhone" maxlength="32" inputmode="tel" placeholder="请输入公司联系电话" /><small class="profile-field-hint">仅用于公司业务联系,不会修改任何成员的登录手机号。</small></el-form-item>
<el-form-item label="联系邮箱" prop="contactEmail"><el-input v-model="draft.contactEmail" placeholder="请输入联系邮箱(选填)" /></el-form-item>
2026-07-30 17:59:42 +08:00
</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>
2026-08-04 09:05:01 +08:00
<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">
2026-08-04 21:01:34 +08:00
<strong>企业认证必填业务项</strong>
<span>营业执照和18位统一社会信用代码需共同保存;法人身份证及其他资质可按需上传。</span>
2026-08-04 09:05:01 +08:00
</div>
<div class="qualification-upload-grid">
<article v-for="item in qualificationDrafts" :key="item.value" class="qualification-upload-item">
<header>
<div>
2026-08-04 14:15:24 +08:00
<strong>{{ item.label }}({{ item.required ? '必填' : '选填' }})</strong>
2026-08-04 09:05:01 +08:00
<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"
2026-09-11 16:17:24 +08:00
accept=".pdf,.jpg,.jpeg,.png,.bmp,application/pdf,image/jpeg,image/png,image/bmp,image/x-ms-bmp"
2026-08-04 09:05:01 +08:00
@change="handleQualificationFileChange(item.value, $event)"
>
<el-button :disabled="qualificationSubmitting"><Upload :size="16" />{{ qualificationRecord(item.value) ? '替换文件' : '选择文件' }}</el-button>
2026-09-11 16:17:24 +08:00
<template #tip><div class="upload-tip">支持 PDF、JPG、PNG、BMP,单个文件不超过 5MB。</div></template>
2026-07-30 17:59:42 +08:00
</el-upload>
2026-08-04 09:05:01 +08:00
<div v-if="item.fileName" class="selected-qualification-file" aria-live="polite">
2026-08-04 10:31:53 +08:00
<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>
2026-08-04 09:05:01 +08:00
<el-button link type="danger" :disabled="qualificationSubmitting" :aria-label="`移除${item.label}文件`" @click="removeSelectedFile(item.value)"><Trash2 :size="16" />移除</el-button>
2026-07-30 17:59:42 +08:00
</div>
2026-08-04 09:05:01 +08:00
<p v-if="item.error" class="qualification-upload-error" role="alert">{{ item.error }}</p>
</article>
</div>
2026-08-04 21:01:34 +08:00
<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>
2026-08-04 09:05:01 +08:00
</el-form-item>
2026-08-04 10:31:53 +08:00
<p v-if="qualificationDialogHint" class="qualification-dialog-hint">{{ qualificationDialogHint }}</p>
2026-09-11 16:17:24 +08:00
<template #footer><el-button :disabled="qualificationSubmitting || reviewSubmitting" @click="qualificationOpen = false">取消</el-button><el-button :loading="qualificationSubmitting" :disabled="!canSaveQualificationBatch || reviewSubmitting" @click="submitQualification()">保存草稿</el-button><el-button type="primary" :loading="qualificationSubmitting || reviewSubmitting" :disabled="selectedQualificationCount > 0 ? !canSaveQualificationBatch : !canSubmitQualificationReview" @click="submitQualificationAndReview">提交审核</el-button></template>
2026-07-30 17:59:42 +08:00
</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">
2026-08-03 20:50:32 +08:00
<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" />
2026-07-30 17:59:42 +08:00
<FileCheck2 :size="42" />
<strong>{{ previewRecord.fileName }}</strong>
2026-08-03 20:50:32 +08:00
<el-tag :type="qualificationStatusMeta(previewRecord.status).type" effect="light">{{ qualificationStatusMeta(previewRecord.status).label }}</el-tag>
2026-07-30 17:59:42 +08:00
<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>
2026-08-03 18:15:34 +08:00
<p v-if="previewError" class="document-preview-error" role="alert">{{ previewError }}</p>
<span v-else>{{ previewUrl ? '文件通过当前商户会话临时加载,关闭弹窗后立即释放。' : '当前文件暂不可预览。' }}</span>
2026-07-30 17:59:42 +08:00
</div>
</el-dialog>
2026-07-31 15:34:22 +08:00
</template>
2026-07-30 17:59:42 +08:00
</section>
</template>
<style scoped>
.profile-page { display: flex; flex-direction: column; gap: 16px; }
2026-07-31 15:34:22 +08:00
.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; }
2026-07-30 17:59:42 +08:00
.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; }
2026-08-03 20:50:32 +08:00
.qualification-preview-frame { display:block; width:100%; min-height:min(60vh,560px); margin:0 0 14px; border:0; background:#fff; }
2026-07-30 17:59:42 +08:00
.company-audit-notice.is-pending { border-color: #e5d2a5; background: #fff9eb; color: #9a6a12; }
2026-08-03 10:51:53 +08:00
.qualification-panel header p { margin: 6px 0 0; color: var(--muted); font-size: 12px; }
2026-07-30 17:59:42 +08:00
.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; }
2026-08-03 10:51:53 +08:00
.info-panel-header { min-height: 66px; padding: 14px 22px; border-bottom: 1px solid var(--border); }
.info-panel-header h3 { margin: 0; font-size: 15px; }
2026-07-30 17:59:42 +08:00
.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; }
2026-08-03 20:50:32 +08:00
.qualification-panel-actions { display:flex; flex-wrap:wrap; justify-content:flex-end; gap:8px; }
2026-08-03 18:15:34 +08:00
.qualification-update-control { display: inline-flex; }
2026-07-30 17:59:42 +08:00
.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; }
2026-09-02 10:04:21 +08:00
.profile-field-hint { display: block; margin-top: 5px; color: var(--muted); font-size: 10px; line-height: 1.5; }
2026-07-30 17:59:42 +08:00
.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; }
2026-09-11 16:17:24 +08:00
.mobile-qualification-actions { display: flex; align-items: center; gap: 8px; }
2026-08-04 09:05:01 +08:00
.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; }
2026-07-30 17:59:42 +08:00
.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); }
2026-08-04 10:31:53 +08:00
.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; }
2026-07-30 17:59:42 +08:00
.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; }
2026-08-04 09:05:01 +08:00
.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; }
2026-08-04 21:01:34 +08:00
.qualification-credit-code-hint { display:block; margin-top:6px; color:var(--muted); font-size:11px; line-height:1.5; }
2026-08-04 09:05:01 +08:00
.qualification-dialog-hint { margin: 10px 0 0; color: var(--muted); font-size: 11px; line-height: 1.5; }
2026-07-30 17:59:42 +08:00
.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; }
2026-08-03 18:15:34 +08:00
.document-preview-error { max-width: 440px; margin: 0; padding: 0 16px; color: #9a5a09; font-size: 11px; line-height: 1.6; text-align: center; }
2026-07-30 17:59:42 +08:00
@media (max-width: 720px) {
.profile-grid, .form-grid-two { grid-template-columns: 1fr; }
2026-08-04 09:05:01 +08:00
.qualification-dialog-intro { flex-direction: column; gap: 5px; }
.qualification-dialog-intro span { text-align: left; }
.qualification-upload-grid { grid-template-columns: 1fr; }
2026-07-30 17:59:42 +08:00
.profile-grid > div:nth-child(odd) { border-right: 0; }
.qualification-panel > header { align-items: flex-start; gap: 12px; }
2026-08-03 20:50:32 +08:00
.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%; }
2026-07-30 17:59:42 +08:00
.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; }
2026-08-04 10:31:53 +08:00
.selected-qualification-file > .el-button { margin-left: 64px; }
2026-07-30 17:59:42 +08:00
}
</style>