2026-08-01 12:17:33 +08:00
< script setup lang = "ts" >
2026-08-10 17:42:17 +08:00
import { computed , nextTick , onBeforeUnmount , onMounted , reactive , ref , watch , type Component } from 'vue'
2026-08-04 13:32:08 +08:00
import { useRoute , useRouter } from 'vue-router'
2026-08-17 10:54:11 +08:00
import { Building2 , CheckCircle2 , CircleCheck , Download , Eye , FileCheck2 , FileText , FileUp , KeyRound , LogIn , MoreHorizontal , Pencil , Plus , Power , Save , ShieldCheck , ShieldX , Trash2 } from '@lucide/vue'
2026-08-03 14:58:17 +08:00
import { ElMessage , ElMessageBox , type FormInstance , type FormRules } from 'element-plus'
2026-08-03 18:49:18 +08:00
import { getApiErrorDetails , getApiErrorMessage , type ApiErrorDetails } from '@/shared/api/http'
import { createPlatformExportTask } from '@/modules/downloads'
2026-08-01 12:17:33 +08:00
import {
2026-08-03 20:50:18 +08:00
approveEnterpriseReview ,
2026-08-08 10:14:07 +08:00
archivePlatformTenant ,
2026-08-03 14:58:17 +08:00
changePlatformTenantStatus ,
createPlatformTenant ,
2026-08-08 10:14:07 +08:00
createPlatformTenantImpersonation ,
2026-08-03 17:44:54 +08:00
downloadTenantQualification ,
2026-08-03 14:58:17 +08:00
getPlatformTenant ,
2026-08-03 20:50:18 +08:00
listEnterpriseReviews ,
2026-08-03 14:58:17 +08:00
listPlatformTenantOrderCounts ,
2026-08-01 12:17:33 +08:00
listPlatformTenants ,
2026-08-03 14:58:17 +08:00
listTenantQualifications ,
2026-08-03 17:44:54 +08:00
qualificationFileName ,
2026-08-03 20:50:18 +08:00
rejectEnterpriseReview ,
2026-08-08 10:14:07 +08:00
resetPlatformTenantOwnerPassword ,
2026-08-03 14:58:17 +08:00
submitPlatformTenantReview ,
updatePlatformTenant ,
uploadPlatformTenantQualification ,
2026-08-05 09:11:48 +08:00
uploadPlatformTenantRequiredQualificationPackage ,
2026-08-03 14:58:17 +08:00
type CreatePlatformTenantRequest ,
2026-08-03 20:50:18 +08:00
type EnterpriseApprovalResponse ,
type EnterpriseReview ,
2026-08-03 14:58:17 +08:00
type PlatformQualificationType ,
type PlatformTenantQualification ,
2026-08-01 12:17:33 +08:00
type PlatformTenantResource ,
type PlatformTenantStatus ,
2026-08-03 18:49:18 +08:00
} from '@/modules/iam'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
2026-08-18 09:09:34 +08:00
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
2026-08-03 20:50:18 +08:00
import CopyButton from '@/shared/components/CopyButton.vue'
2026-08-03 18:49:18 +08:00
import DataTable , { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar , { type QueryField , type QueryModel } from '@/shared/components/QueryToolbar.vue'
2026-08-10 16:24:54 +08:00
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'
2026-08-04 11:22:04 +08:00
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
2026-08-03 18:49:18 +08:00
import { useAccessPreviewStore } from '@/core/access/accessPreview'
2026-08-08 11:33:09 +08:00
import { getClientWebUrl } from '@/core/config/runtimeConfig'
2026-08-01 12:17:33 +08:00
2026-08-03 14:58:17 +08:00
type CustomerFilters = QueryModel & { keyword : string ; status : PlatformTenantStatus | '' }
type QualificationDraft = {
type : PlatformQualificationType
label : string
required : boolean
file : File | null
validTo : string
uploaded : boolean
uploadedName : string
2026-08-03 17:44:54 +08:00
documentUrl : string
previewUrl : string
previewKind : 'image' | 'pdf' | ''
previewLoading : boolean
previewFailed : boolean
2026-08-03 14:58:17 +08:00
uploading : boolean
error : string
}
const access = useAccessPreviewStore ()
2026-08-04 13:32:08 +08:00
const route = useRoute ()
2026-08-04 11:22:04 +08:00
const router = useRouter ()
2026-08-01 12:17:33 +08:00
const page = ref ( 1 )
const pageSize = ref ( 10 )
const loading = ref ( false )
2026-08-03 18:15:41 +08:00
const exportSubmitting = ref ( false )
2026-08-01 12:17:33 +08:00
const loadError = ref < ApiErrorDetails | null >( null )
const result = ref ({ records : [] as PlatformTenantResource [], total : 0 , pages : 0 })
2026-08-03 14:58:17 +08:00
const orderCounts = ref < Record < string , number > > ({})
const rowActionId = ref ( '' )
2026-08-03 20:50:18 +08:00
const pendingReviews = ref < EnterpriseReview [] >([])
const reviewLoadError = ref < ApiErrorDetails | null >( null )
const selectedReview = ref < EnterpriseReview | null >( null )
const decisionOpen = ref ( false )
const reviewSubmitting = ref ( false )
2026-08-13 11:57:44 +08:00
const reviewQualifications = ref < PlatformTenantQualification [] >([])
const reviewQualificationsLoading = ref ( false )
const reviewQualificationsError = ref < ApiErrorDetails | null >( null )
const reviewPreviewOpen = ref ( false )
const reviewPreviewName = ref ( '' )
const reviewPreviewUrl = ref ( '' )
const reviewPreviewKind = ref < 'image' | 'pdf' > ( 'image' )
const reviewDocumentLoading = ref ( '' )
2026-08-03 20:50:18 +08:00
const approvalResultOpen = ref ( false )
const approvalResult = ref < EnterpriseApprovalResponse | null >( null )
2026-08-08 11:05:45 +08:00
const credentialSource = ref < 'creation' | 'approval' > ( 'approval' )
2026-08-03 20:50:18 +08:00
const decision = reactive ({ type : 'approve' as 'approve' | 'reject' , comment : '' })
const decisionFormRef = ref < FormInstance >()
2026-08-08 10:14:07 +08:00
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
2026-08-03 14:58:17 +08:00
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 ( '' )
2026-08-03 17:44:54 +08:00
const qualificationPreviewOpen = ref ( false )
const qualificationPreviewName = ref ( '' )
const qualificationPreviewUrl = ref ( '' )
const qualificationPreviewKind = ref < 'image' | 'pdf' | '' > ( '' )
2026-08-01 12:17:33 +08:00
const filters = ref < CustomerFilters >({ keyword : '' , status : '' })
const queryFields : QueryField [] = [
2026-08-14 12:50:42 +08:00
{ key : 'keyword' , type : 'input' , label : '客户名称' , placeholder : '请输入客户名称' },
2026-08-06 19:03:13 +08:00
{ key : 'status' , type : 'select' , label : '状态' , placeholder : '全部状态' , options : [
2026-08-03 14:58:17 +08:00
{ label : '待完善' , value : 'PROFILE_REQUIRED' }, { label : '待审核' , value : 'PENDING' },
{ label : '启用' , value : 'ACTIVE' }, { label : '已驳回' , value : 'REJECTED' },
2026-08-14 16:06:30 +08:00
{ label : '已停用' , value : 'DISABLED' }, { label : '已归档' , value : 'ARCHIVED' },
2026-08-03 14:58:17 +08:00
] },
2026-08-01 12:17:33 +08:00
]
2026-08-05 09:11:48 +08:00
type CustomerForm = CreatePlatformTenantRequest & { unifiedSocialCreditCode : string }
const emptyForm = () : CustomerForm => ({
2026-08-04 09:05:19 +08:00
tenantName : '' , legalName : '' , legalRepresentative : '' , unifiedSocialCreditCode : '' , contactName : '' , contactPhone : '' ,
2026-08-04 10:31:59 +08:00
contactEmail : '' , contactWechat : '' , servicePackage : '基础版' , salesOwnerName : '' , paymentTermDays : 30 ,
enterpriseType : '' , establishedDate : null , registeredCapital : 100000 , registeredCapitalCurrency : 'CNY' ,
2026-08-03 14:58:17 +08:00
registeredAddress : '' , internalRemark : '' ,
})
const form = ref ( emptyForm ())
const rules : FormRules = {
tenantName : [{ required : true , message : '请输入公司名称' , trigger : 'blur' }],
legalName : [{ required : true , message : '请输入营业执照上的企业名称' , trigger : 'blur' }],
2026-08-04 09:05:19 +08:00
legalRepresentative : [{ required : true , message : '请输入法定代表人姓名' , trigger : 'blur' }],
2026-09-02 10:04:24 +08:00
unifiedSocialCreditCode : [
{ required : true , message : '请输入统一社会信用代码' , trigger : 'blur' },
{ pattern : /^[0-9A-Z]{18}$/ , message : '请输入 18 位大写字母或数字' , trigger : 'blur' },
],
2026-08-03 14:58:17 +08:00
contactName : [{ required : true , message : '请输入联系人姓名' , trigger : 'blur' }],
2026-08-07 14:47:11 +08:00
contactPhone : [
2026-09-02 10:04:24 +08:00
{ required : true , message : '请输入公司联系电话' , trigger : 'blur' },
{ pattern : /^[0-9-]{4,32}$/ , message : '请输入 4-32 位数字或连字符' , trigger : 'blur' },
2026-08-07 14:47:11 +08:00
],
2026-08-03 14:58:17 +08:00
contactEmail : [{ type : 'email' , message : '请输入有效邮箱地址' , trigger : 'blur' }],
servicePackage : [{ required : true , message : '请选择客户套餐' , trigger : 'change' }],
paymentTermDays : [{ required : true , message : '请输入账期天数' , trigger : 'change' }],
2026-08-01 12:17:33 +08:00
}
2026-08-03 20:50:18 +08:00
const decisionRules : FormRules = {
comment : [{
validator : ( _rule , value , callback ) => {
if ( typeof value === 'string' && value . trim ()) callback ()
else callback ( new Error ( '请填写审核说明或驳回原因' ))
},
trigger : [ 'blur' , 'change' ],
}],
}
2026-08-01 12:17:33 +08:00
2026-08-03 14:58:17 +08:00
function newQualificationDrafts () : QualificationDraft [] {
const definitions : Array < Pick < QualificationDraft , ' type ' | ' label ' | ' required ' > > = [
{ type : 'BUSINESS_LICENSE' , label : '营业执照' , required : true },
2026-09-02 10:04:24 +08:00
{ type : 'LEGAL_PERSON_ID' , label : '法人身份证' , required : false },
2026-08-04 14:15:32 +08:00
{ type : 'FREIGHT_FORWARDER_CERTIFICATE' , label : '货代资质证书' , required : false },
{ type : 'BANK_ACCOUNT_LICENSE' , label : '银行开户许可证' , required : false },
2026-08-03 14:58:17 +08:00
]
2026-08-03 17:44:54 +08:00
return definitions . map (( item ) => ({
... item ,
file : null ,
validTo : '' ,
uploaded : false ,
uploadedName : '' ,
documentUrl : '' ,
previewUrl : '' ,
previewKind : '' ,
previewLoading : false ,
previewFailed : false ,
uploading : false ,
error : '' ,
}))
2026-08-03 14:58:17 +08:00
}
const qualifications = ref < QualificationDraft [] >( newQualificationDrafts ())
2026-08-04 21:01:39 +08:00
const creditCodeEstablished = ref ( false )
const unifiedSocialCreditCodePattern = /^[0-9A-Z]{18}$/
2026-08-03 14:58:17 +08:00
2026-08-01 12:17:33 +08:00
const customerColumns : DataTableColumn [] = [
2026-08-15 12:12:38 +08:00
{ key : 'actions' , label : '操作' , width : 330 , slotName : 'actions' },
2026-08-12 14:57:02 +08:00
{ 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 ) },
2026-08-06 19:03:13 +08:00
{ 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 ) },
2026-08-12 14:57:02 +08:00
{ 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 },
2026-08-01 12:17:33 +08:00
{ key : 'status' , label : '状态' , width : 110 , slotName : 'status' },
2026-08-12 14:57:02 +08:00
{ key : 'orderCount' , label : '运单数' , width : 100 , slotName : 'orderCount' },
2026-08-06 19:03:13 +08:00
{ key : 'createdAt' , prop : 'createdAt' , label : '入驻时间' , minWidth : 165 , formatter : ( row : PlatformTenantResource ) => formatDate ( row . createdAt ) },
2026-08-01 12:17:33 +08:00
]
2026-08-03 14:58:17 +08:00
let loadSequence = 0
2026-08-03 20:50:18 +08:00
let reviewLoadSequence = 0
2026-08-04 09:05:19 +08:00
let editorLoadSequence = 0
2026-08-03 14:58:17 +08:00
const editorTitle = computed (() => editingId . value ? '编辑客户' : '新增客户' )
2026-08-12 16:31:46 +08:00
const editorDialogWidth = computed (() => editingId . value
? 'min(640px, calc(100vw - 28px))'
: 'min(680px, calc(100vw - 28px))' )
2026-08-03 14:58:17 +08:00
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 ))
2026-08-04 21:01:39 +08:00
const selectedBusinessLicense = computed (() => qualifications . value . find (( item ) => item . type === 'BUSINESS_LICENSE' ) ? . file )
2026-08-05 09:11:48 +08:00
const selectedLegalPersonId = computed (() => qualifications . value . find (( item ) => item . type === 'LEGAL_PERSON_ID' ) ? . file )
2026-09-02 10:04:24 +08:00
const businessLicenseReadyForSave = computed (() => Boolean ( selectedBusinessLicense . value
|| qualifications . value . find (( item ) => item . type === 'BUSINESS_LICENSE' ) ? . uploaded ))
const creditCodeReadyForSave = computed (() => creditCodeEstablished . value
|| unifiedSocialCreditCodePattern . test ( form . value . unifiedSocialCreditCode || '' ))
const requiredQualificationPackageComplete = computed (() => businessLicenseReadyForSave . value && creditCodeReadyForSave . value )
2026-08-04 21:01:39 +08:00
const missingQualificationPackageItems = computed (() => {
const businessLicense = qualifications . value . find (( item ) => item . type === 'BUSINESS_LICENSE' )
const missing : string [] = []
2026-09-02 10:04:24 +08:00
if ( ! businessLicense ? . file && ! businessLicense ? . uploaded ) missing . push ( '营业执照' )
if ( ! creditCodeReadyForSave . value ) missing . push ( '18位统一社会信用代码(需随营业执照保存)' )
2026-08-04 21:01:39 +08:00
return missing
})
2026-08-03 14:58:17 +08:00
const hasUnsavedChanges = computed (() => Boolean ( editingId . value ) && JSON . stringify ( form . value ) !== savedFormSnapshot . value )
2026-08-03 20:50:18 +08:00
const pendingReviewByTenantId = computed (() => new Map ( pendingReviews . value . map (( review ) => [ String ( review . tenantId ), review ])))
2026-08-08 10:14:07 +08:00
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' },
],
}
2026-08-03 14:58:17 +08:00
function displayText ( value ?: string | null ) { return value ? . trim () || '—' }
function formatDate ( value ?: string | null ) { return value ? value . replace ( 'T' , ' ' ). slice ( 0 , 16 ) : '—' }
2026-08-13 11:57:44 +08:00
function qualificationLabel ( type ?: string | null ) {
return ({ BUSINESS_LICENSE : '营业执照' , LEGAL_PERSON_ID : '法人身份证' , FREIGHT_FORWARDER_CERTIFICATE : '货代资质证书' , BANK_ACCOUNT_LICENSE : '银行开户许可证' } as Record < string , string >)[ type || '' ] || type || '企业资质'
}
function qualificationName ( item : PlatformTenantQualification ) {
try { return qualificationFileName ( item . documentUrl ) } catch { return qualificationLabel ( item . qualificationType ) }
}
2026-08-01 12:17:33 +08:00
function statusMeta ( status : PlatformTenantStatus ) {
2026-08-03 14:58:17 +08:00
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 }
2026-08-14 16:06:30 +08:00
if ( status === 'ARCHIVED' ) return { label : '已归档' , type : 'info' as const }
2026-08-03 14:58:17 +08:00
return { label : '已停用' , type : 'info' as const }
2026-08-01 12:17:33 +08:00
}
2026-08-04 09:05:19 +08:00
2026-08-03 20:50:18 +08:00
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 ,
2026-08-12 12:19:02 +08:00
tenantCode : review . tenantCode ? . trim () || '待生成' ,
tenantName : review . tenantName ? . trim () || '待审核企业' ,
2026-08-03 20:50:18 +08:00
status : 'PENDING' ,
legalName : null ,
2026-08-04 09:05:19 +08:00
legalRepresentative : null ,
2026-08-03 20:50:18 +08:00
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 )
))
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 ),
}
}
2026-08-01 12:17:33 +08:00
async function loadCustomers () {
const sequence = ++ loadSequence
2026-08-03 14:58:17 +08:00
loading . value = true ; loadError . value = null
2026-08-01 12:17:33 +08:00
try {
2026-08-03 20:50:18 +08:00
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
2026-08-01 12:17:33 +08:00
if ( sequence !== loadSequence ) return
2026-08-03 14:58:17 +08:00
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 { /* 主档可继续使用,运单服务失败时不显示模拟数量。 */ }
2026-08-01 12:17:33 +08:00
}
} catch ( error ) {
2026-08-03 14:58:17 +08:00
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 }
2026-08-03 17:44:54 +08:00
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 = ''
}
2026-08-03 14:58:17 +08:00
function resetEditor () {
2026-08-03 17:44:54 +08:00
clearQualificationPreviews ()
2026-08-04 21:01:39 +08:00
form . value = emptyForm (); qualifications . value = newQualificationDrafts (); creditCodeEstablished . value = false ; qualificationLoadError . value = '' ; savedFormSnapshot . value = ''
2026-08-03 14:58:17 +08:00
}
function openCreate () {
2026-08-04 09:05:19 +08:00
editorLoadSequence += 1
2026-08-03 14:58:17 +08:00
editingId . value = null ; editorStatus . value = null ; resetEditor (); editorOpen . value = true
nextTick (() => formRef . value ? . clearValidate ())
}
2026-08-05 09:11:48 +08:00
function mapTenantToForm ( row : PlatformTenantResource ) : CustomerForm {
2026-08-03 14:58:17 +08:00
return {
2026-08-04 09:05:19 +08:00
tenantName : row . tenantName , legalName : row . legalName || '' , legalRepresentative : row . legalRepresentative || '' , unifiedSocialCreditCode : row . unifiedSocialCreditCode || '' ,
2026-08-03 14:58:17 +08:00
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 || '' ,
}
}
2026-08-04 09:05:19 +08:00
async function loadEditorQualifications ( tenantId : number | string , sequence = editorLoadSequence ) {
2026-08-03 14:58:17 +08:00
qualificationLoadError . value = ''
try {
const records = await listTenantQualifications ( tenantId )
2026-08-04 09:05:19 +08:00
if ( sequence !== editorLoadSequence || ! editorOpen . value || editingId . value !== String ( tenantId )) return
const acceptedStatus = [ 'ACTIVE' , 'DISABLED' ]. includes ( editorStatus . value || '' ) ? 'APPROVED' : 'PENDING'
2026-08-03 14:58:17 +08:00
for ( const draft of qualifications . value ) {
2026-08-04 09:05:19 +08:00
const item = [... records ]. reverse (). find (( record ) => (
record . qualificationType === draft . type && record . status === acceptedStatus
))
2026-08-03 17:44:54 +08:00
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 )
}
2026-08-03 14:58:17 +08:00
}
} catch ( error ) { qualificationLoadError . value = getApiErrorMessage ( error , '资质信息加载失败,可稍后重试' ) }
}
async function openEdit ( row : PlatformTenantResource ) {
2026-08-04 09:05:19 +08:00
const id = String ( row . id )
const sequence = ++ editorLoadSequence
editingId . value = id ; editorStatus . value = row . status ; resetEditor (); form . value = mapTenantToForm ( row )
2026-08-03 14:58:17 +08:00
editorOpen . value = true ; editorLoading . value = true
try {
const tenant = await getPlatformTenant ( row . id )
2026-08-04 09:05:19 +08:00
if ( sequence !== editorLoadSequence || editingId . value !== id || ! editorOpen . value ) return
2026-08-03 14:58:17 +08:00
editorStatus . value = tenant . status
form . value = mapTenantToForm ( tenant )
2026-08-04 21:01:39 +08:00
creditCodeEstablished . value = unifiedSocialCreditCodePattern . test ( tenant . unifiedSocialCreditCode || '' )
2026-08-03 14:58:17 +08:00
savedFormSnapshot . value = JSON . stringify ( form . value )
2026-08-04 09:05:19 +08:00
await loadEditorQualifications ( row . id , sequence )
2026-08-03 14:58:17 +08:00
await nextTick (() => formRef . value ? . clearValidate ())
} catch ( error ) {
2026-08-04 09:05:19 +08:00
if ( sequence === editorLoadSequence && editingId . value === id ) editorOpen . value = false
2026-08-03 14:58:17 +08:00
ElMessage . error ( getApiErrorMessage ( error , '客户完整资料加载失败,请稍后重试' ))
2026-08-01 12:17:33 +08:00
} finally {
2026-08-04 09:05:19 +08:00
if ( sequence === editorLoadSequence && editingId . value === id ) editorLoading . value = false
2026-08-01 12:17:33 +08:00
}
}
2026-08-03 14:58:17 +08:00
function selectQualificationFile ( event : Event , draft : QualificationDraft ) {
const input = event . target as HTMLInputElement
const file = input . files ? .[ 0 ] || null
2026-08-03 17:44:54 +08:00
draft . error = '' ; draft . file = null ; clearDraftPreview ( draft )
2026-08-03 14:58:17 +08:00
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 }
2026-08-04 09:05:19 +08:00
if ( file . size > 5 * 1024 * 1024 ) { draft . error = '单个文件不能超过 5MB' ; input . value = '' ; return }
2026-08-03 14:58:17 +08:00
draft . file = file
2026-08-03 17:44:54 +08:00
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
}
2026-08-03 14:58:17 +08:00
async function uploadSelectedQualifications ( tenantId : string ) {
const failures : string [] = []
2026-08-05 09:11:48 +08:00
const businessLicense = qualifications . value . find (( item ) => item . type === 'BUSINESS_LICENSE' )
const legalPersonId = qualifications . value . find (( item ) => item . type === 'LEGAL_PERSON_ID' )
2026-09-02 10:04:24 +08:00
const businessLicenseFile = businessLicense ? . file || null
const legalPersonIdFile = legalPersonId ? . file || null
if ( businessLicense && businessLicenseFile ) {
businessLicense . uploading = true
if ( legalPersonIdFile && legalPersonId ) legalPersonId . uploading = true
businessLicense . error = ''
if ( legalPersonId ) legalPersonId . error = ''
2026-08-05 09:11:48 +08:00
try {
const uploaded = await uploadPlatformTenantRequiredQualificationPackage (
2026-09-02 10:04:24 +08:00
tenantId , businessLicenseFile , legalPersonIdFile , form . value . unifiedSocialCreditCode ,
businessLicense . uploaded , Boolean ( legalPersonIdFile && legalPersonId ? . uploaded ),
2026-08-05 09:11:48 +08:00
)
2026-09-02 10:04:24 +08:00
if ( legalPersonIdFile && ! uploaded . legalPersonIdDocumentUrl ) throw new Error ( '法人身份证上传结果缺少文件地址' )
businessLicense . uploaded = true
2026-08-05 09:11:48 +08:00
creditCodeEstablished . value = true
2026-09-02 10:04:24 +08:00
businessLicense . uploadedName = businessLicenseFile . name
2026-08-05 09:11:48 +08:00
businessLicense . documentUrl = uploaded . businessLicenseDocumentUrl
2026-09-02 10:04:24 +08:00
businessLicense . file = null
if ( legalPersonIdFile && legalPersonId ) {
legalPersonId . uploaded = true
legalPersonId . uploadedName = legalPersonIdFile . name
legalPersonId . documentUrl = uploaded . legalPersonIdDocumentUrl || ''
legalPersonId . file = null
}
2026-08-05 09:11:48 +08:00
} catch ( error ) {
2026-09-02 10:04:24 +08:00
const message = getApiErrorMessage ( error , '营业执照及统一社会信用代码保存失败,请重试' )
businessLicense . error = message
if ( legalPersonIdFile && legalPersonId ) legalPersonId . error = message
failures . push ( '营业执照资质' )
2026-08-05 09:11:48 +08:00
} finally {
2026-09-02 10:04:24 +08:00
businessLicense . uploading = false
if ( legalPersonId ) legalPersonId . uploading = false
2026-08-05 09:11:48 +08:00
}
}
for ( const draft of failures . length ? [] : qualifications . value . filter (( item ) => ! item . required && item . file )) {
2026-08-03 14:58:17 +08:00
draft . uploading = true ; draft . error = ''
try {
2026-08-03 17:44:54 +08:00
const uploaded = await uploadPlatformTenantQualification (
2026-08-03 14:58:17 +08:00
tenantId , draft . type , draft . file as File , draft . validTo || undefined ,
2026-08-05 09:11:48 +08:00
undefined ,
2026-08-03 14:58:17 +08:00
draft . uploaded ,
)
2026-08-03 17:44:54 +08:00
draft . uploaded = true
draft . uploadedName = draft . file ? . name || '已登记文件'
draft . documentUrl = uploaded . documentUrl
draft . file = null
2026-08-03 14:58:17 +08:00
} catch ( error ) {
draft . error = getApiErrorMessage ( error , '上传失败,请重试' ); failures . push ( draft . label )
} finally { draft . uploading = false }
2026-08-01 12:17:33 +08:00
}
2026-08-03 14:58:17 +08:00
return failures
}
async function saveCustomer () {
if ( ! formRef . value || ! ( await formRef . value . validate (). catch (() => false ))) return
2026-08-07 14:47:11 +08:00
if ( ! editingId . value ) {
try {
const available = await checkPlatformTenantContactPhone ( form . value . contactPhone . trim ())
if ( ! available ) {
ElMessage . error ( '联系电话已被其他客户使用,请更换后再提交' )
return
}
} catch ( error ) {
ElMessage . error ( getApiErrorMessage ( error , '联系电话唯一性校验失败,请稍后重试' ))
return
}
}
2026-08-04 21:01:39 +08:00
const qualificationPackageTouched = hasSelectedQualifications . value || Boolean ( form . value . unifiedSocialCreditCode )
if ( qualificationPackageTouched && ! requiredQualificationPackageComplete . value ) {
ElMessage . warning ( `请完整保存必填业务项: ${ missingQualificationPackageItems . value . join ( '、' ) } ` )
return
}
2026-08-03 14:58:17 +08:00
saving . value = true
try {
2026-08-03 17:44:54 +08:00
const hadSelectedQualifications = hasSelectedQualifications . value
2026-08-03 14:58:17 +08:00
let saved : PlatformTenantResource
if ( editingId . value ) {
const { unifiedSocialCreditCode : _immutable , ... payload } = form . value
saved = await updatePlatformTenant ( editingId . value , payload )
} else {
2026-08-05 09:11:48 +08:00
const { unifiedSocialCreditCode : _qualificationCreditCode , ... payload } = form . value
2026-08-08 11:05:45 +08:00
const created = await createPlatformTenant ( payload )
saved = created . tenant
2026-08-03 14:58:17 +08:00
editingId . value = String ( saved . id )
2026-08-08 11:05:45 +08:00
credentialSource . value = 'creation'
approvalResult . value = {
tenantId : saved . id ,
administratorCreated : true ,
administratorUsername : created . administratorUsername ,
temporaryPassword : created . temporaryPassword ,
}
approvalResultOpen . value = true
2026-08-03 14:58:17 +08:00
}
editorStatus . value = saved . status
savedFormSnapshot . value = JSON . stringify ( form . value )
2026-08-03 17:44:54 +08:00
const failures = hadSelectedQualifications ? await uploadSelectedQualifications ( String ( saved . id )) : []
if ( failures . length ) {
ElMessage . warning ( `客户资料已保存,但 ${ failures . join ( '、' ) } 上传失败;失败文件已保留,请修正后再次保存` )
} else if ( hadSelectedQualifications ) {
2026-08-04 21:01:39 +08:00
ElMessage . success ( '营业执照、统一社会信用代码和法人身份证已完整保存,尚未提交审核' )
2026-08-03 17:44:54 +08:00
} else {
ElMessage . success ( '客户资料已保存,尚未提交审核' )
}
2026-08-03 14:58:17 +08:00
await loadCustomers ()
} catch ( error ) { ElMessage . error ( getApiErrorMessage ( error , '客户资料保存失败,请稍后重试' )) }
finally { saving . value = false }
}
async function submitReview () {
2026-08-03 20:50:18 +08:00
if ( submitting . value ) return
submitting . value = true
2026-08-03 14:58:17 +08:00
try {
2026-08-03 20:50:18 +08:00
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 }
2026-08-04 21:01:39 +08:00
if ( ! creditCodeEstablished . value || ! unifiedSocialCreditCodePattern . test ( form . value . unifiedSocialCreditCode || '' )) {
ElMessage . warning ( '请先完整保存营业执照、18位统一社会信用代码和法人身份证' )
return
}
2026-08-03 20:50:18 +08:00
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 }
2026-08-03 14:58:17 +08:00
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 }
}
2026-08-04 13:32:08 +08:00
function showDetail ( row : PlatformTenantResource ) {
void router . push ({ name : 'customer-detail' , params : { tenantId : String ( row . id ) } })
2026-08-03 14:58:17 +08:00
}
2026-08-08 11:21:56 +08:00
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 )
}
2026-08-14 16:06:30 +08:00
function canArchive ( row : PlatformTenantResource ) {
return access . can ( 'platform:customer:delete' ) && [ 'PROFILE_REQUIRED' , 'REJECTED' ]. includes ( row . status )
2026-08-08 11:21:56 +08:00
}
2026-08-10 17:42:17 +08:00
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 ] : []),
2026-08-14 16:06:30 +08:00
...( canArchive ( row ) ? [ 'delete' as const ] : []),
2026-08-10 17:42:17 +08:00
]
}
2026-08-15 12:12:38 +08:00
function customerPrimaryActionCount ( row : PlatformTenantResource ) {
return Number ( access . can ( 'platform:customer:list' ))
+ Number ( access . can ( 'platform:customer:edit' ) && row . status !== 'ARCHIVED' )
+ Number ( hasReviewAction ( row ))
}
function customerDirectSecondaryActions ( row : PlatformTenantResource ) {
return customerSecondaryActions ( row ). slice ( 0 , Math . max ( 0 , 3 - customerPrimaryActionCount ( row )))
}
function customerOverflowActions ( row : PlatformTenantResource ) {
return customerSecondaryActions ( row ). slice ( Math . max ( 0 , 3 - customerPrimaryActionCount ( row )))
}
function customerMoreActionsLabel ( row : PlatformTenantResource ) {
const identity = row . tenantName || row . tenantCode || String ( row . id || '当前客户' )
return `更多客户操作: ${ identity } `
}
2026-08-10 17:42:17 +08:00
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
2026-08-08 11:21:56 +08:00
}
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 )
}
2026-08-03 14:58:17 +08:00
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 = '' }
}
2026-08-08 10:14:07 +08:00
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
2026-08-08 11:33:09 +08:00
const clientUrl = getClientWebUrl ()
2026-08-08 10:14:07 +08:00
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 )
2026-08-14 16:06:30 +08:00
ElMessage . success ( '客户已归档,历史数据已保留且账号已停用' )
2026-08-08 10:14:07 +08:00
archiveDialogOpen . value = false
archiveTenant . value = null
archiveConfirmCode . value = ''
await loadCustomers ()
} catch ( error ) {
archiveError . value = getApiErrorDetails ( error , '客户删除失败,当前客户仍保留' )
} finally {
archiveSubmitting . value = false
}
}
2026-08-13 11:57:44 +08:00
async function openDecision ( row : PlatformTenantResource , type : 'approve' | 'reject' ) {
2026-08-03 20:50:18 +08:00
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
2026-08-13 11:57:44 +08:00
await loadReviewQualifications ()
2026-08-03 20:50:18 +08:00
void nextTick (() => decisionFormRef . value ? . clearValidate ( 'comment' ))
}
2026-08-13 11:57:44 +08:00
async function loadReviewQualifications () {
const review = selectedReview . value
if ( ! review ) return
reviewQualifications . value = []
reviewQualificationsError . value = null
reviewQualificationsLoading . value = true
try {
reviewQualifications . value = await listTenantQualifications ( review . tenantId )
} catch ( error ) {
reviewQualificationsError . value = getApiErrorDetails ( error , '客户资质加载失败,请重试后再审核' )
} finally {
reviewQualificationsLoading . value = false
}
}
function clearReviewPreview () {
if ( reviewPreviewUrl . value ) URL . revokeObjectURL ( reviewPreviewUrl . value )
reviewPreviewOpen . value = false
reviewPreviewUrl . value = ''
reviewPreviewName . value = ''
}
async function loadReviewDocument ( item : PlatformTenantQualification , download : boolean ) {
const review = selectedReview . value
if ( ! review || reviewDocumentLoading . value ) return
const fileName = qualificationName ( item )
reviewDocumentLoading . value = String ( item . id )
try {
const blob = await downloadTenantQualification ( review . tenantId , item . documentUrl )
const url = URL . createObjectURL ( blob )
if ( download ) {
const anchor = document . createElement ( 'a' )
anchor . href = url
anchor . download = fileName
anchor . click ()
URL . revokeObjectURL ( url )
return
}
clearReviewPreview ()
reviewPreviewName . value = fileName
reviewPreviewKind . value = blob . type === 'application/pdf' || fileName . toLowerCase (). endsWith ( '.pdf' ) ? 'pdf' : 'image'
reviewPreviewUrl . value = url
reviewPreviewOpen . value = true
} catch ( error ) {
ElMessage . error ( getApiErrorMessage ( error , '资质文件读取失败,请稍后重试' ))
} finally {
reviewDocumentLoading . value = ''
}
}
2026-08-03 20:50:18 +08:00
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
2026-08-04 09:05:19 +08:00
const successMessage = decision . type === 'reject'
? '企业资料已驳回'
: approved ? . administratorCreated
? '企业资料已审核通过,管理员账号已创建'
: '企业资料已审核通过,注册账号继续作为企业管理员'
ElMessage . success ( successMessage )
2026-08-03 20:50:18 +08:00
if ( approved ? . administratorCreated ) {
2026-08-08 11:05:45 +08:00
credentialSource . value = 'approval'
2026-08-03 20:50:18 +08:00
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
}
2026-08-03 14:58:17 +08:00
async function exportCustomers () {
2026-08-03 18:15:41 +08:00
exportSubmitting . value = true
2026-08-04 11:22:04 +08:00
let task : Awaited < ReturnType < typeof createPlatformExportTask > >
2026-08-03 14:58:17 +08:00
try {
2026-08-04 11:22:04 +08:00
task = await createPlatformExportTask ({
2026-08-03 18:15:41 +08:00
exportType : 'CUSTOMER_LIST' ,
format : 'XLSX' ,
taskName : '客户列表' ,
parameters : {
keyword : filters . value . keyword . trim () || undefined ,
status : filters . value . status || undefined ,
},
})
2026-08-04 11:22:04 +08:00
} 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' })
2026-08-01 12:17:33 +08:00
}
2026-08-07 20:07:37 +08:00
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' })
}
2026-08-01 12:17:33 +08:00
watch ([ page , pageSize ], () => { void loadCustomers () })
2026-08-07 20:07:37 +08:00
watch (
() => typeof route . query . edit === 'string' ? route . query . edit : '' ,
( editId ) => { if ( editId ) void openRouteEditor ( editId ) },
{ immediate : true },
)
2026-08-04 09:05:19 +08:00
watch ( editorOpen , ( open ) => {
if ( ! open ) {
editorLoadSequence += 1
clearQualificationPreviews ()
}
})
2026-08-03 20:50:18 +08:00
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
},
)
2026-08-08 10:14:07 +08:00
watch (() => decision . type , ( type ) => {
if ( ! decisionOpen . value ) return
decision . comment = type === 'approve' ? '企业资料与必填资质完整、清晰,审核通过' : ''
void nextTick (() => decisionFormRef . value ? . clearValidate ( 'comment' ))
})
onBeforeUnmount (() => {
clearQualificationPreviews ()
2026-08-13 11:57:44 +08:00
clearReviewPreview ()
2026-08-08 10:14:07 +08:00
impersonationCleanup ? .()
})
2026-08-07 20:07:37 +08:00
onMounted (() => { void loadCustomers () })
2026-08-01 12:17:33 +08:00
</ script >
< template >
2026-08-10 16:24:54 +08:00
< AdminPage as = "div" class = "customers-page" >
2026-08-18 09:09:34 +08:00
< OperationErrorToast : details = "passwordError || impersonationError || archiveError" />
2026-08-10 16:24:54 +08:00
< 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" >
2026-08-03 20:50:18 +08:00
< 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 >
2026-08-12 14:57:02 +08:00
< 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 >
2026-08-06 20:14:56 +08:00
< 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 >
2026-08-08 11:21:56 +08:00
< template # actions = "{ row }" >
< div class = "table-actions" >
2026-08-13 11:57:44 +08:00
< el-button v-if = "access.can('platform:customer:list')" class="customer-action-button customer-action-detail" size="small" @click="showDetail(row as PlatformTenantResource)"><Eye :size="14" /> 详情 </ el-button >
2026-08-14 16:06:30 +08:00
< el-button v-if = "access.can('platform:customer:edit') && (row as PlatformTenantResource).status !== 'ARCHIVED'" class="customer-action-button customer-action-edit" size="small" @click="openEdit(row as PlatformTenantResource)"><Pencil :size="14" /> 编辑 </ el-button >
2026-08-12 14:57:02 +08:00
< 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 >
2026-08-15 12:12:38 +08:00
< el-button v-for = "action in customerDirectSecondaryActions(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 >
< el-dropdown v-if = "customerOverflowActions(row as PlatformTenantResource).length" trigger="click" popper-class="customer-action-menu" @command="handleMoreAction(row as PlatformTenantResource, $event as string)" >
< el-button class = "customer-action-button customer-action-more" size = "small" plain : aria -label =" customerMoreActionsLabel ( row as PlatformTenantResource )" : title = "customerMoreActionsLabel(row as PlatformTenantResource)" >< MoreHorizontal :size = "16" /></ el-button >
< template # dropdown >< el-dropdown-menu >< el-dropdown-item v-for = "action in customerOverflowActions(row as PlatformTenantResource)" :key="action" :command="action" :disabled="customerActionDisabled(action)" :class="{ 'customer-action-menu__danger': customerActionType(row as PlatformTenantResource, action) === 'danger' }"><component :is="customerRowActionIcons[action]" :size="15" />{{ customerActionLabel ( row as PlatformTenantResource , action ) }}</ el-dropdown-item ></ el-dropdown-menu ></ template >
</ el-dropdown >
2026-08-08 11:21:56 +08:00
</ div >
</ template >
2026-08-12 14:57:02 +08:00
< template # company = "{ row }" >< strong class = "customer-company-name" >{{ ( row as PlatformTenantResource ). tenantName }}</ strong ></ template >
2026-08-03 14:58:17 +08:00
< 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 >
2026-08-10 16:24:54 +08:00
< template # pagination -left =" { total : tableTotal } " >< span > 共 {{ tableTotal }} 个客户 </ span ></ template >
2026-08-01 12:17:33 +08:00
</ DataTable >
2026-08-10 16:24:54 +08:00
</ section >
</ ListPageLayout >
2026-08-03 14:58:17 +08:00
2026-08-12 16:31:46 +08:00
< AppDialog v-model = "editorOpen" :title="editorTitle" :width="editorDialogWidth" class="customer-editor-dialog" :loading="editorLoading" :confirm-loading="saving" destroy -on-close >
2026-08-03 14:58:17 +08:00
< el-form ref = "formRef" v-loading = "editorLoading" :model="form" :rules="rules" label-position="top" class="drawer-form" :disabled="editorLoading || saving || submitting || uploadingQualifications" >
2026-08-10 16:24:54 +08:00
< FormSection title = "基础资料" description = "维护工商登记和企业主体信息。" >< div class = "form-grid two-cols" >
2026-08-03 14:58:17 +08:00
< 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 >
2026-08-04 09:05:19 +08:00
< el-form-item label = "法定代表人" prop = "legalRepresentative" required >< el-input v-model = "form.legalRepresentative" placeholder="请输入法定代表人姓名" maxlength="64" /></ el-form-item >
2026-08-03 14:58:17 +08:00
< el-form-item label = "企业类型" prop = "enterpriseType" >< el-input v-model = "form.enterpriseType" placeholder="如:有限责任公司" maxlength="64" /></ el-form-item >
2026-08-04 10:31:59 +08:00
< 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 >
2026-08-03 14:58:17 +08:00
< 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 >
2026-08-10 16:24:54 +08:00
</ div >< el-form-item label = "注册地址" prop = "registeredAddress" >< el-input v-model = "form.registeredAddress" placeholder="请输入省市区、街道及门牌信息" maxlength="500" /></ el-form-item ></ FormSection >
2026-08-03 14:58:17 +08:00
2026-08-10 16:24:54 +08:00
< FormSection title = "联系与商务" description = "维护客户联系人、账期和内部跟进信息。" >< div class = "form-grid two-cols" >
2026-08-03 14:58:17 +08:00
< el-form-item label = "联系人" prop = "contactName" required >< el-input v-model = "form.contactName" placeholder="请输入主要联系人姓名" maxlength="64" /></ el-form-item >
2026-08-07 14:47:11 +08:00
< 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 >
2026-08-03 14:58:17 +08:00
< 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 >
2026-08-04 10:31:59 +08:00
<!-- < 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 > -->
2026-08-03 14:58:17 +08:00
< 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 >
2026-08-10 16:24:54 +08:00
</ 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 >
2026-08-03 14:58:17 +08:00
2026-08-10 16:24:54 +08:00
< 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 >
2026-08-05 09:11:48 +08:00
< 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 >
2026-08-03 14:58:17 +08:00
< 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" >
2026-08-04 14:15:32 +08:00
< 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 >
2026-08-03 17:44:54 +08:00
< 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 >
2026-08-18 09:37:07 +08:00
< div class = "qualification-validity" role = "cell" >< el-date-picker v-model = "draft.validTo" type="date" value-format="YYYY-MM-DD" placeholder="请选择有效期" :disabled="!canUploadQualifications || draft.uploaded" /></ div >
2026-08-03 14:58:17 +08:00
</ div >
</ div >
2026-08-04 09:05:19 +08:00
< p class = "qualification-hint" > 支持 PDF 、 JPG 、 PNG , 单个文件不超过 5 MB ; < span > * </ span > 为提交审核必填项 。 </ p >
2026-08-10 16:24:54 +08:00
</ FormSection >
2026-08-03 14:58:17 +08:00
</ el-form >
2026-08-10 16:24:54 +08:00
< 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 >
2026-08-12 16:31:46 +08:00
</ AppDialog >
2026-08-03 14:58:17 +08:00
2026-08-10 16:24:54 +08:00
< AppDialog v-model = "qualificationPreviewOpen" :title="qualificationPreviewName || '查看资质文件'" width="min(860px, calc(100vw - 28px))" class="qualification-preview-dialog" destroy-on-close :show-confirm="false" cancel-text="关闭预览" >
2026-08-03 17:44:54 +08:00
< 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 >
2026-08-10 16:24:54 +08:00
</ AppDialog >
2026-08-03 17:44:54 +08:00
2026-08-08 11:05:45 +08:00
< 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" >
2026-08-03 20:50:18 +08:00
< div v-if = "approvalResult" class="approval-result-content" >
2026-08-08 11:05:45 +08:00
< div class = "approval-result-warning" role = "alert" >< ShieldCheck :size = "20" />< div >< strong > 请立即保存以下一次性凭证 </ strong >< span > 临时密码只在本次 {{ credentialSource === 'creation' ? '创建' : '审核' }} 响应中返回 , 关闭后无法再次查看 。 </ span ></ div ></ div >
2026-08-03 20:50:18 +08:00
< 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 >
2026-08-17 10:54:11 +08:00
< el-dialog v-model = "decisionOpen" title="客户入驻审核" width="min(1040px, calc(100vw - 28px))" class="review-decision-dialog" :close-on-click-modal="false" >
< div v-if = "selectedReview" class="review-dialog-content" >
< header class = "review-dialog-header" >
< div class = "review-dialog-title" >< span class = "review-dialog-icon" >< Building2 :size = "18" /></ span >< div >< strong >{{ selectedReview . tenantName || '未命名客户' }}</ strong >< span > 客户编号 : {{ selectedReview . tenantCode || '未生成' }}</ span ></ div ></ div >
< el-tag type = "warning" effect = "plain" > 待审核 </ el-tag >
</ header >
< div class = "review-dialog-grid" >
< section class = "review-dialog-section review-dialog-card" >
< div class = "review-section-heading" >< div >< h3 > 企业基本信息 </ h3 >< span > 核对客户提交的主体资料 </ span ></ div >< CircleCheck :size = "18" /></ div >
< 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 review-dialog-card" >
< div class = "review-section-heading" >< div >< h3 > 资质文件 </ h3 >< span > 共 {{ reviewQualifications . length }} 份 , 逐项查看后再作决定 </ span ></ div >< FileCheck2 :size = "18" /></ div >
< div v-if = "reviewQualificationsLoading" class="review-document-state" > 正在加载资质文件... </ div >
< div v-else-if = "reviewQualificationsError" class="review-document-error" role="alert"><span>{{ reviewQualificationsError.message }}</span><el-button link type="primary" @click="loadReviewQualifications" > 重试 </ el -button ></ div >
< div v-else 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 v-for = "item in reviewQualifications" :key="String(item.id)" class="review-document-row" role="row"><span role="cell" class="review-document-type">{{ qualificationLabel(item.qualificationType) }}</span><span role="cell" class="review-document-name" :title="qualificationName(item)"><FileText :size="15" />{{ qualificationName(item) }}</span><span role="cell" class="review-document-actions"><el-button link type="primary" :loading="reviewDocumentLoading === String(item.id)" @click="loadReviewDocument(item, false)"><Eye :size="14" />预览</el-button><el-button link :disabled="Boolean(reviewDocumentLoading)" @click="loadReviewDocument(item, true)"><Download :size="14" /> 下载 </ el-button ></ span ></ div >
< div v-if = "reviewQualifications.length === 0" class="review-document-empty"><FileText :size="18" />< span > 暂无资质文件 </ span ></ div >
</ div >
</ section >
</ div >
< section class = "review-decision-card" >
< div class = "review-section-heading" >< div >< h3 > 审核决定 </ h3 >< span > 确认资料无误后提交审核结果 </ span ></ div >< ShieldCheck :size = "18" /></ 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 >
2026-08-12 16:31:46 +08:00
</ section >
</ div >
2026-08-17 10:54:11 +08:00
< 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 >
2026-08-03 20:50:18 +08:00
</ el-dialog >
2026-08-08 10:14:07 +08:00
2026-08-13 11:57:44 +08:00
< AppDialog v-model = "reviewPreviewOpen" :title="reviewPreviewName || '查看资质文件'" width="min(860px, calc(100vw - 28px))" destroy-on-close :show-confirm="false" cancel-text="关闭预览" @closed="clearReviewPreview" >
< div class = "qualification-preview" >< img v-if = "reviewPreviewUrl && reviewPreviewKind === 'image'" :src="reviewPreviewUrl" :alt="`${reviewPreviewName}预览`" /><iframe v-else-if="reviewPreviewUrl" :src="reviewPreviewUrl" :title="`${reviewPreviewName} PDF 预览`" /></ div >
</ AppDialog >
2026-08-12 16:31:46 +08:00
< 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" >
2026-08-11 19:59:15 +08:00
< div v-if = "passwordTenant" class="decision-summary" >< div >< strong > 正在重置企业管理员登录密码 </ strong >< span >{{ passwordTenant . tenantName }}</ span ></ div >< span > 客户编号 : {{ passwordTenant . tenantCode }}</ span ></ div >
2026-08-08 10:14:07 +08:00
< 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" >
2026-08-11 19:59:15 +08:00
< div v-if = "impersonationTenant" class="decision-summary" >< div >< strong >{{ impersonationTenant . tenantName }}</ strong >< span > 将以企业管理员身份打开客户工作台 </ span ></ div >< span > 客户编号 : {{ impersonationTenant . tenantCode }}</ span ></ div >
2026-08-08 10:14:07 +08:00
< el-alert type = "warning" :closable = "false" show -icon > 本操作会生成一次性 、 短时有效的客户会话 , 并记录代登录原因 ; 不会替换当前 Admin 会话 。 </ 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 >
< 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 >
2026-08-10 16:24:54 +08:00
</ AdminPage >
2026-08-01 12:17:33 +08:00
</ template >
< style scoped >
2026-08-03 14:58:17 +08:00
. customers - table - heading { display : flex ; min - width : 0 ; align - items : baseline ; gap : 10 px ; } . customers - table - heading h2 { margin : 0 ; color : var ( -- cc - text ); font - size : 16 px ; } . customers - table - heading span { color : var ( -- cc - muted ); font - size : 12 px ; white - space : nowrap ; }
. customers - table - actions { display : flex ; flex - wrap : wrap ; gap : 8 px ; } . customers - table - actions : deep (. el - button + . el - button ) { margin - left : 0 ; }
2026-08-03 20:50:18 +08:00
. table - actions { display : flex ; flex - wrap : wrap ; align - items : center ; gap : 2 px 8 px ; } . table - actions : deep (. el - button ) { margin - left : 0 ; }
2026-08-08 11:21:56 +08:00
: global (. customer - action - menu . el - dropdown - menu__item ) { min - height : 40 px ; gap : 8 px ; }
: global (. customer - action - menu . customer - action - menu__danger ) { color : var ( -- el - color - danger ); }
2026-08-03 20:50:18 +08:00
. review - sync - error { display : flex ; min - width : 0 ; align - items : center ; justify - content : space - between ; gap : 12 px ; padding : 10 px 18 px ; border - bottom : 1 px solid # fecdca ; background : # fffafa ; color : # b42318 ; font - size : 12 px ; } . review - sync - error > div { display : flex ; min - width : 0 ; align - items : center ; gap : 8 px ; } . review - sync - error span { overflow - wrap : anywhere ; }
2026-08-12 14:57:02 +08:00
. 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 ( 135 deg , # 667 eea 0 % , # 764 ba2 50 % , # f093fb 100 % ); box - shadow : 0 4 px 12 px 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 : 1 px 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 : 1 px 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 : 28 px ; margin : 0 ; padding : 5 px 9 px ; border - radius : 8 px ; font - size : 12 px ; font - weight : 650 ; box - shadow : 0 2 px 6 px rgba ( 15 , 23 , 42 , .06 ); }
: deep (. customer - list - table . customer - action - detail ) { border - color : # bfdbfe ; background : # eff6ff ; color : # 2563 eb ; }
: deep (. customer - list - table . customer - action - edit ) { border - color : # c7d2fe ; background : # eef2ff ; color : # 667 eea ; }
: 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 : # 7 c3aed ; }
: deep (. customer - list - table . customer - action - impersonate ) { border - color : # bae6fd ; background : # f0f9ff ; color : # 0369 a1 ; }
: deep (. customer - list - table . customer - action - status . is - enable ) { border - color : # bbf7d0 ; background : # f0fdf4 ; color : # 16 a34a ; }
: 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 2 px 6 px rgba ( 15 , 23 , 42 , .06 ); }
: deep (. customer - list - table . el - tag ) { border : 0 ; border - radius : 12 px ; font - weight : 650 ; }
: deep (. customer - list - table . el - tag -- success ) { background : linear - gradient ( 135 deg , rgba ( 34 , 197 , 94 , .15 ), rgba ( 22 , 163 , 74 , .1 )); color : # 16 a34a ; }
: deep (. customer - list - table . el - tag -- warning ) { background : linear - gradient ( 135 deg , rgba ( 245 , 158 , 11 , .15 ), rgba ( 234 , 88 , 12 , .1 )); color : # ea580c ; }
: deep (. customer - list - table . el - tag -- danger ) { background : linear - gradient ( 135 deg , rgba ( 239 , 68 , 68 , .15 ), rgba ( 220 , 38 , 38 , .1 )); color : # dc2626 ; }
: deep (. customer - list - table . el - tag -- info ) { background : linear - gradient ( 135 deg , rgba ( 102 , 126 , 234 , .15 ), rgba ( 118 , 75 , 162 , .1 )); color : # 667 eea ; }
2026-08-03 14:58:17 +08:00
. capital - input { display : grid ; grid - template - columns : minmax ( 0 , 1 fr ) 130 px ; width : 100 % ; gap : 8 px ; } . capital - input : deep (. el - input - number ),. capital - input : deep (. el - select ) { width : 100 % ; }
2026-08-10 16:24:54 +08:00
. qualification - heading { display : flex ; justify - content : flex - end ; margin - bottom : 12 px ; } . qualification - hint { margin : 12 px 0 0 ; color : var ( -- cc - muted ); font - size : 12 px ; line - height : 1.6 ; } . qualification - hint span ,. qualification - label > span { color : var ( -- el - color - danger ); }
2026-08-07 14:47:11 +08:00
. qualification - credit - code { max - width : 420 px ; margin - top : 16 px ; } . qualification - credit - code small ,. field - hint { display : block ; margin - top : 6 px ; color : var ( -- cc - muted ); font - size : 11 px ; line - height : 1.5 ; }
2026-08-18 09:37:07 +08:00
. qualification - table { overflow : hidden ; margin - top : 14 px ; border : 1 px solid var ( -- cc - border ); border - radius : 8 px ; } . qualification - row { display : grid ; grid - template - columns : 170 px minmax ( 0 , 1 fr ) 220 px ; min - width : 0 ; border - top : 1 px solid var ( -- cc - border ); } . qualification - row : first - child { border - top : 0 ; } . qualification - row > * { display : flex ; min - width : 0 ; min - height : 68 px ; align - items : center ; padding : 12 px 14 px ; } . qualification - header { min - height : 44 px ; background : # f7f8fc ; color : var ( -- cc - muted ); font - size : 12 px ; font - weight : 600 ; } . qualification - header > * { min - height : 44 px ; }
2026-08-03 14:58:17 +08:00
. qualification - label { gap : 5 px ; font - weight : 600 ; } . qualification - label svg { margin - left : auto ; color : var ( -- el - color - success ); }
2026-08-04 14:15:32 +08:00
. qualification - optional { padding : 2 px 5 px ; border - radius : 4 px ; background : var ( -- el - fill - color - light ); color : var ( -- cc - muted ); font - size : 10 px ; font - weight : 500 ; line - height : 1.4 ; }
2026-08-18 09:37:07 +08:00
. qualification - file { gap : 12 px ; } . qualification - file - control { display : flex ; min - width : 0 ; flex : 1 ; flex - wrap : wrap ; align - items : center ; gap : 8 px ; } . file - picker { position : relative ; display : inline - flex ; min - height : 36 px ; flex : 0 0 auto ; align - items : center ; gap : 7 px ; overflow : hidden ; padding : 0 12 px ; border : 1 px solid var ( -- cc - border ); border - radius : 6 px ; background : # fff ; color : var ( -- cc - text ); cursor : pointer ; } . file - picker : focus - within { outline : 2 px solid color - mix ( in srgb , var ( -- cc - primary ) 35 % , transparent ); outline - offset : 2 px ; } . 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 : 140 px ; overflow : hidden ; color : var ( -- cc - muted ); font - size : 12 px ; text - overflow : ellipsis ; white - space : nowrap ; } . qualification - validity : deep (. el - date - editor ) { width : 100 % ; min - width : 0 ; } . qualification - error { width : 100 % ; color : var ( -- el - color - danger ); line - height : 1.4 ; }
2026-08-03 17:44:54 +08:00
. qualification - preview - trigger { position : relative ; display : grid ; width : 68 px ; height : 68 px ; flex : 0 0 68 px ; overflow : hidden ; place - items : center ; padding : 0 ; border : 1 px solid var ( -- cc - border ); border - radius : 6 px ; 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 : 2 px solid color - mix ( in srgb , var ( -- cc - primary ) 42 % , transparent ); outline - offset : 2 px ; }
. 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 : 2 px ; }
. qualification - preview - placeholder small { font - size : 10 px ; font - weight : 700 ; }
. qualification - preview - action { position : absolute ; inset : auto 0 0 ; display : flex ; min - height : 22 px ; align - items : center ; justify - content : center ; gap : 3 px ; background : rgba ( 23 , 32 , 51 , .78 ); color : # fff ; font - size : 10 px ; }
. qualification - preview { display : grid ; max - height : min ( 70 vh , 760 px ); min - height : 240 px ; place - items : center ; overflow : auto ; border : 1 px solid var ( -- cc - border ); border - radius : 6 px ; background : # f8fafc ; }
. qualification - preview img { display : block ; max - width : 100 % ; height : auto ; }
. qualification - preview iframe { width : 100 % ; min - height : min ( 68 vh , 720 px ); border : 0 ; background : # fff ; }
2026-08-03 14:58:17 +08:00
. qualification - load - error { display : flex ; align - items : center ; justify - content : space - between ; gap : 10 px ; margin - top : 12 px ; padding : 10 px 12 px ; border : 1 px solid # fecdca ; border - radius : 6 px ; background : # fffafa ; color : # b42318 ; font - size : 12 px ; }
2026-08-12 16:31:46 +08:00
: global (. customer - editor - dialog . el - dialog ) { display : flex ; max - height : min ( 90 vh , 900 px ); 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 ; }
2026-08-03 14:58:17 +08:00
. customer - editor - footer { display : flex ; align - items : center ; justify - content : space - between ; gap : 16 px ; } . customer - editor - footer > span { color : var ( -- cc - muted ); font - size : 12 px ; text - align : left ; } . customer - editor - footer > div { display : flex ; flex : 0 0 auto ; gap : 8 px ; } . customer - editor - footer : deep (. el - button + . el - button ) { margin - left : 0 ; }
2026-08-03 20:50:18 +08:00
. decision - summary { display : flex ; min - width : 0 ; align - items : center ; justify - content : space - between ; gap : 12 px ; margin - bottom : 18 px ; padding : 12 px 14 px ; border - radius : 8 px ; background : # f8fafc ; } . decision - summary > * { min - width : 0 ; overflow - wrap : anywhere ; } . decision - summary strong { color : var ( -- cc - text ); font - size : 13 px ; } . decision - summary span { color : var ( -- cc - muted ); font - size : 11 px ; }
2026-08-17 10:54:11 +08:00
: global (. review - decision - dialog . el - dialog ) { display : flex ; max - height : min ( 90 vh , 860 px ); flex - direction : column ; overflow : hidden ; }
: global (. review - decision - dialog . el - dialog__header ) { padding : 22 px 28 px 16 px ; border - bottom : 1 px solid # eef1f6 ; }
: global (. review - decision - dialog . el - dialog__body ) { min - height : 0 ; overflow - y : auto ; padding : 20 px 28 px 24 px ; background : # fbfcfe ; }
2026-08-12 16:31:46 +08:00
: global (. review - decision - dialog . el - dialog__footer ) { flex : 0 0 auto ; }
2026-08-17 10:54:11 +08:00
. review - dialog - content { display : grid ; gap : 16 px ; }
. review - dialog - header { display : flex ; align - items : center ; justify - content : space - between ; gap : 16 px ; padding : 14 px 16 px ; border : 1 px solid # dbe4f0 ; border - radius : 10 px ; background : linear - gradient ( 90 deg , # f4f7ff , # fff ); }
. review - dialog - title { display : flex ; min - width : 0 ; align - items : center ; gap : 11 px ; }
. review - dialog - title > div { display : grid ; min - width : 0 ; gap : 3 px ; }
. review - dialog - title strong { overflow : hidden ; color : # 18243 a ; font - size : 16 px ; text - overflow : ellipsis ; white - space : nowrap ; }
. review - dialog - title span : not (. review - dialog - icon ) { overflow : hidden ; color : # 718096 ; font - size : 12 px ; text - overflow : ellipsis ; white - space : nowrap ; }
. review - dialog - icon { display : grid ; width : 36 px ; height : 36 px ; flex : 0 0 36 px ; place - items : center ; border - radius : 9 px ; background : # e9efff ; color : # 526 fe8 ; }
. review - dialog - grid { display : grid ; grid - template - columns : minmax ( 270 px , .85 fr ) minmax ( 390 px , 1.15 fr ); gap : 16 px ; }
. review - dialog - section ,. review - decision - card { min - width : 0 ; padding : 16 px ; border : 1 px solid # e5eaf1 ; border - radius : 10 px ; background : # fff ; box - shadow : 0 2 px 8 px rgba ( 15 , 23 , 42 , .03 ); }
. review - section - heading { display : flex ; align - items : flex - start ; justify - content : space - between ; gap : 12 px ; margin - bottom : 14 px ; }
. review - section - heading > div { min - width : 0 ; }
. review - section - heading h3 { margin : 0 ; color : # 1 a2438 ; font - size : 14 px ; font - weight : 700 ; }
. review - section - heading span { display : block ; margin - top : 4 px ; color : # 8 a94a6 ; font - size : 11 px ; line - height : 1.4 ; }
. review - section - heading > svg { flex : 0 0 auto ; color : # 657 bea ; }
. review - info - grid { display : grid ; grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr )); gap : 10 px ; margin : 0 ; }
. review - info - grid > div { min - width : 0 ; padding : 10 px 11 px ; border - radius : 7 px ; background : # f7f9fc ; }
. review - info - grid dt { margin - bottom : 4 px ; color : # 8792 a5 ; font - size : 11 px ; }
. review - info - grid dd { min - width : 0 ; margin : 0 ; overflow : hidden ; color : # 28364 d ; font - size : 13 px ; font - weight : 600 ; text - overflow : ellipsis ; white - space : nowrap ; }
. review - document - table { overflow : hidden ; border : 1 px solid # e4e9f1 ; border - radius : 8 px ; }
. review - document - header ,. review - document - row { display : grid ; grid - template - columns : 118 px minmax ( 0 , 1 fr ) 138 px ; }
. review - document - header { background : # f5f7fb ; color : # 657188 ; font - size : 11 px ; font - weight : 700 ; }
. review - document - header > span ,. review - document - row > span { min - width : 0 ; padding : 9 px 10 px ; overflow - wrap : anywhere ; border - right : 1 px solid # e4e9f1 ; }
2026-08-12 16:31:46 +08:00
. review - document - header > span : last - child ,. review - document - row > span : last - child { border - right : 0 ; }
2026-08-17 10:54:11 +08:00
. review - document - row { min - height : 46 px ; border - top : 1 px solid # edf0f4 ; color : # 334155 ; font - size : 12 px ; }
. review - document - row > span { display : flex ; align - items : center ; }
. review - document - type { font - weight : 650 ; }
. review - document - name { min - width : 0 ; gap : 6 px ; overflow : hidden ; color : # 4 c5b72 ; text - overflow : ellipsis ; white - space : nowrap ; }
. review - document - name svg { flex : 0 0 auto ; color : # 8798 cf ; }
. review - document - actions { justify - content : flex - end ; gap : 6 px ; }
2026-08-13 11:57:44 +08:00
. review - document - actions : deep (. el - button + . el - button ) { margin - left : 0 ; }
. review - document - state ,. review - document - error ,. review - document - empty { padding : 14 px ; color : var ( -- cc - muted ); font - size : 12 px ; }
. review - document - error { display : flex ; align - items : center ; justify - content : space - between ; gap : 12 px ; margin - bottom : 8 px ; border : 1 px solid # fecdca ; border - radius : 6 px ; background : # fffafa ; color : # b42318 ; }
2026-08-17 10:54:11 +08:00
. review - document - empty { display : flex ; align - items : center ; justify - content : center ; gap : 7 px ; text - align : center ; }. review - document - empty svg { color : # a5afc1 ; }
. review - decision - card : deep (. el - form - item ) { margin - bottom : 0 ; }. review - decision - card : deep (. el - form - item__label ) { color : # 4 b5870 ; font - size : 12 px ; font - weight : 650 ; }. decision - summary > div > span { display : block ; margin - top : 4 px ; }. decision - type { margin : 0 0 14 px ; }. password - input - row { display : grid ; grid - template - columns : minmax ( 0 , 1 fr ) auto ; width : 100 % ; gap : 8 px ; }. dialog - hint { margin : 12 px 0 0 ; color : var ( -- cc - muted ); font - size : 12 px ; line - height : 1.6 ; }. archive - confirm { display : grid ; gap : 8 px ; margin : 16 px 0 ; }. archive - confirm strong { color : var ( -- cc - text ); }. archive - confirm span { color : var ( -- cc - muted ); font - size : 12 px ; }
2026-08-03 20:50:18 +08:00
. approval - result - warning { display : flex ; align - items : flex - start ; gap : 10 px ; padding : 12 px 14 px ; border : 1 px solid # fecdca ; border - radius : 8 px ; background : # fffafa ; color : # b42318 ; } . approval - result - warning strong ,. approval - result - warning span { display : block ; } . approval - result - warning strong { font - size : 14 px ; } . approval - result - warning span { margin - top : 4 px ; font - size : 12 px ; line - height : 1.5 ; } . approval - credentials { margin : 18 px 0 0 ; border : 1 px solid var ( -- cc - border ); border - radius : 8 px ; } . approval - credentials > div { display : grid ; grid - template - columns : 90 px minmax ( 0 , 1 fr ); gap : 12 px ; padding : 12 px 14 px ; } . approval - credentials > div + div { border - top : 1 px solid var ( -- cc - border ); } . approval - credentials dt { color : var ( -- cc - muted ); font - size : 12 px ; } . approval - credentials dd { display : flex ; min - width : 0 ; align - items : center ; gap : 10 px ; margin : 0 ; } . approval - credentials code { min - width : 0 ; flex : 1 ; overflow - wrap : anywhere ; color : var ( -- cc - text ); font - size : 14 px ; font - weight : 700 ; }
2026-08-17 10:54:11 +08:00
@ media ( max - width : 720 px ) { . customer - editor - footer ,. review - sync - error { align - items : stretch ; flex - direction : column ; } . customers - table - actions { display : grid ; grid - template - columns : 1 fr 1 fr ; width : 100 % ; } . customers - table - actions : deep (. el - button ) { min - height : 40 px ; margin : 0 ; } . qualification - table { overflow - x : auto ; } . qualification - row { min - width : 640 px ; } . customer - editor - footer > div { display : grid ; grid - template - columns : repeat ( auto - fit , minmax ( 110 px , 1 fr )); width : 100 % ; } . customer - editor - footer : deep (. el - button ) { min - height : 40 px ; margin : 0 ; } . decision - summary { align - items : flex - start ; flex - direction : column ; }. review - dialog - grid { grid - template - columns : 1 fr ; gap : 12 px ; }. review - info - grid { grid - template - columns : 1 fr 1 fr ; }. review - document - header ,. review - document - row { grid - template - columns : 104 px minmax ( 0 , 1 fr ) 124 px ; }. review - dialog - header { align - items : flex - start ; }. password - input - row { grid - template - columns : 1 fr ; }. table - actions : deep (. el - button ) { min - height : 40 px ; } }
@ media ( max - width : 480 px ) { : global (. review - decision - dialog . el - dialog__header ), : global (. review - decision - dialog . el - dialog__body ) { padding - left : 16 px ; padding - right : 16 px ; }. review - info - grid { grid - template - columns : 1 fr ; }. review - document - header { display : none ; }. review - document - row { grid - template - columns : 1 fr ; gap : 6 px ; padding : 10 px ; }. review - document - row > span { padding : 0 ; border - right : 0 ; }. review - document - actions { justify - content : flex - start ; }. review - dialog - title strong { max - width : 190 px ; } }
2026-08-03 14:58:17 +08:00
@ media ( max - width : 360 px ) { . customers - table - actions ,. capital - input { grid - template - columns : 1 fr ; } }
2026-08-01 12:17:33 +08:00
</ style >