- 新 api/module/anon.ts:两把探针走原生 fetch(alova 的 302 拦截对全部 /api/* 生效且没有
per-request 豁免位,用它问"有没有会话"会当场整页跳 CAS,C15 的代码事实);带 yschapirequest
头让墙内未登录回 JSON 的 code=302 信封而不是 HTML 跳转。提交回落 alova(/anon 面无 302 语义)。
- Fill.vue 引导改双探针:probeSession → 有会话走登录通道并取"我的提交"段;无会话再 probeAnonForm
→ 命中即匿名渲染(匿名人无任何查询能力,编辑/删除段整条不出现);两跳都落空才 guideToLogin。
- F1 顺修(取"改判通道"那一支,不动全局 errorHandle):旧判据 e?.code===4 是死分支——alova 抛的是
不带 code 的 Error,embed 的 needLogin 从来没发出去过;现在未登录引导只由探针结论触发。
- F2 顺修:slot desc → #desc 两处(Vue 3 下旧写法静默不进 Alert 描述插槽,报名号那行从未渲染)。
- App.vue:/f/ 前缀路由先以原生探针判会话,无会话则裸渲染且一个鉴权请求都不发(P124——否则无会话者
还没走到填报页的探针就被带去 CAS,"匿名表单 page=直接可填"根本兑现不了);探到会话时结果直接复用,
登录面不多发一次请求。其余路由引导一字未动。
- upload/image 两个附件控件的上传端点改 inject("fcUploadUrl", 默认 /api/form/upload),由 Fill.vue
按通道 provide;设计器预览等调用点行为不变。vite.config.ts 代理加 /anon → 8086。
门禁:npx vue-tsc --noEmit exit 0;npm run build ✓ built in 11.61s。
183 lines
7.8 KiB
Vue
183 lines
7.8 KiB
Vue
<script setup lang="ts">
|
||
import { onBeforeUnmount, onMounted, reactive, ref } from "vue";
|
||
import layout from "./components/layout.vue";
|
||
import { useRoute, useRouter } from "vue-router";
|
||
import config from "@/assets/config/host.config";
|
||
import { baseApi } from "@/api/module/base";
|
||
import { probeSession } from "@/api/module/anon";
|
||
import { getMyCapabilities, listFillableForms, listManagedForms } from "@/api/module/fill";
|
||
import { firstNavPage, isSingleNav, type NavNode } from "@/utils/menuNav";
|
||
import { Message } from "view-ui-plus";
|
||
import { useDefaultStore } from "@/store";
|
||
|
||
const router = useRouter();
|
||
const route = useRoute();
|
||
const store = useDefaultStore();
|
||
const isReady = ref(false);
|
||
|
||
/**
|
||
* 页面即组件(方案 §3.3):被 iframe 嵌入时自动剥壳——不渲染顶栏/左导航/面包屑,
|
||
* 也不发会话请求(嵌入上下文里本应用 /api/base/* 取不到会话会弹错误提示并画出带壳页面)。
|
||
* 显式 mode 参数(?mode=embed|page)的闭集判定在 T7 填报页落地,壳这层只兜 self != top 这一条。
|
||
*/
|
||
const isEmbedded = window.self !== window.top;
|
||
|
||
/**
|
||
* 嵌入态必须旁路全局 body 的 min-width(assets/styles/base/_base.scss 的 1200px):
|
||
* isEmbedded 只旁路了组件壳,管不到全局 CSS,窄 iframe 里仍按 ≥1200px 排版(整体右偏 + 右裁)。
|
||
* 内联样式优先级高于样式表;非嵌入态移除内联属性回落原值。
|
||
*/
|
||
function syncEmbedLayout(embedded: boolean) {
|
||
if (embedded) document.body.style.minWidth = "0";
|
||
else document.body.style.removeProperty("min-width");
|
||
}
|
||
|
||
const layoutData = reactive({
|
||
appName: "表单中心",
|
||
baseHost: config.baseHost,
|
||
ssoHost: config.ssoHost,
|
||
userInfo: null as any,
|
||
/** 自解析出来的可渲染导航(契约 v2 §2.4 三支路;不再由 UserBase 模块树生成) */
|
||
navList: [] as NavNode[],
|
||
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 () => {
|
||
// 首次导航异步完成后再判默认跳转:mount 时 currentRoute 可能仍是初始 "/"
|
||
await router.isReady();
|
||
syncEmbedLayout(isEmbedded);
|
||
isReady.value = true;
|
||
if (isEmbedded) return;
|
||
|
||
// 免墙直达面(契约 v2 §3.2/C15):/f/{code} 的通道选择归填报页自己的双探针,壳必须先以
|
||
// 「不整页跳转的原生探针」问一次有没有会话。直接发 getUserInfo 的话,无会话者还没走到探针
|
||
// 就被 alova 的 302 拦截带去 CAS,匿名表单在 page 模式下永远打不开(V67 要的那条)。
|
||
// 探到会话时顺手把用户带下来,登录面因此不多发一次请求。
|
||
const probed = router.currentRoute.value.path.startsWith("/f/") ? await probeSession() : null;
|
||
if (probed && !probed.logged) {
|
||
// 没有会话就没有导航可画:裸渲染,页面自己决定是匿名可填还是引导登录
|
||
layoutData.isAlone = true;
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// 会话用户(GetUserMod 已随模块行退役删除,引导只剩这一把 + 三支路自解析)
|
||
const user: any = probed?.user ?? (await baseApi.getUserInfo().send());
|
||
const { id, name, profilePicture } = user || {};
|
||
// 存一份给页面用(如字典页判平台级维护权);权限判定仍在后端,这里只是不重复发一次请求
|
||
store.setUser(user || null);
|
||
|
||
// 三支路并行取(互相独立,串行只会让首屏多等两个 RTT)
|
||
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.navList = buildSelfResolvedNav();
|
||
layoutData.isAlone = isSingleNav(layoutData.navList);
|
||
|
||
if (import.meta.env.DEV) {
|
||
console.table(
|
||
layoutData.navList.map((n) => ({ 渲染项: n.name, page: n.page }))
|
||
);
|
||
}
|
||
|
||
// 入口页:跳第一个可跳转菜单项
|
||
if (router.currentRoute.value.path === "/") {
|
||
const first = firstNavPage(layoutData.navList);
|
||
if (first) router.replace(first);
|
||
}
|
||
} catch (error: any) {
|
||
console.error("加载用户信息失败", error);
|
||
if (error?.message !== "redirect") {
|
||
Message.error("载入用户信息失败,请检查后端服务是否正常!");
|
||
}
|
||
// 降级只降「导航为空」,不造兜底菜单树——静默回落会把"接口没通"伪装成正常
|
||
store.setUser(null);
|
||
layoutData.userInfo = { name: "用户", avatar: "" };
|
||
layoutData.navList = [];
|
||
layoutData.isAlone = true;
|
||
}
|
||
});
|
||
|
||
onBeforeUnmount(() => syncEmbedLayout(false));
|
||
</script>
|
||
|
||
<template>
|
||
<!-- 嵌入态:裸渲染,页面自己管理样式与滚动(方案 §3.3 剥壳) -->
|
||
<router-view v-if="isEmbedded" :key="route.fullPath" />
|
||
<template v-else>
|
||
<layout v-if="isReady" :layoutData="layoutData" />
|
||
<div v-if="isReady" class="base-wrap-main" :class="{ 'alone-page': layoutData.isAlone }">
|
||
<div class="main-wrapper">
|
||
<!-- key=fullPath:仅 query 变化的深链(如详情页换 id)强制重渲染,避免显示上一条数据 -->
|
||
<router-view :key="route.fullPath" />
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</template>
|
||
|
||
<style lang="scss">
|
||
html,
|
||
body {
|
||
margin: 0;
|
||
padding: 0;
|
||
height: 100%;
|
||
overflow: hidden;
|
||
}
|
||
#app {
|
||
height: 100%;
|
||
}
|
||
/* 页面级滚动容器:main-wrapper 定高 + 纵向滚动。内容超一屏时若只给 min-height,
|
||
会被 body 的 overflow:hidden 直接裁掉且无滚动条;列表页高度由 .table-region 实测(规范 §4.3) */
|
||
.base-wrap-main .main-wrapper {
|
||
height: 100%;
|
||
overflow-y: auto;
|
||
}
|
||
/* 表格操作列(operatingHandle/actionIcon 渲染的 Tooltip 包裹层)横排且随单元格居中 */
|
||
.ivu-table-cell > .ivu-tooltip {
|
||
display: inline-block;
|
||
vertical-align: middle;
|
||
margin: 0 2px;
|
||
}
|
||
</style>
|