Files
admin-web/src/modules/iam/pages/RolesView.vue
T

291 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import '@/styles/adminDensity.css'
import '../iamDensity.css'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { Plus, ShieldCheck } from '@lucide/vue'
import {
changePlatformRoleStatus,
createPlatformRole,
deletePlatformRole,
listPlatformPermissions,
listPlatformRoles,
replacePlatformRolePermissions,
updatePlatformRole,
type PlatformRoleResource,
} from '@/modules/iam/api/platformIam'
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.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 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'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { adminNavigation } from '@/core/navigation/adminNavigation'
import type { Permission, Role } from '@/modules/iam/types'
import { buildNavigationPermissionTree, summarizePermissionSelection } from '@/modules/iam/utils/permissionTree'
import { mapPlatformPermission } from '@/modules/iam/utils/platformPermission'
interface TreeController {
getCheckedKeys: (leafOnly?: boolean) => Array<string | number>
getHalfCheckedKeys: () => Array<string | number>
setCheckedKeys: (keys: string[]) => void
}
const access = useAccessPreviewStore()
const editorOpen = ref(false)
const formRef = ref<FormInstance>()
const treeRef = ref<TreeController | null>(null)
const serverRoles = ref<Role[]>([])
const serverPermissions = ref<Permission[]>([])
const loading = ref(false)
const saving = ref(false)
const rowActionId = ref('')
const loadError = ref<ApiErrorDetails | null>(null)
const page = ref(1)
const pageSize = ref(10)
type RoleFilters = QueryModel & { keyword: string; status: string }
const filters = ref<RoleFilters>({ keyword: '', status: '' })
const appliedFilters = ref<RoleFilters>({ ...filters.value })
const queryFields: QueryField[] = [
{ key: 'keyword', type: 'input', label: '角色关键词', placeholder: '角色名称、编码或描述' },
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [{ label: '启用', value: '启用' }, { label: '停用', value: '停用' }] },
]
const emptyForm = (): Omit<Role, 'userCount'> => ({ id: '', name: '', code: '', description: '', dataScope: '本部门数据', status: '启用', permissionIds: [], builtIn: false })
const form = reactive(emptyForm())
const formRules: FormRules = {
name: [{ required: true, whitespace: true, message: '请输入角色名称', trigger: 'blur' }],
code: [{ required: true, whitespace: true, message: '请输入角色编码', trigger: 'blur' }],
}
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')
const { captureBaseline } = useUnsavedWorkRegistration({
key: 'role-editor',
label: editorLabel,
isOpen: editorOpen,
model: form,
requiredPermission,
})
const roles = computed(() => serverRoles.value)
const canCreate = computed(() => access.can('platform:role:create'))
const filteredRows = computed(() => roles.value.filter((item) => {
const keyword = appliedFilters.value.keyword.trim().toLowerCase()
return (!keyword || [item.name, item.code, item.description].some((value) => value.toLowerCase().includes(keyword))) && (!appliedFilters.value.status || item.status === appliedFilters.value.status)
}))
const pagedRows = computed(() => filteredRows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
const roleColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 250, fixed: 'left', slotName: 'actions' },
{ key: 'role', label: '角色名称', minWidth: 180, slotName: 'role' },
{ key: 'roleCode', prop: 'code', label: '角色编码', minWidth: 180, showOverflowTooltip: true },
{ key: 'description', prop: 'description', label: '说明', minWidth: 240, showOverflowTooltip: true },
{ key: 'permissionSummary', label: '授权范围', minWidth: 170, slotName: 'permissionSummary' },
{ key: 'userCount', prop: 'userCount', label: '用户数', width: 86, align: 'right' },
{ key: 'roleType', label: '类型', width: 95, slotName: 'roleType' },
{ key: 'status', label: '状态', width: 88, slotName: 'status' },
]
const availablePermissions = computed(() => serverPermissions.value)
const permissionTree = computed(() => buildNavigationPermissionTree(availablePermissions.value, adminNavigation))
const permissionGroupIds = computed(() => permissionTree.value.map((node) => node.id))
const assignablePermissionIds = computed(() => new Set(availablePermissions.value.map((permission) => permission.id)))
function applyFilters() {
appliedFilters.value = { ...filters.value }
page.value = 1
void loadRoles()
}
function mapPlatformRole(role: PlatformRoleResource): Role {
return {
id: String(role.id),
name: role.roleName,
code: role.roleCode,
description: role.description ?? '暂无角色说明',
userCount: role.memberCount,
dataScope: '全部数据',
status: role.status === 'ACTIVE' ? '启用' : '停用',
permissionIds: (role.permissionIds ?? []).map(String),
builtIn: role.roleType === 'SYSTEM',
}
}
async function loadRoles() {
loading.value = true
loadError.value = null
serverRoles.value = []
serverPermissions.value = []
try {
const [roleRecords, permissionRecords] = await Promise.all([
listPlatformRoles(),
listPlatformPermissions(),
])
serverPermissions.value = permissionRecords.map(mapPlatformPermission)
serverRoles.value = roleRecords.map(mapPlatformRole)
} catch (error) {
loadError.value = getApiErrorDetails(error, '平台角色列表加载失败,请稍后重试')
} finally {
loading.value = false
}
}
function openCreate() {
Object.assign(form, emptyForm())
captureBaseline()
editorOpen.value = true
}
function openEdit(row: Role) {
if (!canEdit.value && !canGrant.value) return
Object.assign(form, { ...row, permissionIds: [...row.permissionIds] })
captureBaseline()
editorOpen.value = true
}
function initializePermissionSelection() {
formRef.value?.clearValidate()
treeRef.value?.setCheckedKeys([...form.permissionIds])
}
function selectedPermissionIds() {
const checked = treeRef.value?.getCheckedKeys(false).map(String).filter((id) => assignablePermissionIds.value.has(id)) || []
const halfChecked = treeRef.value?.getHalfCheckedKeys().map(String).filter((id) => assignablePermissionIds.value.has(id)) || []
return [...new Set([...checked, ...halfChecked])]
}
function syncPermissionIds() {
form.permissionIds = selectedPermissionIds()
}
async function saveRole() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
const normalizedCode = form.code.trim().toUpperCase()
const duplicate = roles.value.some((item) => item.code.toLowerCase() === normalizedCode.toLowerCase() && item.id !== form.id)
if (duplicate) { ElMessage.error('角色编码已经存在'); return }
saving.value = true
try {
const permissionIds = selectedPermissionIds()
if (form.id) {
if (canEdit.value) {
await updatePlatformRole(form.id, { roleName: form.name.trim(), description: form.description.trim() })
}
if (canGrant.value) await replacePlatformRolePermissions(form.id, permissionIds)
} else {
const created = await createPlatformRole({
roleCode: normalizedCode,
roleName: form.name.trim(),
description: form.description.trim(),
})
if (canGrant.value && permissionIds.length > 0) await replacePlatformRolePermissions(created.id, permissionIds)
}
await loadRoles()
editorOpen.value = false
ElMessage.success(form.id ? '角色已更新' : '角色已创建')
} catch (error) {
ElMessage.error(getApiErrorMessage(error, '角色保存失败,请稍后重试'))
} finally {
saving.value = false
}
}
async function removeRole(row: Role) {
if (row.builtIn) { ElMessage.warning('系统内置角色不能删除'); return }
if (row.userCount > 0) { ElMessage.warning('请先移除该角色下的用户'); return }
try {
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)
ElMessage.success('角色已删除')
} catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(getApiErrorMessage(error, '角色删除失败,请稍后重试'))
} finally {
rowActionId.value = ''
}
}
async function toggleStatus(row: Role) {
if (row.builtIn) { ElMessage.warning('系统内置角色不能停用'); return }
const target = row.status === '启用' ? '停用' : '启用'
try {
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))
ElMessage.success(`角色已${target}`)
} catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(getApiErrorMessage(error, '角色状态更新失败,请稍后重试'))
} finally {
rowActionId.value = ''
}
}
function permissionSummary(row: Role) {
const { menuCount, actionCount } = summarizePermissionSelection(availablePermissions.value, row.permissionIds)
return `${menuCount} 菜单 · ${actionCount} 操作`
}
onMounted(loadRoles)
</script>
<template>
<AdminPage class="admin-density iam-density" as="div">
<ListPageLayout aria-label="平台角色管理">
<section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="pagedRows" :columns="roleColumns" preference-key="roles-table" :loading="loading" :total="filteredRows.length" row-key="id" border aria-label="平台角色列表" empty-text="没有符合条件的角色">
<template #toolbar-start><h1>角色权限</h1></template>
<template #filters><QueryToolbar v-model="filters" ariaLabel="角色筛选" :fields="queryFields" preference-key="roles-query" @search="applyFilters" /></template>
<template #notices><template v-if="loadError">
<LoadErrorState title="角色列表暂时无法加载" :message="loadError.message" :loading="loading" @retry="loadRoles">
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
</template></template>
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增角色</el-button></template>
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><div v-if="canEdit || canGrant || access.can('platform:role:status') || access.can('platform:role:delete')" class="table-actions density-actions"><el-button class="density-action" v-if="canEdit || canGrant" size="small" data-tone="neutral" :disabled="rowActionId === (row as Role).id || (row as Role).builtIn" @click="openEdit(row as Role)">配置</el-button><el-button class="density-action" v-if="access.can('platform:role:status')" size="small" :data-tone="(row as Role).status === '启用' ? 'danger' : 'success'" :loading="rowActionId === (row as Role).id" :disabled="(row as Role).builtIn" @click="toggleStatus(row as Role)">{{ (row as Role).status === '启用' ? '停用' : '启用' }}</el-button><el-button class="density-action" v-if="access.can('platform:role:delete')" size="small" data-tone="danger" :disabled="(row as Role).builtIn || (row as Role).userCount > 0 || rowActionId === (row as Role).id" @click="removeRole(row as Role)">删除</el-button></div><span v-else class="secondary-text">仅查看</span></div></template>
<template #role="{ row }"><div class="role-cell"><span><ShieldCheck :size="18" /></span><div><strong>{{ (row as Role).name }}</strong></div></div></template>
<template #permissionSummary="{ row }"><div class="primary-cell"><strong>{{ permissionSummary(row as Role) }}</strong><span>平台 IAM 权限</span></div></template>
<template #roleType="{ row }"><el-tag :type="(row as Role).builtIn ? 'warning' : 'info'" effect="plain" size="small">{{ (row as Role).builtIn ? '系统内置' : '自定义' }}</el-tag></template>
<template #status="{ row }"><StatusBadge :status="(row as Role).status || '—'" /></template>
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 条</span></template>
</DataTable></section>
</ListPageLayout>
<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 { 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); }
.permission-node span { min-width: 0; }
.permission-node.is-navigation-group span { font-weight: 650; }
: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>