Files
admin-web/src/modules/customers/pages/CustomersView.vue
T
2026-08-12 16:31:46 +08:00

1165 lines
79 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, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch, type Component } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { CheckCircle2, Download, Eye, FileText, FileUp, KeyRound, LogIn, Pencil, Plus, Power, Save, ShieldCheck, ShieldX, Trash2 } from '@lucide/vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
import { createPlatformExportTask } from '@/modules/downloads'
import {
approveEnterpriseReview,
archivePlatformTenant,
changePlatformTenantStatus,
checkPlatformTenantContactPhone,
createPlatformTenant,
createPlatformTenantImpersonation,
downloadTenantQualification,
getPlatformTenant,
listEnterpriseReviews,
listPlatformTenantOrderCounts,
listPlatformTenants,
listTenantQualifications,
qualificationFileName,
rejectEnterpriseReview,
resetPlatformTenantOwnerPassword,
submitPlatformTenantReview,
updatePlatformTenant,
uploadPlatformTenantQualification,
uploadPlatformTenantRequiredQualificationPackage,
type CreatePlatformTenantRequest,
type EnterpriseApprovalResponse,
type EnterpriseReview,
type PlatformQualificationType,
type PlatformTenantQualification,
type PlatformTenantResource,
type PlatformTenantStatus,
} from '@/modules/iam'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import CopyButton from '@/shared/components/CopyButton.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import FormSection from '@/shared/components/FormSection.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getClientWebUrl } from '@/core/config/runtimeConfig'
type CustomerFilters = QueryModel & { keyword: string; status: PlatformTenantStatus | '' }
type QualificationDraft = {
type: PlatformQualificationType
label: string
required: boolean
file: File | null
validTo: string
uploaded: boolean
uploadedName: string
documentUrl: string
previewUrl: string
previewKind: 'image' | 'pdf' | ''
previewLoading: boolean
previewFailed: boolean
uploading: boolean
error: string
}
const access = useAccessPreviewStore()
const route = useRoute()
const router = useRouter()
const page = ref(1)
const pageSize = ref(10)
const loading = ref(false)
const exportSubmitting = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const result = ref({ records: [] as PlatformTenantResource[], total: 0, pages: 0 })
const orderCounts = ref<Record<string, number>>({})
const rowActionId = ref('')
const pendingReviews = ref<EnterpriseReview[]>([])
const reviewLoadError = ref<ApiErrorDetails | null>(null)
const selectedReview = ref<EnterpriseReview | null>(null)
const decisionOpen = ref(false)
const reviewSubmitting = ref(false)
const approvalResultOpen = ref(false)
const approvalResult = ref<EnterpriseApprovalResponse | null>(null)
const credentialSource = ref<'creation' | 'approval'>('approval')
const decision = reactive({ type: 'approve' as 'approve' | 'reject', comment: '' })
const decisionFormRef = ref<FormInstance>()
const passwordDialogOpen = ref(false)
const passwordSubmitting = ref(false)
const passwordFormRef = ref<FormInstance>()
const passwordTenant = ref<PlatformTenantResource | null>(null)
const passwordForm = reactive({ newPassword: '', confirmPassword: '', forceChangePassword: true })
const passwordError = ref<ApiErrorDetails | null>(null)
const impersonationDialogOpen = ref(false)
const impersonationSubmitting = ref(false)
const impersonationFormRef = ref<FormInstance>()
const impersonationTenant = ref<PlatformTenantResource | null>(null)
const impersonationForm = reactive({ reason: '' })
const impersonationError = ref<ApiErrorDetails | null>(null)
const archiveDialogOpen = ref(false)
const archiveSubmitting = ref(false)
const archiveTenant = ref<PlatformTenantResource | null>(null)
const archiveConfirmCode = ref('')
const archiveError = ref<ApiErrorDetails | null>(null)
let impersonationCleanup: (() => void) | null = null
const editorOpen = ref(false)
const editorLoading = ref(false)
const saving = ref(false)
const submitting = ref(false)
const savedFormSnapshot = ref('')
const editingId = ref<string | null>(null)
const editorStatus = ref<PlatformTenantStatus | null>(null)
const formRef = ref<FormInstance>()
const qualificationLoadError = ref('')
const qualificationPreviewOpen = ref(false)
const qualificationPreviewName = ref('')
const qualificationPreviewUrl = ref('')
const qualificationPreviewKind = ref<'image' | 'pdf' | ''>('')
const filters = ref<CustomerFilters>({ keyword: '', status: '' })
const queryFields: QueryField[] = [
{ key: 'keyword', type: 'input', label: '客户关键词', placeholder: '公司名称、客户编号或联系人' },
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [
{ label: '待完善', value: 'PROFILE_REQUIRED' }, { label: '待审核', value: 'PENDING' },
{ label: '启用', value: 'ACTIVE' }, { label: '已驳回', value: 'REJECTED' },
{ label: '已停用', value: 'DISABLED' },
] },
]
type CustomerForm = CreatePlatformTenantRequest & { unifiedSocialCreditCode: string }
const emptyForm = (): CustomerForm => ({
tenantName: '', legalName: '', legalRepresentative: '', unifiedSocialCreditCode: '', contactName: '', contactPhone: '',
contactEmail: '', contactWechat: '', servicePackage: '基础版', salesOwnerName: '', paymentTermDays: 30,
enterpriseType: '', establishedDate: null, registeredCapital: 100000, registeredCapitalCurrency: 'CNY',
registeredAddress: '', internalRemark: '',
})
const form = ref(emptyForm())
const rules: FormRules = {
tenantName: [{ required: true, message: '请输入公司名称', trigger: 'blur' }],
legalName: [{ required: true, message: '请输入营业执照上的企业名称', trigger: 'blur' }],
legalRepresentative: [{ required: true, message: '请输入法定代表人姓名', trigger: 'blur' }],
unifiedSocialCreditCode: [{ pattern: /^[0-9A-Z]{18}$/, message: '请输入 18 位大写字母或数字', trigger: 'blur' }],
contactName: [{ required: true, message: '请输入联系人姓名', trigger: 'blur' }],
contactPhone: [
{ required: true, message: '请输入联系电话', trigger: 'blur' },
{ pattern: /^[0-9-]{4,32}$/, message: '请输入 4-32 位数字或连字符,联系电话将作为登录用户名', trigger: 'blur' },
],
contactEmail: [{ type: 'email', message: '请输入有效邮箱地址', trigger: 'blur' }],
servicePackage: [{ required: true, message: '请选择客户套餐', trigger: 'change' }],
paymentTermDays: [{ required: true, message: '请输入账期天数', trigger: 'change' }],
}
const decisionRules: FormRules = {
comment: [{
validator: (_rule, value, callback) => {
if (typeof value === 'string' && value.trim()) callback()
else callback(new Error('请填写审核说明或驳回原因'))
},
trigger: ['blur', 'change'],
}],
}
function newQualificationDrafts(): QualificationDraft[] {
const definitions: Array<Pick<QualificationDraft, 'type' | 'label' | 'required'>> = [
{ type: 'BUSINESS_LICENSE', label: '营业执照', required: true },
{ type: 'LEGAL_PERSON_ID', label: '法人身份证', required: true },
{ type: 'FREIGHT_FORWARDER_CERTIFICATE', label: '货代资质证书', required: false },
{ type: 'BANK_ACCOUNT_LICENSE', label: '银行开户许可证', required: false },
]
return definitions.map((item) => ({
...item,
file: null,
validTo: '',
uploaded: false,
uploadedName: '',
documentUrl: '',
previewUrl: '',
previewKind: '',
previewLoading: false,
previewFailed: false,
uploading: false,
error: '',
}))
}
const qualifications = ref<QualificationDraft[]>(newQualificationDrafts())
const creditCodeEstablished = ref(false)
const unifiedSocialCreditCodePattern = /^[0-9A-Z]{18}$/
const customerColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 500, slotName: 'actions' },
{ key: 'tenantCode', prop: 'tenantCode', label: '客户编号', width: 220, className: 'customer-code-column' },
{ key: 'company', label: '公司名称', width: 250, slotName: 'company' },
{ key: 'unifiedSocialCreditCode', prop: 'unifiedSocialCreditCode', label: '统一社会信用代码', width: 220, formatter: (row: PlatformTenantResource) => displayText(row.unifiedSocialCreditCode) },
{ key: 'contactName', prop: 'contactName', label: '联系人', minWidth: 120, formatter: (row: PlatformTenantResource) => displayText(row.contactName) },
{ key: 'contactPhone', prop: 'contactPhone', label: '电话', minWidth: 150, formatter: (row: PlatformTenantResource) => displayText(row.contactPhone) },
{ key: 'salesOwnerName', prop: 'salesOwnerName', label: '销售人员', minWidth: 120, formatter: (row: PlatformTenantResource) => displayText(row.salesOwnerName) },
{ key: 'paymentTermDays', prop: 'paymentTermDays', label: '账期(天)', width: 105, formatter: (row: PlatformTenantResource) => row.paymentTermDays ?? 0 },
{ key: 'status', label: '状态', width: 110, slotName: 'status' },
{ key: 'orderCount', label: '运单数', width: 100, slotName: 'orderCount' },
{ key: 'createdAt', prop: 'createdAt', label: '入驻时间', minWidth: 165, formatter: (row: PlatformTenantResource) => formatDate(row.createdAt) },
]
let loadSequence = 0
let reviewLoadSequence = 0
let editorLoadSequence = 0
const editorTitle = computed(() => editingId.value ? '编辑客户' : '新增客户')
const editorDialogWidth = computed(() => editingId.value
? 'min(640px, calc(100vw - 28px))'
: 'min(680px, calc(100vw - 28px))')
const canUploadQualifications = computed(() => !editorStatus.value || ['PROFILE_REQUIRED', 'REJECTED'].includes(editorStatus.value))
const canSubmitReview = computed(() => Boolean(editingId.value) && ['PROFILE_REQUIRED', 'REJECTED'].includes(editorStatus.value || ''))
const uploadingQualifications = computed(() => qualifications.value.some((item) => item.uploading))
const hasSelectedQualifications = computed(() => qualifications.value.some((item) => item.file))
const selectedBusinessLicense = computed(() => qualifications.value.find((item) => item.type === 'BUSINESS_LICENSE')?.file)
const selectedLegalPersonId = computed(() => qualifications.value.find((item) => item.type === 'LEGAL_PERSON_ID')?.file)
const requiredPackageChangeSelected = computed(() => Boolean(selectedBusinessLicense.value || selectedLegalPersonId.value))
const requiredPackageFilesSelected = computed(() => Boolean(selectedBusinessLicense.value && selectedLegalPersonId.value))
const creditCodeReadyForSave = computed(() => requiredPackageChangeSelected.value
? requiredPackageFilesSelected.value && (creditCodeEstablished.value
|| unifiedSocialCreditCodePattern.test(form.value.unifiedSocialCreditCode || ''))
: creditCodeEstablished.value)
const requiredQualificationPackageComplete = computed(() => creditCodeReadyForSave.value
&& (requiredPackageChangeSelected.value
? requiredPackageFilesSelected.value
: qualifications.value.filter((item) => item.required).every((item) => item.uploaded)))
const missingQualificationPackageItems = computed(() => {
const businessLicense = qualifications.value.find((item) => item.type === 'BUSINESS_LICENSE')
const legalPersonId = qualifications.value.find((item) => item.type === 'LEGAL_PERSON_ID')
const missing: string[] = []
if (requiredPackageChangeSelected.value ? !businessLicense?.file : !businessLicense?.uploaded) missing.push('营业执照')
if (!creditCodeReadyForSave.value) missing.push('18位统一社会信用代码(需随必填资质包保存)')
if (requiredPackageChangeSelected.value ? !legalPersonId?.file : !legalPersonId?.uploaded) missing.push('法人身份证')
return missing
})
const hasUnsavedChanges = computed(() => Boolean(editingId.value) && JSON.stringify(form.value) !== savedFormSnapshot.value)
const pendingReviewByTenantId = computed(() => new Map(pendingReviews.value.map((review) => [String(review.tenantId), review])))
const passwordRules: FormRules = {
newPassword: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 8, max: 20, message: '密码长度为 8-20 位', trigger: 'blur' },
{ pattern: /^(?=.*[A-Za-z])(?=.*\d).+$/, message: '密码必须同时包含字母和数字', trigger: 'blur' },
],
confirmPassword: [{
validator: (_rule, value, callback) => value === passwordForm.newPassword ? callback() : callback(new Error('两次输入的密码不一致')),
trigger: ['blur', 'change'],
}],
}
const impersonationRules: FormRules = {
reason: [
{ required: true, message: '请输入代登录原因', trigger: 'blur' },
{ min: 5, max: 500, message: '代登录原因需填写 5-500 字', trigger: 'blur' },
],
}
function displayText(value?: string | null) { return value?.trim() || '—' }
function formatDate(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
function statusMeta(status: PlatformTenantStatus) {
if (status === 'ACTIVE') return { label: '启用', type: 'success' as const }
if (status === 'PROFILE_REQUIRED') return { label: '待完善', type: 'info' as const }
if (status === 'PENDING') return { label: '待审核', type: 'warning' as const }
if (status === 'REJECTED') return { label: '已驳回', type: 'danger' as const }
return { label: '已停用', type: 'info' as const }
}
async function loadPendingReviews() {
const sequence = ++reviewLoadSequence
if (!access.can('platform:customer:review')) {
if (sequence === reviewLoadSequence) {
pendingReviews.value = []
reviewLoadError.value = null
}
return [] as EnterpriseReview[]
}
if (sequence === reviewLoadSequence) reviewLoadError.value = null
try {
const reviews = await listEnterpriseReviews()
if (sequence === reviewLoadSequence) pendingReviews.value = reviews
return reviews
} catch (error) {
if (sequence === reviewLoadSequence) {
pendingReviews.value = []
reviewLoadError.value = getApiErrorDetails(error, '待审核客户加载失败,审核操作暂不可用')
}
return [] as EnterpriseReview[]
}
}
async function reloadPendingReviews() {
const reviews = await loadPendingReviews()
if (!access.can('platform:customer:list')) result.value = reviewOnlyPage(reviews)
}
function reviewToCustomer(review: EnterpriseReview): PlatformTenantResource {
const tenantId = String(review.tenantId)
return {
id: tenantId,
tenantCode: review.tenantCode?.trim() || '待生成',
tenantName: review.tenantName?.trim() || '待审核企业',
status: 'PENDING',
legalName: null,
legalRepresentative: null,
unifiedSocialCreditCode: null,
contactName: review.contactName ?? null,
contactPhone: review.contactPhone ?? null,
contactEmail: review.contactEmail ?? null,
servicePackage: null,
contactWechat: null,
salesOwnerName: null,
paymentTermDays: null,
enterpriseType: null,
establishedDate: null,
registeredCapital: null,
registeredCapitalCurrency: null,
registeredAddress: null,
internalRemark: null,
createdAt: review.submittedAt ?? review.createdAt ?? null,
approvedAt: null,
}
}
function reviewOnlyPage(reviews: EnterpriseReview[]) {
const keyword = filters.value.keyword.trim().toLowerCase()
const records = reviews
.map(reviewToCustomer)
.filter((row) => (!filters.value.status || filters.value.status === 'PENDING') && (
!keyword
|| row.tenantName.toLowerCase().includes(keyword)
|| row.tenantCode.toLowerCase().includes(keyword)
|| String(row.id).includes(keyword)
))
const start = (page.value - 1) * pageSize.value
return {
records: records.slice(start, start + pageSize.value),
page: page.value,
size: pageSize.value,
total: records.length,
pages: Math.ceil(records.length / pageSize.value),
}
}
async function loadCustomers() {
const sequence = ++loadSequence
loading.value = true; loadError.value = null
try {
const reviewsRequest = loadPendingReviews()
const pageResult = access.can('platform:customer:list')
? await listPlatformTenants({ page: page.value, size: pageSize.value, keyword: filters.value.keyword.trim() || undefined, status: filters.value.status || undefined })
: reviewOnlyPage(await reviewsRequest)
if (access.can('platform:customer:list')) await reviewsRequest
if (sequence !== loadSequence) return
result.value = pageResult; orderCounts.value = {}
if (access.can('platform:order:list') && pageResult.records.length) {
try {
const counts = await listPlatformTenantOrderCounts(pageResult.records.map((item) => item.id))
if (sequence === loadSequence) orderCounts.value = Object.fromEntries(counts.map((item) => [String(item.tenantId), Number(item.orderCount)]))
} catch { /* 主档可继续使用,运单服务失败时不显示模拟数量。 */ }
}
} catch (error) {
if (sequence === loadSequence) loadError.value = getApiErrorDetails(error, '客户列表加载失败,请稍后重试')
} finally { if (sequence === loadSequence) loading.value = false }
}
function applyFilters() { if (page.value === 1) void loadCustomers(); else page.value = 1 }
function clearDraftPreview(draft: QualificationDraft) {
if (draft.previewUrl) URL.revokeObjectURL(draft.previewUrl)
draft.previewUrl = ''
draft.previewKind = ''
draft.previewLoading = false
draft.previewFailed = false
}
function clearQualificationPreviews() {
for (const draft of qualifications.value) clearDraftPreview(draft)
qualificationPreviewOpen.value = false
qualificationPreviewName.value = ''
qualificationPreviewUrl.value = ''
qualificationPreviewKind.value = ''
}
function resetEditor() {
clearQualificationPreviews()
form.value = emptyForm(); qualifications.value = newQualificationDrafts(); creditCodeEstablished.value = false; qualificationLoadError.value = ''; savedFormSnapshot.value = ''
}
function openCreate() {
editorLoadSequence += 1
editingId.value = null; editorStatus.value = null; resetEditor(); editorOpen.value = true
nextTick(() => formRef.value?.clearValidate())
}
function mapTenantToForm(row: PlatformTenantResource): CustomerForm {
return {
tenantName: row.tenantName, legalName: row.legalName || '', legalRepresentative: row.legalRepresentative || '', unifiedSocialCreditCode: row.unifiedSocialCreditCode || '',
contactName: row.contactName || '', contactPhone: row.contactPhone || '', contactEmail: row.contactEmail || '',
contactWechat: row.contactWechat || '', servicePackage: row.servicePackage || '基础版', salesOwnerName: row.salesOwnerName || '',
paymentTermDays: Number(row.paymentTermDays ?? 0), enterpriseType: row.enterpriseType || '', establishedDate: row.establishedDate || null,
registeredCapital: row.registeredCapital === null ? null : Number(row.registeredCapital),
registeredCapitalCurrency: row.registeredCapitalCurrency || 'CNY', registeredAddress: row.registeredAddress || '',
internalRemark: row.internalRemark || '',
}
}
async function loadEditorQualifications(tenantId: number | string, sequence = editorLoadSequence) {
qualificationLoadError.value = ''
try {
const records = await listTenantQualifications(tenantId)
if (sequence !== editorLoadSequence || !editorOpen.value || editingId.value !== String(tenantId)) return
const acceptedStatus = ['ACTIVE', 'DISABLED'].includes(editorStatus.value || '') ? 'APPROVED' : 'PENDING'
for (const draft of qualifications.value) {
const item = [...records].reverse().find((record) => (
record.qualificationType === draft.type && record.status === acceptedStatus
))
if (item) {
draft.uploaded = true
draft.validTo = item.validTo || ''
draft.documentUrl = item.documentUrl
try { draft.uploadedName = qualificationFileName(item.documentUrl) }
catch { draft.uploadedName = '已登记文件' }
draft.previewKind = draft.uploadedName.toLowerCase().endsWith('.pdf') ? 'pdf' : 'image'
if (draft.previewKind === 'image') void loadQualificationPreview(draft, tenantId, false)
}
}
} catch (error) { qualificationLoadError.value = getApiErrorMessage(error, '资质信息加载失败,可稍后重试') }
}
async function openEdit(row: PlatformTenantResource) {
const id = String(row.id)
const sequence = ++editorLoadSequence
editingId.value = id; editorStatus.value = row.status; resetEditor(); form.value = mapTenantToForm(row)
editorOpen.value = true; editorLoading.value = true
try {
const tenant = await getPlatformTenant(row.id)
if (sequence !== editorLoadSequence || editingId.value !== id || !editorOpen.value) return
editorStatus.value = tenant.status
form.value = mapTenantToForm(tenant)
creditCodeEstablished.value = unifiedSocialCreditCodePattern.test(tenant.unifiedSocialCreditCode || '')
savedFormSnapshot.value = JSON.stringify(form.value)
await loadEditorQualifications(row.id, sequence)
await nextTick(() => formRef.value?.clearValidate())
} catch (error) {
if (sequence === editorLoadSequence && editingId.value === id) editorOpen.value = false
ElMessage.error(getApiErrorMessage(error, '客户完整资料加载失败,请稍后重试'))
} finally {
if (sequence === editorLoadSequence && editingId.value === id) editorLoading.value = false
}
}
function selectQualificationFile(event: Event, draft: QualificationDraft) {
const input = event.target as HTMLInputElement
const file = input.files?.[0] || null
draft.error = ''; draft.file = null; clearDraftPreview(draft)
if (!file) return
const extensionValid = /\.(pdf|jpe?g|png)$/i.test(file.name)
const mimeValid = ['application/pdf', 'image/jpeg', 'image/png'].includes(file.type)
if (!extensionValid || !mimeValid) { draft.error = '仅支持 PDF、JPG、PNG 文件'; input.value = ''; return }
if (file.size > 5 * 1024 * 1024) { draft.error = '单个文件不能超过 5MB'; input.value = ''; return }
draft.file = file
draft.previewKind = file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf') ? 'pdf' : 'image'
draft.previewUrl = URL.createObjectURL(file)
}
async function loadQualificationPreview(draft: QualificationDraft, tenantId: number | string, showError = true) {
if (!draft.documentUrl || draft.previewLoading) return false
draft.previewLoading = true
try {
const blob = await downloadTenantQualification(tenantId, draft.documentUrl)
if (!blob.size) throw new Error('资质文件内容为空')
if (!editorOpen.value || !qualifications.value.includes(draft)) return false
clearDraftPreview(draft)
draft.previewKind = blob.type === 'application/pdf' || draft.uploadedName.toLowerCase().endsWith('.pdf') ? 'pdf' : 'image'
draft.previewUrl = URL.createObjectURL(blob)
return true
} catch (error) {
if (showError) ElMessage.error(getApiErrorMessage(error, '资质文件预览加载失败,请稍后重试'))
return false
} finally { draft.previewLoading = false }
}
async function openQualificationPreview(draft: QualificationDraft) {
if (draft.previewFailed) {
if (!draft.uploaded || !editingId.value) {
ElMessage.warning('该文件无法预览,请重新选择有效文件')
return
}
clearDraftPreview(draft)
draft.previewKind = draft.uploadedName.toLowerCase().endsWith('.pdf') ? 'pdf' : 'image'
}
if (!draft.previewUrl && draft.uploaded && editingId.value) {
await loadQualificationPreview(draft, editingId.value)
}
if (!draft.previewUrl || !draft.previewKind) return
qualificationPreviewName.value = draft.file?.name || draft.uploadedName || draft.label
qualificationPreviewUrl.value = draft.previewUrl
qualificationPreviewKind.value = draft.previewKind
qualificationPreviewOpen.value = true
}
function handleQualificationPreviewError(draft: QualificationDraft) {
draft.previewFailed = true
if (qualificationPreviewUrl.value === draft.previewUrl) qualificationPreviewOpen.value = false
}
async function uploadSelectedQualifications(tenantId: string) {
const failures: string[] = []
const businessLicense = qualifications.value.find((item) => item.type === 'BUSINESS_LICENSE')
const legalPersonId = qualifications.value.find((item) => item.type === 'LEGAL_PERSON_ID')
if (requiredPackageChangeSelected.value && businessLicense?.file && legalPersonId?.file) {
businessLicense.uploading = true; legalPersonId.uploading = true
businessLicense.error = ''; legalPersonId.error = ''
try {
const uploaded = await uploadPlatformTenantRequiredQualificationPackage(
tenantId, businessLicense.file, legalPersonId.file, form.value.unifiedSocialCreditCode,
businessLicense.uploaded, legalPersonId.uploaded,
)
businessLicense.uploaded = true; legalPersonId.uploaded = true
creditCodeEstablished.value = true
businessLicense.uploadedName = businessLicense.file.name
legalPersonId.uploadedName = legalPersonId.file.name
businessLicense.documentUrl = uploaded.businessLicenseDocumentUrl
legalPersonId.documentUrl = uploaded.legalPersonIdDocumentUrl
businessLicense.file = null; legalPersonId.file = null
} catch (error) {
const message = getApiErrorMessage(error, '必填资质包上传失败,请重试')
businessLicense.error = message; legalPersonId.error = message
failures.push('必填资质包')
} finally {
businessLicense.uploading = false; legalPersonId.uploading = false
}
}
for (const draft of failures.length ? [] : qualifications.value.filter((item) => !item.required && item.file)) {
draft.uploading = true; draft.error = ''
try {
const uploaded = await uploadPlatformTenantQualification(
tenantId, draft.type, draft.file as File, draft.validTo || undefined,
undefined,
draft.uploaded,
)
draft.uploaded = true
draft.uploadedName = draft.file?.name || '已登记文件'
draft.documentUrl = uploaded.documentUrl
draft.file = null
} catch (error) {
draft.error = getApiErrorMessage(error, '上传失败,请重试'); failures.push(draft.label)
} finally { draft.uploading = false }
}
return failures
}
async function saveCustomer() {
if (!formRef.value || !(await formRef.value.validate().catch(() => false))) return
if (!editingId.value) {
try {
const available = await checkPlatformTenantContactPhone(form.value.contactPhone.trim())
if (!available) {
ElMessage.error('联系电话已被其他客户使用,请更换后再提交')
return
}
} catch (error) {
ElMessage.error(getApiErrorMessage(error, '联系电话唯一性校验失败,请稍后重试'))
return
}
}
const qualificationPackageTouched = hasSelectedQualifications.value || Boolean(form.value.unifiedSocialCreditCode)
if (qualificationPackageTouched && !requiredQualificationPackageComplete.value) {
ElMessage.warning(`请完整保存必填业务项:${missingQualificationPackageItems.value.join('、')}`)
return
}
saving.value = true
try {
const hadSelectedQualifications = hasSelectedQualifications.value
let saved: PlatformTenantResource
if (editingId.value) {
const { unifiedSocialCreditCode: _immutable, ...payload } = form.value
saved = await updatePlatformTenant(editingId.value, payload)
} else {
const { unifiedSocialCreditCode: _qualificationCreditCode, ...payload } = form.value
const created = await createPlatformTenant(payload)
saved = created.tenant
editingId.value = String(saved.id)
credentialSource.value = 'creation'
approvalResult.value = {
tenantId: saved.id,
administratorCreated: true,
administratorUsername: created.administratorUsername,
temporaryPassword: created.temporaryPassword,
}
approvalResultOpen.value = true
}
editorStatus.value = saved.status
savedFormSnapshot.value = JSON.stringify(form.value)
const failures = hadSelectedQualifications ? await uploadSelectedQualifications(String(saved.id)) : []
if (failures.length) {
ElMessage.warning(`客户资料已保存,但${failures.join('、')}上传失败;失败文件已保留,请修正后再次保存`)
} else if (hadSelectedQualifications) {
ElMessage.success('营业执照、统一社会信用代码和法人身份证已完整保存,尚未提交审核')
} else {
ElMessage.success('客户资料已保存,尚未提交审核')
}
await loadCustomers()
} catch (error) { ElMessage.error(getApiErrorMessage(error, '客户资料保存失败,请稍后重试')) }
finally { saving.value = false }
}
async function submitReview() {
if (submitting.value) return
submitting.value = true
try {
if (!editingId.value) { ElMessage.warning('请先保存客户资料和资质文件'); return }
if (!formRef.value || !(await formRef.value.validate().catch(() => false))) return
if (hasUnsavedChanges.value) { ElMessage.warning('客户资料有未保存的修改,请先保存资料'); return }
if (uploadingQualifications.value) { ElMessage.warning('资质文件正在上传,请稍候再提交审核'); return }
if (hasSelectedQualifications.value) { ElMessage.warning('存在尚未保存的已选文件,请先点击“保存资料和资质”'); return }
if (!creditCodeEstablished.value || !unifiedSocialCreditCodePattern.test(form.value.unifiedSocialCreditCode || '')) {
ElMessage.warning('请先完整保存营业执照、18位统一社会信用代码和法人身份证')
return
}
const missing = qualifications.value.filter((item) => item.required && !item.uploaded)
for (const item of missing) item.error = `${item.label}为提交审核必填文件`
if (missing.length) { ElMessage.warning(`请先上传${missing.map((item) => item.label).join('、')}`); return }
await ElMessageBox.confirm('提交后客户资料与资质将进入企业审核,审核完成前不能继续替换文件。确认提交吗?', '提交企业审核', { type: 'warning', confirmButtonText: '确认提交', cancelButtonText: '继续完善' })
const saved = await submitPlatformTenantReview(editingId.value)
editorStatus.value = saved.status; ElMessage.success('审核单已提交')
editorOpen.value = false; await loadCustomers()
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(getApiErrorMessage(error, '提交审核失败,请稍后重试'))
} finally { submitting.value = false }
}
function showDetail(row: PlatformTenantResource) {
void router.push({ name: 'customer-detail', params: { tenantId: String(row.id) } })
}
function canResetPassword(row: PlatformTenantResource) {
return access.can('platform:customer:reset-password') && ['ACTIVE', 'DISABLED'].includes(row.status)
}
function canImpersonate(row: PlatformTenantResource) {
return access.can('platform:customer:impersonate') && row.status === 'ACTIVE'
}
function canChangeStatus(row: PlatformTenantResource) {
return access.can('platform:customer:status') && ['ACTIVE', 'DISABLED'].includes(row.status)
}
function canArchive() {
return access.can('platform:customer:delete')
}
type CustomerRowAction = 'reset-password' | 'impersonate' | 'status' | 'delete'
const customerRowActionIcons: Record<CustomerRowAction, Component> = {
'reset-password': KeyRound,
impersonate: LogIn,
status: Power,
delete: Trash2,
}
function hasReviewAction(row: PlatformTenantResource) {
return pendingReviewByTenantId.value.has(String(row.id))
&& access.can('platform:customer:review')
&& (access.can('platform:customer:approve') || access.can('platform:customer:reject'))
}
function customerSecondaryActions(row: PlatformTenantResource): CustomerRowAction[] {
return [
...(canResetPassword(row) ? ['reset-password' as const] : []),
...(canImpersonate(row) ? ['impersonate' as const] : []),
...(canChangeStatus(row) ? ['status' as const] : []),
...(canArchive() ? ['delete' as const] : []),
]
}
function customerActionLabel(row: PlatformTenantResource, action: CustomerRowAction) {
if (action === 'reset-password') return '重置密码'
if (action === 'impersonate') return '跳转登录'
if (action === 'status') return row.status === 'ACTIVE' ? '停用' : '启用'
return '删除'
}
function customerActionType(row: PlatformTenantResource, action: CustomerRowAction) {
if (action === 'delete' || (action === 'status' && row.status === 'ACTIVE')) return 'danger'
if (action === 'status') return 'success'
return 'primary'
}
function customerActionDisabled(action: CustomerRowAction) {
if (action === 'reset-password') return passwordSubmitting.value
if (action === 'impersonate') return impersonationSubmitting.value
if (action === 'delete') return archiveSubmitting.value
return false
}
function handleMoreAction(row: PlatformTenantResource, command: string) {
if (command === 'reset-password') openResetPassword(row)
if (command === 'impersonate') openImpersonation(row)
if (command === 'status') void toggleStatus(row)
if (command === 'delete') openArchive(row)
}
async function toggleStatus(row: PlatformTenantResource) {
const next = row.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE'; const verb = next === 'ACTIVE' ? '启用' : '停用'
try {
await ElMessageBox.confirm(`确认${verb}客户“${row.tenantName}”吗?`, `${verb}客户`, { type: 'warning', confirmButtonText: `确认${verb}`, cancelButtonText: '取消' })
rowActionId.value = String(row.id); await changePlatformTenantStatus(row.id, next)
ElMessage.success(`客户已${verb}`); await loadCustomers()
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(getApiErrorMessage(error, `客户${verb}失败`))
} finally { rowActionId.value = '' }
}
function randomPassword() {
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%^&*'
const values = new Uint32Array(10)
crypto.getRandomValues(values)
const body = Array.from(values, (value) => alphabet[value % alphabet.length]).join('')
passwordForm.newPassword = `A7${body}`
passwordForm.confirmPassword = passwordForm.newPassword
void nextTick(() => passwordFormRef.value?.clearValidate())
}
function openResetPassword(row: PlatformTenantResource) {
passwordTenant.value = row
passwordForm.newPassword = ''
passwordForm.confirmPassword = ''
passwordForm.forceChangePassword = true
passwordError.value = null
passwordDialogOpen.value = true
void nextTick(() => passwordFormRef.value?.clearValidate())
}
async function submitResetPassword() {
if (!passwordTenant.value || passwordSubmitting.value || !passwordFormRef.value) return
const valid = await passwordFormRef.value.validate().then(() => true).catch(() => false)
if (!valid) return
passwordSubmitting.value = true
passwordError.value = null
try {
await resetPlatformTenantOwnerPassword(passwordTenant.value.id, {
newPassword: passwordForm.newPassword,
forceChangePassword: passwordForm.forceChangePassword,
})
ElMessage.success('企业管理员密码已重置')
passwordDialogOpen.value = false
passwordTenant.value = null
passwordForm.newPassword = ''
passwordForm.confirmPassword = ''
} catch (error) {
passwordError.value = getApiErrorDetails(error, '客户管理员密码重置失败,请稍后重试')
} finally {
passwordSubmitting.value = false
}
}
function openImpersonation(row: PlatformTenantResource) {
impersonationTenant.value = row
impersonationForm.reason = '协助客户处理业务问题'
impersonationError.value = null
impersonationDialogOpen.value = true
void nextTick(() => impersonationFormRef.value?.clearValidate())
}
async function submitImpersonation() {
if (!impersonationTenant.value || impersonationSubmitting.value || !impersonationFormRef.value) return
const valid = await impersonationFormRef.value.validate().then(() => true).catch(() => false)
if (!valid) return
const clientUrl = getClientWebUrl()
if (!clientUrl) {
impersonationError.value = getApiErrorDetails(new Error('未配置客户工作台地址'), '暂未配置客户工作台地址,无法安全跳转登录')
return
}
let targetOrigin: string
try { targetOrigin = new URL(clientUrl, window.location.origin).origin } catch {
impersonationError.value = getApiErrorDetails(new Error('客户工作台地址无效'), '客户工作台地址配置无效,无法安全跳转登录')
return
}
const popup = window.open(clientUrl, '_blank')
if (!popup) {
impersonationError.value = getApiErrorDetails(new Error('浏览器拦截了新窗口'), '浏览器拦截了客户工作台窗口,请允许弹窗后重试')
return
}
impersonationSubmitting.value = true
impersonationError.value = null
let handoffPending = false
try {
const ticket = await createPlatformTenantImpersonation(impersonationTenant.value.id, { reason: impersonationForm.reason.trim() })
const onReady = (event: MessageEvent) => {
if (event.source !== popup || event.origin !== targetOrigin || event.data?.source !== 'chongchong-client' || event.data?.type !== 'impersonation-ready') return
popup.postMessage({ source: 'chongchong-admin', type: 'impersonation-ticket', code: ticket.code }, targetOrigin)
cleanup()
impersonationSubmitting.value = false
ElMessage.success('客户工作台已打开')
impersonationDialogOpen.value = false
}
const timeout = window.setTimeout(() => {
cleanup()
impersonationSubmitting.value = false
impersonationError.value = getApiErrorDetails(new Error('客户工作台未完成安全握手'), '客户工作台未完成安全握手,请关闭窗口后重试')
}, 10000)
const cleanup = () => {
window.clearTimeout(timeout)
window.removeEventListener('message', onReady)
if (impersonationCleanup === cleanup) impersonationCleanup = null
}
impersonationCleanup?.()
impersonationCleanup = cleanup
window.addEventListener('message', onReady)
handoffPending = true
} catch (error) {
popup.close()
impersonationError.value = getApiErrorDetails(error, '跳转客户工作台失败,请稍后重试')
} finally {
if (!handoffPending) impersonationSubmitting.value = false
}
}
function openArchive(row: PlatformTenantResource) {
archiveTenant.value = row
archiveConfirmCode.value = ''
archiveError.value = null
archiveDialogOpen.value = true
}
async function submitArchive() {
if (!archiveTenant.value || archiveSubmitting.value || archiveConfirmCode.value.trim() !== archiveTenant.value.tenantCode) return
archiveSubmitting.value = true
archiveError.value = null
try {
await archivePlatformTenant(archiveTenant.value.id)
ElMessage.success('客户已删除,历史数据已保留且账号已停用')
archiveDialogOpen.value = false
archiveTenant.value = null
archiveConfirmCode.value = ''
await loadCustomers()
} catch (error) {
archiveError.value = getApiErrorDetails(error, '客户删除失败,当前客户仍保留')
} finally {
archiveSubmitting.value = false
}
}
function openDecision(row: PlatformTenantResource, type: 'approve' | 'reject') {
if (reviewSubmitting.value) return
const review = pendingReviewByTenantId.value.get(String(row.id))
if (!review) {
ElMessage.warning('未找到该客户的待审核单,请刷新列表后重试')
return
}
selectedReview.value = review
decision.type = type
decision.comment = type === 'approve' ? '企业资料与必填资质完整、清晰,审核通过' : ''
decisionOpen.value = true
void nextTick(() => decisionFormRef.value?.clearValidate('comment'))
}
async function submitDecision() {
const review = selectedReview.value
if (!review || !decisionFormRef.value || reviewSubmitting.value) return
const valid = await decisionFormRef.value.validate().then(() => true).catch(() => false)
if (!valid) return
reviewSubmitting.value = true
try {
let approved: EnterpriseApprovalResponse | null = null
if (decision.type === 'approve') {
approved = await approveEnterpriseReview(review.id, decision.comment.trim())
} else {
await rejectEnterpriseReview(review.id, decision.comment.trim())
}
decisionOpen.value = false
const successMessage = decision.type === 'reject'
? '企业资料已驳回'
: approved?.administratorCreated
? '企业资料已审核通过,管理员账号已创建'
: '企业资料已审核通过,注册账号继续作为企业管理员'
ElMessage.success(successMessage)
if (approved?.administratorCreated) {
credentialSource.value = 'approval'
approvalResult.value = approved
approvalResultOpen.value = true
}
await loadCustomers()
} catch (error) {
ElMessage.error(getApiErrorMessage(error, '审核提交失败,请稍后重试'))
} finally {
reviewSubmitting.value = false
}
}
function closeApprovalResult() {
approvalResultOpen.value = false
approvalResult.value = null
}
async function exportCustomers() {
exportSubmitting.value = true
let task: Awaited<ReturnType<typeof createPlatformExportTask>>
try {
task = await createPlatformExportTask({
exportType: 'CUSTOMER_LIST',
format: 'XLSX',
taskName: '客户列表',
parameters: {
keyword: filters.value.keyword.trim() || undefined,
status: filters.value.status || undefined,
},
})
} catch (error) {
ElMessage.error(getApiErrorMessage(error, '客户导出任务创建失败'))
return
} finally {
exportSubmitting.value = false
}
const openDownloadCenter = await showExportTaskCreatedDialog({ taskName: task.taskName, taskNo: task.taskNo })
if (openDownloadCenter) await router.push({ name: 'download-center' })
}
async function openRouteEditor(editId: string) {
if (!editId || !access.can('platform:customer:edit')) return
const row = result.value.records.find((item) => String(item.id) === editId)
try {
await openEdit(row ?? await getPlatformTenant(editId))
} catch (error) {
ElMessage.error(getApiErrorMessage(error, '客户资料加载失败'))
}
if (route.query.edit === editId) void router.replace({ name: 'customers' })
}
watch([page, pageSize], () => { void loadCustomers() })
watch(
() => typeof route.query.edit === 'string' ? route.query.edit : '',
(editId) => { if (editId) void openRouteEditor(editId) },
{ immediate: true },
)
watch(editorOpen, (open) => {
if (!open) {
editorLoadSequence += 1
clearQualificationPreviews()
}
})
watch(
() => [access.can('platform:customer:approve'), access.can('platform:customer:reject')],
([canApprove, canReject]) => {
if ((decision.type === 'approve' && !canApprove) || (decision.type === 'reject' && !canReject)) decisionOpen.value = false
},
)
watch(() => decision.type, (type) => {
if (!decisionOpen.value) return
decision.comment = type === 'approve' ? '企业资料与必填资质完整、清晰,审核通过' : ''
void nextTick(() => decisionFormRef.value?.clearValidate('comment'))
})
onBeforeUnmount(() => {
clearQualificationPreviews()
impersonationCleanup?.()
})
onMounted(() => { void loadCustomers() })
</script>
<template>
<AdminPage as="div" class="customers-page">
<ListPageLayout aria-label="客户管理">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户筛选" :fields="queryFields" preference-key="customers-query" @search="applyFilters" /></template>
<template v-if="loadError" #state>
<LoadErrorState title="客户列表暂时无法加载" :message="loadError.message" :loading="loading" @retry="loadCustomers">
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
</template>
<section v-if="!loadError" class="surface-panel table-panel" :aria-busy="loading">
<div v-if="reviewLoadError" class="review-sync-error" role="alert"><div><ShieldX :size="17" /><span>{{ reviewLoadError.message }}</span></div><el-button link type="primary" @click="reloadPendingReviews">重新加载待审核客户</el-button></div>
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="customerColumns" preference-key="customers-table" :loading="loading" :total="result.total" aria-label="客户列表" row-key="id" empty-text="暂无符合筛选条件的客户" class="customer-list-table" :stripe="true" border>
<template #toolbar-actions><div class="customers-table-actions"><el-button v-if="access.can('platform:customer:export') && access.can('platform:download:list')" :loading="exportSubmitting" :disabled="loading || exportSubmitting" @click="exportCustomers"><Download :size="16" />导出Excel</el-button><el-button v-if="access.can('platform:customer:create')" type="primary" :disabled="loading" @click="openCreate"><Plus :size="16" />新增客户</el-button></div></template>
<template #actions="{ row }">
<div class="table-actions">
<el-button v-if="access.can('platform:customer:view')" class="customer-action-button customer-action-detail" size="small" @click="showDetail(row as PlatformTenantResource)"><Eye :size="14" />详情</el-button>
<el-button v-if="access.can('platform:customer:view') && access.can('platform:customer:edit')" class="customer-action-button customer-action-edit" size="small" @click="openEdit(row as PlatformTenantResource)"><Pencil :size="14" />编辑</el-button>
<el-button v-if="hasReviewAction(row as PlatformTenantResource)" class="customer-action-button customer-action-review" size="small" :disabled="reviewSubmitting" @click="openDecision(row as PlatformTenantResource, access.can('platform:customer:approve') ? 'approve' : 'reject')"><ShieldCheck :size="14" />审核</el-button>
<el-button v-for="action in customerSecondaryActions(row as PlatformTenantResource)" :key="action" :class="[`customer-action-button`, `customer-action-${action}`, { 'is-enable': action === 'status' && (row as PlatformTenantResource).status !== 'ACTIVE', 'is-disable': action === 'status' && (row as PlatformTenantResource).status === 'ACTIVE' }]" size="small" :disabled="customerActionDisabled(action)" @click="handleMoreAction(row as PlatformTenantResource, action)"><component :is="customerRowActionIcons[action]" :size="14" />{{ customerActionLabel(row as PlatformTenantResource, action) }}</el-button>
</div>
</template>
<template #company="{ row }"><strong class="customer-company-name">{{ (row as PlatformTenantResource).tenantName }}</strong></template>
<template #status="{ row }"><el-tag :type="statusMeta((row as PlatformTenantResource).status).type" effect="light">{{ statusMeta((row as PlatformTenantResource).status).label }}</el-tag></template>
<template #orderCount="{ row }">{{ access.can('platform:order:list') ? (orderCounts[String((row as PlatformTenantResource).id)] ?? '—') : '—' }}</template>
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 个客户</span></template>
</DataTable>
</section>
</ListPageLayout>
<AppDialog v-model="editorOpen" :title="editorTitle" :width="editorDialogWidth" class="customer-editor-dialog" :loading="editorLoading" :confirm-loading="saving" destroy-on-close>
<el-form ref="formRef" v-loading="editorLoading" :model="form" :rules="rules" label-position="top" class="drawer-form" :disabled="editorLoading || saving || submitting || uploadingQualifications">
<FormSection title="基础资料" description="维护工商登记和企业主体信息。"><div class="form-grid two-cols">
<el-form-item label="公司名称" prop="tenantName" required><el-input v-model="form.tenantName" placeholder="请输入工商登记公司名称" maxlength="128" /></el-form-item>
<el-form-item label="营业执照企业名称" prop="legalName" required><el-input v-model="form.legalName" placeholder="请输入营业执照上的企业名称" maxlength="128" /></el-form-item>
<el-form-item label="法定代表人" prop="legalRepresentative" required><el-input v-model="form.legalRepresentative" placeholder="请输入法定代表人姓名" maxlength="64" /></el-form-item>
<el-form-item label="企业类型" prop="enterpriseType"><el-input v-model="form.enterpriseType" placeholder="如:有限责任公司" maxlength="64" /></el-form-item>
<el-form-item label="成立日期" prop="establishedDate"><el-date-picker style="width: 100%;" v-model="form.establishedDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择成立日期" :disabled-date="(date: Date) => date.getTime() > Date.now()" /></el-form-item>
<el-form-item label="注册资本" prop="registeredCapital"><div class="capital-input"><el-input-number v-model="form.registeredCapital" :min="0" :precision="2" controls-position="right" /><el-select v-model="form.registeredCapitalCurrency" aria-label="注册资本币种"><el-option label="人民币 CNY" value="CNY" /><el-option label="美元 USD" value="USD" /><el-option label="港币 HKD" value="HKD" /></el-select></div></el-form-item>
</div><el-form-item label="注册地址" prop="registeredAddress"><el-input v-model="form.registeredAddress" placeholder="请输入省市区、街道及门牌信息" maxlength="500" /></el-form-item></FormSection>
<FormSection title="联系与商务" description="维护客户联系人、账期和内部跟进信息。"><div class="form-grid two-cols">
<el-form-item label="联系人" prop="contactName" required><el-input v-model="form.contactName" placeholder="请输入主要联系人姓名" maxlength="64" /></el-form-item>
<el-form-item label="联系电话(登录用户名)" prop="contactPhone" required><el-input v-model="form.contactPhone" :disabled="['ACTIVE', 'DISABLED'].includes(editorStatus || '')" placeholder="请输入手机号或固定电话" maxlength="32" /><small v-if="['ACTIVE', 'DISABLED'].includes(editorStatus || '')" class="field-hint">客户已开户,联系电话作为登录用户名不可修改</small></el-form-item>
<el-form-item label="联系邮箱" prop="contactEmail"><el-input v-model="form.contactEmail" placeholder="如:contact@example.com" maxlength="128" /></el-form-item>
<el-form-item label="联系微信" prop="contactWechat"><el-input v-model="form.contactWechat" placeholder="请输入联系人微信号" maxlength="64" /></el-form-item>
<!-- <el-form-item label="客户套餐" prop="servicePackage" required><el-select v-model="form.servicePackage"><el-option label="基础版" value="基础版" /><el-option label="专业版" value="专业版" /><el-option label="企业版" value="企业版" /></el-select></el-form-item> -->
<el-form-item label="销售人员" prop="salesOwnerName"><el-input v-model="form.salesOwnerName" placeholder="请输入负责销售人员" maxlength="64" /></el-form-item>
<el-form-item label="账期(天)" prop="paymentTermDays" required><el-input-number v-model="form.paymentTermDays" :min="0" :max="365" controls-position="right" /></el-form-item>
</div><el-form-item label="内部备注" prop="internalRemark"><el-input v-model="form.internalRemark" type="textarea" :rows="3" placeholder="请输入仅平台人员可见的客户备注" maxlength="1000" show-word-limit /></el-form-item></FormSection>
<FormSection class="qualification-section" title="企业认证必填业务项" description="营业执照、18位统一社会信用代码、法人身份证需共同完整保存;货代资质证书和银行开户许可证选填。"><div v-if="editorStatus" class="qualification-heading"><el-tag :type="statusMeta(editorStatus).type" effect="light">{{ statusMeta(editorStatus).label }}</el-tag></div>
<el-form-item label="统一社会信用代码" prop="unifiedSocialCreditCode" required class="qualification-credit-code"><el-input v-model="form.unifiedSocialCreditCode" :disabled="creditCodeEstablished" placeholder="请输入18位大写字母或数字" maxlength="18" @input="form.unifiedSocialCreditCode = String($event).toUpperCase()" /><small>{{ creditCodeEstablished ? '已登记的统一社会信用代码不可直接修改。' : '必须与营业执照一致,并随营业执照、法人身份证共同保存。' }}</small></el-form-item>
<div v-if="qualificationLoadError" class="qualification-load-error" role="alert">{{ qualificationLoadError }}<el-button v-if="editingId" link type="primary" @click="loadEditorQualifications(editingId)">重试</el-button></div>
<div class="qualification-table" role="table" aria-label="客户资质文件">
<div class="qualification-row qualification-header" role="row"><span role="columnheader">文件类型</span><span role="columnheader">上传文件</span><span role="columnheader">有效期</span></div>
<div v-for="draft in qualifications" :key="draft.type" class="qualification-row" role="row">
<div class="qualification-label" role="cell"><span v-if="draft.required" aria-hidden="true">*</span>{{ draft.label }}<small v-if="!draft.required" class="qualification-optional">选填</small><CheckCircle2 v-if="draft.uploaded" :size="16" aria-label="已上传" /></div>
<div class="qualification-file" role="cell">
<button v-if="draft.file || draft.uploaded" type="button" class="qualification-preview-trigger" :class="{ 'is-loading': draft.previewLoading }" :disabled="draft.previewLoading" :aria-label="`预览${draft.label}:${draft.file?.name || draft.uploadedName}`" @click="openQualificationPreview(draft)">
<img v-if="draft.previewUrl && draft.previewKind === 'image' && !draft.previewFailed" :src="draft.previewUrl" :alt="`${draft.label}预览`" @error="handleQualificationPreviewError(draft)" />
<span v-else class="qualification-preview-placeholder"><FileText :size="20" /><small>{{ draft.previewKind === 'pdf' ? 'PDF' : '文件' }}</small></span>
<span class="qualification-preview-action"><Eye :size="14" />{{ draft.previewLoading ? '读取中' : (draft.previewFailed ? '重试' : '查看') }}</span>
</button>
<div class="qualification-file-control"><label class="file-picker" :class="{ 'is-disabled': !canUploadQualifications || draft.uploading }"><FileUp :size="16" /><span>{{ draft.uploading ? '上传中' : (draft.file ? '已选择新文件' : (draft.uploaded ? '更换文件' : '选择文件')) }}</span><input type="file" accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png" :disabled="!canUploadQualifications || draft.uploading" @change="selectQualificationFile($event, draft)" /></label><span class="qualification-file-name" :title="draft.file?.name || draft.uploadedName">{{ draft.file?.name || draft.uploadedName || '未选择文件' }}</span><small v-if="draft.error" class="qualification-error" role="alert">{{ draft.error }}</small></div>
</div>
<div role="cell"><el-date-picker v-model="draft.validTo" type="date" value-format="YYYY-MM-DD" placeholder="请选择有效期" :disabled="!canUploadQualifications || draft.uploaded" /></div>
</div>
</div>
<p class="qualification-hint">支持 PDF、JPG、PNG,单个文件不超过 5MB;<span>*</span> 为提交审核必填项。</p>
</FormSection>
</el-form>
<template #footer><div class="customer-editor-footer"><span>保存资料时会一并上传已选资质;进入审核仍需点击“提交审核”并再次确认</span><div><el-button :disabled="editorLoading || saving || submitting || uploadingQualifications" @click="editorOpen = false">关闭</el-button><el-button :loading="saving" :disabled="editorLoading || submitting || uploadingQualifications" @click="saveCustomer"><Save :size="16" />保存资料和资质</el-button><el-button v-if="canSubmitReview" type="primary" :loading="submitting" :disabled="editorLoading || saving || uploadingQualifications" @click="submitReview">提交审核</el-button></div></div></template>
</AppDialog>
<AppDialog v-model="qualificationPreviewOpen" :title="qualificationPreviewName || '查看资质文件'" width="min(860px, calc(100vw - 28px))" class="qualification-preview-dialog" destroy-on-close :show-confirm="false" cancel-text="关闭预览">
<div class="qualification-preview">
<img v-if="qualificationPreviewUrl && qualificationPreviewKind === 'image'" :src="qualificationPreviewUrl" :alt="`${qualificationPreviewName}预览`" />
<iframe v-else-if="qualificationPreviewUrl && qualificationPreviewKind === 'pdf'" :src="qualificationPreviewUrl" :title="`${qualificationPreviewName} PDF 预览`" />
</div>
</AppDialog>
<el-dialog v-model="approvalResultOpen" :title="credentialSource === 'creation' ? '客户已创建,管理员账号已生成' : '审核通过,管理员账号已创建'" width="min(560px, calc(100vw - 28px))" class="approval-result-dialog" :close-on-click-modal="false" :close-on-press-escape="false" :show-close="false">
<div v-if="approvalResult" class="approval-result-content">
<div class="approval-result-warning" role="alert"><ShieldCheck :size="20" /><div><strong>请立即保存以下一次性凭证</strong><span>临时密码只在本次{{ credentialSource === 'creation' ? '创建' : '审核' }}响应中返回,关闭后无法再次查看。</span></div></div>
<dl class="approval-credentials">
<div><dt>登录账号</dt><dd><code>{{ approvalResult.administratorUsername || '未返回' }}</code><CopyButton v-if="approvalResult.administratorUsername" :value="approvalResult.administratorUsername" label="登录账号" size="small" /></dd></div>
<div><dt>临时密码</dt><dd><code>{{ approvalResult.temporaryPassword || '未返回' }}</code><CopyButton v-if="approvalResult.temporaryPassword" :value="approvalResult.temporaryPassword" label="临时密码" size="small" /></dd></div>
</dl>
</div>
<template #footer><el-button type="primary" @click="closeApprovalResult">我已保存,关闭</el-button></template>
</el-dialog>
<el-dialog v-model="decisionOpen" title="客户入驻审核" width="min(900px, calc(100vw - 28px))" class="review-decision-dialog" :close-on-click-modal="false">
<div v-if="selectedReview" class="review-dialog-grid">
<section class="review-dialog-section">
<h3>企业基本信息</h3>
<dl class="review-info-grid">
<div><dt>客户名称</dt><dd>{{ selectedReview.tenantName || '—' }}</dd></div>
<div><dt>客户编号</dt><dd>{{ selectedReview.tenantCode || '—' }}</dd></div>
<div><dt>联系人</dt><dd>{{ selectedReview.contactName || '—' }}</dd></div>
<div><dt>联系电话</dt><dd>{{ selectedReview.contactPhone || '—' }}</dd></div>
<div><dt>联系邮箱</dt><dd>{{ selectedReview.contactEmail || '—' }}</dd></div>
<div><dt>提交时间</dt><dd>{{ formatDate(selectedReview.submittedAt || selectedReview.createdAt) }}</dd></div>
</dl>
</section>
<section class="review-dialog-section">
<h3>资质文件</h3>
<div class="review-document-table" role="table" aria-label="待审核客户资质">
<div class="review-document-header" role="row"><span role="columnheader">文件类型</span><span role="columnheader">证书编号</span><span role="columnheader">状态</span></div>
<div class="review-document-row" role="row"><span role="cell">{{ selectedReview.qualificationType || '企业资质' }}</span><span role="cell">{{ selectedReview.certificateNo || '—' }}</span><span role="cell">待审核</span></div>
</div>
</section>
</div>
<el-radio-group v-if="access.can('platform:customer:approve') && access.can('platform:customer:reject')" v-model="decision.type" class="decision-type" aria-label="审核决定"><el-radio-button label="approve">通过</el-radio-button><el-radio-button label="reject">驳回</el-radio-button></el-radio-group>
<el-form ref="decisionFormRef" :model="decision" :rules="decisionRules" label-position="top">
<el-form-item prop="comment" :label="decision.type === 'approve' ? '审核意见' : '驳回原因'" required><el-input v-model="decision.comment" type="textarea" :rows="3" maxlength="300" show-word-limit :placeholder="decision.type === 'approve' ? '可填写审核意见' : '请明确说明需要企业修改的内容'" /></el-form-item>
</el-form>
<template #footer><div class="drawer-footer"><el-button :disabled="reviewSubmitting" @click="decisionOpen = false">返回</el-button><el-button v-if="access.can('platform:customer:reject') && decision.type === 'reject'" type="danger" :loading="reviewSubmitting" @click="submitDecision">确认驳回</el-button><el-button v-if="access.can('platform:customer:approve') && decision.type === 'approve'" type="primary" :loading="reviewSubmitting" @click="submitDecision">审核通过</el-button></div></template>
</el-dialog>
<el-dialog v-model="passwordDialogOpen" title="重置客户密码" width="min(520px, calc(100vw - 28px))" class="customer-password-dialog" :close-on-click-modal="false" :close-on-press-escape="!passwordSubmitting" :show-close="!passwordSubmitting">
<div v-if="passwordTenant" class="decision-summary"><div><strong>正在重置企业管理员登录密码</strong><span>{{ passwordTenant.tenantName }}</span></div><span>客户编号:{{ passwordTenant.tenantCode }}</span></div>
<el-alert v-if="passwordError" type="error" :closable="false" show-icon role="alert"><template #title><div class="api-error-alert-title"><span>{{ passwordError.message }}</span><ApiErrorMeta :details="passwordError" /></div></template></el-alert>
<el-form ref="passwordFormRef" :model="passwordForm" :rules="passwordRules" label-position="top">
<el-form-item label="新密码" prop="newPassword" required><div class="password-input-row"><el-input v-model="passwordForm.newPassword" type="password" show-password autocomplete="new-password" placeholder="请输入 8-20 位新密码" /><el-button @click="randomPassword">随机生成</el-button></div></el-form-item>
<el-form-item label="确认新密码" prop="confirmPassword" required><el-input v-model="passwordForm.confirmPassword" type="password" show-password autocomplete="new-password" placeholder="请再次输入新密码" /></el-form-item>
<el-checkbox v-model="passwordForm.forceChangePassword">客户下次登录时必须修改密码</el-checkbox>
<p class="dialog-hint">重置对象为企业 OWNER 管理员,原密码将立即失效。</p>
</el-form>
<template #footer><div class="drawer-footer"><el-button :disabled="passwordSubmitting" @click="passwordDialogOpen = false">取消</el-button><el-button type="primary" :loading="passwordSubmitting" @click="submitResetPassword">确认重置</el-button></div></template>
</el-dialog>
<el-dialog v-model="impersonationDialogOpen" title="跳转登录客户工作台" width="min(560px, calc(100vw - 28px))" :close-on-click-modal="false" :close-on-press-escape="!impersonationSubmitting" :show-close="!impersonationSubmitting">
<div v-if="impersonationTenant" class="decision-summary"><div><strong>{{ impersonationTenant.tenantName }}</strong><span>将以企业管理员身份打开客户工作台</span></div><span>客户编号:{{ impersonationTenant.tenantCode }}</span></div>
<el-alert type="warning" :closable="false" show-icon>本操作会生成一次性、短时有效的客户会话,并记录代登录原因;不会替换当前 Admin 会话。</el-alert>
<el-alert v-if="impersonationError" type="error" :closable="false" show-icon role="alert"><template #title><div class="api-error-alert-title"><span>{{ impersonationError.message }}</span><ApiErrorMeta :details="impersonationError" /></div></template></el-alert>
<el-form ref="impersonationFormRef" :model="impersonationForm" :rules="impersonationRules" label-position="top"><el-form-item label="代登录原因" prop="reason" required><el-input v-model="impersonationForm.reason" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请填写 5-500 字的业务原因" /></el-form-item></el-form>
<template #footer><div class="drawer-footer"><el-button :disabled="impersonationSubmitting" @click="impersonationDialogOpen = false">取消</el-button><el-button type="primary" :loading="impersonationSubmitting" @click="submitImpersonation">确认跳转</el-button></div></template>
</el-dialog>
<el-dialog v-model="archiveDialogOpen" title="删除客户" width="min(560px, calc(100vw - 28px))" :close-on-click-modal="false" :close-on-press-escape="!archiveSubmitting" :show-close="!archiveSubmitting">
<el-alert type="warning" :closable="false" show-icon>删除是逻辑归档:历史业务数据会保留,客户账号会被停用。是否允许归档由服务端最终判断。</el-alert>
<div v-if="archiveTenant" class="archive-confirm"><strong>{{ archiveTenant.tenantName }}</strong><span>请输入客户编号 {{ archiveTenant.tenantCode }} 以确认。</span><el-input v-model="archiveConfirmCode" :disabled="archiveSubmitting" :placeholder="archiveTenant.tenantCode" autocomplete="off" /></div>
<el-alert v-if="archiveError" type="error" :closable="false" show-icon role="alert"><template #title><div class="api-error-alert-title"><span>{{ archiveError.message }}</span><ApiErrorMeta :details="archiveError" /></div></template></el-alert>
<template #footer><div class="drawer-footer"><el-button :disabled="archiveSubmitting" @click="archiveDialogOpen = false">取消</el-button><el-button type="danger" :loading="archiveSubmitting" :disabled="!archiveTenant || archiveConfirmCode.trim() !== archiveTenant.tenantCode" @click="submitArchive">确认删除</el-button></div></template>
</el-dialog>
</AdminPage>
</template>
<style scoped>
.customers-table-heading { display:flex; min-width:0; align-items:baseline; gap:10px; } .customers-table-heading h2 { margin:0; color:var(--cc-text); font-size:16px; } .customers-table-heading span { color:var(--cc-muted); font-size:12px; white-space:nowrap; }
.customers-table-actions { display:flex; flex-wrap:wrap; gap:8px; } .customers-table-actions :deep(.el-button + .el-button) { margin-left:0; }
.table-actions { display:flex; flex-wrap:wrap; align-items:center; gap:2px 8px; } .table-actions :deep(.el-button) { margin-left:0; }
:global(.customer-action-menu .el-dropdown-menu__item) { min-height:40px; gap:8px; }
:global(.customer-action-menu .customer-action-menu__danger) { color:var(--el-color-danger); }
.review-sync-error { display:flex; min-width:0; align-items:center; justify-content:space-between; gap:12px; padding:10px 18px; border-bottom:1px solid #fecdca; background:#fffafa; color:#b42318; font-size:12px; } .review-sync-error > div { display:flex; min-width:0; align-items:center; gap:8px; } .review-sync-error span { overflow-wrap:anywhere; }
.customer-company-name { display:block; overflow:hidden; color:#334155; font-weight:500; text-overflow:ellipsis; white-space:nowrap; }
.customers-table-actions :deep(.el-button--primary) { border:0; background:linear-gradient(135deg,#667eea 0%,#764ba2 50%,#f093fb 100%); box-shadow:0 4px 12px rgba(102,126,234,.3); }
.customers-table-actions :deep(.el-button--default) { border-color:rgba(102,126,234,.22); }
:deep(.customer-list-table .el-table__header-wrapper th) { background:#f7f9fc; }
:deep(.customer-list-table .el-table) { --el-table-border-color:#e5eaf1; --el-table-row-hover-bg-color:rgba(102,126,234,.06); border:1px solid #e5eaf1; border-radius:0; overflow:hidden; }
:deep(.customer-list-table .el-table__inner-wrapper::before) { background:#e5eaf1; }
:deep(.customer-list-table .el-table__body tr.el-table__row--striped > td) { background:#fbfcff; }
:deep(.customer-list-table .el-table__body tr:hover > td) { background:rgba(102,126,234,.06) !important; }
:deep(.customer-list-table .el-table__body td), :deep(.customer-list-table .el-table__header th) { border-right:1px solid #edf1f5; }
:deep(.customer-list-table .el-table__body td:last-child), :deep(.customer-list-table .el-table__header th:last-child) { border-right:0; }
:deep(.customer-list-table .customer-code-column .cell) { white-space:nowrap; }
:deep(.customer-list-table .customer-action-button) { min-height:28px; margin:0; padding:5px 9px; border-radius:8px; font-size:12px; font-weight:650; box-shadow:0 2px 6px rgba(15,23,42,.06); }
:deep(.customer-list-table .customer-action-detail) { border-color:#bfdbfe; background:#eff6ff; color:#2563eb; }
:deep(.customer-list-table .customer-action-edit) { border-color:#c7d2fe; background:#eef2ff; color:#667eea; }
:deep(.customer-list-table .customer-action-review) { border-color:#fed7aa; background:#fff7ed; color:#ea580c; }
:deep(.customer-list-table .customer-action-reset-password) { border-color:#ddd6fe; background:#f5f3ff; color:#7c3aed; }
:deep(.customer-list-table .customer-action-impersonate) { border-color:#bae6fd; background:#f0f9ff; color:#0369a1; }
:deep(.customer-list-table .customer-action-status.is-enable) { border-color:#bbf7d0; background:#f0fdf4; color:#16a34a; }
:deep(.customer-list-table .customer-action-status.is-disable), :deep(.customer-list-table .customer-action-delete) { border-color:#fecaca; background:#fef2f2; color:#dc2626; }
:deep(.customer-list-table .customer-action-button:hover) { filter:brightness(.97); transform:none; box-shadow:0 2px 6px rgba(15,23,42,.06); }
:deep(.customer-list-table .el-tag) { border:0; border-radius:12px; font-weight:650; }
:deep(.customer-list-table .el-tag--success) { background:linear-gradient(135deg,rgba(34,197,94,.15),rgba(22,163,74,.1)); color:#16a34a; }
:deep(.customer-list-table .el-tag--warning) { background:linear-gradient(135deg,rgba(245,158,11,.15),rgba(234,88,12,.1)); color:#ea580c; }
:deep(.customer-list-table .el-tag--danger) { background:linear-gradient(135deg,rgba(239,68,68,.15),rgba(220,38,38,.1)); color:#dc2626; }
:deep(.customer-list-table .el-tag--info) { background:linear-gradient(135deg,rgba(102,126,234,.15),rgba(118,75,162,.1)); color:#667eea; }
.capital-input { display:grid; grid-template-columns:minmax(0,1fr) 130px; width:100%; gap:8px; } .capital-input :deep(.el-input-number),.capital-input :deep(.el-select) { width:100%; }
.qualification-heading { display:flex; justify-content:flex-end; margin-bottom:12px; } .qualification-hint { margin:12px 0 0; color:var(--cc-muted); font-size:12px; line-height:1.6; } .qualification-hint span,.qualification-label > span { color:var(--el-color-danger); }
.qualification-credit-code { max-width:420px; margin-top:16px; } .qualification-credit-code small,.field-hint { display:block; margin-top:6px; color:var(--cc-muted); font-size:11px; line-height:1.5; }
.qualification-table { overflow:hidden; margin-top:14px; border:1px solid var(--cc-border); border-radius:8px; } .qualification-row { display:grid; grid-template-columns:180px minmax(300px,1fr) 180px; min-width:0; border-top:1px solid var(--cc-border); } .qualification-row:first-child { border-top:0; } .qualification-row > * { display:flex; min-width:0; min-height:68px; align-items:center; padding:12px 14px; } .qualification-header { min-height:44px; background:#f7f8fc; color:var(--cc-muted); font-size:12px; font-weight:600; } .qualification-header > * { min-height:44px; }
.qualification-label { gap:5px; font-weight:600; } .qualification-label svg { margin-left:auto; color:var(--el-color-success); }
.qualification-optional { padding:2px 5px; border-radius:4px; background:var(--el-fill-color-light); color:var(--cc-muted); font-size:10px; font-weight:500; line-height:1.4; }
.qualification-file { gap:12px; } .qualification-file-control { display:flex; min-width:0; flex:1; flex-wrap:wrap; align-items:center; gap:8px; } .file-picker { position:relative; display:inline-flex; min-height:36px; flex:0 0 auto; align-items:center; gap:7px; overflow:hidden; padding:0 12px; border:1px solid var(--cc-border); border-radius:6px; background:#fff; color:var(--cc-text); cursor:pointer; } .file-picker:focus-within { outline:2px solid color-mix(in srgb,var(--cc-primary) 35%,transparent); outline-offset:2px; } .file-picker.is-disabled { cursor:not-allowed; opacity:.6; } .file-picker input { position:absolute; inset:0; width:100%; height:100%; opacity:0; cursor:inherit; } .qualification-file-name { min-width:0; max-width:180px; overflow:hidden; color:var(--cc-muted); font-size:12px; text-overflow:ellipsis; white-space:nowrap; } .qualification-error { width:100%; color:var(--el-color-danger); line-height:1.4; }
.qualification-preview-trigger { position:relative; display:grid; width:68px; height:68px; flex:0 0 68px; overflow:hidden; place-items:center; padding:0; border:1px solid var(--cc-border); border-radius:6px; background:#f8fafc; color:var(--cc-muted); cursor:pointer; }
.qualification-preview-trigger:hover { border-color:var(--cc-primary); color:var(--cc-primary); }
.qualification-preview-trigger:focus-visible { outline:2px solid color-mix(in srgb,var(--cc-primary) 42%,transparent); outline-offset:2px; }
.qualification-preview-trigger:disabled { cursor:wait; opacity:.72; }
.qualification-preview-trigger img { width:100%; height:100%; object-fit:contain; background:#fff; }
.qualification-preview-placeholder { display:grid; place-items:center; gap:2px; }
.qualification-preview-placeholder small { font-size:10px; font-weight:700; }
.qualification-preview-action { position:absolute; inset:auto 0 0; display:flex; min-height:22px; align-items:center; justify-content:center; gap:3px; background:rgba(23,32,51,.78); color:#fff; font-size:10px; }
.qualification-preview { display:grid; max-height:min(70vh,760px); min-height:240px; place-items:center; overflow:auto; border:1px solid var(--cc-border); border-radius:6px; background:#f8fafc; }
.qualification-preview img { display:block; max-width:100%; height:auto; }
.qualification-preview iframe { width:100%; min-height:min(68vh,720px); border:0; background:#fff; }
.qualification-load-error { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-top:12px; padding:10px 12px; border:1px solid #fecdca; border-radius:6px; background:#fffafa; color:#b42318; font-size:12px; }
:global(.customer-editor-dialog.el-dialog) { display:flex; max-height:min(90vh,900px); flex-direction:column; }
:global(.customer-editor-dialog .el-dialog__body) { min-height:0; overflow-y:auto; overscroll-behavior:contain; }
:global(.customer-editor-dialog .el-dialog__footer) { flex:0 0 auto; }
.customer-editor-footer { display:flex; align-items:center; justify-content:space-between; gap:16px; } .customer-editor-footer > span { color:var(--cc-muted); font-size:12px; text-align:left; } .customer-editor-footer > div { display:flex; flex:0 0 auto; gap:8px; } .customer-editor-footer :deep(.el-button + .el-button) { margin-left:0; }
.decision-summary { display:flex; min-width:0; align-items:center; justify-content:space-between; gap:12px; margin-bottom:18px; padding:12px 14px; border-radius:8px; background:#f8fafc; } .decision-summary > * { min-width:0; overflow-wrap:anywhere; } .decision-summary strong { color:var(--cc-text); font-size:13px; } .decision-summary span { color:var(--cc-muted); font-size:11px; }
:global(.review-decision-dialog.el-dialog) { display:flex; max-height:min(90vh,820px); flex-direction:column; }
:global(.review-decision-dialog .el-dialog__body) { min-height:0; overflow-y:auto; padding:20px 28px; }
:global(.review-decision-dialog .el-dialog__footer) { flex:0 0 auto; }
.review-dialog-grid { display:grid; grid-template-columns:1fr 1fr; gap:24px; margin-bottom:18px; }
.review-dialog-section { min-width:0; }
.review-dialog-section h3 { margin:0 0 12px; padding-bottom:8px; border-bottom:2px solid #667eea; color:#1a1a2e; font-size:14px; font-weight:600; }
.review-info-grid { display:grid; gap:7px; margin:0; }
.review-info-grid > div { display:grid; grid-template-columns:110px minmax(0,1fr); gap:12px; }
.review-info-grid dt { color:#64748b; font-size:12px; }
.review-info-grid dd { min-width:0; margin:0; overflow-wrap:anywhere; color:#1e293b; font-size:13px; }
.review-document-table { overflow:hidden; border:1px solid #e4e4e7; border-radius:6px; }
.review-document-header,.review-document-row { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr) 76px; }
.review-document-header { background:rgba(102,126,234,.08); color:#52525b; font-size:12px; font-weight:600; }
.review-document-header > span,.review-document-row > span { min-width:0; padding:9px 10px; overflow-wrap:anywhere; border-right:1px solid #e4e4e7; }
.review-document-header > span:last-child,.review-document-row > span:last-child { border-right:0; }
.review-document-row { border-top:1px solid #e4e4e7; color:#334155; font-size:13px; }
.decision-summary > div > span { display:block; margin-top:4px; }.decision-type { margin:0 0 16px; }.password-input-row { display:grid; grid-template-columns:minmax(0,1fr) auto; width:100%; gap:8px; }.dialog-hint { margin:12px 0 0; color:var(--cc-muted); font-size:12px; line-height:1.6; }.archive-confirm { display:grid; gap:8px; margin:16px 0; }.archive-confirm strong { color:var(--cc-text); }.archive-confirm span { color:var(--cc-muted); font-size:12px; }
.approval-result-warning { display:flex; align-items:flex-start; gap:10px; padding:12px 14px; border:1px solid #fecdca; border-radius:8px; background:#fffafa; color:#b42318; } .approval-result-warning strong,.approval-result-warning span { display:block; } .approval-result-warning strong { font-size:14px; } .approval-result-warning span { margin-top:4px; font-size:12px; line-height:1.5; } .approval-credentials { margin:18px 0 0; border:1px solid var(--cc-border); border-radius:8px; } .approval-credentials > div { display:grid; grid-template-columns:90px minmax(0,1fr); gap:12px; padding:12px 14px; } .approval-credentials > div + div { border-top:1px solid var(--cc-border); } .approval-credentials dt { color:var(--cc-muted); font-size:12px; } .approval-credentials dd { display:flex; min-width:0; align-items:center; gap:10px; margin:0; } .approval-credentials code { min-width:0; flex:1; overflow-wrap:anywhere; color:var(--cc-text); font-size:14px; font-weight:700; }
@media (max-width:720px) { .customer-editor-footer,.review-sync-error { align-items:stretch; flex-direction:column; } .customers-table-actions { display:grid; grid-template-columns:1fr 1fr; width:100%; } .customers-table-actions :deep(.el-button) { min-height:40px; margin:0; } .qualification-table { overflow-x:auto; } .qualification-row { min-width:640px; } .customer-editor-footer > div { display:grid; grid-template-columns:repeat(auto-fit,minmax(110px,1fr)); width:100%; } .customer-editor-footer :deep(.el-button) { min-height:40px; margin:0; } .decision-summary { align-items:flex-start; flex-direction:column; }.review-dialog-grid { grid-template-columns:1fr; gap:18px; }.review-info-grid > div { grid-template-columns:92px minmax(0,1fr); }.password-input-row { grid-template-columns:1fr; }.table-actions :deep(.el-button) { min-height:40px; } }
@media (max-width:360px) { .customers-table-actions,.capital-input { grid-template-columns:1fr; } }
</style>