Files
admin-web/src/views/PermissionsView.vue
T
2026-08-03 11:45:45 +08:00

286 lines
18 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 { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { Braces, FolderTree, Pencil, Plus, ShieldCheck, Trash2 } from '@lucide/vue'
import StatusBadge from '@/components/StatusBadge.vue'
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
import {
changePlatformPermissionStatus,
createPlatformPermission,
deletePlatformPermission,
listPlatformPermissions,
updatePlatformPermission,
} from '@/api/platformIam'
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/api/http'
import ApiErrorMeta from '@/components/ApiErrorMeta.vue'
import { useUnsavedWorkRegistration } from '@/composables/useUnsavedWorkRegistration'
import { useAccessPreviewStore } from '@/stores/accessPreview'
import type { Permission, PermissionType } from '@/types/admin'
import { buildPermissionTree, getDescendantIds, type PermissionTreeNode } from '@/utils/permissionTree'
import { mapPlatformPermission } from '@/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', placeholder: '权限名称、编码或路径' },
{ key: 'type', type: 'select', placeholder: '全部类型', options: [{ label: '菜单', value: '菜单' }, { label: '按钮', value: '按钮' }] },
{ key: 'status', type: 'select', 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: form.type === '按钮' ? [{ required: true, message: '请选择上级菜单', trigger: 'change' }] : [],
path: form.type === '菜单' ? [{ required: true, whitespace: true, message: '请输入菜单路由', trigger: 'blur' }] : [],
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) => (
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' },
{ prop: 'type', label: '类型', width: 80 },
{ key: 'endpoint', label: '路由 / 接口', minWidth: 260, slotName: 'endpoint' },
{ 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) => 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 (form.type === '按钮') form.path = ''
nextTick(() => formRef.value?.clearValidate(['parentId', 'path']))
}
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: 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: form.type === '按钮' ? 'BUTTON' : 'MENU',
code: form.code.trim(),
name: form.name.trim(),
parentId: form.parentId || '0',
routePath: form.type === '菜单' ? form.path.trim() : null,
httpMethod: 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}”吗?`, '权限状态', { 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 = ''
}
}
function typeClass(type: PermissionType) { return { '目录': 'directory', '菜单': 'menu', '按钮': 'action', '接口': 'api' }[type] }
onMounted(loadPermissions)
</script>
<template>
<div class="business-page">
<el-alert v-if="loadError" type="error" :closable="false" show-icon role="alert"><template #title><div class="api-error-alert-title"><span>{{ loadError.message }}</span><ApiErrorMeta :details="loadError" /></div><el-button link type="danger" @click="loadPermissions">重新加载</el-button></template></el-alert>
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="权限筛选" :fields="queryFields" @search="applyFilters" />
<section class="surface-panel table-panel">
<header class="panel-heading"><div><h2>权限资源树 <span class="secondary-text">共 {{ permissions.length }} 项</span></h2><p>默认展示目录与菜单,展开菜单后查看按钮权限;接口权限将在服务端契约确定后补充。</p></div><div class="permission-tree-heading-actions"><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></div><div class="tree-view-actions"><el-button link type="primary" @click="expandAllRows">展开全部</el-button><el-button link @click="collapseActionRows">收起操作权限</el-button></div></div><el-button v-if="canCreate" type="primary" @click="openCreate(null)"><Plus :size="16" />新增权限</el-button></div></header>
<DataTable :key="tableRenderKey" v-model:page="page" v-model:page-size="pageSize" :data="pagedTreeRows" :columns="permissionColumns" :loading="loading" :total="treeRows.length" row-key="id" :expand-row-keys="expandedRowKeys" :tree-props="{ children: 'children' }" empty-text="没有符合条件的权限资源">
<template #actions="{ row }"><div v-if="canCreate || canEdit || canChangeStatus || canDelete" class="table-actions"><el-button v-if="canCreate && (row as Permission).type === '菜单'" link type="primary" @click="openCreate((row as Permission).id)"><Plus :size="15" />添加下级</el-button><el-button v-if="canEdit" link type="primary" :disabled="(row as Permission).builtIn || rowActionId === (row as Permission).id" @click="openEdit(row as Permission)"><Pencil :size="15" />编辑</el-button><el-button v-if="canChangeStatus" link :type="(row as Permission).status === '启用' ? 'danger' : 'success'" :loading="rowActionId === (row as Permission).id" :disabled="(row as Permission).builtIn" @click="toggleStatus(row as Permission)">{{ (row as Permission).status === '启用' ? '停用' : '启用' }}</el-button><el-button v-if="canDelete" link type="danger" :disabled="(row as Permission).builtIn || rowActionId === (row as Permission).id" @click="removePermission(row as Permission)"><Trash2 :size="15" />删除</el-button><span v-if="(row as Permission).builtIn" class="secondary-text">系统内置</span></div><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="(row as Permission).type === '目录' || (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>
</DataTable>
</section>
<el-drawer v-model="editorOpen" :title="form.id ? '编辑权限资源' : '新增权限资源'" size="600px" class="business-drawer editor-drawer" :modal="false" :close-on-click-modal="false">
<el-form ref="formRef" :model="form" :rules="formRules" label-position="top" class="drawer-form">
<div class="form-section">
<h3>资源定义</h3>
<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="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="form.type === '菜单'" label="路由路径" prop="path" required><el-input v-model="form.path" placeholder="如:/customers" /></el-form-item>
<el-form-item label="显示排序" prop="sort" required><el-input-number v-model="form.sort" :min="0" :max="999" controls-position="right" /></el-form-item>
</div>
</div>
<div class="form-callout"><Braces :size="18" /><div><strong>权限编码约定</strong><p>使用稳定的“业务域:资源:动作”编码;一期真实资源只创建菜单和按钮,接口鉴权由服务端实现。</p></div></div>
</el-form>
<template #footer><div class="drawer-footer"><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button v-if="form.id ? canEdit : canCreate" type="primary" :loading="saving" @click="savePermission">{{ form.id ? '保存修改' : '保存权限' }}</el-button></div></template>
</el-drawer>
</div>
</template>
<style scoped>
.permission-tree-tools { display: flex; align-items: center; gap: 16px; }
.permission-tree-heading-actions { display: flex; align-items: center; gap: 12px; }
.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; }
@media (max-width: 720px) {
.permission-tree-heading-actions { width: 100%; align-items: flex-start; flex-direction: column; gap: 8px; }
.permission-tree-tools { width: 100%; align-items: flex-start; flex-direction: column; gap: 8px; }
.permission-tree-heading-actions > .el-button { min-height: 40px; }
}
@media (max-width: 420px) {
.tree-view-actions { width: 100%; justify-content: flex-start; }
}
</style>