2026-08-01 12:17:33 +08:00
< script setup lang = "ts" >
2026-10-20 23:34:11 +08:00
import '@/styles/adminWorkbench.css'
2026-10-20 15:48:23 +08:00
import InlineHelp from '@/shared/components/InlineHelp.vue'
2026-09-14 17:06:55 +08:00
import '@/styles/adminDensity.css'
import '@/modules/customers/customerDensity.css'
import { computed , nextTick , onBeforeUnmount , onMounted , reactive , ref , watch } from 'vue'
2026-08-04 13:32:08 +08:00
import { useRoute , useRouter } from 'vue-router'
2026-10-20 10:11:31 +08:00
import { CheckCircle2 , FileText , ShieldCheck , ShieldX } 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'
2026-09-18 16:48:40 +08:00
import RowActions , { type RowActionItem } from '@/shared/components/RowActions.vue'
2026-08-03 18:49:18 +08:00
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'
2026-10-20 10:11:31 +08:00
import { customerDialogTitle } from '../customerDialogTitle'
2026-08-10 16:24:54 +08:00
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-09-11 16:17:21 +08:00
{ label : '已停用' , value : 'DISABLED' },
2026-08-03 14:58:17 +08:00
] },
2026-08-01 12:17:33 +08:00
]
2026-09-14 15:33:35 +08:00
type CustomerForm = CreatePlatformTenantRequest & { customerNickname : string ; unifiedSocialCreditCode : string }
2026-08-05 09:11:48 +08:00
const emptyForm = () : CustomerForm => ({
2026-09-14 15:33:35 +08:00
tenantName : '' , customerNickname : '' , 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' }],
2026-09-14 15:33:35 +08:00
customerNickname : [
{ max : 32 , message : '客户昵称最多 32 个字符' , trigger : 'blur' },
{
validator : ( _rule , value , callback ) => {
if ( ! editingId . value || ( typeof value === 'string' && value . trim ())) callback ()
else callback ( new Error ( '请输入客户昵称' ))
},
trigger : [ 'blur' , 'change' ],
},
],
2026-08-03 14:58:17 +08:00
legalName : [{ required : true , message : '请输入营业执照上的企业名称' , trigger : 'blur' }],
2026-09-11 16:17:21 +08:00
legalRepresentative : [{ max : 64 , message : '法定代表人最多 64 个字符' , 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' },
2026-09-14 15:33:35 +08:00
{ key : 'customerNickname' , prop : 'customerNickname' , label : '客户昵称' , width : 120 , formatter : ( row : PlatformTenantResource ) => displayText ( row . customerNickname ) },
2026-08-12 14:57:02 +08:00
{ 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 ) },
2026-09-02 10:05:49 +08:00
{ 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-10-20 10:11:31 +08:00
const editorCustomer = computed (() => result . value . records . find ( row => String ( row . id ) === editingId . value ) || { tenantName : form . value . tenantName })
const editorTitle = computed (() => editingId . value ? customerDialogTitle ( '编辑客户' , editorCustomer . value ) : '新增客户' )
2026-09-14 17:06:55 +08:00
const editorDialogWidth = 'min(1120px, calc(100vw - 28px))'
2026-09-18 09:15:40 +08:00
const canUploadQualifications = computed (() => ! editorStatus . value
|| [ 'PROFILE_REQUIRED' , 'REJECTED' , 'ACTIVE' , 'DISABLED' ]. includes ( editorStatus . value ))
2026-08-03 14:58:17 +08:00
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-09-10 12:18:39 +08:00
editingId . value = null ; editorStatus . value = null ; resetEditor (); editorOpen . value = true
2026-08-03 14:58:17 +08:00
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-09-14 15:33:35 +08:00
tenantName : row . tenantName , customerNickname : row . customerNickname || '' , 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-09-02 10:04:47 +08:00
if ( ! requiredQualificationPackageComplete . value ) {
2026-08-04 21:01:39 +08:00
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-09-02 10:04:47 +08:00
const hadBusinessLicense = Boolean ( selectedBusinessLicense . value )
const hadLegalPersonId = Boolean ( selectedLegalPersonId . value )
2026-08-03 14:58:17 +08:00
let saved : PlatformTenantResource
if ( editingId . value ) {
const { unifiedSocialCreditCode : _immutable , ... payload } = form . value
2026-09-14 15:33:35 +08:00
saved = await updatePlatformTenant ( editingId . value , { ... payload , customerNickname : form . value . customerNickname . trim () })
2026-08-03 14:58:17 +08:00
} 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 )) : []
2026-09-18 09:15:40 +08:00
const canSubmitSavedCustomerForReview = [ 'PROFILE_REQUIRED' , 'REJECTED' ]. includes ( saved . status )
2026-08-03 17:44:54 +08:00
if ( failures . length ) {
ElMessage . warning ( `客户资料已保存,但 ${ failures . join ( '、' ) } 上传失败;失败文件已保留,请修正后再次保存` )
2026-09-18 09:15:40 +08:00
} else if ( canSubmitSavedCustomerForReview && hadBusinessLicense && hadLegalPersonId ) {
2026-09-02 10:04:47 +08:00
ElMessage . success ( '营业执照、统一社会信用代码和选填的法人身份证已保存,尚未提交审核' )
2026-09-18 09:15:40 +08:00
} else if ( canSubmitSavedCustomerForReview && hadBusinessLicense ) {
2026-09-02 10:04:47 +08:00
ElMessage . success ( '营业执照和统一社会信用代码已保存,法人身份证可按需补充,尚未提交审核' )
2026-09-18 09:15:40 +08:00
} else if ( canSubmitSavedCustomerForReview && hadSelectedQualifications ) {
2026-09-02 10:04:47 +08:00
ElMessage . success ( '客户资料和已选资质已保存,尚未提交审核' )
2026-09-18 09:15:40 +08:00
} else if ( hadSelectedQualifications ) {
ElMessage . success ( '客户资料和已选资质已保存' )
2026-08-03 17:44:54 +08:00
} else {
2026-09-14 10:27:47 +08:00
ElMessage . success ( '客户资料已保存' )
2026-08-03 17:44:54 +08:00
}
2026-09-14 15:38:29 +08:00
if ( ! failures . length ) editorOpen . value = false
2026-09-18 09:15:40 +08:00
await loadCustomers ()
2026-08-03 14:58:17 +08:00
} 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 || '' )) {
2026-09-02 10:04:47 +08:00
ElMessage . warning ( '请先完整保存营业执照和18位统一社会信用代码;法人身份证为选填项' )
2026-08-04 21:01:39 +08:00
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-10-20 23:34:11 +08:00
await ElMessageBox . confirm ( '提交后客户资料与资质将进入企业审核,审核完成前不能继续替换文件。确认提交吗?' , customerDialogTitle ( '提交企业审核' , editorCustomer . value ), { customClass : 'admin-density-confirm admin-workbench' , type : 'warning' , confirmButtonText : '确认提交' , cancelButtonText : '继续完善' })
2026-08-03 14:58:17 +08:00
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'
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 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
}
2026-09-18 16:48:40 +08:00
function customerRowActions ( row : PlatformTenantResource ) : RowActionItem [] {
return [
{ key : 'detail' , label : '详情' , priority : 500 , visible : access . can ( 'platform:customer:list' ) },
{ key : 'edit' , label : '编辑' , priority : 400 , visible : access . can ( 'platform:customer:edit' ) && row . status !== 'ARCHIVED' },
{ key : 'review' , label : '审核' , priority : 300 , tone : 'warning' , visible : hasReviewAction ( row ), disabled : reviewSubmitting . value },
... customerSecondaryActions ( row ). map (( action , index ) : RowActionItem => ({
key : action ,
label : customerActionLabel ( row , action ),
priority : 200 - index ,
tone : customerActionType ( row , action ),
disabled : customerActionDisabled ( action ),
divided : action === 'delete' ,
})),
]
}
function handleCustomerRowAction ( row : PlatformTenantResource , command : string ) {
if ( command === 'detail' ) showDetail ( row )
else if ( command === 'edit' ) openEdit ( row )
else if ( command === 'review' ) openDecision ( row , access . can ( 'platform:customer:approve' ) ? 'approve' : 'reject' )
else handleMoreAction ( row , command )
}
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 {
2026-10-20 23:34:11 +08:00
await ElMessageBox . confirm ( `确认 ${ verb } 客户“ ${ row . tenantName } ”吗?` , customerDialogTitle ( ` ${ verb } 客户` , row ), { customClass : 'admin-density-confirm admin-workbench' , type : 'warning' , confirmButtonText : `确认 ${ verb } ` , cancelButtonText : '取消' })
2026-08-03 14:58:17 +08:00
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
}
2026-09-02 20:49:19 +08:00
let targetUrl : URL
try {
targetUrl = new URL ( clientUrl , window . location . origin )
targetUrl . pathname = ` ${ targetUrl . pathname . replace ( /\/+$/ , '' ) } /login`
targetUrl . search = ''
targetUrl . hash = ''
targetUrl . searchParams . set ( 'impersonationOrigin' , window . location . origin )
} catch {
2026-08-08 10:14:07 +08:00
impersonationError . value = getApiErrorDetails ( new Error ( '客户工作台地址无效' ), '客户工作台地址配置无效,无法安全跳转登录' )
return
}
2026-09-02 20:49:19 +08:00
const targetOrigin = targetUrl . origin
const popup = window . open ( targetUrl . toString (), '_blank' )
2026-08-08 10:14:07 +08:00
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 () })
2026-09-02 20:49:19 +08:00
let ticketSent = false
const onMessage = ( event : MessageEvent ) => {
if ( event . source !== popup || event . origin !== targetOrigin || event . data ? . source !== 'chongchong-client' ) return
if ( event . data ? . type === 'impersonation-ready' && ! ticketSent ) {
ticketSent = true
popup . postMessage ({ source : 'chongchong-admin' , type : 'impersonation-ticket' , code : ticket . code }, targetOrigin )
return
}
if ( event . data ? . type === 'impersonation-failed' ) {
cleanup ()
impersonationSubmitting . value = false
impersonationError . value = getApiErrorDetails ( new Error ( '客户工作台未写入代登录会话' ), '客户工作台登录写入失败,请关闭窗口后重试' )
return
}
if ( event . data ? . type !== 'impersonation-complete' ) return
2026-08-08 10:14:07 +08:00
cleanup ()
impersonationSubmitting . value = false
2026-09-02 20:49:19 +08:00
ElMessage . success ( '客户工作台登录成功' )
2026-08-08 10:14:07 +08:00
impersonationDialogOpen . value = false
}
const timeout = window . setTimeout (() => {
cleanup ()
impersonationSubmitting . value = false
2026-09-02 20:49:19 +08:00
impersonationError . value = getApiErrorDetails ( new Error ( '客户工作台未确认会话写入' ), '客户工作台未确认登录成功,请关闭窗口后重试' )
}, 15000 )
2026-08-08 10:14:07 +08:00
const cleanup = () => {
window . clearTimeout ( timeout )
2026-09-02 20:49:19 +08:00
window . removeEventListener ( 'message' , onMessage )
2026-08-08 10:14:07 +08:00
if ( impersonationCleanup === cleanup ) impersonationCleanup = null
}
impersonationCleanup ? .()
impersonationCleanup = cleanup
2026-09-02 20:49:19 +08:00
window . addEventListener ( 'message' , onMessage )
2026-08-08 10:14:07 +08:00
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
2026-09-02 10:04:47 +08:00
decision . comment = type === 'approve' ? '企业资料、营业执照和统一社会信用代码完整、清晰,审核通过(法人身份证为选填项)' : ''
2026-08-03 20:50:18 +08:00
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
2026-09-02 10:04:47 +08:00
decision . comment = type === 'approve' ? '企业资料、营业执照和统一社会信用代码完整、清晰,审核通过(法人身份证为选填项)' : ''
2026-08-08 10:14:07 +08:00
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-10-20 23:34:11 +08:00
< AdminPage as = "div" class = "customers-page admin-density customer-density admin-workbench" >
2026-08-18 09:09:34 +08:00
< OperationErrorToast : details = "passwordError || impersonationError || archiveError" />
2026-08-10 16:24:54 +08:00
< ListPageLayout aria -label =" 客户管理 " >
2026-09-30 14:01:58 +08:00
2026-10-21 13:22:42 +08:00
< section class = "workbench-table is-page-surface" :aria-busy = "loading" >< DataTable appearance = "integrated" column -settings-mode =" show " :results-visible = "!loadError" 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" border >
2026-09-30 14:01:58 +08:00
< template # toolbar -start >< h1 > 客户列表 </ h1 ></ template >
< template # filters >< QueryToolbar v-model = "filters" ariaLabel="客户筛选" :fields="queryFields" preference-key="customers-query" @search="applyFilters" /></ template >
< template # notices >< template v-if = "loadError" >
2026-08-10 16:24:54 +08:00
< LoadErrorState title = "客户列表暂时无法加载" :message = "loadError.message" :loading = "loading" @retry ="loadCustomers" >
< template # details >< ApiErrorMeta :details = "loadError" /></ template >
</ LoadErrorState >
2026-09-30 14:01:58 +08:00
</ template ></ template >
< template # before -results >< 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 ></ template >
2026-09-14 17:06:55 +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">导出Excel</el-button><el-button v-if="access.can('platform:customer:create')" type="primary" :disabled="loading" @click="openCreate" > 新增客户 </ el -button ></ div ></ template >
2026-09-30 14:01:58 +08:00
< template # actions = "{ row }" >< RowActions text -actions class = "density-action" : actions = "customerRowActions(row as PlatformTenantResource)" : more -label =" customerMoreActionsLabel ( row as PlatformTenantResource )" @action ="handleCustomerRowAction(row as PlatformTenantResource, $event)" /></ 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-09-30 14:01:58 +08:00
</ DataTable ></ section >
2026-08-10 16:24:54 +08:00
</ ListPageLayout >
2026-08-03 14:58:17 +08:00
2026-10-20 23:34:11 +08:00
< AppDialog v-model = "editorOpen" :title="editorTitle" :width="editorDialogWidth" class="admin-density-dialog customer-density-dialog customer-editor-dialog admin-workbench is-dialog" :loading="editorLoading" :confirm-loading="saving" fullscreenable destroy -on-close >
2026-10-21 07:40:08 +08:00
< el-form ref = "formRef" v-loading = "editorLoading" :model="form" :rules="rules" label-position="left" label-width="120px" class="density-form density-dialog-sections" :disabled="editorLoading || saving || submitting || uploadingQualifications" >
2026-10-20 23:34:11 +08:00
< section class = "density-dialog-section workbench-evidence" data -tone =" work " >< h3 class = "density-dialog-section__title" > 基础资料 </ h3 >
2026-10-20 10:11:31 +08:00
< table class = "density-table density-table--editable customer-editor-table" aria -label =" 基础资料 " >< tbody >
2026-10-21 07:40:08 +08:00
< tr >< td >< el-form-item label = "公司名称" prop = "tenantName" required >< el-input v-model = "form.tenantName" placeholder="请输入工商登记公司名称" maxlength="128" /></el-form-item></td><td><el-form-item label="客户昵称" prop="customerNickname"><el-input v-model="form.customerNickname" placeholder="不填默认生成中文拼音大写首字母,如 SZWL" maxlength="32" /></el-form-item></td><td><el-form-item label="营业执照企业名称" prop="legalName" required><el-input v-model="form.legalName" placeholder="请输入营业执照上的企业名称" maxlength="128" /></ el-form-item ></ td ></ tr >
< tr >< td >< el-form-item label = "法定代表人" prop = "legalRepresentative" >< el-input v-model = "form.legalRepresentative" placeholder="请输入法定代表人姓名(选填)" maxlength="64" /></el-form-item></td><td><el-form-item label="企业类型" prop="enterpriseType"><el-input v-model="form.enterpriseType" placeholder="如:有限责任公司" maxlength="64" /></el-form-item></td><td><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 ></ td ></ tr >
< tr >< td colspan = "3" >< el-form-item label = "注册资本" prop = "registeredCapital" >< div class = "capital-input" >< el-input-number v-model = "form.registeredCapital" :min="0" :precision="2" controls-position="right" placeholder="请输入注册资本(选填)" /><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 ></ td ></ tr >
< tr >< td colspan = "3" >< el-form-item label = "注册地址" prop = "registeredAddress" >< el-input v-model = "form.registeredAddress" placeholder="请输入省市区、街道及门牌信息" maxlength="500" /></ el-form-item ></ td ></ tr >
2026-10-20 10:11:31 +08:00
</ tbody ></ table >
</ section >
2026-08-03 14:58:17 +08:00
2026-10-20 23:34:11 +08:00
< section class = "density-dialog-section workbench-evidence" data -tone =" work " >< h3 class = "density-dialog-section__title" > 联系与商务 < InlineHelp title = "企业联系信息说明" >< p > 仅用于企业业务联系 , 不会修改企业管理员登录手机号 ; 登录手机号请在成员 / 账号管理中单独维护 , 修改会影响登录 。 </ p ></ InlineHelp ></ h3 >
2026-10-20 10:11:31 +08:00
< table class = "density-table density-table--editable customer-editor-table" aria -label =" 联系与商务 " >< tbody >
2026-10-21 07:40:08 +08:00
< tr >< td >< el-form-item label = "联系人" prop = "contactName" required >< el-input v-model = "form.contactName" placeholder="请输入主要联系人姓名" maxlength="64" /></el-form-item></td><td><el-form-item label="公司联系电话" prop="contactPhone" required><el-input v-model="form.contactPhone" placeholder="请输入公司联系电话" maxlength="32" /></el-form-item></td><td><el-form-item label="联系邮箱" prop="contactEmail"><el-input v-model="form.contactEmail" placeholder="如:contact@example.com" maxlength="128" /></ el-form-item ></ td ></ tr >
< tr >< td >< el-form-item label = "联系微信" prop = "contactWechat" >< el-input v-model = "form.contactWechat" placeholder="请输入联系人微信号" maxlength="64" /></el-form-item></td><td><el-form-item label="销售人员" prop="salesOwnerName"><el-input v-model="form.salesOwnerName" placeholder="请输入负责销售人员" maxlength="64" /></el-form-item></td><td><el-form-item label="账期(天)" prop="paymentTermDays" required><el-input-number v-model="form.paymentTermDays" :min="0" :max="365" controls-position="right" placeholder="请输入账期天数" /></ el-form-item ></ td ></ tr >
< tr >< td colspan = "3" >< 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 ></ td ></ tr >
2026-10-20 10:11:31 +08:00
</ tbody ></ table >
</ section >
2026-08-03 14:58:17 +08:00
2026-10-20 23:34:11 +08:00
< section class = "density-dialog-section qualification-section workbench-evidence" data -tone =" work " >< h3 class = "density-dialog-section__title" > 企业认证资料 < InlineHelp title = "资质提交规则" >< p > 营业执照和18位统一社会信用代码为必填业务项 ; 法人身份证 、 货代资质证书和银行开户许可证均为选填 。 </ p >< p > 支持 PDF 、 JPG 、 PNG , 单个文件不超过 5 MB ; < span > * </ span > 为提交审核必填项 。 </ p ></ InlineHelp ></ h3 >< div v-if = "editorStatus" class="qualification-heading"><el-tag :type="statusMeta(editorStatus).type" effect="light" >{{ statusMeta ( editorStatus ) .label }} </ el -tag ></ div >
2026-10-21 07:40:08 +08:00
< table class = "density-table density-table--editable" aria -label =" 企业信用代码 " >< tbody >< tr >< td >< el-form-item label = "统一社会信用代码" prop = "unifiedSocialCreditCode" label -width =" 144px " required class = "qualification-credit-code" >< span v-if = "creditCodeEstablished">{{ form.unifiedSocialCreditCode }}</span><el-input v-else v-model="form.unifiedSocialCreditCode" placeholder="请输入18位大写字母或数字" maxlength="18" @input="form.unifiedSocialCreditCode = String($event).toUpperCase()" />< small >{{ creditCodeEstablished ? '已登记的统一社会信用代码不可直接修改。' : '必须与营业执照一致,并随营业执照共同保存;法人身份证为选填。' }}</ small ></ el-form-item ></ td ></ tr ></ tbody ></ table >
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 >
2026-09-14 17:06:55 +08:00
< span class = "qualification-preview-action" >{{ draft . previewLoading ? '读取中' : ( draft . previewFailed ? '重试' : '查看' ) }}</ span >
2026-08-03 17:44:54 +08:00
</ button >
2026-09-14 17:06:55 +08:00
< div class = "qualification-file-control" >< label class = "file-picker" : class = "{ 'is-disabled': !canUploadQualifications || draft.uploading }" >< 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 >
2026-08-03 17:44:54 +08:00
</ div >
2026-09-18 09:15:40 +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.uploading || (draft.uploaded && !draft.file)" /></ div >
2026-08-03 14:58:17 +08:00
</ div >
</ div >
2026-10-20 15:48:27 +08:00
2026-10-20 10:11:31 +08:00
</ section >
2026-08-03 14:58:17 +08:00
</ el-form >
2026-10-20 10:11:31 +08:00
< template # footer >< div class = "customer-editor-footer" >< span > 保存资料时会一并上传已选资质 ; 进入审核仍需点击 “ 提交审核 ” 并再次确认 </ span >< div class = "density-actions density-actions--footer" >< el-button : disabled = "editorLoading || saving || submitting || uploadingQualifications" @click ="editorOpen = false" > 关闭 </ el -button >< el-button :loading = "saving" : disabled = "editorLoading || submitting || uploadingQualifications" @click ="saveCustomer" > 保存资料和资质 </ 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-10-20 23:34:11 +08:00
< AppDialog v-model = "qualificationPreviewOpen" :title="customerDialogTitle('查看资质文件', editorCustomer)" width="min(860px, calc(100vw - 28px))" class="admin-density-dialog customer-density-dialog qualification-preview-dialog customer-qualification-preview admin-workbench is-dialog" fullscreenable destroy-on-close :show-confirm="false" cancel-text="关闭预览" >
2026-10-20 10:11:31 +08:00
< p class = "density-dialog-note customer-preview-filename" >{{ qualificationPreviewName }}</ p >
< div class = "density-dialog-section qualification-preview" data -tone =" source " >
2026-08-03 17:44:54 +08:00
< 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-10-20 23:34:11 +08:00
< AppDialog v-model = "approvalResultOpen" :title="customerDialogTitle(credentialSource === 'creation' ? '客户已创建,管理员账号已生成' : '审核通过,管理员账号已创建', credentialSource === 'creation' ? editorCustomer : selectedReview)" width="min(560px, calc(100vw - 28px))" class="admin-density-dialog customer-density-dialog approval-result-dialog admin-workbench is-dialog" :close-on-click-modal="false" :close-on-press-escape="false" :show-close="false" >
2026-10-20 10:11:31 +08:00
< div v-if = "approvalResult" class="density-dialog-sections" >
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-10-20 23:34:11 +08:00
< section class = "density-dialog-section workbench-evidence" data -tone =" summary " >< table class = "density-table" aria -label =" 一次性管理员凭据 " >< tbody >
2026-10-20 10:11:31 +08:00
< tr >< th scope = "row" > 登录账号 </ th >< td >< div class = "customer-credential-value" >< code >{{ approvalResult . administratorUsername || '未返回' }}</ code >< CopyButton v-if = "approvalResult.administratorUsername" :value="approvalResult.administratorUsername" label="登录账号" size="small" /></ div ></ td ></ tr >
< tr >< th scope = "row" > 临时密码 </ th >< td >< div class = "customer-credential-value" >< code >{{ approvalResult . temporaryPassword || '未返回' }}</ code >< CopyButton v-if = "approvalResult.temporaryPassword" :value="approvalResult.temporaryPassword" label="临时密码" size="small" /></ div ></ td ></ tr >
</ tbody ></ table ></ section >
2026-08-03 20:50:18 +08:00
</ div >
< template # footer >< el-button type = "primary" @click ="closeApprovalResult" > 我已保存 , 关闭 </ el -button ></ template >
2026-10-20 10:11:31 +08:00
</ AppDialog >
2026-08-03 20:50:18 +08:00
2026-10-20 23:34:11 +08:00
< AppDialog v-model = "decisionOpen" :title="customerDialogTitle('客户入驻审核', selectedReview)" fullscreenable :confirm-loading="reviewSubmitting" :lock-while-submitting="false" width="min(1040px, calc(100vw - 28px))" class="admin-density-dialog customer-density-dialog review-decision-dialog admin-workbench is-dialog" :close-on-click-modal="false" >
2026-10-20 10:11:31 +08:00
< div v-if = "selectedReview" class="density-dialog-sections" >
2026-10-20 23:34:11 +08:00
< section class = "density-dialog-section workbench-evidence" data -tone =" source " >
2026-10-20 10:11:31 +08:00
< h3 class = "density-dialog-section__title" > 企业基本信息 < el-tag type = "warning" effect = "plain" > 待审核 </ el-tag ></ h3 >
< table class = "density-table customer-review-table" aria -label =" 待审核企业资料 " >< tbody >
< tr >< th scope = "row" > 客户名称 </ th >< td >{{ selectedReview . tenantName || '未填写' }}</ td >< th scope = "row" > 客户编号 </ th >< td >{{ selectedReview . tenantCode || '未生成' }}</ td ></ tr >
< tr >< th scope = "row" > 联系人 </ th >< td >{{ selectedReview . contactName || '未填写' }}</ td >< th scope = "row" > 公司联系电话 </ th >< td >{{ selectedReview . contactPhone || '未填写' }}</ td ></ tr >
< tr >< th scope = "row" > 联系邮箱 </ th >< td >{{ selectedReview . contactEmail || '未填写' }}</ td >< th scope = "row" > 提交时间 </ th >< td >{{ formatDate ( selectedReview . submittedAt || selectedReview . createdAt ) }}</ td ></ tr >
</ tbody ></ table >
</ section >
2026-10-20 23:34:11 +08:00
< section class = "density-dialog-section workbench-evidence" data -tone =" source " >
2026-10-20 10:11:31 +08:00
< h3 class = "density-dialog-section__title" > 资质文件 </ h3 >
< p class = "density-dialog-note" > 营业执照为必填 ; 法人身份证及其他资质为选填 。 共 {{ reviewQualifications . length }} 份 </ p >
< 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 >
< table v-else class = "density-table customer-review-documents" aria -label =" 待审核客户资质 " >
< thead >< tr >< th > 资质类型 </ th >< th > 已上传文件 </ th >< th > 操作 </ th ></ tr ></ thead >
< tbody >< tr v-for = "item in reviewQualifications" :key="String(item.id)"><td>{{ qualificationLabel(item.qualificationType) }}</td><td :title="qualificationName(item)">{{ qualificationName(item) }}</td><td><div class="review-document-actions density-actions"><el-button class="density-action" data-tone="primary" :loading="reviewDocumentLoading === String(item.id)" @click="loadReviewDocument(item, false)">预览</el-button><el-button class="density-action" :disabled="Boolean(reviewDocumentLoading)" @click="loadReviewDocument(item, true)" > 下载 </ el -button ></ div ></ td ></ tr >
< tr v-if = "reviewQualifications.length === 0"><td colspan="3" > 暂无资质文件 </ td ></ tr ></ tbody >
</ table >
</ section >
< section class = "density-dialog-section" data -tone =" work " >
< h3 class = "density-dialog-section__title" > 审核决定 </ h3 >
2026-08-17 10:54:11 +08:00
< 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 >
2026-10-20 10:11:31 +08:00
< el-form ref = "decisionFormRef" :model = "decision" :rules = "decisionRules" label -position =" top " class = "density-form density-form--flush-end" >
2026-08-17 10:54:11 +08:00
< 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-10-20 10:11:31 +08:00
< template # footer >< div class = "density-actions density-actions--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-13 11:57:44 +08:00
</ AppDialog >
2026-10-20 23:34:11 +08:00
< AppDialog class = "admin-density-dialog customer-density-dialog customer-qualification-preview admin-workbench is-dialog" v-model = "reviewPreviewOpen" :title="customerDialogTitle('查看审核资质', selectedReview)" fullscreenable width="min(860px, calc(100vw - 28px))" destroy-on-close :show-confirm="false" cancel-text="关闭预览" @closed="clearReviewPreview" >
2026-10-20 10:11:31 +08:00
< p class = "density-dialog-note customer-preview-filename" >{{ reviewPreviewName }}</ p >
< div class = "density-dialog-section qualification-preview" data -tone =" source " >< img v-if = "reviewPreviewUrl && reviewPreviewKind === 'image'" :src="reviewPreviewUrl" :alt="`${reviewPreviewName}预览`" /><iframe v-else-if="reviewPreviewUrl" :src="reviewPreviewUrl" :title="`${reviewPreviewName} PDF 预览`" /></ div >
</ AppDialog >
2026-10-20 23:34:11 +08:00
< AppDialog v-model = "passwordDialogOpen" :title="customerDialogTitle('重置客户密码', passwordTenant)" :confirm-loading="passwordSubmitting" width="min(520px, calc(100vw - 28px))" class="admin-density-dialog customer-density-dialog customer-password-dialog admin-workbench is-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-10-20 10:11:31 +08:00
< el-form ref = "passwordFormRef" :model = "passwordForm" :rules = "passwordRules" label -position =" top " class = "density-form" >
2026-08-08 10:14:07 +08:00
< 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 >
2026-10-20 10:11:31 +08:00
< p class = "density-dialog-note" > 重置对象为企业 OWNER 管理员 , 原密码将立即失效 。 </ p >
2026-08-08 10:14:07 +08:00
</ el-form >
2026-10-20 10:11:31 +08:00
< template # footer >< div class = "density-actions density-actions--footer" >< el-button :disabled = "passwordSubmitting" @click ="passwordDialogOpen = false" > 取消 </ el -button >< el-button type = "primary" :loading = "passwordSubmitting" @click ="submitResetPassword" > 确认重置 </ el -button ></ div ></ template >
</ AppDialog >
2026-08-08 10:14:07 +08:00
2026-10-20 23:34:11 +08:00
< AppDialog class = "admin-density-dialog customer-density-dialog admin-workbench is-dialog" v-model = "impersonationDialogOpen" :title="customerDialogTitle('跳转登录客户工作台', impersonationTenant)" :confirm-loading="impersonationSubmitting" 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 >
2026-10-20 10:11:31 +08:00
< el-form ref = "impersonationFormRef" :model = "impersonationForm" :rules = "impersonationRules" label -position =" top " class = "density-form" >< 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 = "density-actions density-actions--footer" >< el-button :disabled = "impersonationSubmitting" @click ="impersonationDialogOpen = false" > 取消 </ el -button >< el-button type = "primary" :loading = "impersonationSubmitting" @click ="submitImpersonation" > 确认跳转 </ el -button ></ div ></ template >
</ AppDialog >
2026-08-08 10:14:07 +08:00
2026-10-20 23:34:11 +08:00
< AppDialog class = "admin-density-dialog customer-density-dialog admin-workbench is-dialog" v-model = "archiveDialogOpen" :title="customerDialogTitle('删除客户', archiveTenant)" :confirm-loading="archiveSubmitting" width="min(560px, calc(100vw - 28px))" :close-on-click-modal="false" :close-on-press-escape="!archiveSubmitting" :show-close="!archiveSubmitting" >
2026-08-08 10:14:07 +08:00
< 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 >
2026-10-20 10:11:31 +08:00
< template # footer >< div class = "density-actions density-actions--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 >
</ AppDialog >
2026-08-10 16:24:54 +08:00
</ AdminPage >
2026-08-01 12:17:33 +08:00
</ template >
< style scoped >
2026-10-20 23:34:11 +08:00
. customers - table - heading { display : flex ; min - width : 0 ; align - items : baseline ; gap : var ( -- admin - space - 4 , 10 px ); } . customers - table - heading h2 { margin : 0 ; color : var ( -- workbench - text ); font - size : var ( -- admin - font - title , 16 px ); } . customers - table - heading span { color : var ( -- workbench - muted ); font - size : var ( -- admin - font - caption , 12 px ); white - space : nowrap ; }
2026-09-14 17:54:21 +08:00
. customers - table - actions { display : flex ; flex - wrap : wrap ; gap : var ( -- admin - space - 3 , 8 px ); } . customers - table - actions : deep (. el - button + . el - button ) { margin - left : 0 ; }
. table - actions { display : flex ; flex - wrap : wrap ; align - items : center ; } . table - actions : deep (. el - button ) { margin - left : 0 ; }
: global (. customer - action - menu . el - dropdown - menu__item ) { min - height : 40 px ; gap : var ( -- admin - space - 3 , 8 px ); }
2026-08-08 11:21:56 +08:00
: global (. customer - action - menu . customer - action - menu__danger ) { color : var ( -- el - color - danger ); }
2026-09-14 17:54:21 +08:00
. review - sync - error { display : flex ; min - width : 0 ; align - items : center ; justify - content : space - between ; gap : var ( -- admin - space - 5 , 12 px ); padding : var ( -- admin - space - 4 , 10 px ) 18 px ; border - bottom : 1 px solid # fecdca ; background : # fffafa ; color : # b42318 ; font - size : var ( -- admin - font - caption , 12 px ); } . review - sync - error > div { display : flex ; min - width : 0 ; align - items : center ; gap : var ( -- admin - space - 3 , 8 px ); } . review - sync - error span { overflow - wrap : anywhere ; }
2026-10-20 23:34:11 +08:00
. customer - company - name { display : block ; overflow : hidden ; color : var ( -- workbench - text ); font - weight : 500 ; text - overflow : ellipsis ; white - space : nowrap ; }
2026-08-12 14:57:02 +08:00
: deep (. customer - list - table . customer - code - column . cell ) { white - space : nowrap ; }
2026-09-14 17:54:21 +08:00
. capital - input { display : grid ; grid - template - columns : minmax ( 0 , 1 fr ) 130 px ; width : 100 % ; gap : var ( -- admin - space - 3 , 8 px ); } . capital - input : deep (. el - input - number ) { width : 100 % ; } . capital - input : deep (. el - select ) { width : 100 % ; }
2026-10-20 23:34:11 +08:00
. qualification - heading { display : flex ; justify - content : flex - end ; margin - bottom : var ( -- admin - space - 5 , 12 px ); } . qualification - hint { margin : var ( -- admin - space - 5 , 12 px ) 0 0 ; color : var ( -- workbench - muted ); font - size : var ( -- admin - font - caption , 12 px ); line - height : 1.6 ; } . qualification - hint span { color : var ( -- el - color - danger ); } . qualification - label > span { color : var ( -- el - color - danger ); }
. qualification - credit - code small { display : block ; margin - top : var ( -- admin - space - 2 , 6 px ); color : var ( -- workbench - muted ); font - size : var ( -- admin - font - caption , 11 px ); line - height : 1.5 ; } . field - hint { display : block ; margin - top : var ( -- admin - space - 2 , 6 px ); color : var ( -- workbench - muted ); line - height : 1.5 ; }
2026-10-21 13:22:42 +08:00
. qualification - table { container - type : inline - size ; overflow : hidden ; margin - top : 14 px ; border : 1 px solid var ( -- workbench - 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 ( -- workbench - border ); } . qualification - row : first - child { border - top : 0 ; } . qualification - row > * { display : flex ; min - width : 0 ; align - items : center ; } . qualification - header { min - height : 44 px ; background : var ( -- cc - list - table - header - bg ); color : var ( -- cc - list - table - header - text ); font - size : var ( -- admin - font - caption , 12 px ); font - weight : 600 ; }
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-10-20 23:34:11 +08:00
. qualification - optional { padding : 2 px 5 px ; border - radius : var ( -- admin - radius , 4 px ); background : var ( -- el - fill - color - light ); color : var ( -- workbench - muted ); font - size : var ( -- admin - font - caption , 10 px ); font - weight : 500 ; line - height : 1.4 ; }
. qualification - file { gap : var ( -- admin - space - 5 , 12 px ); } . qualification - file - control { display : flex ; min - width : 0 ; flex : 1 ; flex - wrap : wrap ; align - items : center ; gap : var ( -- admin - space - 3 , 8 px ); } . file - picker { position : relative ; display : inline - flex ; flex : 0 0 auto ; align - items : center ; gap : 7 px ; overflow : hidden ; padding : 0 var ( -- admin - space - 5 , 12 px ); border : 1 px solid var ( -- workbench - border ); border - radius : 6 px ; background : var ( -- workbench - surface ); color : var ( -- workbench - text ); cursor : pointer ; } . file - picker : focus - within { outline : 2 px solid color - mix ( in srgb , var ( -- workbench - 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 ; overflow : hidden ; color : var ( -- workbench - muted ); font - size : var ( -- admin - font - caption , 12 px ); text - overflow : ellipsis ; } . qualification - validity : deep (. el - date - editor ) { width : 100 % ; min - width : 0 ; } . qualification - error { width : 100 % ; color : var ( -- el - color - danger ); line - height : 1.4 ; }
. 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 ( -- workbench - border ); border - radius : 6 px ; background : var ( -- workbench - surface - soft ); color : var ( -- workbench - muted ); cursor : pointer ; }
. qualification - preview - trigger : hover { border - color : var ( -- workbench - primary ); color : var ( -- workbench - primary ); }
. qualification - preview - trigger : focus - visible { outline : 2 px solid color - mix ( in srgb , var ( -- workbench - primary ) 42 % , transparent ); outline - offset : 2 px ; }
2026-08-03 17:44:54 +08:00
. qualification - preview - trigger : disabled { cursor : wait ; opacity : .72 ; }
2026-09-18 09:15:40 +08:00
@ container ( max - width : 660 px ) {
. qualification - row { grid - template - columns : 112 px minmax ( 0 , 1 fr ); }
. qualification - header { display : none ; }
. qualification - label { grid - row : span 2 ; align - items : center ; }
. qualification - file ,. qualification - validity { min - height : 44 px ; }
}
2026-10-20 23:34:11 +08:00
. qualification - preview - trigger img { width : 100 % ; height : 100 % ; object - fit : contain ; background : var ( -- workbench - surface ); }
2026-08-03 17:44:54 +08:00
. qualification - preview - placeholder { display : grid ; place - items : center ; gap : 2 px ; }
2026-09-14 17:54:21 +08:00
. qualification - preview - placeholder small { font - size : var ( -- admin - font - caption , 10 px ); font - weight : 700 ; }
2026-10-20 23:34:11 +08:00
. 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 : var ( -- workbench - surface ); }
2026-09-14 17:54:21 +08:00
. qualification - load - error { display : flex ; align - items : center ; justify - content : space - between ; gap : var ( -- admin - space - 4 , 10 px ); margin - top : var ( -- admin - space - 5 , 12 px ); padding : var ( -- admin - space - 4 , 10 px ) var ( -- admin - space - 5 , 12 px ); border : 1 px solid # fecdca ; border - radius : 6 px ; background : # fffafa ; color : # b42318 ; font - size : var ( -- admin - font - caption , 12 px ); }
2026-10-20 23:34:11 +08:00
. customer - editor - footer { display : flex ; align - items : center ; justify - content : space - between ; gap : var ( -- admin - space - 6 , 16 px ); } . customer - editor - footer > span { color : var ( -- workbench - muted ); font - size : var ( -- admin - font - caption , 12 px ); text - align : left ; }
. decision - summary { display : flex ; min - width : 0 ; align - items : center ; justify - content : space - between ; gap : var ( -- admin - space - 5 , 12 px ); background : var ( -- workbench - surface - soft ); } . decision - summary > * { min - width : 0 ; overflow - wrap : anywhere ; } . decision - summary strong { color : var ( -- workbench - text ); font - size : var ( -- admin - font - body , 13 px ); } . decision - summary span { color : var ( -- workbench - muted ); font - size : var ( -- admin - font - caption , 11 px ); }
. review - document - state { padding : 14 px ; color : var ( -- workbench - muted ); font - size : var ( -- admin - font - caption , 12 px ); }
. review - document - error { padding : 14 px ; color : var ( -- workbench - muted ); font - size : var ( -- admin - font - caption , 12 px ); }
. review - document - empty { padding : 14 px ; color : var ( -- workbench - muted ); font - size : var ( -- admin - font - caption , 12 px ); }
2026-09-14 17:54:21 +08:00
. review - document - error { display : flex ; align - items : center ; justify - content : space - between ; gap : var ( -- admin - space - 5 , 12 px ); margin - bottom : var ( -- admin - space - 3 , 8 px ); border : 1 px solid # fecdca ; border - radius : 6 px ; background : # fffafa ; color : # b42318 ; }
2026-10-20 23:34: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 : var ( -- workbench - muted ); }
. decision - summary > div > span { display : block ; margin - top : var ( -- admin - space - 1 , 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 : var ( -- admin - space - 3 , 8 px ); }. archive - confirm { display : grid ; gap : var ( -- admin - space - 3 , 8 px ); margin : var ( -- admin - space - 6 , 16 px ) 0 ; }. archive - confirm strong { color : var ( -- workbench - text ); }. archive - confirm span { color : var ( -- workbench - muted ); font - size : var ( -- admin - font - caption , 12 px ); }
2026-10-20 10:11:31 +08:00
. approval - result - warning { display : flex ; align - items : flex - start ; gap : var ( -- admin - space - 4 , 10 px ); padding : var ( -- admin - space - 5 , 12 px ) 14 px ; border : 1 px solid # fecdca ; border - radius : 8 px ; background : # fffafa ; color : # b42318 ; } . approval - result - warning strong { display : block ; } . approval - result - warning span { display : block ; } . approval - result - warning strong { font - size : var ( -- admin - font - section , 14 px ); } . approval - result - warning span { margin - top : var ( -- admin - space - 1 , 4 px ); font - size : var ( -- admin - font - caption , 12 px ); line - height : 1.5 ; }
@ 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 - document - header ,. 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 ; } }
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 >