/** * 按钮级/页面级权限的**前端呈现**判定(方案 §6.1 第②层、§6.2 权限锚点、V26 的前端半句)。 * * 输入永远是 `GetUserMod` 返回的原始模块树(含 path 空的权限锚点行)—— * 不是 `utils/menuNav.ts` 过滤后的导航树:导航按规则跳过了 path 空节点, * 拿过滤后的树来判按钮,锚点行永远不在里面,判据就成了猜。 * * 三条纪律: * 1. **这里只决定"按钮画不画"**,接口侧一律会话自取(契约 §7 身份行)——藏按钮不等于授权; * 2. **中心与快应用两种上下文的导出载体不同形**(手册 §2.2 中心表没有导出行、方案 §6.2 * 快应用表的导出行是 path 空锚点),所以按上下文分派,不硬造一个 `fc:export:*` 协议值 * (中心面原来只有数据范围升级位那一个锚点值,T10 已把它退役、换成后端管理员两表; * 前端这套按钮锚点判定与此无关,消费的仍是快应用自己那两行 path 空行); * 3. **快应用上下文拿不到锚点就是不给**(fail-closed),不做"没配就当有"的宽定。 */ import type { IModule } from "@/types/Basics"; /** 深度优先展平(顺序不承诺,只做集合判定) */ export function flattenModules(tree: IModule[] | null | undefined): IModule[] { const out: IModule[] = []; for (const node of tree ?? []) { if (!node) continue; out.push(node); out.push(...flattenModules(node.children)); } return out; } /** 授权页面行 path 集合(path 空的权限锚点不在内)——与后端 FormModuleKeys.paths 同一判据 */ export function authorizedPaths(tree: IModule[] | null | undefined): string[] { return flattenModules(tree) .map((m) => String(m.page ?? "").trim()) .filter((p) => p !== ""); } /** 权限锚点行(path 为空的节点)——契约 §8"锚点行形态"注记 / P47:只认 path 空节点 */ export function anchorRows(tree: IModule[] | null | undefined): IModule[] { return flattenModules(tree).filter((m) => String(m.page ?? "").trim() === ""); } /** 是否被授权了某个页面行(path 精确匹配) */ export function hasPage(tree: IModule[] | null | undefined, path: string): boolean { return authorizedPaths(tree).includes(path); } /** 是否存在某个权限锚点行(按行名称匹配——快应用"导出"行的形态就是 path 空 + 名称"导出") */ export function hasAnchorNamed(tree: IModule[] | null | undefined, name: string): boolean { const want = name.trim(); return anchorRows(tree).some((m) => String(m.name ?? "").trim() === want); } export type DataExportContext = "center" | "app"; /** * 导出按钮显隐(V26 前端半句:"无导出授权者前端无按钮";接口拒是 T4 已验的另一半)。 * * - `center`:中心管理端。手册 §2.2 的中心模块行表里没有导出行,后端 T4 的导出端点 * 按 `/admin/data` 页面行把关,所以前端同口径按页面行判——不自造一个中心侧没有的锚点。 * - `app`:快应用上下文。方案 §6.2 的快应用典型配法里"导出"是 path 空的按钮级锚点行, * 授权了才在树里出现;拿不到该锚点就不画按钮(fail-closed)。 */ export function canExport(tree: IModule[] | null | undefined, context: DataExportContext): boolean { if (context === "app") return hasAnchorNamed(tree, "导出"); return hasPage(tree, "/admin/data"); } /** * 快应用树里的填报目标(模块行 path 形如 `/f/{code}`,方案 §6.2 表第一行)。 * 数据/统计页要的是表单 uuid,而 uuid 只能由 code 经 resolve 得到(契约 §1"resolve 是读接口的唯一入口")—— * 快应用上下文不授权定义管理行,拿不到表单列表,这条是唯一的合规取数路径。 */ export function fillCodesOfModules(tree: IModule[] | null | undefined): string[] { const codes: string[] = []; for (const p of authorizedPaths(tree)) { const m = /^\/f\/([^/?#]+)/.exec(p); if (m && !codes.includes(m[1])) codes.push(m[1]); } return codes; } /** * 受限身份可见集的来源(V26 第三半句的前端侧口径): * 学生身份只见"填报"路由 = 导航树里只有填报行——这是模块授权的直接结果, * 前端不再自己按角色过滤一遍(任务书 T8:范围以后端为准,前端不自行过滤)。 * 本函数只做"树里有几项可跳转"的取证辅助,供断言用。 */ export function navigableCount(tree: IModule[] | null | undefined): number { return authorizedPaths(tree).length; }