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