feat: 接入权限接口
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user