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

342 lines
20 KiB
Vue
Raw Normal View History

2026-07-30 17:59:43 +08:00
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
2026-08-10 17:42:17 +08:00
import { Braces, FolderTree, MoreHorizontal, Pencil, Plus, Power, ShieldCheck, Trash2 } from '@lucide/vue'
2026-08-03 18:49:18 +08:00
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'
2026-08-10 15:36:53 +08:00
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import FormSection from '@/shared/components/FormSection.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import {
changePlatformPermissionStatus,
createPlatformPermission,
deletePlatformPermission,
listPlatformPermissions,
updatePlatformPermission,
2026-08-03 18:49:18 +08:00
} from '@/modules/iam/api/platformIam'
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import type { Permission, PermissionType } from '@/modules/iam/types'
import { buildPermissionTree, getDescendantIds, type PermissionTreeNode } from '@/modules/iam/utils/permissionTree'
import { mapPlatformPermission } from '@/modules/iam/utils/platformPermission'
2026-07-30 17:59:43 +08:00
const access = useAccessPreviewStore()
const editorOpen = ref(false)
const formRef = ref<FormInstance>()
2026-07-30 17:59:43 +08:00
const serverPermissions = ref<Permission[]>([])
const loading = ref(false)
const saving = ref(false)
const rowActionId = ref('')
2026-07-30 17:59:43 +08:00
const loadError = ref<ApiErrorDetails | null>(null)
const page = ref(1)
const pageSize = ref(10)
type PermissionFilters = QueryModel & { keyword: string; type: string; status: string }
const filters = ref<PermissionFilters>({ keyword: '', type: '', status: '' })
const appliedFilters = ref<PermissionFilters>({ ...filters.value })
const queryFields: QueryField[] = [
2026-08-06 19:03:13 +08:00
{ key: 'keyword', type: 'input', label: '权限关键词', placeholder: '权限名称、编码或路径' },
2026-08-13 11:57:44 +08:00
{ key: 'type', type: 'select', label: '类型', placeholder: '全部类型', options: [{ label: '菜单', value: '菜单' }, { label: '页面', value: '页面' }, { label: '按钮', value: '按钮' }, { label: '接口', value: '接口' }] },
2026-08-06 19:03:13 +08:00
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [{ label: '启用', value: '启用' }, { label: '停用', value: '停用' }] },
]
const expandedRowKeys = ref<string[]>([])
const tableRenderKey = ref(0)
2026-07-30 17:59:43 +08:00
const emptyForm = (): Permission => ({ id: '', parentId: null, name: '', code: '', type: '菜单', path: '', method: '-', status: '启用', sort: 10 })
const form = reactive(emptyForm())
const formRules = computed<FormRules>(() => ({
name: [{ required: true, whitespace: true, message: '请输入权限名称', trigger: 'blur' }],
code: [{ required: true, whitespace: true, message: '请输入权限编码', trigger: 'blur' }],
type: [{ required: true, message: '请选择资源类型', trigger: 'change' }],
2026-08-13 11:57:44 +08:00
parentId: ['按钮', '页面', '接口'].includes(form.type) ? [{ required: true, message: '请选择上级菜单', trigger: 'change' }] : [],
path: ['菜单', '页面'].includes(form.type) ? [{ required: true, whitespace: true, message: '请输入菜单路由', trigger: 'blur' }] : [],
method: form.type === '接口' ? [{ required: true, message: '请选择 HTTP 方法', trigger: 'change' }] : [],
sort: [{ required: true, type: 'number', message: '请输入显示排序', trigger: 'change' }],
}))
2026-07-30 17:59:43 +08:00
const editorLabel = computed(() => form.id ? '编辑权限' : '新增权限')
const requiredPermission = computed(() => form.id ? 'platform:permission:edit' : 'platform:permission:create')
2026-07-30 17:59:43 +08:00
const { captureBaseline } = useUnsavedWorkRegistration({
key: 'permission-editor',
label: editorLabel,
isOpen: editorOpen,
model: form,
requiredPermission,
})
2026-08-13 11:57:44 +08:00
const availablePermissionTypes: PermissionType[] = ['菜单', '页面', '按钮', '接口']
2026-07-31 15:34:35 +08:00
const permissions = computed(() => serverPermissions.value)
2026-07-30 17:59:43 +08:00
const canCreate = computed(() => access.can('platform:permission:create'))
const canEdit = computed(() => access.can('platform:permission:edit'))
const canChangeStatus = computed(() => access.can('platform:permission:status'))
const canDelete = computed(() => access.can('platform:permission:delete'))
2026-07-30 17:59:43 +08:00
function matches(item: Permission) {
const keyword = appliedFilters.value.keyword.trim().toLowerCase()
return (!keyword || [item.name, item.code, item.path].some((value) => value.toLowerCase().includes(keyword))) && (!appliedFilters.value.type || item.type === appliedFilters.value.type) && (!appliedFilters.value.status || item.status === appliedFilters.value.status)
2026-07-30 17:59:43 +08:00
}
const treeRows = computed(() => buildPermissionTree(permissions.value, matches))
const pagedTreeRows = computed(() => treeRows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
const rootRowKeys = computed(() => treeRows.value.map((item) => item.id))
2026-07-30 17:59:43 +08:00
const blockedParentIds = computed(() => new Set(
form.id ? [form.id, ...getDescendantIds(permissions.value, form.id)] : [],
))
const parentOptions = computed(() => permissions.value.filter((item) => (
2026-08-13 11:57:44 +08:00
['目录', '菜单', '页面'].includes(item.type) && !blockedParentIds.value.has(item.id)
2026-07-30 17:59:43 +08:00
)))
const permissionColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 280, fixed: 'left', slotName: 'actions' },
{ key: 'name', label: '权限名称', minWidth: 260, slotName: 'name' },
2026-08-17 19:52:04 +08:00
{ key: 'code', label: '权限编码', minWidth: 220, slotName: 'code' },
2026-08-06 19:03:13 +08:00
{ key: 'type', prop: 'type', label: '类型', width: 80 },
2026-08-17 19:52:04 +08:00
{ key: 'endpoint', label: '路由 / 接口', minWidth: 260, slotName: 'endpoint' },
{ key: 'sort', prop: 'sort', label: '排序', width: 72, align: 'right' },
{ key: 'status', label: '状态', width: 88, slotName: 'status' },
]
2026-07-30 17:59:43 +08:00
async function loadPermissions() {
loading.value = true
loadError.value = null
serverPermissions.value = []
try {
serverPermissions.value = (await listPlatformPermissions()).map(mapPlatformPermission)
expandedRowKeys.value = rootRowKeys.value
tableRenderKey.value += 1
2026-07-30 17:59:43 +08:00
} catch (error) {
loadError.value = getApiErrorDetails(error, '权限资源加载失败,请稍后重试')
} finally {
loading.value = false
}
}
function applyFilters() {
appliedFilters.value = { ...filters.value }
page.value = 1
void loadPermissions()
}
function collectDescendantIds(nodes: PermissionTreeNode[], ids: string[] = []) {
nodes.forEach((node) => {
ids.push(node.id)
if (node.children) collectDescendantIds(node.children, ids)
})
return ids
}
function permissionSummary(row: PermissionTreeNode) {
const descendants = collectDescendantIds(row.children ?? [])
.map((id) => permissions.value.find((item) => item.id === id))
.filter((item): item is Permission => Boolean(item))
2026-08-13 11:57:44 +08:00
const menuCount = descendants.filter((item) => ['菜单', '页面'].includes(item.type)).length
const actionCount = descendants.filter((item) => ['按钮', '接口'].includes(item.type)).length
if (menuCount === 0 && actionCount === 0) return '无下级权限'
return [menuCount ? `${menuCount} 个菜单` : '', actionCount ? `${actionCount} 项操作` : ''].filter(Boolean).join(' · ')
}
function expandAllRows() {
expandedRowKeys.value = collectDescendantIds(treeRows.value)
tableRenderKey.value += 1
}
function collapseActionRows() {
expandedRowKeys.value = rootRowKeys.value
tableRenderKey.value += 1
}
function openCreate(parentId: string | null = null) {
Object.assign(form, emptyForm(), { parentId })
captureBaseline()
editorOpen.value = true
nextTick(() => formRef.value?.clearValidate())
}
function openEdit(row: Permission) {
if (!canEdit.value || row.builtIn) return
Object.assign(form, { ...row })
captureBaseline()
editorOpen.value = true
nextTick(() => formRef.value?.clearValidate())
}
function handleTypeChange() {
2026-08-13 11:57:44 +08:00
if (!['菜单', '页面'].includes(form.type)) form.path = ''
form.method = form.type === '接口' ? (form.method === '-' ? 'GET' : form.method) : '-'
nextTick(() => formRef.value?.clearValidate(['parentId', 'path', 'method']))
}
2026-07-30 17:59:43 +08:00
async function savePermission() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
2026-07-30 17:59:43 +08:00
if (permissions.value.some((item) => item.code.toLowerCase() === form.code.toLowerCase() && item.id !== form.id)) { ElMessage.error('权限编码已经存在'); return }
2026-07-31 15:34:35 +08:00
saving.value = true
try {
if (form.id) {
const updated = await updatePlatformPermission(form.id, {
name: form.name.trim(),
parentId: form.parentId,
2026-08-13 11:57:44 +08:00
routePath: ['菜单', '页面'].includes(form.type) ? form.path.trim() : null,
httpMethod: form.method === '-' ? null : form.method,
sortNo: form.sort,
})
const index = serverPermissions.value.findIndex((item) => item.id === form.id)
if (index >= 0) serverPermissions.value[index] = mapPlatformPermission(updated)
ElMessage.success('权限资源已更新')
} else {
2026-07-30 17:59:43 +08:00
const created = await createPlatformPermission({
2026-08-13 11:57:44 +08:00
type: ({ 目录: 'MENU', 菜单: 'MENU', 页面: 'PAGE', 按钮: 'BUTTON', 接口: 'API' } as const)[form.type],
2026-07-30 17:59:43 +08:00
code: form.code.trim(),
name: form.name.trim(),
2026-07-30 21:23:39 +08:00
parentId: form.parentId || '0',
2026-08-13 11:57:44 +08:00
routePath: ['菜单', '页面'].includes(form.type) ? form.path.trim() : null,
httpMethod: form.type === '接口' ? form.method : null,
2026-07-30 17:59:43 +08:00
sortNo: form.sort,
})
serverPermissions.value.push(mapPlatformPermission(created))
ElMessage.success('权限资源已创建')
}
editorOpen.value = false
2026-07-31 15:34:35 +08:00
} catch (error) {
ElMessage.error(getApiErrorMessage(error, form.id ? '权限资源更新失败,请稍后重试' : '权限资源创建失败,请稍后重试'))
2026-07-31 15:34:35 +08:00
} finally {
saving.value = false
2026-07-30 17:59:43 +08:00
}
}
async function toggleStatus(row: Permission) {
if (row.builtIn) return
const target = row.status === '启用' ? '停用' : '启用'
try {
await ElMessageBox.confirm(`确认${target}权限“${row.name}”吗?`, '权限状态', { confirmButtonText: '确认', cancelButtonText: '取消' })
rowActionId.value = row.id
const updated = await changePlatformPermissionStatus(row.id, target === '启用' ? 'ACTIVE' : 'DISABLED')
Object.assign(row, mapPlatformPermission(updated))
ElMessage.success(`权限已${target}`)
} catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(getApiErrorMessage(error, '权限状态更新失败,请稍后重试'))
} finally {
rowActionId.value = ''
}
}
async function removePermission(row: Permission) {
if (row.builtIn) return
try {
await ElMessageBox.confirm(`确认删除权限“${row.name}”吗?删除前需确保没有下级资源且未分配给角色。`, '删除权限', { confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' })
rowActionId.value = row.id
await deletePlatformPermission(row.id)
await loadPermissions()
ElMessage.success('权限资源已删除')
} catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(getApiErrorMessage(error, '权限资源删除失败,请稍后重试'))
} finally {
rowActionId.value = ''
}
}
2026-08-13 11:57:44 +08:00
function typeClass(type: PermissionType) { return { '目录': 'directory', '菜单': 'menu', '页面': 'menu', '按钮': 'action', '接口': 'api' }[type] }
2026-07-30 17:59:43 +08:00
2026-08-10 17:42:17 +08:00
function canAddChild(row: Permission) {
2026-08-13 11:57:44 +08:00
return canCreate.value && ['菜单', '页面'].includes(row.type)
2026-08-10 17:42:17 +08:00
}
function canEditRow(row: Permission) {
return canEdit.value && !row.builtIn
}
function canChangeStatusRow(row: Permission) {
return canChangeStatus.value && !row.builtIn
}
function canDeleteRow(row: Permission) {
return canDelete.value && !row.builtIn
}
function permissionActionCount(row: Permission) {
return Number(canAddChild(row))
+ Number(canEditRow(row))
+ Number(canChangeStatusRow(row))
+ Number(canDeleteRow(row))
}
function showDeleteDirect(row: Permission) {
return canDeleteRow(row) && permissionActionCount(row) <= 3
}
function showDeleteMore(row: Permission) {
return canDeleteRow(row) && permissionActionCount(row) > 3
}
function moreActionsLabel(row: Permission) {
2026-08-12 12:19:02 +08:00
const resourceLabel = [row.name]
2026-08-10 17:42:17 +08:00
.find((value) => typeof value === 'string' && value.trim())
?.trim() || '当前权限'
return `${resourceLabel}的更多操作`
}
2026-07-30 17:59:43 +08:00
onMounted(loadPermissions)
</script>
<template>
2026-08-10 15:36:53 +08:00
<AdminPage as="div">
<ListPageLayout aria-label="平台权限资源管理">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="权限筛选" :fields="queryFields" preference-key="permissions-query" @search="applyFilters" /></template>
<template v-if="loadError" #state>
<LoadErrorState title="权限资源暂时无法加载" :message="loadError.message" :loading="loading" @retry="loadPermissions">
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
</template>
2026-07-30 17:59:43 +08:00
2026-08-10 16:24:54 +08:00
<section v-if="!loadError" class="surface-panel table-panel">
2026-08-12 14:08:33 +08:00
<DataTable :key="tableRenderKey" v-model:page="page" v-model:page-size="pageSize" :data="pagedTreeRows" :columns="permissionColumns" preference-key="permissions-table" :loading="loading" :total="treeRows.length" row-key="id" aria-label="平台权限资源列表" :expand-row-keys="expandedRowKeys" :tree-props="{ children: 'children' }" empty-text="没有符合条件的权限资源">
2026-08-10 18:21:40 +08:00
<template #toolbar-start><div class="permission-tree-tools"><div class="legend-list"><span><i class="is-menu" />目录 / 菜单</span><span><i class="is-action" />操作</span><span><i class="is-api" />接口预留</span><span class="permission-protection-hint">受保护资源不可编辑、停用或删除</span></div><div class="tree-view-actions"><el-button link type="primary" @click="expandAllRows">展开全部</el-button><el-button link @click="collapseActionRows">收起操作权限</el-button></div></div></template>
2026-08-10 17:42:17 +08:00
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate(null)"><Plus :size="16" />新增权限</el-button></template>
<template #actions="{ row }">
<div v-if="permissionActionCount(row as Permission) > 0 || (row as Permission).builtIn" class="table-actions">
2026-08-12 17:45:49 +08:00
<el-button v-if="canAddChild(row as Permission)" size="small" plain type="primary" @click="openCreate((row as Permission).id)"><Plus :size="15" />添加下级</el-button>
<el-button v-if="canEditRow(row as Permission)" size="small" plain type="primary" :disabled="rowActionId === (row as Permission).id" @click="openEdit(row as Permission)"><Pencil :size="15" />编辑</el-button>
<el-button v-if="canChangeStatusRow(row as Permission)" size="small" plain :type="(row as Permission).status === '启用' ? 'danger' : 'success'" :loading="rowActionId === (row as Permission).id" @click="toggleStatus(row as Permission)"><Power :size="15" />{{ (row as Permission).status === '启用' ? '停用' : '启用' }}</el-button>
<el-button v-if="showDeleteDirect(row as Permission)" size="small" plain type="danger" :disabled="rowActionId === (row as Permission).id" @click="removePermission(row as Permission)"><Trash2 :size="15" />删除</el-button>
2026-08-10 17:42:17 +08:00
<el-dropdown v-if="showDeleteMore(row as Permission)" trigger="click" @command="(command: string) => command === 'delete' && removePermission(row as Permission)">
2026-08-12 17:45:49 +08:00
<el-button size="small" plain :aria-label="moreActionsLabel(row as Permission)" :title="moreActionsLabel(row as Permission)"><MoreHorizontal :size="16" /></el-button>
2026-08-10 17:42:17 +08:00
<template #dropdown><el-dropdown-menu><el-dropdown-item command="delete" :disabled="rowActionId === (row as Permission).id" class="danger-menu-item"><Trash2 :size="15" />删除</el-dropdown-item></el-dropdown-menu></template>
</el-dropdown>
</div>
<span v-else class="secondary-text">仅查看</span>
</template>
2026-08-13 11:57:44 +08:00
<template #name="{ row }"><div class="permission-name"><span :class="`is-${typeClass((row as Permission).type)}`"><FolderTree v-if="['目录', '菜单', '页面'].includes((row as Permission).type)" :size="16" /><ShieldCheck v-else-if="(row as Permission).type === '按钮'" :size="16" /><Braces v-else :size="16" /></span><div><strong>{{ (row as Permission).name }}</strong><small v-if="(row as PermissionTreeNode).children?.length">{{ permissionSummary(row as PermissionTreeNode) }}</small></div></div></template>
<template #code="{ row }"><code class="code-value">{{ (row as Permission).code }}</code></template>
<template #endpoint="{ row }"><div class="endpoint-cell"><span v-if="(row as Permission).method !== '-'">{{ (row as Permission).method }}</span><code>{{ (row as Permission).path || '-' }}</code></div></template>
<template #status="{ row }"><StatusBadge :status="(row as Permission).status" /></template>
2026-08-10 16:24:54 +08:00
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 项</span></template>
</DataTable>
2026-08-10 15:36:53 +08:00
</section>
</ListPageLayout>
2026-07-30 17:59:43 +08:00
2026-08-10 15:36:53 +08:00
<AppDrawer v-model="editorOpen" :title="form.id ? '编辑权限资源' : '新增权限资源'" size="600px" class="business-drawer editor-drawer" :modal="false" :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="drawer-form">
2026-08-10 15:36:53 +08:00
<FormSection title="资源定义" description="定义权限名称、编码、类型和层级关系。">
2026-07-30 17:59:43 +08:00
<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>
2026-08-13 11:57:44 +08:00
<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>
2026-09-03 19:29:38 +08:00
<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>
2026-07-30 17:59:43 +08:00
</div>
2026-08-10 15:36:53 +08:00
</FormSection>
2026-08-13 11:57:44 +08:00
<div class="form-callout"><Braces :size="18" /><div><strong>权限编码约定</strong><p>使用稳定的“业务域:资源:动作”编码;资源类型与后端 MENU、PAGE、BUTTON、API 契约一致。</p></div></div>
2026-07-30 17:59:43 +08:00
</el-form>
2026-08-10 15:36:53 +08:00
</AppDrawer>
</AdminPage>
2026-07-30 17:59:43 +08:00
</template>
<style scoped>
.permission-tree-tools { display: flex; align-items: center; gap: 16px; }
2026-08-10 18:21:40 +08:00
.permission-protection-hint { color: var(--cc-muted); }
.tree-view-actions { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
.tree-view-actions .el-button + .el-button { margin-left: 0; }
.permission-name > div { display: flex; min-width: 0; flex-direction: column; gap: 2px; }
.permission-name small { color: var(--cc-muted); font-size: 11px; line-height: 1.35; }
2026-08-10 17:42:17 +08:00
:deep(.danger-menu-item:not(.is-disabled)) { color: var(--el-color-danger); }
@media (max-width: 720px) {
.permission-tree-tools { width: 100%; align-items: flex-start; flex-direction: column; gap: 8px; }
}
@media (max-width: 420px) {
.tree-view-actions { width: 100%; justify-content: flex-start; }
}
</style>