feat(p95-fill): 可填写换源后端接口,快应用导航壳按身份写死,/f/ 模块行消费退役
build / build (push) Successful in 1m47s

任务书 P95 W4/W5/W6。填报入口收回中心后,前端不再从应用模块树里挑 /f/{code} 行算清单。

- My.vue「可填写」:数据源 store.modules → GET /api/form/my/fillable,只画 canFill=true;
  空态文案改「表单发布后按面向人群自动出现在这里」,不再引导去 UserBase 配模块行
- AppContext.vue 整页改版:清单吃 fillable?appCode=,三档按 myTier 渲染
  (填报恒显 + 单表单直落保留 / 数据管理 MANAGE∪ADMIN / 统计 ADMIN);
  GetUserMod?appCode 仍拉一次,唯一用途=交给内嵌 Data.vue 判导出锚点(P76 机制原样),
  空树整页告警与「树里没有 /f/ 行」分支退役——取树失败只等于不画导出按钮(fail-closed)
- fillLink 新增 fillRouteOf(与 buildFillLink 同一条 appCode 规则,点进去与分享出去同址);
  modulePerms 删 fillCodesOfModules(唯一消费者已退役),canExport/anchorRows/hasPage 保留勿伤;
  menuRoute 的 /f/ 解析与门户导航未动(分享链接、P87 探针照旧)
- mock:快应用树改四行终态(删填报行),新增 /api/form/my/fillable 桩覆盖 FILL/MANAGE/ADMIN
- vue-tsc --noEmit 与 vite build 双绿;p87 探针由当前源码重打包后 15/15 复跑绿
This commit is contained in:
chenxing
2026-10-16 12:44:42 +08:00
parent 777273e3a5
commit 48e46e367b
11 changed files with 300 additions and 193 deletions
+12 -1
View File
@@ -6,7 +6,7 @@
*/
import { alovaInstance } from "@/utils/alova/alova-iview";
import type { ResolvedForm, SubmitRequest, SubmitView, MyAnswerItem } from "@/types/answer";
import type { AnswerDetail, MyAnswerViewItem } from "@/types/data";
import type { AnswerDetail, FillableForm, MyAnswerViewItem } from "@/types/data";
/** 解析当前发布版本(契约 §1:入口寻址) */
export function resolveForm(code: string, appCode?: string) {
@@ -55,3 +55,14 @@ export function listAnswersAboutMe() {
export function getMyAnswerDetail(answerId: string) {
return alovaInstance.Get<AnswerDetail>("/api/form/my/detail", { params: { answerId } }).send();
}
// ==================== 可填写列表(P95 W3/W4,/my/fillable) ====================
// 数据源=后端按 P95 三层模型解析的**填报入口**清单,不再是会话菜单树里挑 /f/ 行(P91 的形态)。
// appCode 可选:缺省=全部应用(中心 + 快应用),快应用上下文传自身 appCode 收窄。
/** 我可填的表单(入口层非 NONE 的都下发;本人能不能填看 canFill) */
export function listFillableForms(appCode?: string) {
const params: Record<string, string> = {};
if (appCode) params.appCode = appCode;
return alovaInstance.Get<FillableForm[]>("/api/form/my/fillable", { params }).send();
}
+8 -15
View File
@@ -82,25 +82,18 @@ const OWN_MODULES: IModule[] = [
];
/**
* 第一个快应用的模块树(方案 §6.2 表的典型配法,T9 演练桩)。
* 第一个快应用的模块树(T9 演练桩,P95 W6 改版为终态形态)。
*
* 只在 `appCode=fc-drill` 时下发,用来在本机把 **P76(导出锚点按应用树判)/ P77(应用内详情形态)/
* V19(快应用独立模块树渲染左导航)** 跑到可实拍的程度——真会话取证要等 H2/H4(执行手册 §2 人工项)。
* 只在 `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: 9201,
modId: 9201,
name: "填报",
parentId: 0,
page: "/f/drill-honour",
level: 1,
icon: "edit",
orderNumber: 1,
usable: true,
children: []
},
{
id: 9202,
modId: 9202,
+34 -1
View File
@@ -18,7 +18,7 @@
* 游标(keyset):`cursor`=上一页末行 answerId(uuid),服务端按它定位后取下一页——
* 这里用 uuid 在数组里的下标模拟,形状与后端一致(前端不感知差异)。
*/
import type { AnswerDetail, AnswerRow, BatchItemResult, FilterField, ListView, MyAnswerViewItem, StatsView } from "@/types/data";
import type { AnswerDetail, AnswerRow, BatchItemResult, FilterField, FillableForm, ListView, MyAnswerViewItem, StatsView } from "@/types/data";
const ok = (data: any) => ({ code: 0, msg: "", data });
const bizErr = (code: number, msg: string) => ({ code, msg, data: null });
@@ -317,6 +317,39 @@ export default [
return ok(results);
} },
// ==================== 可填写入口清单(P95 W3/W4,/my/fillable) ====================
/**
* 三行种子刻意覆盖三层模型的可观测差异(冒烟脚本 p95-fill-scope-smoke.sh 就断言这三件事):
* ① FILL:学生面向、canFill=true → 「可填写」画卡;
* ② MANAGE:学生面向、canFill=false(教师那种"只能代填")→ 「可填写」<b>不</b>画卡,
* 但快应用上下文的数据管理 tab 要靠它出现;
* ③ ADMIN:教师面向、canFill=true + 最高层 → 统计 tab 只由它决定。
* appCode 传值时按该应用收窄(缺省=全部应用),与后端 FillableFormService 同一口径。
*/
{ url: "/api/form/my/fillable", method: "get", response: ({ query }: any) => {
const rows: FillableForm[] = [
{ code: "honor_apply", name: "三好学生申报", appCode: APP, audience: 1,
deadline: "2026-12-31 23:59:59", fillLimit: 1, groupTag: null, canFill: true, myTier: "FILL" },
{ code: "home_visit", name: "家访登记(教师代填)", appCode: APP, audience: 1,
deadline: null, fillLimit: 0, groupTag: "五年级", canFill: false, myTier: "MANAGE" },
{ code: "teacher_survey", name: "教师问卷", appCode: APP, audience: 2,
deadline: "2026-11-30 23:59:59", fillLimit: 0, groupTag: null, canFill: true, myTier: "ADMIN" },
{ code: "drill-honour", name: "荣誉申报(演练应用)", appCode: "fc-drill", audience: 1,
deadline: "2026-10-31 23:59:59", fillLimit: 0, groupTag: null, canFill: true, myTier: "FILL" },
{ code: "drill_roster", name: "花名册(演练应用,教师代填)", appCode: "fc-drill", audience: 1,
deadline: null, fillLimit: 0, groupTag: null, canFill: false, myTier: "MANAGE" }
];
// 排序沿后端口径:deadline 升序、不限截止在后、同档按 code
const app = String(query?.appCode || "").trim();
const hit = app ? rows.filter((r) => r.appCode === app) : rows;
const sorted = [...hit].sort((a, b) => {
const da = a.deadline || "9999";
const db = b.deadline || "9999";
return da === db ? a.code.localeCompare(b.code) : da.localeCompare(db);
});
return ok(sorted);
} },
// ==================== 我的填写两视图(V18) ====================
{ url: "/api/form/my/submit-list", method: "get", response: () => {
const items: MyAnswerViewItem[] = seed
+2 -2
View File
@@ -130,8 +130,8 @@ const routes: RouteRecordRaw[] = [
meta: { title: "关于我的", parentPath: "/my" },
},
{
// P91 可填表单列表:数据源=会话菜单树里的 /f/ 行(授权语义在后端,前端不自行过滤)。
// 子页面路由不占模块行、不进导航(手册 §2.2 注记),入口在 My 容器顶部 Tab。
// P91 起第三视图;P95 W4 换数据源=GET /api/form/my/fillable(中心按三层模型解析的填报入口,
// 不再从会话菜单树挑 /f/ 行)。子页面路由不占模块行、不进导航(手册 §2.2 注记),入口在 My 容器顶部 Tab。
path: "/my/fillable",
name: "fc_my_fillable",
component: () => import("@/views/My.vue"),
+24
View File
@@ -126,6 +126,30 @@ export interface MyAnswerViewItem {
submitTime: string;
}
/**
* 「可填写」列表的行(P95 W3/W4,`GET /api/form/my/fillable`)。
*
* <p>这不是答卷行,是**表单入口**行:后端按 P95 三层模型逐张表算出"这个人在这张表上算哪一层",
* NONE 的行不下发。`canFill` 与 `myTier` 是两件事——`canFill=false` 且 `myTier="MANAGE"`
* 就是"教师×面向学生"那种只能代填的表:「可填写」列表把它筛掉,但快应用上下文的
* 数据管理 tab 仍要它(AppContext.vue 按 myTier 决定 tab 显隐)。</p>
*/
export interface FillableForm {
code: string;
name: string;
appCode: string;
/** 面向人群:1 学生 / 2 教师(与定义域 FormView 同一个数字口径) */
audience: number;
/** yyyy-MM-dd HH:mm:ss;null=不限截止 */
deadline: string | null;
/** 限填次数:0 不限 / 1 限一次 */
fillLimit: number;
groupTag: string | null;
canFill: boolean;
/** 并集最高层:ADMIN > MANAGE > FILL */
myTier: "ADMIN" | "MANAGE" | "FILL";
}
/** 数据范围闭集四枚举的展示名(契约 §8;与后端 FormEnums.DataScope 的 label 同源) */
export const DATA_SCOPE_LABELS: Record<string, string> = {
ALL: "全部",
+18
View File
@@ -21,6 +21,24 @@ export function buildFillLink(form: { code: string; appCode?: string | null }):
return `${base}#/f/${encodeURIComponent(form.code)}${query}`;
}
/**
* 表单 → 站内跳转的路由对象(P95 W4/W5:「可填写」列表与快应用导航壳都点这一条)。
*
* <p>与 {@link buildFillLink} <b>共用同一条 appCode 规则</b>(中心表单不带 query、快应用带),
* 只是不拼绝对地址——复制出去的链接和点进去的路径必须是同一个地址,
* 否则"分享给同事"和"自己点"会解析到两张表。</p>
*/
export function fillRouteOf(form: { code: string; appCode?: string | null }): {
path: string;
query: Record<string, string>;
} {
const appCode = String(form.appCode ?? "").trim();
return {
path: `/f/${encodeURIComponent(form.code)}`,
query: appCode && appCode !== DEFAULT_APP_CODE ? { appCode } : {}
};
}
/**
* 复制文本到剪贴板:navigator.clipboard 优先(安全上下文),失败回退旧系统同款
* 临时 textarea + execCommand(http 内网环境/旧内核兜底)。返回是否成功。
+4 -3
View File
@@ -1,10 +1,11 @@
/**
* 数据面页面的"选哪张表单"共用件(数据列表 / 统计页两处同形,快应用上下文第三种候选来源)。
*
* 两条来路都是<b>合规</b>的,区别只在账号被授权了哪一行模块:
* 两条来路都是<b>合规</b>的,区别只在清单从哪儿取:
* - 中心上下文:`/api/form/admin/list`(要"表单设计"模块行)→ 一次拿全本校表单清单;
* - 快应用上下文:只认该应用模块树里的 `/f/{code}` 行,逐个 `resolve` 换 uuid
* (定义编辑权不在快应用,方案 §3.4;而 resolve 是读接口的唯一入口,契约 §1)。
* - 快应用上下文:宿主(AppContext)把 `GET /api/form/my/fillable?appCode=` 里按层级筛出的 code
* 传进来(P95 W5;原"从该应用模块树挑 `/f/{code}` 行"的形态已退役),
* 逐个 `resolve` 换 uuid(定义编辑权不在快应用,方案 §3.4;而 resolve 是读接口的唯一入口,契约 §1)。
*
* 清单取不到(只授权数据管理、没授权表单设计的账号)时**不假装是空列表**:
* 报错文案留给页面显示,并留一条"按 code 直达"的口子——它同样只经 resolve,不绕过任何守卫。
-14
View File
@@ -64,20 +64,6 @@ export function canExport(tree: IModule[] | null | undefined, context: DataExpor
return hasPage(tree, "/admin/data");
}
/**
* 快应用树里的填报目标(模块行 path 形如 `/f/{code}`,方案 §6.2 表第一行)。
* 数据/统计页要的是表单 uuid,而 uuid 只能由 code 经 resolve 得到(契约 §1"resolve 是读接口的唯一入口")——
* 快应用上下文不授权定义管理行,拿不到表单列表,这条是唯一的合规取数路径。
*/
export function fillCodesOfModules(tree: IModule[] | null | undefined): string[] {
const codes: string[] = [];
for (const p of authorizedPaths(tree)) {
const m = /^\/f\/([^/?#]+)/.exec(p);
if (m && !codes.includes(m[1])) codes.push(m[1]);
}
return codes;
}
/**
* 受限身份可见集的来源(V26 第三半句的前端侧口径):
* 学生身份只见"填报"路由 = 导航树里只有填报行——这是模块授权的直接结果,
+141 -113
View File
@@ -1,68 +1,71 @@
<script setup lang="ts">
/**
* 快应用上下文 /app/:appCode/(T8 落地,替换 T5 占位)。
* 快应用上下文 /app/:appCode/(T8 落地,P95 W5 改版:导航壳按身份写死)。
*
* 形态(方案 §6.2 纯 A 形态 + 任务书 T8"快应用上下文"):
* 形态(方案 §6.2 纯 A 形态 + P95 三层模型):
* 快应用 = UserBase 真实应用行 + 无自有后端 + pc_url → 本路由。
* - **模块树**:经 `/api/base/GetUserMod?appCode=<目标应用>` 取**那个应用**的树。H4 未上线时
* 后端返回空树 + warn(T1 已验收的兜底,P3 裁决)——本页照此显示空态,**绝不回落本应用菜单**。
* - **范围矩阵**:受限身份可见集**以后端为准**,本页不自行按角色过滤(任务书 T8 红线)——
* 树里有什么就是什么,页面只做"单表单直落 vs 应用聚合轻导航"的形态分派。
* - **单表单直落无导航**:树里只有一个可跳转填报行(`/f/{code}`)→ 直接渲染填报页,无轻导航。
* - **应用聚合带轻导航**:树里有多个可跳转行 → 顶部一排轻导航(按 path 分派),内容区渲染对应视图。
*
* V26 三半句的前端侧(模块把关):
* ① 无导出授权者不渲染导出按钮 → `canExport(tree, "app")`,其中 **tree 是本页拉到的那棵应用树**,
* 经 `modules` prop 下传给内嵌的 Data.vue(P76:中心会话树里没有快应用的"导出"锚点行,
* 拿它判锚点永远判不中——fail-closed 方向,有授权也不画按钮);
* ② 导航不渲染 path 空节点 → 树构建走 `buildNavTree`(menuNav 已跳过锚点行,本页复验);
* ③ 学生身份只见填报路由 → 树里只有 `/f/{code}` 行时,导航自然只有填报项(后端授权的直接结果,
* 前端不再自己按角色过滤)。
*
* path → 视图分派(快应用模块行的 path 形态,方案 §6.2 表):
* `/f/{code}` → 填报(渲染单表单,或跳中心填报页)
* `/app/{appCode}/data` → 数据管理(复用 Data.vue,codes 来自树里的填报行)
* `/app/{appCode}/stats`→ 统计(复用 Stats.vue)
* 其余(含导入这类 path 空锚点)→ 不渲染为可跳转项(menuNav 已过滤 path 空)
* - **清单来源**:`GET /api/form/my/fillable?appCode=<目标应用>`——中心按
* `audience × 身份 × 管理员两表` 解析出的填报入口。**不再吃该应用的模块树**,
* 发一张表单不需要超管加一条 `/f/{code}` 模块行(P95 §0 病根第 1 条就此拆掉)。
* - **导航壳按身份渲染**(三档写死,不再由"树里有什么"决定):
* · 填报 tab <b>恒显</b>:`canFill=true` 的行;0 行=空态;恰 1 行且地址没带子视图段=
* <b>单表单直落</b>形态保留(内容区直接给这张表的填报入口,不叠导航);
* · 数据管理 tab:存在 `myTier∈{MANAGE, ADMIN}` 的行时显,`:codes` 喂接口结果;
* · 统计 tab:存在 `myTier=ADMIN` 的行时显,codes 同上。
* - **"范围以后端为准"这条红线不动**:tab 只决定画不画;进去能看哪些行仍由后端
* (`DataScopeService` + `FormAdminGuard.requireDataView`)把关,前端不按角色再筛一遍(任务书 T8 红线)。
* - **模块树仍然拉一次**,唯一用途=把原始树经 `modules` prop 下传给内嵌 Data.vue 判"导出"锚点
* (P76:中心会话树里没有快应用的锚点行,拿错树永远判不中)。空树/取树失败=fail-closed 不画导出按钮,
* <b>不再整页报"这个应用进不来"</b>——原"空树"告警与"树里没有 /f/ 行"的分支随这条依赖收敛而退役。
*/
import { computed, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { baseApi } from "@/api/module/base";
import { buildNavTree, flattenNav, type NavNode } from "@/utils/menuNav";
import { resolveMenuPage } from "@/utils/menuRoute";
import { fillCodesOfModules } from "@/utils/modulePerms";
import { listFillableForms } from "@/api/module/fill";
import { fillRouteOf } from "@/utils/fillLink";
import Data from "@/views/admin/Data.vue";
import Stats from "@/views/admin/Stats.vue";
import type { FillableForm } from "@/types/data";
import type { IModule } from "@/types/Basics";
const route = useRoute();
const router = useRouter();
const appCode = computed(() => String(route.params.appCode ?? ""));
/** 子视图段(/app/:appCode/:view):空=由树决定单表单直落或落默认聚合视图 */
/** 子视图段(/app/:appCode/:view):空=填报(单表单直落或多表单列表) */
const viewParam = computed(() => String(route.params.view ?? ""));
const fillRows = ref<FillableForm[]>([]);
/** 目标应用的原始模块树:本页只把它交给内嵌 Data.vue 判导出锚点(P76),不再拿它算导航 */
const rawTree = ref<IModule[]>([]);
const navTree = ref<NavNode[]>([]);
const loading = ref(false);
const errorText = ref("");
const emptyTree = ref(false);
/** 树里的填报目标 code(数据/统计视图靠它 resolve 出 uuid——快应用不授权定义管理行,§3.4) */
const fillCodes = computed(() => fillCodesOfModules(rawTree.value));
/** 填报 tab 的行:后端 canFill 判定(层②"只能代填"的行 canFill=false,不进这张列表) */
const fillCards = computed(() => fillRows.value.filter((r) => r.canFill));
/** 数据管理候选=层②∪层③;统计候选=层③(与后端 FillAccessService 的三个谓词一一对应) */
const manageRows = computed(() => fillRows.value.filter((r) => r.myTier === "MANAGE" || r.myTier === "ADMIN"));
const adminRows = computed(() => fillRows.value.filter((r) => r.myTier === "ADMIN"));
/** 可跳转导航项(path 空锚点已被 menuNav 跳过——V26 第二半句的复验点) */
const navItems = computed(() => flattenNav(navTree.value));
const isSingleForm = computed(() => navItems.value.length === 1 && /^\/f\//.test(navItems.value[0]?.page || ""));
function codesOf(rows: FillableForm[]): string[] {
const codes: string[] = [];
for (const row of rows) if (!codes.includes(row.code)) codes.push(row.code);
return codes;
}
const dataCodes = computed(() => codesOf(manageRows.value));
const statsCodes = computed(() => codesOf(adminRows.value));
const showDataTab = computed(() => dataCodes.value.length > 0);
const showStatsTab = computed(() => statsCodes.value.length > 0);
/** 单表单直落:可填项恰好一个且地址没带子视图段(P95 保留 T8 的这条形态,只换数据源) */
const isSingleForm = computed(() => !viewParam.value.trim() && fillCards.value.length === 1);
/** 当前内容视图:'fill' | 'data' | 'stats' | 'unknown' */
const activeView = computed<"fill" | "data" | "stats" | "unknown">(() => {
const v = viewParam.value.trim();
if (!v) return isSingleForm.value ? "fill" : navItems.value.length ? "data" : "unknown";
if (v === "data") return "data";
if (v === "stats") return "stats";
if (v === "fill") return "fill";
// 模块行里配置的完整 path 段(如 /app/honor/data 的 "data" 已覆盖;未知段按 unknown 呈现)
if (!v || v === "fill") return "fill";
if (v === "data") return showDataTab.value ? "data" : "unknown";
if (v === "stats") return showStatsTab.value ? "stats" : "unknown";
return "unknown";
});
@@ -73,58 +76,46 @@ const currentTitle = computed(() => {
return "应用表单";
});
async function loadTree() {
async function loadAll() {
if (!appCode.value) {
errorText.value = "地址里缺少 appCode";
return;
}
loading.value = true;
errorText.value = "";
emptyTree.value = false;
try {
// 目标应用的模块树:H4 未上线时后端返回空树(P3 口径),这里如实显示空态
const tree: IModule[] = (await baseApi.getUserMod(appCode.value).send()) || [];
rawTree.value = tree;
// 导航树:path 空节点(权限锚点)跳过(menuNav 规则①,V26 第二半句)
navTree.value = buildNavTree(tree, resolveMenuPage);
emptyTree.value = navTree.value.length === 0;
fillRows.value = (await listFillableForms(appCode.value)) || [];
} catch (e: any) {
rawTree.value = [];
navTree.value = [];
errorText.value = e?.message || "目标应用的模块树取不到";
emptyTree.value = true;
fillRows.value = [];
errorText.value = e?.message || "可填清单取不到";
} finally {
loading.value = false;
}
}
/** 轻导航点击:按模块行的 path 分派到本页的内容视图段 */
function gotoNav(item: { page: string }) {
const p = item.page;
if (/^\/f\//.test(p)) {
router.push(p);
return;
// 导出锚点那棵树:失败或空都按"判不出锚点=不给导出按钮"处理(fail-closed),
// 刻意不并进 errorText——它取不到不影响填报/数据/统计三档壳的成立
try {
rawTree.value = (await baseApi.getUserMod(appCode.value).send()) || [];
} catch {
rawTree.value = [];
}
const m = /^\/app\/[^/]+\/(.+)$/.exec(p);
const seg = m ? m[1] : p.replace(/^\//, "");
router.push(`/app/${appCode.value}/${seg}`);
}
/** 树里第一个可跳转项(单表单直落/默认落点用) */
function firstNavPath(): string {
return navItems.value[0]?.page || "";
/** tab 点击:填报段回到应用入口(保留单表单直落判定),其余段按名分派 */
function gotoView(seg: "" | "data" | "stats") {
router.push(seg ? `/app/${appCode.value}/${seg}` : `/app/${appCode.value}/`);
}
onMounted(async () => {
await loadTree();
// 单表单直落:树里只有一个填报行且当前没带子视图段 → 直接把内容区渲染成该表单的填报
// (不跳转,避免把用户从 /app/{appCode}/ 弹到别处;用户看着地址栏仍是应用入口)
if (!viewParam.value && isSingleForm.value) {
// 保持在本页渲染填报(activeView 已算成 fill),无需额外动作
}
});
function goFill(row: FillableForm) {
router.push(fillRouteOf(row));
}
watch(appCode, () => loadTree());
function fillPathText(row: FillableForm): string {
const target = fillRouteOf(row);
return `#${target.path}${target.query.appCode ? `?appCode=${target.query.appCode}` : ""}`;
}
onMounted(loadAll);
watch(appCode, () => loadAll());
</script>
<template>
@@ -133,64 +124,75 @@ watch(appCode, () => loadTree());
<div class="app-head">
<span class="app-tag">快应用</span>
<code class="app-code">{{ appCode || "(空)" }}</code>
<span class="toolbar-note">模块树按目标应用取(GetUserMod?appCode=),范围以后端为准</span>
<Button icon="md-refresh" size="small" @click="loadTree">刷新</Button>
<span class="toolbar-note">清单按目标应用取(GET /api/form/my/fillable?appCode=),导航壳按身份渲染,范围以后端为准</span>
<Button icon="md-refresh" size="small" :loading="loading" @click="loadAll">刷新</Button>
</div>
<Alert v-if="errorText" show-icon type="error" class="region-tip">{{ errorText }}</Alert>
<Alert v-else-if="emptyTree" show-icon type="warning" class="region-tip">
「{{ appCode }}」这个应用当前没有可进入的模块行。
<br />两种可能:① 你的身份没有被授权该应用的任何模块行(受限身份未见集由后端决定);
② 平台侧尚未上线目标应用的模块树查询(UserBase 的 appCode 参数,H4)——届时本接口返回空树,
<b>本页刻意不回落表单中心自己的菜单</b>(回落会把「要 A 应用的菜单」伪装成「A 应用就这些」)。
</Alert>
<!-- 导航壳:三档由身份决定(不是由模块树里有什么决定) -->
<div class="light-nav">
<span class="nav-live">
<a :class="['nav-item', { active: activeView === 'fill' }]" href="javascript:;"
@click="gotoView('')">填报({{ fillCards.length }})</a>
<a v-if="showDataTab" :class="['nav-item', { active: activeView === 'data' }]" href="javascript:;"
@click="gotoView('data')">数据管理</a>
<a v-if="showStatsTab" :class="['nav-item', { active: activeView === 'stats' }]" href="javascript:;"
@click="gotoView('stats')">统计</a>
</span>
<span class="toolbar-note">数据管理/统计两档按你在这些表单上的层级出现;导出按钮仍按该应用的模块树判</span>
</div>
<template v-else>
<!-- 应用聚合:多于一个可跳转行时给轻导航(单表单直落时不出导航) -->
<div v-if="!isSingleForm && navItems.length" class="light-nav">
<span class="nav-live">
<a v-for="item in navItems" :key="item.id" href="javascript:;"
:class="['nav-item', { active: route.path === item.page }]" @click="gotoNav(item)">
{{ item.name }}
</a>
</span>
<span class="toolbar-note">path 空的权限锚点行(如"导出")不进导航</span>
</div>
<h5 class="section-title">{{ currentTitle }}</h5>
<h5 class="section-title">{{ currentTitle }}</h5>
<!-- 数据/统计视图复用中心管理端组件:定义管理权不在快应用,codes 只来自模块树的填报行;
embedded 让它只渲染本体,避免与本页的白卡/内边距叠成两层;
modules 把**本页刚拉到的那棵应用树**传进去——导出按钮的锚点判定吃它,不吃中心会话树(P76) -->
<Data v-if="activeView === 'data'" context="app" :app-code="appCode" :codes="fillCodes"
:modules="rawTree" embedded />
<Stats v-else-if="activeView === 'stats'" context="app" :app-code="appCode" :codes="fillCodes" embedded />
<template v-else-if="activeView === 'fill'">
<!-- 数据/统计视图复用中心管理端组件:定义管理权不在快应用,codes 来自可填清单(接口驱动,P95);
embedded 让它只渲染本体,避免与本页白卡/内边距叠成两层;
modules 把**本页拉到的那棵应用树**传进去——导出按钮的锚点判定吃它,不吃中心会话树(P76) -->
<Data v-if="activeView === 'data'" context="app" :app-code="appCode" :codes="dataCodes"
:modules="rawTree" embedded />
<Stats v-else-if="activeView === 'stats'" context="app" :app-code="appCode" :codes="statsCodes" embedded />
<template v-else-if="activeView === 'fill'">
<!-- 单表单直落:只有一个可填项时不叠列表,内容区直接给这张表的填报入口 -->
<template v-if="isSingleForm">
<Alert show-icon type="info">
单表单直落:该应用只授权了一个填报行,内容区直接渲染填报,不再叠一层导航。
<br />填报目标:<code>{{ firstNavPath() || "(树里没有 /f/{code} 行)" }}</code>
单表单直落:该应用在你名下只有一个可填入口,内容区直接给它,不再叠一层导航。
<br />填报目标:<code>{{ fillPathText(fillCards[0]) }}</code>
</Alert>
<div class="fill-mount">
<div class="fill-hint">
直接进入填报页:
<a href="javascript:;" @click="firstNavPath() && router.push(firstNavPath())">
{{ firstNavPath() || "—" }}
</a>
<a href="javascript:;" @click="goFill(fillCards[0])">{{ fillCards[0].name }}</a>
(填报页本体是 T7 交付的 /f/:code,本页不复制一份)
</div>
</div>
</template>
<Alert v-else show-icon type="warning">
该应用模块行里的这一段路径没有对应视图(本页只认识 data / stats / fill 三种,
以及 path 形如 /f/{code} 的填报行)。请核对平台侧的模块行配置。
<div v-else-if="fillCards.length" class="fill-cards">
<div v-for="row in fillCards" :key="row.code" class="fill-card">
<div class="fill-card-main">
<div class="fill-card-name">{{ row.name }}</div>
<code class="fill-card-path">{{ fillPathText(row) }}</code>
</div>
<Button type="primary" @click="goFill(row)">进入填报</Button>
</div>
</div>
<Alert v-else show-icon type="warning" class="region-tip">
「{{ appCode }}」这个应用在你名下当前没有可填报的表单。
<br />表单发布后会按它的<b>面向人群</b>自动出现在这里(面向学生的表出现在学生账号下,
面向教师的表出现在教师账号下)——与基础平台的模块行授权无关;
如果你是教师且这里为空,说明该应用没有面向学生的表单可代填。
</Alert>
</template>
<Alert v-else show-icon type="warning">
这一段路径对你不可见(本页只有 填报 / 数据管理 / 统计 三档,后两档按你在这些表单上的层级出现)。
<br />能直接地址进来但看不到档,说明层级不够——数据面与统计的把关在后端,不在这里。
</Alert>
<p class="toolbar-note foot-note">
V26 前端侧三半句:①无导出授权不画导出按钮(快应用按 path 空的"导出"锚点行判)——
导出按钮在数据视图内按该规则显隐;②导航不渲染 path 空节点;③学生身份只见填报路由(树里授权的直接结果)。
接口侧的拒绝是 T4 已验的另一半,前端藏按钮不等于授权。
V26 前端侧三半句(P95 改版):①无导出授权不画导出按钮——快应用按该应用树里 path 空的"导出"
锚点行判(本页把那棵树原样交给内嵌 Data.vue,判不中就不画,fail-closed);
②导航不再由模块树生成,按身份三档写死,path 空节点自然不进导航;
③学生身份只见填报档=层①只给 canFill、不给数据/统计档的直接结果。
接口侧的拒绝是 T4 已验的另一半,<b>前端藏按钮/藏档不等于授权</b>。
</p>
</div>
</div>
@@ -264,6 +266,32 @@ watch(appCode, () => loadTree());
font-size: 13px;
color: #515a6e;
}
.fill-cards {
display: flex;
flex-direction: column;
gap: 12px;
}
.fill-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 18px;
border: 1px solid #e8eaec;
border-radius: 4px;
background: #fff;
}
.fill-card-name {
font-size: 14px;
color: #17233d;
font-weight: 600;
}
.fill-card-path {
color: #808695;
font-size: 12px;
margin-top: 4px;
display: inline-block;
}
.foot-note {
display: block;
margin-top: 20px;
+53 -41
View File
@@ -9,10 +9,11 @@
* 两者是**两个端点、两个谓词**,页面按路由分派;顶部 Tab 只是 UI 导航(切路由),不是接口参数。
* 管理员代填后:这条答卷在**填表人的"我提交的"**里出现,同时在**被代填人的"关于我的"**里出现(V18)。
*
* P91 起第三视图「可填写」(/my/fillable):这不是答卷视图,是**填报入口列表**——
* 数据源=会话菜单树里 page 形如 /f/{code} 的行(App.vue 已拉过、store.modules 存原件),
* 复用 buildNavTree+flattenNav 同一套解析(与左侧导航同口径,天然吃到 menuRoute 的修复)。
* **不得在此按角色过滤**:树里有什么就是什么,授权语义在后端(任务书 T8 红线同源)。
* P91 起第三视图「可填写」(/my/fillable),P95 W4 换数据源:这不再是答卷视图,而是**填报入口列表**。
* 数据源从"会话菜单树里挑 path 形如 /f/{code} 的行"改成 `GET /api/form/my/fillable`——
* 谁能填哪张表由中心按三层模型(audience × 身份 × 管理员两表)自解析,
* UserBase 侧不再需要逐表单配 `/f/` 模块行(任务书 P95 §0.1/§0.2 红线 6:分享链接与直链解析不动)。
* 前端照旧**不做本地角色过滤**:接口下发 canFill=true 的才画卡(canFill 是后端算的,不是我筛的)。
*
* 三条交互口径沿 T7 的"我的提交"段(不另造轮子):
* 1. **编辑入口只在"我提交的"**:编辑=填表人的动作;"关于我的"整列只读
@@ -30,14 +31,13 @@ import { useRoute, useRouter } from "vue-router";
import { Message, Modal } from "view-ui-plus";
import { actionColumn } from "@/utils/tableActions";
import { useElementHeight } from "@/utils/hook/useElementHeight";
import { deleteMyAnswer, getMyAnswerDetail, listAnswersAboutMe, listMySubmitted } from "@/api/module/fill";
import { deleteMyAnswer, getMyAnswerDetail, listAnswersAboutMe, listFillableForms, listMySubmitted } from "@/api/module/fill";
import AnswerEditModal from "@/components/answer/AnswerEditModal.vue";
import AnswerValueView from "@/components/answer/AnswerValueView.vue";
import { statusDisplay } from "@/utils/dataValue";
import { buildNavTree, flattenNav, type FlatNav } from "@/utils/menuNav";
import { resolveMenuPage } from "@/utils/menuRoute";
import { fillRouteOf } from "@/utils/fillLink";
import { useDefaultStore } from "@/store";
import type { AnswerDetail, MyAnswerViewItem } from "@/types/data";
import type { AnswerDetail, FillableForm, MyAnswerViewItem } from "@/types/data";
const route = useRoute();
const router = useRouter();
@@ -66,9 +66,9 @@ const VIEW_META = {
},
fillable: {
title: "可填写",
endpoint: "数据源:会话菜单树(GetUserMod)",
predicate: "谓词:模块行 path 形如 /f/{code}(授权在后端)",
note: "当前账号被授权的填报入口。列表由会话菜单树直接推导——树里没有的行这里不出现,也不做本地角色过滤。"
endpoint: "GET /api/form/my/fillable",
predicate: "谓词:后端按三层模型解析出的填报入口(canFill=true 才画卡)",
note: "这里是当前账号能填报的表单。表单发布后按面向人群自动出现在这里——不需要有人在基础平台逐张表配模块行。"
}
} as const;
@@ -78,10 +78,19 @@ const regionRef = ref<HTMLElement | null>(null);
const regionH = useElementHeight(regionRef);
const rows = ref<MyAnswerViewItem[]>([]);
const fillableRows = ref<FillableForm[]>([]);
const loading = ref(false);
const loadedOnce = ref(false);
const errorText = ref("");
/**
* 「可填写」的卡片集=接口里 `canFill=true` 的行。
*
* <p>过滤条件只有这一条,且判定<b>来自后端</b>:`canFill=false` 的行(教师×面向学生那种"只能代填")
* 由后端按三层模型算出,前端只是不把它画成"进入填报"的卡——前端不重新按角色判一遍(T8 红线:范围以后端为准)。</p>
*/
const fillableCards = computed<FillableForm[]>(() => fillableRows.value.filter((r) => r.canFill));
/** 会话用户 id:判"这条是不是我提交的"(编辑/删除入口显隐),身份来源仍是会话(不传后端) */
const sessionUserId = computed(() => store.sessionUserId);
@@ -90,14 +99,19 @@ function isMine(row: MyAnswerViewItem): boolean {
}
async function load() {
if (view.value === "fillable") return; // 数据源=本地菜单树,无需请求
loading.value = true;
errorText.value = "";
try {
rows.value = view.value === "about" ? await listAnswersAboutMe() : await listMySubmitted();
if (view.value === "fillable") {
// P95 W4:入口清单来自后端解析(不再是会话菜单树);缺省 appCode=全部应用
fillableRows.value = (await listFillableForms()) || [];
} else {
rows.value = view.value === "about" ? await listAnswersAboutMe() : await listMySubmitted();
}
loadedOnce.value = true;
} catch (e: any) {
rows.value = [];
fillableRows.value = [];
errorText.value = e?.message || "加载失败";
Message.error(errorText.value);
} finally {
@@ -110,25 +124,17 @@ function switchView(target: "submit" | "about" | "fillable") {
router.push({ path: `/${target === "submit" ? "my/submit" : "my/" + target}` });
}
// ==================== P91 可填写视图:菜单树 → 填报入口卡片 ====================
// ==================== P91 可填写视图:接口入口行 → 填报卡片 ====================
/**
* 与左侧导航同一套解析:buildNavTree 吃 menuRoute(P87 起 /f/{code} 行能解析),
* flattenNav 展平后按 page 形态挑填报行;按 page 去重(同名/多行指向同一表单只出一张卡)。
*/
const fillableItems = computed<FlatNav[]>(() => {
if (view.value !== "fillable") return [];
const nav = flattenNav(buildNavTree(store.modules, resolveMenuPage));
const seen = new Set<string>();
return nav.filter((n) => {
if (!/^\/f\//.test(n.page) || seen.has(n.page)) return false;
seen.add(n.page);
return true;
});
});
/** 点卡片=进 /f/{code}(中心表单不带 query,快应用带 ?appCode=,与复制出去的分享链接同一条地址) */
function goFill(row: FillableForm) {
router.push(fillRouteOf(row));
}
function goFill(page: string) {
router.push({ path: page });
/** 卡片上那行灰字:把"点进去会到哪个地址"如实写出来(与 fillRouteOf 同一份规则,不另算一遍) */
function fillPathText(row: FillableForm): string {
const route = fillRouteOf(row);
return `#${route.path}${route.query.appCode ? `?appCode=${route.query.appCode}` : ""}`;
}
// ==================== 详情 / 编辑 / 删除 ====================
@@ -242,7 +248,7 @@ onMounted(load);
<Radio label="submit">我提交的</Radio>
<Radio label="about">关于我的</Radio>
</RadioGroup>
<Button v-if="view !== 'fillable'" icon="md-refresh" @click="load">刷新</Button>
<Button icon="md-refresh" @click="load">刷新</Button>
<span class="toolbar-note">
{{ meta.endpoint }} | {{ meta.predicate }}
<template v-if="view !== 'fillable'">(两个端点两个谓词,不做参数分支)</template>
@@ -255,27 +261,29 @@ onMounted(load);
<br />本视图不渲染编辑/删除入口(填表人才能改自己提交的那条);也不渲染改状态(填表人在接口层即被拒)。
</template>
<template v-else-if="view === 'fillable'">
<br />入口来自基础平台的「填报」模块行(path=/f/{表单code},授权给组后这里才会出现)。
<br />入口由表单中心按<b>面向人群 × 你的身份</b>解析(P95 三层模型),与基础平台的模块行无关;
教师面向学生表单只能<b>代填本校学生</b>,那种表不在这里出现(在快应用上下文的数据管理里)。
</template>
</Alert>
<!-- P91 可填写:卡片列表(菜单名 + 进入填报),数据源=会话菜单树 -->
<!-- P95 可填写:卡片列表(名称 + 进入填报),数据源=GET /api/form/my/fillable -->
<template v-if="view === 'fillable'">
<div v-if="fillableItems.length" class="fill-cards">
<div v-for="item in fillableItems" :key="item.id" class="fill-card">
<div v-if="fillableCards.length" class="fill-cards">
<div v-for="item in fillableCards" :key="`${item.appCode}/${item.code}`" class="fill-card">
<div class="fill-card-main">
<div class="fill-card-name">
<i class="iconfont icon-edit"></i>
{{ item.name }}
</div>
<code class="fill-card-path">{{ item.page }}</code>
<code class="fill-card-path">{{ fillPathText(item) }}</code>
</div>
<Button type="primary" @click="goFill(item.page)">进入填报</Button>
<Button type="primary" @click="goFill(item)">进入填报</Button>
</div>
</div>
<Alert v-else show-icon type="warning">
当前账号没有可填写的表单入口。请找管理员在基础平台为本应用配置「填报」模块行
(path 填 /f/{表单code}),并授权给你所在的组织组。
<Alert v-else-if="loadedOnce" show-icon type="warning">
当前账号没有可填写的表单。表单发布后,会按它的<b>面向人群</b>自动出现在这里
(面向学生的表出现在学生账号下,面向教师的表出现在教师账号下)——
不需要在基础平台为本应用逐张配置「填报」模块行。
</Alert>
</template>
@@ -284,10 +292,14 @@ onMounted(load);
no-data-text="这里还没有数据" class="link-table" />
</div>
<p class="toolbar-note foot-note">
<p v-if="view !== 'fillable'" class="toolbar-note foot-note">
共 {{ rows.length }} 条(跨表单,不带值文档——要看值走详情,值文档与钉住版本的 schema 一起下发的,
渲染与校验同源)。
</p>
<p v-else class="toolbar-note foot-note">
共 {{ fillableCards.length }} 张可填(接口还包含"只能代填"的那些行,本列表只画 canFill=true 的;
排序按截止时间,不限截止的在最后)。
</p>
</div>
<Modal v-model="detailOpen" :title="`答卷详情(${detail?.formName || detail?.code || ''})`" :width="720"
+4 -3
View File
@@ -14,8 +14,9 @@
* 前端不据此再过滤一遍(任务书 T8 红线)。
*
* 快应用上下文(AppContext)复用本组件:传 `context="app"` + `app-code` + `codes`
* (候选来自该应用模块树的 `/f/{code}` 行,经 resolve 拿 uuid——定义管理权不在快应用上下文里,§3.4)
* + `modules`(**同一棵原始模块树**,按钮级权限按它判,P76)。
* (候选来自 `GET /api/form/my/fillable?appCode=` 按层级筛出的 code,经 resolve 拿 uuid——
* 定义管理权不在快应用上下文里,§3.4;P95 W5 起不再从该应用模块树挑 `/f/` 行)
* + `modules`(**同一棵原始模块树**,只给按钮级权限判定用,P76)。
*/
import { computed, h, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
@@ -51,7 +52,7 @@ const props = withDefaults(
context?: "center" | "app";
/** app 上下文的归属应用(center 留空=form-center) */
appCode?: string;
/** app 上下文的候选 code(来自该应用模块树的 /f/{code} 行) */
/** app 上下文的候选 code(来自 /api/form/my/fillable 的层级筛取,P95 W5) */
codes?: string[];
/**
* 按钮级权限判定的**那棵树**(P76):app 上下文必须由宿主传入目标应用的模块树