diff --git a/src/App.vue b/src/App.vue index 9089831..6ca37f5 100644 --- a/src/App.vue +++ b/src/App.vue @@ -4,10 +4,9 @@ import layout from "./components/layout.vue"; import { useRoute, useRouter } from "vue-router"; import config from "@/assets/config/host.config"; import { baseApi } from "@/api/module/base"; -import { resolveMenuPage } from "@/utils/menuRoute"; -import { buildNavTree, firstNavPage, isSingleNav, type NavNode } from "@/utils/menuNav"; +import { getMyCapabilities, listFillableForms, listManagedForms } from "@/api/module/fill"; +import { firstNavPage, isSingleNav, type NavNode } from "@/utils/menuNav"; import { Message } from "view-ui-plus"; -import type { IModule } from "@/types/Basics"; import { useDefaultStore } from "@/store"; const router = useRouter(); @@ -17,7 +16,7 @@ const isReady = ref(false); /** * 页面即组件(方案 §3.3):被 iframe 嵌入时自动剥壳——不渲染顶栏/左导航/面包屑, - * 也不发两个鉴权请求(嵌入上下文里本应用 /api/base/* 取不到会话会弹错误提示并画出带壳页面)。 + * 也不发会话请求(嵌入上下文里本应用 /api/base/* 取不到会话会弹错误提示并画出带壳页面)。 * 显式 mode 参数(?mode=embed|page)的闭集判定在 T7 填报页落地,壳这层只兜 self != top 这一条。 */ const isEmbedded = window.self !== window.top; @@ -37,11 +36,36 @@ const layoutData = reactive({ baseHost: config.baseHost, ssoHost: config.ssoHost, userInfo: null as any, - /** 已按「path 空=权限锚点不进导航」规则过滤的可渲染菜单树(见 utils/menuNav.ts) */ + /** 自解析出来的可渲染导航(契约 v2 §2.4 三支路;不再由 UserBase 模块树生成) */ navList: [] as NavNode[], isAlone: false, }); +/** 导航节点 id:layout 只做高亮/面包屑对照,不参与任何判定,序号稳定即可 */ +let navSeq = 1000; +function navItem(name: string, page: string, icon: string): NavNode { + return { id: navSeq++, modId: page, name, icon, page, external: false, raw: page, group: false, children: [] }; +} + +/** + * 三支路自解析(D5):可填的表单 / 我管理的表单 / 校管理员系统设置。 + * + * 前端不自行判权——每一支的画不画只看后端那三把接口给了什么: + * `fillable`(入集即可填)、`managed`(创建人 ∪ 表单管理员)、`capabilities.schoolAdmin`。 + */ +function buildSelfResolvedNav(): NavNode[] { + const nav: NavNode[] = [navItem("我的填写", "/my", "ios-person"), navItem("可填的表单", "/my/fillable", "ios-create")]; + if (store.managed.length) { + nav.push(navItem("我管理的表单", "/admin/data", "ios-list")); + nav.push(navItem("统计", "/admin/stats", "ios-pie")); + } + if (store.capabilities.schoolAdmin) { + nav.push(navItem("表单设计", "/admin/forms", "md-build")); + nav.push(navItem("系统设置", "/admin/app", "md-settings")); + } + return nav; +} + onMounted(async () => { // 首次导航异步完成后再判默认跳转:mount 时 currentRoute 可能仍是初始 "/" await router.isReady(); @@ -50,38 +74,42 @@ onMounted(async () => { if (isEmbedded) return; try { - // 1. 当前会话用户 + // 会话用户(GetUserMod 已随模块行退役删除,引导只剩这一把 + 三支路自解析) const user: any = await baseApi.getUserInfo().send(); const { id, name, profilePicture } = user || {}; // 存一份给页面用(如字典页判平台级维护权);权限判定仍在后端,这里只是不重复发一次请求 store.setUser(user || null); - // 2. 当前用户已授权的模块树(左导航数据源;path 空的权限锚点行也在内,由导航层跳过) - const modList: IModule[] = (await baseApi.getUserMod().send()) || []; + // 三支路并行取(互相独立,串行只会让首屏多等两个 RTT) + const [fillable, managed, capabilities] = await Promise.all([ + listFillableForms().catch((e: any) => { + console.warn("[自解析] 可填清单取不到,按空集处理:", e?.message); + return []; + }), + listManagedForms().catch((e: any) => { + console.warn("[自解析] 管理清单取不到,按空集处理:", e?.message); + return []; + }), + getMyCapabilities().catch((e: any) => { + console.warn("[自解析] 能力位取不到,按无入口处理(fail-closed):", e?.message); + return { schoolAdmin: false }; + }) + ]); + store.setFillable(fillable || []); + store.setManaged(managed || []); + store.setCapabilities(capabilities || null); + layoutData.userInfo = { id, name: name || "用户", avatar: profilePicture || "" }; - layoutData.navList = buildNavTree(modList, resolveMenuPage); + layoutData.navList = buildSelfResolvedNav(); layoutData.isAlone = isSingleNav(layoutData.navList); - // 原始树留一份:按钮级权限(快应用的"导出"锚点)按它判,导航树已把锚点行过滤掉了 - store.setModules(modList); if (import.meta.env.DEV) { - // 冒烟取证:原始树 vs 可渲染导航(path 空的权限锚点应当只出现在前者) console.table( - (modList || []).map((m: any) => ({ - name: m?.name, - id: m?.id, - modId: m?.modId, - page: m?.page, - icon: m?.icon, - children: (m?.children || []).length - })) - ); - console.table( - layoutData.navList.map((n) => ({ 渲染项: n.name, page: n.page, 分组头: n.group })) + layoutData.navList.map((n) => ({ 渲染项: n.name, page: n.page })) ); } - // 入口页:跳第一个可跳转菜单项(无授权用户即「我的填写」) + // 入口页:跳第一个可跳转菜单项 if (router.currentRoute.value.path === "/") { const first = firstNavPage(layoutData.navList); if (first) router.replace(first); @@ -91,7 +119,7 @@ onMounted(async () => { if (error?.message !== "redirect") { Message.error("载入用户信息失败,请检查后端服务是否正常!"); } - // 降级只降「导航为空」,不造兜底菜单树——静默回落会把「平台未配模块/未授权」伪装成正常 + // 降级只降「导航为空」,不造兜底菜单树——静默回落会把"接口没通"伪装成正常 store.setUser(null); layoutData.userInfo = { name: "用户", avatar: "" }; layoutData.navList = []; diff --git a/src/api/module/base.ts b/src/api/module/base.ts index a51a75b..e703e5f 100644 --- a/src/api/module/base.ts +++ b/src/api/module/base.ts @@ -1,14 +1,16 @@ import { alovaInstance } from "@/utils/alova/alova-iview"; -import type { IModule } from "@/types/Basics"; import type { SchoolTreeNode } from "@/types/admin"; /** - * 基础平台接口(notice/front 的 api/module 形态 + flow-center base.ts 同构): - * 用户信息与已授权模块列表(左导航数据源)。 + * 基础平台接口(notice/front 的 api/module 形态 + flow-center base.ts 同构)。 * - * 模块行在 UserBase 按本应用(app_code=form-center)配置,path 须逐字对上 - * router/routes.ts(手册 §2.2 表 v1.5 五行版)。数据范围升级位不再挂在模块行上: - * T10 起由后端管理员两表(school_admin / form_admin)承载,前端只在 /admin/app 的角色设置 tab 维护它。 + * 契约 v2 §2.4 之后只剩两把: + * - `getUserInfo`:会话用户本体,App.vue 引导的唯一请求; + * - `getSchoolAll`:本校组织树(教师版),管理员两表选人弹窗的数据源。 + * + * **`getUserMod` 已随 UserBase 模块行整条退役而删除**(§19 作废-10):左导航改由三支路自解析 + * (`/api/form/my/fillable`、`/api/form/my/managed`、`/api/form/my/capabilities`), + * B14 缺口与跨项目触点 H4 一并消失。 * * 写法与 Notice 的一处差异:Notice 用 `new Method(...)`,而 alova 3.5 的 Method 第一个 * 泛型位是 AlovaGenerics(不是响应类型),strict vue-tsc 下报 TS2344;本端改用实例方法 @@ -22,19 +24,6 @@ class BaseApi { return alovaInstance.Get(`${BASE}/GetUserInfo`); } - /** - * 当前用户已授权的模块树(左侧菜单数据源)。 - * - * @param appCode 可选目标应用(快应用上下文 /app/{appCode}/ 要的是那个应用的树, - * 参数名按执行手册 §5 P2 定稿 appCode)。UserBase 侧 H4 未上线时后端 - * 返回空树并打 warn——前端据此显示空导航,不得回落本应用树(P3 裁决)。 - */ - getUserMod(appCode?: string) { - return alovaInstance.Get(`${BASE}/GetUserMod`, { - params: appCode ? { appCode } : undefined - }); - } - /** * 本校组织树(教师版)——选人弹窗 UserCheckModal 的 loader,形态同 notice。 * 没有 schId 入参:域只从会话推导,因此候选天然限在本校树里(跨校的人在接口层就给不出来)。 diff --git a/src/api/module/fill.ts b/src/api/module/fill.ts index 308db09..818a477 100644 --- a/src/api/module/fill.ts +++ b/src/api/module/fill.ts @@ -6,7 +6,7 @@ */ import { alovaInstance } from "@/utils/alova/alova-iview"; import type { ResolvedForm, SubmitRequest, SubmitView, MyAnswerItem } from "@/types/answer"; -import type { AnswerDetail, FillableForm, MyAnswerViewItem } from "@/types/data"; +import type { AnswerDetail, Capabilities, FillableForm, ManagedForm, MyAnswerViewItem } from "@/types/data"; /** 解析当前发布版本(契约 §1:入口寻址) */ export function resolveForm(code: string, appCode?: string) { @@ -67,3 +67,18 @@ export function listFillableForms(appCode?: string) { if (appCode) params.appCode = appCode; return alovaInstance.Get("/api/form/my/fillable", { params }).send(); } + +// ==================== 菜单自解析另两支路(契约 v2 §2.4/D5,T14) ==================== +// 三支路的后两支:我管理的表单(C3)与能力位(校管理员)。前端不自行判权,只消费这三把接口。 + +/** 我管理的表单(创建人 ∪ 表单管理员;role 标出来源) */ +export function listManagedForms(appCode?: string) { + const params: Record = {}; + if (appCode) params.appCode = appCode; + return alovaInstance.Get("/api/form/my/managed", { params }).send(); +} + +/** 能力位:只回答"要不要给这个人画系统设置入口",不是权限 */ +export function getMyCapabilities() { + return alovaInstance.Get("/api/form/my/capabilities").send(); +} diff --git a/src/assets/mock/baseapi.ts b/src/assets/mock/baseapi.ts index 6984a74..17d5c79 100644 --- a/src/assets/mock/baseapi.ts +++ b/src/assets/mock/baseapi.ts @@ -2,150 +2,16 @@ * 表单中心本地 Mock(vite-plugin-mock,仅 `npm run dev:mock` 即 --mode mock 生效)。 * * 背景与授权口径:执行手册 §5 P1 的降级取证先例——本地 CAS/UserBase 不可用时, - * 以本文件的模块树验证「壳渲染 + 左导航 + path 空节点跳过」,并把结论记入问题单。 + * 用本文件的桩验证「壳渲染 + 自解析左导航」,并把结论记入问题单。 * - * 形状严格对齐后端 FormBaseController.toFrontendTree 的下发字段 - * (id/modId/name/parentId/page/level/icon/orderNumber/usable/children,信封 {code,msg,data}), - * 模块行取执行手册 §2.2 的 v1.5 现行表(中心管理员四行 + 我的填写,共五行)。 + * 契约 v2 §2.4(T14)之后本桩只剩两把基础端点:`GetUserInfo` 与 `GetSchoolAll`。 + * 原 `GetUserMod` 与两棵模块树桩(中心五行 / 快应用演练行)随模块行整条退役删除—— + * 左导航改由三支路(`my/fillable`、`my/managed`、`my/capabilities`)自解析, + * 那三支的桩在 `dataapi.ts` 里。 */ -import type { IModule } from "@/types/Basics"; const ok = (data: any) => ({ code: 0, msg: "", data }); -/** 中心应用(form-center)自身的模块树 */ -const OWN_MODULES: IModule[] = [ - { - id: 9101, - modId: 9101, - name: "表单设计", - parentId: 0, - page: "/admin/forms", - level: 1, - icon: "word", - orderNumber: 1, - usable: true, - children: [] - }, - { - id: 9102, - modId: 9102, - name: "数据管理", - parentId: 0, - page: "/admin/data", - level: 1, - icon: "category", - orderNumber: 2, - usable: true, - children: [] - }, - { - id: 9103, - modId: 9103, - name: "统计", - parentId: 0, - page: "/admin/stats", - level: 1, - icon: "tongji", - orderNumber: 3, - usable: true, - children: [] - }, - { - id: 9104, - modId: 9104, - name: "应用设置", - parentId: 0, - page: "/admin/app", - level: 1, - icon: "zidingyi", - orderNumber: 4, - usable: true, - children: [] - }, - { - id: 9105, - modId: 9105, - name: "我的填写", - parentId: 0, - page: "/my", - level: 1, - // 平台未配置图标 → layout 兜底 icon-list(规范 §4 / Notice 13afb22) - icon: "", - orderNumber: 5, - usable: true, - children: [] - } - // v1.5(T10)起中心应用就这五行:字典管理行撤(迁入 /admin/app 的页内 tab)、 - // "数据全量"path 空锚点行撤(UserBase 侧 customId 权限锚点协议退役,数据范围升级改由后端管理员两表承载)。 - // 本地要复现旧六行版的登录态(V46 的"不再出现字典管理导航项")请用 t10 冒烟脚本里的临时树覆盖, - // 不要把遗留行写回这份 mock——它是现行配置的镜像。 -]; - -/** - * 第一个快应用的模块树(T9 演练桩,P95 W6 改版为终态形态)。 - * - * 只在 `appCode=fc-drill` 时下发,用来在本机把 **P76(导出锚点按应用树判)/ P77(应用内详情形态)** - * 跑到可实拍的程度——真会话取证要等 H2/H4(执行手册 §2 人工项)。 - * 其余外指 appCode 仍旧返回空树:那是 H4 未上线时的既定口径(P3),桩不能替平台把没上线的能力假装成有。 - * - * P95 起这棵树里没有「填报」行了:`/f/{code}` 模块行不再承担填报授权 - * (入口由中心按三层模型解析,见 `/api/form/my/fillable` 桩),本页的树只剩两种职责—— - * 数据查看/统计两个页面行,和 path 空的导入/导出按钮锚点(P76 判的就是这两行)。 - * 与 `service/sql/h2_userbase_app_and_modules.sql` 的快应用段同一份终态。 - */ -const DRILL_APP_MODULES: IModule[] = [ - { - id: 9202, - modId: 9202, - name: "数据查看", - parentId: 0, - page: "/app/fc-drill/data", - level: 1, - icon: "category", - orderNumber: 2, - usable: true, - children: [] - }, - { - id: 9203, - modId: 9203, - name: "统计", - parentId: 0, - page: "/app/fc-drill/stats", - level: 1, - icon: "tongji", - orderNumber: 3, - usable: true, - children: [] - }, - { - // 1.5 预留位:path 空锚点(方案 §6.2 表第四行)——不进导航 - id: 9204, - modId: 9204, - name: "导入", - parentId: 0, - page: "", - level: 1, - icon: "", - orderNumber: 4, - usable: true, - children: [] - }, - { - // 按钮级权限锚点:导出按钮显隐按它判(V26 第一半句,P76 修的就是"判的是哪棵树") - id: 9205, - modId: 9205, - name: "导出", - parentId: 0, - page: "", - level: 1, - icon: "", - orderNumber: 5, - usable: true, - children: [] - } -]; - export default [ { url: "/api/base/GetUserInfo", @@ -163,18 +29,6 @@ export default [ school: { id: 3260, name: "云海附属中学" } }) }, - { - url: "/api/base/GetUserMod", - method: "get", - // 与后端同一条判定:appCode 指向别的应用 → 空树(H4 未上线口径,P3:前端不得回落本应用树)。 - // 唯一例外 fc-drill=T9 快应用演练桩(模块树见 DRILL_APP_MODULES),用于本机实拍 P76/P77/V19 - response: ({ query }: any) => { - const target = String(query?.appCode ?? "").trim(); - if (target === "fc-drill") return ok(DRILL_APP_MODULES); - if (target && target.toLowerCase() !== "form-center") return ok([]); - return ok(OWN_MODULES); - } - }, { // 选人弹窗的组织树桩(T10):形状=后端 SchoolModels.Node(isParent=true 是组、false 是人员叶子)。 // id 即 UserBase 用户主键,提交给 /school-admins 与 /forms/{uuid}/admins 的就是它。 diff --git a/src/assets/mock/dataapi.ts b/src/assets/mock/dataapi.ts index 9343238..bf58034 100644 --- a/src/assets/mock/dataapi.ts +++ b/src/assets/mock/dataapi.ts @@ -18,7 +18,7 @@ * 游标(keyset):`cursor`=上一页末行 answerId(uuid),服务端按它定位后取下一页—— * 这里用 uuid 在数组里的下标模拟,形状与后端一致(前端不感知差异)。 */ -import type { AnswerDetail, AnswerRow, BatchItemResult, FilterField, FillableForm, ListView, MyAnswerViewItem, StatsView } from "@/types/data"; +import type { AnswerDetail, AnswerRow, BatchItemResult, FilterField, FillableForm, ListView, ManagedForm, MyAnswerViewItem, StatsView } from "@/types/data"; const ok = (data: any) => ({ code: 0, msg: "", data }); const bizErr = (code: number, msg: string) => ({ code, msg, data: null }); @@ -349,6 +349,33 @@ export default [ return ok(sorted); } }, + // ==================== 菜单自解析另两支路(契约 v2 §2.4/C3,T14) ==================== + /** + * 我管理的表单(C3):行形状=fillable 行 + `role` 来源标记(creator/form_admin), + * 另带 `formUuid`/`status`(管理与定义动作都按 uuid 寻址,问题单 P120)。 + * mock 里把上面三行里的两张给成"我管的":一张创建人、一张表单管理员、一张两者都是。 + */ + { url: "/api/form/my/managed", method: "get", response: ({ query }: any) => { + const rows: ManagedForm[] = [ + { formUuid: FORM_UUID, status: 1, code: "honor_apply", name: "三好学生申报", appCode: APP, + audience: ["STUDENT"], deadline: "2026-12-31 23:59:59", fillLimit: 1, groupTag: null, + role: ["creator", "form_admin"] }, + { formUuid: FORM_UUID.replace(/.$/, "2"), status: 1, code: "teacher_survey", name: "教师问卷", + appCode: APP, audience: ["TEACHER"], deadline: "2026-11-30 23:59:59", fillLimit: 0, + groupTag: null, role: ["form_admin"] }, + { formUuid: FORM_UUID.replace(/.$/, "3"), status: 0, code: "home_visit", name: "家访登记(草稿)", + appCode: "fc-drill", audience: ["STUDENT", "TEACHER"], deadline: null, fillLimit: 0, + groupTag: "五年级", role: ["creator"] } + ]; + const app = String(query?.appCode || "").trim(); + return ok(app ? rows.filter((r) => r.appCode === app) : rows); + } }, + /** + * 能力位:mock 固定给"是校管理员",好让"系统设置/表单设计"两支在本地实拍得出来。 + * 真实取值来自后端 `FormAccessPolicy.isSchoolAdmin`(超管恒 true)。 + */ + { url: "/api/form/my/capabilities", method: "get", response: () => ok({ schoolAdmin: true }) }, + // ==================== 我的填写两视图(V18) ==================== { url: "/api/form/my/submit-list", method: "get", response: () => { const items: MyAnswerViewItem[] = seed diff --git a/src/store/index.ts b/src/store/index.ts index 4ae4e3b..3a9ae79 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,25 +1,34 @@ import { defineStore } from "pinia"; -import type { IModule } from "@/types/Basics"; +import type { Capabilities, FillableForm, ManagedForm } from "@/types/data"; /** * 全局 store(notice/front 基线形态)。 - * T6 起开第一个业务切片:会话用户本体——字典管理页要按它判「能不能进平台级上下文维护」 - * (UserBase 侧只有 type==1 超管可写 sch_id=0 的行,方案 §6.5;后端 FormAdminGuard 同判)。 - * 这里存的是**展示与门禁提示用的会话身份**,不是权限来源:接口侧一律会话自取, - * 前端藏掉按钮不等于授权(契约 §7 身份行)。 + * + * 存两类东西: + * 1. **会话用户本体**——展示与门禁提示用(字典页判"能不能进平台级上下文维护")。 + * 这里存的是展示位,**不是权限来源**:接口侧一律会话自取,前端藏掉按钮不等于授权(契约 §7 身份行); + * 2. **菜单自解析的三支路数据**(契约 v2 §2.4/D5)——可填清单、我管理的清单、能力位。 + * 原第四项"GetUserMod 原始模块树"随模块行整条退役删除(§19 作废-10):中心不再有任何 + * 按授权树判定的地方,"两棵树"的缺口(B13/P78)因此不复存在。 */ + +/** 中心表单的归属应用(快应用行的 appCode 不是这个值) */ +export const CENTER_APP_CODE = "form-center"; + +/** 列表行的唯一键:同 code 在不同 app_code 下是两张表(契约 §1 的 uk=(sch_id, app_code, code)) */ +const rowKey = (appCode: string, code: string) => `${appCode}#${code}`; + export const useDefaultStore = defineStore("defaultStore", { state: () => ({ userRole: "school" as string, /** /api/base/GetUserInfo 原样下发的会话用户(未拿到时为 null) */ user: null as Record | null, - /** - * /api/base/GetUserMod 的原始模块树(T8 起留一份给按钮级判定用)。 - * 必须是原始树而不是 `utils/menuNav.ts` 过滤后的导航树——导航按规则跳过了 - * path 空的权限锚点行,而按钮位(快应用的"导出")正是这类行(方案 §6.2)。 - * 判定函数在 `utils/modulePerms.ts`;这里只是避免同一棵树重复发一次请求。 - */ - modules: [] as IModule[] + /** 三支路之一:可填清单(后端按 audience 门筛过,入集即可填) */ + fillable: [] as FillableForm[], + /** 三支路之二:我管理的清单(创建人 ∪ 表单管理员) */ + managed: [] as ManagedForm[], + /** 三支路之三:能力位("系统设置"入口的唯一数据源) */ + capabilities: { schoolAdmin: false } as Capabilities }), getters: { /** 超管(UserBase UserTypeEnum.SUPER_ADMIN=1):唯一能操作平台级行的身份 */ @@ -33,6 +42,10 @@ export const useDefaultStore = defineStore("defaultStore", { /** 会话用户 id:判断"这条答卷是不是我提交的"(编辑/删除入口显隐)用 */ sessionUserId(state): number | null { return state.user?.id ?? null; + }, + /** "我管理的表单"键集:导出/详情管理按钮的显隐判据(ALL 档的清单形态) */ + managedKeys(state): Set { + return new Set(state.managed.map((r) => rowKey(r.appCode, r.code))); } }, actions: { @@ -42,8 +55,27 @@ export const useDefaultStore = defineStore("defaultStore", { setUser(user: Record | null) { this.user = user || null; }, - setModules(modules: IModule[] | null) { - this.modules = modules || []; + setFillable(rows: FillableForm[] | null) { + this.fillable = rows || []; + }, + setManaged(rows: ManagedForm[] | null) { + this.managed = rows || []; + }, + setCapabilities(caps: Capabilities | null) { + this.capabilities = { schoolAdmin: !!caps?.schoolAdmin }; } } }); + +/** + * "这张表我管不管得动"的判定(managed 清单成员 = 该表 ALL 档持有者,契约 v2 C3/C4②)。 + * + *

前端只用它决定**画不画按钮**(导出、详情里的管理动作、快应用的数据/统计 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 = { SELF: "我相关" }; +/** + * 「我管理的表单」的行(契约 v2 C3,`GET /api/form/my/managed`)= fillable 行 + `role` 来源标记。 + * + * `role` 闭集:`creator`(创建人,C16)/ `form_admin`(表单管理员表命中), + * 两个来源同时命中时数组有两项——界面要能说出"你为什么管得动这张表"。 + */ +export interface ManagedForm extends FillableForm { + /** 管理与定义动作都按 uuid 寻址(code 只作填报入口;问题单 P120) */ + formUuid: string; + /** 生命周期:0 草稿 / 1 已发布 / 2 停用(管理支路要标出"这张还没发布") */ + status: number; + role: string[]; +} + +/** 能力位(契约 v2 §2.4 第三支路,`GET /api/form/my/capabilities`):前端"系统设置"入口的唯一数据源 */ +export interface Capabilities { + schoolAdmin: boolean; +} + /** 范围名 → 展示文案;未知名原样显示(后端加枚举值不破坏前端) */ export function scopeLabel(name: string): string { return DATA_SCOPE_LABELS[name] ?? name; diff --git a/src/utils/menuNav.ts b/src/utils/menuNav.ts index 1f7e35b..88d87cc 100644 --- a/src/utils/menuNav.ts +++ b/src/utils/menuNav.ts @@ -1,39 +1,24 @@ /** - * 左导航渲染模型(GetUserMod 模块树 → 可渲染菜单树)。 + * 左导航的渲染模型(契约 v2 §2.4/D5 之后的形态)。 * - * 纯函数、零运行时依赖(只 import type),解析器由调用方注入(resolveMenuPage)—— - * 便于在冒烟脚本里直接跑断言,不必起浏览器。 - * - * 三条渲染规则(执行手册 §2.2「path 空行=纯权限锚点」的前端一侧): - * ① 跳过判据取平台原始 path 是否为空,不取解析结果:权限锚点(快应用面的「导入/导出」 - * 按钮行)本就不进导航,靠名字/modId 猜出路由再显示反而是假象——中心应用自手册 §2.2 v1.5 - * 起没有 path 空行,这条规则留着是给快应用面用的; - * ② 有子节点的分组行即使自身 path 为空也保留为分组头(头本身不可点), - * 子节点按 ① 过滤后全空则整个分组不渲染; - * ③ 原始 path 非空但解析不出已注册路由的行照常显示,点击时提示配置错误—— - * 配置问题要暴露成可见告警,不能静默少一条菜单(与 utils/menuRoute.ts 的告警口径一致)。 + *

原 `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 @@