style(admin): unify dialog layouts and dense readonly presentation
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import InternalUsersView from './InternalUsersView.vue'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import * as api from '../api/platformIam'
|
||||
import { listAllPlatformWarehouses } from '@/modules/warehouses'
|
||||
const { default: ElementPlus, ElDialog, ElSelect, ElCheckbox, ElMessageBox } = createRequire(import.meta.url)('element-plus') as typeof import('element-plus')
|
||||
vi.mock('../api/platformIam', () => ({ listPlatformManagedUsers: vi.fn(), getPlatformUserOptions: vi.fn(), listSharedReceivingOverseasWarehouseOptions: vi.fn(), createPlatformManagedUser: vi.fn(), updatePlatformManagedUser: vi.fn(), changePlatformManagedUserStatus: vi.fn(), resetPlatformManagedUserPassword: vi.fn() }))
|
||||
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
|
||||
const memberId = '9223372036854775807', warehouseId = '9223372036854775806'
|
||||
let record: api.PlatformManagedUser
|
||||
const table = { props: ['data'], template: '<div><slot name="toolbar-actions"/><div class="real-row"><slot v-if="data?.length" name="actions" :row="data[0]"/></div></div>' }
|
||||
function button(label: string, selector = 'button') {
|
||||
const found = Array.from(document.querySelectorAll<HTMLButtonElement>(selector)).find(node => node.textContent?.trim() === label)
|
||||
expect(found, label).toBeDefined(); return found!
|
||||
}
|
||||
async function view() {
|
||||
const wrapper = mount(InternalUsersView, { attachTo: document.body, global: { plugins: [ElementPlus], stubs: { DataTable: table, QueryToolbar: true } } }); await flushPromises(); return wrapper
|
||||
}
|
||||
async function editor(create = false) {
|
||||
const wrapper = await view(); button(create ? '新增用户' : '编辑').click(); await flushPromises(); return wrapper
|
||||
}
|
||||
async function text(placeholder: string, value: string) {
|
||||
const input = document.querySelector<HTMLInputElement>(`.el-dialog [placeholder="${placeholder}"]`)!
|
||||
input.value = value; input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); await flushPromises()
|
||||
}
|
||||
function select(wrapper: Awaited<ReturnType<typeof view>>, placeholder: string) {
|
||||
const found = wrapper.findAllComponents(ElSelect).find(node => node.props('placeholder') === placeholder)
|
||||
expect(found, placeholder).toBeDefined(); return found!
|
||||
}
|
||||
async function fillCreate(wrapper: Awaited<ReturnType<typeof view>>, roles = ['ROLE-OP']) {
|
||||
await text('登录用户名', 'new_operator'); await text('真实姓名', '新操作员'); await text('手机号码', '13800000000'); await text('邮箱地址', 'worker@example.test')
|
||||
select(wrapper, '选择角色').vm.$emit('update:modelValue', roles)
|
||||
await text('8-64 位,包含字母和数字', 'TestPassword123'); await text('再次输入密码', 'TestPassword123'); await flushPromises()
|
||||
}
|
||||
function permissions(values: string[]) { useAuthStore().permissions = ['platform:user:list', ...values]; useAccessPreviewStore().initializeAfterLogin() }
|
||||
enableAutoUnmount(afterEach)
|
||||
afterEach(() => ElMessageBox.close())
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks(); document.body.innerHTML = ''; setActivePinia(createPinia())
|
||||
useAuthStore().token = 'test-session'
|
||||
permissions(['platform:user:create','platform:user:edit','platform:user:status','platform:user:reset-password','platform:user:grant-role'])
|
||||
record = { membershipId: memberId, userId: '9223372036854775805', authUserId: '9223372036854775804', username: 'test_operator', displayName: '测试操作员', phone: '13800000000', email: 'worker@example.test', tenantId: '0', tenantName: '平台', memberType: 'INTERNAL', memberNo: 'EMP-TEST', status: 'ACTIVE', roleIds: ['ROLE-OP'], roleNames: ['运营角色'], lastLoginAt: '2026-10-20T10:30:00', protectedAccount: false, overseasWarehouseId: null }
|
||||
vi.mocked(api.listPlatformManagedUsers).mockImplementation(async () => ({ records: [record], total: 1, page: 1, size: 10, pages: 1 }))
|
||||
vi.mocked(api.getPlatformUserOptions).mockResolvedValue({ tenants: [{ id: '0', name: '平台', status: 'ACTIVE' }], roles: [
|
||||
{ id: 'ROLE-OP', tenantId: '0', roleCode: 'PLATFORM_OPERATOR', name: '运营角色', status: 'ACTIVE' },
|
||||
{ id: 'ROLE-OVER', tenantId: '0', roleCode: 'OVERSEAS_WAREHOUSE_PDA_OPERATOR', name: '海外PDA角色', status: 'ACTIVE' },
|
||||
{ id: 'ROLE-DOM', tenantId: '0', roleCode: 'CN_FIRSTLEG_PDA_OPERATOR', name: '国内PDA角色', status: 'ACTIVE' },
|
||||
] })
|
||||
vi.mocked(api.listSharedReceivingOverseasWarehouseOptions).mockResolvedValue([{ id: warehouseId, warehouseCode: 'US-TEST', warehouseName: '海外收货仓', countryCode: 'US' }])
|
||||
vi.mocked(listAllPlatformWarehouses).mockResolvedValue([{ id: warehouseId, tenantId: '0', warehouseCode: 'CN-TEST', internalCode: null, warehouseName: '国内作业仓', warehouseType: 'DOMESTIC', countryCode: 'CN', timezone: 'Asia/Shanghai', contactName: null, contactPhone: null, contactEmail: null, status: 'ACTIVE', remark: null }])
|
||||
vi.mocked(api.createPlatformManagedUser).mockImplementation(async () => record)
|
||||
vi.mocked(api.updatePlatformManagedUser).mockImplementation(async () => record)
|
||||
vi.mocked(api.changePlatformManagedUserStatus).mockImplementation(async (_id,status) => ({ ...record, status }))
|
||||
vi.mocked(api.resetPlatformManagedUserPassword).mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
describe('internal user unified dialogs', () => {
|
||||
it('creates a platform account and preserves draft through native fullscreen', async () => {
|
||||
const wrapper = await editor(true); await fillCreate(wrapper)
|
||||
const dialog = wrapper.findComponent(AppDialog)
|
||||
expect(dialog.props()).toMatchObject({ width: 'min(650px, calc(100vw - 28px))', fullscreenable: true, title: '新增用户' })
|
||||
document.querySelector<HTMLButtonElement>('[aria-label="展开全屏"]')!.click(); await flushPromises()
|
||||
expect(dialog.findComponent(ElDialog).props('fullscreen')).toBe(true)
|
||||
expect(document.querySelector<HTMLInputElement>('[placeholder="登录用户名"]')!.value).toBe('new_operator')
|
||||
button('确认添加', '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(api.createPlatformManagedUser).toHaveBeenCalledWith({ username: 'new_operator', displayName: '新操作员', phone: '13800000000', email: 'worker@example.test', tenantId: '0', roleIds: ['ROLE-OP'], overseasWarehouseId: null, password: 'TestPassword123', status: 'ACTIVE' })
|
||||
})
|
||||
|
||||
it('enforces required fields and matching initial passwords before creating', async () => {
|
||||
const wrapper = await editor(true)
|
||||
button('确认添加', '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(api.createPlatformManagedUser).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => expect(document.querySelector('.el-dialog')?.textContent).toContain('请输入用户名'))
|
||||
await fillCreate(wrapper); await text('再次输入密码', 'Mismatch123')
|
||||
button('确认添加', '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(api.createPlatformManagedUser).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => expect(document.querySelector('.el-dialog')?.textContent).toContain('两次输入的密码不一致'))
|
||||
})
|
||||
|
||||
it('edits the same membership with readonly login name and timestamp', async () => {
|
||||
const wrapper = await editor()
|
||||
expect(wrapper.findComponent(AppDialog).props('title')).toBe('编辑用户 · 测试操作员')
|
||||
expect(document.querySelector('.el-dialog [placeholder="登录用户名"]')).toBeNull()
|
||||
expect(document.querySelector('.el-dialog')?.textContent).toContain('test_operator')
|
||||
expect(document.querySelector('.el-dialog')?.textContent).toContain('2026-10-20 10:30')
|
||||
expect(document.querySelector('.el-dialog')?.textContent).not.toContain(memberId)
|
||||
await text('真实姓名', '更名操作员')
|
||||
button('保存修改', '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(api.updatePlatformManagedUser).toHaveBeenCalledWith(memberId, { displayName: '更名操作员', phone: record.phone, email: record.email, tenantId: '0', roleIds: ['ROLE-OP'], overseasWarehouseId: null })
|
||||
expect(api.resetPlatformManagedUserPassword).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['ROLE-OVER','ROLE-DOM'] as const)('requires and submits the original %s long warehouse binding', async role => {
|
||||
const wrapper = await editor(true); await fillCreate(wrapper, [role])
|
||||
button('确认添加', '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(api.createPlatformManagedUser).not.toHaveBeenCalled()
|
||||
const placeholder = role === 'ROLE-OVER' ? '请选择平台共享海外收货仓' : '请选择国内作业仓'
|
||||
select(wrapper, placeholder).vm.$emit('update:modelValue', warehouseId); await flushPromises()
|
||||
button('确认添加', '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(api.createPlatformManagedUser).toHaveBeenCalledWith(expect.objectContaining(role === 'ROLE-OVER' ? { overseasWarehouseId: warehouseId } : { overseasWarehouseId: null, domesticWarehouseId: warehouseId }))
|
||||
if (role === 'ROLE-DOM') expect(listAllPlatformWarehouses).toHaveBeenCalledWith({ warehouseType: 'DOMESTIC', status: 'ACTIVE' })
|
||||
})
|
||||
|
||||
it('recovers a shared warehouse load failure without discarding the user draft', async () => {
|
||||
vi.mocked(api.listSharedReceivingOverseasWarehouseOptions).mockRejectedValueOnce(new ApiResponseError('目录暂不可用', 'A0500', 500, null, 'req-pda-options'))
|
||||
const wrapper = await editor(true); await fillCreate(wrapper, ['ROLE-OVER'])
|
||||
expect(document.querySelector('.el-dialog .load-error-state')?.textContent).toContain('目录暂不可用')
|
||||
expect(document.querySelector('.el-dialog .load-error-state')?.textContent).toContain('req-pda-options')
|
||||
button('重新加载', '.el-dialog button').click(); await flushPromises()
|
||||
expect(api.listSharedReceivingOverseasWarehouseOptions).toHaveBeenCalledTimes(2)
|
||||
expect(document.querySelector('.el-dialog .load-error-state')).toBeNull()
|
||||
expect(document.querySelector<HTMLInputElement>('[placeholder="登录用户名"]')!.value).toBe('new_operator')
|
||||
})
|
||||
|
||||
it('keeps protected role and status as readonly facts and disables the row status action', async () => {
|
||||
record.protectedAccount = true
|
||||
const wrapper = await editor()
|
||||
expect(button('禁用', '.real-row button').disabled).toBe(true)
|
||||
expect(wrapper.findAllComponents(ElSelect)).toHaveLength(0)
|
||||
expect(document.querySelector('[aria-label="用户基本资料"]')?.textContent).toContain('运营角色')
|
||||
expect(document.querySelector('[aria-label="用户基本资料"]')?.textContent).toContain('正常')
|
||||
expect(api.changePlatformManagedUserStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows only the original status command when editing with status permission alone', async () => {
|
||||
permissions(['platform:user:status'])
|
||||
const wrapper = await editor()
|
||||
expect(document.querySelector('.el-dialog [placeholder="真实姓名"]')).toBeNull()
|
||||
expect(document.querySelector('.el-dialog [placeholder="手机号码"]')).toBeNull()
|
||||
expect(document.querySelector('[aria-label="用户登录密码"]')).toBeNull()
|
||||
wrapper.findComponent(ElSelect).vm.$emit('update:modelValue', 'DISABLED'); await flushPromises()
|
||||
button('保存修改', '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(api.changePlatformManagedUserStatus).toHaveBeenCalledWith(memberId, 'DISABLED')
|
||||
expect(api.updatePlatformManagedUser).not.toHaveBeenCalled(); expect(api.resetPlatformManagedUserPassword).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the separate reset command without changing profile for reset-only permission', async () => {
|
||||
permissions(['platform:user:reset-password'])
|
||||
const wrapper = await editor()
|
||||
expect(document.querySelector('.el-dialog [placeholder="真实姓名"]')).toBeNull()
|
||||
await wrapper.findComponent(ElCheckbox).get('input').setValue(true); await flushPromises()
|
||||
await text('8-64 位,包含字母和数字', 'ResetPassword123'); await text('再次输入密码', 'ResetPassword123')
|
||||
button('保存修改', '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(api.resetPlatformManagedUserPassword).toHaveBeenCalledWith(memberId, 'ResetPassword123')
|
||||
expect(api.updatePlatformManagedUser).not.toHaveBeenCalled(); expect(api.changePlatformManagedUserStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('locks all close paths during saving and retains editable values on failure', async () => {
|
||||
let rejectSave!: (error: Error) => void
|
||||
vi.mocked(api.updatePlatformManagedUser).mockImplementation(() => new Promise((_resolve,reject) => { rejectSave = reject }))
|
||||
const wrapper = await editor(); await text('真实姓名', '失败草稿')
|
||||
button('保存修改', '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(wrapper.findComponent(ElDialog).props()).toMatchObject({ showClose: false, closeOnPressEscape: false, closeOnClickModal: false })
|
||||
expect(button('取消', '.el-dialog__footer button').disabled).toBe(true)
|
||||
expect(document.querySelector<HTMLInputElement>('[placeholder="真实姓名"]')!.disabled).toBe(true)
|
||||
rejectSave(new Error('更新失败')); await flushPromises()
|
||||
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(true)
|
||||
expect(document.querySelector<HTMLInputElement>('[placeholder="真实姓名"]')!.value).toBe('失败草稿')
|
||||
})
|
||||
|
||||
it.each(['ACTIVE','DISABLED'] as const)('identifies %s status confirmation without exposing account IDs', async status => {
|
||||
record.status = status
|
||||
const verb = status === 'ACTIVE' ? '禁用' : '启用'
|
||||
await view(); button(verb, '.real-row button').click(); await flushPromises()
|
||||
expect(document.querySelector('.el-message-box__title')?.textContent).toBe(`${verb}用户 · 测试操作员`)
|
||||
expect(document.querySelector('.el-message-box')?.textContent).not.toContain(memberId)
|
||||
expect(api.changePlatformManagedUserStatus).not.toHaveBeenCalled()
|
||||
button(`确认${verb}`, '.el-message-box button').click(); await flushPromises()
|
||||
expect(api.changePlatformManagedUserStatus).toHaveBeenCalledWith(memberId, status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE')
|
||||
})
|
||||
|
||||
it('keeps creation hidden without grant-role permission', async () => {
|
||||
permissions(['platform:user:create'])
|
||||
const wrapper = await view()
|
||||
expect(wrapper.findAll('button').some(node => node.text() === '新增用户')).toBe(false)
|
||||
expect(api.createPlatformManagedUser).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -89,10 +89,11 @@ describe('InternalUsersView platform internal user management', () => {
|
||||
|
||||
it('uses the prototype-sized centered editor with a scrollable body and mobile single column', () => {
|
||||
expect(internalUsersSource).toContain('width="min(650px, calc(100vw - 28px))"')
|
||||
expect(internalUsersSource).toContain(':global(.internal-user-editor-dialog .el-dialog__body) { min-height: 0; overflow-y: auto; }')
|
||||
expect(internalUsersSource).toContain(':global(.internal-user-editor-dialog .el-dialog__footer) { flex: 0 0 auto; }')
|
||||
expect(internalUsersSource).toContain('fullscreenable destroy-on-close')
|
||||
expect(internalUsersSource).toContain(':global(.internal-user-editor-dialog.app-dialog.admin-density-dialog.el-dialog:not(.is-fullscreen)) { max-height: min(760px, calc(100vh - 32px)); }')
|
||||
expect(internalUsersSource).not.toContain(':global(.internal-user-editor-dialog .el-dialog__body)')
|
||||
expect(internalUsersSource).toContain('@media (max-width: 720px)')
|
||||
expect(internalUsersSource).toContain('.form-grid.two-cols { grid-template-columns: 1fr; }')
|
||||
expect(internalUsersSource).toContain('.user-editor-table tr { display: grid; }')
|
||||
})
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
|
||||
@@ -23,7 +23,7 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import FormSection from '@/shared/components/FormSection.vue'
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
@@ -98,7 +98,7 @@ const canEdit = computed(() => access.can('platform:user:edit'))
|
||||
const canGrantRoles = computed(() => access.can('platform:user:grant-role'))
|
||||
const canChangeStatus = computed(() => access.can('platform:user:status'))
|
||||
const canResetPassword = computed(() => access.can('platform:user:reset-password'))
|
||||
const editorLabel = computed(() => editingUser.value ? `编辑用户 - ${editingUser.value.displayName}` : '新增用户')
|
||||
const editorLabel = computed(() => editingUser.value ? `编辑用户 · ${editingUser.value.displayName?.trim() || editingUser.value.username?.trim() || '—'}` : '新增用户')
|
||||
|
||||
useUnsavedWorkRegistration({
|
||||
key: 'platform-user-editor',
|
||||
@@ -120,6 +120,9 @@ const domesticPdaRoleIds = computed(() => new Set(
|
||||
availableRoles.value.filter((role) => role.roleCode === 'CN_FIRSTLEG_PDA_OPERATOR').map((role) => String(role.id)),
|
||||
))
|
||||
const hasDomesticPdaRole = computed(() => form.roleIds.some((roleId) => domesticPdaRoleIds.value.has(roleId)))
|
||||
const selectedRoleNames = computed(() => form.roleIds.map((roleId) => availableRoles.value.find((role) => String(role.id) === roleId)?.name || '—').join('、') || '—')
|
||||
const selectedOverseasWarehouse = computed(() => overseasWarehouses.value.find((warehouse) => warehouse.id === form.overseasWarehouseId)?.warehouseName || '—')
|
||||
const selectedDomesticWarehouse = computed(() => domesticWarehouses.value.find((warehouse) => String(warehouse.id) === form.domesticWarehouseId)?.warehouseName || '—')
|
||||
|
||||
const formRules = computed<FormRules>(() => ({
|
||||
username: editingUser.value ? [] : [
|
||||
@@ -331,7 +334,7 @@ async function toggleStatus(row: PlatformManagedUser) {
|
||||
const targetStatus = row.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE'
|
||||
const targetLabel = targetStatus === 'ACTIVE' ? '启用' : '禁用'
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认${targetLabel}用户“${row.displayName}”吗?`, `${targetLabel}用户`, { customClass: 'admin-density-confirm',
|
||||
await ElMessageBox.confirm(`确认${targetLabel}用户“${row.displayName}”吗?`, `${targetLabel}用户 · ${row.displayName?.trim() || row.username?.trim() || '—'}`, { customClass: 'admin-density-confirm',
|
||||
confirmButtonText: `确认${targetLabel}`,
|
||||
cancelButtonText: '取消',
|
||||
type: targetStatus === 'DISABLED' ? 'warning' : 'info',
|
||||
@@ -429,93 +432,47 @@ onMounted(loadInitialData)
|
||||
</DataTable></section>
|
||||
</ListPageLayout>
|
||||
|
||||
<AppDialog v-model="editorOpen" :title="editorLabel" width="min(650px, calc(100vw - 28px))" class="internal-user-editor-dialog admin-density-dialog iam-density" destroy-on-close :confirm-loading="saving" :confirm-text="editingUser ? '保存修改' : '确认添加'" @confirm="saveUser">
|
||||
<el-form class="density-form" ref="formRef" :model="form" :rules="formRules" label-position="top">
|
||||
<FormSection class="density-form-section" title="用户资料" description="维护登录身份、联系方式与平台角色。">
|
||||
<div class="form-grid two-cols">
|
||||
<el-form-item label="用户名" prop="username" required>
|
||||
<el-input v-model="form.username" :disabled="Boolean(editingUser)" placeholder="登录用户名" maxlength="64" />
|
||||
</el-form-item>
|
||||
<el-form-item label="姓名" prop="displayName" required>
|
||||
<el-input v-model="form.displayName" :disabled="Boolean(editingUser) && !canEdit" placeholder="真实姓名" maxlength="64" />
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号" prop="phone" required>
|
||||
<el-input v-model="form.phone" :disabled="Boolean(editingUser) && !canEdit" placeholder="手机号码" inputmode="tel" maxlength="11" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱" prop="email">
|
||||
<el-input v-model="form.email" :disabled="Boolean(editingUser) && !canEdit" placeholder="邮箱地址" inputmode="email" maxlength="128" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色" prop="roleIds" required>
|
||||
<el-select v-model="form.roleIds" multiple collapse-tags collapse-tags-tooltip
|
||||
:disabled="Boolean(editingUser) && (!canEdit || !canGrantRoles || editingUser?.protectedAccount)" placeholder="选择角色">
|
||||
<el-option v-for="role in availableRoles" :key="role.id" :label="role.name" :value="String(role.id)" :disabled="role.status !== 'ACTIVE'" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-select v-model="form.status" :disabled="Boolean(editingUser) && (!canChangeStatus || cannotDisable(editingUser))">
|
||||
<el-option label="正常" value="ACTIVE" />
|
||||
<el-option label="禁用" value="DISABLED" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="hasOverseasPdaRole" label="实际海外收货仓" prop="overseasWarehouseId" required>
|
||||
<el-select v-model="form.overseasWarehouseId" filterable
|
||||
:loading="overseasWarehousesLoading" :disabled="Boolean(editingUser) && !canEdit"
|
||||
placeholder="请选择平台共享海外收货仓">
|
||||
<el-option v-for="warehouse in overseasWarehouses" :key="warehouse.id"
|
||||
:label="`${warehouse.warehouseName}(${warehouse.warehouseCode} · ${warehouse.countryCode})`"
|
||||
:value="warehouse.id" />
|
||||
</el-select>
|
||||
<p v-if="overseasWarehousesError" class="field-error">
|
||||
{{ overseasWarehousesError.message }}
|
||||
<el-button link type="primary" :loading="overseasWarehousesLoading" @click="loadOverseasWarehouses">重新加载</el-button>
|
||||
</p>
|
||||
<p v-else class="field-help">仓库将写入登录 Token,海外 PDA 不提供仓库切换。</p>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="hasDomesticPdaRole" label="国内作业仓" prop="domesticWarehouseId" required>
|
||||
<el-select v-model="form.domesticWarehouseId" filterable
|
||||
:loading="domesticWarehousesLoading" :disabled="Boolean(editingUser) && !canEdit"
|
||||
placeholder="请选择国内作业仓">
|
||||
<el-option v-for="warehouse in domesticWarehouses" :key="String(warehouse.id)"
|
||||
:label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`"
|
||||
:value="String(warehouse.id)" />
|
||||
</el-select>
|
||||
<p v-if="domesticWarehousesError" class="field-error">
|
||||
{{ domesticWarehousesError.message }}
|
||||
<el-button link type="primary" :loading="domesticWarehousesLoading" @click="loadDomesticWarehouses">重新加载</el-button>
|
||||
</p>
|
||||
<p v-else class="field-help">该人员只会出现在此仓库的国内头程 PDA 任务候选中。</p>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="editingUser" label="最后登录">
|
||||
<el-input :model-value="formatLogin(editingUser.lastLoginAt)" disabled />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
<FormSection class="density-form-section" v-if="!editingUser || canResetPassword" title="登录密码" description="密码为 8-64 位,并同时包含字母和数字。">
|
||||
<el-checkbox v-if="editingUser" v-model="form.resetPassword">同时重置密码</el-checkbox>
|
||||
<div v-if="!editingUser || form.resetPassword" class="form-grid two-cols password-fields">
|
||||
<el-form-item :label="editingUser ? '新密码' : '初始密码'" prop="password" required>
|
||||
<el-input v-model="form.password" type="password" show-password autocomplete="new-password" placeholder="8-64 位,包含字母和数字" />
|
||||
</el-form-item>
|
||||
<el-form-item label="确认密码" prop="confirmPassword" required>
|
||||
<el-input v-model="form.confirmPassword" type="password" show-password autocomplete="new-password" placeholder="再次输入密码" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</FormSection>
|
||||
<AppDialog v-model="editorOpen" :title="editorLabel" width="min(650px, calc(100vw - 28px))" class="internal-user-editor-dialog admin-density-dialog iam-density" fullscreenable destroy-on-close :confirm-loading="saving" :confirm-text="editingUser ? '保存修改' : '确认添加'" @confirm="saveUser">
|
||||
<el-form class="density-form density-dialog-sections" ref="formRef" :model="form" :rules="formRules" label-position="left" label-width="100px" :disabled="saving">
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="用户基本资料">
|
||||
<h3 class="density-dialog-section__title">用户资料</h3>
|
||||
<table class="density-table density-table--editable user-editor-table"><tbody>
|
||||
<tr><td><el-form-item label="用户名" prop="username" required><span v-if="editingUser">{{form.username || '—'}}</span><el-input v-else v-model="form.username" placeholder="登录用户名" maxlength="64" /></el-form-item></td><td><el-form-item label="姓名" prop="displayName" required><span v-if="editingUser && !canEdit">{{form.displayName || '—'}}</span><el-input v-else v-model="form.displayName" placeholder="真实姓名" maxlength="64" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="手机号" prop="phone" required><span v-if="editingUser && !canEdit">{{form.phone || '—'}}</span><el-input v-else v-model="form.phone" placeholder="手机号码" inputmode="tel" maxlength="11" /></el-form-item></td><td><el-form-item label="邮箱" prop="email"><span v-if="editingUser && !canEdit">{{form.email || '—'}}</span><el-input v-else v-model="form.email" placeholder="邮箱地址" inputmode="email" maxlength="128" /></el-form-item></td></tr>
|
||||
<tr><td colspan="2"><el-form-item label="角色" prop="roleIds" required><span v-if="editingUser && (!canEdit || !canGrantRoles || editingUser.protectedAccount)">{{selectedRoleNames}}</span><el-select v-else v-model="form.roleIds" multiple collapse-tags collapse-tags-tooltip placeholder="选择角色"><el-option v-for="role in availableRoles" :key="role.id" :label="role.name" :value="String(role.id)" :disabled="role.status !== 'ACTIVE'" /></el-select></el-form-item></td></tr>
|
||||
<tr><td :colspan="editingUser ? 1 : 2"><el-form-item label="状态" prop="status"><span v-if="editingUser && (!canChangeStatus || cannotDisable(editingUser))">{{form.status === 'ACTIVE' ? '正常' : '禁用'}}</span><el-select v-else v-model="form.status"><el-option label="正常" value="ACTIVE" /><el-option label="禁用" value="DISABLED" /></el-select></el-form-item></td><td v-if="editingUser"><el-form-item label="最后登录"><span>{{formatLogin(editingUser.lastLoginAt)}}</span></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section v-if="hasOverseasPdaRole || hasDomesticPdaRole" class="density-dialog-section" data-tone="work" aria-label="用户作业仓库">
|
||||
<h3 class="density-dialog-section__title">作业仓库</h3>
|
||||
<table class="density-table density-table--editable"><tbody>
|
||||
<tr v-if="hasOverseasPdaRole"><td><el-form-item label="实际海外收货仓" prop="overseasWarehouseId" label-width="145px" required>
|
||||
<template #label>实际海外收货仓 <InlineHelp title="海外仓作业范围">该人员仅在绑定的海外仓作业,海外 PDA 不提供仓库切换。</InlineHelp></template>
|
||||
<span v-if="editingUser && !canEdit">{{selectedOverseasWarehouse}}</span><el-select v-else v-model="form.overseasWarehouseId" filterable :loading="overseasWarehousesLoading" placeholder="请选择平台共享海外收货仓"><el-option v-for="warehouse in overseasWarehouses" :key="warehouse.id" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode} · ${warehouse.countryCode})`" :value="warehouse.id" /></el-select>
|
||||
<LoadErrorState v-if="overseasWarehousesError" compact title="共享海外收货仓加载失败" :message="overseasWarehousesError.message" :loading="overseasWarehousesLoading" @retry="loadOverseasWarehouses"><template #details><ApiErrorMeta :details="overseasWarehousesError" /></template></LoadErrorState>
|
||||
</el-form-item></td></tr>
|
||||
<tr v-if="hasDomesticPdaRole"><td><el-form-item label="国内作业仓" prop="domesticWarehouseId" label-width="145px" required>
|
||||
<template #label>国内作业仓 <InlineHelp title="国内仓作业范围">该人员只会出现在此仓库的国内头程 PDA 任务候选中。</InlineHelp></template>
|
||||
<span v-if="editingUser && !canEdit">{{selectedDomesticWarehouse}}</span><el-select v-else v-model="form.domesticWarehouseId" filterable :loading="domesticWarehousesLoading" placeholder="请选择国内作业仓"><el-option v-for="warehouse in domesticWarehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="String(warehouse.id)" /></el-select>
|
||||
<LoadErrorState v-if="domesticWarehousesError" compact title="国内作业仓加载失败" :message="domesticWarehousesError.message" :loading="domesticWarehousesLoading" @retry="loadDomesticWarehouses"><template #details><ApiErrorMeta :details="domesticWarehousesError" /></template></LoadErrorState>
|
||||
</el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section v-if="!editingUser || canResetPassword" class="density-dialog-section" data-tone="work" aria-label="用户登录密码">
|
||||
<h3 class="density-dialog-section__title">登录密码 <InlineHelp title="密码要求">密码为 8-64 位,并同时包含字母和数字。</InlineHelp></h3>
|
||||
<table class="density-table density-table--editable user-editor-table"><tbody>
|
||||
<tr v-if="editingUser"><td colspan="2"><el-checkbox v-model="form.resetPassword">同时重置密码</el-checkbox></td></tr>
|
||||
<tr v-if="!editingUser || form.resetPassword"><td><el-form-item :label="editingUser ? '新密码' : '初始密码'" prop="password" required><el-input v-model="form.password" type="password" show-password autocomplete="new-password" placeholder="8-64 位,包含字母和数字" /></el-form-item></td><td><el-form-item label="确认密码" prop="confirmPassword" required><el-input v-model="form.confirmPassword" type="password" show-password autocomplete="new-password" placeholder="再次输入密码" /></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.password-fields { margin-top: var(--admin-space-6); }
|
||||
.field-help, .field-error { margin: 6px 0 0; font-size: var(--admin-font-caption); line-height: 1.5; }
|
||||
.field-help { color: var(--cc-text-secondary); }
|
||||
.field-error { color: var(--el-color-danger); }
|
||||
:global(.internal-user-editor-dialog) { max-height: min(760px, calc(100vh - 32px)); display: flex; flex-direction: column; }
|
||||
:global(.internal-user-editor-dialog .el-dialog__body) { min-height: 0; overflow-y: auto; }
|
||||
:global(.internal-user-editor-dialog .el-dialog__footer) { flex: 0 0 auto; }
|
||||
@media (max-width: 720px) {
|
||||
.form-grid.two-cols { grid-template-columns: 1fr; }
|
||||
}
|
||||
.user-editor-table { table-layout: fixed; }
|
||||
.user-editor-table td:not([colspan="2"]) { width: 50%; }
|
||||
:global(.internal-user-editor-dialog.app-dialog.admin-density-dialog.el-dialog:not(.is-fullscreen)) { max-height: min(760px, calc(100vh - 32px)); }
|
||||
@media (max-width: 720px) { .user-editor-table tr { display: grid; } .user-editor-table td:not([colspan="2"]) { width: auto; } }
|
||||
</style>
|
||||
|
||||
@@ -40,8 +40,9 @@ describe('permission editor modal', () => {
|
||||
expect(dialog.style.getPropertyValue('--el-dialog-width')).toBe('min(720px, calc(100vw - 28px))')
|
||||
expect(document.querySelector('.el-drawer')).toBeNull()
|
||||
expect(dialog.querySelectorAll('.el-form-item')).toHaveLength(6)
|
||||
const codeInput = dialog.querySelector<HTMLInputElement>('input[placeholder="如:platform:customer:list"]')!
|
||||
expect(codeInput.disabled).toBe(mode === 'edit')
|
||||
const codeInput = dialog.querySelector<HTMLInputElement>('input[placeholder="如:platform:customer:list"]')
|
||||
if (mode === 'edit') { expect(codeInput).toBeNull(); expect(dialog.textContent).toContain('platform:sample:list') }
|
||||
else expect(codeInput?.disabled).toBe(false)
|
||||
// Vitest externalizes Element Plus: its CJS validator interop differs from the Vite browser bundle.
|
||||
// Exercise the dialog's validation gate explicitly, without changing production rules.
|
||||
const form = wrapper.findComponent({ name: 'ElForm' })
|
||||
@@ -64,6 +65,7 @@ describe('permission editor modal', () => {
|
||||
if (mode === 'create') expect(createPlatformPermission).toHaveBeenCalledWith({ type: 'MENU', code: 'platform:new:list', name: '新增菜单', parentId: '0', routePath: '/new', httpMethod: null, sortNo: 10 })
|
||||
else expect(updatePlatformPermission).toHaveBeenCalledWith('90071992547409931', { name: '测试菜单', parentId: null, routePath: '/sample', httpMethod: null, sortNo: 10 })
|
||||
expect(wrapper.getComponent(AppDialog).props('modelValue')).toBe(true)
|
||||
expect(codeInput.value).toBe(mode === 'create' ? 'platform:new:list' : 'platform:sample:list')
|
||||
if (mode === 'create') expect(codeInput?.value).toBe('platform:new:list')
|
||||
else expect(dialog.textContent).toContain('platform:sample:list')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,13 +3,13 @@ import '@/styles/adminDensity.css'
|
||||
import '../iamDensity.css'
|
||||
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { Braces, FolderTree, Plus, ShieldCheck } from '@lucide/vue'
|
||||
import { FolderTree, Plus, ShieldCheck } from '@lucide/vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import FormSection from '@/shared/components/FormSection.vue'
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
|
||||
@@ -59,7 +59,7 @@ const formRules = computed<FormRules>(() => ({
|
||||
method: form.type === '接口' ? [{ required: true, message: '请选择 HTTP 方法', trigger: 'change' }] : [],
|
||||
sort: [{ required: true, type: 'number', message: '请输入显示排序', trigger: 'change' }],
|
||||
}))
|
||||
const editorLabel = computed(() => form.id ? '编辑权限' : '新增权限')
|
||||
const editorLabel = computed(() => form.id ? `编辑权限资源 · ${form.name.trim() || form.code.trim() || '—'}` : '新增权限资源')
|
||||
const requiredPermission = computed(() => form.id ? 'platform:permission:edit' : 'platform:permission:create')
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({
|
||||
key: 'permission-editor',
|
||||
@@ -206,7 +206,7 @@ async function toggleStatus(row: Permission) {
|
||||
if (row.builtIn) return
|
||||
const target = row.status === '启用' ? '停用' : '启用'
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认${target}权限“${row.name}”吗?`, '权限状态', { customClass: 'admin-density-confirm', confirmButtonText: '确认', cancelButtonText: '取消' })
|
||||
await ElMessageBox.confirm(`确认${target}权限“${row.name}”吗?`, `${target}权限 · ${row.name.trim() || row.code || '—'}`, { customClass: 'admin-density-confirm', confirmButtonText: '确认', cancelButtonText: '取消' })
|
||||
rowActionId.value = row.id
|
||||
const updated = await changePlatformPermissionStatus(row.id, target === '启用' ? 'ACTIVE' : 'DISABLED')
|
||||
Object.assign(row, mapPlatformPermission(updated))
|
||||
@@ -222,7 +222,7 @@ async function toggleStatus(row: Permission) {
|
||||
async function removePermission(row: Permission) {
|
||||
if (row.builtIn) return
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除权限“${row.name}”吗?删除前需确保没有下级资源且未分配给角色。`, '删除权限', { customClass: 'admin-density-confirm', confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' })
|
||||
await ElMessageBox.confirm(`确认删除权限“${row.name}”吗?删除前需确保没有下级资源且未分配给角色。`, `删除权限 · ${row.name.trim() || row.code || '—'}`, { customClass: 'admin-density-confirm', confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' })
|
||||
rowActionId.value = row.id
|
||||
await deletePlatformPermission(row.id)
|
||||
await loadPermissions()
|
||||
@@ -299,26 +299,25 @@ onMounted(loadPermissions)
|
||||
</DataTable></section>
|
||||
</ListPageLayout>
|
||||
|
||||
<AppDialog v-model="editorOpen" :title="form.id ? '编辑权限资源' : '新增权限资源'" width="min(720px, calc(100vw - 28px))" class="permission-editor-dialog admin-density-dialog iam-density" :confirm-loading="saving" :confirm-text="form.id ? '保存修改' : '保存权限'" :show-confirm="form.id ? canEdit : canCreate" @confirm="savePermission">
|
||||
<el-form ref="formRef" :model="form" :rules="formRules" label-position="top" class="density-form">
|
||||
<FormSection class="density-form-section" title="资源定义" description="定义权限名称、编码、类型和层级关系。">
|
||||
<div class="form-grid two-cols">
|
||||
<el-form-item label="权限名称" prop="name" required><el-input v-model="form.name" placeholder="用户可理解的中文名称" maxlength="64" /></el-form-item>
|
||||
<el-form-item label="权限编码" prop="code" required><el-input v-model="form.code" :disabled="Boolean(form.id)" placeholder="如:platform:customer:list" maxlength="128" /></el-form-item>
|
||||
<el-form-item label="资源类型" prop="type" required><el-select v-model="form.type" :disabled="Boolean(form.id)" @change="handleTypeChange"><el-option v-for="item in availablePermissionTypes" :key="item" :label="item" :value="item" /></el-select></el-form-item>
|
||||
<el-form-item label="上级资源" prop="parentId" :required="['按钮', '页面', '接口'].includes(form.type)"><el-select v-model="form.parentId" clearable placeholder="作为一级资源"><el-option v-for="item in parentOptions" :key="item.id" :label="`${item.name} (${item.code})`" :value="item.id" /></el-select></el-form-item>
|
||||
<el-form-item v-if="['菜单', '页面'].includes(form.type)" label="路由路径" prop="path" required><el-input v-model="form.path" placeholder="如:/customers" /></el-form-item>
|
||||
<el-form-item v-if="form.type === '接口'" label="HTTP 方法" prop="method" required><el-select v-model="form.method"><el-option v-for="method in ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']" :key="method" :label="method" :value="method" /></el-select></el-form-item>
|
||||
<el-form-item label="显示排序" prop="sort" required><el-input-number v-model="form.sort" :min="0" :max="999" controls-position="right" placeholder="请输入排序值" /></el-form-item>
|
||||
</div>
|
||||
</FormSection>
|
||||
<div class="form-callout"><Braces :size="18" /><div><strong>权限编码约定</strong><p>使用稳定的“业务域:资源:动作”编码;资源类型与后端 MENU、PAGE、BUTTON、API 契约一致。</p></div></div>
|
||||
<AppDialog v-model="editorOpen" :title="editorLabel" width="min(720px, calc(100vw - 28px))" class="permission-editor-dialog admin-density-dialog iam-density" fullscreenable :confirm-loading="saving" :confirm-text="form.id ? '保存修改' : '保存权限'" :show-confirm="form.id ? canEdit : canCreate" @confirm="savePermission">
|
||||
<el-form ref="formRef" :model="form" :rules="formRules" label-position="left" label-width="100px" class="density-form" :disabled="saving">
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="权限资源定义">
|
||||
<h3 class="density-dialog-section__title">资源定义 <InlineHelp title="权限编码约定">使用稳定的“业务域:资源:动作”编码;资源类型与后端 MENU、PAGE、BUTTON、API 契约一致。</InlineHelp></h3>
|
||||
<table class="density-table density-table--editable permission-editor-table"><tbody>
|
||||
<tr><td><el-form-item label="权限名称" prop="name" required><el-input v-model="form.name" placeholder="用户可理解的中文名称" maxlength="64" /></el-form-item></td><td><el-form-item label="权限编码" prop="code" required><span v-if="form.id">{{form.code || '—'}}</span><el-input v-else v-model="form.code" placeholder="如:platform:customer:list" maxlength="128" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="资源类型" prop="type" required><span v-if="form.id">{{form.type}}</span><el-select v-else v-model="form.type" @change="handleTypeChange"><el-option v-for="item in availablePermissionTypes" :key="item" :label="item" :value="item" /></el-select></el-form-item></td><td><el-form-item label="上级资源" prop="parentId" :required="['按钮', '页面', '接口'].includes(form.type)"><el-select v-model="form.parentId" clearable placeholder="作为一级资源"><el-option v-for="item in parentOptions" :key="item.id" :label="`${item.name} (${item.code})`" :value="item.id" /></el-select></el-form-item></td></tr>
|
||||
<tr v-if="['菜单', '页面'].includes(form.type)"><td colspan="2"><el-form-item label="路由路径" prop="path" required><el-input v-model="form.path" placeholder="如:/customers" /></el-form-item></td></tr>
|
||||
<tr><td v-if="form.type === '接口'"><el-form-item label="HTTP 方法" prop="method" required><el-select v-model="form.method"><el-option v-for="method in ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']" :key="method" :label="method" :value="method" /></el-select></el-form-item></td><td :colspan="form.type === '接口' ? 1 : 2"><el-form-item label="显示排序" prop="sort" required><el-input-number v-model="form.sort" :min="0" :max="999" controls-position="right" placeholder="请输入排序值" /></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.permission-editor-table { table-layout: fixed; }
|
||||
.permission-editor-table td:not([colspan="2"]) { width: 50%; }
|
||||
.permission-tree-tools { display: flex; align-items: center; gap: var(--admin-space-6); }
|
||||
.permission-protection-hint { color: var(--cc-muted); }
|
||||
.tree-view-actions { display: flex; align-items: center; gap: var(--admin-space-1); white-space: nowrap; }
|
||||
@@ -328,6 +327,8 @@ onMounted(loadPermissions)
|
||||
:deep(.danger-menu-item:not(.is-disabled)) { color: var(--el-color-danger); }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.permission-editor-table tr { display: grid; }
|
||||
.permission-editor-table td:not([colspan="2"]) { width: auto; }
|
||||
.permission-tree-tools { width: 100%; align-items: flex-start; flex-direction: column; gap: 8px; }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import RowActions from '@/shared/components/RowActions.vue'
|
||||
import RolesView from './RolesView.vue'
|
||||
import PermissionsView from './PermissionsView.vue'
|
||||
import * as api from '../api/platformIam'
|
||||
const { default: ElementPlus, ElDialog, ElTree, ElFormItem, ElSelect, ElOption, ElMessageBox } = createRequire(import.meta.url)('element-plus') as typeof import('element-plus')
|
||||
vi.mock('../api/platformIam', () => ({ listPlatformRoles: vi.fn(), listPlatformPermissions: vi.fn(), createPlatformRole: vi.fn(), updatePlatformRole: vi.fn(), replacePlatformRolePermissions: vi.fn(), changePlatformRoleStatus: vi.fn(), deletePlatformRole: vi.fn(), createPlatformPermission: vi.fn(), updatePlatformPermission: vi.fn(), changePlatformPermissionStatus: vi.fn(), deletePlatformPermission: vi.fn() }))
|
||||
const roleId = '9223372036854775807', rootId = '9223372036854775806', childId = '9223372036854775805', hiddenId = '9223372036854775804'
|
||||
let role: api.PlatformRoleResource
|
||||
let resources: api.PlatformPermissionResource[]
|
||||
const table = { props: ['data'], template: '<div><slot name="toolbar-actions"/><div class="real-row"><slot v-if="data?.length" name="actions" :row="data[0]"/></div></div>' }
|
||||
function button(label: string, selector = 'button') {
|
||||
const found = Array.from(document.querySelectorAll<HTMLButtonElement>(selector)).find(node => node.textContent?.trim() === label)
|
||||
expect(found, label).toBeDefined(); return found!
|
||||
}
|
||||
async function view(component: typeof RolesView | typeof PermissionsView) {
|
||||
const wrapper = mount(component, { attachTo: document.body, global: { plugins: [ElementPlus], stubs: { DataTable: table, QueryToolbar: true } } }); await flushPromises(); return wrapper
|
||||
}
|
||||
async function roleEditor(create = false) {
|
||||
const wrapper = await view(RolesView); button(create ? '新增角色' : '配置').click(); await flushPromises()
|
||||
wrapper.findComponent(AppDialog).vm.$emit('opened'); await flushPromises(); return wrapper
|
||||
}
|
||||
async function permissionEditor(create = false) {
|
||||
const wrapper = await view(PermissionsView)
|
||||
if (create) button('新增权限').click()
|
||||
else wrapper.findComponent(RowActions).vm.$emit('action', 'edit')
|
||||
await flushPromises(); return wrapper
|
||||
}
|
||||
async function input(placeholder: string, value: string) {
|
||||
const node = document.querySelector<HTMLInputElement>(`.el-dialog [placeholder="${placeholder}"]`)!
|
||||
expect(node, placeholder).not.toBeNull(); node.value = value; node.dispatchEvent(new Event('input', { bubbles: true })); node.dispatchEvent(new Event('change', { bubbles: true })); await flushPromises()
|
||||
}
|
||||
function select(wrapper: Awaited<ReturnType<typeof view>>, label: string) {
|
||||
const field = wrapper.findAllComponents(ElFormItem).find(field => field.props('label') === label)
|
||||
expect(field, label).toBeDefined(); return field!.findComponent(ElSelect)
|
||||
}
|
||||
function permissions(codes: string[]) { useAuthStore().permissions = codes; useAccessPreviewStore().initializeAfterLogin() }
|
||||
enableAutoUnmount(afterEach)
|
||||
afterEach(() => ElMessageBox.close())
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks(); document.body.innerHTML = ''; setActivePinia(createPinia()); useAuthStore().token = 'test-session'
|
||||
permissions(['platform:role:list','platform:role:create','platform:role:edit','platform:role:grant','platform:role:status','platform:role:delete','platform:permission:list','platform:permission:create','platform:permission:edit','platform:permission:status','platform:permission:delete'])
|
||||
role = { id: roleId, tenantId: '0', appCode: 'PLATFORM', roleCode: 'TEST_OPERATOR', roleName: '测试角色', roleType: 'CUSTOM', status: 'ACTIVE', description: '原说明', memberCount: 0, permissionIds: [childId] }
|
||||
resources = [
|
||||
{ id: rootId, tenantId: '0', appCode: 'PLATFORM', permissionType: 'MENU', parentId: '0', permissionCode: 'platform:customer:list', permissionName: '测试客户菜单', routePath: '/customers', httpMethod: null, sortNo: 1, status: 'ACTIVE', builtIn: false },
|
||||
{ id: childId, tenantId: '0', appCode: 'PLATFORM', permissionType: 'BUTTON', parentId: rootId, permissionCode: 'platform:customer:create', permissionName: '测试新增客户', routePath: null, httpMethod: null, sortNo: 2, status: 'ACTIVE', builtIn: false },
|
||||
{ id: hiddenId, tenantId: '0', appCode: 'PLATFORM', permissionType: 'MENU', parentId: '0', permissionCode: 'platform:express:list', permissionName: '正式隐藏菜单权限', routePath: '/master-data/express-companies', httpMethod: null, sortNo: 3, status: 'ACTIVE', builtIn: false },
|
||||
]
|
||||
vi.mocked(api.listPlatformRoles).mockImplementation(async () => [role])
|
||||
vi.mocked(api.listPlatformPermissions).mockImplementation(async () => resources)
|
||||
vi.mocked(api.createPlatformRole).mockImplementation(async () => role)
|
||||
vi.mocked(api.updatePlatformRole).mockImplementation(async () => role)
|
||||
vi.mocked(api.replacePlatformRolePermissions).mockResolvedValue(undefined)
|
||||
vi.mocked(api.changePlatformRoleStatus).mockImplementation(async (_id,status) => ({ ...role, status }))
|
||||
vi.mocked(api.deletePlatformRole).mockResolvedValue(undefined)
|
||||
vi.mocked(api.createPlatformPermission).mockImplementation(async () => resources[0]!)
|
||||
vi.mocked(api.updatePlatformPermission).mockImplementation(async () => resources[0]!)
|
||||
vi.mocked(api.changePlatformPermissionStatus).mockImplementation(async (_id,status) => ({ ...resources[0]!, status }))
|
||||
vi.mocked(api.deletePlatformPermission).mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
describe('roles and permission dialogs', () => {
|
||||
it('keeps saved and hidden formal grants across fullscreen and excludes navigation group IDs', async () => {
|
||||
const wrapper = await roleEditor(); const dialog = wrapper.findComponent(AppDialog); const tree = wrapper.findComponent(ElTree)
|
||||
expect(dialog.props()).toMatchObject({ title: '配置角色 · TEST_OPERATOR', width: 'min(860px, calc(100vw - 28px))', fullscreenable: true })
|
||||
expect(document.querySelector('.el-dialog [placeholder="如:ORDER_OPERATOR"]')).toBeNull()
|
||||
expect(tree.vm.getCheckedKeys()).toContain(childId)
|
||||
expect(tree.vm.getNode(hiddenId)).toBeTruthy()
|
||||
tree.vm.setCheckedKeys([childId, hiddenId]); tree.vm.$emit('check'); await flushPromises()
|
||||
expect(tree.vm.getHalfCheckedKeys().length).toBeGreaterThan(0)
|
||||
document.querySelector<HTMLButtonElement>('[aria-label="展开全屏"]')!.click(); await flushPromises()
|
||||
expect(dialog.findComponent(ElDialog).props('fullscreen')).toBe(true)
|
||||
expect(tree.vm.getCheckedKeys()).toContain(hiddenId)
|
||||
button('保存角色', '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(api.replacePlatformRolePermissions).toHaveBeenCalledWith(roleId, expect.arrayContaining([childId, hiddenId]))
|
||||
const granted = vi.mocked(api.replacePlatformRolePermissions).mock.calls[0]![1]
|
||||
expect(granted.every(id => !String(id).startsWith('navigation-'))).toBe(true)
|
||||
})
|
||||
|
||||
it('grants permissions with readonly role facts when no profile-edit permission exists', async () => {
|
||||
permissions(['platform:role:list','platform:role:grant'])
|
||||
const wrapper = await roleEditor()
|
||||
expect(document.querySelector('.el-dialog [placeholder="如:订单运营专员"]')).toBeNull()
|
||||
expect(document.querySelector('[aria-label="角色基本资料"]')?.textContent).toContain('原说明')
|
||||
button('保存角色', '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(api.updatePlatformRole).not.toHaveBeenCalled()
|
||||
expect(api.replacePlatformRolePermissions).toHaveBeenCalledWith(roleId, [childId])
|
||||
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(false)
|
||||
})
|
||||
|
||||
it('edits only role facts when grant permission is absent', async () => {
|
||||
permissions(['platform:role:list','platform:role:edit'])
|
||||
const wrapper = await roleEditor()
|
||||
expect(wrapper.findComponent(ElTree).exists()).toBe(false)
|
||||
await input('如:订单运营专员', '修改角色')
|
||||
button('保存角色', '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(api.updatePlatformRole).toHaveBeenCalledWith(roleId, { roleName: '修改角色', description: '原说明' })
|
||||
expect(api.replacePlatformRolePermissions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('validates create, normalizes its code and attaches checked grants only after creation', async () => {
|
||||
const wrapper = await roleEditor(true)
|
||||
button('保存角色', '.el-dialog__footer button').click(); await flushPromises(); expect(api.createPlatformRole).not.toHaveBeenCalled()
|
||||
await input('如:订单运营专员', '新角色'); await input('如:ORDER_OPERATOR', 'new_operator')
|
||||
wrapper.findComponent(ElTree).vm.setCheckedKeys([childId]); wrapper.findComponent(ElTree).vm.$emit('check'); await flushPromises()
|
||||
button('保存角色', '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(api.createPlatformRole).toHaveBeenCalledWith({ roleCode: 'NEW_OPERATOR', roleName: '新角色', description: '' })
|
||||
expect(api.replacePlatformRolePermissions).toHaveBeenCalledWith(roleId, [childId])
|
||||
})
|
||||
|
||||
it('locks the tree and close controls during save then preserves failed selection', async () => {
|
||||
let rejectGrant!: (error: Error) => void
|
||||
vi.mocked(api.replacePlatformRolePermissions).mockImplementation(() => new Promise((_resolve,reject) => { rejectGrant = reject }))
|
||||
const wrapper = await roleEditor()
|
||||
button('保存角色', '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(wrapper.findComponent(ElDialog).props()).toMatchObject({ showClose: false, closeOnPressEscape: false, closeOnClickModal: false })
|
||||
expect(button('取消', '.el-dialog__footer button').disabled).toBe(true)
|
||||
const checks = Array.from(document.querySelectorAll<HTMLInputElement>('.permission-tree input[type="checkbox"]'))
|
||||
expect(checks.length).toBeGreaterThan(0); expect(checks.every(input => input.disabled)).toBe(true)
|
||||
rejectGrant(new Error('授权失败')); await flushPromises()
|
||||
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(true)
|
||||
expect(wrapper.findComponent(ElTree).vm.getCheckedKeys()).toContain(childId)
|
||||
})
|
||||
|
||||
it.each(['停用','启用','删除'] as const)('identifies the role %s confirmation and retains the original command', async verb => {
|
||||
if (verb === '启用') role.status = 'DISABLED'
|
||||
await view(RolesView); button(verb, '.real-row button').click(); await flushPromises()
|
||||
expect(document.querySelector('.el-message-box__title')?.textContent).toBe(`${verb}角色 · 测试角色`)
|
||||
expect(api.changePlatformRoleStatus).not.toHaveBeenCalled(); expect(api.deletePlatformRole).not.toHaveBeenCalled()
|
||||
button(verb === '删除' ? '删除' : '确认', '.el-message-box button').click(); await flushPromises()
|
||||
if (verb === '删除') expect(api.deletePlatformRole).toHaveBeenCalledWith(roleId)
|
||||
else expect(api.changePlatformRoleStatus).toHaveBeenCalledWith(roleId, verb === '启用' ? 'ACTIVE' : 'DISABLED')
|
||||
})
|
||||
|
||||
it('retains built-in role protection for status and delete', async () => {
|
||||
role.roleType = 'SYSTEM'
|
||||
await view(RolesView)
|
||||
expect(button('停用', '.real-row button').disabled).toBe(true)
|
||||
expect(button('删除', '.real-row button').disabled).toBe(true)
|
||||
expect(api.changePlatformRoleStatus).not.toHaveBeenCalled(); expect(api.deletePlatformRole).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([['菜单','MENU'],['页面','PAGE'],['按钮','BUTTON'],['接口','API']] as const)('creates %s resources with original conditional payload', async (kind,type) => {
|
||||
const wrapper = await permissionEditor(true)
|
||||
await input('用户可理解的中文名称', '新增资源'); await input('如:platform:customer:list', 'platform:new:action')
|
||||
select(wrapper, '资源类型').vm.$emit('update:modelValue', kind); select(wrapper, '资源类型').vm.$emit('change', kind); await flushPromises()
|
||||
if (kind !== '菜单') {
|
||||
button('保存权限', '.el-dialog__footer button').click(); await flushPromises(); expect(api.createPlatformPermission).not.toHaveBeenCalled()
|
||||
select(wrapper, '上级资源').vm.$emit('update:modelValue', rootId); await flushPromises()
|
||||
}
|
||||
if (kind === '菜单' || kind === '页面') await input('如:/customers', '/new')
|
||||
expect(document.querySelector('.el-dialog [placeholder="如:/customers"]') !== null).toBe(kind === '菜单' || kind === '页面')
|
||||
button('保存权限', '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(api.createPlatformPermission).toHaveBeenCalledWith({ type, code: 'platform:new:action', name: '新增资源', parentId: kind === '菜单' ? '0' : rootId, routePath: kind === '菜单' || kind === '页面' ? '/new' : null, httpMethod: kind === '接口' ? 'GET' : null, sortNo: 10 })
|
||||
})
|
||||
|
||||
it('keeps code/type fixed and excludes current resource and descendants from parent choices', async () => {
|
||||
const wrapper = await permissionEditor()
|
||||
expect(wrapper.findComponent(AppDialog).props()).toMatchObject({ title: '编辑权限资源 · 测试客户菜单', width: 'min(720px, calc(100vw - 28px))', fullscreenable: true })
|
||||
expect(document.querySelector('.el-dialog [placeholder="如:platform:customer:list"]')).toBeNull()
|
||||
expect(select(wrapper, '资源类型').exists()).toBe(false)
|
||||
const parents = select(wrapper, '上级资源').findAllComponents(ElOption).map(option => option.props('value'))
|
||||
expect(parents).toContain(hiddenId); expect(parents).not.toContain(rootId); expect(parents).not.toContain(childId)
|
||||
document.querySelector<HTMLButtonElement>('[aria-label="展开全屏"]')!.click(); await flushPromises()
|
||||
await input('用户可理解的中文名称', '重命名资源')
|
||||
button('保存修改', '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(api.updatePlatformPermission).toHaveBeenCalledWith(rootId, { name: '重命名资源', parentId: null, routePath: '/customers', httpMethod: null, sortNo: 1 })
|
||||
})
|
||||
|
||||
it('keeps failed permission input visible after locking close during submission', async () => {
|
||||
let rejectSave!: (error: Error) => void
|
||||
vi.mocked(api.updatePlatformPermission).mockImplementation(() => new Promise((_resolve,reject) => { rejectSave = reject }))
|
||||
const wrapper = await permissionEditor(); await input('用户可理解的中文名称', '失败输入')
|
||||
button('保存修改', '.el-dialog__footer button').click(); await flushPromises()
|
||||
expect(wrapper.findComponent(ElDialog).props('showClose')).toBe(false)
|
||||
expect(button('取消', '.el-dialog__footer button').disabled).toBe(true)
|
||||
rejectSave(new Error('保存失败')); await flushPromises()
|
||||
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(true)
|
||||
expect(document.querySelector<HTMLInputElement>('[placeholder="用户可理解的中文名称"]')!.value).toBe('失败输入')
|
||||
})
|
||||
|
||||
it.each(['停用','启用','删除'] as const)('identifies permission %s confirmation without losing original restrictions', async verb => {
|
||||
if (verb === '启用') resources[0]!.status = 'DISABLED'
|
||||
const wrapper = await view(PermissionsView)
|
||||
wrapper.findComponent(RowActions).vm.$emit('action', verb === '删除' ? 'delete' : 'status'); await flushPromises()
|
||||
expect(document.querySelector('.el-message-box__title')?.textContent).toBe(`${verb}权限 · 测试客户菜单`)
|
||||
if (verb === '删除') expect(document.querySelector('.el-message-box')?.textContent).toContain('没有下级资源且未分配给角色')
|
||||
expect(api.deletePlatformPermission).not.toHaveBeenCalled(); expect(api.changePlatformPermissionStatus).not.toHaveBeenCalled()
|
||||
button(verb === '删除' ? '删除' : '确认', '.el-message-box button').click(); await flushPromises()
|
||||
if (verb === '删除') expect(api.deletePlatformPermission).toHaveBeenCalledWith(rootId)
|
||||
else expect(api.changePlatformPermissionStatus).toHaveBeenCalledWith(rootId, verb === '启用' ? 'ACTIVE' : 'DISABLED')
|
||||
})
|
||||
|
||||
it('never opens editing for a built-in permission resource', async () => {
|
||||
resources[0]!.builtIn = true
|
||||
const wrapper = await view(PermissionsView)
|
||||
wrapper.findComponent(RowActions).vm.$emit('action', 'edit'); await flushPromises()
|
||||
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(false)
|
||||
expect(api.updatePlatformPermission).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -121,7 +121,9 @@ describe('RolesView', () => {
|
||||
expect(rolesSource).not.toContain('<AppDrawer v-model="editorOpen"')
|
||||
expect(rolesSource).toContain('grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.3fr)')
|
||||
expect(rolesSource).toContain('.role-editor-layout { grid-template-columns: minmax(0, 1fr); }')
|
||||
expect(rolesSource).toContain(':global(.role-editor-dialog .el-dialog__body) { min-height: 0; overflow-y: auto; }')
|
||||
expect(rolesSource).toContain('fullscreenable :confirm-loading="saving"')
|
||||
expect(rolesSource).toContain(':global(.role-editor-dialog.app-dialog.admin-density-dialog.el-dialog:not(.is-fullscreen))')
|
||||
expect(rolesSource).not.toContain('max-height: 380px')
|
||||
})
|
||||
|
||||
it('loads platform roles from IAM for a server session', async () => {
|
||||
|
||||
@@ -21,7 +21,7 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import FormSection from '@/shared/components/FormSection.vue'
|
||||
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
@@ -62,7 +62,7 @@ const formRules: FormRules = {
|
||||
name: [{ required: true, whitespace: true, message: '请输入角色名称', trigger: 'blur' }],
|
||||
code: [{ required: true, whitespace: true, message: '请输入角色编码', trigger: 'blur' }],
|
||||
}
|
||||
const editorLabel = computed(() => form.id ? '配置角色' : '新增角色')
|
||||
const editorLabel = computed(() => form.id ? `配置角色 · ${form.code.trim() || form.name.trim() || '—'}` : '新增角色')
|
||||
const canEdit = computed(() => access.can('platform:role:edit'))
|
||||
const canGrant = computed(() => access.can('platform:role:grant'))
|
||||
const requiredPermission = computed(() => form.id && canEdit.value ? 'platform:role:edit' : form.id ? 'platform:role:grant' : 'platform:role:create')
|
||||
@@ -197,7 +197,7 @@ async function removeRole(row: Role) {
|
||||
if (row.builtIn) { ElMessage.warning('系统内置角色不能删除'); return }
|
||||
if (row.userCount > 0) { ElMessage.warning('请先移除该角色下的用户'); return }
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除角色“${row.name}”吗?`, '删除角色', { customClass: 'admin-density-confirm', confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' })
|
||||
await ElMessageBox.confirm(`确认删除角色“${row.name}”吗?`, `删除角色 · ${row.name.trim() || row.code || '—'}`, { customClass: 'admin-density-confirm', confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' })
|
||||
rowActionId.value = row.id
|
||||
await deletePlatformRole(row.id)
|
||||
serverRoles.value = serverRoles.value.filter((item) => item.id !== row.id)
|
||||
@@ -214,7 +214,7 @@ async function toggleStatus(row: Role) {
|
||||
if (row.builtIn) { ElMessage.warning('系统内置角色不能停用'); return }
|
||||
const target = row.status === '启用' ? '停用' : '启用'
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认${target}角色“${row.name}”吗?`, '角色状态', { customClass: 'admin-density-confirm', confirmButtonText: '确认', cancelButtonText: '取消' })
|
||||
await ElMessageBox.confirm(`确认${target}角色“${row.name}”吗?`, `${target}角色 · ${row.name.trim() || row.code || '—'}`, { customClass: 'admin-density-confirm', confirmButtonText: '确认', cancelButtonText: '取消' })
|
||||
rowActionId.value = row.id
|
||||
const updated = await changePlatformRoleStatus(row.id, target === '启用' ? 'ACTIVE' : 'DISABLED')
|
||||
Object.assign(row, mapPlatformRole(updated))
|
||||
@@ -261,33 +261,30 @@ onMounted(loadRoles)
|
||||
</DataTable></section>
|
||||
</ListPageLayout>
|
||||
|
||||
<AppDialog v-model="editorOpen" :title="form.id ? '配置角色' : '新增角色'" width="min(860px, calc(100vw - 28px))" class="role-editor-dialog admin-density-dialog iam-density" :confirm-loading="saving" confirm-text="保存角色" :show-confirm="form.id ? canEdit || canGrant : canCreate" destroy-on-close @opened="initializePermissionSelection" @confirm="saveRole">
|
||||
<el-form ref="formRef" :model="form" :rules="formRules" label-position="top" class="role-editor-form density-form">
|
||||
<div class="role-editor-layout">
|
||||
<FormSection title="基本信息" description="维护角色名称、编码和职责说明。" class="role-editor-basic density-form-section">
|
||||
<el-form-item label="角色名称" prop="name" required><el-input v-model="form.name" :disabled="Boolean(form.id) && !canEdit" placeholder="如:订单运营专员" maxlength="64" /></el-form-item>
|
||||
<el-form-item label="角色编码" prop="code" required><el-input v-model="form.code" :disabled="Boolean(form.id)" placeholder="如:ORDER_OPERATOR" maxlength="64" /></el-form-item>
|
||||
<el-form-item label="角色说明"><el-input v-model="form.description" :disabled="Boolean(form.id) && !canEdit" type="textarea" :rows="3" placeholder="说明该角色的工作范围" maxlength="200" show-word-limit /></el-form-item>
|
||||
</FormSection>
|
||||
<FormSection v-if="canGrant" title="菜单与操作权限" :description="`按当前后台菜单分组;勾选上级可全选下级,部分选中时显示半选,当前 ${form.permissionIds.length} 项授权。`" class="role-editor-permissions density-form-section"><el-tree ref="treeRef" class="permission-tree" :data="permissionTree" node-key="id" show-checkbox :default-expanded-keys="permissionGroupIds" :props="{ label: 'name', children: 'children' }" @check="syncPermissionIds"><template #default="{ data }"><div class="permission-node" :class="{ 'is-navigation-group': !data.permissionId }"><span>{{ data.name }}</span></div></template></el-tree></FormSection>
|
||||
</div>
|
||||
<AppDialog v-model="editorOpen" :title="editorLabel" width="min(860px, calc(100vw - 28px))" class="role-editor-dialog admin-density-dialog iam-density" fullscreenable :confirm-loading="saving" confirm-text="保存角色" :show-confirm="form.id ? canEdit || canGrant : canCreate" destroy-on-close @opened="initializePermissionSelection" @confirm="saveRole">
|
||||
<el-form ref="formRef" :model="form" :rules="formRules" label-position="left" label-width="90px" class="density-form density-dialog-sections role-editor-layout" :disabled="saving">
|
||||
<section class="density-dialog-section" data-tone="work" aria-label="角色基本资料">
|
||||
<h3 class="density-dialog-section__title">基本信息</h3>
|
||||
<table class="density-table density-table--editable"><tbody>
|
||||
<tr><td><el-form-item label="角色名称" prop="name" required><span v-if="form.id && !canEdit">{{form.name || '—'}}</span><el-input v-else v-model="form.name" placeholder="如:订单运营专员" maxlength="64" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="角色编码" prop="code" required><span v-if="form.id">{{form.code || '—'}}</span><el-input v-else v-model="form.code" placeholder="如:ORDER_OPERATOR" maxlength="64" /></el-form-item></td></tr>
|
||||
<tr><td><el-form-item label="角色说明"><span v-if="form.id && !canEdit">{{form.description || '—'}}</span><el-input v-else v-model="form.description" type="textarea" :rows="3" placeholder="说明该角色的工作范围" maxlength="200" show-word-limit /></el-form-item></td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
<section v-if="canGrant" class="role-editor-permissions density-dialog-section" data-tone="source" aria-label="角色菜单与操作权限">
|
||||
<h3 class="density-dialog-section__title">菜单与操作权限 <small>当前 {{form.permissionIds.length}} 项授权</small><InlineHelp title="权限选择说明">按当前后台菜单分组;勾选上级可全选下级,部分选中时显示半选。</InlineHelp></h3>
|
||||
<el-tree ref="treeRef" class="permission-tree" :data="permissionTree" node-key="id" show-checkbox :default-expanded-keys="permissionGroupIds" :props="{ label: 'name', children: 'children', disabled: () => saving }" @check="syncPermissionIds"><template #default="{ data }"><div class="permission-node" :class="{ 'is-navigation-group': !data.permissionId }"><span>{{ data.name }}</span></div></template></el-tree>
|
||||
</section>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.role-editor-layout { display: grid; grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.3fr); gap: var(--admin-space-5); }
|
||||
.role-editor-layout { grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.3fr); align-items: start; }
|
||||
.role-editor-layout:not(:has(.role-editor-permissions)) { grid-template-columns: minmax(0, 1fr); }
|
||||
.role-editor-layout > .role-editor-permissions.density-form-section { min-width: 0; padding: 0 0 0 var(--admin-space-5); border-left: 1px solid var(--cc-border); }
|
||||
.permission-tree { max-height: 380px; overflow-x: hidden; overflow-y: auto; }
|
||||
.permission-node span { min-width: 0; }
|
||||
.permission-node.is-navigation-group span { font-weight: 650; }
|
||||
:global(.role-editor-dialog) { max-height: min(760px, calc(100vh - 32px)); display: flex; flex-direction: column; }
|
||||
:global(.role-editor-dialog .el-dialog__body) { min-height: 0; overflow-y: auto; }
|
||||
:global(.role-editor-dialog .el-dialog__footer) { flex: 0 0 auto; }
|
||||
@media (max-width: 720px) {
|
||||
.role-editor-layout { grid-template-columns: minmax(0, 1fr); }
|
||||
.role-editor-layout > .role-editor-permissions.density-form-section { padding-top: 18px; padding-left: 0; border-top: 1px solid var(--cc-border); border-left: 0; }
|
||||
}
|
||||
:global(.role-editor-dialog.app-dialog.admin-density-dialog.el-dialog:not(.is-fullscreen)) { max-height: min(760px, calc(100vh - 32px)); }
|
||||
@media (max-width: 720px) { .role-editor-layout { grid-template-columns: minmax(0, 1fr); } }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user