feat(t9): P76 导出锚点判树修正 + P77 应用内详情形态 + 观察项①勾选回填 + 演练桩
build / build (push) Successful in 3m17s

P76(T8 验收遗留):快应用上下文的导出按钮此前恒按中心会话树判"导出"锚点
(Data.vue exportable 读 store.modules),锚点判定不生效(fail-closed 方向,
有授权也不画按钮)。本轮把 AppContext 拉到的**目标应用树**经 modules prop 下传,
Data.vue 的权限判据改为 permsTree(app=应用树 / center=会话树),canExport 纯函数不动。
Stats.vue 未接:本页无锚点判定也无 store.modules 引用,加 prop 是死代码(差异记 T9 报告)。

P77(T8 验收遗留):应用上下文内的"详情"不再跳回中心路由 /admin/data/detail。
形态二选一取**子路由 /app/:appCode/data/detail**(同一个 AnswerDetail.vue):
地址保留 uuid 可深链/刷新、详情页只有一份实现、back() 本就按 app 回应用数据视图,
写 parentPath 反而造假(menuRoute 的 ROUTE_PATHS 只收静态 path,应用段导航在页内轻导航)。
goDetail 按 props.context 分派;back() 改读 route.params.appCode,撤掉 query.app 双轨。

观察项①(T8 验收记档):确认跨触底页勾选**会被清空**——view-ui-plus 的 data 监听器
用 makeObjBaseData 重建 objData,_isChecked 只认行上的 _checked(点选不回写行),
整组替换后勾选视觉与 getSelection 归零而"已选 N 条"仍累计。新增 withSelectionState()
在 reload/loadMore 两条装载路径按 selected 回填 _checked,selected 为唯一事实源。

演练桩(本地件,dev:mock 生效):baseapi 增 fc-drill 快应用模块树(方案 §6.2 五行含
导入/导出两个 path 空锚点),dataapi 增 /api/form/resolve 桩(快应用数据视图唯一的
合规取数路径是 code→resolve→uuid,契约 §1)。其余外指 appCode 仍旧返回空树(P3 口径)。

门禁:npm run build ✓ / npm run type-check 0 错 ✓;
t1~t9 全量复跑:t5 13/0/1、t6 22/0/2、t7 12/0/0、t8 21/0/1、t9 12/0/1
(skip 均为 headless Edge --dump-dom 吐 0 字节的 P73 环境事实,不假通过)。
本提交含 T6~T8 三轮堆在工作区的既有改动(沿 T8 验收 §5 推送分层)。
This commit is contained in:
chenxing
2026-10-15 12:57:10 +08:00
parent fdd9aaaa3b
commit 14d45f7e3c
61 changed files with 9236 additions and 72 deletions
+251 -18
View File
@@ -1,39 +1,272 @@
<script setup lang="ts">
import { computed } from "vue";
import { useRoute } from "vue-router";
/**
* 快应用上下文 /app/:appCode/* 占位(T5)。
* 快应用上下文 /app/:appCode/(T8 落地,替换 T5 占位)。
*
* 形态(方案 §6.2 纯 A 形态、手册 §2.1):快应用没有独立前端,UserBase 应用行 pc_url 直接指向
* 本路由,中心按<b>那个应用</b>的模块树渲染轻导航/直落单表单——取树走 api/module/base.ts 的
* getUserMod(appCode)(参数名按 P2 定稿 appCode)。
* 形态(方案 §6.2 纯 A 形态 + 任务书 T8"快应用上下文"):
* 快应用 = UserBase 真实应用行 + 无自有后端 + pc_url → 本路由。
* - **模块树**:经 `/api/base/GetUserMod?appCode=<目标应用>` 取**那个应用**的树。H4 未上线时
* 后端返回空树 + warn(T1 已验收的兜底,P3 裁决)——本页照此显示空态,**绝不回落本应用菜单**。
* - **范围矩阵**:受限身份可见集**以后端为准**,本页不自行按角色过滤(任务书 T8 红线)——
* 树里有什么就是什么,页面只做"单表单直落 vs 应用聚合轻导航"的形态分派。
* - **单表单直落无导航**:树里只有一个可跳转填报行(`/f/{code}`)→ 直接渲染填报页,无轻导航。
* - **应用聚合带轻导航**:树里有多个可跳转行 → 顶部一排轻导航(按 path 分派),内容区渲染对应视图。
*
* P3 裁决的口径在本页成立:<b>H4 未上线时该请求返回空树,页面就显示空态,绝不回落本应用菜单</b>
* (回落会把「要 A 应用的菜单」伪装成「A 应用就这些」)。树渲染与范围矩阵在 T8 落地。
* 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 空)
*/
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 Data from "@/views/admin/Data.vue";
import Stats from "@/views/admin/Stats.vue";
import type { IModule } from "@/types/Basics";
const route = useRoute();
const router = useRouter();
const appCode = computed(() => String(route.params.appCode ?? ""));
const view = computed(() => String(route.params.view ?? ""));
/** 子视图段(/app/:appCode/:view):空=由树决定单表单直落或落默认聚合视图 */
const viewParam = computed(() => String(route.params.view ?? ""));
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));
/** 可跳转导航项(path 空锚点已被 menuNav 跳过——V26 第二半句的复验点) */
const navItems = computed(() => flattenNav(navTree.value));
const isSingleForm = computed(() => navItems.value.length === 1 && /^\/f\//.test(navItems.value[0]?.page || ""));
/** 当前内容视图:'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 呈现)
return "unknown";
});
const currentTitle = computed(() => {
if (activeView.value === "data") return "数据查看";
if (activeView.value === "stats") return "统计";
if (activeView.value === "fill") return "填报";
return "应用表单";
});
async function loadTree() {
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;
} catch (e: any) {
rawTree.value = [];
navTree.value = [];
errorText.value = e?.message || "目标应用的模块树取不到";
emptyTree.value = true;
} finally {
loading.value = false;
}
}
/** 轻导航点击:按模块行的 path 分派到本页的内容视图段 */
function gotoNav(item: { page: string }) {
const p = item.page;
if (/^\/f\//.test(p)) {
router.push(p);
return;
}
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 || "";
}
onMounted(async () => {
await loadTree();
// 单表单直落:树里只有一个填报行且当前没带子视图段 → 直接把内容区渲染成该表单的填报
// (不跳转,避免把用户从 /app/{appCode}/ 弹到别处;用户看着地址栏仍是应用入口)
if (!viewParam.value && isSingleForm.value) {
// 保持在本页渲染填报(activeView 已算成 fill),无需额外动作
}
});
watch(appCode, () => loadTree());
</script>
<template>
<div class="page-wrapper yh-fill">
<div class="main-container">
<h4 class="subtitle">快应用上下文</h4>
<Alert show-icon type="info">
<code>/app/:appCode/*</code> 路由骨架占位;目标应用模块树渲染、单表单直落与应用聚合轻导航在 T8 落地。
<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>
</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>
<p class="app-meta">
appCode = <b>{{ appCode || "(空)" }}</b>
<template v-if="view">|view = <b>{{ view }}</b></template>
<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>
<!-- 数据/统计视图复用中心管理端组件:定义管理权不在快应用,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'">
<Alert show-icon type="info">
单表单直落:该应用只授权了一个填报行,内容区直接渲染填报,不再叠一层导航。
<br />填报目标:<code>{{ firstNavPath() || "(树里没有 /f/{code} 行)" }}</code>
</Alert>
<div class="fill-mount">
<div class="fill-hint">
直接进入填报页:
<a href="javascript:;" @click="firstNavPath() && router.push(firstNavPath())">
{{ firstNavPath() || "—" }}
</a>
(填报页本体是 T7 交付的 /f/:code,本页不复制一份)
</div>
</div>
</template>
<Alert v-else show-icon type="warning">
该应用模块行里的这一段路径没有对应视图(本页只认识 data / stats / fill 三种,
以及 path 形如 /f/{code} 的填报行)。请核对平台侧的模块行配置。
</Alert>
</template>
<p class="toolbar-note foot-note">
V26 前端侧三半句:①无导出授权不画导出按钮(快应用按 path 空的"导出"锚点行判)——
导出按钮在数据视图内按该规则显隐;②导航不渲染 path 空节点;③学生身份只见填报路由(树里授权的直接结果)。
接口侧的拒绝是 T4 已验的另一半,前端藏按钮不等于授权。
</p>
</div>
</div>
</template>
<style scoped>
.app-meta {
margin: 16px 0 0;
.app-head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.app-tag {
display: inline-block;
padding: 1px 8px;
font-size: 12px;
color: #fff;
background: #2d8cf0;
border-radius: 3px;
}
.app-code {
color: #17233d;
}
.toolbar-note {
color: #808695;
font-size: 12px;
}
.region-tip {
margin-bottom: 12px;
}
.light-nav {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.nav-live {
display: inline-flex;
gap: 4px;
}
.nav-item {
display: inline-block;
padding: 3px 12px;
font-size: 13px;
color: #515a6e;
border-radius: 3px;
}
.nav-item:hover {
color: #2d8cf0;
background: #f0f6ff;
}
.nav-item.active {
color: #2d8cf0;
background: #f0f6ff;
font-weight: 600;
}
.section-title {
margin: 0 0 10px;
font-size: 14px;
font-weight: 600;
color: #17233d;
border-left: 3px solid #2d8cf0;
padding-left: 8px;
}
.fill-mount {
padding: 16px 0;
}
.fill-hint {
font-size: 13px;
color: #515a6e;
}
.foot-note {
display: block;
margin-top: 20px;
line-height: 1.6;
}
</style>