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
@@ -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;
}
}