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:
+53
-25
@@ -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
@@ -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
@@ -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
@@ -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 的就是它。
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
+118
-93
@@ -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,12 +159,10 @@ 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">
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user