feat: 接入权限接口

This commit is contained in:
xy
2026-09-24 08:31:21 +08:00
parent 93fb5fd2c4
commit e5b5412322
24 changed files with 2398 additions and 1738 deletions
@@ -0,0 +1,669 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
createPermissionResource,
deletePermissionResource,
getPermissionResources,
setPermissionResourceStatus,
sortPermissionResource,
updatePermissionResource,
} from '@/api/management/permissions'
import type {
CreatePermissionResourceRequest,
PermissionResourceNodeDto,
UpdatePermissionResourceRequest,
} from '@/api/management/types'
import { useLoginStore } from '@/stores/login'
const loginStore = useLoginStore()
const loading = ref(false)
const saving = ref(false)
const tree = ref<PermissionResourceNodeDto[]>([])
const selectedId = ref('')
const expanded = ref(true)
const treeKey = ref(0)
const editorVisible = ref(false)
const editingId = ref('')
const form = ref({
resourceType: 'DIR' as PermissionResourceNodeDto['resourceType'],
parentId: null as string | null,
code: '',
name: '',
routePath: '',
componentPath: '',
icon: '',
sortNo: 1,
status: 'ENABLED' as 'ENABLED' | 'DISABLED',
visible: true,
cacheable: false,
})
const canManage = computed(() => loginStore.hasPermission('system:config:menu'))
const currentNode = computed(
() => flatten(tree.value).find((node) => node.id === selectedId.value) ?? null,
)
const currentParentName = computed(() => {
const node = currentNode.value
if (!node?.parentId) return '根目录'
return flatten(tree.value).find((item) => item.id === node.parentId)?.name ?? '—'
})
const formParentName = computed(() => {
if (!form.value.parentId) return '根目录'
return flatten(tree.value).find((node) => node.id === form.value.parentId)?.name ?? '—'
})
const editorTitle = computed(
() => `${editingId.value ? '编辑' : '新增'}${typeName(form.value.resourceType)}`,
)
function flatten(nodes: PermissionResourceNodeDto[]): PermissionResourceNodeDto[] {
return nodes.flatMap((node) => [node, ...flatten(node.children ?? [])])
}
function isEffectivelyDisabled(node: PermissionResourceNodeDto): boolean {
if (node.status === 'DISABLED') return true
if (!node.parentId) return false
const parent = flatten(tree.value).find((item) => item.id === node.parentId)
return parent ? isEffectivelyDisabled(parent) : false
}
function typeName(type: PermissionResourceNodeDto['resourceType']) {
return type === 'DIR' ? '目录' : type === 'MENU' ? '菜单' : '按钮'
}
function selectNode(node: PermissionResourceNodeDto) {
selectedId.value = node.id
}
async function loadTree() {
if (!canManage.value) return
loading.value = true
try {
tree.value = await getPermissionResources()
if (!flatten(tree.value).some((node) => node.id === selectedId.value)) {
selectedId.value = tree.value[0]?.id ?? ''
}
treeKey.value += 1
} catch {
ElMessage.error('菜单资源加载失败,请检查菜单配置权限')
} finally {
loading.value = false
}
}
function openCreate(type: PermissionResourceNodeDto['resourceType'], parentId: string | null) {
editingId.value = ''
const siblings = parentId
? (flatten(tree.value).find((node) => node.id === parentId)?.children ?? [])
: tree.value
form.value = {
resourceType: type,
parentId,
code: '',
name: '',
routePath: '',
componentPath: '',
icon: '',
sortNo: siblings.length + 1,
status: 'ENABLED',
visible: type === 'MENU',
cacheable: false,
}
editorVisible.value = true
}
function openEdit(node: PermissionResourceNodeDto) {
editingId.value = node.id
form.value = {
resourceType: node.resourceType,
parentId: node.parentId,
code: node.code ?? '',
name: node.name,
routePath: node.routePath ?? '',
componentPath: node.componentPath ?? '',
icon: node.icon ?? '',
sortNo: node.sortNo,
status: node.status,
visible: node.visible,
cacheable: node.cacheable,
}
editorVisible.value = true
}
function validateForm() {
if (!form.value.name.trim()) return '请输入资源名称'
if (form.value.resourceType !== 'DIR' && !form.value.code.trim()) return '请输入权限标识'
if (
form.value.resourceType === 'MENU' &&
(!form.value.routePath.trim() || !form.value.componentPath.trim())
) {
return '菜单必须配置路由和组件路径'
}
if (
form.value.code &&
!/^[a-z][a-z0-9_-]*:[a-z0-9_-]+:[a-z0-9_-]+$/.test(form.value.code.trim())
) {
return '权限标识格式应为 domain:resource:action'
}
return ''
}
async function saveResource() {
const validationError = validateForm()
if (validationError) {
ElMessage.warning(validationError)
return
}
saving.value = true
const existingNode = editingId.value
? flatten(tree.value).find((node) => node.id === editingId.value)
: undefined
const oldStatus = existingNode?.status ?? 'ENABLED'
let resourceSaved = false
let statusChanged = false
const shared = {
code: form.value.resourceType === 'DIR' ? null : form.value.code.trim(),
name: form.value.name.trim(),
routePath: form.value.resourceType === 'MENU' ? form.value.routePath.trim() : null,
componentPath: form.value.resourceType === 'MENU' ? form.value.componentPath.trim() : null,
icon: form.value.icon.trim() || null,
sortNo: Number(form.value.sortNo),
visible: form.value.resourceType === 'MENU' && form.value.visible,
cacheable: form.value.resourceType === 'MENU' && form.value.cacheable,
}
try {
if (editingId.value) {
const payload: UpdatePermissionResourceRequest = shared
await updatePermissionResource(editingId.value, payload)
resourceSaved = true
selectedId.value = editingId.value
if (oldStatus !== form.value.status) {
await setPermissionResourceStatus(editingId.value, form.value.status === 'ENABLED')
statusChanged = true
}
ElMessage.success('菜单资源已更新')
} else {
const payload: CreatePermissionResourceRequest = {
...shared,
resourceType: form.value.resourceType,
parentId: form.value.parentId,
}
const createdId = await createPermissionResource(payload)
selectedId.value = createdId
editingId.value = createdId
resourceSaved = true
if (form.value.status === 'DISABLED') {
await setPermissionResourceStatus(createdId, false)
statusChanged = true
}
ElMessage.success(`${typeName(form.value.resourceType)}已创建`)
}
editorVisible.value = false
await loadTree()
await loginStore.refreshAuthorization()
} catch {
if (resourceSaved) {
ElMessage.error('资源已保存,但状态更新失败;请检查状态后重试')
await loadTree()
if (statusChanged) await loginStore.refreshAuthorization()
} else {
ElMessage.error('保存失败,请检查权限标识是否重复以及父级资源是否启用')
}
} finally {
saving.value = false
}
}
function hasProtectedDescendant(node: PermissionResourceNodeDto) {
return flatten(node.children ?? []).some((child) => child.protectedNode)
}
async function changeStatus(node: PermissionResourceNodeDto) {
const enabled = node.status !== 'ENABLED'
if (node.protectedNode || (!enabled && hasProtectedDescendant(node))) return
try {
await ElMessageBox.confirm(
`确定${enabled ? '启用' : '停用'}${typeName(node.resourceType)}“${node.name}”吗?`,
'确认状态变更',
{ type: 'warning', confirmButtonText: enabled ? '启用' : '停用', cancelButtonText: '取消' },
)
await setPermissionResourceStatus(node.id, enabled)
ElMessage.success(`${typeName(node.resourceType)}已${enabled ? '启用' : '停用'}`)
await loadTree()
await loginStore.refreshAuthorization()
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error('资源状态更新失败')
}
}
async function removeResource(node: PermissionResourceNodeDto) {
if (node.protectedNode || (node.resourceType === 'DIR' && node.children.length > 0)) return
const cascade = node.resourceType === 'MENU' && node.children.length > 0
if (cascade && hasProtectedDescendant(node)) return
try {
await ElMessageBox.confirm(
cascade
? `删除菜单“${node.name}”会同时删除其下的 ${node.children.length} 个按钮,是否继续?`
: `确定删除${typeName(node.resourceType)}“${node.name}”吗?`,
'删除菜单资源',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
)
await deletePermissionResource(node.id, cascade)
selectedId.value = node.parentId ?? ''
ElMessage.success('菜单资源已删除')
await loadTree()
await loginStore.refreshAuthorization()
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error('删除失败,请先处理下级资源')
}
}
function siblingsOf(node: PermissionResourceNodeDto) {
return node.parentId
? (flatten(tree.value).find((item) => item.id === node.parentId)?.children ?? [])
: tree.value
}
async function moveNode(node: PermissionResourceNodeDto, direction: -1 | 1) {
const siblings = [...siblingsOf(node)].sort((a, b) => a.sortNo - b.sortNo)
const index = siblings.findIndex((item) => item.id === node.id)
const neighbor = siblings[index + direction]
if (!neighbor) return
const temporarySort = Math.max(...siblings.map((item) => item.sortNo), 0) + siblings.length + 10
try {
await sortPermissionResource(node.id, temporarySort)
await sortPermissionResource(neighbor.id, node.sortNo)
await sortPermissionResource(node.id, neighbor.sortNo)
await loadTree()
} catch {
ElMessage.error('调整顺序失败')
await loadTree()
}
}
function toggleExpansion() {
expanded.value = !expanded.value
treeKey.value += 1
}
onMounted(loadTree)
</script>
<template>
<section class="menu-settings">
<header class="menu-toolbar">
<div>
<strong>菜单资源</strong>
<p>菜单资源同时作为导航、路由和角色授权的唯一来源。</p>
</div>
<div class="toolbar-actions">
<el-button :disabled="!canManage" @click="toggleExpansion">{{
expanded ? '全部收起' : '全部展开'
}}</el-button>
<el-button type="primary" :disabled="!canManage" @click="openCreate('DIR', null)"
>新增目录</el-button
>
<el-button
type="primary"
plain
:disabled="
!canManage ||
!currentNode ||
currentNode.resourceType === 'BUTTON' ||
isEffectivelyDisabled(currentNode)
"
@click="
currentNode &&
openCreate(currentNode.resourceType === 'DIR' ? 'MENU' : 'BUTTON', currentNode.id)
"
>新增下级</el-button
>
</div>
</header>
<div v-loading="loading" class="resource-workspace">
<aside class="resource-tree-card">
<el-tree
:key="treeKey"
:data="tree"
:props="{ children: 'children', label: 'name' }"
node-key="id"
:default-expand-all="expanded"
:expand-on-click-node="false"
highlight-current
:current-node-key="selectedId"
@node-click="selectNode"
>
<template #default="{ data }">
<span class="tree-node">
<span class="type-icon" :class="`type-${data.resourceType.toLowerCase()}`">{{
data.resourceType === 'DIR' ? '目' : data.resourceType === 'MENU' ? '页' : '钮'
}}</span>
<span class="tree-name">{{ data.name }}</span>
<small>#{{ data.sortNo }}</small>
<em :class="data.status === 'ENABLED' ? 'enabled' : 'disabled'">{{
data.status === 'ENABLED' ? '启用' : '停用'
}}</em>
</span>
</template>
</el-tree>
<div v-if="!loading && !tree.length" class="tree-empty">暂无菜单资源</div>
</aside>
<section v-if="currentNode" class="resource-detail-card">
<header class="detail-heading">
<div>
<span class="detail-type">{{ typeName(currentNode.resourceType) }}</span>
<h2>{{ currentNode.name }}</h2>
</div>
<div class="toolbar-actions">
<el-button
:disabled="!canManage || currentNode.protectedNode"
@click="openEdit(currentNode)"
>编辑</el-button
>
<el-button
:disabled="
!canManage ||
currentNode.protectedNode ||
(currentNode.status === 'ENABLED' && hasProtectedDescendant(currentNode))
"
@click="changeStatus(currentNode)"
>{{ currentNode.status === 'ENABLED' ? '停用' : '启用' }}</el-button
>
<el-button
:disabled="
!canManage ||
currentNode.protectedNode ||
(currentNode.resourceType === 'DIR' && currentNode.children.length > 0) ||
hasProtectedDescendant(currentNode)
"
type="danger"
plain
@click="removeResource(currentNode)"
>删除</el-button
>
</div>
</header>
<dl class="resource-facts">
<div>
<dt>权限标识</dt>
<dd>{{ currentNode.code ?? '—' }}</dd>
</div>
<div>
<dt>上级资源</dt>
<dd>
{{ currentParentName }}
</dd>
</div>
<div>
<dt>排序</dt>
<dd>
{{ currentNode.sortNo }}
<span class="sort-actions"
><button
type="button"
:disabled="!canManage"
aria-label="上移"
@click="moveNode(currentNode, -1)"
>
↑</button
><button
type="button"
:disabled="!canManage"
aria-label="下移"
@click="moveNode(currentNode, 1)"
>
↓
</button></span
>
</dd>
</div>
<div v-if="currentNode.resourceType === 'MENU'">
<dt>路由</dt>
<dd>{{ currentNode.routePath || '—' }}</dd>
</div>
<div v-if="currentNode.resourceType === 'MENU'">
<dt>组件</dt>
<dd>{{ currentNode.componentPath || '—' }}</dd>
</div>
<div>
<dt>图标</dt>
<dd>{{ currentNode.icon || '—' }}</dd>
</div>
<div v-if="currentNode.resourceType === 'MENU'">
<dt>导航显示</dt>
<dd>{{ currentNode.visible ? '显示' : '隐藏(仍可通过授权路由访问)' }}</dd>
</div>
<div v-if="currentNode.resourceType === 'MENU'">
<dt>缓存</dt>
<dd>{{ currentNode.cacheable ? '开启' : '关闭' }}</dd>
</div>
<div>
<dt>核心资源</dt>
<dd>{{ currentNode.protectedNode ? '是(不可删除或停用)' : '否' }}</dd>
</div>
</dl>
</section>
<section v-else class="resource-detail-card empty-detail">
从左侧选择资源,查看或维护配置。
</section>
</div>
<el-dialog v-model="editorVisible" :title="editorTitle" width="560px" destroy-on-close>
<el-form label-position="top">
<el-form-item label="资源类型"
><el-input :model-value="typeName(form.resourceType)" disabled
/></el-form-item>
<el-form-item label="上级资源"
><el-input :model-value="formParentName" disabled
/></el-form-item>
<el-form-item label="资源名称" required
><el-input v-model="form.name" maxlength="100"
/></el-form-item>
<el-form-item v-if="form.resourceType !== 'DIR'" label="权限标识" required>
<el-input
v-model="form.code"
:disabled="Boolean(editingId)"
placeholder="如 system:role:view"
/>
</el-form-item>
<el-form-item v-if="form.resourceType === 'MENU'" label="路由路径" required
><el-input v-model="form.routePath" placeholder="如 /system/role"
/></el-form-item>
<el-form-item v-if="form.resourceType === 'MENU'" label="组件路径" required
><el-input v-model="form.componentPath" placeholder="如 system/role/index"
/></el-form-item>
<el-form-item label="图标"
><el-input v-model="form.icon" placeholder="图标名称或符号"
/></el-form-item>
<el-form-item label="排序"
><el-input-number v-model="form.sortNo" :min="0" :max="9999"
/></el-form-item>
<el-form-item label="状态">
<el-select v-model="form.status">
<el-option label="启用" value="ENABLED" />
<el-option label="停用" value="DISABLED" />
</el-select>
</el-form-item>
<el-form-item v-if="form.resourceType === 'MENU'" label="菜单选项">
<el-checkbox v-model="form.visible">在侧边导航中显示</el-checkbox>
<el-checkbox v-model="form.cacheable">缓存页面</el-checkbox>
</el-form-item>
</el-form>
<template #footer
><el-button @click="editorVisible = false">取消</el-button
><el-button type="primary" :loading="saving" @click="saveResource"
>保存</el-button
></template
>
</el-dialog>
</section>
</template>
<style scoped>
.menu-settings {
min-height: 480px;
}
.menu-toolbar,
.detail-heading {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
}
.menu-toolbar {
margin-bottom: 14px;
}
.menu-toolbar strong {
color: var(--ink);
font-size: 15px;
}
.menu-toolbar p {
margin: 5px 0 0;
color: var(--mut);
font-size: 12px;
}
.toolbar-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.resource-workspace {
display: grid;
grid-template-columns: minmax(280px, 0.85fr) minmax(380px, 1.5fr);
gap: 14px;
align-items: start;
min-height: 420px;
}
.resource-tree-card,
.resource-detail-card {
min-height: 420px;
padding: 16px;
background: #fff;
border: 1px solid var(--line);
border-radius: var(--r);
}
.tree-node {
display: flex;
gap: 7px;
align-items: center;
width: 100%;
min-width: 0;
font-size: 12px;
}
.type-icon {
display: inline-grid;
width: 20px;
height: 20px;
color: var(--brand);
font-size: 10px;
place-items: center;
background: var(--brand-l);
border-radius: 5px;
}
.type-button {
color: #a06c1c;
background: #fff4dd;
}
.tree-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tree-node small {
margin-left: auto;
color: #98a8ae;
font-size: 10px;
}
.tree-node em {
padding: 1px 5px;
font-size: 9px;
font-style: normal;
border-radius: 10px;
}
.tree-node .enabled {
color: #18875d;
background: #e7f6ef;
}
.tree-node .disabled {
color: #849198;
background: #eef1f2;
}
.detail-heading {
padding-bottom: 14px;
border-bottom: 1px solid var(--line);
}
.detail-heading h2 {
margin: 4px 0 0;
color: var(--ink);
font-size: 16px;
}
.detail-type {
color: var(--brand);
font-size: 11px;
}
.resource-facts {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 18px;
margin: 0;
}
.resource-facts div {
padding: 14px 0;
border-bottom: 1px solid #eef3f5;
}
.resource-facts dt {
margin-bottom: 5px;
color: var(--mut);
font-size: 11px;
}
.resource-facts dd {
margin: 0;
color: var(--ink);
font-size: 12px;
overflow-wrap: anywhere;
}
.sort-actions {
display: inline-flex;
gap: 5px;
margin-left: 8px;
}
.sort-actions button {
width: 22px;
height: 22px;
color: var(--brand);
background: #f2f9fb;
border: 1px solid var(--line);
border-radius: 4px;
}
.sort-actions button:disabled {
opacity: 0.45;
}
.tree-empty,
.empty-detail {
display: grid;
color: var(--mut);
place-items: center;
}
.tree-empty {
padding: 50px 0;
font-size: 12px;
}
@media (max-width: 980px) {
.resource-workspace {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.menu-toolbar,
.detail-heading {
align-items: flex-start;
flex-direction: column;
}
.resource-facts {
grid-template-columns: 1fr;
}
}
</style>
@@ -1,8 +1,11 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useLoginStore } from '@/stores/login'
import EmbedSettingsCard from './components/EmbedSettingsCard.vue'
import MenuResourceSettings from './components/MenuResourceSettings.vue'
import SignatureSettingsCard from './components/SignatureSettingsCard.vue'
import SmsSettingsCard from './components/SmsSettingsCard.vue'
import TabletDevicesCard from './components/TabletDevicesCard.vue'
@@ -15,6 +18,12 @@ import {
import type { SettingToggleKey, SignatureSettings, SmsChannel, TabletDevice } from './types'
const loading = ref(true)
const activeTab = ref('general')
const loginStore = useLoginStore()
const canManageMenus = computed(() => loginStore.hasPermission('system:config:menu'))
watch(canManageMenus, (canManage) => {
if (!canManage && activeTab.value === 'menus') activeTab.value = 'general'
})
const signatureSettings = reactive<SignatureSettings>({ ...mockSignatureSettings })
const smsChannel = ref<SmsChannel>(defaultSmsChannel)
const tabletDevices = ref<TabletDevice[]>([])
@@ -71,25 +80,32 @@ onMounted(loadSettings)
<template>
<section class="settings-page">
<div v-if="loading" class="loading-grid" aria-label="正在加载系统设置">
<span v-for="index in 4" :key="index" />
</div>
<el-tabs v-model="activeTab" class="settings-tabs">
<el-tab-pane label="系统参数" name="general">
<div v-if="loading" class="loading-grid" aria-label="正在加载系统设置">
<span v-for="index in 4" :key="index" />
</div>
<div v-else class="settings-grid">
<SignatureSettingsCard
:settings="signatureSettings"
@update:link-expire-hours="updateLinkExpireHours"
@toggle="toggleSetting"
/>
<SmsSettingsCard
:channel="smsChannel"
:channel-options="smsChannelOptions"
@update:channel="updateSmsChannel"
@test="sendTestSms"
/>
<TabletDevicesCard :devices="tabletDevices" @reconnect="reconnectDevice" />
<EmbedSettingsCard @copy="copyEmbedUrl" @rotate-key="rotateKey" />
</div>
<div v-else class="settings-grid">
<SignatureSettingsCard
:settings="signatureSettings"
@update:link-expire-hours="updateLinkExpireHours"
@toggle="toggleSetting"
/>
<SmsSettingsCard
:channel="smsChannel"
:channel-options="smsChannelOptions"
@update:channel="updateSmsChannel"
@test="sendTestSms"
/>
<TabletDevicesCard :devices="tabletDevices" @reconnect="reconnectDevice" />
<EmbedSettingsCard @copy="copyEmbedUrl" @rotate-key="rotateKey" />
</div>
</el-tab-pane>
<el-tab-pane v-if="canManageMenus" label="菜单设置" name="menus">
<MenuResourceSettings />
</el-tab-pane>
</el-tabs>
</section>
</template>
@@ -100,6 +116,10 @@ onMounted(loadSettings)
margin: 0 auto;
}
.settings-tabs {
background: transparent;
}
.settings-grid,
.loading-grid {
display: grid;