From ec24e85829f1316bef78a0ddebfc351db5e7be92 Mon Sep 17 00:00:00 2001 From: yelan Date: Thu, 24 Sep 2026 10:44:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=9D=83=E9=99=90):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=20v-permission=20=E6=8C=89=E9=92=AE=E7=BA=A7=E6=9D=83=E9=99=90?= =?UTF-8?q?=E6=8C=87=E4=BB=A4=E5=B9=B6=E7=A7=BB=E9=99=A4=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E6=9D=83=E9=99=90=E5=88=AB=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- clinical-web/README.md | 2 + clinical-web/src/directives/index.ts | 7 ++ clinical-web/src/directives/permission.ts | 83 +++++++++++++++++++ clinical-web/src/main.ts | 5 +- clinical-web/src/stores/login.ts | 28 +------ .../components/DocumentLibraryHeader.vue | 23 +++-- .../components/DocumentTemplateCard.vue | 39 ++++----- .../documents/components/WordEditorDialog.vue | 23 ++++- .../src/views/management/documents/index.vue | 54 ++++-------- .../views/management/documents/permissions.ts | 43 ++++++++++ .../src/views/management/reports/index.vue | 2 +- 11 files changed, 219 insertions(+), 90 deletions(-) create mode 100644 clinical-web/src/directives/index.ts create mode 100644 clinical-web/src/directives/permission.ts create mode 100644 clinical-web/src/views/management/documents/permissions.ts diff --git a/clinical-web/README.md b/clinical-web/README.md index 0b7252c..f8ce20c 100644 --- a/clinical-web/README.md +++ b/clinical-web/README.md @@ -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}`:患者定位; diff --git a/clinical-web/src/directives/index.ts b/clinical-web/src/directives/index.ts new file mode 100644 index 0000000..c161ee6 --- /dev/null +++ b/clinical-web/src/directives/index.ts @@ -0,0 +1,7 @@ +import type { App } from 'vue' + +import permission from './permission' + +export function registerDirectives(app: App) { + app.directive('permission', permission) +} diff --git a/clinical-web/src/directives/permission.ts b/clinical-web/src/directives/permission.ts new file mode 100644 index 0000000..c2a0f6c --- /dev/null +++ b/clinical-web/src/directives/permission.ts @@ -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 + scope: EffectScope +} + +const bindingStates = new WeakMap() + +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 = { + mounted(el, binding) { + const value = ref(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 diff --git a/clinical-web/src/main.ts b/clinical-web/src/main.ts index 47c5ee6..6a61c99 100644 --- a/clinical-web/src/main.ts +++ b/clinical-web/src/main.ts @@ -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') diff --git a/clinical-web/src/stores/login.ts b/clinical-web/src/stores/login.ts index 9be5bda..b48b7d1 100644 --- a/clinical-web/src/stores/login.ts +++ b/clinical-web/src/stores/login.ts @@ -42,32 +42,10 @@ export const useLoginStore = defineStore('login', () => { } const permissions = authorization.value?.permissions ?? user.value?.permissions ?? [] - const legacyAliases: Record = { - '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(() => diff --git a/clinical-web/src/views/management/documents/components/DocumentLibraryHeader.vue b/clinical-web/src/views/management/documents/components/DocumentLibraryHeader.vue index 7da299c..2b5881f 100644 --- a/clinical-web/src/views/management/documents/components/DocumentLibraryHeader.vue +++ b/clinical-web/src/views/management/documents/components/DocumentLibraryHeader.vue @@ -1,14 +1,16 @@