feat(permissions): implement document permission matrix

This commit is contained in:
yelan
2026-08-27 16:47:55 +08:00
parent e7aa4843b7
commit 94a0d88f8b
5 changed files with 578 additions and 17 deletions

View File

@@ -0,0 +1,123 @@
<script setup lang="ts">
import type { DepartmentDocumentAccess, DepartmentPermissionRow } from '../types'
defineProps<{
rows: DepartmentPermissionRow[]
}>()
const emit = defineEmits<{
adjust: [row: DepartmentPermissionRow]
}>()
const accessLabels: Record<DepartmentDocumentAccess, string> = {
available: '可用',
invisible: '不可见',
}
</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>
</template>
<style scoped>
.table-scroll {
width: 100%;
overflow-x: auto;
}
.permission-table {
width: 100%;
min-width: 640px;
font-size: 13px;
border-collapse: collapse;
}
.permission-table th {
padding: 9px 12px;
color: var(--mut);
font-size: 12.5px;
font-weight: 700;
text-align: left;
white-space: nowrap;
background: #f5f9fa;
border-bottom: 1px solid var(--line);
}
.permission-table td {
padding: 9px 12px;
vertical-align: middle;
border-bottom: 1px solid #eef4f6;
}
.permission-table tbody tr:hover td {
background: #f7fbfc;
}
.access-tag {
display: inline-block;
padding: 2px 10px;
font-size: 11.5px;
font-weight: 700;
border-radius: 20px;
}
.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;
background: #fff;
border: 1px solid var(--brand);
border-radius: 5px;
}
.adjust-button:hover {
background: var(--brand-l);
}
</style>

View File

@@ -0,0 +1,144 @@
<script setup lang="ts">
import type { PermissionColumn, PermissionKey, RolePermissionRow } from '../types'
defineProps<{
rows: RolePermissionRow[]
columns: PermissionColumn[]
}>()
const emit = defineEmits<{
toggle: [roleId: string, permission: PermissionKey]
}>()
</script>
<template>
<div class="permission-grid" role="table" aria-label="按角色授权矩阵">
<div class="grid-cell grid-header role-header" role="columnheader">角色</div>
<div
v-for="column in columns"
:key="column.key"
class="grid-cell grid-header"
role="columnheader"
:title="column.description"
>
{{ column.label }}
</div>
<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 }}
</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] }"
:aria-pressed="row.permissions[column.key]"
:aria-label="`${row.name}${column.label}${row.permissions[column.key] ? '已开启' : '已关闭'}`"
:title="`${column.label}${column.description}`"
@click="emit('toggle', row.id, column.key)"
>
<span class="switch-track" aria-hidden="true" />
</button>
</div>
</template>
</div>
</template>
<style scoped>
.permission-grid {
display: grid;
grid-template-columns: 140px repeat(3, minmax(100px, 1fr));
gap: 8px;
align-items: center;
font-size: 12.5px;
}
.grid-cell {
display: flex;
align-items: center;
min-height: 28px;
}
.grid-header {
justify-content: center;
color: var(--mut);
font-size: 12px;
font-weight: 700;
}
.role-header {
justify-content: flex-start;
}
.role-name {
gap: 7px;
color: var(--ink);
font-weight: 700;
}
.role-color {
width: 10px;
height: 10px;
flex-shrink: 0;
border-radius: 3px;
}
.permission-cell {
justify-content: center;
}
.permission-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 28px;
padding: 0;
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;
}
.switch-track::after {
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
content: '';
background: #fff;
border-radius: 50%;
transition: left 0.2s ease;
}
.permission-toggle.active .switch-track {
background: var(--ok);
}
.permission-toggle.active .switch-track::after {
left: 18px;
}
@media (max-width: 600px) {
.permission-grid {
grid-template-columns: 120px repeat(3, minmax(76px, 1fr));
gap: 6px;
}
}
</style>

View File

@@ -1,12 +1,187 @@
<script setup lang="ts"> <script setup lang="ts">
import PagePlaceholder from '@/components/PagePlaceholder.vue' import { onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import DepartmentPermissionTable from './components/DepartmentPermissionTable.vue'
import RolePermissionMatrix from './components/RolePermissionMatrix.vue'
import { departmentPermissionRows, permissionColumns, rolePermissionRows } from './mock'
import type { DepartmentPermissionRow, PermissionKey, RolePermissionRow } from './types'
const loading = ref(true)
const roles = ref<RolePermissionRow[]>([])
const departments = ref<DepartmentPermissionRow[]>([])
async function loadPermissions() {
loading.value = true
await Promise.resolve()
roles.value = rolePermissionRows.map((role) => ({
...role,
permissions: { ...role.permissions },
}))
departments.value = departmentPermissionRows.map((row) => ({ ...row }))
loading.value = false
}
function toggleRolePermission(roleId: string, permission: PermissionKey) {
const role = roles.value.find((item) => item.id === roleId)
if (!role) {
return
}
role.permissions[permission] = !role.permissions[permission]
ElMessage.info('权限已调整并记录审计日志')
}
function adjustDepartmentPermission(row: DepartmentPermissionRow) {
ElMessage.info(`原型演示:调整${row.department}文档权限`)
}
onMounted(loadPermissions)
</script> </script>
<template> <template>
<PagePlaceholder <section class="permissions-page">
group="管理" <section class="page-card intro-card">
title="文档权限" <div class="card-heading">
icon="⊞" <h2>文档库权限矩阵</h2>
description="文档权限页面占位,待根据 Web 端需求实现。" <span>可见 / 可用 / 可维护 分级授权下级继承上级</span>
/> </div>
<p>
可见=可浏览模板可用=可发起签署可维护=可编辑/停用模板科室签署员默认继承本科室可用权限调整即时生效并记录审计日志
</p>
</section>
<template v-if="loading">
<section class="page-card loading-card" aria-label="正在加载权限配置">
<span class="loading-title" />
<span class="loading-row" />
<span class="loading-row" />
<span class="loading-row short" />
</section>
</template>
<template v-else>
<section class="page-card">
<div class="card-heading">
<h2>按角色授权</h2>
</div>
<RolePermissionMatrix
:rows="roles"
:columns="permissionColumns"
@toggle="toggleRolePermission"
/>
</section>
<section class="page-card department-card">
<div class="card-heading">
<h2>按科室授权</h2>
<span>科室级别的文档可用范围</span>
</div>
<DepartmentPermissionTable :rows="departments" @adjust="adjustDepartmentPermission" />
</section>
</template>
</section>
</template> </template>
<style scoped>
.permissions-page {
width: 100%;
max-width: 1440px;
margin: 0 auto;
}
.page-card {
padding: 16px 18px;
margin-bottom: 16px;
background: var(--card);
border-radius: var(--r);
box-shadow: var(--sh);
}
.intro-card {
margin-bottom: 14px;
}
.card-heading {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 14px;
}
.card-heading h2 {
margin: 0;
color: var(--ink);
font-size: 14.5px;
line-height: 1.4;
}
.card-heading span {
margin-left: auto;
color: var(--mut);
font-size: 12px;
}
.intro-card p {
margin: 0;
color: var(--mut);
font-size: 12px;
line-height: 1.8;
}
.department-card {
margin-top: 16px;
margin-bottom: 0;
}
.loading-card {
display: flex;
flex-direction: column;
gap: 14px;
min-height: 180px;
}
.loading-card span {
display: block;
background: linear-gradient(90deg, #edf3f5 25%, #f7fafb 37%, #edf3f5 63%);
background-size: 400% 100%;
border-radius: 6px;
animation: skeleton-shimmer 1.4s ease infinite;
}
.loading-title {
width: 28%;
height: 20px;
}
.loading-row {
width: 100%;
height: 14px;
}
.loading-row.short {
width: 70%;
}
@keyframes skeleton-shimmer {
0% {
background-position: 100% 0;
}
100% {
background-position: -100% 0;
}
}
@media (max-width: 600px) {
.card-heading {
align-items: flex-start;
flex-direction: column;
}
.card-heading span {
margin-left: 0;
}
}
</style>

View File

@@ -0,0 +1,108 @@
import type { DepartmentPermissionRow, PermissionColumn, RolePermissionRow } from './types'
export const permissionColumns: PermissionColumn[] = [
{
key: 'visible',
label: '可见',
description: '可浏览模板',
},
{
key: 'usable',
label: '可用',
description: '可发起签署',
},
{
key: 'maintainable',
label: '可维护',
description: '可编辑、停用模板',
},
]
export const rolePermissionRows: RolePermissionRow[] = [
{
id: 'system-admin',
name: '系统管理员',
color: '#6b5bd2',
description: '系统配置、用户与权限管理、审计日志',
userCount: 2,
permissions: {
visible: true,
usable: true,
maintainable: true,
},
},
{
id: 'medical-management',
name: '医务管理',
color: '#0e6e8c',
description: '全院文档库维护、报表分析、任务监督',
userCount: 4,
permissions: {
visible: true,
usable: true,
maintainable: false,
},
},
{
id: 'department-signer',
name: '科室签署员',
color: '#0f9d6c',
description: '本科室文档发起签署、手写板 / 短信操作',
userCount: 23,
permissions: {
visible: false,
usable: false,
maintainable: false,
},
},
{
id: 'audit-viewer',
name: '查询审计员',
color: '#d9821f',
description: '只读查询报表与审计留痕,不可发起',
userCount: 3,
permissions: {
visible: false,
usable: false,
maintainable: false,
},
},
]
export const departmentPermissionRows: DepartmentPermissionRow[] = [
{
id: 'radiology',
department: '放射科',
ownDocuments: 'available',
commonDocuments: 'available',
otherDocuments: 'invisible',
},
{
id: 'cardiology',
department: '心内科',
ownDocuments: 'available',
commonDocuments: 'available',
otherDocuments: 'invisible',
},
{
id: 'orthopedics',
department: '骨科',
ownDocuments: 'available',
commonDocuments: 'available',
otherDocuments: 'invisible',
},
{
id: 'health-management',
department: '健康管理中心',
ownDocuments: 'available',
commonDocuments: 'available',
otherDocuments: 'invisible',
},
{
id: 'general',
department: '全院通用',
ownDocuments: 'available',
commonDocuments: 'available',
otherDocuments: 'invisible',
},
]

View File

@@ -1,15 +1,26 @@
export type PermissionSubjectFilter = 'all' | 'user' | 'role' export type PermissionKey = 'visible' | 'usable' | 'maintainable'
export interface PermissionFilterForm { export interface PermissionColumn {
keyword: string key: PermissionKey
subjectType: PermissionSubjectFilter label: string
description: string
} }
export interface PermissionTableRow { export interface RolePermissionRow {
id: string id: string
subjectType: PermissionSubjectFilter name: string
subjectName: string color: string
documentName: string description: string
permission: 'view' | 'sign' | 'manage' userCount: number
updatedAt: string permissions: Record<PermissionKey, boolean>
}
export type DepartmentDocumentAccess = 'available' | 'invisible'
export interface DepartmentPermissionRow {
id: string
department: string
ownDocuments: DepartmentDocumentAccess
commonDocuments: DepartmentDocumentAccess
otherDocuments: DepartmentDocumentAccess
} }