feat: 完善客户列表管理操作

This commit is contained in:
2026-08-08 10:14:07 +08:00
parent 70b8289ff5
commit 6f709267b7
7 changed files with 334 additions and 10 deletions
+1
View File
@@ -1,2 +1,3 @@
VITE_API_BASE_URL=/api
API_PROXY_TARGET=http://8.163.30.128:9000
VITE_CLIENT_WEB_URL=http://localhost:5176
+2
View File
@@ -16,6 +16,8 @@ RUN pnpm config set registry https://registry.npmmirror.com && \
pnpm install --frozen-lockfile
COPY . .
ARG VITE_CLIENT_WEB_URL
ENV VITE_CLIENT_WEB_URL=${VITE_CLIENT_WEB_URL}
# Docker 构建时同机还会运行多项服务,限制 Vitest 并发以避免测试超时。
# pnpm 会直接把脚本名后的参数传给 Vitest;额外的 `--` 会让 Vitest
# 把 maxWorkers 当作位置参数,导致并发限制不生效。
+2
View File
@@ -3,6 +3,8 @@ services:
build:
context: .
dockerfile: Dockerfile
args:
VITE_CLIENT_WEB_URL: "${VITE_CLIENT_WEB_URL:-http://8.163.30.128:9002}"
image: admin-web-test:local
container_name: admin-web-test
restart: unless-stopped
@@ -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 () => {
+210 -5
View File
@@ -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>
+21
View File
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import {
approveEnterpriseReview,
archivePlatformTenant,
assignPlatformInternalUserRoles,
changePlatformManagedUserStatus,
changePlatformInternalUserStatus,
@@ -12,6 +13,7 @@ import {
createPlatformManagedUser,
createPlatformRole,
createPlatformTenant,
createPlatformTenantImpersonation,
deletePlatformPermission,
deletePlatformRole,
downloadTenantQualification,
@@ -27,6 +29,7 @@ import {
replacePlatformRolePermissions,
resetPlatformInternalUserPassword,
resetPlatformManagedUserPassword,
resetPlatformTenantOwnerPassword,
submitPlatformTenantReview,
updatePlatformInternalUser,
updatePlatformManagedUser,
@@ -290,6 +293,24 @@ describe('platform IAM roles', () => {
expect(http.put).toHaveBeenNthCalledWith(2, '/platform/v1/iam/tenants/8/status', { status: 'DISABLED' })
})
it('supports owner password reset, logical archive, and audited impersonation contracts', async () => {
const password = { newPassword: 'NewPassword#2026', forceChangePassword: true }
const archivedTenant = { id: '190000000000000099', tenantCode: 'TEN99', tenantName: '测试客户', status: 'DISABLED' }
const impersonation = { code: 'ticket-code', ticketId: 'ticket-id', expiresIn: 60 }
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: null })
vi.mocked(http.post)
.mockResolvedValueOnce({ code: '200', message: 'success', data: archivedTenant })
.mockResolvedValueOnce({ code: '200', message: 'success', data: impersonation })
await expect(resetPlatformTenantOwnerPassword('190000000000000099', password)).resolves.toBeNull()
await expect(archivePlatformTenant('190000000000000099')).resolves.toEqual(archivedTenant)
await expect(createPlatformTenantImpersonation('190000000000000099', { reason: '客户问题排查' })).resolves.toEqual(impersonation)
expect(http.put).toHaveBeenCalledWith('/platform/v1/iam/tenants/190000000000000099/owner/password', password)
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/iam/tenants/190000000000000099/archive', {})
expect(http.post).toHaveBeenNthCalledWith(2, '/platform/v1/iam/tenants/190000000000000099/impersonation', { reason: '客户问题排查' })
})
it('loads real order totals for the visible customer tenants', async () => {
const counts = [{ tenantId: '8', orderCount: 12 }, { tenantId: '9', orderCount: 0 }]
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: counts })
+33
View File
@@ -141,6 +141,21 @@ export interface EnterpriseApprovalResponse {
temporaryPassword: string | null
}
export interface ResetPlatformTenantOwnerPasswordRequest {
newPassword: string
forceChangePassword: boolean
}
export interface CreatePlatformTenantImpersonationRequest {
reason: string
}
export interface PlatformTenantImpersonationResponse {
code: string
ticketId: string
expiresIn: number
}
export interface PlatformRoleResource {
id: number | string
tenantId: number | string
@@ -350,6 +365,24 @@ export function submitPlatformTenantReview(tenantId: number | string) {
return postData<PlatformTenantResource>(`/platform/v1/iam/tenants/${tenantId}/submit-review`, {})
}
export function resetPlatformTenantOwnerPassword(
tenantId: number | string,
payload: ResetPlatformTenantOwnerPasswordRequest,
) {
return putData<void>(`/platform/v1/iam/tenants/${tenantId}/owner/password`, payload)
}
export function archivePlatformTenant(tenantId: number | string) {
return postData<PlatformTenantResource>(`/platform/v1/iam/tenants/${tenantId}/archive`, {})
}
export function createPlatformTenantImpersonation(
tenantId: number | string,
payload: CreatePlatformTenantImpersonationRequest,
) {
return postData<PlatformTenantImpersonationResponse>(`/platform/v1/iam/tenants/${tenantId}/impersonation`, payload)
}
export async function uploadPlatformTenantQualification(
tenantId: number | string,
qualificationType: PlatformQualificationType,