feat(document-permissions): 重构权限页面按模板接入真实接口

This commit is contained in:
yelan
2026-09-03 10:24:28 +08:00
parent f9b556896b
commit e43a118dee
9 changed files with 789 additions and 226 deletions
@@ -0,0 +1,55 @@
# 文档权限页面接口接入说明
依据 default_OpenAPI (1).json,文档权限页面当前按“文档模板”维度接入权限。页面保留原型的三部分:
1. **文档库权限矩阵**:说明可见、可用、可维护和继承规则。
2. **按角色授权**:选择一个文档模板,调整角色的 VIEW、USE、MAINTAIN。
3. **按科室授权**:沿用当前模板,调整科室的 VIEW、USE、MAINTAIN。
## 已接入接口
| 用途 | 方法 | 路径 |
| ---------------------- | ------ | --------------------------------------------------------- |
| 查询可配置模板 | GET | /api/v1/templates |
| 查询角色 | GET | /api/v1/roles |
| 查询科室 | GET | /api/v1/departments |
| 查询模板权限及继承结果 | GET | /api/v1/templates/{templateId}/permissions |
| 绑定模板权限 | POST | /api/v1/templates/{templateId}/permissions |
| 撤销模板权限 | DELETE | /api/v1/templates/{templateId}/permissions/{permissionId} |
权限绑定请求使用 OpenAPI 中的 PermissionBindingRequest:
```json
{
"subjectType": "ROLE",
"subjectId": "<role-or-department-uuid>",
"permissionLevel": "USE",
"effect": "ALLOW"
}
```
权限等级映射:
- VIEW:可浏览模板
- USE:可发起签署
- MAINTAIN:可维护模板
## 页面保存行为
当前契约没有更新权限绑定的 PUT 接口,因此开关按以下规则保存:
- 开启:删除同主体、同等级的直接 DENY;没有有效允许权限时 POST ALLOW。
- 关闭:撤销直接 ALLOW;如果仍有继承的 ALLOW,POST 直接 DENY 覆盖继承。
- 继承 DENY 没有持久化绑定 ID,且契约约定拒绝优先,页面将其显示为不可直接覆盖。
页面不会把继承结果当成可删除的直接绑定,也不会伪造全局权限矩阵数据。真实环境接口没有数据时会显示空态。
## 当前 OpenAPI 未提供的能力
如果产品要求“跨全部文档模板”的全局角色/科室权限矩阵,后端还需要提供以下能力之一:
- 全局角色权限查询与批量保存接口;
- 全局科室权限查询与批量保存接口;
- 或提供带模板维度的批量权限查询/保存接口,避免前端逐个模板请求。
另外,当前 GET /api/v1/templates 的语义是查询当前用户有权限查看的模板。若权限管理员需要配置自己当前不可见的模板,请后端提供管理员可见的模板列表语义或专用接口。
@@ -95,6 +95,7 @@ function mapTemplatePermission(dto: TemplatePermissionResponseDto): TemplatePerm
effect: dto.effect,
inherited: dto.inherited,
createdAt: formatDateTime(dto.createdAt),
createdBy: dto.createdBy,
}
}
+4 -2
View File
@@ -511,7 +511,7 @@ export type ApiPermissionLevel = 'VIEW' | 'USE' | 'MAINTAIN'
export type ApiPermissionEffect = 'ALLOW' | 'DENY'
export interface TemplatePermissionResponseDto {
id: string
id?: string
templateId: string
subjectType: ApiPermissionSubjectType
subjectId: string
@@ -519,10 +519,11 @@ export interface TemplatePermissionResponseDto {
effect: ApiPermissionEffect
inherited: boolean
createdAt: string
createdBy?: string
}
export interface TemplatePermissionRecord {
id: string
id?: string
templateId: string
subjectType: ApiPermissionSubjectType
subjectId: string
@@ -531,6 +532,7 @@ export interface TemplatePermissionRecord {
effect: ApiPermissionEffect
inherited: boolean
createdAt: string
createdBy?: string
}
export interface CreateTemplatePermissionRequest {
@@ -1,59 +1,109 @@
<script setup lang="ts">
import type { DepartmentDocumentAccess, DepartmentPermissionRow } from '../types'
import type {
PermissionCellState,
PermissionColumn,
PermissionKey,
DepartmentPermissionRow,
} from '../types'
defineProps<{
const props = defineProps<{
rows: DepartmentPermissionRow[]
columns: PermissionColumn[]
saving?: boolean
}>()
const emit = defineEmits<{
adjust: [row: DepartmentPermissionRow]
toggle: [departmentId: string, permission: PermissionKey]
}>()
const accessLabels: Record<DepartmentDocumentAccess, string> = {
available: '可用',
invisible: '不可见',
function getState(row: DepartmentPermissionRow, permission: PermissionKey): PermissionCellState {
return (
row.permissionStates?.[permission] ?? (row.permissions[permission] ? 'direct-allow' : 'none')
)
}
function stateLabel(state: PermissionCellState) {
const labels: Record<PermissionCellState, string> = {
none: '未授权',
'direct-allow': '直接允许',
'direct-deny': '直接拒绝',
'inherited-allow': '继承允许',
'inherited-deny': '继承拒绝',
}
return labels[state]
}
function isDisabled(row: DepartmentPermissionRow, permission: PermissionKey) {
return props.saving || getState(row, permission) === 'inherited-deny'
}
</script>
<template>
<div class="table-scroll">
<table class="permission-table">
<thead>
<tr>
<th>科室</th>
<th>本科室文档</th>
<th>全院通用文档</th>
<th>其他科室文档</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row.id">
<td>
<strong>{{ row.department }}</strong>
</td>
<td>
<span class="access-tag" :class="'is-' + row.ownDocuments">
{{ accessLabels[row.ownDocuments] }}
</span>
</td>
<td>
<span class="access-tag" :class="'is-' + row.commonDocuments">
{{ accessLabels[row.commonDocuments] }}
</span>
</td>
<td>
<span class="access-tag" :class="'is-' + row.otherDocuments">
{{ accessLabels[row.otherDocuments] }}
</span>
</td>
<td>
<button type="button" class="adjust-button" @click="emit('adjust', row)">调整</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="!rows.length" class="empty-state">暂无科室数据</div>
<template v-else>
<div class="table-scroll">
<table class="permission-table" aria-label="按科室授权矩阵">
<thead>
<tr>
<th>科室</th>
<th
v-for="column in columns"
:key="column.key"
class="permission-heading"
:title="column.description"
>
{{ column.label }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row.id">
<td>
<div class="department-name">
<strong>{{ row.department }}</strong>
<small v-if="row.campus">{{ row.campus }}</small>
</div>
</td>
<td v-for="column in columns" :key="column.key" class="permission-cell">
<button
type="button"
class="permission-toggle"
:class="{
active: row.permissions[column.key],
inherited: getState(row, column.key).startsWith('inherited'),
denied: getState(row, column.key).endsWith('deny'),
}"
:disabled="isDisabled(row, column.key)"
:aria-pressed="row.permissions[column.key]"
:aria-label="
row.department +
':' +
column.label +
',' +
stateLabel(getState(row, column.key))
"
:title="
column.label +
':' +
column.description +
'(' +
stateLabel(getState(row, column.key)) +
')'
"
@click="emit('toggle', row.id, column.key)"
>
<span class="switch-track" aria-hidden="true" />
</button>
<span class="state-label">{{ stateLabel(getState(row, column.key)) }}</span>
</td>
</tr>
</tbody>
</table>
</div>
<p class="legend">
继承允许可通过开关新增直接拒绝;继承拒绝由上级规则控制,当前接口不支持直接覆盖。
</p>
</template>
</template>
<style scoped>
@@ -64,15 +114,15 @@ const accessLabels: Record<DepartmentDocumentAccess, string> = {
.permission-table {
width: 100%;
min-width: 640px;
font-size: 13px;
min-width: 680px;
font-size: 12.5px;
border-collapse: collapse;
}
.permission-table th {
padding: 9px 12px;
color: var(--mut);
font-size: 12.5px;
font-size: 12px;
font-weight: 700;
text-align: left;
white-space: nowrap;
@@ -80,6 +130,10 @@ const accessLabels: Record<DepartmentDocumentAccess, string> = {
border-bottom: 1px solid var(--line);
}
.permission-table th.permission-heading {
text-align: center;
}
.permission-table td {
padding: 9px 12px;
vertical-align: middle;
@@ -90,34 +144,106 @@ const accessLabels: Record<DepartmentDocumentAccess, string> = {
background: #f7fbfc;
}
.access-tag {
display: inline-block;
padding: 2px 10px;
font-size: 11.5px;
font-weight: 700;
.department-name {
display: flex;
flex-direction: column;
gap: 2px;
color: var(--ink);
}
.department-name small {
color: var(--mut);
font-size: 11px;
}
.permission-cell {
width: 23%;
text-align: center;
}
.permission-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 28px;
padding: 0;
vertical-align: middle;
background: transparent;
border-radius: 16px;
}
.permission-toggle:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
}
.switch-track {
position: relative;
display: block;
width: 36px;
height: 20px;
background: #cbd8de;
border-radius: 20px;
transition: background-color 0.2s ease;
}
.access-tag.is-available {
color: var(--ok);
background: var(--ok-l);
}
.access-tag.is-invisible {
color: #8a9aa3;
background: #eceef0;
}
.adjust-button {
padding: 4px 10px;
color: var(--brand);
font-size: 12px;
.switch-track::after {
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
content: '';
background: #fff;
border: 1px solid var(--brand);
border-radius: 5px;
border-radius: 50%;
transition: left 0.2s ease;
}
.adjust-button:hover {
background: var(--brand-l);
.permission-toggle.active .switch-track {
background: var(--ok);
}
.permission-toggle.inherited.active .switch-track {
background: #4eaa8a;
box-shadow: inset 0 0 0 2px rgb(255 255 255 / 45%);
}
.permission-toggle.denied .switch-track {
background: #cbd8de;
}
.permission-toggle.active .switch-track::after {
left: 18px;
}
.permission-toggle:disabled {
cursor: not-allowed;
opacity: 0.58;
}
.state-label {
display: inline-block;
min-width: 52px;
margin-left: 3px;
color: var(--mut);
font-size: 10.5px;
text-align: left;
}
.legend {
margin: 10px 0 0;
color: var(--mut);
font-size: 11px;
line-height: 1.6;
}
.empty-state {
padding: 28px 12px;
color: var(--mut);
font-size: 12.5px;
text-align: center;
background: #f8fbfc;
border-radius: 6px;
}
</style>
@@ -1,18 +1,46 @@
<script setup lang="ts">
import type { PermissionColumn, PermissionKey, RolePermissionRow } from '../types'
import type {
PermissionCellState,
PermissionColumn,
PermissionKey,
RolePermissionRow,
} from '../types'
defineProps<{
const props = defineProps<{
rows: RolePermissionRow[]
columns: PermissionColumn[]
saving?: boolean
}>()
const emit = defineEmits<{
toggle: [roleId: string, permission: PermissionKey]
}>()
function getState(row: RolePermissionRow, permission: PermissionKey): PermissionCellState {
return (
row.permissionStates?.[permission] ?? (row.permissions[permission] ? 'direct-allow' : 'none')
)
}
function stateLabel(state: PermissionCellState) {
const labels: Record<PermissionCellState, string> = {
none: '未授权',
'direct-allow': '直接允许',
'direct-deny': '直接拒绝',
'inherited-allow': '继承允许',
'inherited-deny': '继承拒绝',
}
return labels[state]
}
function isDisabled(row: RolePermissionRow, permission: PermissionKey) {
return props.saving || getState(row, permission) === 'inherited-deny'
}
</script>
<template>
<div class="permission-grid" role="table" aria-label="按角色授权矩阵">
<div v-if="!rows.length" class="empty-state">暂无角色数据</div>
<div v-else class="permission-grid" role="table" aria-label="按角色授权矩阵">
<div class="grid-cell grid-header role-header" role="columnheader">角色</div>
<div
v-for="column in columns"
@@ -27,16 +55,34 @@ const emit = defineEmits<{
<template v-for="row in rows" :key="row.id">
<div class="grid-cell role-name" role="rowheader">
<span class="role-color" :style="{ backgroundColor: row.color }" aria-hidden="true" />
{{ row.name }}
<span class="role-info">
<strong>{{ row.name }}</strong>
<small v-if="row.description">{{ row.description }}</small>
<small v-if="row.userCount">{{ row.userCount }} 人</small>
</span>
</div>
<div v-for="column in columns" :key="column.key" class="grid-cell permission-cell">
<button
type="button"
class="permission-toggle"
:class="{ active: row.permissions[column.key] }"
:class="{
active: row.permissions[column.key],
inherited: getState(row, column.key).startsWith('inherited'),
denied: getState(row, column.key).endsWith('deny'),
}"
:disabled="isDisabled(row, column.key)"
:aria-pressed="row.permissions[column.key]"
:aria-label="`${row.name}:${column.label}${row.permissions[column.key] ? ',已开启' : ',已关闭'}`"
:title="`${column.label}:${column.description}`"
:aria-label="
row.name + ':' + column.label + ',' + stateLabel(getState(row, column.key))
"
:title="
column.label +
':' +
column.description +
'(' +
stateLabel(getState(row, column.key)) +
')'
"
@click="emit('toggle', row.id, column.key)"
>
<span class="switch-track" aria-hidden="true" />
@@ -49,7 +95,7 @@ const emit = defineEmits<{
<style scoped>
.permission-grid {
display: grid;
grid-template-columns: 140px repeat(3, minmax(100px, 1fr));
grid-template-columns: minmax(190px, 1.5fr) repeat(3, minmax(100px, 1fr));
gap: 8px;
align-items: center;
font-size: 12.5px;
@@ -75,16 +121,35 @@ const emit = defineEmits<{
.role-name {
gap: 7px;
color: var(--ink);
align-items: flex-start;
font-weight: 700;
}
.role-color {
margin-top: 3px;
width: 10px;
height: 10px;
flex-shrink: 0;
border-radius: 3px;
}
.role-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.role-info small {
overflow: hidden;
color: var(--mut);
font-size: 11px;
font-weight: 400;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.permission-cell {
justify-content: center;
}
@@ -131,10 +196,33 @@ const emit = defineEmits<{
background: var(--ok);
}
.permission-toggle.inherited.active .switch-track {
background: #4eaa8a;
box-shadow: inset 0 0 0 2px rgb(255 255 255 / 45%);
}
.permission-toggle.denied .switch-track {
background: #cbd8de;
}
.permission-toggle.active .switch-track::after {
left: 18px;
}
.permission-toggle:disabled {
cursor: not-allowed;
opacity: 0.58;
}
.empty-state {
padding: 28px 12px;
color: var(--mut);
font-size: 12.5px;
text-align: center;
background: #f8fbfc;
border-radius: 6px;
}
@media (max-width: 600px) {
.permission-grid {
grid-template-columns: 120px repeat(3, minmax(76px, 1fr));
@@ -68,6 +68,12 @@ function submit() {
emit('add', { ...form })
}
function deletePermission(permissionId: string | undefined) {
if (permissionId) {
emit('delete', permissionId)
}
}
function formatSubject(subjectType: PermissionSubjectOption['type'], subjectId: string) {
const subject = props.subjects.find((item) => item.type === subjectType && item.id === subjectId)
return subject?.label ?? subjectId
@@ -137,7 +143,7 @@ watch(
class="delete-button"
:disabled="permission.inherited || saving"
:title="permission.inherited ? '继承权限不可直接删除' : '删除权限'"
@click="emit('delete', permission.id)"
@click="deletePermission(permission.id)"
>
删除
</button>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { getDepartments } from '@/api/management/organization'
@@ -11,87 +11,160 @@ import {
isPermissionMockEnabled,
} from '@/api/management/document-permissions'
import { getRoles } from '@/api/management/roles'
import { getUsers } from '@/api/management/users'
import type { DocumentRecord, TemplatePermissionRecord } from '@/api/management/types'
import type {
DepartmentRecord,
DocumentRecord,
RoleRecord,
TemplatePermissionRecord,
} from '@/api/management/types'
import DepartmentPermissionTable from './components/DepartmentPermissionTable.vue'
import RolePermissionMatrix from './components/RolePermissionMatrix.vue'
import TemplatePermissionPanel from './components/TemplatePermissionPanel.vue'
import { departmentPermissionRows, permissionColumns, rolePermissionRows } from './mock'
import type {
DepartmentPermissionRow,
PermissionCellState,
PermissionKey,
PermissionSubjectOption,
RolePermissionRow,
TemplatePermissionForm,
} from './types'
type PermissionSubjectType = 'ROLE' | 'DEPARTMENT'
interface PermissionResolution {
enabled: boolean
state: PermissionCellState
}
const permissionKeys: PermissionKey[] = ['VIEW', 'USE', 'MAINTAIN']
const loading = ref(true)
const roles = ref<RolePermissionRow[]>([])
const departments = ref<DepartmentPermissionRow[]>([])
const roleRecords = ref<RoleRecord[]>([])
const departmentRecords = ref<DepartmentRecord[]>([])
const roleRows = ref<RolePermissionRow[]>([])
const departmentRows = ref<DepartmentPermissionRow[]>([])
const templates = ref<DocumentRecord[]>([])
const selectedTemplateId = ref('')
const templatePermissions = ref<TemplatePermissionRecord[]>([])
const permissionSubjects = ref<PermissionSubjectOption[]>([])
const permissionLoading = ref(false)
const permissionSaving = ref(false)
const permissionError = ref(false)
let permissionRequestId = 0
const selectedTemplate = computed(() =>
templates.value.find((template) => template.id === selectedTemplateId.value),
)
function resolvePermission(
subjectType: PermissionSubjectType,
subjectId: string,
permission: PermissionKey,
): PermissionResolution {
const records = templatePermissions.value.filter(
(record) =>
record.subjectType === subjectType &&
record.subjectId === subjectId &&
record.permissionLevel === permission,
)
const hasDirectDeny = records.some((record) => !record.inherited && record.effect === 'DENY')
const hasDirectAllow = records.some((record) => !record.inherited && record.effect === 'ALLOW')
const hasInheritedDeny = records.some((record) => record.inherited && record.effect === 'DENY')
const hasInheritedAllow = records.some((record) => record.inherited && record.effect === 'ALLOW')
if (hasDirectDeny) {
return { enabled: false, state: 'direct-deny' }
}
if (hasInheritedDeny) {
return { enabled: false, state: 'inherited-deny' }
}
if (hasDirectAllow) {
return { enabled: true, state: 'direct-allow' }
}
if (hasInheritedAllow) {
return { enabled: true, state: 'inherited-allow' }
}
return { enabled: false, state: 'none' }
}
function buildPermissionMaps(subjectType: PermissionSubjectType, subjectId: string) {
const permissions = {} as Record<PermissionKey, boolean>
const permissionStates = {} as Record<PermissionKey, PermissionCellState>
for (const permission of permissionKeys) {
const resolution = resolvePermission(subjectType, subjectId, permission)
permissions[permission] = resolution.enabled
permissionStates[permission] = resolution.state
}
return { permissions, permissionStates }
}
function syncPermissionRows() {
roleRows.value = roleRecords.value.map((role, index) => ({
id: role.id,
name: role.name,
color: role.color ?? ['#6b5bd2', '#0e6e8c', '#0f9d6c', '#d9821f'][index % 4],
description: role.description,
userCount: role.userCount ?? 0,
...buildPermissionMaps('ROLE', role.id),
}))
departmentRows.value = departmentRecords.value.map((department) => ({
id: department.id,
department: department.name,
...buildPermissionMaps('DEPARTMENT', department.id),
}))
}
async function loadPermissions() {
loading.value = true
try {
if (isPermissionMockEnabled) {
roles.value = rolePermissionRows.map((role) => ({
roleRows.value = rolePermissionRows.map((role) => ({
...role,
permissions: { ...role.permissions },
}))
departments.value = departmentPermissionRows.map((row) => ({ ...row }))
departmentRows.value = departmentPermissionRows.map((row) => ({
...row,
permissions: { ...row.permissions },
}))
return
}
const [templatesResult, rolesResult, departmentsResult, usersResult] = await Promise.allSettled(
[
getDocuments({ page: 1, pageSize: 200, status: 'all' }),
getRoles(),
getDepartments(),
getUsers({ page: 1, pageSize: 200, status: 'all' }),
],
)
const [templatesResult, rolesResult, departmentsResult] = await Promise.allSettled([
getDocuments({ page: 1, pageSize: 200, status: 'all' }),
getRoles({ page: 1, pageSize: 200 }),
getDepartments(),
])
if (templatesResult.status === 'rejected') {
throw templatesResult.reason
}
templates.value = templatesResult.value.records
roleRecords.value = rolesResult.status === 'fulfilled' ? rolesResult.value : []
departmentRecords.value =
departmentsResult.status === 'fulfilled' ? departmentsResult.value : []
syncPermissionRows()
if (rolesResult.status === 'rejected' || departmentsResult.status === 'rejected') {
ElMessage.warning('角色或科室列表加载不完整,部分授权对象暂不可配置')
}
selectedTemplateId.value = templates.value[0]?.id ?? ''
permissionSubjects.value = [
...(rolesResult.status === 'fulfilled'
? rolesResult.value.map((role) => ({
type: 'ROLE' as const,
id: role.id,
label: role.name,
}))
: []),
...(departmentsResult.status === 'fulfilled'
? departmentsResult.value.map((department) => ({
type: 'DEPARTMENT' as const,
id: department.id,
label: department.name,
}))
: []),
...(usersResult.status === 'fulfilled'
? usersResult.value.records.map((user) => ({
type: 'USER' as const,
id: user.id,
label: `${user.name}${user.employeeNo ? `(${user.employeeNo})` : ''}`,
}))
: []),
]
await loadTemplatePermissions()
} catch {
roleRecords.value = []
departmentRecords.value = []
roleRows.value = []
departmentRows.value = []
templates.value = []
permissionSubjects.value = []
templatePermissions.value = []
permissionError.value = false
ElMessage.error('权限配置加载失败,请稍后重试')
} finally {
loading.value = false
@@ -99,76 +172,170 @@ async function loadPermissions() {
}
async function loadTemplatePermissions() {
if (!selectedTemplateId.value || isPermissionMockEnabled) {
const templateId = selectedTemplateId.value
const requestId = ++permissionRequestId
if (!templateId || isPermissionMockEnabled) {
templatePermissions.value = []
permissionError.value = false
return
}
permissionLoading.value = true
permissionError.value = false
try {
templatePermissions.value = await getTemplatePermissions(selectedTemplateId.value)
const records = await getTemplatePermissions(templateId)
if (requestId !== permissionRequestId || templateId !== selectedTemplateId.value) {
return
}
templatePermissions.value = records
syncPermissionRows()
} catch {
if (requestId !== permissionRequestId) {
return
}
templatePermissions.value = []
permissionError.value = true
syncPermissionRows()
ElMessage.error('模板权限加载失败,请稍后重试')
} finally {
permissionLoading.value = false
if (requestId === permissionRequestId) {
permissionLoading.value = false
}
}
}
function toggleRolePermission(roleId: string, permission: PermissionKey) {
const role = roles.value.find((item) => item.id === roleId)
if (!role) {
if (isPermissionMockEnabled) {
const role = roleRows.value.find((item) => item.id === roleId)
if (role) {
role.permissions[permission] = !role.permissions[permission]
ElMessage.info('演示数据已调整,真实环境将通过权限接口保存')
}
return
}
role.permissions[permission] = !role.permissions[permission]
ElMessage.info('权限已调整并记录审计日志')
void setSubjectPermission('ROLE', roleId, permission)
}
function adjustDepartmentPermission(row: DepartmentPermissionRow) {
ElMessage.info(`原型演示:调整${row.department}文档权限`)
}
async function addTemplatePermission(form: TemplatePermissionForm) {
if (!selectedTemplateId.value) {
function toggleDepartmentPermission(departmentId: string, permission: PermissionKey) {
if (isPermissionMockEnabled) {
const row = departmentRows.value.find((item) => item.id === departmentId)
if (row) {
row.permissions[permission] = !row.permissions[permission]
ElMessage.info('演示数据已调整,真实环境将通过权限接口保存')
}
return
}
void setSubjectPermission('DEPARTMENT', departmentId, permission)
}
async function applyTemplatePermission(
templateId: string,
subjectType: PermissionSubjectType,
subjectId: string,
permission: PermissionKey,
enabled: boolean,
) {
const matchingRecords = templatePermissions.value.filter(
(record) =>
record.subjectType === subjectType &&
record.subjectId === subjectId &&
record.permissionLevel === permission,
)
const directAllows = matchingRecords.filter(
(record) => !record.inherited && record.effect === 'ALLOW',
)
const directDenies = matchingRecords.filter(
(record) => !record.inherited && record.effect === 'DENY',
)
const inheritedAllows = matchingRecords.some(
(record) => record.inherited && record.effect === 'ALLOW',
)
if (enabled) {
for (const binding of directDenies) {
if (!binding.id) {
throw new Error('该直接拒绝权限缺少绑定 ID,无法撤销')
}
await deleteTemplatePermission(templateId, binding.id)
}
if (!directAllows.length && !inheritedAllows) {
await createTemplatePermission(templateId, {
subjectType,
subjectId,
permissionLevel: permission,
effect: 'ALLOW',
})
}
return
}
for (const binding of directAllows) {
if (!binding.id) {
throw new Error('该直接允许权限缺少绑定 ID,无法撤销')
}
await deleteTemplatePermission(templateId, binding.id)
}
if (!directDenies.length && inheritedAllows) {
await createTemplatePermission(templateId, {
subjectType,
subjectId,
permissionLevel: permission,
effect: 'DENY',
})
}
}
async function setSubjectPermission(
subjectType: PermissionSubjectType,
subjectId: string,
permission: PermissionKey,
) {
if (!selectedTemplateId.value || permissionSaving.value) {
return
}
const current = resolvePermission(subjectType, subjectId, permission)
if (current.state === 'inherited-deny') {
ElMessage.warning('该权限由上级规则拒绝,当前接口不支持直接覆盖')
return
}
const nextEnabled = !current.enabled
permissionSaving.value = true
try {
await createTemplatePermission(selectedTemplateId.value, form)
await applyTemplatePermission(
selectedTemplateId.value,
subjectType,
subjectId,
permission,
nextEnabled,
)
await loadTemplatePermissions()
ElMessage.success('模板权限已添加')
} catch {
ElMessage.error('模板权限添加失败,请稍后重试')
ElMessage.success(nextEnabled ? '权限已开启' : '权限已关闭')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '权限调整失败,请稍后重试')
} finally {
permissionSaving.value = false
}
}
async function deleteTemplatePermissionRecord(permissionId: string) {
if (!selectedTemplateId.value || !window.confirm('确定删除这条模板权限吗?')) {
return
}
permissionSaving.value = true
try {
await deleteTemplatePermission(selectedTemplateId.value, permissionId)
await loadTemplatePermissions()
ElMessage.success('模板权限已删除')
} catch {
ElMessage.error('模板权限删除失败,请稍后重试')
} finally {
permissionSaving.value = false
}
function handleTemplateChange(event: Event) {
selectedTemplateId.value = (event.target as HTMLSelectElement).value
void loadTemplatePermissions()
}
function selectTemplate(templateId: string) {
selectedTemplateId.value = templateId
function retryTemplatePermissions() {
void loadTemplatePermissions()
}
@@ -196,44 +363,79 @@ onMounted(loadPermissions)
</section>
</template>
<template v-else-if="isPermissionMockEnabled">
<template v-else>
<section class="page-card">
<div class="card-heading">
<h2>按角色授权</h2>
<div class="card-heading section-heading">
<div class="heading-copy">
<h2>按角色授权</h2>
<p>选择文档模板后,调整各个角色的可见、可用和可维护权限。</p>
</div>
<label v-if="!isPermissionMockEnabled" class="template-select">
<span>当前文档模板</span>
<select :value="selectedTemplateId" @change="handleTemplateChange">
<option value="" disabled>请选择模板</option>
<option v-for="template in templates" :key="template.id" :value="template.id">
{{ template.name }} · {{ template.version }}
</option>
</select>
</label>
</div>
<div v-if="!isPermissionMockEnabled && !selectedTemplateId" class="empty-state">
暂无可配置的文档模板
</div>
<div v-else-if="!isPermissionMockEnabled && permissionLoading" class="loading-state">
正在加载模板权限…
</div>
<div v-else-if="!isPermissionMockEnabled && permissionError" class="error-state">
<span>模板权限加载失败</span>
<button type="button" class="retry-button" @click="retryTemplatePermissions">
重新加载
</button>
</div>
<RolePermissionMatrix
:rows="roles"
v-else
:rows="roleRows"
:columns="permissionColumns"
:saving="permissionSaving"
@toggle="toggleRolePermission"
/>
</section>
<section class="page-card department-card">
<div class="card-heading">
<h2>按科室授权</h2>
<span>科室级别的文档可用范围</span>
<div class="card-heading section-heading">
<div class="heading-copy">
<h2>按科室授权</h2>
<p>
{{ selectedTemplate ? selectedTemplate.name : '当前文档模板' }}:调整各个科室的权限。
</p>
</div>
<span v-if="selectedTemplate" class="template-meta">
{{ selectedTemplate.code || selectedTemplate.id }} · {{ selectedTemplate.category }}
</span>
</div>
<DepartmentPermissionTable :rows="departments" @adjust="adjustDepartmentPermission" />
<div v-if="!isPermissionMockEnabled && !selectedTemplateId" class="empty-state">
暂无可配置的文档模板
</div>
<div v-else-if="!isPermissionMockEnabled && permissionLoading" class="loading-state">
正在加载模板权限…
</div>
<div v-else-if="!isPermissionMockEnabled && permissionError" class="error-state">
<span>模板权限加载失败</span>
<button type="button" class="retry-button" @click="retryTemplatePermissions">
重新加载
</button>
</div>
<DepartmentPermissionTable
v-else
:rows="departmentRows"
:columns="permissionColumns"
:saving="permissionSaving"
@toggle="toggleDepartmentPermission"
/>
</section>
</template>
<section v-else class="page-card">
<div class="card-heading">
<h2>模板权限</h2>
<span>按模板配置角色、科室和用户的可见/可用/可维护权限</span>
</div>
<TemplatePermissionPanel
:templates="templates"
:selected-template-id="selectedTemplateId"
:permissions="templatePermissions"
:subjects="permissionSubjects"
:loading="permissionLoading"
:saving="permissionSaving"
@update:selected-template-id="selectTemplate"
@add="addTemplatePermission"
@delete="deleteTemplatePermissionRecord"
/>
</section>
</section>
</template>
@@ -263,6 +465,15 @@ onMounted(loadPermissions)
margin-bottom: 14px;
}
.section-heading {
align-items: flex-end;
justify-content: space-between;
}
.heading-copy {
min-width: 0;
}
.card-heading h2 {
margin: 0;
color: var(--ink);
@@ -270,8 +481,14 @@ onMounted(loadPermissions)
line-height: 1.4;
}
.card-heading span {
margin-left: auto;
.heading-copy p {
margin: 5px 0 0;
color: var(--mut);
font-size: 11.5px;
font-weight: 400;
}
.card-heading > span {
color: var(--mut);
font-size: 12px;
}
@@ -283,8 +500,37 @@ onMounted(loadPermissions)
line-height: 1.8;
}
.template-select {
display: flex;
flex-direction: column;
flex-shrink: 0;
gap: 6px;
min-width: min(360px, 100%);
color: var(--mut);
font-size: 12px;
}
select {
min-width: 0;
padding: 7px 10px;
color: var(--ink);
font: inherit;
background: #fff;
border: 1px solid var(--line);
border-radius: 6px;
}
select:focus {
outline: none;
border-color: var(--brand);
}
.template-meta {
padding-bottom: 8px;
font-size: 11.5px !important;
}
.department-card {
margin-top: 16px;
margin-bottom: 0;
}
@@ -317,6 +563,38 @@ onMounted(loadPermissions)
width: 70%;
}
.loading-state,
.empty-state,
.error-state {
padding: 28px 16px;
color: var(--mut);
font-size: 12.5px;
text-align: center;
background: #f8fbfc;
border-radius: 6px;
}
.error-state {
display: flex;
gap: 10px;
align-items: center;
justify-content: center;
color: var(--err);
}
.retry-button {
padding: 4px 10px;
color: var(--brand);
font-size: 12px;
background: #fff;
border: 1px solid var(--brand);
border-radius: 5px;
}
.retry-button:hover {
background: var(--brand-l);
}
@keyframes skeleton-shimmer {
0% {
background-position: 100% 0;
@@ -327,13 +605,28 @@ onMounted(loadPermissions)
}
}
@media (max-width: 760px) {
.section-heading {
align-items: flex-start;
flex-direction: column;
}
.template-select {
width: 100%;
}
.template-meta {
padding-bottom: 0;
}
}
@media (max-width: 600px) {
.card-heading {
align-items: flex-start;
flex-direction: column;
}
.card-heading span {
.card-heading > span {
margin-left: 0;
}
}
@@ -2,17 +2,17 @@ import type { DepartmentPermissionRow, PermissionColumn, RolePermissionRow } fro
export const permissionColumns: PermissionColumn[] = [
{
key: 'visible',
key: 'VIEW',
label: '可见',
description: '可浏览模板',
},
{
key: 'usable',
key: 'USE',
label: '可用',
description: '可发起签署',
},
{
key: 'maintainable',
key: 'MAINTAIN',
label: '可维护',
description: '可编辑、停用模板',
},
@@ -26,9 +26,9 @@ export const rolePermissionRows: RolePermissionRow[] = [
description: '系统配置、用户与权限管理、审计日志',
userCount: 2,
permissions: {
visible: true,
usable: true,
maintainable: true,
VIEW: true,
USE: true,
MAINTAIN: true,
},
},
{
@@ -38,9 +38,9 @@ export const rolePermissionRows: RolePermissionRow[] = [
description: '全院文档库维护、报表分析、任务监督',
userCount: 4,
permissions: {
visible: true,
usable: true,
maintainable: false,
VIEW: true,
USE: true,
MAINTAIN: false,
},
},
{
@@ -50,9 +50,9 @@ export const rolePermissionRows: RolePermissionRow[] = [
description: '本科室文档发起签署、手写板 / 短信操作',
userCount: 23,
permissions: {
visible: false,
usable: false,
maintainable: false,
VIEW: false,
USE: false,
MAINTAIN: false,
},
},
{
@@ -62,9 +62,9 @@ export const rolePermissionRows: RolePermissionRow[] = [
description: '只读查询报表与审计留痕,不可发起',
userCount: 3,
permissions: {
visible: false,
usable: false,
maintainable: false,
VIEW: false,
USE: false,
MAINTAIN: false,
},
},
]
@@ -73,36 +73,26 @@ export const departmentPermissionRows: DepartmentPermissionRow[] = [
{
id: 'radiology',
department: '放射科',
ownDocuments: 'available',
commonDocuments: 'available',
otherDocuments: 'invisible',
permissions: { VIEW: true, USE: true, MAINTAIN: false },
},
{
id: 'cardiology',
department: '心内科',
ownDocuments: 'available',
commonDocuments: 'available',
otherDocuments: 'invisible',
permissions: { VIEW: true, USE: true, MAINTAIN: false },
},
{
id: 'orthopedics',
department: '骨科',
ownDocuments: 'available',
commonDocuments: 'available',
otherDocuments: 'invisible',
permissions: { VIEW: true, USE: true, MAINTAIN: false },
},
{
id: 'health-management',
department: '健康管理中心',
ownDocuments: 'available',
commonDocuments: 'available',
otherDocuments: 'invisible',
permissions: { VIEW: true, USE: true, MAINTAIN: false },
},
{
id: 'general',
department: '全院通用',
ownDocuments: 'available',
commonDocuments: 'available',
otherDocuments: 'invisible',
permissions: { VIEW: true, USE: true, MAINTAIN: false },
},
]
@@ -1,4 +1,7 @@
export type PermissionKey = 'visible' | 'usable' | 'maintainable'
export type PermissionKey = 'VIEW' | 'USE' | 'MAINTAIN'
export type PermissionCellState =
'none' | 'direct-allow' | 'direct-deny' | 'inherited-allow' | 'inherited-deny'
export interface PermissionColumn {
key: PermissionKey
@@ -13,16 +16,15 @@ export interface RolePermissionRow {
description: string
userCount: number
permissions: Record<PermissionKey, boolean>
permissionStates?: Record<PermissionKey, PermissionCellState>
}
export type DepartmentDocumentAccess = 'available' | 'invisible'
export interface DepartmentPermissionRow {
id: string
department: string
ownDocuments: DepartmentDocumentAccess
commonDocuments: DepartmentDocumentAccess
otherDocuments: DepartmentDocumentAccess
campus?: string
permissions: Record<PermissionKey, boolean>
permissionStates?: Record<PermissionKey, PermissionCellState>
}
export interface PermissionSubjectOption {