338 lines
20 KiB
Vue
338 lines
20 KiB
Vue
<script setup lang="ts">
|
||
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 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 ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
|
||
import {
|
||
changePlatformPermissionStatus,
|
||
createPlatformPermission,
|
||
deletePlatformPermission,
|
||
listPlatformPermissions,
|
||
updatePlatformPermission,
|
||
} 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'
|
||
|
||
const access = useAccessPreviewStore()
|
||
const editorOpen = ref(false)
|
||
const formRef = ref<FormInstance>()
|
||
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 PermissionFilters = QueryModel & { keyword: string; type: string; status: string }
|
||
const filters = ref<PermissionFilters>({ keyword: '', type: '', status: '' })
|
||
const appliedFilters = ref<PermissionFilters>({ ...filters.value })
|
||
const queryFields: QueryField[] = [
|
||
{ key: 'keyword', type: 'input', label: '权限关键词', placeholder: '权限名称、编码或路径' },
|
||
{ key: 'type', type: 'select', label: '类型', placeholder: '全部类型', options: [{ label: '菜单', value: '菜单' }, { label: '页面', value: '页面' }, { label: '按钮', value: '按钮' }, { label: '接口', value: '接口' }] },
|
||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [{ label: '启用', value: '启用' }, { label: '停用', value: '停用' }] },
|
||
]
|
||
const expandedRowKeys = ref<string[]>([])
|
||
const tableRenderKey = ref(0)
|
||
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' }],
|
||
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' }],
|
||
}))
|
||
const editorLabel = computed(() => form.id ? '编辑权限' : '新增权限')
|
||
const requiredPermission = computed(() => form.id ? 'platform:permission:edit' : 'platform:permission:create')
|
||
const { captureBaseline } = useUnsavedWorkRegistration({
|
||
key: 'permission-editor',
|
||
label: editorLabel,
|
||
isOpen: editorOpen,
|
||
model: form,
|
||
requiredPermission,
|
||
})
|
||
const availablePermissionTypes: PermissionType[] = ['菜单', '页面', '按钮', '接口']
|
||
const permissions = computed(() => serverPermissions.value)
|
||
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'))
|
||
|
||
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)
|
||
}
|
||
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))
|
||
const blockedParentIds = computed(() => new Set(
|
||
form.id ? [form.id, ...getDescendantIds(permissions.value, form.id)] : [],
|
||
))
|
||
const parentOptions = computed(() => permissions.value.filter((item) => (
|
||
['目录', '菜单', '页面'].includes(item.type) && !blockedParentIds.value.has(item.id)
|
||
)))
|
||
const permissionColumns: DataTableColumn[] = [
|
||
{ key: 'actions', label: '操作', width: 280, fixed: 'left', slotName: 'actions' },
|
||
{ key: 'name', label: '权限名称', minWidth: 260, slotName: 'name' },
|
||
{ key: 'code', label: '权限编码', minWidth: 220, slotName: 'code' },
|
||
{ key: 'type', prop: 'type', label: '类型', width: 80 },
|
||
{ key: 'endpoint', label: '路由 / 接口', minWidth: 260, slotName: 'endpoint' },
|
||
{ key: 'sort', prop: 'sort', label: '排序', width: 72, align: 'right' },
|
||
{ key: 'status', label: '状态', width: 88, slotName: 'status' },
|
||
]
|
||
|
||
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
|
||
} 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))
|
||
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() {
|
||
if (!['菜单', '页面'].includes(form.type)) form.path = ''
|
||
form.method = form.type === '接口' ? (form.method === '-' ? 'GET' : form.method) : '-'
|
||
nextTick(() => formRef.value?.clearValidate(['parentId', 'path', 'method']))
|
||
}
|
||
async function savePermission() {
|
||
const valid = await formRef.value?.validate().catch(() => false)
|
||
if (!valid) return
|
||
if (permissions.value.some((item) => item.code.toLowerCase() === form.code.toLowerCase() && item.id !== form.id)) { ElMessage.error('权限编码已经存在'); return }
|
||
saving.value = true
|
||
try {
|
||
if (form.id) {
|
||
const updated = await updatePlatformPermission(form.id, {
|
||
name: form.name.trim(),
|
||
parentId: form.parentId,
|
||
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 {
|
||
const created = await createPlatformPermission({
|
||
type: ({ 目录: 'MENU', 菜单: 'MENU', 页面: 'PAGE', 按钮: 'BUTTON', 接口: 'API' } as const)[form.type],
|
||
code: form.code.trim(),
|
||
name: form.name.trim(),
|
||
parentId: form.parentId || '0',
|
||
routePath: ['菜单', '页面'].includes(form.type) ? form.path.trim() : null,
|
||
httpMethod: form.type === '接口' ? form.method : null,
|
||
sortNo: form.sort,
|
||
})
|
||
serverPermissions.value.push(mapPlatformPermission(created))
|
||
ElMessage.success('权限资源已创建')
|
||
}
|
||
editorOpen.value = false
|
||
} catch (error) {
|
||
ElMessage.error(getApiErrorMessage(error, form.id ? '权限资源更新失败,请稍后重试' : '权限资源创建失败,请稍后重试'))
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
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: '取消' })
|
||
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}”吗?删除前需确保没有下级资源且未分配给角色。`, '删除权限', { customClass: 'admin-density-confirm', 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 = ''
|
||
}
|
||
}
|
||
function typeClass(type: PermissionType) { return { '目录': 'directory', '菜单': 'menu', '页面': 'menu', '按钮': 'action', '接口': 'api' }[type] }
|
||
|
||
function canAddChild(row: Permission) {
|
||
return canCreate.value && ['菜单', '页面'].includes(row.type)
|
||
}
|
||
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 moreActionsLabel(row: Permission) {
|
||
const resourceLabel = [row.name]
|
||
.find((value) => typeof value === 'string' && value.trim())
|
||
?.trim() || '当前权限'
|
||
return `${resourceLabel}的更多操作`
|
||
}
|
||
function permissionActions(row: Permission): RowActionItem[] {
|
||
return [
|
||
{ key: 'add-child', label: '添加下级', priority: 400, visible: canAddChild(row) },
|
||
{ key: 'edit', label: '编辑', priority: 300, visible: canEditRow(row), disabled: rowActionId.value === row.id },
|
||
{ key: 'status', label: row.status === '启用' ? '停用' : '启用', priority: 200, visible: canChangeStatusRow(row), tone: row.status === '启用' ? 'danger' : 'success', loading: rowActionId.value === row.id },
|
||
{ key: 'delete', label: '删除', tone: 'danger', divided: true, visible: canDeleteRow(row), disabled: rowActionId.value === row.id },
|
||
]
|
||
}
|
||
function handlePermissionAction(command: string, row: Permission) {
|
||
if (command === 'add-child') openCreate(row.id)
|
||
else if (command === 'edit') openEdit(row)
|
||
else if (command === 'status') void toggleStatus(row)
|
||
else if (command === 'delete') void removePermission(row)
|
||
}
|
||
|
||
onMounted(loadPermissions)
|
||
</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" :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="没有符合条件的权限资源">
|
||
<template #toolbar-start><h1>菜单权限</h1></template>
|
||
<template #filters><QueryToolbar v-model="filters" ariaLabel="权限筛选" :fields="queryFields" preference-key="permissions-query" @search="applyFilters" /></template>
|
||
<template #notices><template v-if="loadError">
|
||
<LoadErrorState title="权限资源暂时无法加载" :message="loadError.message" :loading="loading" @retry="loadPermissions">
|
||
<template #details><ApiErrorMeta :details="loadError" /></template>
|
||
</LoadErrorState>
|
||
</template></template>
|
||
<template #before-results><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>
|
||
|
||
|
||
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate(null)"><Plus :size="16" />新增权限</el-button></template>
|
||
<template #actions="{ row }"><RowActions text-actions v-if="permissionActions(row as Permission).some(action => action.visible !== false)" :actions="permissionActions(row as Permission)" :more-label="moreActionsLabel(row as Permission)" @action="handlePermissionAction($event, row as Permission)" /><span v-else class="secondary-text">仅查看</span></template>
|
||
<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>
|
||
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 项</span></template>
|
||
</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>
|
||
</el-form>
|
||
</AppDialog>
|
||
</AdminPage>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.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; }
|
||
.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: var(--admin-font-caption); line-height: 1.35; }
|
||
: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>
|