From 60083517c7c4c07974a1b86a70881811d1fa6b70 Mon Sep 17 00:00:00 2001 From: chenxing Date: Tue, 20 Oct 2026 14:05:05 +0800 Subject: [PATCH] =?UTF-8?q?t15:=E5=89=8D=E7=AB=AF=E5=8F=8C=E6=8E=A2?= =?UTF-8?q?=E9=92=88=E5=BC=95=E5=AF=BC=E2=80=94=E2=80=94/f/=20=E9=A1=B5?= =?UTF-8?q?=E9=80=9A=E9=81=93=E9=80=89=E6=8B=A9=E8=B5=B0=E5=8C=BF=E5=90=8D?= =?UTF-8?q?=E9=9D=A2=EF=BC=8C=E9=A1=BA=E4=BF=AE=20F1=20=E6=9C=AA=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E5=BC=95=E5=AF=BC=E4=B8=8E=20F2=20desc=20=E6=8F=92?= =?UTF-8?q?=E6=A7=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新 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。 --- src/App.vue | 14 ++++- src/api/module/anon.ts | 80 +++++++++++++++++++++++++ src/components/fields/image/pc.vue | 8 ++- src/components/fields/upload/pc.vue | 11 +++- src/views/Fill.vue | 90 +++++++++++++++++++++++------ vite.config.ts | 7 +++ 6 files changed, 186 insertions(+), 24 deletions(-) create mode 100644 src/api/module/anon.ts diff --git a/src/App.vue b/src/App.vue index 6ca37f5..c0f5cd5 100644 --- a/src/App.vue +++ b/src/App.vue @@ -4,6 +4,7 @@ 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"; @@ -73,9 +74,20 @@ onMounted(async () => { 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 = await baseApi.getUserInfo().send(); + const user: any = probed?.user ?? (await baseApi.getUserInfo().send()); const { id, name, profilePicture } = user || {}; // 存一份给页面用(如字典页判平台级维护权);权限判定仍在后端,这里只是不重复发一次请求 store.setUser(user || null); diff --git a/src/api/module/anon.ts b/src/api/module/anon.ts new file mode 100644 index 0000000..ea09381 --- /dev/null +++ b/src/api/module/anon.ts @@ -0,0 +1,80 @@ +/** + * 匿名通道(契约 v2 §3.2/C6/C15,T15)。 + * + *

两把探针必须走原生 fetch,不走 alova:alova 实例的 302 拦截对全部 `/api/*` 生效且 + * 没有 per-request 豁免位(C15 的代码事实,alova-core.ts:58-65),而引导期要问的问题恰恰是 + * "这个人有没有会话"——用 alova 去问,无会话者当场被整页带去 CAS,双探针就没了意义。 + * 探针只回数据,不跳转。

+ * + *

提交回落 alova:`/anon` 面不存在 302 语义(无会话墙),业务错误提示链与登录面同一条; + * 上传端点由本模块给出 URL 串(附件控件按注入的 URL 走,见 Fill.vue 的 provide)。

+ */ +import conf from "@/assets/config/host.config"; +import Utils from "@/utils"; +import { alovaInstance } from "@/utils/alova/alova-iview"; +import type { ResolvedForm, SubmitRequest, SubmitView } from "@/types/answer"; + +/** 平台统一响应信封(探针只看 code,不套 alova 的错误处理) */ +interface Envelope { + code: number; + msg?: string; + data?: T; +} + +/** 会话探针的结果:有没有会话 + 有会话时顺手把用户带回来(省一次重复请求) */ +export interface SessionProbe { + logged: boolean; + user: any | null; +} + +/** + * 探针请求。带 `yschapirequest: 1` 是给 CAS 过滤器认"这是 AJAX"的记号——不带它,墙内未登录会回 + * HTML 跳转而不是 JSON 的 code=302 信封,fetch 会跟着跳到 CAS 域名然后撞 CORS,探针就变成了一次 + * 无声的失败。`yschreferer` 同 alova 实例的头,保持两条通道对后端看起来是同一个前端。 + */ +async function probe(path: string): Promise> { + try { + const res = await fetch(`${conf.serviceHost}${path}`, { + credentials: "include", + headers: { + yschapirequest: "1", + yschreferer: Utils.strToHexCharCode(window.location.href) + } + }); + if (!res.ok) return { code: -1, msg: `HTTP ${res.status}` }; + return (await res.json()) as Envelope; + } catch (e: any) { + return { code: -1, msg: e?.message || "探针请求失败" }; + } +} + +/** 探针 1(C15 第一跳):当前浏览器有没有 CAS 会话。非 0(含 302/网络错)一律按"无会话"回答 */ +export async function probeSession(): Promise { + const envelope = await probe("/api/base/GetUserInfo"); + return { logged: envelope.code === 0, user: envelope.code === 0 ? envelope.data ?? null : null }; +} + +/** + * 探针 2(C15 第二跳):这张表是不是匿名表单。 + * 后端对"非匿名表单"和"压根不存在"给同一条 PARAM 表单不存在(C6/V54 防探测), + * 所以这里只需要"是不是 0"一个判据,msg 不进 UI。 + */ +export async function probeAnonForm(code: string, appCode?: string): Promise { + const query = appCode ? `&appCode=${encodeURIComponent(appCode)}` : ""; + const envelope = await probe(`/anon/form/resolve?code=${encodeURIComponent(code)}${query}`); + return envelope.code === 0 && envelope.data ? envelope.data : null; +} + +/** 匿名提交(响应形状与登录面同一条:{answerId, answerNo, replayed},C7 幂等命中 200+原 answerId) */ +export function submitAnonAnswer(body: SubmitRequest) { + return alovaInstance.Post("/anon/form/submit", body).send(); +} + +/** + * 匿名上传的端点寻址(`code` 必带:后端只有表单能定 sch_id,落错校提交时的附件归属就查不到这行)。 + * 返回的是给附件控件用的完整请求路径,appCode 以 query 参数交出去。 + */ +export function anonUploadPath(code: string, appCode?: string): string { + const query = appCode ? `&appCode=${encodeURIComponent(appCode)}` : ""; + return `/anon/form/upload?code=${encodeURIComponent(code)}${query}`; +} diff --git a/src/components/fields/image/pc.vue b/src/components/fields/image/pc.vue index 9032695..e88b9ff 100644 --- a/src/components/fields/image/pc.vue +++ b/src/components/fields/image/pc.vue @@ -3,13 +3,17 @@ * image 图片(值 [{id,fileName,key}],契约 §3——中心上传接口直接返回这三件, * 前端只存返回值,绝不把 fileUrl/绝对 URL 写进答卷值,否则换域名历史附件全断)。 */ -import { computed, ref } from "vue"; +import { computed, inject, ref } from "vue"; +import type { ComputedRef } from "vue"; import type { FieldWidgetProps } from "../types"; import type { FileValue } from "@/types/formSchema"; import { Upload } from "view-ui-plus"; import { alovaInstance } from "@/utils/alova/alova-iview"; import conf from "@/assets/config/host.config"; +/** 上传端点按通道注入(匿名面走 /anon/form/upload,契约 v2 C6);无注入=登录面默认 */ +const uploadUrl = inject>("fcUploadUrl", computed(() => "/api/form/upload")); + /** 附件回显基址:{ctx}/upload/(P93);serviceHost 空(dev 同源)时走 /upload 相对路径 */ const fileBase = `${conf.serviceHost || ""}/upload`; @@ -57,7 +61,7 @@ async function handleUpload(file: File) { const formData = new FormData(); formData.append("file", file); const result = await alovaInstance.Post<{ id: number; fileName: string; key: string }>( - "/api/form/upload", + uploadUrl.value, formData, { headers: { "Content-Type": "multipart/form-data" } } ).send(); diff --git a/src/components/fields/upload/pc.vue b/src/components/fields/upload/pc.vue index bca5c4d..0cde00d 100644 --- a/src/components/fields/upload/pc.vue +++ b/src/components/fields/upload/pc.vue @@ -4,12 +4,19 @@ * 字段级 accept/maxSize/maxFiles 只能往更严收,不能往宽放)。 * accept 不含 .svg 由设计器把门(P28),这里只如实呈现配置。 */ -import { computed, ref } from "vue"; +import { computed, inject, ref } from "vue"; +import type { ComputedRef } from "vue"; import type { FieldWidgetProps } from "../types"; import type { FileValue } from "@/types/formSchema"; import { Upload } from "view-ui-plus"; import { alovaInstance } from "@/utils/alova/alova-iview"; +/** + * 上传端点由填报页按通道注入(匿名面必须走 `/anon/form/upload?code=`,契约 v2 C6: + * 只有表单能定 form_file.sch_id=表单归属校)。没有注入=登录面默认,设计器预览等调用点行为不变。 + */ +const uploadUrl = inject>("fcUploadUrl", computed(() => "/api/form/upload")); + const props = withDefaults(defineProps( - "/api/form/upload", + uploadUrl.value, formData, { headers: { "Content-Type": "multipart/form-data" } } ).send(); diff --git a/src/views/Fill.vue b/src/views/Fill.vue index 4c4167c..0d8eb6a 100644 --- a/src/views/Fill.vue +++ b/src/views/Fill.vue @@ -1,20 +1,24 @@