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 @@