feat: 完善客户列表管理操作
This commit is contained in:
@@ -4,12 +4,15 @@ import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
approveEnterpriseReview,
|
||||
archivePlatformTenant,
|
||||
downloadTenantQualification,
|
||||
createPlatformTenantImpersonation,
|
||||
getPlatformTenant,
|
||||
listEnterpriseReviews,
|
||||
listPlatformTenants,
|
||||
listTenantQualifications,
|
||||
rejectEnterpriseReview,
|
||||
resetPlatformTenantOwnerPassword,
|
||||
updatePlatformTenant,
|
||||
uploadPlatformTenantQualification,
|
||||
uploadPlatformTenantRequiredQualificationPackage,
|
||||
@@ -45,9 +48,11 @@ vi.mock('vue-router', async (importOriginal) => ({
|
||||
|
||||
vi.mock('@/modules/iam', () => ({
|
||||
approveEnterpriseReview: vi.fn(),
|
||||
archivePlatformTenant: vi.fn(),
|
||||
changePlatformTenantStatus: vi.fn(),
|
||||
checkPlatformTenantContactPhone: vi.fn().mockResolvedValue(true),
|
||||
createPlatformTenant: vi.fn(),
|
||||
createPlatformTenantImpersonation: vi.fn(),
|
||||
downloadTenantQualification: vi.fn(),
|
||||
getPlatformTenant: vi.fn(),
|
||||
listEnterpriseReviews: vi.fn(),
|
||||
@@ -56,6 +61,7 @@ vi.mock('@/modules/iam', () => ({
|
||||
listTenantQualifications: vi.fn(),
|
||||
qualificationFileName: vi.fn((documentUrl: string) => documentUrl.split('/').at(-1) || '资质文件'),
|
||||
rejectEnterpriseReview: vi.fn(),
|
||||
resetPlatformTenantOwnerPassword: vi.fn(),
|
||||
submitPlatformTenantReview: vi.fn(),
|
||||
updatePlatformTenant: vi.fn(),
|
||||
uploadPlatformTenantQualification: vi.fn(),
|
||||
@@ -349,7 +355,7 @@ describe('CustomersView', () => {
|
||||
|
||||
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
||||
await flushPromises()
|
||||
tableButtonByText('通过')?.click()
|
||||
tableButtonByText('审核')?.click()
|
||||
await flushPromises()
|
||||
;[...document.querySelectorAll<HTMLButtonElement>('button')]
|
||||
.find((button) => button.textContent?.trim() === '确认通过')?.click()
|
||||
@@ -379,7 +385,7 @@ describe('CustomersView', () => {
|
||||
|
||||
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
||||
await flushPromises()
|
||||
tableButtonByText('通过')?.click()
|
||||
tableButtonByText('审核')?.click()
|
||||
await flushPromises()
|
||||
;[...document.querySelectorAll<HTMLButtonElement>('button')]
|
||||
.find((button) => button.textContent?.trim() === '确认通过')?.click()
|
||||
@@ -402,7 +408,7 @@ describe('CustomersView', () => {
|
||||
|
||||
expect(listPlatformTenants).not.toHaveBeenCalled()
|
||||
expect(document.body.textContent).toContain(fullTenant.tenantName)
|
||||
expect(tableButtonByText('通过')).toBeDefined()
|
||||
expect(tableButtonByText('审核')).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not let an older review response restore stale actions', async () => {
|
||||
@@ -423,11 +429,65 @@ describe('CustomersView', () => {
|
||||
|
||||
resolveSecond([pendingReview])
|
||||
await flushPromises()
|
||||
expect(tableButtonByText('通过')).toBeDefined()
|
||||
expect(tableButtonByText('审核')).toBeDefined()
|
||||
|
||||
resolveFirst([])
|
||||
await flushPromises()
|
||||
expect(tableButtonByText('通过')).toBeDefined()
|
||||
expect(tableButtonByText('审核')).toBeDefined()
|
||||
})
|
||||
|
||||
it('resets only the customer owner password through the tenant contract', async () => {
|
||||
const auth = useAuthStore()
|
||||
auth.permissions = [...auth.permissions, 'platform:customer:reset-password']
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [{ ...fullTenant, status: 'ACTIVE' }], page: 1, size: 10, total: 1, pages: 1 })
|
||||
vi.mocked(resetPlatformTenantOwnerPassword).mockResolvedValue()
|
||||
|
||||
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
||||
await flushPromises()
|
||||
tableButtonByText('重置密码')?.click()
|
||||
await flushPromises()
|
||||
|
||||
const password = document.querySelector<HTMLInputElement>('input[autocomplete="new-password"]')
|
||||
const confirmation = document.querySelector<HTMLInputElement>('input[placeholder="请再次输入新密码"]')
|
||||
password!.value = 'NewPassword#2026'
|
||||
password!.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
confirmation!.value = 'NewPassword#2026'
|
||||
confirmation!.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await flushPromises()
|
||||
;[...document.querySelectorAll<HTMLButtonElement>('button')]
|
||||
.find((button) => button.textContent?.trim() === '确认重置')?.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(resetPlatformTenantOwnerPassword).toHaveBeenCalledWith(fullTenant.id, {
|
||||
newPassword: 'NewPassword#2026', forceChangePassword: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('archives a customer only after its customer ID is explicitly confirmed', async () => {
|
||||
const auth = useAuthStore()
|
||||
auth.permissions = [...auth.permissions, 'platform:customer:delete']
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
vi.mocked(archivePlatformTenant).mockResolvedValue({ ...fullTenant, status: 'DISABLED' })
|
||||
|
||||
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
||||
await flushPromises()
|
||||
tableButtonByText('删除')?.click()
|
||||
await flushPromises()
|
||||
|
||||
const confirmation = document.querySelector<HTMLInputElement>('input[placeholder="TEN8"]')
|
||||
expect(confirmation).toBeTruthy()
|
||||
expect([...document.querySelectorAll<HTMLButtonElement>('button')]
|
||||
.find((button) => button.textContent?.trim() === '确认删除')?.disabled).toBe(true)
|
||||
|
||||
confirmation!.value = 'TEN8'
|
||||
confirmation!.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await flushPromises()
|
||||
;[...document.querySelectorAll<HTMLButtonElement>('button')]
|
||||
.find((button) => button.textContent?.trim() === '确认删除')?.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(archivePlatformTenant).toHaveBeenCalledWith(fullTenant.id)
|
||||
})
|
||||
|
||||
it('keeps the first operation column unfixed so it cannot cover a 320px table viewport', async () => {
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
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 { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { createPlatformExportTask } from '@/modules/downloads'
|
||||
import {
|
||||
approveEnterpriseReview,
|
||||
archivePlatformTenant,
|
||||
changePlatformTenantStatus,
|
||||
checkPlatformTenantContactPhone,
|
||||
createPlatformTenant,
|
||||
createPlatformTenantImpersonation,
|
||||
downloadTenantQualification,
|
||||
getPlatformTenant,
|
||||
listEnterpriseReviews,
|
||||
@@ -18,6 +20,7 @@ import {
|
||||
listTenantQualifications,
|
||||
qualificationFileName,
|
||||
rejectEnterpriseReview,
|
||||
resetPlatformTenantOwnerPassword,
|
||||
submitPlatformTenantReview,
|
||||
updatePlatformTenant,
|
||||
uploadPlatformTenantQualification,
|
||||
@@ -75,6 +78,24 @@ const approvalResultOpen = ref(false)
|
||||
const approvalResult = ref<EnterpriseApprovalResponse | null>(null)
|
||||
const decision = reactive({ type: 'approve' as 'approve' | 'reject', comment: '' })
|
||||
const decisionFormRef = ref<FormInstance>()
|
||||
const passwordDialogOpen = ref(false)
|
||||
const passwordSubmitting = ref(false)
|
||||
const passwordFormRef = ref<FormInstance>()
|
||||
const passwordTenant = ref<PlatformTenantResource | null>(null)
|
||||
const passwordForm = reactive({ newPassword: '', confirmPassword: '', forceChangePassword: true })
|
||||
const passwordError = ref<ApiErrorDetails | null>(null)
|
||||
const impersonationDialogOpen = ref(false)
|
||||
const impersonationSubmitting = ref(false)
|
||||
const impersonationFormRef = ref<FormInstance>()
|
||||
const impersonationTenant = ref<PlatformTenantResource | null>(null)
|
||||
const impersonationForm = reactive({ reason: '' })
|
||||
const impersonationError = ref<ApiErrorDetails | null>(null)
|
||||
const archiveDialogOpen = ref(false)
|
||||
const archiveSubmitting = ref(false)
|
||||
const archiveTenant = ref<PlatformTenantResource | null>(null)
|
||||
const archiveConfirmCode = ref('')
|
||||
const archiveError = ref<ApiErrorDetails | null>(null)
|
||||
let impersonationCleanup: (() => void) | null = null
|
||||
const editorOpen = ref(false)
|
||||
const editorLoading = ref(false)
|
||||
const saving = ref(false)
|
||||
@@ -158,7 +179,7 @@ const creditCodeEstablished = ref(false)
|
||||
const unifiedSocialCreditCodePattern = /^[0-9A-Z]{18}$/
|
||||
|
||||
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: 'company', label: '公司名称', minWidth: 230, slotName: 'company' },
|
||||
{ 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 pendingReviewByTenantId = computed(() => new Map(pendingReviews.value.map((review) => [String(review.tenantId), review])))
|
||||
const passwordRules: FormRules = {
|
||||
newPassword: [
|
||||
{ required: true, message: '请输入新密码', trigger: 'blur' },
|
||||
{ min: 8, max: 20, message: '密码长度为 8-20 位', trigger: 'blur' },
|
||||
{ pattern: /^(?=.*[A-Za-z])(?=.*\d).+$/, message: '密码必须同时包含字母和数字', trigger: 'blur' },
|
||||
],
|
||||
confirmPassword: [{
|
||||
validator: (_rule, value, callback) => value === passwordForm.newPassword ? callback() : callback(new Error('两次输入的密码不一致')),
|
||||
trigger: ['blur', 'change'],
|
||||
}],
|
||||
}
|
||||
const impersonationRules: FormRules = {
|
||||
reason: [
|
||||
{ required: true, message: '请输入代登录原因', trigger: 'blur' },
|
||||
{ min: 5, max: 500, message: '代登录原因需填写 5-500 字', trigger: 'blur' },
|
||||
],
|
||||
}
|
||||
function displayText(value?: string | null) { return value?.trim() || '—' }
|
||||
function formatDate(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
|
||||
function statusMeta(status: PlatformTenantStatus) {
|
||||
@@ -577,6 +615,136 @@ async function toggleStatus(row: PlatformTenantResource) {
|
||||
} 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') {
|
||||
if (reviewSubmitting.value) return
|
||||
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
|
||||
},
|
||||
)
|
||||
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() })
|
||||
</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="暂无符合筛选条件的客户">
|
||||
<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 #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="reviewSubmitting" @click="openDecision(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="impersonationSubmitting" @click="openImpersonation(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 #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>
|
||||
@@ -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">
|
||||
<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-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>
|
||||
<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 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>
|
||||
</template>
|
||||
|
||||
@@ -806,7 +1010,8 @@ onMounted(() => { void loadCustomers() })
|
||||
.customer-editor :deep(.el-drawer__footer) { flex:0 0 auto; }
|
||||
.customer-editor-footer { display:flex; align-items:center; justify-content:space-between; gap:16px; } .customer-editor-footer > span { color:var(--cc-muted); font-size:12px; text-align:left; } .customer-editor-footer > div { display:flex; flex:0 0 auto; gap:8px; } .customer-editor-footer :deep(.el-button + .el-button) { margin-left:0; }
|
||||
.decision-summary { display:flex; min-width:0; align-items:center; justify-content:space-between; gap:12px; margin-bottom:18px; padding:12px 14px; border-radius:8px; background:#f8fafc; } .decision-summary > * { min-width:0; overflow-wrap:anywhere; } .decision-summary strong { color:var(--cc-text); font-size:13px; } .decision-summary span { color:var(--cc-muted); font-size:11px; }
|
||||
.decision-summary > div > span { display:block; margin-top:4px; }.decision-type { margin:0 0 16px; }.password-input-row { display:grid; grid-template-columns:minmax(0,1fr) auto; width:100%; gap:8px; }.dialog-hint { margin:12px 0 0; color:var(--cc-muted); font-size:12px; line-height:1.6; }.archive-confirm { display:grid; gap:8px; margin:16px 0; }.archive-confirm strong { color:var(--cc-text); }.archive-confirm span { color:var(--cc-muted); font-size:12px; }
|
||||
.approval-result-warning { display:flex; align-items:flex-start; gap:10px; padding:12px 14px; border:1px solid #fecdca; border-radius:8px; background:#fffafa; color:#b42318; } .approval-result-warning strong,.approval-result-warning span { display:block; } .approval-result-warning strong { font-size:14px; } .approval-result-warning span { margin-top:4px; font-size:12px; line-height:1.5; } .approval-credentials { margin:18px 0 0; border:1px solid var(--cc-border); border-radius:8px; } .approval-credentials > div { display:grid; grid-template-columns:90px minmax(0,1fr); gap:12px; padding:12px 14px; } .approval-credentials > div + div { border-top:1px solid var(--cc-border); } .approval-credentials dt { color:var(--cc-muted); font-size:12px; } .approval-credentials dd { display:flex; min-width:0; align-items:center; gap:10px; margin:0; } .approval-credentials code { min-width:0; flex:1; overflow-wrap:anywhere; color:var(--cc-text); font-size:14px; font-weight:700; }
|
||||
@media (max-width:720px) { .customers-error,.customer-editor-footer,.review-sync-error { align-items:stretch; flex-direction:column; } .customers-table-actions { display:grid; grid-template-columns:1fr 1fr; width:100%; } .customers-table-actions :deep(.el-button) { min-height:40px; margin:0; } .qualification-table { overflow-x:auto; } .qualification-row { min-width:640px; } .customer-editor-footer > div { display:grid; grid-template-columns:repeat(auto-fit,minmax(110px,1fr)); width:100%; } .customer-editor-footer :deep(.el-button) { min-height:40px; margin:0; } .decision-summary { align-items:flex-start; flex-direction:column; } }
|
||||
@media (max-width:720px) { .customers-error,.customer-editor-footer,.review-sync-error { align-items:stretch; flex-direction:column; } .customers-table-actions { display:grid; grid-template-columns:1fr 1fr; width:100%; } .customers-table-actions :deep(.el-button) { min-height:40px; margin:0; } .qualification-table { overflow-x:auto; } .qualification-row { min-width:640px; } .customer-editor-footer > div { display:grid; grid-template-columns:repeat(auto-fit,minmax(110px,1fr)); width:100%; } .customer-editor-footer :deep(.el-button) { min-height:40px; margin:0; } .decision-summary { align-items:flex-start; flex-direction:column; }.password-input-row { grid-template-columns:1fr; }.table-actions :deep(.el-button) { min-height:40px; } }
|
||||
@media (max-width:360px) { .customers-table-actions,.capital-input { grid-template-columns:1fr; } }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user