2026-08-01 12:17:33 +08:00
< script setup lang = "ts" >
2026-08-03 14:58:17 +08:00
import { computed , nextTick , onMounted , ref , watch } from 'vue'
import { Building2 , CheckCircle2 , Download , Eye , FileUp , Pencil , Plus , Power } from '@lucide/vue'
import { ElMessage , ElMessageBox , type FormInstance , type FormRules } from 'element-plus'
import { getApiErrorDetails , getApiErrorMessage , type ApiErrorDetails } from '@/api/http'
2026-08-01 12:17:33 +08:00
import {
2026-08-03 14:58:17 +08:00
changePlatformTenantStatus ,
createPlatformTenant ,
getPlatformTenant ,
listPlatformTenantOrderCounts ,
2026-08-01 12:17:33 +08:00
listPlatformTenants ,
2026-08-03 14:58:17 +08:00
listTenantQualifications ,
submitPlatformTenantReview ,
updatePlatformTenant ,
uploadPlatformTenantQualification ,
type CreatePlatformTenantRequest ,
type PlatformQualificationType ,
type PlatformTenantQualification ,
2026-08-01 12:17:33 +08:00
type PlatformTenantResource ,
type PlatformTenantStatus ,
} from '@/api/platformIam'
import ApiErrorMeta from '@/components/ApiErrorMeta.vue'
import DataTable , { type DataTableColumn } from '@/components/common/DataTable.vue'
import QueryToolbar , { type QueryField , type QueryModel } from '@/components/common/QueryToolbar.vue'
2026-08-03 14:58:17 +08:00
import { useAccessPreviewStore } from '@/stores/accessPreview'
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
uploading : boolean
error : string
}
const access = useAccessPreviewStore ()
2026-08-01 12:17:33 +08:00
const page = ref ( 1 )
const pageSize = ref ( 10 )
const loading = ref ( false )
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 ( '' )
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 detailOpen = ref ( false )
const detailLoading = ref ( false )
const detail = ref < PlatformTenantResource | null >( null )
const detailQualifications = ref < PlatformTenantQualification [] >([])
const qualificationLoadError = ref ( '' )
2026-08-01 12:17:33 +08:00
const filters = ref < CustomerFilters >({ keyword : '' , status : '' })
const queryFields : QueryField [] = [
2026-08-03 14:58:17 +08:00
{ key : 'keyword' , type : 'input' , placeholder : '公司名称、客户ID或联系人' },
{ key : 'status' , type : 'select' , placeholder : '全部状态' , options : [
{ label : '待完善' , value : 'PROFILE_REQUIRED' }, { label : '待审核' , value : 'PENDING' },
{ label : '启用' , value : 'ACTIVE' }, { label : '已驳回' , value : 'REJECTED' },
{ label : '已停用' , value : 'DISABLED' },
] },
2026-08-01 12:17:33 +08:00
]
2026-08-03 14:58:17 +08:00
const emptyForm = () : CreatePlatformTenantRequest => ({
tenantName : '' , legalName : '' , unifiedSocialCreditCode : '' , contactName : '' , contactPhone : '' ,
contactEmail : '' , contactWechat : '' , servicePackage : '基础版' , salesOwnerName : '' , paymentTermDays : 0 ,
enterpriseType : '' , establishedDate : null , registeredCapital : null , registeredCapitalCurrency : 'CNY' ,
registeredAddress : '' , internalRemark : '' ,
})
const form = ref ( emptyForm ())
const rules : FormRules = {
tenantName : [{ required : true , message : '请输入公司名称' , trigger : 'blur' }],
legalName : [{ required : true , message : '请输入营业执照上的企业名称' , trigger : 'blur' }],
unifiedSocialCreditCode : [
{ required : true , message : '请输入统一社会信用代码' , trigger : 'blur' },
{ pattern : /^[0-9A-Z]{18}$/ , message : '请输入 18 位大写字母或数字' , trigger : 'blur' },
],
contactName : [{ required : true , message : '请输入联系人姓名' , trigger : 'blur' }],
contactPhone : [{ required : true , message : '请输入联系电话' , trigger : 'blur' }],
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 14:58:17 +08:00
function newQualificationDrafts () : QualificationDraft [] {
const definitions : Array < Pick < QualificationDraft , ' type ' | ' label ' | ' required ' > > = [
{ type : 'BUSINESS_LICENSE' , label : '营业执照' , required : true },
{ type : 'LEGAL_PERSON_ID' , label : '法人身份证' , required : true },
{ type : 'FREIGHT_FORWARDER_CERTIFICATE' , label : '货代资质证书' , required : false },
{ type : 'BANK_ACCOUNT_LICENSE' , label : '银行开户许可证' , required : false },
]
return definitions . map (( item ) => ({ ... item , file : null , validTo : '' , uploaded : false , uploadedName : '' , uploading : false , error : '' }))
}
const qualifications = ref < QualificationDraft [] >( newQualificationDrafts ())
2026-08-01 12:17:33 +08:00
const customerColumns : DataTableColumn [] = [
2026-08-03 14:58:17 +08:00
{ key : 'actions' , label : '操作' , width : 245 , fixed : 'left' , slotName : 'actions' },
{ prop : 'tenantCode' , label : '客户ID' , minWidth : 170 },
{ key : 'company' , label : '公司名称' , minWidth : 230 , slotName : 'company' },
{ prop : 'unifiedSocialCreditCode' , label : '统一社会信用代码' , minWidth : 205 , formatter : ( row : PlatformTenantResource ) => displayText ( row . unifiedSocialCreditCode ) },
{ prop : 'contactName' , label : '联系人' , minWidth : 120 , formatter : ( row : PlatformTenantResource ) => displayText ( row . contactName ) },
{ prop : 'contactPhone' , label : '电话' , minWidth : 150 , formatter : ( row : PlatformTenantResource ) => displayText ( row . contactPhone ) },
{ prop : 'salesOwnerName' , label : '销售人员' , minWidth : 120 , formatter : ( row : PlatformTenantResource ) => displayText ( row . salesOwnerName ) },
{ 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-03 14:58:17 +08:00
{ key : 'orderCount' , label : '运单数' , width : 100 , slotName : 'orderCount' },
{ 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
const editorTitle = computed (() => editingId . value ? '编辑客户' : '新增客户' )
const canUploadQualifications = computed (() => ! editorStatus . value || [ 'PROFILE_REQUIRED' , 'REJECTED' ]. includes ( editorStatus . value ))
const canSubmitReview = computed (() => Boolean ( editingId . value ) && [ 'PROFILE_REQUIRED' , 'REJECTED' ]. includes ( editorStatus . value || '' ))
const uploadingQualifications = computed (() => qualifications . value . some (( item ) => item . uploading ))
const hasSelectedQualifications = computed (() => qualifications . value . some (( item ) => item . file ))
const hasUnsavedChanges = computed (() => Boolean ( editingId . value ) && JSON . stringify ( form . value ) !== savedFormSnapshot . value )
function displayText ( value ?: string | null ) { return value ? . trim () || '—' }
function formatDate ( value ?: string | null ) { return value ? value . replace ( 'T' , ' ' ). slice ( 0 , 16 ) : '—' }
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 }
return { label : '已停用' , type : 'info' as const }
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 14:58:17 +08:00
const pageResult = await listPlatformTenants ({ page : page . value , size : pageSize . value , keyword : filters . value . keyword . trim () || undefined , status : filters . value . status || undefined })
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 }
function resetEditor () {
form . value = emptyForm (); qualifications . value = newQualificationDrafts (); qualificationLoadError . value = '' ; savedFormSnapshot . value = ''
}
function openCreate () {
editingId . value = null ; editorStatus . value = null ; resetEditor (); editorOpen . value = true
nextTick (() => formRef . value ? . clearValidate ())
}
function mapTenantToForm ( row : PlatformTenantResource ) : CreatePlatformTenantRequest {
return {
tenantName : row . tenantName , legalName : row . legalName || '' , unifiedSocialCreditCode : row . unifiedSocialCreditCode || '' ,
contactName : row . contactName || '' , contactPhone : row . contactPhone || '' , contactEmail : row . contactEmail || '' ,
contactWechat : row . contactWechat || '' , servicePackage : row . servicePackage || '基础版' , salesOwnerName : row . salesOwnerName || '' ,
paymentTermDays : Number ( row . paymentTermDays ?? 0 ), enterpriseType : row . enterpriseType || '' , establishedDate : row . establishedDate || null ,
registeredCapital : row . registeredCapital === null ? null : Number ( row . registeredCapital ),
registeredCapitalCurrency : row . registeredCapitalCurrency || 'CNY' , registeredAddress : row . registeredAddress || '' ,
internalRemark : row . internalRemark || '' ,
}
}
async function loadEditorQualifications ( tenantId : number | string ) {
qualificationLoadError . value = ''
try {
const records = await listTenantQualifications ( tenantId )
for ( const draft of qualifications . value ) {
const item = [... records ]. reverse (). find (( record ) => record . qualificationType === draft . type && record . status !== 'REJECTED' )
if ( item ) { draft . uploaded = true ; draft . validTo = item . validTo || '' ; draft . uploadedName = '已登记文件' }
}
} catch ( error ) { qualificationLoadError . value = getApiErrorMessage ( error , '资质信息加载失败,可稍后重试' ) }
}
async function openEdit ( row : PlatformTenantResource ) {
editingId . value = String ( row . id ); editorStatus . value = row . status ; resetEditor (); form . value = mapTenantToForm ( row )
editorOpen . value = true ; editorLoading . value = true
try {
const tenant = await getPlatformTenant ( row . id )
if ( editingId . value !== String ( row . id )) return
editorStatus . value = tenant . status
form . value = mapTenantToForm ( tenant )
savedFormSnapshot . value = JSON . stringify ( form . value )
await loadEditorQualifications ( row . id )
await nextTick (() => formRef . value ? . clearValidate ())
} catch ( error ) {
if ( editingId . value === String ( row . id )) editorOpen . value = false
ElMessage . error ( getApiErrorMessage ( error , '客户完整资料加载失败,请稍后重试' ))
2026-08-01 12:17:33 +08:00
} finally {
2026-08-03 14:58:17 +08:00
if ( editingId . value === String ( row . 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
draft . error = '' ; draft . file = null
if ( ! file ) return
const extensionValid = /\.(pdf|jpe?g|png)$/i . test ( file . name )
const mimeValid = [ 'application/pdf' , 'image/jpeg' , 'image/png' ]. includes ( file . type )
if ( ! extensionValid || ! mimeValid ) { draft . error = '仅支持 PDF、JPG、PNG 文件' ; input . value = '' ; return }
if ( file . size > 10 * 1024 * 1024 ) { draft . error = '单个文件不能超过 10MB' ; input . value = '' ; return }
draft . file = file
}
async function uploadSelectedQualifications ( tenantId : string ) {
const failures : string [] = []
for ( const draft of qualifications . value . filter (( item ) => item . file )) {
draft . uploading = true ; draft . error = ''
try {
await uploadPlatformTenantQualification (
tenantId , draft . type , draft . file as File , draft . validTo || undefined ,
draft . type === 'BUSINESS_LICENSE' ? form . value . unifiedSocialCreditCode : undefined ,
draft . uploaded ,
)
draft . uploaded = true ; draft . uploadedName = draft . file ? . name || '已登记文件' ; draft . file = null
} 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 uploadQualifications () {
if ( ! editingId . value ) { ElMessage . warning ( '请先保存客户资料,再上传资质文件' ); return }
if ( ! hasSelectedQualifications . value ) { ElMessage . warning ( '请先选择需要上传的资质文件' ); return }
const failures = await uploadSelectedQualifications ( editingId . value )
if ( failures . length ) ElMessage . warning ( ` ${ failures . join ( '、' ) } 上传失败,请根据提示重试` )
else ElMessage . success ( '已选资质上传成功,尚未提交审核' )
}
async function saveCustomer () {
if ( ! formRef . value || ! ( await formRef . value . validate (). catch (() => false ))) return
saving . value = true
try {
let saved : PlatformTenantResource
if ( editingId . value ) {
const { unifiedSocialCreditCode : _immutable , ... payload } = form . value
saved = await updatePlatformTenant ( editingId . value , payload )
} else {
saved = await createPlatformTenant ( form . value )
editingId . value = String ( saved . id )
}
editorStatus . value = saved . status
savedFormSnapshot . value = JSON . stringify ( form . value )
ElMessage . success ( '客户资料已保存为草稿,尚未提交审核' )
await loadCustomers ()
} catch ( error ) { ElMessage . error ( getApiErrorMessage ( error , '客户资料保存失败,请稍后重试' )) }
finally { saving . value = false }
}
async function submitReview () {
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 }
const missing = qualifications . value . filter (( item ) => item . required && ! item . uploaded )
for ( const item of missing ) item . error = ` ${ item . label } 为提交审核必填文件`
if ( missing . length ) { ElMessage . warning ( '请先上传营业执照和法人身份证' ); return }
try {
await ElMessageBox . confirm ( '提交后客户资料与资质将进入企业审核,审核完成前不能继续替换文件。确认提交吗?' , '提交企业审核' , { type : 'warning' , confirmButtonText : '确认提交' , cancelButtonText : '继续完善' })
submitting . value = true
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 }
}
async function showDetail ( row : PlatformTenantResource ) {
detail . value = row ; detailQualifications . value = []; detailOpen . value = true ; detailLoading . value = true
try {
const [ tenant , records ] = await Promise . all ([ getPlatformTenant ( row . id ), listTenantQualifications ( row . id )])
detail . value = tenant ; detailQualifications . value = records
} catch ( error ) { ElMessage . error ( getApiErrorMessage ( error , '客户详情加载失败' )) }
finally { detailLoading . value = false }
}
async function toggleStatus ( row : PlatformTenantResource ) {
const next = row . status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE' ; const verb = next === 'ACTIVE' ? '启用' : '停用'
try {
await ElMessageBox . confirm ( `确认 ${ verb } 客户“ ${ row . tenantName } ”吗?` , ` ${ verb } 客户` , { type : 'warning' , confirmButtonText : `确认 ${ verb } ` , cancelButtonText : '取消' })
rowActionId . value = String ( row . id ); await changePlatformTenantStatus ( row . id , next )
ElMessage . success ( `客户已 ${ verb } ` ); await loadCustomers ()
} catch ( error ) {
if ( error !== 'cancel' && error !== 'close' ) ElMessage . error ( getApiErrorMessage ( error , `客户 ${ verb } 失败` ))
} finally { rowActionId . value = '' }
}
function xmlEscape ( value : unknown ) { return String ( value ?? '' ). replace ( /&/g , '&' ). replace ( /</g , '<' ). replace ( />/g , '>' ). replace ( /"/g , '"' ) }
async function exportCustomers () {
loading . value = true
try {
const first = await listPlatformTenants ({ page : 1 , size : 200 , keyword : filters . value . keyword . trim () || undefined , status : filters . value . status || undefined })
const rows = [... first . records ]
for ( let current = 2 ; current <= first . pages ; current += 1 ) rows . push (...( await listPlatformTenants ({ page : current , size : 200 , keyword : filters . value . keyword . trim () || undefined , status : filters . value . status || undefined })). records )
const body = rows . map (( row ) => `<tr> ${ [ row . tenantCode , row . tenantName , row . unifiedSocialCreditCode , row . contactName , row . contactPhone , row . salesOwnerName , row . paymentTermDays , statusMeta ( row . status ). label , formatDate ( row . createdAt )]. map (( cell ) => `<td> ${ xmlEscape ( cell ) } </td>` ). join ( '' ) } </tr>` ). join ( '' )
const html = `<html><head><meta charset="utf-8"></head><body><table><tr> ${ [ '客户ID' , '公司名称' , '统一社会信用代码' , '联系人' , '电话' , '销售人员' , '账期(天)' , '状态' , '入驻时间' ]. map (( cell ) => `<th> ${ cell } </th>` ). join ( '' ) } </tr> ${ body } </table></body></html>`
const url = URL . createObjectURL ( new Blob ([ '\ufeff' , html ], { type : 'application/vnd.ms-excel;charset=utf-8' }))
const anchor = document . createElement ( 'a' ); anchor . href = url ; anchor . download = `客户列表- ${ new Date (). toISOString (). slice ( 0 , 10 ) } .xls` ; anchor . click (); URL . revokeObjectURL ( url )
ElMessage . success ( `已导出 ${ rows . length } 条客户数据` )
} catch ( error ) { ElMessage . error ( getApiErrorMessage ( error , '客户数据导出失败' )) }
finally { loading . value = false }
2026-08-01 12:17:33 +08:00
}
watch ([ page , pageSize ], () => { void loadCustomers () })
2026-08-03 14:58:17 +08:00
onMounted ( loadCustomers )
2026-08-01 12:17:33 +08:00
</ script >
< template >
< div class = "business-page customers-page" >
< QueryToolbar v-model = "filters" class="surface-panel" ariaLabel="客户筛选" :fields="queryFields" @search="applyFilters" />
2026-08-03 14:58:17 +08:00
< section v-if = "loadError" class="surface-panel customers-error" role="alert"><div><strong>客户列表暂时无法加载</strong><p>{{ loadError.message }}</p><ApiErrorMeta :details="loadError" /></div><el-button type="primary" :loading="loading" @click="loadCustomers" > 重新加载 </ el -button ></ section >
2026-08-01 12:17:33 +08:00
< section v-else class = "surface-panel table-panel" :aria-busy = "loading" >
2026-08-03 14:58:17 +08:00
< div class = "customers-table-header" >< div class = "customers-table-heading" >< h2 > 客户列表 </ h2 >< span > 共 {{ result . total }} 个客户 </ span ></ div >< div class = "customers-table-actions" >< el-button :disabled = "loading" @click ="exportCustomers" >< Download :size = "16" /> 导出Excel </ el-button >< el-button v-if = "access.can('platform:customer:create')" type="primary" :disabled="loading" @click="openCreate"><Plus :size="16" /> 新增客户 </ el-button ></ div ></ div >
< DataTable v -model:page = "page" v -model:page-size = "pageSize" :data = "result.records" :columns = "customerColumns" :loading = "loading" :total = "result.total" aria -label =" 客户列表 " row -key =" id " empty -text =" 暂无符合筛选条件的客户 " >
< template # actions = "{ row }" >< div class = "table-actions" >< el-button v-if = "access.can('platform:customer:view')" link type="primary" @click="showDetail(row as PlatformTenantResource)"><Eye :size="15" />详情</el-button><el-button v-if="access.can('platform:customer:view') && access.can('platform:customer:edit')" link type="primary" @click="openEdit(row as PlatformTenantResource)"><Pencil :size="15" />编辑</el-button><el-button v-if="access.can('platform:customer:status') && ['ACTIVE','DISABLED'].includes((row as PlatformTenantResource).status)" link :type="(row as PlatformTenantResource).status === 'ACTIVE' ? 'danger' : 'success'" :loading="rowActionId === String((row as PlatformTenantResource).id)" @click="toggleStatus(row as PlatformTenantResource)"><Power :size="15" />{{ ( row as PlatformTenantResource ). status === 'ACTIVE' ? '停用' : '启用' }}</ el-button ></ div ></ template >
< template # company = "{ row }" >< div class = "customer-company-cell" >< span >< Building2 :size = "18" /></ span >< strong >{{ ( row as PlatformTenantResource ). tenantName }}</ strong ></ div ></ template >
< template # status = "{ row }" >< el-tag : type = "statusMeta((row as PlatformTenantResource).status).type" effect = "light" >{{ statusMeta (( row as PlatformTenantResource ). status ). label }}</ el-tag ></ template >
< template # orderCount = "{ row }" >{{ access . can ( 'platform:order:list' ) ? ( orderCounts [ String (( row as PlatformTenantResource ). id )] ?? '—' ) : '—' }}</ template >
2026-08-01 12:17:33 +08:00
</ DataTable >
</ section >
2026-08-03 14:58:17 +08:00
< el-drawer v-model = "editorOpen" :title="editorTitle" size="760px" class="business-drawer customer-editor" :close-on-click-modal="false" >
< el-form ref = "formRef" v-loading = "editorLoading" :model="form" :rules="rules" label-position="top" class="drawer-form" :disabled="editorLoading || saving || submitting || uploadingQualifications" >
< div class = "form-section" >< h3 > 基础资料 </ h3 >< div class = "form-grid two-cols" >
< el-form-item label = "公司名称" prop = "tenantName" required >< el-input v-model = "form.tenantName" placeholder="请输入工商登记公司名称" maxlength="128" /></ el-form-item >
< el-form-item label = "营业执照企业名称" prop = "legalName" required >< el-input v-model = "form.legalName" placeholder="请输入营业执照上的企业名称" maxlength="128" /></ el-form-item >
< el-form-item label = "统一社会信用代码" prop = "unifiedSocialCreditCode" required >< el-input v-model = "form.unifiedSocialCreditCode" :disabled="Boolean(editingId)" placeholder="请输入18位大写字母或数字" maxlength="18" @input="form.unifiedSocialCreditCode = String($event).toUpperCase()" /></ el-form-item >
< el-form-item label = "企业类型" prop = "enterpriseType" >< el-input v-model = "form.enterpriseType" placeholder="如:有限责任公司" maxlength="64" /></ el-form-item >
< el-form-item label = "成立日期" prop = "establishedDate" >< el-date-picker v-model = "form.establishedDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择成立日期" :disabled-date="(date: Date) => date.getTime() > Date.now()" /></ el-form-item >
< el-form-item label = "注册资本" prop = "registeredCapital" >< div class = "capital-input" >< el-input-number v-model = "form.registeredCapital" :min="0" :precision="2" controls-position="right" /><el-select v-model="form.registeredCapitalCurrency" aria-label="注册资本币种"><el-option label="人民币 CNY" value="CNY" /><el-option label="美元 USD" value="USD" /><el-option label="港币 HKD" value="HKD" /></ el-select ></ div ></ el-form-item >
</ div >< el-form-item label = "注册地址" prop = "registeredAddress" >< el-input v-model = "form.registeredAddress" placeholder="请输入省市区、街道及门牌信息" maxlength="500" /></ el-form-item ></ div >
< div class = "form-section" >< h3 > 联系与商务 </ h3 >< div class = "form-grid two-cols" >
< el-form-item label = "联系人" prop = "contactName" required >< el-input v-model = "form.contactName" placeholder="请输入主要联系人姓名" maxlength="64" /></ el-form-item >
< el-form-item label = "联系电话" prop = "contactPhone" required >< el-input v-model = "form.contactPhone" placeholder="请输入手机号或固定电话" maxlength="32" /></ el-form-item >
< el-form-item label = "联系邮箱" prop = "contactEmail" >< el-input v-model = "form.contactEmail" placeholder="如:contact@example.com" maxlength="128" /></ el-form-item >
< el-form-item label = "联系微信" prop = "contactWechat" >< el-input v-model = "form.contactWechat" placeholder="请输入联系人微信号" maxlength="64" /></ el-form-item >
< el-form-item label = "客户套餐" prop = "servicePackage" required >< el-select v-model = "form.servicePackage"><el-option label="基础版" value="基础版" /><el-option label="专业版" value="专业版" /><el-option label="企业版" value="企业版" /></ el-select ></ el-form-item >
< el-form-item label = "销售人员" prop = "salesOwnerName" >< el-input v-model = "form.salesOwnerName" placeholder="请输入负责销售人员" maxlength="64" /></ el-form-item >
< el-form-item label = "账期(天)" prop = "paymentTermDays" required >< el-input-number v-model = "form.paymentTermDays" :min="0" :max="365" controls-position="right" /></ el-form-item >
</ div >< el-form-item label = "内部备注" prop = "internalRemark" >< el-input v-model = "form.internalRemark" type="textarea" :rows="3" placeholder="请输入仅平台人员可见的客户备注" maxlength="1000" show -word-limit /></ el-form-item ></ div >
< div class = "form-section qualification-section" >< div class = "qualification-heading" >< div >< h3 > 资质文件 </ h3 >< p > 先选择并上传文件 ; 上传成功不会创建审核单 , 点击 “ 提交审核 ” 并确认后才会进入审核 。 </ p ></ div >< el-tag v-if = "editorStatus" :type="statusMeta(editorStatus).type" effect="light" >{{ statusMeta ( editorStatus ) .label }} </ el -tag ></ div >
< div v-if = "qualificationLoadError" class="qualification-load-error" role="alert">{{ qualificationLoadError }}<el-button v-if="editingId" link type="primary" @click="loadEditorQualifications(editingId)" > 重试 </ el -button ></ div >
< div class = "qualification-table" role = "table" aria -label =" 客户资质文件 " >
< div class = "qualification-row qualification-header" role = "row" >< span role = "columnheader" > 文件类型 </ span >< span role = "columnheader" > 上传文件 </ span >< span role = "columnheader" > 有效期 </ span ></ div >
< div v-for = "draft in qualifications" :key="draft.type" class="qualification-row" role="row" >
< div class = "qualification-label" role = "cell" >< span v-if = "draft.required" aria-hidden="true">*</span>{{ draft.label }}<CheckCircle2 v-if="draft.uploaded" :size="16" aria-label="已上传" /></ div >
< div class = "qualification-file" role = "cell" >< label class = "file-picker" : class = "{ 'is-disabled': !canUploadQualifications || draft.uploading }" >< FileUp :size = "16" />< span >{{ draft . uploading ? '上传中' : ( draft . file ? '已选择新文件' : ( draft . uploaded ? '更换文件' : '选择文件' )) }}</ span >< input type = "file" accept = ".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png" : disabled = "!canUploadQualifications || draft.uploading" @change ="selectQualificationFile($event, draft)" /></ label >< span class = "qualification-file-name" >{{ draft . file ? . name || draft . uploadedName || '未选择文件' }}</ span >< small v-if = "draft.error" class="qualification-error" role="alert" >{{ draft.error }}</ small ></ div >
< div role = "cell" >< el-date-picker v-model = "draft.validTo" type="date" value-format="YYYY-MM-DD" placeholder="请选择有效期" :disabled="!canUploadQualifications || draft.uploaded" /></ div >
</ div >
</ div >
< p class = "qualification-hint" > 支持 PDF 、 JPG 、 PNG , 单个文件不超过 10 MB ; < span > * </ span > 为提交审核必填项 。 </ p >
</ div >
</ el-form >
< template # footer >< div class = "drawer-footer customer-editor-footer" >< span >{{ editingId ? '保存资料和上传资质均不会送审,提交审核需要再次确认' : '请先保存资料生成客户草稿,再上传资质' }}</ span >< div >< 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 = "canUploadQualifications" :loading="uploadingQualifications" :disabled="editorLoading || !editingId || saving || submitting || !hasSelectedQualifications" @click="uploadQualifications">上传已选资质</el-button><el-button v-if="canSubmitReview" type="primary" :loading="submitting" :disabled="editorLoading || saving || uploadingQualifications" @click="submitReview" > 提交审核 </ el -button ></ div ></ div ></ template >
</ el-drawer >
< el-drawer v-model = "detailOpen" title="客户详情" size="620px" class="business-drawer"><div v-loading="detailLoading" class="customer-detail"><template v-if="detail"><div class="detail-title"><span><Building2 :size="22" /></span><div><h3>{{ detail.tenantName }}</h3><p>{{ detail.tenantCode }}</p></div></div><el-descriptions :column="1" border><el-descriptions-item label="统一社会信用代码">{{ displayText(detail.unifiedSocialCreditCode) }}</el-descriptions-item><el-descriptions-item label="联系人">{{ displayText(detail.contactName) }} · {{ displayText(detail.contactPhone) }}</el-descriptions-item><el-descriptions-item label="联系邮箱">{{ displayText(detail.contactEmail) }}</el-descriptions-item><el-descriptions-item label="客户套餐">{{ displayText(detail.servicePackage) }}</el-descriptions-item><el-descriptions-item label="销售人员">{{ displayText(detail.salesOwnerName) }}</el-descriptions-item><el-descriptions-item label="账期">{{ detail.paymentTermDays ?? 0 }} 天</el-descriptions-item><el-descriptions-item label="注册地址">{{ displayText(detail.registeredAddress) }}</el-descriptions-item><el-descriptions-item label="入驻时间">{{ formatDate(detail.createdAt) }}</el-descriptions-item><el-descriptions-item label="内部备注">{{ displayText(detail.internalRemark) }}</el-descriptions-item></el-descriptions><div class="detail-qualification-list"><h4>资质文件</h4><div v-if="detailQualifications.length"><div v-for="item in detailQualifications" :key="String(item.id)"><span>{{ qualifications.find((draft) => draft.type === item.qualificationType)?.label || item.qualificationType }}</span><el-tag :type="item.status === 'APPROVED' ? 'success' : item.status === 'REJECTED' ? 'danger' : 'warning'" size="small" >{{ item.status === ' APPROVED ' ? ' 已通过 ' : item.status === ' REJECTED ' ? ' 已驳回 ' : ' 待审核 ' }} </ el -tag ></ div ></ div >< p v-else > 暂未上传资质文件 </ p ></ div ></ template ></ div > </ el -drawer >
2026-08-01 12:17:33 +08:00
</ div >
</ template >
< style scoped >
2026-08-03 14:58:17 +08:00
. customers - error { display : flex ; min - width : 0 ; align - items : center ; justify - content : space - between ; gap : 16 px ; padding : 18 px 20 px ; border - color : # fecdca ; background : # fffafa ; }
. customers - error strong { color : # 7 a271a ; font - size : 16 px ; } . customers - error p { margin : 4 px 0 0 ; color : # b54708 ; font - size : 12 px ; overflow - wrap : anywhere ; }
. customers - table - header { display : flex ; min - width : 0 ; align - items : center ; justify - content : space - between ; gap : 16 px ; padding : 16 px 18 px ; border - bottom : 1 px solid var ( -- cc - border ); }
. customers - table - heading { display : flex ; min - width : 0 ; align - items : baseline ; gap : 10 px ; } . customers - table - heading h2 { margin : 0 ; color : var ( -- cc - text ); font - size : 16 px ; } . customers - table - heading span { color : var ( -- cc - muted ); font - size : 12 px ; white - space : nowrap ; }
. customers - table - actions { display : flex ; flex - wrap : wrap ; gap : 8 px ; } . customers - table - actions : deep (. el - button + . el - button ) { margin - left : 0 ; }
. customer - company - cell { display : flex ; min - width : 0 ; align - items : center ; gap : 10 px ; } . customer - company - cell > span ,. detail - title > span { display : grid ; width : 34 px ; height : 34 px ; flex : 0 0 auto ; place - items : center ; border - radius : 7 px ; background : # eef4ff ; color : var ( -- cc - primary ); } . customer - company - cell strong { overflow : hidden ; text - overflow : ellipsis ; }
. capital - input { display : grid ; grid - template - columns : minmax ( 0 , 1 fr ) 130 px ; width : 100 % ; gap : 8 px ; } . capital - input : deep (. el - input - number ),. capital - input : deep (. el - select ) { width : 100 % ; }
. qualification - heading { display : flex ; align - items : flex - start ; justify - content : space - between ; gap : 16 px ; } . qualification - heading h3 { margin - bottom : 4 px ; } . qualification - heading p ,. qualification - hint { margin : 0 ; color : var ( -- cc - muted ); font - size : 12 px ; line - height : 1.6 ; } . qualification - hint { margin - top : 12 px ; } . qualification - hint span ,. qualification - label > span { color : var ( -- el - color - danger ); }
. qualification - table { overflow : hidden ; margin - top : 14 px ; border : 1 px solid var ( -- cc - border ); border - radius : 8 px ; } . qualification - row { display : grid ; grid - template - columns : 180 px minmax ( 260 px , 1 fr ) 180 px ; min - width : 0 ; border - top : 1 px solid var ( -- cc - border ); } . qualification - row : first - child { border - top : 0 ; } . qualification - row > * { display : flex ; min - width : 0 ; min - height : 68 px ; align - items : center ; padding : 12 px 14 px ; } . qualification - header { min - height : 44 px ; background : # f7f8fc ; color : var ( -- cc - muted ); font - size : 12 px ; font - weight : 600 ; } . qualification - header > * { min - height : 44 px ; }
. qualification - label { gap : 5 px ; font - weight : 600 ; } . qualification - label svg { margin - left : auto ; color : var ( -- el - color - success ); }
. qualification - file { flex - wrap : wrap ; gap : 8 px ; } . file - picker { display : inline - flex ; min - height : 36 px ; align - items : center ; gap : 7 px ; padding : 0 12 px ; border : 1 px solid var ( -- cc - border ); border - radius : 6 px ; background : # fff ; color : var ( -- cc - text ); cursor : pointer ; } . file - picker : focus - within { outline : 2 px solid color - mix ( in srgb , var ( -- cc - primary ) 35 % , transparent ); outline - offset : 2 px ; } . file - picker . is - disabled { cursor : not - allowed ; opacity : .6 ; } . file - picker input { position : absolute ; width : 1 px ; height : 1 px ; overflow : hidden ; clip : rect ( 0 0 0 0 ); white - space : nowrap ; } . qualification - file - name { min - width : 0 ; overflow : hidden ; color : var ( -- cc - muted ); font - size : 12 px ; text - overflow : ellipsis ; white - space : nowrap ; } . qualification - error { width : 100 % ; color : var ( -- el - color - danger ); line - height : 1.4 ; }
. qualification - load - error { display : flex ; align - items : center ; justify - content : space - between ; gap : 10 px ; margin - top : 12 px ; padding : 10 px 12 px ; border : 1 px solid # fecdca ; border - radius : 6 px ; background : # fffafa ; color : # b42318 ; font - size : 12 px ; }
. customer - editor - footer { display : flex ; align - items : center ; justify - content : space - between ; gap : 16 px ; } . customer - editor - footer > span { color : var ( -- cc - muted ); font - size : 12 px ; text - align : left ; } . customer - editor - footer > div { display : flex ; flex : 0 0 auto ; gap : 8 px ; } . customer - editor - footer : deep (. el - button + . el - button ) { margin - left : 0 ; }
. customer - detail { min - height : 200 px ; } . detail - title { display : flex ; align - items : center ; gap : 12 px ; margin - bottom : 20 px ; } . detail - title h3 { margin : 0 ; font - size : 20 px ; } . detail - title p { margin : 4 px 0 0 ; color : var ( -- cc - muted ); font - size : 12 px ; }
. detail - qualification - list { margin - top : 20 px ; } . detail - qualification - list h4 { margin : 0 0 10 px ; } . detail - qualification - list > div > div { display : flex ; align - items : center ; justify - content : space - between ; gap : 12 px ; padding : 10 px 0 ; border - bottom : 1 px solid var ( -- cc - border ); } . detail - qualification - list p { color : var ( -- cc - muted ); }
@ media ( max - width : 720 px ) { . customers - error ,. customers - table - header ,. customer - editor - footer { align - items : stretch ; flex - direction : column ; } . customers - table - actions { display : grid ; grid - template - columns : 1 fr 1 fr ; } . 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 ; } }
@ media ( max - width : 360 px ) { . customers - table - actions ,. capital - input { grid - template - columns : 1 fr ; } }
2026-08-01 12:17:33 +08:00
</ style >