feat: 接入权限接口
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { useLoginStore } from '@/stores/login'
|
||||
import PagePlaceholder from '@/components/PagePlaceholder.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const loginStore = useLoginStore()
|
||||
|
||||
const componentRegistry: Record<string, Component> = {
|
||||
'dashboard/index': defineAsyncComponent(() => import('@/views/workbench/home/index.vue')),
|
||||
'sign/workbench/index': defineAsyncComponent(() => import('@/views/workbench/signing/index.vue')),
|
||||
'system/org/index': defineAsyncComponent(() => import('@/views/management/users/index.vue')),
|
||||
'system/role/index': defineAsyncComponent(() => import('@/views/management/roles/index.vue')),
|
||||
'doc/library/index': defineAsyncComponent(() => import('@/views/management/documents/index.vue')),
|
||||
'report/index': defineAsyncComponent(() => import('@/views/management/reports/index.vue')),
|
||||
'system/setting/index': defineAsyncComponent(
|
||||
() => import('@/views/management/settings/index.vue'),
|
||||
),
|
||||
}
|
||||
|
||||
const resource = computed(() => loginStore.menuForPath(route.path))
|
||||
const pageComponent = computed(() => {
|
||||
const path = resource.value?.componentPath
|
||||
return path ? (componentRegistry[path] ?? null) : null
|
||||
})
|
||||
const unsupportedMessage = computed(() => {
|
||||
const componentPath = resource.value?.componentPath
|
||||
return componentPath
|
||||
? `前端暂未接入组件「${componentPath}」。资源路由和权限已从菜单资源读取。`
|
||||
: '当前路由没有对应的启用菜单资源。'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KeepAlive v-if="pageComponent && resource?.cacheable">
|
||||
<component :is="pageComponent" :key="resource?.id" />
|
||||
</KeepAlive>
|
||||
<component :is="pageComponent" v-else-if="pageComponent" />
|
||||
<PagePlaceholder
|
||||
v-else
|
||||
group="菜单资源"
|
||||
:title="resource?.name ?? '页面未接入'"
|
||||
:icon="resource?.icon ?? '◇'"
|
||||
:description="unsupportedMessage"
|
||||
/>
|
||||
</template>
|
||||
@@ -123,6 +123,10 @@ function mapRoleRecord(policy: RolePermissionPolicyRecord, index: number): RoleR
|
||||
color: ['#6b5bd2', '#0e6e8c', '#0f9d6c', '#d9821f', '#c65d5d'][index % 5],
|
||||
userCount: policy.userCount ?? undefined,
|
||||
status: 'ENABLED',
|
||||
menuPermissionCount: 0,
|
||||
buttonPermissionCount: 0,
|
||||
dataScope: 'ALL',
|
||||
configured: true,
|
||||
version: policy.version,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,870 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
import { getAllDepartments, getCampuses } from '@/api/management/organization'
|
||||
import {
|
||||
createRole,
|
||||
deleteRole,
|
||||
getRoleDataScope,
|
||||
getRolePermissionTree,
|
||||
getRolePermissions,
|
||||
getRoles,
|
||||
setRoleStatus,
|
||||
updateRole,
|
||||
updateRoleDataScope,
|
||||
updateRolePermissions,
|
||||
} from '@/api/management/roles'
|
||||
import type {
|
||||
CampusRecord,
|
||||
DepartmentRecord,
|
||||
PermissionResourceNodeDto,
|
||||
RoleDataScopeType,
|
||||
RoleRecord,
|
||||
} from '@/api/management/types'
|
||||
import { useLoginStore } from '@/stores/login'
|
||||
|
||||
const loginStore = useLoginStore()
|
||||
const loading = ref(false)
|
||||
const roles = ref<RoleRecord[]>([])
|
||||
const keyword = ref('')
|
||||
const formVisible = ref(false)
|
||||
const formSaving = ref(false)
|
||||
const permissionVisible = ref(false)
|
||||
const permissionLoading = ref(false)
|
||||
const permissionSaving = ref(false)
|
||||
const scopeVisible = ref(false)
|
||||
const scopeLoading = ref(false)
|
||||
const scopeSaving = ref(false)
|
||||
const activeRole = ref<RoleRecord | null>(null)
|
||||
const resourceTree = ref<PermissionResourceNodeDto[]>([])
|
||||
const permissionIds = ref<string[]>([])
|
||||
const campuses = ref<CampusRecord[]>([])
|
||||
const departments = ref<DepartmentRecord[]>([])
|
||||
const scope = reactive<{
|
||||
scopeType: RoleDataScopeType
|
||||
campusIds: string[]
|
||||
departmentIds: string[]
|
||||
}>({ scopeType: 'ALL', campusIds: [], departmentIds: [] })
|
||||
const roleForm = reactive<{
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
description: string
|
||||
status: 'ENABLED' | 'DISABLED'
|
||||
}>({ id: '', code: '', name: '', description: '', status: 'ENABLED' })
|
||||
|
||||
const canCreateOrEdit = computed(() => loginStore.hasPermission('system:role:edit'))
|
||||
const canChangeStatus = computed(() => loginStore.hasPermission('system:role:status'))
|
||||
const canDelete = computed(() => loginStore.hasPermission('system:role:delete'))
|
||||
const canViewPermissions = computed(() => loginStore.hasPermission('system:role-permission:view'))
|
||||
const canEditPermissions = computed(() => loginStore.hasPermission('system:role-permission:edit'))
|
||||
const canEditScope = computed(() => loginStore.hasPermission('system:role-scope:edit'))
|
||||
const availableDepartments = computed(() =>
|
||||
scope.campusIds.length
|
||||
? departments.value.filter((department) => scope.campusIds.includes(department.campusId))
|
||||
: departments.value,
|
||||
)
|
||||
const filteredRoles = computed(() => {
|
||||
const query = keyword.value.trim().toLocaleLowerCase()
|
||||
if (!query) return roles.value
|
||||
return roles.value.filter(
|
||||
(role) =>
|
||||
role.name.toLocaleLowerCase().includes(query) ||
|
||||
role.code.toLocaleLowerCase().includes(query),
|
||||
)
|
||||
})
|
||||
const scopeLabels: Record<RoleDataScopeType, string> = {
|
||||
ALL: '全院',
|
||||
CUSTOM: '指定院区 / 科室',
|
||||
DEPARTMENT: '本科室',
|
||||
DEPARTMENT_AND_CHILDREN: '本科室及下级',
|
||||
SELF: '仅本人',
|
||||
}
|
||||
function flattenNodes(nodes: PermissionResourceNodeDto[]): PermissionResourceNodeDto[] {
|
||||
return nodes.flatMap((node) => [node, ...flattenNodes(node.children ?? [])])
|
||||
}
|
||||
|
||||
function descendants(node: PermissionResourceNodeDto): PermissionResourceNodeDto[] {
|
||||
return (node.children ?? []).flatMap((child) => [child, ...descendants(child)])
|
||||
}
|
||||
|
||||
function nodeIsEffectivelyDisabled(node: PermissionResourceNodeDto): boolean {
|
||||
if (node.status === 'DISABLED') return true
|
||||
if (!node.parentId) return false
|
||||
const parent = flattenNodes(resourceTree.value).find((item) => item.id === node.parentId)
|
||||
return parent ? nodeIsEffectivelyDisabled(parent) : false
|
||||
}
|
||||
|
||||
function isPermissionChecked(node: PermissionResourceNodeDto): boolean {
|
||||
if (node.resourceType !== 'DIR') return permissionIds.value.includes(node.id)
|
||||
const activePermissions = flattenNodes([node]).filter(
|
||||
(child) => child.resourceType !== 'DIR' && !nodeIsEffectivelyDisabled(child),
|
||||
)
|
||||
return (
|
||||
activePermissions.length > 0 &&
|
||||
activePermissions.every((child) => permissionIds.value.includes(child.id))
|
||||
)
|
||||
}
|
||||
|
||||
function isPermissionIndeterminate(node: PermissionResourceNodeDto): boolean {
|
||||
if (node.resourceType !== 'DIR') return false
|
||||
const activePermissions = flattenNodes([node]).filter(
|
||||
(child) => child.resourceType !== 'DIR' && !nodeIsEffectivelyDisabled(child),
|
||||
)
|
||||
const selectedCount = activePermissions.filter((child) =>
|
||||
permissionIds.value.includes(child.id),
|
||||
).length
|
||||
return selectedCount > 0 && selectedCount < activePermissions.length
|
||||
}
|
||||
|
||||
function isPermissionDisabled(node: PermissionResourceNodeDto): boolean {
|
||||
if (node.resourceType === 'DIR') {
|
||||
const hasGrantableDescendant = flattenNodes([node]).some(
|
||||
(child) => child.resourceType !== 'DIR' && !nodeIsEffectivelyDisabled(child),
|
||||
)
|
||||
return nodeIsEffectivelyDisabled(node) || !hasGrantableDescendant
|
||||
}
|
||||
return nodeIsEffectivelyDisabled(node) && !permissionIds.value.includes(node.id)
|
||||
}
|
||||
|
||||
async function loadRoles() {
|
||||
loading.value = true
|
||||
try {
|
||||
roles.value = await getRoles()
|
||||
} catch {
|
||||
roles.value = []
|
||||
ElMessage.error('角色列表加载失败,请检查当前账号权限')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function isSystemAdmin(role: RoleRecord) {
|
||||
return role.code === 'SYSTEM_ADMIN'
|
||||
}
|
||||
|
||||
function openCreateForm() {
|
||||
Object.assign(roleForm, { id: '', code: '', name: '', description: '', status: 'ENABLED' })
|
||||
formVisible.value = true
|
||||
}
|
||||
|
||||
function openEditForm(role: RoleRecord) {
|
||||
Object.assign(roleForm, {
|
||||
id: role.id,
|
||||
code: role.code,
|
||||
name: role.name,
|
||||
description: role.description,
|
||||
status: role.status,
|
||||
})
|
||||
formVisible.value = true
|
||||
}
|
||||
|
||||
async function saveRole() {
|
||||
if (!roleForm.name.trim() || (!roleForm.id && !roleForm.code.trim())) {
|
||||
ElMessage.warning('请填写角色名称和角色编码')
|
||||
return
|
||||
}
|
||||
if (!roleForm.id && !/^[A-Z][A-Z0-9_]{1,19}$/.test(roleForm.code.trim())) {
|
||||
ElMessage.warning('角色编码需为 2–20 位大写字母、数字或下划线,且以字母开头')
|
||||
return
|
||||
}
|
||||
formSaving.value = true
|
||||
let savedRoleId = roleForm.id
|
||||
let roleDetailsSaved = false
|
||||
let statusChanged = false
|
||||
try {
|
||||
const payload = {
|
||||
code: roleForm.code.trim(),
|
||||
name: roleForm.name.trim(),
|
||||
description: roleForm.description.trim(),
|
||||
}
|
||||
if (roleForm.id) {
|
||||
await updateRole(roleForm.id, payload)
|
||||
roleDetailsSaved = true
|
||||
const currentRole = roles.value.find((role) => role.id === roleForm.id)
|
||||
if (currentRole && currentRole.status !== roleForm.status) {
|
||||
await setRoleStatus(roleForm.id, roleForm.status === 'ENABLED')
|
||||
statusChanged = true
|
||||
}
|
||||
ElMessage.success('角色信息已保存')
|
||||
formVisible.value = false
|
||||
await loadRoles()
|
||||
if (statusChanged) await loginStore.refreshAuthorization()
|
||||
} else {
|
||||
const id = await createRole(payload)
|
||||
savedRoleId = id
|
||||
roleDetailsSaved = true
|
||||
if (roleForm.status === 'DISABLED') {
|
||||
await setRoleStatus(id, false)
|
||||
statusChanged = true
|
||||
}
|
||||
formVisible.value = false
|
||||
await loadRoles()
|
||||
const createdRole = roles.value.find((role) => role.id === id)
|
||||
if (createdRole && canViewPermissions.value) {
|
||||
await openPermissionConfig(createdRole)
|
||||
} else {
|
||||
ElMessage.success('角色已创建,请配置角色权限后再启用')
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (roleDetailsSaved && savedRoleId) {
|
||||
ElMessage.error('角色信息已保存,但状态更新失败;请在列表中重试状态操作')
|
||||
formVisible.value = false
|
||||
await loadRoles()
|
||||
if (statusChanged) await loginStore.refreshAuthorization()
|
||||
} else {
|
||||
ElMessage.error('角色保存失败,请确认角色编码未重复')
|
||||
}
|
||||
} finally {
|
||||
formSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleRoleStatus(role: RoleRecord) {
|
||||
if (!canChangeStatus.value || isSystemAdmin(role)) return
|
||||
const enabled = role.status !== 'ENABLED'
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定${enabled ? '启用' : '停用'}角色“${role.name}”吗?`,
|
||||
'确认角色状态',
|
||||
{ type: 'warning', confirmButtonText: enabled ? '启用' : '停用', cancelButtonText: '取消' },
|
||||
)
|
||||
await setRoleStatus(role.id, enabled)
|
||||
ElMessage.success(`角色已${enabled ? '启用' : '停用'}`)
|
||||
await loadRoles()
|
||||
await loginStore.refreshAuthorization()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel' && error !== 'close') ElMessage.error('角色状态更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRole(role: RoleRecord) {
|
||||
if (!canDelete.value || isSystemAdmin(role)) return
|
||||
try {
|
||||
await ElMessageBox.confirm(`删除角色“${role.name}”后无法恢复,是否继续?`, '删除角色', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
await deleteRole(role.id)
|
||||
ElMessage.success('角色已删除')
|
||||
await loadRoles()
|
||||
await loginStore.refreshAuthorization()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel' && error !== 'close') ElMessage.error('角色删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function openPermissionConfig(role: RoleRecord) {
|
||||
if (!canViewPermissions.value) return
|
||||
activeRole.value = role
|
||||
permissionVisible.value = true
|
||||
permissionLoading.value = true
|
||||
try {
|
||||
const [tree, assigned] = await Promise.all([
|
||||
getRolePermissionTree(),
|
||||
getRolePermissions(role.id),
|
||||
])
|
||||
resourceTree.value = tree
|
||||
const nodes = flattenNodes(tree)
|
||||
const selected = new Set<string>()
|
||||
for (const id of assigned) {
|
||||
const node = nodes.find((item) => item.id === id)
|
||||
if (!node) continue
|
||||
if (node.resourceType === 'DIR') {
|
||||
descendants(node)
|
||||
.filter((child) => child.resourceType !== 'DIR')
|
||||
.forEach((child) => selected.add(child.id))
|
||||
} else {
|
||||
selected.add(id)
|
||||
if (node.resourceType === 'BUTTON') {
|
||||
const parent = nodes.find((item) => item.id === node.parentId)
|
||||
if (parent) selected.add(parent.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
permissionIds.value = [...selected]
|
||||
} catch {
|
||||
ElMessage.error('角色权限加载失败,请检查查看权限')
|
||||
} finally {
|
||||
permissionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handlePermissionToggle(node: PermissionResourceNodeDto, checked: boolean) {
|
||||
const selected = new Set(permissionIds.value)
|
||||
const childPermissions = descendants(node).filter((child) => child.resourceType !== 'DIR')
|
||||
if (node.resourceType === 'DIR') {
|
||||
childPermissions.forEach((child) => {
|
||||
if (!checked) selected.delete(child.id)
|
||||
else if (!nodeIsEffectivelyDisabled(child)) selected.add(child.id)
|
||||
})
|
||||
} else if (node.resourceType === 'MENU') {
|
||||
if (checked && !nodeIsEffectivelyDisabled(node)) selected.add(node.id)
|
||||
else if (!checked) selected.delete(node.id)
|
||||
childPermissions
|
||||
.filter((child) => child.resourceType === 'BUTTON')
|
||||
.forEach((child) => {
|
||||
if (!checked) selected.delete(child.id)
|
||||
else if (!nodeIsEffectivelyDisabled(child)) selected.add(child.id)
|
||||
})
|
||||
} else if (node.resourceType === 'BUTTON') {
|
||||
const parent = flattenNodes(resourceTree.value).find((item) => item.id === node.parentId)
|
||||
if (checked && !nodeIsEffectivelyDisabled(node)) {
|
||||
selected.add(node.id)
|
||||
if (parent && !nodeIsEffectivelyDisabled(parent)) selected.add(parent.id)
|
||||
} else {
|
||||
selected.delete(node.id)
|
||||
}
|
||||
}
|
||||
permissionIds.value = [...selected]
|
||||
}
|
||||
|
||||
function handlePermissionChange(node: PermissionResourceNodeDto, event: Event) {
|
||||
const target = event.target
|
||||
if (target instanceof HTMLInputElement) {
|
||||
handlePermissionToggle(node, target.checked)
|
||||
}
|
||||
}
|
||||
|
||||
async function savePermissions() {
|
||||
if (!activeRole.value || !canEditPermissions.value) return
|
||||
permissionSaving.value = true
|
||||
try {
|
||||
await updateRolePermissions(activeRole.value.id, permissionIds.value)
|
||||
ElMessage.success('角色权限已保存')
|
||||
permissionVisible.value = false
|
||||
await loadRoles()
|
||||
await loginStore.refreshAuthorization()
|
||||
} catch {
|
||||
ElMessage.error('角色权限保存失败,请稍后重试')
|
||||
} finally {
|
||||
permissionSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openScopeConfig(role: RoleRecord) {
|
||||
if (!canEditScope.value || isSystemAdmin(role)) return
|
||||
activeRole.value = role
|
||||
scopeVisible.value = true
|
||||
scopeLoading.value = true
|
||||
try {
|
||||
const [currentScope, campusOptions, departmentOptions] = await Promise.all([
|
||||
getRoleDataScope(role.id),
|
||||
getCampuses(),
|
||||
getAllDepartments(),
|
||||
])
|
||||
Object.assign(scope, {
|
||||
scopeType: currentScope.scopeType,
|
||||
campusIds: [...currentScope.campusIds],
|
||||
departmentIds: [...currentScope.departmentIds],
|
||||
})
|
||||
campuses.value = campusOptions
|
||||
departments.value = departmentOptions
|
||||
} catch {
|
||||
ElMessage.error('角色数据范围加载失败')
|
||||
} finally {
|
||||
scopeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveScope() {
|
||||
if (!activeRole.value || !canEditScope.value) return
|
||||
if (scope.scopeType === 'CUSTOM' && !scope.campusIds.length && !scope.departmentIds.length) {
|
||||
ElMessage.warning('自定义范围至少选择一个院区或科室')
|
||||
return
|
||||
}
|
||||
scopeSaving.value = true
|
||||
try {
|
||||
await updateRoleDataScope(activeRole.value.id, {
|
||||
scopeType: scope.scopeType,
|
||||
campusIds: scope.scopeType === 'CUSTOM' ? scope.campusIds : [],
|
||||
departmentIds: scope.scopeType === 'CUSTOM' ? scope.departmentIds : [],
|
||||
})
|
||||
ElMessage.success('角色数据范围已保存')
|
||||
scopeVisible.value = false
|
||||
await loadRoles()
|
||||
await loginStore.refreshAuthorization()
|
||||
} catch {
|
||||
ElMessage.error('角色数据范围保存失败')
|
||||
} finally {
|
||||
scopeSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadRoles)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="roles-page">
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<h1>角色权限</h1>
|
||||
<p>集中维护角色信息、菜单操作权限和数据范围;员工只负责绑定角色。</p>
|
||||
</div>
|
||||
<button v-if="canCreateOrEdit" class="primary-button" type="button" @click="openCreateForm">
|
||||
+新增角色
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section class="page-card">
|
||||
<div class="role-toolbar">
|
||||
<input v-model="keyword" type="search" placeholder="搜索角色名称或编码" />
|
||||
<span>{{ filteredRoles.length }} 个角色</span>
|
||||
</div>
|
||||
<div class="table-scroll">
|
||||
<table class="role-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>角色名称</th>
|
||||
<th>角色编码</th>
|
||||
<th>描述</th>
|
||||
<th>状态</th>
|
||||
<th>菜单权限</th>
|
||||
<th>按钮权限</th>
|
||||
<th>数据范围</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="role in filteredRoles" :key="role.id">
|
||||
<td>
|
||||
<strong>{{ role.name }}</strong
|
||||
><span v-if="isSystemAdmin(role)" class="builtin-tag">内置</span>
|
||||
</td>
|
||||
<td>
|
||||
<code>{{ role.code }}</code>
|
||||
</td>
|
||||
<td>{{ role.description || '—' }}</td>
|
||||
<td>
|
||||
<span
|
||||
class="status-tag"
|
||||
:class="role.status === 'ENABLED' ? 'is-enabled' : 'is-disabled'"
|
||||
>{{ role.status === 'ENABLED' ? '启用' : '停用' }}</span
|
||||
>
|
||||
</td>
|
||||
<td>{{ role.menuPermissionCount }}</td>
|
||||
<td>{{ role.buttonPermissionCount }}</td>
|
||||
<td>{{ scopeLabels[role.dataScope] ?? '未配置' }}</td>
|
||||
<td class="actions">
|
||||
<button
|
||||
v-if="canViewPermissions"
|
||||
class="text-button"
|
||||
type="button"
|
||||
@click="openPermissionConfig(role)"
|
||||
>
|
||||
配置权限
|
||||
</button>
|
||||
<button
|
||||
v-if="canEditScope"
|
||||
class="text-button"
|
||||
type="button"
|
||||
:disabled="isSystemAdmin(role)"
|
||||
@click="openScopeConfig(role)"
|
||||
>
|
||||
数据范围
|
||||
</button>
|
||||
<button
|
||||
v-if="canCreateOrEdit"
|
||||
class="text-button"
|
||||
type="button"
|
||||
:disabled="isSystemAdmin(role)"
|
||||
@click="openEditForm(role)"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
v-if="canChangeStatus"
|
||||
class="text-button"
|
||||
type="button"
|
||||
:disabled="isSystemAdmin(role)"
|
||||
@click="toggleRoleStatus(role)"
|
||||
>
|
||||
{{ role.status === 'ENABLED' ? '停用' : '启用' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="canDelete"
|
||||
class="text-button danger"
|
||||
type="button"
|
||||
:disabled="isSystemAdmin(role)"
|
||||
@click="removeRole(role)"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!loading && !filteredRoles.length">
|
||||
<td colspan="8" class="empty-cell">
|
||||
{{ roles.length ? '没有匹配的角色' : '暂无角色' }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="loading">
|
||||
<td colspan="8" class="empty-cell">正在加载角色…</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-dialog
|
||||
v-model="formVisible"
|
||||
:title="roleForm.id ? '编辑角色' : '新增角色'"
|
||||
width="520px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="角色名称" required
|
||||
><el-input v-model="roleForm.name" maxlength="80"
|
||||
/></el-form-item>
|
||||
<el-form-item label="角色编码" required
|
||||
><el-input
|
||||
v-model="roleForm.code"
|
||||
:disabled="Boolean(roleForm.id)"
|
||||
maxlength="20"
|
||||
placeholder="例如 CLINIC_MANAGER"
|
||||
/></el-form-item>
|
||||
<el-form-item label="描述"
|
||||
><el-input v-model="roleForm.description" type="textarea" :rows="3" maxlength="500"
|
||||
/></el-form-item>
|
||||
<el-form-item label="角色状态">
|
||||
<el-select v-model="roleForm.status" :disabled="!canChangeStatus" class="full-width">
|
||||
<el-option label="启用" value="ENABLED" />
|
||||
<el-option label="停用" value="DISABLED" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer
|
||||
><el-button @click="formVisible = false">取消</el-button
|
||||
><el-button type="primary" :loading="formSaving" @click="saveRole"
|
||||
>保存</el-button
|
||||
></template
|
||||
>
|
||||
</el-dialog>
|
||||
|
||||
<el-drawer
|
||||
v-model="permissionVisible"
|
||||
:title="`角色权限 · ${activeRole?.name ?? ''}`"
|
||||
size="680px"
|
||||
destroy-on-close
|
||||
>
|
||||
<div v-loading="permissionLoading" class="permission-drawer">
|
||||
<p class="help-text">
|
||||
目录选择会包含全部下级;选择菜单会包含其按钮;单独选择按钮会同时保留所属菜单。
|
||||
</p>
|
||||
<div class="permission-tree">
|
||||
<section v-for="directory in resourceTree" :key="directory.id" class="permission-group">
|
||||
<label class="permission-row directory-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isPermissionChecked(directory)"
|
||||
:indeterminate="isPermissionIndeterminate(directory)"
|
||||
:disabled="!canEditPermissions || isPermissionDisabled(directory)"
|
||||
@change="handlePermissionChange(directory, $event)"
|
||||
/>
|
||||
<span>{{ directory.name }}</span>
|
||||
<small>目录</small>
|
||||
<em v-if="nodeIsEffectivelyDisabled(directory)">已停用</em>
|
||||
</label>
|
||||
<div v-for="menu in directory.children ?? []" :key="menu.id" class="permission-branch">
|
||||
<label class="permission-row menu-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isPermissionChecked(menu)"
|
||||
:disabled="!canEditPermissions || isPermissionDisabled(menu)"
|
||||
@change="handlePermissionChange(menu, $event)"
|
||||
/>
|
||||
<span>{{ menu.name }}</span>
|
||||
<small>菜单</small>
|
||||
<em v-if="nodeIsEffectivelyDisabled(menu)">已停用</em>
|
||||
</label>
|
||||
<label
|
||||
v-for="button in menu.children ?? []"
|
||||
:key="button.id"
|
||||
class="permission-row button-row"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isPermissionChecked(button)"
|
||||
:disabled="!canEditPermissions || isPermissionDisabled(button)"
|
||||
@change="handlePermissionChange(button, $event)"
|
||||
/>
|
||||
<span>{{ button.name }}</span>
|
||||
<small>按钮</small>
|
||||
<em v-if="nodeIsEffectivelyDisabled(button)">已停用</em>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
<p v-if="!resourceTree.length" class="empty-cell">暂无可配置菜单资源</p>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer
|
||||
><el-button @click="permissionVisible = false">取消</el-button
|
||||
><el-button
|
||||
type="primary"
|
||||
:disabled="!canEditPermissions"
|
||||
:loading="permissionSaving"
|
||||
@click="savePermissions"
|
||||
>保存权限</el-button
|
||||
></template
|
||||
>
|
||||
</el-drawer>
|
||||
|
||||
<el-dialog
|
||||
v-model="scopeVisible"
|
||||
:title="`数据范围 · ${activeRole?.name ?? ''}`"
|
||||
width="560px"
|
||||
destroy-on-close
|
||||
>
|
||||
<div v-loading="scopeLoading" class="scope-form">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="范围类型">
|
||||
<el-select v-model="scope.scopeType" class="full-width">
|
||||
<el-option label="全院" value="ALL" />
|
||||
<el-option label="指定院区 / 科室" value="CUSTOM" />
|
||||
<el-option label="本科室" value="DEPARTMENT" />
|
||||
<el-option label="本科室及下级" value="DEPARTMENT_AND_CHILDREN" />
|
||||
<el-option label="仅本人" value="SELF" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<template v-if="scope.scopeType === 'CUSTOM'">
|
||||
<el-form-item label="院区"
|
||||
><el-select
|
||||
v-model="scope.campusIds"
|
||||
multiple
|
||||
filterable
|
||||
clearable
|
||||
class="full-width"
|
||||
@change="
|
||||
scope.departmentIds = scope.departmentIds.filter((id) =>
|
||||
availableDepartments.some((department) => department.id === id),
|
||||
)
|
||||
"
|
||||
><el-option
|
||||
v-for="campus in campuses"
|
||||
:key="campus.id"
|
||||
:label="campus.name"
|
||||
:value="campus.id" /></el-select
|
||||
></el-form-item>
|
||||
<el-form-item label="科室"
|
||||
><el-select
|
||||
v-model="scope.departmentIds"
|
||||
multiple
|
||||
filterable
|
||||
clearable
|
||||
class="full-width"
|
||||
><el-option
|
||||
v-for="department in availableDepartments"
|
||||
:key="department.id"
|
||||
:label="department.name"
|
||||
:value="department.id" /></el-select
|
||||
></el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
</div>
|
||||
<template #footer
|
||||
><el-button @click="scopeVisible = false">取消</el-button
|
||||
><el-button type="primary" :loading="scopeSaving" @click="saveScope"
|
||||
>保存</el-button
|
||||
></template
|
||||
>
|
||||
</el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.roles-page {
|
||||
width: 100%;
|
||||
max-width: 1480px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.page-heading {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.page-heading h1 {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-size: 19px;
|
||||
}
|
||||
.page-heading p {
|
||||
margin: 6px 0 0;
|
||||
color: var(--mut);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.page-card {
|
||||
padding: 16px;
|
||||
background: var(--card);
|
||||
border-radius: var(--r);
|
||||
box-shadow: var(--sh);
|
||||
}
|
||||
.role-toolbar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.role-toolbar input {
|
||||
width: min(360px, 100%);
|
||||
padding: 8px 10px;
|
||||
color: var(--ink);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.role-toolbar span {
|
||||
color: var(--mut);
|
||||
font-size: 12px;
|
||||
}
|
||||
.table-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.role-table {
|
||||
width: 100%;
|
||||
min-width: 1040px;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
.role-table th {
|
||||
padding: 11px 10px;
|
||||
color: var(--mut);
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
background: #f5f9fa;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.role-table td {
|
||||
padding: 12px 10px;
|
||||
border-bottom: 1px solid #eef4f6;
|
||||
}
|
||||
.role-table td code {
|
||||
color: #627c88;
|
||||
font-size: 11px;
|
||||
}
|
||||
.builtin-tag,
|
||||
.status-tag {
|
||||
display: inline-block;
|
||||
margin-left: 7px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 12px;
|
||||
font-size: 10px;
|
||||
}
|
||||
.builtin-tag {
|
||||
color: #7257bd;
|
||||
background: #f1edff;
|
||||
}
|
||||
.is-enabled {
|
||||
color: var(--ok);
|
||||
background: var(--ok-l);
|
||||
}
|
||||
.is-disabled {
|
||||
color: #819099;
|
||||
background: #edf1f2;
|
||||
}
|
||||
.actions {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.text-button {
|
||||
margin-right: 8px;
|
||||
padding: 2px 0;
|
||||
color: var(--brand);
|
||||
font-size: 12px;
|
||||
background: transparent;
|
||||
}
|
||||
.text-button.danger {
|
||||
color: var(--err);
|
||||
}
|
||||
.text-button:disabled {
|
||||
color: #a7b1b5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.primary-button {
|
||||
padding: 8px 13px;
|
||||
color: #fff;
|
||||
background: var(--brand);
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.empty-cell {
|
||||
padding: 40px !important;
|
||||
color: var(--mut);
|
||||
text-align: center;
|
||||
}
|
||||
.permission-drawer {
|
||||
min-height: 240px;
|
||||
}
|
||||
.permission-tree {
|
||||
max-height: calc(100vh - 250px);
|
||||
overflow: auto;
|
||||
}
|
||||
.permission-group {
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid #eef4f6;
|
||||
}
|
||||
.permission-branch {
|
||||
margin-left: 23px;
|
||||
}
|
||||
.permission-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
color: var(--ink);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.permission-row input {
|
||||
accent-color: var(--brand);
|
||||
}
|
||||
.permission-row small {
|
||||
color: var(--mut);
|
||||
font-size: 10px;
|
||||
}
|
||||
.permission-row em {
|
||||
color: var(--err);
|
||||
font-size: 10px;
|
||||
font-style: normal;
|
||||
}
|
||||
.permission-row:has(input:disabled) {
|
||||
color: #8b989e;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.button-row {
|
||||
margin-left: 24px;
|
||||
}
|
||||
.directory-row {
|
||||
font-weight: 600;
|
||||
}
|
||||
.help-text {
|
||||
margin: 0 0 12px;
|
||||
color: var(--mut);
|
||||
font-size: 12px;
|
||||
}
|
||||
.resource-node {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.resource-node small {
|
||||
color: var(--mut);
|
||||
font-size: 10px;
|
||||
}
|
||||
.resource-node em {
|
||||
color: var(--err);
|
||||
font-size: 10px;
|
||||
font-style: normal;
|
||||
}
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.page-heading {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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;
|
||||
|
||||
@@ -1,300 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { ApiPermissionRecord, RoleRecord } from '@/api/management/types'
|
||||
|
||||
const props = defineProps<{
|
||||
roles: RoleRecord[]
|
||||
permissions: ApiPermissionRecord[]
|
||||
selectedRoleId: string
|
||||
selectedPermissionIds: string[]
|
||||
loading: boolean
|
||||
saving: boolean
|
||||
error: string
|
||||
canEdit?: boolean
|
||||
drawer?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectRole: [roleId: string]
|
||||
togglePermission: [permissionId: string]
|
||||
save: []
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const groupedPermissions = computed(() => {
|
||||
const groups = new Map<string, ApiPermissionRecord[]>()
|
||||
|
||||
for (const permission of props.permissions) {
|
||||
const group = groups.get(permission.resourceType) ?? []
|
||||
group.push(permission)
|
||||
groups.set(permission.resourceType, group)
|
||||
}
|
||||
|
||||
return [...groups.entries()]
|
||||
})
|
||||
|
||||
function isSelected(permissionId: string) {
|
||||
return props.selectedPermissionIds.includes(permissionId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
:class="['api-permission-card', { 'drawer-panel': props.drawer, 'page-card': !props.drawer }]"
|
||||
>
|
||||
<div class="card-heading">
|
||||
<div>
|
||||
<h2>角色 API 权限</h2>
|
||||
<p>维护角色与后端 API 权限目录的关联,不等同于文档模板权限。</p>
|
||||
</div>
|
||||
<span class="heading-spacer" />
|
||||
<button
|
||||
v-if="props.drawer"
|
||||
type="button"
|
||||
class="close-button"
|
||||
aria-label="关闭角色 API 权限"
|
||||
@click="emit('close')"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<button
|
||||
v-if="props.canEdit !== false"
|
||||
type="button"
|
||||
class="table-button"
|
||||
:disabled="saving || loading || !selectedRoleId"
|
||||
@click="emit('save')"
|
||||
>
|
||||
{{ saving ? '保存中…' : '保存角色权限' }}
|
||||
</button>
|
||||
<span v-else class="readonly-note">当前账号仅可查看</span>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-state">正在加载 API 权限目录…</div>
|
||||
<div v-else-if="error" class="error-state">{{ error }}</div>
|
||||
<div v-else-if="!roles.length" class="empty-state">暂无可配置角色</div>
|
||||
<div v-else-if="!permissions.length" class="empty-state">
|
||||
暂无 API 权限目录或当前账号无查询权限
|
||||
</div>
|
||||
<div v-else class="api-permission-layout">
|
||||
<label class="role-selector">
|
||||
<span>角色</span>
|
||||
<select
|
||||
:value="selectedRoleId"
|
||||
:disabled="saving"
|
||||
@change="emit('selectRole', ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option v-for="role in roles" :key="role.id" :value="role.id">
|
||||
{{ role.name }}({{ role.code }})
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="permission-groups">
|
||||
<fieldset v-for="[resourceType, records] in groupedPermissions" :key="resourceType">
|
||||
<legend>{{ resourceType }}</legend>
|
||||
<label v-for="permission in records" :key="permission.id" class="permission-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isSelected(permission.id)"
|
||||
:disabled="saving || props.canEdit === false"
|
||||
@change="emit('togglePermission', permission.id)"
|
||||
/>
|
||||
<span>
|
||||
<strong>{{ permission.name }}</strong>
|
||||
<small>
|
||||
{{ permission.code }} · {{ permission.action }}
|
||||
<template v-if="permission.status === 'DISABLED'"> · 已停用</template>
|
||||
</small>
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.api-permission-card {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
/* 必须是 height 而不是 min-height:min-height 只有下限没有上限,
|
||||
权限目录一多,面板会跟着内容一起长高,被抽屉的 overflow: hidden 直接裁掉,
|
||||
内部就没有任何可滚动区域了。 */
|
||||
.drawer-panel {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
margin-top: 0;
|
||||
overflow: hidden;
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.card-heading {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.drawer-panel .card-heading {
|
||||
flex-shrink: 0;
|
||||
padding: 18px 20px;
|
||||
}
|
||||
|
||||
.card-heading h2 {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.card-heading p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--mut);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.heading-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
padding: 0 2px;
|
||||
color: var(--mut);
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.readonly-note {
|
||||
color: var(--mut);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.table-button {
|
||||
padding: 5px 10px;
|
||||
color: var(--brand);
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--brand);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.table-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.error-state,
|
||||
.empty-state {
|
||||
padding: 28px 16px;
|
||||
color: var(--mut);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-state {
|
||||
color: var(--err);
|
||||
}
|
||||
|
||||
.api-permission-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* min-height: 0 让这个 flex 子项能被压缩到内容高度以下,
|
||||
否则它会撑到内容高度,overflow: auto 永远触发不了 */
|
||||
.drawer-panel .api-permission-layout {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.role-selector {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.role-selector span {
|
||||
color: var(--ink);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.role-selector select {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.permission-groups {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.permission-groups fieldset {
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.permission-groups legend {
|
||||
padding: 0 5px;
|
||||
color: var(--brand);
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.permission-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
padding: 6px 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.permission-item input {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.permission-item span {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.permission-item strong {
|
||||
color: var(--ink);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.permission-item small {
|
||||
overflow: hidden;
|
||||
color: var(--mut);
|
||||
font-size: 10.5px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.api-permission-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,141 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { RoleSummary } from '../types'
|
||||
|
||||
defineProps<{
|
||||
rows: RoleSummary[]
|
||||
interactive?: boolean
|
||||
selectedRoleId?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [role: RoleSummary]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="rows.length" class="role-cards">
|
||||
<button
|
||||
v-for="role in rows"
|
||||
:key="role.id"
|
||||
type="button"
|
||||
class="role-card"
|
||||
:class="{ 'is-selected': selectedRoleId === role.id }"
|
||||
:disabled="!interactive"
|
||||
:aria-label="`${role.name},${interactive ? '配置角色 API 权限' : '无角色权限配置权限'}`"
|
||||
@click="emit('select', role)"
|
||||
>
|
||||
<h3>
|
||||
<span class="role-color" :style="{ backgroundColor: role.color }" aria-hidden="true" />
|
||||
{{ role.name }}
|
||||
</h3>
|
||||
<p>{{ role.description }}</p>
|
||||
<span class="role-count">
|
||||
{{ role.userCount === null ? '暂无人数统计' : `${role.userCount} 人` }}
|
||||
</span>
|
||||
<span v-if="interactive" class="role-action">配置角色权限 →</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="role-empty">暂无角色概览</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.role-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.role-card {
|
||||
width: 100%;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
padding: 13px 14px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
}
|
||||
|
||||
.role-card:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.role-card:not(:disabled):hover,
|
||||
.role-card.is-selected {
|
||||
border-color: var(--brand);
|
||||
box-shadow: var(--sh-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.role-card:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.role-card h3 {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.role-color {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.role-card p {
|
||||
min-height: 35px;
|
||||
margin: 6px 0;
|
||||
color: var(--mut);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.role-count {
|
||||
display: inline-block;
|
||||
padding: 2px 9px;
|
||||
color: var(--brand);
|
||||
font-size: 11px;
|
||||
background: var(--brand-l);
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.role-action {
|
||||
display: block;
|
||||
margin-top: 9px;
|
||||
color: var(--brand);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.role-empty {
|
||||
padding: 26px 12px;
|
||||
margin-bottom: 16px;
|
||||
color: var(--mut);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
background: #f8fbfc;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.role-cards {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.role-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -11,6 +11,7 @@ const props = defineProps<{
|
||||
campuses: CampusRecord[]
|
||||
departments: DepartmentRecord[]
|
||||
roles: RoleRecord[]
|
||||
canAssignRoles: boolean
|
||||
saving: boolean
|
||||
}>()
|
||||
|
||||
@@ -38,7 +39,6 @@ function createEmptyForm(): UserEditorForm {
|
||||
campusId: props.campuses[0]?.id ?? '',
|
||||
departmentId: '',
|
||||
status: 'enabled',
|
||||
dataScope: 'DEPARTMENT',
|
||||
roleIds: [],
|
||||
clearPhone: false,
|
||||
clearEmail: false,
|
||||
@@ -48,7 +48,13 @@ function createEmptyForm(): UserEditorForm {
|
||||
function resetForm() {
|
||||
Object.assign(
|
||||
form,
|
||||
props.initial ? { ...props.initial, roleIds: [...props.initial.roleIds] } : createEmptyForm(),
|
||||
props.initial
|
||||
? {
|
||||
...props.initial,
|
||||
roleIds: [...props.initial.roleIds],
|
||||
roleLabels: [...(props.initial.roleLabels ?? [])],
|
||||
}
|
||||
: createEmptyForm(),
|
||||
)
|
||||
error.message = ''
|
||||
}
|
||||
@@ -97,9 +103,9 @@ function submit() {
|
||||
return
|
||||
}
|
||||
|
||||
const disabledRole = props.roles.find(
|
||||
(role) => form.roleIds.includes(role.id) && role.status === 'DISABLED',
|
||||
)
|
||||
const disabledRole = props.canAssignRoles
|
||||
? props.roles.find((role) => form.roleIds.includes(role.id) && role.status === 'DISABLED')
|
||||
: undefined
|
||||
|
||||
if (disabledRole) {
|
||||
error.message = `角色“${disabledRole.name}”已停用,请移除后再保存`
|
||||
@@ -147,7 +153,13 @@ watch(
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
<strong id="user-editor-title">{{ isEditing ? '编辑用户' : '新增用户' }}</strong>
|
||||
<p>{{ isEditing ? '更新用户基础资料与数据范围' : '创建可登录医签通的医护用户' }}</p>
|
||||
<p>
|
||||
{{
|
||||
isEditing
|
||||
? '更新用户基础资料;权限和数据范围由绑定角色决定'
|
||||
: '创建可登录医签通的医护用户'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="close-button" aria-label="关闭" @click="emit('close')">
|
||||
×
|
||||
@@ -231,16 +243,7 @@ watch(
|
||||
<option value="locked">锁定</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>数据范围</span>
|
||||
<select v-model="form.dataScope">
|
||||
<option value="ALL">全院</option>
|
||||
<option value="CAMPUS">院区</option>
|
||||
<option value="DEPARTMENT">本科室</option>
|
||||
<option value="READ_ONLY_ALL">全院只读</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="form-grid__full">
|
||||
<label v-if="canAssignRoles" class="form-grid__full">
|
||||
<span>角色</span>
|
||||
<select v-model="form.roleIds" multiple size="4">
|
||||
<option v-for="role in roles" :key="role.id" :value="role.id">
|
||||
@@ -251,6 +254,11 @@ watch(
|
||||
</select>
|
||||
<small>可按住 Ctrl / Command 多选;保存时会完整替换用户角色。</small>
|
||||
</label>
|
||||
<label v-else class="form-grid__full">
|
||||
<span>已绑定角色</span>
|
||||
<input :value="form.roleLabels?.join('、') || '未分配角色'" disabled />
|
||||
<small>员工角色绑定权限由组织管理单独控制。</small>
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="error.message" class="form-error">{{ error.message }}</p>
|
||||
<p class="form-tip">
|
||||
|
||||
@@ -33,7 +33,6 @@ const statusLabels: Record<UserStatus, string> = {
|
||||
<th>院区</th>
|
||||
<th>科室</th>
|
||||
<th>角色</th>
|
||||
<th>数据范围</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
@@ -68,7 +67,6 @@ const statusLabels: Record<UserStatus, string> = {
|
||||
{{ row.role }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ row.dataScope }}</td>
|
||||
<td>
|
||||
<span class="status-tag" :class="'status-' + row.status">
|
||||
{{ statusLabels[row.status] }}
|
||||
@@ -103,7 +101,7 @@ const statusLabels: Record<UserStatus, string> = {
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!rows.length">
|
||||
<td colspan="8" class="empty-cell">没有找到匹配的用户</td>
|
||||
<td colspan="7" class="empty-cell">没有找到匹配的用户</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,35 +1,11 @@
|
||||
import type { RoleSummary, UserTableRow } from './types'
|
||||
import type { UserTableRow } from './types'
|
||||
|
||||
export const roleSummaries: RoleSummary[] = [
|
||||
{
|
||||
id: 'system-admin',
|
||||
name: '系统管理员',
|
||||
color: '#6b5bd2',
|
||||
description: '系统配置、用户与权限管理、审计日志',
|
||||
userCount: 2,
|
||||
},
|
||||
{
|
||||
id: 'medical-management',
|
||||
name: '医务管理',
|
||||
color: '#0e6e8c',
|
||||
description: '全院文档库维护、报表分析、任务监督',
|
||||
userCount: 4,
|
||||
},
|
||||
{
|
||||
id: 'department-signer',
|
||||
name: '科室签署员',
|
||||
color: '#0f9d6c',
|
||||
description: '本科室文档发起签署、手写板 / 短信操作',
|
||||
userCount: 23,
|
||||
},
|
||||
{
|
||||
id: 'audit-viewer',
|
||||
name: '查询审计员',
|
||||
color: '#d9821f',
|
||||
description: '只读查询报表与审计留痕,不可发起',
|
||||
userCount: 3,
|
||||
},
|
||||
]
|
||||
export const roleColors: Record<string, string> = {
|
||||
系统管理员: '#6b5bd2',
|
||||
医务管理: '#0e6e8c',
|
||||
科室签署员: '#0f9d6c',
|
||||
查询审计员: '#d9821f',
|
||||
}
|
||||
|
||||
export const users: UserTableRow[] = [
|
||||
{
|
||||
@@ -39,7 +15,6 @@ export const users: UserTableRow[] = [
|
||||
campus: '本部院区',
|
||||
department: '医务处',
|
||||
role: '医务管理',
|
||||
dataScope: '全院',
|
||||
status: 'enabled',
|
||||
},
|
||||
{
|
||||
@@ -49,7 +24,6 @@ export const users: UserTableRow[] = [
|
||||
campus: '本部院区',
|
||||
department: '放射科',
|
||||
role: '科室签署员',
|
||||
dataScope: '本科室',
|
||||
status: 'enabled',
|
||||
},
|
||||
{
|
||||
@@ -59,7 +33,6 @@ export const users: UserTableRow[] = [
|
||||
campus: '本部院区',
|
||||
department: '心内科',
|
||||
role: '科室签署员',
|
||||
dataScope: '本科室',
|
||||
status: 'enabled',
|
||||
},
|
||||
{
|
||||
@@ -69,7 +42,6 @@ export const users: UserTableRow[] = [
|
||||
campus: '东院区',
|
||||
department: '骨科',
|
||||
role: '科室签署员',
|
||||
dataScope: '本科室',
|
||||
status: 'enabled',
|
||||
},
|
||||
{
|
||||
@@ -79,7 +51,6 @@ export const users: UserTableRow[] = [
|
||||
campus: '本部院区',
|
||||
department: '信息科',
|
||||
role: '系统管理员',
|
||||
dataScope: '全院',
|
||||
status: 'enabled',
|
||||
},
|
||||
{
|
||||
@@ -89,7 +60,6 @@ export const users: UserTableRow[] = [
|
||||
campus: '东院区',
|
||||
department: '健康管理中心',
|
||||
role: '科室签署员',
|
||||
dataScope: '本科室',
|
||||
status: 'disabled',
|
||||
},
|
||||
{
|
||||
@@ -99,11 +69,6 @@ export const users: UserTableRow[] = [
|
||||
campus: '本部院区',
|
||||
department: '纪检监察室',
|
||||
role: '查询审计员',
|
||||
dataScope: '全院只读',
|
||||
status: 'enabled',
|
||||
},
|
||||
]
|
||||
|
||||
export const roleColors = Object.fromEntries(
|
||||
roleSummaries.map((role) => [role.name, role.color]),
|
||||
) as Record<string, string>
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
import type { BackendDataScope } from '@/api/management/types'
|
||||
|
||||
export type UserStatus = 'enabled' | 'disabled' | 'locked'
|
||||
|
||||
export interface RoleSummary {
|
||||
id: string
|
||||
name: string
|
||||
color: string
|
||||
description: string
|
||||
userCount: number | null
|
||||
}
|
||||
|
||||
export interface UserTableRow {
|
||||
id: string
|
||||
name: string
|
||||
@@ -24,7 +14,6 @@ export interface UserTableRow {
|
||||
role: string
|
||||
roleNames?: string[]
|
||||
roleIds?: string[]
|
||||
dataScope: string
|
||||
status: UserStatus
|
||||
version?: number
|
||||
}
|
||||
@@ -42,8 +31,8 @@ export interface UserEditorForm {
|
||||
campusId: string
|
||||
departmentId: string
|
||||
status: UserStatus
|
||||
dataScope: BackendDataScope
|
||||
roleIds: string[]
|
||||
roleLabels?: string[]
|
||||
clearPhone?: boolean
|
||||
clearEmail?: boolean
|
||||
version?: number
|
||||
|
||||
Reference in New Issue
Block a user