t14:前端模块行退役——左导航三支路自解析、AppContext 重构为通用壳两视图

- App.vue:引导只剩 GetUserInfo,之后并行取 fillable/managed/capabilities 三支构造导航
  (取不到按空集/false 走,fail-closed,不造兜底菜单树);摘 GetUserMod 调用与 setModules
- store:modules/IModule 状态与 setModules 删除,改存三支路数据 + managesForm() 判"这张表我管不管得动"
- AppContext.vue:重写为通用壳两视图(§5.1)——管理员视图=数据列表+复制填报路径
  (添加/导入 T18 接进这一档),填报者视图=仅填报 tab;判定只看 managed 是否非空,
  壳零自有权限逻辑;GetUserMod?appCode= 拉目标应用树那一路与 :modules prop 链整条拆除
- Data.vue:导出按钮改"该表单 ALL 档(managed 成员)",中心与快应用两种上下文从此同一条判据;
  AnswerDetail.vue 的管理动作区同判据;utils/modulePerms.ts(canExport/hasPage)删除
- utils/menuNav.ts:buildNavTree 与"从树渲染菜单"三条规则删除,只留导航数据结构与
  高亮/面包屑/默认跳转;menuRoute 的 /f/{code} 形态识别按任务书保留(分享直链/历史链接)
- mock:baseapi 去 GetUserMod 与两棵模块树;dataapi 补 /my/managed 与 /my/capabilities 桩
This commit is contained in:
chenxing
2026-10-20 13:21:23 +08:00
parent 4a7db3d22c
commit 6401a69c18
12 changed files with 324 additions and 472 deletions
+53 -25
View File
@@ -4,10 +4,9 @@ import layout from "./components/layout.vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import config from "@/assets/config/host.config"; import config from "@/assets/config/host.config";
import { baseApi } from "@/api/module/base"; import { baseApi } from "@/api/module/base";
import { resolveMenuPage } from "@/utils/menuRoute"; import { getMyCapabilities, listFillableForms, listManagedForms } from "@/api/module/fill";
import { buildNavTree, firstNavPage, isSingleNav, type NavNode } from "@/utils/menuNav"; import { firstNavPage, isSingleNav, type NavNode } from "@/utils/menuNav";
import { Message } from "view-ui-plus"; import { Message } from "view-ui-plus";
import type { IModule } from "@/types/Basics";
import { useDefaultStore } from "@/store"; import { useDefaultStore } from "@/store";
const router = useRouter(); const router = useRouter();
@@ -17,7 +16,7 @@ const isReady = ref(false);
/** /**
* 页面即组件(方案 §3.3):被 iframe 嵌入时自动剥壳——不渲染顶栏/左导航/面包屑, * 页面即组件(方案 §3.3):被 iframe 嵌入时自动剥壳——不渲染顶栏/左导航/面包屑,
* 也不发两个鉴权请求(嵌入上下文里本应用 /api/base/* 取不到会话会弹错误提示并画出带壳页面)。 * 也不发会话请求(嵌入上下文里本应用 /api/base/* 取不到会话会弹错误提示并画出带壳页面)。
* 显式 mode 参数(?mode=embed|page)的闭集判定在 T7 填报页落地,壳这层只兜 self != top 这一条。 * 显式 mode 参数(?mode=embed|page)的闭集判定在 T7 填报页落地,壳这层只兜 self != top 这一条。
*/ */
const isEmbedded = window.self !== window.top; const isEmbedded = window.self !== window.top;
@@ -37,11 +36,36 @@ const layoutData = reactive({
baseHost: config.baseHost, baseHost: config.baseHost,
ssoHost: config.ssoHost, ssoHost: config.ssoHost,
userInfo: null as any, userInfo: null as any,
/** 已按「path 空=权限锚点不进导航」规则过滤的可渲染菜单树(见 utils/menuNav.ts) */ /** 自解析出来的可渲染导航(契约 v2 §2.4 三支路;不再由 UserBase 模块树生成) */
navList: [] as NavNode[], navList: [] as NavNode[],
isAlone: false, 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):可填的表单 / 我管理的表单 / 校管理员系统设置。
*
* <b>前端不自行判权</b>——每一支的画不画只看后端那三把接口给了什么:
* `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 () => { onMounted(async () => {
// 首次导航异步完成后再判默认跳转:mount 时 currentRoute 可能仍是初始 "/" // 首次导航异步完成后再判默认跳转:mount 时 currentRoute 可能仍是初始 "/"
await router.isReady(); await router.isReady();
@@ -50,38 +74,42 @@ onMounted(async () => {
if (isEmbedded) return; if (isEmbedded) return;
try { try {
// 1. 当前会话用户 // 会话用户(GetUserMod 已随模块行退役删除,引导只剩这一把 + 三支路自解析)
const user: any = await baseApi.getUserInfo().send(); const user: any = await baseApi.getUserInfo().send();
const { id, name, profilePicture } = user || {}; const { id, name, profilePicture } = user || {};
// 存一份给页面用(如字典页判平台级维护权);权限判定仍在后端,这里只是不重复发一次请求 // 存一份给页面用(如字典页判平台级维护权);权限判定仍在后端,这里只是不重复发一次请求
store.setUser(user || null); store.setUser(user || null);
// 2. 当前用户已授权的模块树(左导航数据源;path 空的权限锚点行也在内,由导航层跳过) // 三支路并行取(互相独立,串行只会让首屏多等两个 RTT)
const modList: IModule[] = (await baseApi.getUserMod().send()) || []; 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.userInfo = { id, name: name || "用户", avatar: profilePicture || "" };
layoutData.navList = buildNavTree(modList, resolveMenuPage); layoutData.navList = buildSelfResolvedNav();
layoutData.isAlone = isSingleNav(layoutData.navList); layoutData.isAlone = isSingleNav(layoutData.navList);
// 原始树留一份:按钮级权限(快应用的"导出"锚点)按它判,导航树已把锚点行过滤掉了
store.setModules(modList);
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
// 冒烟取证:原始树 vs 可渲染导航(path 空的权限锚点应当只出现在前者)
console.table( console.table(
(modList || []).map((m: any) => ({ layoutData.navList.map((n) => ({ 渲染项: n.name, page: n.page }))
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 }))
); );
} }
// 入口页:跳第一个可跳转菜单项(无授权用户即「我的填写」) // 入口页:跳第一个可跳转菜单项
if (router.currentRoute.value.path === "/") { if (router.currentRoute.value.path === "/") {
const first = firstNavPage(layoutData.navList); const first = firstNavPage(layoutData.navList);
if (first) router.replace(first); if (first) router.replace(first);
@@ -91,7 +119,7 @@ onMounted(async () => {
if (error?.message !== "redirect") { if (error?.message !== "redirect") {
Message.error("载入用户信息失败,请检查后端服务是否正常!"); Message.error("载入用户信息失败,请检查后端服务是否正常!");
} }
// 降级只降「导航为空」,不造兜底菜单树——静默回落会把「平台未配模块/未授权」伪装成正常 // 降级只降「导航为空」,不造兜底菜单树——静默回落会把"接口没通"伪装成正常
store.setUser(null); store.setUser(null);
layoutData.userInfo = { name: "用户", avatar: "" }; layoutData.userInfo = { name: "用户", avatar: "" };
layoutData.navList = []; layoutData.navList = [];
+8 -19
View File
@@ -1,14 +1,16 @@
import { alovaInstance } from "@/utils/alova/alova-iview"; import { alovaInstance } from "@/utils/alova/alova-iview";
import type { IModule } from "@/types/Basics";
import type { SchoolTreeNode } from "@/types/admin"; 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 须逐字对上 * 契约 v2 §2.4 之后只剩两把:
* router/routes.ts(手册 §2.2 表 v1.5 五行版)。数据范围升级位不再挂在模块行上: * - `getUserInfo`:会话用户本体,App.vue 引导的唯一请求;
* T10 起由后端管理员两表(school_admin / form_admin)承载,前端只在 /admin/app 的角色设置 tab 维护它。 * - `getSchoolAll`:本校组织树(教师版),管理员两表选人弹窗的数据源。
*
* **`getUserMod` 已随 UserBase 模块行整条退役而删除**(§19 作废-10):左导航改由三支路自解析
* (`/api/form/my/fillable`、`/api/form/my/managed`、`/api/form/my/capabilities`),
* B14 缺口与跨项目触点 H4 一并消失。
* *
* 写法与 Notice 的一处差异:Notice 用 `new Method<T>(...)`,而 alova 3.5 的 Method 第一个 * 写法与 Notice 的一处差异:Notice 用 `new Method<T>(...)`,而 alova 3.5 的 Method 第一个
* 泛型位是 AlovaGenerics(不是响应类型),strict vue-tsc 下报 TS2344;本端改用实例方法 * 泛型位是 AlovaGenerics(不是响应类型),strict vue-tsc 下报 TS2344;本端改用实例方法
@@ -22,19 +24,6 @@ class BaseApi {
return alovaInstance.Get(`${BASE}/GetUserInfo`); return alovaInstance.Get(`${BASE}/GetUserInfo`);
} }
/**
* 当前用户已授权的模块树(左侧菜单数据源)。
*
* @param appCode 可选目标应用(快应用上下文 /app/{appCode}/ 要的是<b>那个应用</b>的树,
* 参数名按执行手册 §5 P2 定稿 appCode)。UserBase 侧 H4 未上线时后端
* 返回空树并打 warn——前端据此显示空导航,<b>不得回落本应用树</b>(P3 裁决)。
*/
getUserMod(appCode?: string) {
return alovaInstance.Get<IModule[]>(`${BASE}/GetUserMod`, {
params: appCode ? { appCode } : undefined
});
}
/** /**
* 本校组织树(教师版)——选人弹窗 UserCheckModal 的 loader,形态同 notice。 * 本校组织树(教师版)——选人弹窗 UserCheckModal 的 loader,形态同 notice。
* 没有 schId 入参:域只从会话推导,因此候选天然限在本校树里(跨校的人在接口层就给不出来)。 * 没有 schId 入参:域只从会话推导,因此候选天然限在本校树里(跨校的人在接口层就给不出来)。
+16 -1
View File
@@ -6,7 +6,7 @@
*/ */
import { alovaInstance } from "@/utils/alova/alova-iview"; import { alovaInstance } from "@/utils/alova/alova-iview";
import type { ResolvedForm, SubmitRequest, SubmitView, MyAnswerItem } from "@/types/answer"; 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:入口寻址) */ /** 解析当前发布版本(契约 §1:入口寻址) */
export function resolveForm(code: string, appCode?: string) { export function resolveForm(code: string, appCode?: string) {
@@ -67,3 +67,18 @@ export function listFillableForms(appCode?: string) {
if (appCode) params.appCode = appCode; if (appCode) params.appCode = appCode;
return alovaInstance.Get<FillableForm[]>("/api/form/my/fillable", { params }).send(); return alovaInstance.Get<FillableForm[]>("/api/form/my/fillable", { params }).send();
} }
// ==================== 菜单自解析另两支路(契约 v2 §2.4/D5,T14) ====================
// 三支路的后两支:我管理的表单(C3)与能力位(校管理员)。前端不自行判权,只消费这三把接口。
/** 我管理的表单(创建人 ∪ 表单管理员;role 标出来源) */
export function listManagedForms(appCode?: string) {
const params: Record<string, string> = {};
if (appCode) params.appCode = appCode;
return alovaInstance.Get<ManagedForm[]>("/api/form/my/managed", { params }).send();
}
/** 能力位:只回答"要不要给这个人画系统设置入口",不是权限 */
export function getMyCapabilities() {
return alovaInstance.Get<Capabilities>("/api/form/my/capabilities").send();
}
+5 -151
View File
@@ -2,150 +2,16 @@
* 表单中心本地 Mock(vite-plugin-mock,仅 `npm run dev:mock` 即 --mode mock 生效)。 * 表单中心本地 Mock(vite-plugin-mock,仅 `npm run dev:mock` 即 --mode mock 生效)。
* *
* 背景与授权口径:执行手册 §5 P1 的降级取证先例——本地 CAS/UserBase 不可用时, * 背景与授权口径:执行手册 §5 P1 的降级取证先例——本地 CAS/UserBase 不可用时,
* 以本文件的模块树验证「壳渲染 + 左导航 + path 空节点跳过」,并把结论记入问题单。 * 用本文件的桩验证「壳渲染 + 自解析左导航」,并把结论记入问题单。
* *
* 形状严格对齐后端 FormBaseController.toFrontendTree 的下发字段 * 契约 v2 §2.4(T14)之后本桩只剩两把基础端点:`GetUserInfo` 与 `GetSchoolAll`。
* (id/modId/name/parentId/page/level/icon/orderNumber/usable/children,信封 {code,msg,data}), * 原 `GetUserMod` 与两棵模块树桩(中心五行 / 快应用演练行)随模块行整条退役删除——
* 模块行取执行手册 §2.2 的 v1.5 现行表(中心管理员四行 + 我的填写,共五行)。 * 左导航改由三支路(`my/fillable`、`my/managed`、`my/capabilities`)自解析,
* 那三支的桩在 `dataapi.ts` 里。
*/ */
import type { IModule } from "@/types/Basics";
const ok = (data: any) => ({ code: 0, msg: "", data }); 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),桩不能替平台把没上线的能力假装成有。
*
* <b>P95 起这棵树里没有「填报」行了</b>:`/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 [ export default [
{ {
url: "/api/base/GetUserInfo", url: "/api/base/GetUserInfo",
@@ -163,18 +29,6 @@ export default [
school: { id: 3260, name: "云海附属中学" } 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 是人员叶子)。 // 选人弹窗的组织树桩(T10):形状=后端 SchoolModels.Node(isParent=true 是组、false 是人员叶子)。
// id 即 UserBase 用户主键,提交给 /school-admins 与 /forms/{uuid}/admins 的就是它。 // id 即 UserBase 用户主键,提交给 /school-admins 与 /forms/{uuid}/admins 的就是它。
+28 -1
View File
@@ -18,7 +18,7 @@
* 游标(keyset):`cursor`=上一页末行 answerId(uuid),服务端按它定位后取下一页—— * 游标(keyset):`cursor`=上一页末行 answerId(uuid),服务端按它定位后取下一页——
* 这里用 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 ok = (data: any) => ({ code: 0, msg: "", data });
const bizErr = (code: number, msg: string) => ({ code, msg, data: null }); const bizErr = (code: number, msg: string) => ({ code, msg, data: null });
@@ -349,6 +349,33 @@ export default [
return ok(sorted); 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) ==================== // ==================== 我的填写两视图(V18) ====================
{ url: "/api/form/my/submit-list", method: "get", response: () => { { url: "/api/form/my/submit-list", method: "get", response: () => {
const items: MyAnswerViewItem[] = seed const items: MyAnswerViewItem[] = seed
+46 -14
View File
@@ -1,25 +1,34 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import type { IModule } from "@/types/Basics"; import type { Capabilities, FillableForm, ManagedForm } from "@/types/data";
/** /**
* 全局 store(notice/front 基线形态)。 * 全局 store(notice/front 基线形态)。
* T6 起开第一个业务切片:会话用户本体——字典管理页要按它判「能不能进平台级上下文维护」 *
* (UserBase 侧只有 type==1 超管可写 sch_id=0 的行,方案 §6.5;后端 FormAdminGuard 同判)。 * 存两类东西:
* 这里存的是**展示与门禁提示用的会话身份**,不是权限来源:接口侧一律会话自取, * 1. **会话用户本体**——展示与门禁提示用(字典页判"能不能进平台级上下文维护")。
* 前端藏掉按钮不等于授权(契约 §7 身份行)。 * 这里存的是展示位,**不是权限来源**:接口侧一律会话自取,前端藏掉按钮不等于授权(契约 §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", { export const useDefaultStore = defineStore("defaultStore", {
state: () => ({ state: () => ({
userRole: "school" as string, userRole: "school" as string,
/** /api/base/GetUserInfo 原样下发的会话用户(未拿到时为 null) */ /** /api/base/GetUserInfo 原样下发的会话用户(未拿到时为 null) */
user: null as Record<string, any> | null, user: null as Record<string, any> | null,
/** /** 三支路之一:可填清单(后端按 audience 门筛过,入集即可填) */
* /api/base/GetUserMod 的<b>原始模块树</b>(T8 起留一份给按钮级判定用)。 fillable: [] as FillableForm[],
* 必须是原始树而不是 `utils/menuNav.ts` 过滤后的导航树——导航按规则跳过了 /** 三支路之二:我管理的清单(创建人 ∪ 表单管理员) */
* path 空的权限锚点行,而按钮位(快应用的"导出")正是这类行(方案 §6.2)。 managed: [] as ManagedForm[],
* 判定函数在 `utils/modulePerms.ts`;这里只是避免同一棵树重复发一次请求。 /** 三支路之三:能力位("系统设置"入口的唯一数据源) */
*/ capabilities: { schoolAdmin: false } as Capabilities
modules: [] as IModule[]
}), }),
getters: { getters: {
/** 超管(UserBase UserTypeEnum.SUPER_ADMIN=1):唯一能操作平台级行的身份 */ /** 超管(UserBase UserTypeEnum.SUPER_ADMIN=1):唯一能操作平台级行的身份 */
@@ -33,6 +42,10 @@ export const useDefaultStore = defineStore("defaultStore", {
/** 会话用户 id:判断"这条答卷是不是我提交的"(编辑/删除入口显隐)用 */ /** 会话用户 id:判断"这条答卷是不是我提交的"(编辑/删除入口显隐)用 */
sessionUserId(state): number | null { sessionUserId(state): number | null {
return state.user?.id ?? null; return state.user?.id ?? null;
},
/** "我管理的表单"键集:导出/详情管理按钮的显隐判据(ALL 档的清单形态) */
managedKeys(state): Set<string> {
return new Set(state.managed.map((r) => rowKey(r.appCode, r.code)));
} }
}, },
actions: { actions: {
@@ -42,8 +55,27 @@ export const useDefaultStore = defineStore("defaultStore", {
setUser(user: Record<string, any> | null) { setUser(user: Record<string, any> | null) {
this.user = user || null; this.user = user || null;
}, },
setModules(modules: IModule[] | null) { setFillable(rows: FillableForm[] | null) {
this.modules = modules || []; this.fillable = rows || [];
},
setManaged(rows: ManagedForm[] | null) {
this.managed = rows || [];
},
setCapabilities(caps: Capabilities | null) {
this.capabilities = { schoolAdmin: !!caps?.schoolAdmin };
} }
} }
}); });
/**
* "这张表我管不管得动"的判定(managed 清单成员 = 该表 ALL 档持有者,契约 v2 C3/C4②)。
*
* <p>前端只用它决定**画不画按钮**(导出、详情里的管理动作、快应用的数据/统计 tab);
* 真正的把关在后端(C4 三段门 + 行级范围构造器),藏按钮不等于授权。
* 刻意不做"拿不到清单就当有权限"的宽定——fail-closed 与原模块行判定同向。</p>
*/
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);
}
+19
View File
@@ -152,6 +152,25 @@ export const DATA_SCOPE_LABELS: Record<string, string> = {
SELF: "我相关" 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 { export function scopeLabel(name: string): string {
return DATA_SCOPE_LABELS[name] ?? name; return DATA_SCOPE_LABELS[name] ?? name;
+10 -70
View File
@@ -1,39 +1,24 @@
/** /**
* 左导航渲染模型(GetUserMod 模块树 → 可渲染菜单树)。 * 左导航的渲染模型(契约 v2 §2.4/D5 之后的形态)。
* *
* 纯函数、零运行时依赖(只 import type),解析器由调用方注入(resolveMenuPage)—— * <p>原 `buildNavTree(模块树 → 导航树)` 与它的三条规则(path 空=权限锚点跳过、分组头保留、
* 便于在冒烟脚本里直接跑断言,不必起浏览器。 * 配错照常显示)随 **UserBase 模块行整条退役** 一起删除(§19 作废-10)——导航不再从平台树生成,
* * 而由 `App.vue` 按后端三支路(可填 / 我管理的 / 能力位)直接构造 `NavNode`。
* 三条渲染规则(执行手册 §2.2「path 空行=纯权限锚点」的前端一侧): * 本文件因此只留"导航数据结构 + 高亮/面包屑/默认跳转"这三件与权限无关的事。</p>
* ① <b>跳过判据取平台原始 path 是否为空</b>,不取解析结果:权限锚点(快应用面的「导入/导出」
* 按钮行)本就不进导航,靠名字/modId 猜出路由再显示反而是假象——中心应用自手册 §2.2 v1.5
* 起没有 path 空行,这条规则留着是给快应用面用的;
* ② 有子节点的分组行即使自身 path 为空也保留为<b>分组头</b>(头本身不可点),
* 子节点按 ① 过滤后全空则整个分组不渲染;
* ③ 原始 path 非空但解析不出已注册路由的行<b>照常显示</b>,点击时提示配置错误——
* 配置问题要暴露成可见告警,不能静默少一条菜单(与 utils/menuRoute.ts 的告警口径一致)。
*/ */
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 { export interface NavNode {
id: number; id: number;
/** 自解析形态下 modId 就是目标 path(layout 只用它做高亮对照,不参与任何判定) */
modId: number | string; modId: number | string;
name: string; name: string;
/** 平台配置的图标名(如 list),空串由 layout 兜底 icon-list */ /** 平台配置的图标名,空由 layout 兜底 icon-list */
icon: string; icon: string;
/** 解析后的内部路由 path(外链为完整 URL);解析不出时为空串 */ /** 内部路由 path(外链为完整 URL);为空表示这一项没有可跳转页面 */
page: string; page: string;
external: boolean; external: boolean;
/** 平台原始配置串,仅排障用 */ /** 原始配置串(自解析形态下与 page 同值,仅排障对照用) */
raw: string; raw: string;
/** 分组头(有可渲染子节点)为 true,本身不参与高亮/跳转 */ /** 分组头(有可渲染子节点)为 true,本身不参与高亮/跳转 */
group: boolean; group: boolean;
@@ -50,51 +35,6 @@ export interface FlatNav {
pid: number; 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[] { export function flattenNav(tree: NavNode[], pid = 0): FlatNav[] {
const out: FlatNav[] = []; const out: FlatNav[] = [];
@@ -126,7 +66,7 @@ export function countNavItems(tree: NavNode[]): number {
/** /**
* 「单模块独占」= 只有一个可跳转菜单项且无分组:隐藏左导航、内容区铺满。 * 「单模块独占」= 只有一个可跳转菜单项且无分组:隐藏左导航、内容区铺满。
* 无授权用户只余「我的填写」一行时即为此形态。 * 三支路都空的人(例如只被授了一张表当填报人、`/my` 一项)即为此形态。
*/ */
export function isSingleNav(tree: NavNode[]): boolean { export function isSingleNav(tree: NavNode[]): boolean {
const hasGroup = tree.some((node) => node.group); const hasGroup = tree.some((node) => node.group);
-75
View File
@@ -1,75 +0,0 @@
/**
* 按钮级/页面级权限的**前端呈现**判定(方案 §6.1 第②层、§6.2 权限锚点、V26 的前端半句)。
*
* 输入永远是 `GetUserMod` 返回的<b>原始模块树</b>(含 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 的中心模块行表里<b>没有导出行</b>,后端 T4 的导出端点
* 按 `/admin/data` 页面行把关,所以前端同口径按页面行判——不自造一个中心侧没有的锚点。
* - `app`:快应用上下文。方案 §6.2 的快应用典型配法里"导出"是 <b>path 空的按钮级锚点行</b>,
* 授权了才在树里出现;拿不到该锚点就不画按钮(fail-closed)。
*/
export function canExport(tree: IModule[] | null | undefined, context: DataExportContext): boolean {
if (context === "app") return hasAnchorNamed(tree, "导出");
return hasPage(tree, "/admin/data");
}
/**
* 受限身份可见集的来源(V26 第三半句的前端侧口径):
* 学生身份只见"填报"路由 = 导航树里只有填报行——这是模块授权的直接结果,
* 前端<b>不再自己按角色过滤一遍</b>(任务书 T8:范围以后端为准,前端不自行过滤)。
* 本函数只做"树里有几项可跳转"的取证辅助,供断言用。
*/
export function navigableCount(tree: IModule[] | null | undefined): number {
return authorizedPaths(tree).length;
}
+114 -89
View File
@@ -1,77 +1,57 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* 快应用上下文 /app/:appCode/(T8 落地,P95 W5 改版:导航壳按身份写死)。 * 快应用上下文 `#/app/:appCode/` = <b>通用壳</b>(契约 v2 §5.1/D15,T14 重构)。
* *
* 形态(方案 §6.2 纯 A 形态 + P95 三层模型): * 一个壳承载全部形态,**壳零自有权限逻辑**——两视图的判定只看后端两把清单:
* 快应用 = UserBase 真实应用行 + 无自有后端 + pc_url → 本路由。 * · `GET /api/form/my/managed?appCode=`(创建人 ∪ 表单管理员=该 app 域内的 ALL 档)
* - **清单来源**:`GET /api/form/my/fillable?appCode=<目标应用>`——中心按 * · `GET /api/form/my/fillable?appCode=`(audience 门筛过的可填清单,入集即可填)
* `audience × 身份 × 管理员两表` 解析出的填报入口。**不再吃该应用的模块树**, *
* 发一张表单不需要超管加一条 `/f/{code}` 模块行(P95 §0 病根第 1 条就此拆掉)。 * 两视图(§5.1 原文):
* - **导航壳按身份渲染**(三档写死,不再由"树里有什么"决定): * · <b>管理员视图</b>:数据列表 / 添加(代录,T18 接)/ 导入(T18 接)/ 删除 / 复制填报路径;
* · 填报 tab <b>恒显</b>:`canFill=true` 的行;0 行=空态;恰 1 行且地址没带子视图段= * · <b>填报者视图</b>:只有「填报」tab。
* <b>单表单直落</b>形态保留(内容区直接给这张表的填报入口,不叠导航); * 判定即"对这张 app 域表单是不是 ALL 档持有者"——原 P95 的 `myTier` 三档写死分组随三层模型出清
* · 数据管理 tab:存在 `myTier∈{MANAGE, ADMIN}` 的行时显,`:codes` 喂接口结果; * (§19 作废-9),原 `GetUserMod?appCode=` 拉目标应用树那一路也整条摘掉(它唯一剩余用途=导出锚点判定,
* · 统计 tab:存在 `myTier=ADMIN` 的行时显,codes 同上。 * 现改 managed 成员判定,B13/P78 遗迹清零)。
* - **"范围以后端为准"这条红线不动**:tab 只决定画不画;进去能看哪些行仍由后端 *
* (`DataScopeService` + `FormAdminGuard.requireDataView`)把关,前端不按角色再筛一遍(任务书 T8 红线)。 * 仍保留的既有形态:只有一个可填项且地址没带子视图段时<b>单表单直落</b>(T8/P95 的入口语义,不叠导航)。
* - **模块树仍然拉一次**,唯一用途=把原始树经 `modules` prop 下传给内嵌 Data.vue 判"导出"锚点
* (P76:中心会话树里没有快应用的锚点行,拿错树永远判不中)。空树/取树失败=fail-closed 不画导出按钮,
* <b>不再整页报"这个应用进不来"</b>——原"空树"告警与"树里没有 /f/ 行"的分支随这条依赖收敛而退役。
*/ */
import { computed, onMounted, ref, watch } from "vue"; import { computed, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { baseApi } from "@/api/module/base"; import { listFillableForms, listManagedForms } from "@/api/module/fill";
import { listFillableForms } from "@/api/module/fill";
import { fillRouteOf } from "@/utils/fillLink"; import { fillRouteOf } from "@/utils/fillLink";
import Data from "@/views/admin/Data.vue"; import Data from "@/views/admin/Data.vue";
import Stats from "@/views/admin/Stats.vue"; import type { FillableForm, ManagedForm } from "@/types/data";
import type { FillableForm } from "@/types/data"; import { Message } from "view-ui-plus";
import type { IModule } from "@/types/Basics";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const appCode = computed(() => String(route.params.appCode ?? "")); const appCode = computed(() => String(route.params.appCode ?? ""));
/** 子视图段(/app/:appCode/:view):空=填报(单表单直落或多表单列表) */ /** 子视图段(/app/:appCode/:view):空=按视图默认档(管理员→数据,填报者→填报) */
const viewParam = computed(() => String(route.params.view ?? "")); const viewParam = computed(() => String(route.params.view ?? ""));
const fillRows = ref<FillableForm[]>([]); const fillRows = ref<FillableForm[]>([]);
/** 目标应用的原始模块树:本页只把它交给内嵌 Data.vue 判导出锚点(P76),不再拿它算导航 */ const managedRows = ref<ManagedForm[]>([]);
const rawTree = ref<IModule[]>([]);
const loading = ref(false); const loading = ref(false);
const errorText = ref(""); const errorText = ref("");
/** 填报 tab 的行:契约 v2 C2 之后入集即等价于可填(行里没有 canFill,前端不再筛) */ /** ALL 档持有=该 app 域里有 managed 行——这是两视图唯一的分岔判据 */
const isAdminView = computed(() => managedRows.value.length > 0);
const fillCards = computed(() => fillRows.value); const fillCards = computed(() => fillRows.value);
/** const adminCodes = computed(() => managedRows.value.map((r) => r.code));
* 数据管理/统计两档的驱动位在 T13 这一批处于<b>换轨空窗</b>:
* 原驱动是 P95 的 `myTier`(三层模型的最高层),随 §19 作废-3 一起删掉了;
* 新驱动是 T14 的 `GET /api/form/my/managed`(创建人 ∪ form_admin 的"我管理的表单")。
* 本批同一次执行内就会接上,空窗期这两档不出现在壳上——<b>刻意不留"按 myTier 猜"的兼容分支</b>,
* 免得留下第二套判定口径(红线:壳零自有权限逻辑)。
*/
const dataCodes = computed<string[]>(() => []);
const statsCodes = computed<string[]>(() => []);
const showDataTab = computed(() => dataCodes.value.length > 0);
const showStatsTab = computed(() => statsCodes.value.length > 0);
/** 单表单直落:可填项恰好一个且地址没带子视图段(P95 保留 T8 的这条形态,只换数据源) */ const activeView = computed<"data" | "fill" | "unknown">(() => {
const isSingleForm = computed(() => !viewParam.value.trim() && fillCards.value.length === 1);
const activeView = computed<"fill" | "data" | "stats" | "unknown">(() => {
const v = viewParam.value.trim(); const v = viewParam.value.trim();
if (!v || v === "fill") return "fill"; if (v === "data") return isAdminView.value ? "data" : "unknown";
if (v === "data") return showDataTab.value ? "data" : "unknown"; if (v === "fill") return "fill";
if (v === "stats") return showStatsTab.value ? "stats" : "unknown"; if (!v) return isAdminView.value ? "data" : "fill";
return "unknown"; return "unknown";
}); });
const currentTitle = computed(() => { /** 单表单直落:填报者视图下只有一个可填项且地址没带子视图段 */
if (activeView.value === "data") return "数据查看"; const isSingleForm = computed(() => !isAdminView.value && !viewParam.value.trim() && fillCards.value.length === 1);
if (activeView.value === "stats") return "统计";
if (activeView.value === "fill") return "填报"; const currentTitle = computed(() => (activeView.value === "data" ? "数据查看" : "填报"));
return "应用表单";
});
async function loadAll() { async function loadAll() {
if (!appCode.value) { if (!appCode.value) {
@@ -81,24 +61,23 @@ async function loadAll() {
loading.value = true; loading.value = true;
errorText.value = ""; errorText.value = "";
try { try {
fillRows.value = (await listFillableForms(appCode.value)) || []; const [fillable, managed] = await Promise.all([
listFillableForms(appCode.value),
listManagedForms(appCode.value)
]);
fillRows.value = fillable || [];
managedRows.value = managed || [];
} catch (e: any) { } catch (e: any) {
fillRows.value = []; fillRows.value = [];
errorText.value = e?.message || "可填清单取不到"; managedRows.value = [];
errorText.value = e?.message || "该应用的清单取不到";
} finally { } finally {
loading.value = false; loading.value = false;
} }
// 导出锚点那棵树:失败或空都按"判不出锚点=不给导出按钮"处理(fail-closed),
// 刻意不并进 errorText——它取不到不影响填报/数据/统计三档壳的成立
try {
rawTree.value = (await baseApi.getUserMod(appCode.value).send()) || [];
} catch {
rawTree.value = [];
}
} }
/** tab 点击:填报段回到应用入口(保留单表单直落判定),其余段按名分派 */ /** tab 点击:空段=按视图默认档,其余段按名分派 */
function gotoView(seg: "" | "data" | "stats") { function gotoView(seg: "" | "data" | "fill") {
router.push(seg ? `/app/${appCode.value}/${seg}` : `/app/${appCode.value}/`); router.push(seg ? `/app/${appCode.value}/${seg}` : `/app/${appCode.value}/`);
} }
@@ -108,7 +87,19 @@ function goFill(row: FillableForm) {
function fillPathText(row: FillableForm): string { function fillPathText(row: FillableForm): string {
const target = fillRouteOf(row); const target = fillRouteOf(row);
return `#${target.path}${target.query.appCode ? `?appCode=${target.query.appCode}` : ""}`; return `${location.origin}${location.pathname}#${target.path}${target.query.appCode ? `?appCode=${target.query.appCode}` : ""}`;
}
/** 复制填报路径(§5.1 管理员视图动作):单表单直落地址即分享单元(code 寻址 + 会话) */
async function copyFillPath(row: FillableForm) {
const text = fillPathText(row);
try {
await navigator.clipboard.writeText(text);
Message.success("填报路径已复制:" + text);
} catch {
// 非 https/无剪贴板权限时 clipboard 会拒——降级成把地址摊在提示里让人手抄,不静默失败
Message.warning(`当前环境不允许自动复制,请手动复制:${text}`);
}
} }
onMounted(loadAll); onMounted(loadAll);
@@ -121,33 +112,46 @@ watch(appCode, () => loadAll());
<div class="app-head"> <div class="app-head">
<span class="app-tag">快应用</span> <span class="app-tag">快应用</span>
<code class="app-code">{{ appCode || "(空)" }}</code> <code class="app-code">{{ appCode || "(空)" }}</code>
<span class="toolbar-note">清单按目标应用取(GET /api/form/my/fillable?appCode=),导航壳按身份渲染,范围以后端为准</span> <span class="toolbar-note">
清单按目标应用取(managed + fillable 两支),视图分岔只看 ALL 档,壳不自行判权
</span>
<Button icon="md-refresh" size="small" :loading="loading" @click="loadAll">刷新</Button> <Button icon="md-refresh" size="small" :loading="loading" @click="loadAll">刷新</Button>
</div> </div>
<Alert v-if="errorText" show-icon type="error" class="region-tip">{{ errorText }}</Alert> <Alert v-if="errorText" show-icon type="error" class="region-tip">{{ errorText }}</Alert>
<!-- 导航壳:三档由身份决定(不是由模块树里有什么决定) --> <!-- 两视图导航:管理员才有「数据查看」档;填报档两种视图都在 -->
<div class="light-nav"> <div class="light-nav">
<span class="nav-live"> <span class="nav-live">
<a v-if="isAdminView" :class="['nav-item', { active: activeView === 'data' }]" href="javascript:;"
@click="gotoView('data')">数据查看</a>
<a :class="['nav-item', { active: activeView === 'fill' }]" href="javascript:;" <a :class="['nav-item', { active: activeView === 'fill' }]" href="javascript:;"
@click="gotoView('')">填报({{ fillCards.length }})</a> @click="gotoView('fill')">填报({{ fillCards.length }})</a>
<a v-if="showDataTab" :class="['nav-item', { active: activeView === 'data' }]" href="javascript:;" </span>
@click="gotoView('data')">数据管理</a> <span class="toolbar-note">
<a v-if="showStatsTab" :class="['nav-item', { active: activeView === 'stats' }]" href="javascript:;" {{ isAdminView ? "你在该应用下有 ALL 档的表单(创建人或表单管理员)" : "你在这里只是填报人——没有可管理的数据" }}
@click="gotoView('stats')">统计</a>
</span> </span>
<span class="toolbar-note">数据管理/统计两档按你在这些表单上的层级出现;导出按钮仍按该应用的模块树判</span>
</div> </div>
<h5 class="section-title">{{ currentTitle }}</h5> <h5 class="section-title">{{ currentTitle }}</h5>
<!-- 数据/统计视图复用中心管理端组件:定义管理权不在快应用,codes 来自可填清单(接口驱动,P95); <!-- 管理员视图:数据列表(内嵌 Data,候选=managed 的 code)+ 复制填报路径。
embedded 让它只渲染本体,避免与本页白卡/内边距叠成两层; 「添加(代录)」与「导入」在 T18 接进这一档(同一条 submit 管线、同一 ALL 档守卫)。 -->
modules 把**本页拉到的那棵应用树**传进去——导出按钮的锚点判定吃它,不吃中心会话树(P76) --> <template v-if="activeView === 'data'">
<Data v-if="activeView === 'data'" context="app" :app-code="appCode" :codes="dataCodes" <div class="manage-rows">
:modules="rawTree" embedded /> <div v-for="row in managedRows" :key="row.code" class="manage-row">
<Stats v-else-if="activeView === 'stats'" context="app" :app-code="appCode" :codes="statsCodes" embedded /> <div class="manage-main">
<div class="manage-name">{{ row.name }}</div>
<code class="manage-path">{{ fillPathText(row) }}</code>
<span class="toolbar-note">
来源:{{ (row.role || []).map((r) => (r === 'creator' ? '创建人' : '表单管理员')).join('、') }}
</span>
</div>
<Button size="small" icon="md-copy" @click="copyFillPath(row)">复制填报路径</Button>
</div>
</div>
<Data v-if="adminCodes.length" context="app" :app-code="appCode" :codes="adminCodes" embedded />
</template>
<template v-else-if="activeView === 'fill'"> <template v-else-if="activeView === 'fill'">
<!-- 单表单直落:只有一个可填项时不叠列表,内容区直接给这张表的填报入口 --> <!-- 单表单直落:只有一个可填项时不叠列表,内容区直接给这张表的填报入口 -->
<template v-if="isSingleForm"> <template v-if="isSingleForm">
@@ -155,13 +159,11 @@ watch(appCode, () => loadAll());
单表单直落:该应用在你名下只有一个可填入口,内容区直接给它,不再叠一层导航。 单表单直落:该应用在你名下只有一个可填入口,内容区直接给它,不再叠一层导航。
<br />填报目标:<code>{{ fillPathText(fillCards[0]) }}</code> <br />填报目标:<code>{{ fillPathText(fillCards[0]) }}</code>
</Alert> </Alert>
<div class="fill-mount">
<div class="fill-hint"> <div class="fill-hint">
直接进入填报页: 直接进入填报页:
<a href="javascript:;" @click="goFill(fillCards[0])">{{ fillCards[0].name }}</a> <a href="javascript:;" @click="goFill(fillCards[0])">{{ fillCards[0].name }}</a>
(填报页本体是 T7 交付的 /f/:code,本页不复制一份) (填报页本体是 T7 交付的 /f/:code,本页不复制一份)
</div> </div>
</div>
</template> </template>
<div v-else-if="fillCards.length" class="fill-cards"> <div v-else-if="fillCards.length" class="fill-cards">
<div v-for="row in fillCards" :key="row.code" class="fill-card"> <div v-for="row in fillCards" :key="row.code" class="fill-card">
@@ -174,22 +176,20 @@ watch(appCode, () => loadAll());
</div> </div>
<Alert v-else show-icon type="warning" class="region-tip"> <Alert v-else show-icon type="warning" class="region-tip">
「{{ appCode }}」这个应用在你名下当前没有可填报的表单。 「{{ appCode }}」这个应用在你名下当前没有可填报的表单。
<br />表单发布后会按它的<b>面向人群</b>自动出现在这里(面向学生的表出现在学生账号下, <br />表单发布后按它的<b>面向人群</b>自动出现在这里(面向学生的表出现在学生账号下,
面向教师的表出现在教师账号下)——与基础平台的模块行授权无关; 面向教师的表出现在教师账号下)——与基础平台的模块行授权无关;匿名表单(ANONYMOUS)
如果你是教师且这里为空,说明该应用没有面向学生的表单可代填。 不下发给登录会话,持 URL 直达即可。
</Alert> </Alert>
</template> </template>
<Alert v-else show-icon type="warning"> <Alert v-else show-icon type="warning">
这一段路径对你不可见(本页只有 填报 / 数据管理 / 统计 三档,后两档按你在这些表单上的层级出现)。 这一段路径对你不可见(本页只有 数据查看 / 填报 两档,前者要求该应用下有 ALL 档的表单)。
<br />能直接地址进来但看不到档,说明层级不够——数据面与统计的把关在后端,不在这里。 <br />能直接地址进来但看不到档,说明不是这张表的 ALL 档持有者——把关在后端守卫,不在这里。
</Alert> </Alert>
<p class="toolbar-note foot-note"> <p class="toolbar-note foot-note">
V26 前端侧三半句(P95 改版):①无导出授权不画导出按钮——快应用按该应用树里 path 空的"导出" V72 前端侧三半句:①导出/管理按钮按「该表单 ALL 档」显隐(内嵌 Data.vue 自己读 managed 清单,
锚点行判(本页把那棵树原样交给内嵌 Data.vue,判不中就不画,fail-closed); 宿主不再传权限树);②导航不再由模块树生成,按两支清单构造,path 空的权限锚点形态从此不存在;
②导航不再由模块树生成,按身份三档写死,path 空节点自然不进导航; ③接口侧的 403 是另一半,<b>前端藏按钮/藏档不等于授权</b>。
③学生身份只见填报档=层①只给 canFill、不给数据/统计档的直接结果。
接口侧的拒绝是 T4 已验的另一半,<b>前端藏按钮/藏档不等于授权</b>。
</p> </p>
</div> </div>
</div> </div>
@@ -256,10 +256,35 @@ watch(appCode, () => loadAll());
border-left: 3px solid #2d8cf0; border-left: 3px solid #2d8cf0;
padding-left: 8px; padding-left: 8px;
} }
.fill-mount { .manage-rows {
padding: 16px 0; display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 14px;
}
.manage-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 10px 14px;
border: 1px solid #e8eaec;
border-radius: 4px;
background: #fff;
}
.manage-name {
font-size: 13px;
font-weight: 600;
color: #17233d;
}
.manage-path {
display: inline-block;
margin: 2px 8px 2px 0;
color: #808695;
font-size: 12px;
} }
.fill-hint { .fill-hint {
padding: 12px 0;
font-size: 13px; font-size: 13px;
color: #515a6e; color: #515a6e;
} }
+9 -3
View File
@@ -22,7 +22,7 @@ import { resolveForm } from "@/api/module/fill";
import AnswerValueView from "@/components/answer/AnswerValueView.vue"; import AnswerValueView from "@/components/answer/AnswerValueView.vue";
import AnswerEditModal from "@/components/answer/AnswerEditModal.vue"; import AnswerEditModal from "@/components/answer/AnswerEditModal.vue";
import { useDefaultStore } from "@/store"; import { useDefaultStore } from "@/store";
import { hasPage } from "@/utils/modulePerms"; import { managesForm } from "@/store";
import { statusDisplay } from "@/utils/dataValue"; import { statusDisplay } from "@/utils/dataValue";
import type { AnswerDetail } from "@/types/data"; import type { AnswerDetail } from "@/types/data";
import type { StatusDefView } from "@/types/form"; import type { StatusDefView } from "@/types/form";
@@ -36,8 +36,14 @@ const detail = ref<AnswerDetail | null>(null);
const loading = ref(false); const loading = ref(false);
const error = ref(""); const error = ref("");
/** 数据管理权(授权模块行判定;填表人身份进来时这里就是 false——V29 前端半句) */ /**
const canManage = computed(() => hasPage(store.modules, "/admin/data")); * 数据管理权(契约 v2 C4②:该表单 ALL 档;`modulePerms.hasPage` 随模块行退役删除)。
* 判据=这张表在 `my/managed` 清单里;填表人身份进来时就是 false(V29 前端半句不变),
* 接口侧的拒绝仍由后端守卫兜底——藏按钮不等于授权。
*/
const canManage = computed(() =>
managesForm(store.managed, detail.value?.appCode, detail.value?.code)
);
const liveStatusDefs = ref<StatusDefView[]>([]); const liveStatusDefs = ref<StatusDefView[]>([]);
const statusModal = ref<{ visible: boolean; target: string }>({ visible: false, target: "" }); const statusModal = ref<{ visible: boolean; target: string }>({ visible: false, target: "" });
const editOpen = ref(false); const editOpen = ref(false);
+12 -20
View File
@@ -13,10 +13,9 @@
* 4. **数据范围以后端为准**:响应里的 `scopes` 只用来把"你现在看到的是哪个范围"讲清楚, * 4. **数据范围以后端为准**:响应里的 `scopes` 只用来把"你现在看到的是哪个范围"讲清楚,
* 前端不据此再过滤一遍(任务书 T8 红线)。 * 前端不据此再过滤一遍(任务书 T8 红线)。
* *
* 快应用上下文(AppContext)复用本组件:传 `context="app"` + `app-code` + `codes` * 快应用上下文(通用壳)复用本组件:传 `context="app"` + `app-code` + `codes`
* (候选来自 `GET /api/form/my/fillable?appCode=` 按层级筛出的 code,经 resolve 拿 uuid—— * (候选由壳按"我管理的表单 ∩ 该应用"算出,经 resolve 拿 uuid——定义管理权不在通用壳里,§3.4)。
* 定义管理权不在快应用上下文里,§3.4;P95 W5 起不再从该应用模块树挑 `/f/` 行) * 导出按钮的显隐不再需要宿主传任何权限载体:本组件自己按 `my/managed` 清单判 ALL 档(契约 v2 §2.4)。
* + `modules`(**同一棵原始模块树**,只给按钮级权限判定用,P76)。
*/ */
import { computed, h, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue"; import { computed, h, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
@@ -25,8 +24,7 @@ import { actionColumn } from "@/utils/tableActions";
import { useElementHeight } from "@/utils/hook/useElementHeight"; import { useElementHeight } from "@/utils/hook/useElementHeight";
import { useFormCandidates } from "@/utils/hook/useFormCandidates"; import { useFormCandidates } from "@/utils/hook/useFormCandidates";
import { dataApi, downloadBlob, exportBlob } from "@/api/module/data"; import { dataApi, downloadBlob, exportBlob } from "@/api/module/data";
import { useDefaultStore } from "@/store"; import { managesForm, useDefaultStore } from "@/store";
import { canExport } from "@/utils/modulePerms";
import AnswerEditModal from "@/components/answer/AnswerEditModal.vue"; import AnswerEditModal from "@/components/answer/AnswerEditModal.vue";
import { import {
cellText, cellText,
@@ -42,7 +40,6 @@ import {
import { scopeLabel, type AnswerDetail, type AnswerRow, type FilterField, type FilterItem, type ListView } from "@/types/data"; import { scopeLabel, type AnswerDetail, type AnswerRow, type FilterField, type FilterItem, type ListView } from "@/types/data";
import type { StatusDefView } from "@/types/form"; import type { StatusDefView } from "@/types/form";
import type { DesignField } from "@/types/formSchema"; import type { DesignField } from "@/types/formSchema";
import type { IModule } from "@/types/Basics";
/** 行视图 + 勾选态(`_checked` 是 view-ui-plus Table 的行内选中字段,见 withSelectionState) */ /** 行视图 + 勾选态(`_checked` 是 view-ui-plus Table 的行内选中字段,见 withSelectionState) */
type RowVM = AnswerRow & { _checked?: boolean }; type RowVM = AnswerRow & { _checked?: boolean };
@@ -54,20 +51,13 @@ const props = withDefaults(
appCode?: string; appCode?: string;
/** app 上下文的候选 code(来自 /api/form/my/fillable 的层级筛取,P95 W5) */ /** app 上下文的候选 code(来自 /api/form/my/fillable 的层级筛取,P95 W5) */
codes?: string[]; codes?: string[];
/**
* 按钮级权限判定的**那棵树**(P76):app 上下文必须由宿主传入目标应用的模块树
* (GetUserMod?appCode= 的结果)——中心会话树里没有快应用的"导出"锚点行,
* 拿它判锚点等于永远判不中(fail-closed 方向:有授权也不画按钮)。
* center 上下文不传,仍按 `store.modules`(会话自己的树)判页面行。
*/
modules?: IModule[] | null;
/** /**
* 被宿主页面内嵌(快应用上下文 /app/:appCode/)时置 true:只渲染内容本体, * 被宿主页面内嵌(快应用上下文 /app/:appCode/)时置 true:只渲染内容本体,
* 不再套一层 `.page-wrapper > .main-container`——否则会出现双层白卡与双份内边距。 * 不再套一层 `.page-wrapper > .main-container`——否则会出现双层白卡与双份内边距。
*/ */
embedded?: boolean; embedded?: boolean;
}>(), }>(),
{ context: "center", appCode: "", codes: () => [], modules: null, embedded: false } { context: "center", appCode: "", codes: () => [], embedded: false }
); );
const route = useRoute(); const route = useRoute();
@@ -446,12 +436,14 @@ async function submitBatchStatus() {
// ==================== 导出 ==================== // ==================== 导出 ====================
/** /**
* 权限判定用的树(P76):app 上下文=宿主编进来的**目标应用树**(快应用的"导出"锚点行在这里), * 导出按钮显隐(契约 v2 §2.4:`modulePerms.canExport` 随模块行退役删除):
* center 上下文=会话自己的树(按 `/admin/data` 页面行判,手册 §2.2 中心表没有导出行)。 * 判据换成"<b>该表单 ALL 档持有</b>"的清单形态——当前选中的这张表在 `my/managed` 里,就画导出。
* 两种上下文各自吃自己的树,`canExport` 的纯函数判据不变。 * 中心与快应用两种上下文从此**同一条判据**(不再有"两棵树"的差别,B13/P78 遗迹清零);
* 接口侧的 403 兜底仍在后端守卫,这里只决定画不画。
*/ */
const permsTree = computed(() => (props.context === "app" ? props.modules : store.modules)); const exportable = computed(() =>
const exportable = computed(() => canExport(permsTree.value, props.context === "app" ? "app" : "center")); managesForm(store.managed, current.value?.appCode || props.appCode, current.value?.code)
);
const exporting = ref(false); const exporting = ref(false);
function askExport() { function askExport() {