feat: 接入权限接口

This commit is contained in:
xy
2026-09-24 08:31:21 +08:00
parent 93fb5fd2c4
commit e5b5412322
24 changed files with 2398 additions and 1738 deletions
@@ -0,0 +1,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