feat(权限): 新增 v-permission 按钮级权限指令并移除前端权限别名

This commit is contained in:
yelan
2026-09-24 10:44:00 +08:00
parent e5b5412322
commit ec24e85829
11 changed files with 219 additions and 90 deletions
+2
View File
@@ -177,6 +177,8 @@ API 模块与页面按业务域对应。工作台页面使用 `api/workbench`
路由、侧栏和页面操作均按后端授权资源控制;导航每 15 秒及窗口重新聚焦时刷新,角色、菜单资源或员工角色绑定变更后也会主动刷新当前授权。角色权限从菜单资源树配置目录、菜单和按钮,员工角色绑定通过独立用户角色接口保存。菜单资源维护使用真实接口;系统参数卡片仍是演示数据,最终授权始终以后端校验为准。
按钮级权限统一用全局指令 `v-permission` 声明(实现见 `src/directives/permission.ts`):`v-permission="'doc:template:edit'"` 无权限时隐藏,`v-permission.disable="'doc:template:edit'"` 无权限时置灰;权限码必须与菜单资源树里 BUTTON 节点的 `code` 完全一致,前端不再维护别名映射。指令只负责体验,按钮背后的处理函数仍要用同一套权限码自行校验。文档库管理(`views/management/documents`)是本轮先落地的页面,其余页面沿用旧的 `hasPermission` 写法,待逐个统计按钮后迁移。
签署工作台已接入的真实接口包括:
- `GET /api/v1/patients`、`GET /api/v1/patients/{id}`:患者定位;
+7
View File
@@ -0,0 +1,7 @@
import type { App } from 'vue'
import permission from './permission'
export function registerDirectives(app: App) {
app.directive('permission', permission)
}
+83
View File
@@ -0,0 +1,83 @@
import { effectScope, ref, watchEffect, type Directive, type EffectScope, type Ref } from 'vue'
import { useLoginStore } from '@/stores/login'
/**
* 指令值:单个权限码,或需要同时具备的一组权限码。
* 数组按「且」判断,与 stores/login 的 hasPermission 口径保持一致。
*/
export type PermissionBindingValue = string | string[]
interface PermissionBindingState {
value: Ref<PermissionBindingValue | undefined>
scope: EffectScope
}
const bindingStates = new WeakMap<HTMLElement, PermissionBindingState>()
function isAllowed(value: PermissionBindingValue | undefined): boolean {
if (!value || (Array.isArray(value) && value.length === 0)) {
return true
}
return useLoginStore().hasPermission(value)
}
function applyPermission(el: HTMLElement, allowed: boolean, disableOnly: boolean) {
if (disableOnly) {
el.toggleAttribute('disabled', !allowed)
el.setAttribute('aria-disabled', String(!allowed))
return
}
el.style.display = allowed ? '' : 'none'
}
/**
* 按钮级权限指令。
*
* - `v-permission="'doc:template:edit'"`:没有权限时隐藏元素;
* - `v-permission.disable="'doc:template:edit'"`:没有权限时置灰,保留位置,
* 用于「必须让操作人看到、但当前不可执行」的弹窗主按钮;
* - 传数组时按「且」判断,与 hasPermission 一致。
*
* 使用约定:
* - 权限码必须与菜单资源树里 BUTTON 节点的 code 完全一致,指令内部不做任何别名兜底;
* - 隐藏或置灰只影响体验,后端校验仍是最终边界,因此提交接口的处理函数仍需自行校验;
* - 与 v-if 可同时使用:隐藏走 display,不删除 DOM,不会和 v-if 的节点复用打架。
*/
const permission: Directive<HTMLElement, PermissionBindingValue> = {
mounted(el, binding) {
const value = ref<PermissionBindingValue | undefined>(binding.value)
const disableOnly = Boolean(binding.modifiers.disable)
const scope = effectScope()
// 用 watchEffect 追踪授权数据:角色、菜单资源或员工角色变更后,
// refreshAuthorization 会让按钮自动恢复或隐藏,不必刷新页面。
scope.run(() => {
watchEffect(() => {
applyPermission(el, isAllowed(value.value), disableOnly)
})
})
bindingStates.set(el, { value, scope })
},
updated(el, binding) {
const state = bindingStates.get(el)
if (state) {
state.value.value = binding.value
}
},
unmounted(el) {
bindingStates.get(el)?.scope.stop()
bindingStates.delete(el)
},
}
declare module 'vue' {
interface GlobalDirectives {
vPermission: typeof permission
}
}
export default permission
+4 -1
View File
@@ -4,10 +4,13 @@ import 'element-plus/dist/index.css'
import { createApp } from 'vue'
import App from './App.vue'
import { registerDirectives } from './directives'
import router from './router'
import './styles/index.css'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia).use(router).use(ElementPlus).mount('#app')
app.use(pinia).use(router).use(ElementPlus)
registerDirectives(app)
app.mount('#app')
+3 -25
View File
@@ -42,32 +42,10 @@ export const useLoginStore = defineStore('login', () => {
}
const permissions = authorization.value?.permissions ?? user.value?.permissions ?? []
const legacyAliases: Record<string, string> = {
'sign:task:query': 'sign:task:view',
'sign:template:query': 'doc:template:view',
'sign:template:create': 'doc:template:create',
'sign:template:update': 'doc:template:edit',
'sign:template:publish': 'doc:template:publish',
'report:signing:query': 'report:sign:view',
'report:signing:export': 'report:sign:export',
'system:user:query': 'system:org:view',
'system:user:create': 'system:org:emp',
'system:user:update': 'system:org:emp',
'system:user:disable': 'system:org:emp',
'system:user:password:reset': 'system:org:pwd',
'system:role:permission:query': 'system:role-permission:view',
'system:role:permission:update': 'system:role-permission:edit',
'audit:log:query': 'audit:log:view',
}
return requiredPermissions.every((permission) => {
const alias = legacyAliases[permission]
return (
permissions.includes(permission) ||
(alias !== undefined && permissions.includes(alias)) ||
permissions.includes('*')
)
})
return requiredPermissions.every(
(permission) => permissions.includes(permission) || permissions.includes('*'),
)
}
const accessibleMenus = computed(() =>
@@ -1,14 +1,16 @@
<script setup lang="ts">
import { DOC_TEMPLATE_PERMISSIONS } from '../permissions'
withDefaults(
defineProps<{
modelValue: string
total: number
/** 只在演示模式下展示「重置演示数据」:真实环境不该出现这个自测入口 */
showReset?: boolean
canCreate?: boolean
canExport?: boolean
/** 模板包导出接口尚未接入,先只在演示模式展示入口;功能上线后放开这里即可 */
showExport?: boolean
}>(),
{ showReset: false, canCreate: false, canExport: false },
{ showReset: false, showExport: false },
)
const emit = defineEmits<{
@@ -42,11 +44,22 @@ function handleInput(event: Event) {
aria-label="搜索模板名称、编号或科室"
@input="handleInput"
/>
<button v-if="canCreate" type="button" class="tool-button primary" @click="emit('add')">
<button
v-permission="DOC_TEMPLATE_PERMISSIONS.create"
type="button"
class="tool-button primary"
@click="emit('add')"
>
<span aria-hidden="true">+</span>
新增文档
</button>
<button v-if="canExport" type="button" class="tool-button secondary" @click="emit('export')">
<button
v-if="showExport"
v-permission="DOC_TEMPLATE_PERMISSIONS.export"
type="button"
class="tool-button secondary"
@click="emit('export')"
>
导出模板包
</button>
<button
@@ -5,13 +5,10 @@ import type { TemplateVersionAction } from '@/api/management/documents'
import type { DocumentTemplate } from '../types'
import { readStatusLabel, readStatusTone } from '../mock'
import { DOC_TEMPLATE_PERMISSIONS, workflowPermission } from '../permissions'
const props = defineProps<{
template: DocumentTemplate
canEdit: boolean
canInitiate: boolean
canWorkflow: (action: TemplateVersionAction) => boolean
canRemove: boolean
/** 演示模式下「删除」是真删;真实模式下语义是下架(模板无 DELETE 接口,走归档) */
isMock: boolean
}>()
@@ -31,16 +28,10 @@ const statusLabel = computed(() => readStatusLabel(props.template))
const statusTone = computed(() => readStatusTone(props.template))
/** 只有已发布 / 待复审的模板可以发起签署;草稿与已归档不可用 */
const canInitiateTemplate = computed(
() => props.canInitiate && Boolean(props.template.currentVersionId || props.isMock),
)
const hasInitiableVersion = computed(() => Boolean(props.template.currentVersionId || props.isMock))
const initiateTitle = computed(() =>
canInitiateTemplate.value
? '用此模板发起签署'
: props.canInitiate
? '仅已发布或待复审的模板可发起签署'
: '当前账号没有发起签署任务的权限',
hasInitiableVersion.value ? '用此模板发起签署' : '仅已发布或待复审的模板可发起签署',
)
/**
@@ -78,7 +69,7 @@ const primaryAction = computed<{ key: TemplateVersionAction; label: string } | n
*/
const secondaryActions = computed<{ key: TemplateVersionAction; label: string; title: string }[]>(
() => {
if (props.template.backendStatus === 'PENDING_REVIEW' && props.canWorkflow('reject')) {
if (props.template.backendStatus === 'PENDING_REVIEW') {
return [
{
key: 'reject',
@@ -94,7 +85,7 @@ const secondaryActions = computed<{ key: TemplateVersionAction; label: string; t
/**
* 本次交付暂不包含「版本历史」与「删除 / 下架」入口,先隐藏对应按钮。
* 相关 emit、页面处理函数与接口调用保持原样,需要时把开关改回 true 即可恢复。
* 权限码已按 doc:template:hist / doc:template:stop 接好,需要恢复时把开关改回 true 即可。
*/
const showVersionAction = false
const showRemoveAction = false
@@ -124,11 +115,17 @@ const removeTitle = computed(() => (props.isMock ? '删除此文书' : '下架
</span>
<div class="template-actions">
<button type="button" class="card-button" @click="emit('preview', template)">预览</button>
<button v-if="canEdit" type="button" class="card-button" @click="emit('edit', template)">
<button
v-permission="DOC_TEMPLATE_PERMISSIONS.edit"
type="button"
class="card-button"
@click="emit('edit', template)"
>
编辑
</button>
<button
v-if="showVersionAction && canInitiate"
v-if="showVersionAction"
v-permission="DOC_TEMPLATE_PERMISSIONS.history"
type="button"
class="card-button"
title="查看该文书的版本历史"
@@ -137,7 +134,8 @@ const removeTitle = computed(() => (props.isMock ? '删除此文书' : '下架
版本
</button>
<button
v-if="canInitiateTemplate"
v-if="hasInitiableVersion"
v-permission="DOC_TEMPLATE_PERMISSIONS.initiate"
type="button"
class="card-button primary"
:title="initiateTitle"
@@ -147,7 +145,8 @@ const removeTitle = computed(() => (props.isMock ? '删除此文书' : '下架
</button>
<!-- 还不能签署时,把「当前该做的下一步」放在同一位置,避免按钮越堆越多 -->
<button
v-if="primaryAction && canWorkflow(primaryAction.key)"
v-if="primaryAction"
v-permission="workflowPermission(primaryAction.key)"
type="button"
class="card-button primary"
:title="`${primaryAction.label}后才能发起签署`"
@@ -158,6 +157,7 @@ const removeTitle = computed(() => (props.isMock ? '删除此文书' : '下架
<button
v-for="action in secondaryActions"
:key="action.key"
v-permission="workflowPermission(action.key)"
type="button"
class="card-button"
:title="action.title"
@@ -166,7 +166,8 @@ const removeTitle = computed(() => (props.isMock ? '删除此文书' : '下架
{{ action.label }}
</button>
<button
v-if="showRemoveAction && canRemove"
v-if="showRemoveAction"
v-permission="DOC_TEMPLATE_PERMISSIONS.stop"
type="button"
class="card-button danger"
:title="removeTitle"
@@ -14,6 +14,7 @@ import {
} from '@/utils/signing-fields'
import EditorWangPane from './editors/EditorWangPane.vue'
import { DOC_TEMPLATE_PERMISSIONS } from '../permissions'
import type { DocumentTemplate, TemplateEditorForm } from '../types'
const props = defineProps<{
@@ -30,6 +31,11 @@ const emit = defineEmits<{
save: [form: TemplateEditorForm]
}>()
/** 新增文书用创建权限,编辑已有文书用编辑权限;后端仍会再校验一次 */
const savePermission = computed(() =>
props.template ? DOC_TEMPLATE_PERMISSIONS.edit : DOC_TEMPLATE_PERMISSIONS.create,
)
// Word 自维护方案:科室把文书从 Word/WPS 直接粘贴进来(保留表格与基本格式),
// 或上传 .docx 由 mammoth 转换;签署时由交互管线识别"勾选"表并转为签署域。
// 编辑引擎固定为 WangEditor(MIT)。更换引擎只需新增一个实现 EditorPaneHandle 的面板组件,
@@ -389,7 +395,14 @@ function save() {
<footer class="dialog-footer">
<p>保存后进入文书库;签署时自动填充占位符,"勾选"表转为交互勾选。</p>
<button type="button" class="footer-button secondary" @click="emit('close')">取消</button>
<button type="button" class="footer-button primary" @click="save">保存文书</button>
<button
v-permission.disable="savePermission"
type="button"
class="footer-button primary"
@click="save"
>
保存文书
</button>
</footer>
</section>
</div>
@@ -716,6 +729,14 @@ function save() {
filter: brightness(0.96);
}
.footer-button:disabled {
color: #aebbc0;
cursor: not-allowed;
background: #f1f5f6;
border-color: #dce5e8;
filter: none;
}
@media (max-width: 900px) {
.meta-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -46,6 +46,7 @@ import {
readBackendStatus,
saveDocumentLibrary,
} from './mock'
import { DOC_TEMPLATE_PERMISSIONS, workflowPermission } from './permissions'
import type {
DepartmentTab,
DocumentBackendStatus,
@@ -72,42 +73,24 @@ const appStore = useAppStore()
const loginStore = useLoginStore()
const useMockLibrary = mockFlags.documents
const canCreateTemplate = computed(
() =>
!loginStore.isReadOnly &&
(useMockLibrary ||
loginStore.hasWritePermission(['sign:template:create', 'sign:template:version:create'])),
)
const canEditTemplate = computed(
() =>
!loginStore.isReadOnly &&
(useMockLibrary ||
loginStore.hasWritePermission(['sign:template:update', 'sign:template:version:create'])),
)
const canArchiveTemplate = computed(
() =>
!loginStore.isReadOnly &&
(useMockLibrary || loginStore.hasWritePermission('sign:template:archive')),
)
const canInitiateSigning = computed(
() =>
!loginStore.isReadOnly && (useMockLibrary || loginStore.hasWritePermission('sign:task:create')),
)
const workflowPermissions: Record<TemplateVersionAction, string> = {
'submit-review': 'sign:template:review:submit',
reject: 'sign:template:review:reject',
approve: 'sign:template:review:approve',
publish: 'sign:template:publish',
disable: 'sign:template:disable',
archive: 'sign:template:archive',
/**
* 页面操作的服务端校验兜底。
*
* 按钮是否显示由模板上的 v-permission 决定,这里必须用同一套权限码再判一次:
* 快捷键、回车提交和程序化调用不会经过按钮,只靠隐藏按钮挡不住。
*/
function canWriteDocument(permission: string) {
return !loginStore.isReadOnly && (useMockLibrary || loginStore.hasWritePermission(permission))
}
const canCreateTemplate = computed(() => canWriteDocument(DOC_TEMPLATE_PERMISSIONS.create))
const canEditTemplate = computed(() => canWriteDocument(DOC_TEMPLATE_PERMISSIONS.edit))
const canArchiveTemplate = computed(() => canWriteDocument(DOC_TEMPLATE_PERMISSIONS.stop))
const canInitiateSigning = computed(() => canWriteDocument(DOC_TEMPLATE_PERMISSIONS.initiate))
function canRunTemplateWorkflow(action: TemplateVersionAction) {
return (
!loginStore.isReadOnly &&
(useMockLibrary || loginStore.hasWritePermission(workflowPermissions[action]))
)
return canWriteDocument(workflowPermission(action))
}
/** 真实模式下的加载状态:区分"正在加载"与"确实没有模板" */
const libraryLoading = ref(false)
@@ -959,8 +942,7 @@ onMounted(() => {
v-model="filters.keyword"
:total="filteredTemplates.length"
:show-reset="useMockLibrary"
:can-create="canCreateTemplate"
:can-export="useMockLibrary"
:show-export="useMockLibrary"
@add="showAddMessage"
@export="showExportMessage"
@reset="resetMockData"
@@ -985,10 +967,6 @@ onMounted(() => {
v-for="template in filteredTemplates"
:key="template.id"
:template="template"
:can-edit="canEditTemplate"
:can-initiate="canInitiateSigning"
:can-workflow="canRunTemplateWorkflow"
:can-remove="canArchiveTemplate"
:is-mock="useMockLibrary"
@preview="handlePreview"
@edit="showEditMessage"
@@ -0,0 +1,43 @@
import type { TemplateVersionAction } from '@/api/management/documents'
/**
* 文档库管理的权限码。
*
* 必须与后端菜单资源树里 BUTTON 节点的 code 完全一致(当前为 doc:template:* 这一套)。
* 前端不再维护别名映射:码对不上就等于没有权限,不做兜底。
*/
export const DOC_TEMPLATE_PERMISSIONS = {
view: 'doc:template:view',
index: 'doc:template:index',
create: 'doc:template:create',
edit: 'doc:template:edit',
export: 'doc:template:export',
history: 'doc:template:hist',
props: 'doc:template:props',
publish: 'doc:template:publish',
stop: 'doc:template:stop',
/** 发起签署复用签署任务的新增权限 */
initiate: 'sign:task:create',
} as const
/**
* 版本工作流的权限码映射。
*
* 后端当前没有独立的「提交审核 / 审核通过 / 驳回」按钮码,因此按职责归并:
* - 提交审核 / 重新提交归入编辑权限:改稿并送审是编辑流程的一步;
* - 审核通过 / 驳回 / 发布 / 重新启用归入发布权限:决定文书能否上线;
* - 停用 / 归档归入停用权限。
* 后端补齐独立按钮码后只需改这张表,页面不需要调整。
*/
export const TEMPLATE_WORKFLOW_PERMISSIONS: Record<TemplateVersionAction, string> = {
'submit-review': DOC_TEMPLATE_PERMISSIONS.edit,
reject: DOC_TEMPLATE_PERMISSIONS.publish,
approve: DOC_TEMPLATE_PERMISSIONS.publish,
publish: DOC_TEMPLATE_PERMISSIONS.publish,
disable: DOC_TEMPLATE_PERMISSIONS.stop,
archive: DOC_TEMPLATE_PERMISSIONS.stop,
}
export function workflowPermission(action: TemplateVersionAction): string {
return TEMPLATE_WORKFLOW_PERMISSIONS[action]
}
@@ -82,7 +82,7 @@ const loginStore = useLoginStore()
const canExportReport = computed(
() =>
!loginStore.isReadOnly &&
(isReportsMockEnabled || loginStore.hasWritePermission('report:signing:export')),
(isReportsMockEnabled || loginStore.hasWritePermission('report:sign:export')),
)
let reportRequestId = 0