diff --git a/src/utils/directives/Access.ts b/src/utils/directives/Access.ts new file mode 100644 index 0000000..b4ea886 --- /dev/null +++ b/src/utils/directives/Access.ts @@ -0,0 +1,226 @@ +import type { Directive, DirectiveBinding, EffectScope } from "vue"; +import { effectScope, watch } from "vue"; +import { AccessMatchMode, useUserStore } from "../../store"; + +/** 访问控制类型 */ +export type AccessControlType = "permission" | "role"; +/** 指令结果生效方式 */ +export type AccessEffectMode = "if" | "show"; + +/** 自定义回调参数 */ +export type AccessDirectiveCallbackPayload = { + type: AccessControlType; + granted: boolean; + valueList: string[]; + mode: AccessMatchMode; + el: HTMLElement; +} + +/** + * 访问控制指令配置 + * `value` 支持单个值或值数组 + * 数组默认按 `or` 逻辑处理,需要 `and` 时显式传 `mode` + * `callback` 存在时优先执行回调,不再使用 `effect` + */ +export type AccessDirectiveConfig = { + value: string | string[]; + mode?: AccessMatchMode; + effect?: AccessEffectMode; + reverse?: boolean; + display?: string; + callback?: (payload: AccessDirectiveCallbackPayload) => void; +} + +type AccessDirectiveValue = string | string[] | AccessDirectiveConfig; + +type AccessDirectiveState = { + scope: EffectScope; + placeholder: Comment | null; + display: string; + config: AccessDirectiveConfig; + lastGranted?: boolean; +} + +type AccessMatcher = (store: ReturnType, valueList: string[], mode: AccessMatchMode) => boolean; + +const ACCESS_STATE_KEY = "__vAccessState"; + +/** 过滤空字符串并统一裁剪空白 */ +function normalizeValueList(value: string | string[]): string[] { + return (Array.isArray(value) ? value : [value]) + .map(item => item?.trim()) + .filter((item): item is string => !!item); +} + +/** 统一把指令值转成标准配置 */ +function normalizeConfig(value: AccessDirectiveValue): AccessDirectiveConfig { + if (typeof value === "string" || Array.isArray(value)) { + return { + value, + mode: "or", + effect: "if", + reverse: false + }; + } + + return { + value: value?.value || [], + mode: value?.mode || "or", + effect: value?.effect || "if", + reverse: !!value?.reverse, + display: value?.display, + callback: value?.callback + }; +} + +/** 恢复被 `if` 模式替换掉的真实元素 */ +function restoreElement(el: HTMLElement, state: AccessDirectiveState): void { + if (state.placeholder?.parentNode) { + state.placeholder.parentNode.replaceChild(el, state.placeholder); + } + state.placeholder = null; +} + +/** `if` 模式:无权限时直接移出 DOM */ +function applyIfEffect(el: HTMLElement, state: AccessDirectiveState, granted: boolean): void { + if (granted) { + restoreElement(el, state); + return; + } + + if (!state.placeholder && el.parentNode) { + state.placeholder = document.createComment("v-access"); + el.parentNode.replaceChild(state.placeholder, el); + } +} + +/** `show` 模式:保留 DOM,仅切换 display */ +function applyShowEffect(el: HTMLElement, state: AccessDirectiveState, granted: boolean): void { + restoreElement(el, state); + el.style.display = granted ? state.display : "none"; +} + +/** 自定义模式:交给调用方自己处理样式或行为 */ +function applyCustomCallback( + el: HTMLElement, + state: AccessDirectiveState, + granted: boolean, + type: AccessControlType, + valueList: string[], + mode: AccessMatchMode +): void { + restoreElement(el, state); + if (state.lastGranted === granted) { + return; + } + + state.config.callback?.({ + type, + granted, + valueList, + mode, + el + }); +} + +/** 根据当前登录用户状态刷新指令效果 */ +function updateAccess( + el: HTMLElement, + state: AccessDirectiveState, + type: AccessControlType, + matcher: AccessMatcher +): void { + const store = useUserStore(); + const config = state.config; + const mode = config.mode || "or"; + const valueList = normalizeValueList(config.value); + const granted = config.reverse ? !matcher(store, valueList, mode) : matcher(store, valueList, mode); + + if (config.callback) { + applyCustomCallback(el, state, granted, type, valueList, mode); + } else if (config.effect === "show") { + applyShowEffect(el, state, granted); + } else { + applyIfEffect(el, state, granted); + } + + state.lastGranted = granted; +} + +function getState(el: HTMLElement): AccessDirectiveState | undefined { + return (el as HTMLElement & { [ACCESS_STATE_KEY]?: AccessDirectiveState })[ACCESS_STATE_KEY]; +} + +function setState(el: HTMLElement, state: AccessDirectiveState): void { + (el as HTMLElement & { [ACCESS_STATE_KEY]?: AccessDirectiveState })[ACCESS_STATE_KEY] = state; +} + +function clearState(el: HTMLElement): void { + delete (el as HTMLElement & { [ACCESS_STATE_KEY]?: AccessDirectiveState })[ACCESS_STATE_KEY]; +} + +/** 卸载时清掉占位注释,避免遗留无用节点 */ +function clearPlaceholder(state: AccessDirectiveState): void { + if (state.placeholder?.parentNode) { + state.placeholder.parentNode.removeChild(state.placeholder); + } + state.placeholder = null; +} + +/** 创建角色/权限通用指令 */ +function createAccessDirective(type: AccessControlType, matcher: AccessMatcher): Directive { + return { + mounted(el: HTMLElement, binding: DirectiveBinding) { + const state: AccessDirectiveState = { + scope: effectScope(), + placeholder: null, + display: el.style.display, + config: normalizeConfig(binding.value) + }; + + setState(el, state); + + state.scope.run(() => { + const store = useUserStore(); + watch( + () => { + return type === "permission" ? store.getPermissionList().join("|") : store.getRoleList().join("|"); + }, + () => updateAccess(el, state, type, matcher), + { immediate: true } + ); + }); + }, + updated(el: HTMLElement, binding: DirectiveBinding) { + const state = getState(el); + if (!state) { + return; + } + + state.config = normalizeConfig(binding.value); + updateAccess(el, state, type, matcher); + }, + unmounted(el: HTMLElement) { + const state = getState(el); + if (!state) { + return; + } + + state.scope.stop(); + clearPlaceholder(state); + clearState(el); + } + }; +} + +/** 权限指令:`v-can` */ +export const VCan = createAccessDirective("permission", (store, valueList, mode) => { + return store.hasPermissions(valueList, mode); +}); + +/** 角色指令:`v-role` */ +export const VRole = createAccessDirective("role", (store, valueList, mode) => { + return store.hasRoles(valueList, mode); +}); + +export default VCan; diff --git a/src/utils/index.ts b/src/utils/index.ts index 3ea2ed5..7576e1c 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -13,11 +13,13 @@ export { default as Toolkit } from "./Toolkit"; export { default as IconMapper } from "./IconMapper"; export { default as SettingMapper } from "./SettingMapper"; +export { default as VCan } from "./directives/Access"; export { default as VDraggable } from "./directives/Draggable"; export { default as VPopup } from "./directives/Popup"; export * from "./MethodLocker"; export * from "./Network"; export * from "./Prismjs"; +export * from "./directives/Access"; export * from "./directives/Draggable"; export * from "./directives/Popup";