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
+118 -93
View File
@@ -1,77 +1,57 @@
<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/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/ 行"的分支随这条依赖收敛而退役。
* 一个壳承载全部形态,**壳零自有权限逻辑**——两视图的判定只看后端两把清单:
* · `GET /api/form/my/managed?appCode=`(创建人 ∪ 表单管理员=该 app 域内的 ALL 档)
* · `GET /api/form/my/fillable?appCode=`(audience 门筛过的可填清单,入集即可填)
*
* 两视图(§5.1 原文):
* · <b>管理员视图</b>:数据列表 / 添加(代录,T18 接)/ 导入(T18 接)/ 删除 / 复制填报路径;
* · <b>填报者视图</b>:只有「填报」tab。
* 判定即"对这张 app 域表单是不是 ALL 档持有者"——原 P95 的 `myTier` 三档写死分组随三层模型出清
* (§19 作废-9),原 `GetUserMod?appCode=` 拉目标应用树那一路也整条摘掉(它唯一剩余用途=导出锚点判定,
* 现改 managed 成员判定,B13/P78 遗迹清零)。
*
* 仍保留的既有形态:只有一个可填项且地址没带子视图段时<b>单表单直落</b>(T8/P95 的入口语义,不叠导航)。
*/
import { computed, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { baseApi } from "@/api/module/base";
import { listFillableForms } from "@/api/module/fill";
import { listFillableForms, listManagedForms } 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";
import type { FillableForm, ManagedForm } from "@/types/data";
import { Message } from "view-ui-plus";
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 managedRows = ref<ManagedForm[]>([]);
const loading = ref(false);
const errorText = ref("");
/** 填报 tab 的行:契约 v2 C2 之后入集即等价于可填(行里没有 canFill,前端不再筛) */
/** ALL 档持有=该 app 域里有 managed 行——这是两视图唯一的分岔判据 */
const isAdminView = computed(() => managedRows.value.length > 0);
const fillCards = computed(() => fillRows.value);
/**
* 数据管理/统计两档的驱动位在 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);
const adminCodes = computed(() => managedRows.value.map((r) => r.code));
/** 单表单直落:可填项恰好一个且地址没带子视图段(P95 保留 T8 的这条形态,只换数据源) */
const isSingleForm = computed(() => !viewParam.value.trim() && fillCards.value.length === 1);
const activeView = computed<"fill" | "data" | "stats" | "unknown">(() => {
const activeView = computed<"data" | "fill" | "unknown">(() => {
const v = viewParam.value.trim();
if (!v || v === "fill") return "fill";
if (v === "data") return showDataTab.value ? "data" : "unknown";
if (v === "stats") return showStatsTab.value ? "stats" : "unknown";
if (v === "data") return isAdminView.value ? "data" : "unknown";
if (v === "fill") return "fill";
if (!v) return isAdminView.value ? "data" : "fill";
return "unknown";
});
const currentTitle = computed(() => {
if (activeView.value === "data") return "数据查看";
if (activeView.value === "stats") return "统计";
if (activeView.value === "fill") return "填报";
return "应用表单";
});
/** 单表单直落:填报者视图下只有一个可填项且地址没带子视图段 */
const isSingleForm = computed(() => !isAdminView.value && !viewParam.value.trim() && fillCards.value.length === 1);
const currentTitle = computed(() => (activeView.value === "data" ? "数据查看" : "填报"));
async function loadAll() {
if (!appCode.value) {
@@ -81,24 +61,23 @@ async function loadAll() {
loading.value = true;
errorText.value = "";
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) {
fillRows.value = [];
errorText.value = e?.message || "可填清单取不到";
managedRows.value = [];
errorText.value = e?.message || "该应用的清单取不到";
} finally {
loading.value = false;
}
// 导出锚点那棵树:失败或空都按"判不出锚点=不给导出按钮"处理(fail-closed),
// 刻意不并进 errorText——它取不到不影响填报/数据/统计三档壳的成立
try {
rawTree.value = (await baseApi.getUserMod(appCode.value).send()) || [];
} catch {
rawTree.value = [];
}
}
/** tab 点击:填报段回到应用入口(保留单表单直落判定),其余段按名分派 */
function gotoView(seg: "" | "data" | "stats") {
/** tab 点击:空段=按视图默认档,其余段按名分派 */
function gotoView(seg: "" | "data" | "fill") {
router.push(seg ? `/app/${appCode.value}/${seg}` : `/app/${appCode.value}/`);
}
@@ -108,7 +87,19 @@ function goFill(row: FillableForm) {
function fillPathText(row: FillableForm): string {
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);
@@ -121,33 +112,46 @@ watch(appCode, () => loadAll());
<div class="app-head">
<span class="app-tag">快应用</span>
<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>
</div>
<Alert v-if="errorText" show-icon type="error" class="region-tip">{{ errorText }}</Alert>
<!-- 导航壳:三档由身份决定(不是由模块树里有什么决定) -->
<!-- 两视图导航:管理员才有「数据查看」档;填报档两种视图都在 -->
<div class="light-nav">
<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:;"
@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>
@click="gotoView('fill')">填报({{ fillCards.length }})</a>
</span>
<span class="toolbar-note">
{{ isAdminView ? "你在该应用下有 ALL 档的表单(创建人或表单管理员)" : "你在这里只是填报人——没有可管理的数据" }}
</span>
<span class="toolbar-note">数据管理/统计两档按你在这些表单上的层级出现;导出按钮仍按该应用的模块树判</span>
</div>
<h5 class="section-title">{{ currentTitle }}</h5>
<!-- 数据/统计视图复用中心管理端组件:定义管理权不在快应用,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 />
<!-- 管理员视图:数据列表(内嵌 Data,候选=managed 的 code)+ 复制填报路径。
「添加(代录)」与「导入」在 T18 接进这一档(同一条 submit 管线、同一 ALL 档守卫)。 -->
<template v-if="activeView === 'data'">
<div class="manage-rows">
<div v-for="row in managedRows" :key="row.code" class="manage-row">
<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-if="isSingleForm">
@@ -155,12 +159,10 @@ watch(appCode, () => loadAll());
单表单直落:该应用在你名下只有一个可填入口,内容区直接给它,不再叠一层导航。
<br />填报目标:<code>{{ fillPathText(fillCards[0]) }}</code>
</Alert>
<div class="fill-mount">
<div class="fill-hint">
直接进入填报页:
<a href="javascript:;" @click="goFill(fillCards[0])">{{ fillCards[0].name }}</a>
(填报页本体是 T7 交付的 /f/:code,本页不复制一份)
</div>
<div class="fill-hint">
直接进入填报页:
<a href="javascript:;" @click="goFill(fillCards[0])">{{ fillCards[0].name }}</a>
(填报页本体是 T7 交付的 /f/:code,本页不复制一份)
</div>
</template>
<div v-else-if="fillCards.length" class="fill-cards">
@@ -174,22 +176,20 @@ watch(appCode, () => loadAll());
</div>
<Alert v-else show-icon type="warning" class="region-tip">
「{{ appCode }}」这个应用在你名下当前没有可填报的表单。
<br />表单发布后会按它的<b>面向人群</b>自动出现在这里(面向学生的表出现在学生账号下,
面向教师的表出现在教师账号下)——与基础平台的模块行授权无关;
如果你是教师且这里为空,说明该应用没有面向学生的表单可代填。
<br />表单发布后按它的<b>面向人群</b>自动出现在这里(面向学生的表出现在学生账号下,
面向教师的表出现在教师账号下)——与基础平台的模块行授权无关;匿名表单(ANONYMOUS)
不下发给登录会话,持 URL 直达即可。
</Alert>
</template>
<Alert v-else show-icon type="warning">
这一段路径对你不可见(本页只有 填报 / 数据管理 / 统计 三档,后两档按你在这些表单上的层级出现)。
<br />能直接地址进来但看不到档,说明层级不够——数据面与统计的把关在后端,不在这里。
这一段路径对你不可见(本页只有 数据查看 / 填报 两档,前者要求该应用下有 ALL 档的表单)。
<br />能直接地址进来但看不到档,说明不是这张表的 ALL 档持有者——把关在后端守卫,不在这里。
</Alert>
<p class="toolbar-note foot-note">
V26 前端侧三半句(P95 改版):①无导出授权不画导出按钮——快应用按该应用树里 path 空的"导出"
锚点行判(本页把那棵树原样交给内嵌 Data.vue,判不中就不画,fail-closed);
②导航不再由模块树生成,按身份三档写死,path 空节点自然不进导航;
③学生身份只见填报档=层①只给 canFill、不给数据/统计档的直接结果。
接口侧的拒绝是 T4 已验的另一半,<b>前端藏按钮/藏档不等于授权</b>。
V72 前端侧三半句:①导出/管理按钮按「该表单 ALL 档」显隐(内嵌 Data.vue 自己读 managed 清单,
宿主不再传权限树);②导航不再由模块树生成,按两支清单构造,path 空的权限锚点形态从此不存在;
③接口侧的 403 是另一半,<b>前端藏按钮/藏档不等于授权</b>。
</p>
</div>
</div>
@@ -256,10 +256,35 @@ watch(appCode, () => loadAll());
border-left: 3px solid #2d8cf0;
padding-left: 8px;
}
.fill-mount {
padding: 16px 0;
.manage-rows {
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 {
padding: 12px 0;
font-size: 13px;
color: #515a6e;
}