前端只用它决定**画不画按钮**(导出、详情里的管理动作、快应用的数据/统计 tab); + * 真正的把关在后端(C4 三段门 + 行级范围构造器),藏按钮不等于授权。 + * 刻意不做"拿不到清单就当有权限"的宽定——fail-closed 与原模块行判定同向。
+ */ +export function managesForm(managed: ManagedForm[], appCode: string | null | undefined, code: string | null | undefined): boolean { + if (!code) return false; + const key = rowKey(appCode || CENTER_APP_CODE, code); + return managed.some((r) => rowKey(r.appCode, r.code) === key); +} diff --git a/src/types/data.ts b/src/types/data.ts index 6dfab06..9b6a6f7 100644 --- a/src/types/data.ts +++ b/src/types/data.ts @@ -152,6 +152,25 @@ export const DATA_SCOPE_LABELS: Record原 `buildNavTree(模块树 → 导航树)` 与它的三条规则(path 空=权限锚点跳过、分组头保留、 + * 配错照常显示)随 **UserBase 模块行整条退役** 一起删除(§19 作废-10)——导航不再从平台树生成, + * 而由 `App.vue` 按后端三支路(可填 / 我管理的 / 能力位)直接构造 `NavNode`。 + * 本文件因此只留"导航数据结构 + 高亮/面包屑/默认跳转"这三件与权限无关的事。
*/ -import type { IModule } from "@/types/Basics"; - -/** 菜单项目标解析器(utils/menuRoute.ts 的 resolveMenuPage 同签名) */ -export type MenuResolver = ( - page?: string | null, - modId?: number | string | null, - name?: string | null -) => { path: string; external: boolean }; - /** 可渲染的导航节点 */ export interface NavNode { id: number; + /** 自解析形态下 modId 就是目标 path(layout 只用它做高亮对照,不参与任何判定) */ modId: number | string; name: string; - /** 平台配置的图标名(如 list),空串由 layout 兜底 icon-list */ + /** 平台配置的图标名,空由 layout 兜底 icon-list */ icon: string; - /** 解析后的内部路由 path(外链为完整 URL);解析不出时为空串 */ + /** 内部路由 path(外链为完整 URL);为空表示这一项没有可跳转页面 */ page: string; external: boolean; - /** 平台原始配置串,仅排障用 */ + /** 原始配置串(自解析形态下与 page 同值,仅排障对照用) */ raw: string; /** 分组头(有可渲染子节点)为 true,本身不参与高亮/跳转 */ group: boolean; @@ -50,51 +35,6 @@ export interface FlatNav { pid: number; } -/** 原始 path 是否为空(= 纯权限锚点,不进导航) */ -function isAnchorRow(node: IModule): boolean { - return String(node?.page ?? "").trim() === ""; -} - -export function buildNavTree(modList: IModule[] | null | undefined, resolve: MenuResolver): NavNode[] { - const tree: NavNode[] = []; - for (const node of modList ?? []) { - if (!node) continue; - const children = buildNavTree(node.children, resolve); - const raw = String(node.page ?? "").trim(); - if (children.length) { - // 规则②:分组头保留(自身 path 是否为空无关),子节点已按规则①过滤。 - // 分组头不作为链接渲染,page 只留作排障对照 - tree.push({ - id: node.id, - modId: node.modId, - name: node.name, - icon: node.icon ?? "", - page: resolve(raw, node.modId, node.name).path, - external: false, - raw, - group: true, - children - }); - continue; - } - // 规则①:叶子且原始 path 为空 = 权限锚点,跳过渲染 - if (isAnchorRow(node)) continue; - const resolved = resolve(raw, node.modId, node.name); - tree.push({ - id: node.id, - modId: node.modId, - name: node.name, - icon: node.icon ?? "", - page: resolved.path, - external: resolved.external, - raw, - group: false, - children: [] - }); - } - return tree; -} - /** 深度优先展平(分组头不入表:它没有可跳转页面,高亮/面包屑只对照可跳转项) */ export function flattenNav(tree: NavNode[], pid = 0): FlatNav[] { const out: FlatNav[] = []; @@ -126,7 +66,7 @@ export function countNavItems(tree: NavNode[]): number { /** * 「单模块独占」= 只有一个可跳转菜单项且无分组:隐藏左导航、内容区铺满。 - * 无授权用户只余「我的填写」一行时即为此形态。 + * 三支路都空的人(例如只被授了一张表当填报人、`/my` 一项)即为此形态。 */ export function isSingleNav(tree: NavNode[]): boolean { const hasGroup = tree.some((node) => node.group); diff --git a/src/utils/modulePerms.ts b/src/utils/modulePerms.ts deleted file mode 100644 index e0e8da6..0000000 --- a/src/utils/modulePerms.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * 按钮级/页面级权限的**前端呈现**判定(方案 §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"); -} - -/** - * 受限身份可见集的来源(V26 第三半句的前端侧口径): - * 学生身份只见"填报"路由 = 导航树里只有填报行——这是模块授权的直接结果, - * 前端不再自己按角色过滤一遍(任务书 T8:范围以后端为准,前端不自行过滤)。 - * 本函数只做"树里有几项可跳转"的取证辅助,供断言用。 - */ -export function navigableCount(tree: IModule[] | null | undefined): number { - return authorizedPaths(tree).length; -} diff --git a/src/views/AppContext.vue b/src/views/AppContext.vue index 33b8784..b0bec1e 100644 --- a/src/views/AppContext.vue +++ b/src/views/AppContext.vue @@ -1,77 +1,57 @@