This commit is contained in:
owenchen committed 2026-10-13 10:47:39 +08:00
1 parent 6a8258ac4b
commit a7b25d49c4
29 files changed
+1960 -649

No files matched your search

@@ -0,0 +1,217 @@
/**
* 权限上下文:把 permission-kernel 接到 pinia 登录档案上
*
* 数据来源只有一个:useLoginProfileStore().permissionInfo
* (登录接口 /Auth/Login 返回的 SecurityUserPermissionInfo,已在 store 的 persist.paths 里持久化)。
* 不再另开一份权限缓存,避免和档案不同源。
*
* 两套判定策略,故意的:
* 1) 路由级(canVisitPage)走 fail-open——本地档案缺失、权限清单为空时放行。
* 理由与旧项目一致:档案是从本地缓存水合的,一旦过期或被写坏成空档案,
* 严格模式会把所有人全挡在 403 外面,而真正的安全边界在后端(每个接口都有 [PermissionOperate])。
* 2) 按钮级(usePagePermission / usePermission)走 fail-closed——没有授权证据就不显示。
* 多挡一个按钮只是少个入口,误放一个按钮却会让人点了才收到 403。
*/
import type { ComputedRef } from "vue";
import type {
PermissionAction,
PermissionIndex,
PermissionInfoLike,
PermissionNames,
PermissionOperate,
} from "@/permission/permission-kernel";
import type { PageName } from "@/router/routes";
import { computed } from "vue";
import {
actionsOf,
buildPermissionIndex,
can,
canAny,
canOperate,
grantedPermissions,
} from "@/permission/permission-kernel";
import { PAGE_PERMISSIONS, resolvePageName } from "@/router/routes";
import { useLoginProfileStore } from "@/stores";
/** 权限名支持数组:任一通过即通过(一个页面挂在多个功能权限下);定义在内核,此处转出便于页面直接使用 */
export type { PermissionNames } from "@/permission/permission-kernel";
/**
* 权限快照(与旧 WebVue 的 hooks/PermissionContext.ts 同名同 API,
* 便于旧页面代码继续移植;区别是这里构造时建一次索引,四个方法都是 O(1))
*/
export class PermissionContext {
private readonly index: PermissionIndex;
constructor(info?: PermissionInfoLike | null) {
this.index = buildPermissionIndex(info);
}
canNone(permission: PermissionNames): boolean {
return canAny(this.index, "none", permission);
}
canView(permission: PermissionNames, dependentId?: string): boolean {
return canAny(this.index, "view", permission, dependentId);
}
canCreate(permission: PermissionNames, dependentId?: string): boolean {
return canAny(this.index, "create", permission, dependentId);
}
canAmend(permission: PermissionNames, dependentId?: string): boolean {
return canAny(this.index, "amend", permission, dependentId);
}
canDelete(permission: PermissionNames, dependentId?: string): boolean {
return canAny(this.index, "delete", permission, dependentId);
}
/** 某权限(或某条数据级授权)上四种动作的完整结果,一次取全给整页按钮用 */
flags(permission: string, dependentId?: string) {
return actionsOf(this.index, permission, dependentId);
}
/** 后端 [PermissionOperate(name, Operate.X)] 同款判定,含 Submit/Approval */
canOperate(operate: PermissionOperate, permission: string): boolean {
return canOperate(this.index, operate, permission);
}
}
// 共享索引:全站建一次,档案变化时由 computed 自动失效重建。
// 旧实现是每次 usePermissionContext() 都 new 一个上下文、每个判定都线性扫数组;
// 相册这类页面一屏十几个按钮各判各的,重建成本会落到渲染路径上。
let sharedIndex: ComputedRef<PermissionIndex> | null = null;
function permissionIndex(): ComputedRef<PermissionIndex> {
if (!sharedIndex) {
const store = useLoginProfileStore();
sharedIndex = computed(() => buildPermissionIndex(store.permissionInfo));
}
return sharedIndex;
}
/** 取当前档案的索引;在渲染/判定期间读 .value,等于把"档案变→重新判定"交给 Vue 依赖追踪 */
function currentIndex(): PermissionIndex {
return permissionIndex().value;
}
/** 当前登录用户的权限快照;档案为空时得到空索引(判定一律 false) */
export function usePermissionContext(): PermissionContext {
const store = useLoginProfileStore();
return new PermissionContext(store.permissionInfo);
}
/** 组合式判定:函数形式,读的是共享索引,随档案变化自动生效 */
export function usePermission() {
return {
canNone: (permission: PermissionNames) =>
canAny(currentIndex(), "none", permission),
canView: (permission: PermissionNames, dependentId?: string) =>
canAny(currentIndex(), "view", permission, dependentId),
canCreate: (permission: PermissionNames, dependentId?: string) =>
canAny(currentIndex(), "create", permission, dependentId),
canAmend: (permission: PermissionNames, dependentId?: string) =>
canAny(currentIndex(), "amend", permission, dependentId),
canDelete: (permission: PermissionNames, dependentId?: string) =>
canAny(currentIndex(), "delete", permission, dependentId),
/** 数据级或动作型判定的统一入口,如 can("view", "FamilyGroupShare", familyId) */
can: (action: PermissionAction, permission: string, dependentId?: string) =>
can(currentIndex(), action, permission, dependentId),
/** 与后端接口鉴权同口径的操作判定,如 canOperate("Submit", "PublishPhotoEnquiry") */
canOperate: (operate: PermissionOperate, permission: string) =>
canOperate(currentIndex(), operate, permission),
/** 已授权的权限名清单,调试与"我有什么权限"排查用 */
granted: (action: PermissionAction = "none") =>
grantedPermissions(currentIndex(), action),
};
}
/** 页面绑定的权限名;未登记表示该页不做权限校验 */
export function pagePermission(name: PageName): PermissionNames | undefined {
return PAGE_PERMISSIONS[name];
}
/** 当前页面名:按页面栈栈顶的真实路径反查(拿不到返回 null) */
export function currentPageName(): PageName | null {
const pages = getCurrentPages();
const current = pages[pages.length - 1];
return current ? resolvePageName(`/${current.route}`) : null;
}
/**
* 页面级四把锁,模板里直接 v-if="perm.canDelete" 用。
* @param name 页面名;省略时按页面栈推断,但在 onLoad 早期栈可能还没压上当前页,
* 所以能显式传就显式传,省得依赖一个时机敏感的行为
*/
export function usePagePermission(name?: PageName) {
const target = name ?? currentPageName();
const permission = target ? pagePermission(target) : undefined;
// 没绑定权限名的页面(欢迎页、错误页等)四个锁全开:它不属于权限体系管的事
const pass = () => !permission;
const flags = computed(() => {
if (!permission) {
return {
none: true,
view: true,
create: true,
amend: true,
delete: true,
};
}
const list = Array.isArray(permission) ? permission : [permission];
// 多个权限名时取并集:任一给了该动作即算有
const merged = {
none: false,
view: false,
create: false,
amend: false,
delete: false,
};
for (const item of list) {
const one = actionsOf(currentIndex(), item);
merged.none ||= one.none;
merged.view ||= one.view;
merged.create ||= one.create;
merged.amend ||= one.amend;
merged.delete ||= one.delete;
}
return merged;
});
return {
pageName: target ?? null,
permission: permission ?? null,
/** 一次拿全五种结果,模板里频繁判定时比四个 computed 少读四次索引 */
flags,
canNone: computed(() => pass() || flags.value.none),
canView: computed(() => pass() || flags.value.view),
canCreate: computed(() => pass() || flags.value.create),
canAmend: computed(() => pass() || flags.value.amend),
canDelete: computed(() => pass() || flags.value.delete),
};
}
/**
* 登录档案里的权限清单是否不可信(缺失/空)。
* 空清单既可能是"这人真没权限",也可能是"本地档案被写坏或还没水合",
* 前端分不出这两种情况,所以只在路由级选择放行,把裁决权交回后端。
*/
function permissionInfoUnusable(): boolean {
const info = useLoginProfileStore().permissionInfo;
return !info?.Permissions?.length;
}
/**
* 路由级判定:能不能进这个页面。
* 顺序上必须在登录校验之后(未登录该给 401 而不是 403),由 nav-guard 保证。
*/
export function canVisitPage(name: PageName): boolean {
const permission = pagePermission(name);
if (!permission) {
return true;
}
if (permissionInfoUnusable()) {
return true;
}
return canAny(currentIndex(), "view", permission);
}
@@ -0,0 +1,396 @@
/**
* 权限内核的可执行校验(不引测试框架,纯 TS + 断言函数)
*
* 跑法(PowerShell,在项目根目录):
* & "C:\Program Files\nodejs\node.exe" ".\node_modules\esbuild\bin\esbuild" `
* src\permission\permission-kernel.spec.ts --bundle --platform=node --format=cjs `
* --alias:@=./src --outfile="$env:TEMP\permission-kernel.spec.cjs" --log-level=warning
* & "C:\Program Files\nodejs\node.exe" "$env:TEMP\permission-kernel.spec.cjs"
*
* 为什么要它:判定语义的正确性只能靠"跑一遍"来钉住,尤其 canView 是否检 ViewIndc
* 这条——它是本次移植相对旧 WebVue 唯一的行为变更,靠读代码很容易自我说服"应该没问题"。
* 项目目前还没接测试运行器,所以先写成"能被 esbuild 打包直接用 node 跑"的形态:
* 将来引入 vitest,只需把 check() 换成 expect(),用例本身不用动。
*
* 约定:所有用例都对照后端 CloudBuilder.Security.Service/Authorization/PermissionContext.cs
* 的既有行为写,断言的是"前端与后端一致",不是"前端自己的偏好"。
*/
import type {
DependentRowLike,
PermissionInfoLike,
PermissionRowLike,
} from "./permission-kernel";
import {
actionsOf,
buildPermissionIndex,
can,
canAny,
canOperate,
grantedPermissions,
} from "./permission-kernel";
let failed = 0;
let total = 0;
/** 断言:失败不中断,好让一次跑完能看清全部问题 */
function check(label: string, actual: unknown, expected: unknown): void {
total++;
if (actual !== expected) {
failed++;
// 本项目的 no-console 只放行 warn/error(页面代码里不该打印),
// 这是个只在 node 下跑的校验脚本,就沿用放行的那一个
console.warn(
` FAIL ${label} => expected ${String(expected)}, got ${String(actual)}`,
);
}
}
function row(
permission: string,
flags: Partial<Record<"amend" | "create" | "delete" | "view", string>>,
): PermissionRowLike {
return {
PermissionName: permission,
ViewIndc: flags.view,
CreateIndc: flags.create,
AmendIndc: flags.amend,
DeleteIndc: flags.delete,
};
}
function depRow(
permission: string,
dependent: string,
flags: Partial<Record<"amend" | "create" | "delete" | "view", string>>,
): DependentRowLike {
return { ...row(permission, flags), DependentDetailName: dependent };
}
function info(
permissions: PermissionRowLike[] = [],
dependents: DependentRowLike[] = [],
): PermissionInfoLike {
return { Permissions: permissions, PermissionDependents: dependents };
}
// ---------------------------------------------------------------- 1. view 必须检 ViewIndc
{
// view_indc='N' 而 create_indc='Y':后端 CanView 为 false。
// 旧前端只看"权限名在不在",会误放行,页面进来后列表接口一片 403。
const index = buildPermissionIndex(
info([row("ActorEnquiry", { view: "N", create: "Y" })]),
);
check(
"view=N 时 canView 为 false(修复旧前端漏检)",
can(index, "view", "ActorEnquiry"),
false,
);
check(
"view=N 时 canNone 仍为 true(只要求挂过权限)",
can(index, "none", "ActorEnquiry"),
true,
);
check(
"create=Y 时 canCreate 为 true",
can(index, "create", "ActorEnquiry"),
true,
);
}
// ---------------------------------------------------------------- 2. 四种动作各自独立
{
const index = buildPermissionIndex(
info([row("DmsFileEnquiry", { view: "Y" })]),
);
check("只给 view:view 通过", can(index, "view", "DmsFileEnquiry"), true);
check(
"只给 view:create 不通过",
can(index, "create", "DmsFileEnquiry"),
false,
);
check(
"只给 view:amend 不通过",
can(index, "amend", "DmsFileEnquiry"),
false,
);
check(
"只给 view:delete 不通过",
can(index, "delete", "DmsFileEnquiry"),
false,
);
const full = buildPermissionIndex(
info([
row("DmsFileEnquiry", {
view: "Y",
create: "Y",
amend: "Y",
delete: "Y",
}),
]),
);
check("四标全 Y:delete 通过", can(full, "delete", "DmsFileEnquiry"), true);
}
// ---------------------------------------------------------------- 3. 多用户组授权做 OR 合并
{
// 同一权限名由两个用户组分别授予 view 与 delete,合并后两者都应有
const index = buildPermissionIndex(
info([
row("FamilyGroupEnquiry", { view: "Y" }),
row("FamilyGroupEnquiry", { delete: "Y" }),
]),
);
check("多组合并:view 命中", can(index, "view", "FamilyGroupEnquiry"), true);
check(
"多组合并:delete 命中",
can(index, "delete", "FamilyGroupEnquiry"),
true,
);
check(
"多组合并:amend 仍未命中",
can(index, "amend", "FamilyGroupEnquiry"),
false,
);
const flags = actionsOf(index, "FamilyGroupEnquiry");
check(
"actionsOf 一次取全",
`${flags.view}/${flags.create}/${flags.amend}/${flags.delete}`,
"true/false/false/true",
);
}
// ---------------------------------------------------------------- 4. 数据级只查 dependents,不回退
{
const index = buildPermissionIndex(
info(
[row("PublishPhotoEnquiry", { view: "Y" })],
[depRow("PublishPhotoEnquiry", "F1001", { view: "N" })],
),
);
check("功能级命中", can(index, "view", "PublishPhotoEnquiry"), true);
// 后端 CanView(permission, dependent) 只查 PermissionDependents;
// 前端若"数据级没查到就退回功能级",就比后端宽,会出现前端放行后端 403
check(
"数据级 N:不回退到功能级",
can(index, "view", "PublishPhotoEnquiry", "F1001"),
false,
);
check(
"数据级整条不存在:false",
can(index, "view", "PublishPhotoEnquiry", "F9999"),
false,
);
check(
"数据级 Y:命中",
can(
buildPermissionIndex(
info([], [depRow("PublishPhotoEnquiry", "F1", { view: "Y" })]),
),
"view",
"PublishPhotoEnquiry",
"F1",
),
true,
);
}
// ---------------------------------------------------------------- 5. 严格比较(与后端一致)
{
const index = buildPermissionIndex(
info([
row("SecurityGroupEnquiry", { view: "y" }),
row(" security ", { view: "Y" }),
]),
);
check(
"小写 y 不算 YES(后端按 'Y' 精确比较)",
can(index, "view", "SecurityGroupEnquiry"),
false,
);
check("权限名区分大小写", can(index, "view", "securitygroupenquiry"), false);
check("权限名不做 trim", can(index, "view", "security"), false);
check("原样存在的名字可命中", can(index, "view", " security "), true);
}
// ---------------------------------------------------------------- 6. 档案缺失或损坏
{
const broken: PermissionInfoLike[] = [
{},
{ Permissions: null },
{ Permissions: undefined, PermissionDependents: undefined },
{ Permissions: [] },
{ Permissions: [null as unknown as PermissionRowLike] },
{ Permissions: [{ PermissionName: "" } as PermissionRowLike] },
{
PermissionDependents: [
{ PermissionName: "A", DependentDetailName: "" } as DependentRowLike,
],
},
];
broken.forEach((item, i) => {
const index = buildPermissionIndex(item);
check(
`损坏档案[${i}]:不抛异常且判定为 false`,
can(index, "view", "A"),
false,
);
check(`损坏档案[${i}]:none 也为 false`, can(index, "none", "A"), false);
});
check(
"传 null 得到空索引",
can(buildPermissionIndex(null), "none", "A"),
false,
);
check(
"不传参得到空索引",
grantedPermissions(buildPermissionIndex(), "none").length,
0,
);
}
// ---------------------------------------------------------------- 7. canAny 批量判定
{
const index = buildPermissionIndex(
info([row("PreviewAudio", { view: "Y" })]),
);
check("数组任一命中", canAny(index, "view", ["Nope", "PreviewAudio"]), true);
check("数组全不命中", canAny(index, "view", ["Nope", "Nothing"]), false);
check("空数组为 false(而非 undefined)", canAny(index, "view", []), false);
check(
"数组里的空串被跳过",
canAny(index, "view", ["", "PreviewAudio"]),
true,
);
check("单串仍可用", canAny(index, "view", "PreviewAudio"), true);
check(
"批量判定带 dependent",
canAny(index, "view", ["PreviewAudio"], "F1"),
false,
);
}
// ---------------------------------------------------------------- 8. 未登记动作与未知权限
{
const index = buildPermissionIndex(
info([row("ActorEnquiry", { view: "Y" })]),
);
check("未授予的权限名:false", can(index, "view", "NeverGranted"), false);
const flags = actionsOf(index, "NeverGranted");
check(
"未授予权限的 actionsOf 返回全 false 而非 undefined",
`${flags.none}/${flags.view}`,
"false/false",
);
}
// ---------------------------------------------------------------- 9. 已授权清单
{
const index = buildPermissionIndex(
info([
row("A_Enquiry", { view: "Y" }),
row("B_Enquiry", { view: "N", create: "Y" }),
row("C_Enquiry", { view: "Y", delete: "Y" }),
]),
);
check(
"none 清单含全部挂过的名字",
grantedPermissions(index, "none").join(","),
"A_Enquiry,B_Enquiry,C_Enquiry",
);
check(
"view 清单只含 Y",
grantedPermissions(index, "view").join(","),
"A_Enquiry,C_Enquiry",
);
check(
"create 清单",
grantedPermissions(index, "create").join(","),
"B_Enquiry",
);
check(
"delete 清单",
grantedPermissions(index, "delete").join(","),
"C_Enquiry",
);
check("amend 清单为空", grantedPermissions(index, "amend").length, 0);
}
// ---------------------------------------------------------------- 10. 操作型判定(对齐后端 PermissionHandler 的 switch)
{
const index = buildPermissionIndex(
info(
[
row("PublishPhotoEnquiry", {
view: "Y",
create: "Y",
amend: "Y",
delete: "Y",
}),
],
// Submit/Approval 这类操作在后端是"数据级授权行",靠 DependentDetailName 命中
[depRow("PublishPhotoEnquiry", "Submit", { view: "N" })],
),
);
check("Find -> view", canOperate(index, "Find", "PublishPhotoEnquiry"), true);
check(
"Print -> view",
canOperate(index, "Print", "PublishPhotoEnquiry"),
true,
);
check(
"Insert -> create",
canOperate(index, "Insert", "PublishPhotoEnquiry"),
true,
);
check(
"Update -> amend",
canOperate(index, "Update", "PublishPhotoEnquiry"),
true,
);
check(
"Delete -> delete",
canOperate(index, "Delete", "PublishPhotoEnquiry"),
true,
);
check(
"None 只要求挂过",
canOperate(index, "None", "PublishPhotoEnquiry"),
true,
);
check(
"Submit 走数据级,命中",
canOperate(index, "Submit", "PublishPhotoEnquiry"),
true,
);
check(
"Approval 未挂 → false(即使四个 Indc 全 Y)",
canOperate(index, "Approval", "PublishPhotoEnquiry"),
false,
);
// view_indc='N' 时 Find 必须为 false:这条正是旧前端会误判成 true 的场景
const noView = buildPermissionIndex(
info([row("ActorEnquiry", { view: "N", create: "Y" })]),
);
check(
"view=N 时 Find 为 false",
canOperate(noView, "Find", "ActorEnquiry"),
false,
);
check(
"view=N 时 Insert 仍为 true",
canOperate(noView, "Insert", "ActorEnquiry"),
true,
);
}
console.warn(
failed === 0
? `permission-kernel: ${total} assertions passed`
: `permission-kernel: ${failed}/${total} assertions FAILED`,
);
if (failed > 0) {
// 让非零退出可被脚本捕获,避免"跑了但没人看输出"
throw new Error("permission-kernel assertions failed");
}
@@ -0,0 +1,281 @@
/**
* 权限判定内核:纯函数 + 预建索引,不碰 uni / pinia / 请求层
*
* 语义以服务端为准,逐条对齐 CloudBuilder.Security.Service/Authorization/PermissionContext.cs:
* - view / create / amend / delete 各自要求对应的 Indc 为 'Y';
* - none 只要求权限名挂过(后端 CanNone 同款,用于"有授权行就允许,具体动作交给接口判");
* - 传 dependentId 时只在 PermissionDependents 里找,不回退到 Permissions——回退等于比后端宽;
* - 不做 '*' 通配、不做大小写宽松匹配:后端是精确字符串比较,前端放宽只会带来
* "前端放行、后端 403" 的错觉,那正是本层要消除的东西。
*
* 与旧 WebVue 的一处行为差异(有意的修复):
* 旧 hooks/PermissionContext.ts 的 canView 只判断权限名存不存在,漏检 ViewIndc。
* 于是 view_indc='N'、create_indc='Y' 的授权行会让用户进得来页面,
* 但页面的列表接口走 Find -> 后端 CanView -> 403,表现为"打开就一片红"。
* 这里按后端补齐后,进不来就直接跳 403 页,原因写在页面上。
*
* 之所以做成纯内核:判定逻辑要能被脚本直接跑起来验证(不依赖设备、不依赖后端),
* 也方便日后单测;pinia/uni 的取值与响应式都留在 permission-context.ts 那层。
*/
import { YesNoPolicy } from "@/data/custom/yesNoPolicy";
/** 四种可判定的动作,none 表示"不细分动作,只要求有授权行" */
export type PermissionAction = "none" | "view" | "create" | "amend" | "delete";
/**
* 权限名入参:单个或数组(数组为 OR,任一命中即通过)。
* 放在内核里是因为 routes.ts 的 PAGE_PERMISSIONS 也要用同一份定义,
* 而内核不 import 任何模块,不会形成 routes <-> permission 的循环依赖。
*/
export type PermissionNames = string | string[];
/**
* 权限行的结构快照:只声明内核要用的字段,不 import 生成的实体类。
* 生成器(data/auto/*)改字段名时不该牵连判定逻辑,只要后端返回的 JSON 形状不变就照样工作。
*/
export interface PermissionRowLike {
PermissionName: string;
ViewIndc?: string;
CreateIndc?: string;
AmendIndc?: string;
DeleteIndc?: string;
}
/** 数据级(dependent)权限行:比功能级多一个 DependentDetailName,如"某个家族/某个机构" */
export interface DependentRowLike extends PermissionRowLike {
DependentDetailName: string;
}
/** 内核需要的登录档案权限信息子集,与 SecurityUserPermissionInfo 结构兼容 */
export interface PermissionInfoLike {
// 功能级授权行;DependentRowLike 是其超集,故生成实体可直接传入
Permissions?: PermissionRowLike[] | null;
// 数据级授权行
PermissionDependents?: DependentRowLike[] | null;
}
/** 某个权限名上四种动作的合并结果(多组授权会 OR) */
export interface ActionFlags {
none: boolean;
view: boolean;
create: boolean;
amend: boolean;
delete: boolean;
}
/**
* 预建索引:把旧实现"每次判定都 Array.some 线性扫一遍"换成一次构建、多次 O(1) 查询。
* 旧版一个页面要调 canView/canCreate/canAmend/canDelete 四次,扫四遍;
* 相册页权限行可能上百,加上小程序 setData 频率敏感,索引这层不亏。
*/
export interface PermissionIndex {
/** key = PermissionName */
byName: Map<string, ActionFlags>;
/** key = dependentKey(permission, dependent) */
byDependent: Map<string, ActionFlags>;
}
/** 空索引:登录档案缺失时用,配合 context 层的"清单为空即放行"策略 */
const EMPTY_INDEX: PermissionIndex = {
byName: new Map(),
byDependent: new Map(),
};
/** 数据级权限的复合键;用 \u0000 分隔,权限名与明细名都不可能出现控制字符 */
export function dependentKey(permission: string, dependent: string): string {
return `${permission}\u0000${dependent}`;
}
function emptyFlags(): ActionFlags {
return {
none: false,
view: false,
create: false,
amend: false,
delete: false,
};
}
function isYes(value?: string): boolean {
return value === YesNoPolicy.YES;
}
/** 同一权限名可能被多个用户组授予,合并成 OR:任一组给了某个动作就算有 */
function merge(
target: Map<string, ActionFlags>,
key: string,
row: PermissionRowLike,
): void {
let flags = target.get(key);
if (!flags) {
flags = emptyFlags();
target.set(key, flags);
}
flags.none = true;
flags.view ||= isYes(row.ViewIndc);
flags.create ||= isYes(row.CreateIndc);
flags.amend ||= isYes(row.AmendIndc);
flags.delete ||= isYes(row.DeleteIndc);
}
/**
* 建索引。传 null/undefined/空清单会得到空索引,不抛异常——
* 登录档案是从本地缓存水合的,损坏或半截是正常状态,判定层不该让页面崩。
*/
export function buildPermissionIndex(
info?: PermissionInfoLike | null,
): PermissionIndex {
if (!info) {
return EMPTY_INDEX;
}
const byName = new Map<string, ActionFlags>();
const byDependent = new Map<string, ActionFlags>();
for (const row of info.Permissions ?? []) {
if (row?.PermissionName) {
merge(byName, row.PermissionName, row);
}
}
for (const row of info.PermissionDependents ?? []) {
if (row?.PermissionName && row.DependentDetailName) {
merge(
byDependent,
dependentKey(row.PermissionName, row.DependentDetailName),
row,
);
}
}
return { byName, byDependent };
}
function lookup(
index: PermissionIndex,
permission: string,
dependent?: string,
): ActionFlags | undefined {
if (dependent) {
return index.byDependent.get(dependentKey(permission, dependent));
}
return index.byName.get(permission);
}
/**
* 单条判定。
* @param index 预建索引
* @param action 要判定的动作
* @param permission 权限名,须与后端 security_function.permission_name 逐字一致
* @param dependent 数据级授权明细名;给了它就只查 dependent 集合,与后端一致
*/
export function can(
index: PermissionIndex,
action: PermissionAction,
permission: string,
dependent?: string,
): boolean {
const flags = lookup(index, permission, dependent);
if (!flags) {
return false;
}
return action === "none" ? flags.none : flags[action];
}
/**
* 批量判定:任一权限名通过即通过(OR)。
* 一个页面被多个功能权限共享时用得上,例如相册浏览页同时挂在
* PublishPhotoEnquiry 与 FamilyGroupShare 之下。
*/
export function canAny(
index: PermissionIndex,
action: PermissionAction,
permissions: PermissionNames,
dependent?: string,
): boolean {
const list = Array.isArray(permissions) ? permissions : [permissions];
// 刻意不写成 list.some(name => ...):单参数箭头的括号,prettier 要加、
// ESLint 要去,改回去就来回拉锯;for-of 在两个工具下都稳定
for (const name of list) {
if (name && can(index, action, name, dependent)) {
return true;
}
}
return false;
}
/**
* 后端 [PermissionOperate(name, Operate.X)] 的枚举值,逐字照抄
* CloudBuilder.Core/Authorization/PermissionOperateAttribute.cs 的 Operate。
* 用 as const 数组而不是多行字面量联合:联合类型的换行位置 prettier 与
* operator-linebreak 各执一词,只有这种写法在两个工具下都不被改写
*/
export const PERMISSION_OPERATES = [
"None",
"Find",
"Print",
"Insert",
"Update",
"Delete",
"Submit",
"Approval",
] as const;
/** 后端 Operate 枚举的字符串取值 */
export type PermissionOperate = (typeof PERMISSION_OPERATES)[number];
/**
* 按后端的鉴权口径判一个操作:与 PermissionHandler.HandleRequirementAsync 的 switch 一一对应。
* Find/Print -> view,Insert -> create,Update -> amend,Delete -> delete;
* 其余(Submit/Approval)走 default 分支——后端把 operate 字符串当成 DependentDetailName
* 去数据级授权里查"有没有挂过这个操作",所以这里同样是 none + dependent。
*
* 用意:前端按钮的可用性判定和接口能不能调,用的是同一份映射,不会出现
* "提交按钮亮着、请求过去被 403"。新增操作类型时只改这一处。
*/
export function canOperate(
index: PermissionIndex,
operate: PermissionOperate,
permission: string,
): boolean {
switch (operate) {
case "None": {
return can(index, "none", permission);
}
case "Find":
case "Print": {
return can(index, "view", permission);
}
case "Insert": {
return can(index, "create", permission);
}
case "Update": {
return can(index, "amend", permission);
}
case "Delete": {
return can(index, "delete", permission);
}
default: {
return can(index, "none", permission, operate);
}
}
}
/** 取某权限名(或某条数据级授权)的全部动作,供 UI 一次性渲染按钮可用性 */
export function actionsOf(
index: PermissionIndex,
permission: string,
dependent?: string,
): ActionFlags {
return lookup(index, permission, dependent) ?? emptyFlags();
}
/** 已授权的动作清单,调试面板与"我到底有什么权限"排查用 */
export function grantedPermissions(
index: PermissionIndex,
action: PermissionAction,
): string[] {
const names: string[] = [];
index.byName.forEach((flags, name) => {
if (action === "none" ? flags.none : flags[action]) {
names.push(name);
}
});
return names;
}