feat(phase3): complete loading workbenches

This commit is contained in:
2026-08-10 17:42:17 +08:00
parent 820432a381
commit 1595fbcae2
34 changed files with 1352 additions and 159 deletions
@@ -101,7 +101,7 @@ function tableButtonByText(text: string) {
}
async function clickMoreAction(text: string) {
document.querySelector<HTMLButtonElement>('.el-table__body button[aria-label="更多客户操作"]')?.click()
document.querySelector<HTMLButtonElement>('.el-table__body button[aria-label^="更多客户操作:"]')?.click()
await flushPromises()
const item = [...document.querySelectorAll<HTMLElement>('.el-dropdown-menu__item')]
.find((candidate) => candidate.textContent?.trim() === text && !candidate.classList.contains('is-disabled'))
@@ -455,7 +455,8 @@ describe('CustomersView', () => {
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
await clickMoreAction('重置密码')
tableButtonByText('重置密码')?.click()
await flushPromises()
const password = document.querySelector<HTMLInputElement>('input[autocomplete="new-password"]')
const confirmation = document.querySelector<HTMLInputElement>('input[placeholder="请再次输入新密码"]')
@@ -479,11 +480,10 @@ describe('CustomersView', () => {
useAccessPreviewStore().initializeAfterLogin()
vi.mocked(archivePlatformTenant).mockResolvedValue({ ...fullTenant, status: 'DISABLED' })
const wrapper = mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
const dropdowns = wrapper.findAllComponents({ name: 'ElDropdown' })
expect(dropdowns.length).toBeGreaterThan(0)
dropdowns.at(-1)!.vm.$emit('command', 'delete')
expect(document.querySelector('.el-table__body button[aria-label^="更多客户操作:"]')).toBeNull()
tableButtonByText('删除')?.click()
await flushPromises()
const confirmation = document.querySelector<HTMLInputElement>('input[placeholder="TEN8"]')
@@ -519,9 +519,53 @@ describe('CustomersView', () => {
expect(tableButtonByText('详情')).toBeDefined()
expect(tableButtonByText('编辑')).toBeDefined()
expect(document.querySelector('.el-table__body button[aria-label="更多客户操作"]')).toBeTruthy()
expect(tableButtonByText('重置密码')).toBeUndefined()
await clickMoreAction('重置密码')
expect(document.querySelector('input[autocomplete="new-password"]')).toBeTruthy()
expect(document.querySelector('.el-table__body button[aria-label="更多客户操作:柏悦物流"]')).toBeTruthy()
expect(tableButtonByText('重置密码')).toBeDefined()
expect(tableButtonByText('跳转登录')).toBeUndefined()
await clickMoreAction('跳转登录')
expect(document.body.textContent).toContain('代登录原因')
})
it('uses stable unique customer identities for multiple more-action buttons', async () => {
const auth = useAuthStore()
auth.permissions = [...auth.permissions, 'platform:customer:reset-password', 'platform:customer:impersonate', 'platform:customer:status', 'platform:customer:delete']
useAccessPreviewStore().initializeAfterLogin()
vi.mocked(listPlatformTenants).mockResolvedValue({
records: [
{ ...fullTenant, id: '8', tenantName: '客户甲', tenantCode: 'TEN-A', status: 'ACTIVE' },
{ ...fullTenant, id: '9', tenantName: '', tenantCode: 'TEN-B', status: 'ACTIVE' },
{ ...fullTenant, id: '10', tenantName: '', tenantCode: '', status: 'ACTIVE' },
],
page: 1, size: 10, total: 3, pages: 1,
})
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
const buttons = [...document.querySelectorAll<HTMLButtonElement>('.el-table__body button[aria-label^="更多客户操作:"]')]
expect(buttons.map((button) => button.getAttribute('aria-label'))).toEqual([
'更多客户操作:客户甲',
'更多客户操作:TEN-B',
'更多客户操作:10',
])
expect(buttons.map((button) => button.getAttribute('title'))).toEqual(buttons.map((button) => button.getAttribute('aria-label')))
})
it('falls back to the current customer when all row identifiers are missing', async () => {
const auth = useAuthStore()
auth.permissions = [...auth.permissions, 'platform:customer:reset-password', 'platform:customer:impersonate', 'platform:customer:status', 'platform:customer:delete']
useAccessPreviewStore().initializeAfterLogin()
vi.mocked(listPlatformTenants).mockResolvedValue({
records: [{ ...fullTenant, id: null, tenantName: '', tenantCode: '', status: 'ACTIVE' } as unknown as PlatformTenantResource],
page: 1, size: 10, total: 1, pages: 1,
})
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
const button = document.querySelector<HTMLButtonElement>('.el-table__body button[aria-label^="更多客户操作:"]')
expect(button?.getAttribute('aria-label')).toBe('更多客户操作:当前客户')
expect(button?.getAttribute('title')).toBe('更多客户操作:当前客户')
expect(button?.getAttribute('aria-label')).not.toMatch(/undefined|null/)
})
})
+60 -10
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch, type Component } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Building2, CheckCircle2, Download, Eye, FileText, FileUp, KeyRound, LogIn, MoreHorizontal, Pencil, Plus, Power, Save, ShieldCheck, ShieldX, Trash2 } from '@lucide/vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
@@ -633,8 +633,60 @@ function canChangeStatus(row: PlatformTenantResource) {
function canArchive() {
return access.can('platform:customer:delete')
}
function hasMoreActions(row: PlatformTenantResource) {
return canResetPassword(row) || canImpersonate(row) || canChangeStatus(row) || canArchive()
type CustomerRowAction = 'reset-password' | 'impersonate' | 'status' | 'delete'
const customerRowActionIcons: Record<CustomerRowAction, Component> = {
'reset-password': KeyRound,
impersonate: LogIn,
status: Power,
delete: Trash2,
}
function hasReviewAction(row: PlatformTenantResource) {
return pendingReviewByTenantId.value.has(String(row.id))
&& access.can('platform:customer:review')
&& (access.can('platform:customer:approve') || access.can('platform:customer:reject'))
}
function customerPrimaryActionCount(row: PlatformTenantResource) {
return Number(access.can('platform:customer:view'))
+ Number(access.can('platform:customer:view') && access.can('platform:customer:edit'))
+ Number(hasReviewAction(row))
}
function customerSecondaryActions(row: PlatformTenantResource): CustomerRowAction[] {
return [
...(canResetPassword(row) ? ['reset-password' as const] : []),
...(canImpersonate(row) ? ['impersonate' as const] : []),
...(canChangeStatus(row) ? ['status' as const] : []),
...(canArchive() ? ['delete' as const] : []),
]
}
function directCustomerActions(row: PlatformTenantResource) {
return customerSecondaryActions(row).slice(0, Math.max(0, 3 - customerPrimaryActionCount(row)))
}
function moreCustomerActions(row: PlatformTenantResource) {
return customerSecondaryActions(row).slice(Math.max(0, 3 - customerPrimaryActionCount(row)))
}
function customerMoreActionsLabel(row: PlatformTenantResource) {
const compatibleTenantId = (row as PlatformTenantResource & { tenantId?: unknown }).tenantId
const identifier = [row.tenantName, row.tenantCode, row.id, compatibleTenantId]
.map((value) => String(value ?? '').trim())
.find(Boolean)
return `更多客户操作:${identifier || '当前客户'}`
}
function customerActionLabel(row: PlatformTenantResource, action: CustomerRowAction) {
if (action === 'reset-password') return '重置密码'
if (action === 'impersonate') return '跳转登录'
if (action === 'status') return row.status === 'ACTIVE' ? '停用' : '启用'
return '删除'
}
function customerActionType(row: PlatformTenantResource, action: CustomerRowAction) {
if (action === 'delete' || (action === 'status' && row.status === 'ACTIVE')) return 'danger'
if (action === 'status') return 'success'
return 'primary'
}
function customerActionDisabled(action: CustomerRowAction) {
if (action === 'reset-password') return passwordSubmitting.value
if (action === 'impersonate') return impersonationSubmitting.value
if (action === 'delete') return archiveSubmitting.value
return false
}
function handleMoreAction(row: PlatformTenantResource, command: string) {
if (command === 'reset-password') openResetPassword(row)
@@ -917,15 +969,13 @@ onMounted(() => { void loadCustomers() })
<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-dropdown v-if="hasMoreActions(row as PlatformTenantResource)" trigger="click" :disabled="rowActionId === String((row as PlatformTenantResource).id)" popper-class="customer-action-menu" @command="handleMoreAction(row as PlatformTenantResource, String($event))">
<el-button link type="primary" aria-label="更多客户操作" title="更多操作"><MoreHorizontal :size="16" /></el-button>
<el-button v-if="hasReviewAction(row as PlatformTenantResource)" 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-for="action in directCustomerActions(row as PlatformTenantResource)" :key="action" link :type="customerActionType(row as PlatformTenantResource, action)" :disabled="customerActionDisabled(action)" @click="handleMoreAction(row as PlatformTenantResource, action)"><component :is="customerRowActionIcons[action]" :size="15" />{{ customerActionLabel(row as PlatformTenantResource, action) }}</el-button>
<el-dropdown v-if="moreCustomerActions(row as PlatformTenantResource).length" trigger="click" :disabled="rowActionId === String((row as PlatformTenantResource).id)" popper-class="customer-action-menu" @command="handleMoreAction(row as PlatformTenantResource, String($event))">
<el-button link type="primary" :aria-label="customerMoreActionsLabel(row as PlatformTenantResource)" :title="customerMoreActionsLabel(row as PlatformTenantResource)"><MoreHorizontal :size="16" /></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="canResetPassword(row as PlatformTenantResource)" command="reset-password" :disabled="passwordSubmitting"><KeyRound :size="15" />重置密码</el-dropdown-item>
<el-dropdown-item v-if="canImpersonate(row as PlatformTenantResource)" command="impersonate" :disabled="impersonationSubmitting"><LogIn :size="15" />跳转登录</el-dropdown-item>
<el-dropdown-item v-if="canChangeStatus(row as PlatformTenantResource)" command="status"><Power :size="15" />{{ (row as PlatformTenantResource).status === 'ACTIVE' ? '停用' : '启用' }}</el-dropdown-item>
<el-dropdown-item v-if="canArchive()" command="delete" :disabled="archiveSubmitting" divided class="customer-action-menu__danger"><Trash2 :size="15" />删除</el-dropdown-item>
<el-dropdown-item v-for="action in moreCustomerActions(row as PlatformTenantResource)" :key="action" :command="action" :disabled="customerActionDisabled(action)" :divided="action === 'delete'" :class="{ 'customer-action-menu__danger': action === 'delete' }"><component :is="customerRowActionIcons[action]" :size="15" />{{ customerActionLabel(row as PlatformTenantResource, action) }}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>