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:
+251
-18
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user