This commit is contained in:
Timi
2026-07-30 18:35:48 +08:00
parent f0d1090932
commit fc816e2d84
2 changed files with 228 additions and 0 deletions
+226
View File
@@ -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<typeof useUserStore>, 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<HTMLElement, AccessDirectiveValue> {
return {
mounted(el: HTMLElement, binding: DirectiveBinding<AccessDirectiveValue>) {
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<AccessDirectiveValue>) {
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;
+2
View File
@@ -13,11 +13,13 @@ export { default as Toolkit } from "./Toolkit";
export { default as IconMapper } from "./IconMapper"; export { default as IconMapper } from "./IconMapper";
export { default as SettingMapper } from "./SettingMapper"; export { default as SettingMapper } from "./SettingMapper";
export { default as VCan } from "./directives/Access";
export { default as VDraggable } from "./directives/Draggable"; export { default as VDraggable } from "./directives/Draggable";
export { default as VPopup } from "./directives/Popup"; export { default as VPopup } from "./directives/Popup";
export * from "./MethodLocker"; export * from "./MethodLocker";
export * from "./Network"; export * from "./Network";
export * from "./Prismjs"; export * from "./Prismjs";
export * from "./directives/Access";
export * from "./directives/Draggable"; export * from "./directives/Draggable";
export * from "./directives/Popup"; export * from "./directives/Popup";