@@ -1,15 +1,17 @@
< script setup lang = "ts" >
< script setup lang = "ts" >
import { computed , nextTick , onBeforeUnmount , onMounted , reactive , ref , watch } from 'vue'
import { computed , nextTick , onBeforeUnmount , onMounted , reactive , ref , watch } from 'vue'
import { useRoute , useRouter } from 'vue-router'
import { useRoute , useRouter } from 'vue-router'
import { Building2 , CheckCircle2 , Download , Eye , FileText , FileUp , Pencil , Plus , Power , ShieldCheck , ShieldX } from '@lucide/vue'
import { Building2 , CheckCircle2 , Download , Eye , FileText , FileUp , KeyRound , LogIn , Pencil , Plus , Power , ShieldCheck , ShieldX , Trash2 } from '@lucide/vue'
import { ElMessage , ElMessageBox , type FormInstance , type FormRules } from 'element-plus'
import { ElMessage , ElMessageBox , type FormInstance , type FormRules } from 'element-plus'
import { getApiErrorDetails , getApiErrorMessage , type ApiErrorDetails } from '@/shared/api/http'
import { getApiErrorDetails , getApiErrorMessage , type ApiErrorDetails } from '@/shared/api/http'
import { createPlatformExportTask } from '@/modules/downloads'
import { createPlatformExportTask } from '@/modules/downloads'
import {
import {
approveEnterpriseReview ,
approveEnterpriseReview ,
archivePlatformTenant ,
changePlatformTenantStatus ,
changePlatformTenantStatus ,
checkPlatformTenantContactPhone ,
checkPlatformTenantContactPhone ,
createPlatformTenant ,
createPlatformTenant ,
createPlatformTenantImpersonation ,
downloadTenantQualification ,
downloadTenantQualification ,
getPlatformTenant ,
getPlatformTenant ,
listEnterpriseReviews ,
listEnterpriseReviews ,
@@ -18,6 +20,7 @@ import {
listTenantQualifications ,
listTenantQualifications ,
qualificationFileName ,
qualificationFileName ,
rejectEnterpriseReview ,
rejectEnterpriseReview ,
resetPlatformTenantOwnerPassword ,
submitPlatformTenantReview ,
submitPlatformTenantReview ,
updatePlatformTenant ,
updatePlatformTenant ,
uploadPlatformTenantQualification ,
uploadPlatformTenantQualification ,
@@ -75,6 +78,24 @@ const approvalResultOpen = ref(false)
const approvalResult = ref < EnterpriseApprovalResponse | null > ( null )
const approvalResult = ref < EnterpriseApprovalResponse | null > ( null )
const decision = reactive ( { type : 'approve' as 'approve' | 'reject' , comment : '' } )
const decision = reactive ( { type : 'approve' as 'approve' | 'reject' , comment : '' } )
const decisionFormRef = ref < FormInstance > ( )
const decisionFormRef = ref < FormInstance > ( )
const passwordDialogOpen = ref ( false )
const passwordSubmitting = ref ( false )
const passwordFormRef = ref < FormInstance > ( )
const passwordTenant = ref < PlatformTenantResource | null > ( null )
const passwordForm = reactive ( { newPassword : '' , confirmPassword : '' , forceChangePassword : true } )
const passwordError = ref < ApiErrorDetails | null > ( null )
const impersonationDialogOpen = ref ( false )
const impersonationSubmitting = ref ( false )
const impersonationFormRef = ref < FormInstance > ( )
const impersonationTenant = ref < PlatformTenantResource | null > ( null )
const impersonationForm = reactive ( { reason : '' } )
const impersonationError = ref < ApiErrorDetails | null > ( null )
const archiveDialogOpen = ref ( false )
const archiveSubmitting = ref ( false )
const archiveTenant = ref < PlatformTenantResource | null > ( null )
const archiveConfirmCode = ref ( '' )
const archiveError = ref < ApiErrorDetails | null > ( null )
let impersonationCleanup : ( ( ) => void ) | null = null
const editorOpen = ref ( false )
const editorOpen = ref ( false )
const editorLoading = ref ( false )
const editorLoading = ref ( false )
const saving = ref ( false )
const saving = ref ( false )
@@ -158,7 +179,7 @@ const creditCodeEstablished = ref(false)
const unifiedSocialCreditCodePattern = /^[0-9A-Z]{18}$/
const unifiedSocialCreditCodePattern = /^[0-9A-Z]{18}$/
const customerColumns : DataTableColumn [ ] = [
const customerColumns : DataTableColumn [ ] = [
{ key : 'actions' , label : '操作' , minWidth : 190 , slotName : 'actions' } ,
{ key : 'actions' , label : '操作' , minWidth : 520 , slotName : 'actions' } ,
{ key : 'tenantCode' , prop : 'tenantCode' , label : '客户ID' , minWidth : 170 } ,
{ key : 'tenantCode' , prop : 'tenantCode' , label : '客户ID' , minWidth : 170 } ,
{ key : 'company' , label : '公司名称' , minWidth : 230 , slotName : 'company' } ,
{ key : 'company' , label : '公司名称' , minWidth : 230 , slotName : 'company' } ,
{ key : 'unifiedSocialCreditCode' , prop : 'unifiedSocialCreditCode' , label : '统一社会信用代码' , minWidth : 205 , formatter : ( row : PlatformTenantResource ) => displayText ( row . unifiedSocialCreditCode ) } ,
{ key : 'unifiedSocialCreditCode' , prop : 'unifiedSocialCreditCode' , label : '统一社会信用代码' , minWidth : 205 , formatter : ( row : PlatformTenantResource ) => displayText ( row . unifiedSocialCreditCode ) } ,
@@ -202,6 +223,23 @@ const missingQualificationPackageItems = computed(() => {
} )
} )
const hasUnsavedChanges = computed ( ( ) => Boolean ( editingId . value ) && JSON . stringify ( form . value ) !== savedFormSnapshot . value )
const hasUnsavedChanges = computed ( ( ) => Boolean ( editingId . value ) && JSON . stringify ( form . value ) !== savedFormSnapshot . value )
const pendingReviewByTenantId = computed ( ( ) => new Map ( pendingReviews . value . map ( ( review ) => [ String ( review . tenantId ) , review ] ) ) )
const pendingReviewByTenantId = computed ( ( ) => new Map ( pendingReviews . value . map ( ( review ) => [ String ( review . tenantId ) , review ] ) ) )
const passwordRules : FormRules = {
newPassword : [
{ required : true , message : '请输入新密码' , trigger : 'blur' } ,
{ min : 8 , max : 20 , message : '密码长度为 8-20 位' , trigger : 'blur' } ,
{ pattern : /^(?=.*[A-Za-z])(?=.*\d).+$/ , message : '密码必须同时包含字母和数字' , trigger : 'blur' } ,
] ,
confirmPassword : [ {
validator : ( _rule , value , callback ) => value === passwordForm . newPassword ? callback ( ) : callback ( new Error ( '两次输入的密码不一致' ) ) ,
trigger : [ 'blur' , 'change' ] ,
} ] ,
}
const impersonationRules : FormRules = {
reason : [
{ required : true , message : '请输入代登录原因' , trigger : 'blur' } ,
{ min : 5 , max : 500 , message : '代登录原因需填写 5-500 字' , trigger : 'blur' } ,
] ,
}
function displayText ( value ? : string | null ) { return value ? . trim ( ) || '—' }
function displayText ( value ? : string | null ) { return value ? . trim ( ) || '—' }
function formatDate ( value ? : string | null ) { return value ? value . replace ( 'T' , ' ' ) . slice ( 0 , 16 ) : '—' }
function formatDate ( value ? : string | null ) { return value ? value . replace ( 'T' , ' ' ) . slice ( 0 , 16 ) : '—' }
function statusMeta ( status : PlatformTenantStatus ) {
function statusMeta ( status : PlatformTenantStatus ) {
@@ -577,6 +615,136 @@ async function toggleStatus(row: PlatformTenantResource) {
} finally { rowActionId . value = '' }
} finally { rowActionId . value = '' }
}
}
function randomPassword ( ) {
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%^&*'
const values = new Uint32Array ( 10 )
crypto . getRandomValues ( values )
const body = Array . from ( values , ( value ) => alphabet [ value % alphabet . length ] ) . join ( '' )
passwordForm . newPassword = ` A7 ${ body } `
passwordForm . confirmPassword = passwordForm . newPassword
void nextTick ( ( ) => passwordFormRef . value ? . clearValidate ( ) )
}
function openResetPassword ( row : PlatformTenantResource ) {
passwordTenant . value = row
passwordForm . newPassword = ''
passwordForm . confirmPassword = ''
passwordForm . forceChangePassword = true
passwordError . value = null
passwordDialogOpen . value = true
void nextTick ( ( ) => passwordFormRef . value ? . clearValidate ( ) )
}
async function submitResetPassword ( ) {
if ( ! passwordTenant . value || passwordSubmitting . value || ! passwordFormRef . value ) return
const valid = await passwordFormRef . value . validate ( ) . then ( ( ) => true ) . catch ( ( ) => false )
if ( ! valid ) return
passwordSubmitting . value = true
passwordError . value = null
try {
await resetPlatformTenantOwnerPassword ( passwordTenant . value . id , {
newPassword : passwordForm . newPassword ,
forceChangePassword : passwordForm . forceChangePassword ,
} )
ElMessage . success ( '企业管理员密码已重置' )
passwordDialogOpen . value = false
passwordTenant . value = null
passwordForm . newPassword = ''
passwordForm . confirmPassword = ''
} catch ( error ) {
passwordError . value = getApiErrorDetails ( error , '客户管理员密码重置失败,请稍后重试' )
} finally {
passwordSubmitting . value = false
}
}
function openImpersonation ( row : PlatformTenantResource ) {
impersonationTenant . value = row
impersonationForm . reason = '协助客户处理业务问题'
impersonationError . value = null
impersonationDialogOpen . value = true
void nextTick ( ( ) => impersonationFormRef . value ? . clearValidate ( ) )
}
async function submitImpersonation ( ) {
if ( ! impersonationTenant . value || impersonationSubmitting . value || ! impersonationFormRef . value ) return
const valid = await impersonationFormRef . value . validate ( ) . then ( ( ) => true ) . catch ( ( ) => false )
if ( ! valid ) return
const clientUrl = String ( import . meta . env . VITE _CLIENT _WEB _URL || '' ) . trim ( )
if ( ! clientUrl ) {
impersonationError . value = getApiErrorDetails ( new Error ( '未配置客户工作台地址' ) , '暂未配置客户工作台地址,无法安全跳转登录' )
return
}
let targetOrigin : string
try { targetOrigin = new URL ( clientUrl , window . location . origin ) . origin } catch {
impersonationError . value = getApiErrorDetails ( new Error ( '客户工作台地址无效' ) , '客户工作台地址配置无效,无法安全跳转登录' )
return
}
const popup = window . open ( clientUrl , '_blank' )
if ( ! popup ) {
impersonationError . value = getApiErrorDetails ( new Error ( '浏览器拦截了新窗口' ) , '浏览器拦截了客户工作台窗口,请允许弹窗后重试' )
return
}
impersonationSubmitting . value = true
impersonationError . value = null
let handoffPending = false
try {
const ticket = await createPlatformTenantImpersonation ( impersonationTenant . value . id , { reason : impersonationForm . reason . trim ( ) } )
const onReady = ( event : MessageEvent ) => {
if ( event . source !== popup || event . origin !== targetOrigin || event . data ? . source !== 'chongchong-client' || event . data ? . type !== 'impersonation-ready' ) return
popup . postMessage ( { source : 'chongchong-admin' , type : 'impersonation-ticket' , code : ticket . code } , targetOrigin )
cleanup ( )
impersonationSubmitting . value = false
ElMessage . success ( '客户工作台已打开' )
impersonationDialogOpen . value = false
}
const timeout = window . setTimeout ( ( ) => {
cleanup ( )
impersonationSubmitting . value = false
impersonationError . value = getApiErrorDetails ( new Error ( '客户工作台未完成安全握手' ) , '客户工作台未完成安全握手,请关闭窗口后重试' )
} , 10000 )
const cleanup = ( ) => {
window . clearTimeout ( timeout )
window . removeEventListener ( 'message' , onReady )
if ( impersonationCleanup === cleanup ) impersonationCleanup = null
}
impersonationCleanup ? . ( )
impersonationCleanup = cleanup
window . addEventListener ( 'message' , onReady )
handoffPending = true
} catch ( error ) {
popup . close ( )
impersonationError . value = getApiErrorDetails ( error , '跳转客户工作台失败,请稍后重试' )
} finally {
if ( ! handoffPending ) impersonationSubmitting . value = false
}
}
function openArchive ( row : PlatformTenantResource ) {
archiveTenant . value = row
archiveConfirmCode . value = ''
archiveError . value = null
archiveDialogOpen . value = true
}
async function submitArchive ( ) {
if ( ! archiveTenant . value || archiveSubmitting . value || archiveConfirmCode . value . trim ( ) !== archiveTenant . value . tenantCode ) return
archiveSubmitting . value = true
archiveError . value = null
try {
await archivePlatformTenant ( archiveTenant . value . id )
ElMessage . success ( '客户已删除,历史数据已保留且账号已停用' )
archiveDialogOpen . value = false
archiveTenant . value = null
archiveConfirmCode . value = ''
await loadCustomers ( )
} catch ( error ) {
archiveError . value = getApiErrorDetails ( error , '客户删除失败,当前客户仍保留' )
} finally {
archiveSubmitting . value = false
}
}
function openDecision ( row : PlatformTenantResource , type : 'approve' | 'reject' ) {
function openDecision ( row : PlatformTenantResource , type : 'approve' | 'reject' ) {
if ( reviewSubmitting . value ) return
if ( reviewSubmitting . value ) return
const review = pendingReviewByTenantId . value . get ( String ( row . id ) )
const review = pendingReviewByTenantId . value . get ( String ( row . id ) )
@@ -681,7 +849,15 @@ watch(
if ( ( decision . type === 'approve' && ! canApprove ) || ( decision . type === 'reject' && ! canReject ) ) decisionOpen . value = false
if ( ( decision . type === 'approve' && ! canApprove ) || ( decision . type === 'reject' && ! canReject ) ) decisionOpen . value = false
} ,
} ,
)
)
onBeforeUnmount ( clearQualificationPreviews )
watch ( ( ) => decision . type , ( type ) => {
if ( ! decisionOpen . value ) return
decision . comment = type === 'approve' ? '企业资料与必填资质完整、清晰,审核通过' : ''
void nextTick ( ( ) => decisionFormRef . value ? . clearValidate ( 'comment' ) )
} )
onBeforeUnmount ( ( ) => {
clearQualificationPreviews ( )
impersonationCleanup ? . ( )
} )
onMounted ( ( ) => { void loadCustomers ( ) } )
onMounted ( ( ) => { void loadCustomers ( ) } )
< / script >
< / script >
@@ -694,7 +870,7 @@ onMounted(() => { void loadCustomers() })
< DataTable v -model :page = "page" v -model :page-size = "pageSize" :data = "result.records" :columns = "customerColumns" preference -key = " customers -table " :loading = "loading" :total = "result.total" aria -label = " 客户列表 " row -key = " id " empty -text = " 暂无符合筛选条件的客户 " >
< DataTable v -model :page = "page" v -model :page-size = "pageSize" :data = "result.records" :columns = "customerColumns" preference -key = " customers -table " :loading = "loading" :total = "result.total" aria -label = " 客户列表 " row -key = " id " empty -text = " 暂无符合筛选条件的客户 " >
< template # toolbar -start > < div class = "customers-table-heading" > < h2 > 客户列表 < / h2 > < span > 共 { { result . total } } 个客户 < / span > < / div > < / template >
< template # toolbar -start > < div class = "customers-table-heading" > < h2 > 客户列表 < / h2 > < span > 共 { { result . total } } 个客户 < / span > < / div > < / template >
< template # toolbar -actions > < div class = "customers-table-actions" > < el-button v-if = "access.can('platform:customer:export') && access.can('platform:download:list')" :loading="exportSubmitting" :disabled="loading || exportSubmitting" @click="exportCustomers"><Download :size="16" />导出Excel</el-button><el-button v-if="access.can('platform:customer:create')" type="primary" :disabled="loading" @click="openCreate"><Plus :size="16" / > 新增客户 < / el-button > < / div > < / template >
< template # toolbar -actions > < div class = "customers-table-actions" > < el-button v-if = "access.can('platform:customer:export') && access.can('platform:download:list')" :loading="exportSubmitting" :disabled="loading || exportSubmitting" @click="exportCustomers"><Download :size="16" />导出Excel</el-button><el-button v-if="access.can('platform:customer:create')" type="primary" :disabled="loading" @click="openCreate"><Plus :size="16" / > 新增客户 < / el-button > < / div > < / template >
< template # actions = "{ row }" > < div class = "table-actions" > < el-button v-if = "access.can('platform:customer:view')" 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="pendingReviewByTenantId.has(String((row as PlatformTenantResource).id)) && access.can('platform:customer:approve' )" link type="success " :disabled="reviewSubmitting" @click="openDecision(row as PlatformTenantResource, 'approve ')"><ShieldCheck :size="15" />通过 </el-button><el-button v-if="pendingReviewByTenantId.has(String((row as PlatformTenantResource).id)) && access.can('platform:customer:reject') " link type="danger " :disabled="review Submitting" @click="openDecis ion(row as PlatformTenantResource, 'reject')"><ShieldX :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 # 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="pendingReviewByTenantId.has(String((row as PlatformTenantResource).id)) && access.can('platform:customer:review') && (access.can('platform:customer:approve') || access.can('platform:customer:reject') )" link type="warning " :disabled="reviewSubmitting" @click="openDecision(row as PlatformTenantResource, access.can('platform:customer:approve') ? 'approve' : 'reject ')"><ShieldCheck :size="15" />审核 </el-button><el-button v-if="access.can('platform:customer:reset-password') && ['ACTIVE','DISABLED'].includes((row as PlatformTenantResource).status)" link type="primary" :disabled="passwordSubmitting" @click="openResetPassword(row as PlatformTenantResource)"><KeyRound :size="15" />重置密码</el-button><el-button v-if="access.can('platform:customer:impersonate') && (row as PlatformTenantResource).status === 'ACTIVE' " link type="primary " :disabled="impersonation Submitting" @click="openImpersonat ion(row as PlatformTenantResource)"><LogIn :size="15" />跳转登录</el-button><el-button v-if="access.can('platform:customer:delete')" link type="danger" :disabled="archiveSubmitting" @click="openArchive(row as PlatformTenantResource)"><Trash2 :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 # 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 # status = "{ row }" > < el-tag : type = "statusMeta((row as PlatformTenantResource).status).type" effect = "light" > { { statusMeta ( ( row as PlatformTenantResource ) . status ) . label } } < / el-tag > < / template >
< template # orderCount = "{ row }" > { { access . can ( 'platform:order:list' ) ? ( orderCounts [ String ( ( row as PlatformTenantResource ) . id ) ] ? ? '—' ) : '—' } } < / template >
< template # orderCount = "{ row }" > { { access . can ( 'platform:order:list' ) ? ( orderCounts [ String ( ( row as PlatformTenantResource ) . id ) ] ? ? '—' ) : '—' } } < / template >
@@ -767,11 +943,39 @@ onMounted(() => { void loadCustomers() })
< el-dialog v-model = "decisionOpen" :title="decision.type === 'approve' ? '确认审核通过' : '确认驳回企业资料'" width="min(520px, calc(100vw - 28px))" class="review-decision-dialog" :close-on-click-modal="false" >
< el-dialog v-model = "decisionOpen" :title="decision.type === 'approve' ? '确认审核通过' : '确认驳回企业资料'" width="min(520px, calc(100vw - 28px))" class="review-decision-dialog" :close-on-click-modal="false" >
< div v-if = "selectedReview" class="decision-summary" > < strong > { { selectedReview . tenantName || '当前客户' } } < / strong > < span > 审核单 { { selectedReview . id } } < / span > < / div >
< div v-if = "selectedReview" class="decision-summary" > < strong > { { selectedReview . tenantName || '当前客户' } } < / strong > < span > 审核单 { { selectedReview . id } } < / span > < / div >
< el-radio-group v-if = "access.can('platform:customer:approve') && access.can('platform:customer:reject')" v-model="decision.type" class="decision-type" aria-label="审核决定"><el-radio-button label="approve">通过</el-radio-button><el-radio-button label="reject" > 驳回 < / el -radio -button > < / el-radio-group >
< el-form ref = "decisionFormRef" :model = "decision" :rules = "decisionRules" label -position = " top " >
< el-form ref = "decisionFormRef" :model = "decision" :rules = "decisionRules" label -position = " top " >
< el-form-item prop = "comment" : label = "decision.type === 'approve' ? '审核说明' : '驳回原因'" required > < el-input v-model = "decision.comment" type="textarea" :rows="4" maxlength="300" show-word-limit :placeholder="decision.type === 'approve' ? '请填写审核通过说明' : '请明确说明需要企业修改的内容'" / > < / el-form-item >
< el-form-item prop = "comment" : label = "decision.type === 'approve' ? '审核说明' : '驳回原因'" required > < el-input v-model = "decision.comment" type="textarea" :rows="4" maxlength="300" show-word-limit :placeholder="decision.type === 'approve' ? '请填写审核通过说明' : '请明确说明需要企业修改的内容'" / > < / el-form-item >
< / el-form >
< / el-form >
< template # footer > < div class = "drawer-footer" > < el-button :disabled = "reviewSubmitting" @click ="decisionOpen = false" > 取消 < / el -button > < el-button : type = "decision.type === 'approve' ? 'success' : 'danger'" :loading = "reviewSubmitting" @click ="submitDecision" > {{ decision.type = = = ' approve ' ? ' 确认通过 ' : ' 确认驳回 ' }} < / el -button > < / div > < / template >
< template # footer > < div class = "drawer-footer" > < el-button :disabled = "reviewSubmitting" @click ="decisionOpen = false" > 取消 < / el -button > < el-button : type = "decision.type === 'approve' ? 'success' : 'danger'" :loading = "reviewSubmitting" @click ="submitDecision" > {{ decision.type = = = ' approve ' ? ' 确认通过 ' : ' 确认驳回 ' }} < / el -button > < / div > < / template >
< / el-dialog >
< / el-dialog >
< el-dialog v-model = "passwordDialogOpen" title="重置客户密码" width="min(560px, calc(100vw - 28px))" :close-on-click-modal="false" :close-on-press-escape="!passwordSubmitting" :show-close="!passwordSubmitting" >
< div v-if = "passwordTenant" class="decision-summary" > < div > < strong > 正在重置企业管理员登录密码 < / strong > < span > { { passwordTenant . tenantName } } < / span > < / div > < span > 客户ID : { { passwordTenant . tenantCode } } < / span > < / div >
< el-alert v-if = "passwordError" type="error" :closable="false" show-icon role="alert"><template #title><div class="api-error-alert-title"><span>{{ passwordError.message }}</span><ApiErrorMeta :details="passwordError" / > < / div > < / template > < / el-alert >
< el-form ref = "passwordFormRef" :model = "passwordForm" :rules = "passwordRules" label -position = " top " >
< el-form-item label = "新密码" prop = "newPassword" required > < div class = "password-input-row" > < el-input v-model = "passwordForm.newPassword" type="password" show-password autocomplete="new-password" placeholder="请输入 8-20 位新密码" /><el-button @click="randomPassword" > 随机生成 < / el -button > < / div > < / el-form-item >
< el-form-item label = "确认新密码" prop = "confirmPassword" required > < el-input v-model = "passwordForm.confirmPassword" type="password" show-password autocomplete="new-password" placeholder="请再次输入新密码" / > < / el-form-item >
< el-checkbox v-model = "passwordForm.forceChangePassword" > 客户下次登录时必须修改密码 < / el -checkbox >
< p class = "dialog-hint" > 重置对象为企业 OWNER 管理员 , 原密码将立即失效 。 < / p >
< / el-form >
< template # footer > < div class = "drawer-footer" > < el-button :disabled = "passwordSubmitting" @click ="passwordDialogOpen = false" > 取消 < / el -button > < el-button type = "primary" :loading = "passwordSubmitting" @click ="submitResetPassword" > 确认重置 < / el -button > < / div > < / template >
< / el-dialog >
< el-dialog v-model = "impersonationDialogOpen" title="跳转登录客户工作台" width="min(560px, calc(100vw - 28px))" :close-on-click-modal="false" :close-on-press-escape="!impersonationSubmitting" :show-close="!impersonationSubmitting" >
< div v-if = "impersonationTenant" class="decision-summary" > < div > < strong > { { impersonationTenant . tenantName } } < / strong > < span > 将以企业管理员身份打开客户工作台 < / span > < / div > < span > 客户ID : { { impersonationTenant . tenantCode } } < / span > < / div >
< el-alert type = "warning" :closable = "false" show -icon > 本操作会生成一次性 、 短时有效的客户会话 , 并记录代登录原因 ; 不会替换当前 Admin 会话 。 < / el-alert >
< el-alert v-if = "impersonationError" type="error" :closable="false" show-icon role="alert"><template #title><div class="api-error-alert-title"><span>{{ impersonationError.message }}</span><ApiErrorMeta :details="impersonationError" / > < / div > < / template > < / el-alert >
< el-form ref = "impersonationFormRef" :model = "impersonationForm" :rules = "impersonationRules" label -position = " top " > < el-form-item label = "代登录原因" prop = "reason" required > < el-input v-model = "impersonationForm.reason" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请填写 5-500 字的业务原因" / > < / el-form-item > < / el-form >
< template # footer > < div class = "drawer-footer" > < el-button :disabled = "impersonationSubmitting" @click ="impersonationDialogOpen = false" > 取消 < / el -button > < el-button type = "primary" :loading = "impersonationSubmitting" @click ="submitImpersonation" > 确认跳转 < / el -button > < / div > < / template >
< / el-dialog >
< el-dialog v-model = "archiveDialogOpen" title="删除客户" width="min(560px, calc(100vw - 28px))" :close-on-click-modal="false" :close-on-press-escape="!archiveSubmitting" :show-close="!archiveSubmitting" >
< el -alert type = "warning" :closable = "false" show -icon > 删除是逻辑归档 : 历史业务数据会保留 , 客户账号会被停用 。 是否允许归档由服务端最终判断 。 < / el-alert >
< div v-if = "archiveTenant" class="archive-confirm"><strong>{{ archiveTenant.tenantName }}</strong><span>请输入客户编号 {{ archiveTenant.tenantCode }} 以确认。</span><el-input v-model="archiveConfirmCode" :disabled="archiveSubmitting" :placeholder="archiveTenant.tenantCode" autocomplete="off" / > < / div >
< el-alert v-if = "archiveError" type="error" :closable="false" show-icon role="alert"><template #title><div class="api-error-alert-title"><span>{{ archiveError.message }}</span><ApiErrorMeta :details="archiveError" / > < / div > < / template > < / el-alert >
< template # footer > < div class = "drawer-footer" > < el-button :disabled = "archiveSubmitting" @click ="archiveDialogOpen = false" > 取消 < / el -button > < el-button type = "danger" :loading = "archiveSubmitting" : disabled = "!archiveTenant || archiveConfirmCode.trim() !== archiveTenant.tenantCode" @click ="submitArchive" > 确认删除 < / el -button > < / div > < / template >
< / el-dialog >
< / div >
< / div >
< / template >
< / template >
@@ -806,7 +1010,8 @@ onMounted(() => { void loadCustomers() })
. customer - editor : deep ( . el - drawer _ _footer ) { flex : 0 0 auto ; }
. customer - editor : deep ( . el - drawer _ _footer ) { flex : 0 0 auto ; }
. 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 - 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 ; }
. decision - summary { display : flex ; min - width : 0 ; align - items : center ; justify - content : space - between ; gap : 12 px ; margin - bottom : 18 px ; padding : 12 px 14 px ; border - radius : 8 px ; background : # f8fafc ; } . decision - summary > * { min - width : 0 ; overflow - wrap : anywhere ; } . decision - summary strong { color : var ( -- cc - text ) ; font - size : 13 px ; } . decision - summary span { color : var ( -- cc - muted ) ; font - size : 11 px ; }
. decision - summary { display : flex ; min - width : 0 ; align - items : center ; justify - content : space - between ; gap : 12 px ; margin - bottom : 18 px ; padding : 12 px 14 px ; border - radius : 8 px ; background : # f8fafc ; } . decision - summary > * { min - width : 0 ; overflow - wrap : anywhere ; } . decision - summary strong { color : var ( -- cc - text ) ; font - size : 13 px ; } . decision - summary span { color : var ( -- cc - muted ) ; font - size : 11 px ; }
. decision - summary > div > span { display : block ; margin - top : 4 px ; } . decision - type { margin : 0 0 16 px ; } . password - input - row { display : grid ; grid - template - columns : minmax ( 0 , 1 fr ) auto ; width : 100 % ; gap : 8 px ; } . dialog - hint { margin : 12 px 0 0 ; color : var ( -- cc - muted ) ; font - size : 12 px ; line - height : 1.6 ; } . archive - confirm { display : grid ; gap : 8 px ; margin : 16 px 0 ; } . archive - confirm strong { color : var ( -- cc - text ) ; } . archive - confirm span { color : var ( -- cc - muted ) ; font - size : 12 px ; }
. approval - result - warning { display : flex ; align - items : flex - start ; gap : 10 px ; padding : 12 px 14 px ; border : 1 px solid # fecdca ; border - radius : 8 px ; background : # fffafa ; color : # b42318 ; } . approval - result - warning strong , . approval - result - warning span { display : block ; } . approval - result - warning strong { font - size : 14 px ; } . approval - result - warning span { margin - top : 4 px ; font - size : 12 px ; line - height : 1.5 ; } . approval - credentials { margin : 18 px 0 0 ; border : 1 px solid var ( -- cc - border ) ; border - radius : 8 px ; } . approval - credentials > div { display : grid ; grid - template - columns : 90 px minmax ( 0 , 1 fr ) ; gap : 12 px ; padding : 12 px 14 px ; } . approval - credentials > div + div { border - top : 1 px solid var ( -- cc - border ) ; } . approval - credentials dt { color : var ( -- cc - muted ) ; font - size : 12 px ; } . approval - credentials dd { display : flex ; min - width : 0 ; align - items : center ; gap : 10 px ; margin : 0 ; } . approval - credentials code { min - width : 0 ; flex : 1 ; overflow - wrap : anywhere ; color : var ( -- cc - text ) ; font - size : 14 px ; font - weight : 700 ; }
. approval - result - warning { display : flex ; align - items : flex - start ; gap : 10 px ; padding : 12 px 14 px ; border : 1 px solid # fecdca ; border - radius : 8 px ; background : # fffafa ; color : # b42318 ; } . approval - result - warning strong , . approval - result - warning span { display : block ; } . approval - result - warning strong { font - size : 14 px ; } . approval - result - warning span { margin - top : 4 px ; font - size : 12 px ; line - height : 1.5 ; } . approval - credentials { margin : 18 px 0 0 ; border : 1 px solid var ( -- cc - border ) ; border - radius : 8 px ; } . approval - credentials > div { display : grid ; grid - template - columns : 90 px minmax ( 0 , 1 fr ) ; gap : 12 px ; padding : 12 px 14 px ; } . approval - credentials > div + div { border - top : 1 px solid var ( -- cc - border ) ; } . approval - credentials dt { color : var ( -- cc - muted ) ; font - size : 12 px ; } . approval - credentials dd { display : flex ; min - width : 0 ; align - items : center ; gap : 10 px ; margin : 0 ; } . approval - credentials code { min - width : 0 ; flex : 1 ; overflow - wrap : anywhere ; color : var ( -- cc - text ) ; font - size : 14 px ; font - weight : 700 ; }
@ media ( max - width : 720 px ) { . customers - error , . 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 ; } }
@ media ( max - width : 720 px ) { . customers - error , . 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 ; } . password - input - row { grid - template - columns : 1 fr ; } . table - actions : deep ( . el - button ) { min - height : 40 px ; } }
@ media ( max - width : 360 px ) { . customers - table - actions , . capital - input { grid - template - columns : 1 fr ; } }
@ media ( max - width : 360 px ) { . customers - table - actions , . capital - input { grid - template - columns : 1 fr ; } }
< / style >
< / style >