Files
form-center-front/src/App.vue
T
chenxing 60083517c7 t15:前端双探针引导——/f/ 页通道选择走匿名面,顺修 F1 未登录引导与 F2 desc 插槽
- 新 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。
2026-10-20 14:05:05 +08:00

183 lines
7.8 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>