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。
This commit is contained in:
chenxing
2026-10-20 14:05:05 +08:00
parent 6401a69c18
commit 60083517c7
6 changed files with 186 additions and 24 deletions
+13 -1
View File
@@ -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);
+80
View File
@@ -0,0 +1,80 @@
/**
* 匿名通道(契约 v2 §3.2/C6/C15,T15)。
*
* <p><b>两把探针必须走原生 fetch,不走 alova</b>:alova 实例的 302 拦截对全部 `/api/*` 生效且
* 没有 per-request 豁免位(C15 的代码事实,alova-core.ts:58-65),而引导期要问的问题恰恰是
* "这个人有没有会话"——用 alova 去问,无会话者当场被整页带去 CAS,双探针就没了意义。
* 探针只回数据,不跳转。</p>
*
* <p>提交回落 alova:`/anon` 面不存在 302 语义(无会话墙),业务错误提示链与登录面同一条;
* 上传端点由本模块给出 URL 串(附件控件按注入的 URL 走,见 Fill.vue 的 provide)。</p>
*/
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<T = any> {
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<T>(path: string): Promise<Envelope<T>> {
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<T>;
} catch (e: any) {
return { code: -1, msg: e?.message || "探针请求失败" };
}
}
/** 探针 1(C15 第一跳):当前浏览器有没有 CAS 会话。非 0(含 302/网络错)一律按"无会话"回答 */
export async function probeSession(): Promise<SessionProbe> {
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<ResolvedForm | null> {
const query = appCode ? `&appCode=${encodeURIComponent(appCode)}` : "";
const envelope = await probe<ResolvedForm>(`/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<SubmitView>("/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}`;
}
+6 -2
View File
@@ -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<ComputedRef<string>>("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();
+9 -2
View File
@@ -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<ComputedRef<string>>("fcUploadUrl", computed(() => "/api/form/upload"));
const props = withDefaults(defineProps<FieldWidgetProps & {
modelValue?: FileValue[];
readonly?: boolean;
@@ -54,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();
+71 -19
View File
@@ -1,20 +1,24 @@
<script setup lang="ts">
/**
* 填报页 /f/:code(T7 落地,替换 T5 占位)。
* 填报页 /f/:code(T7 落地,替换 T5 占位;T15 换双探针通道引导)。
*
* 契约纪律:
* 1. mode 闭集(page/embed),显式优先,self!=top 仅作未传时自动兜底(契约 §7);
* 2. 身份一律 CAS 会话自取,任何身份注入被禁止(V17);
* 2. 登录面身份一律 CAS 会话自取,任何身份注入被禁止(V17);
* 3. 提交带客户端幂等键(uuid),命中返回 200+原 answerId(契约 §5);
* 4. 值渲染默认转义,全仓禁 v-html(方案 §2.3 红线);
* 5. 三态空值与已删字段降级(契约 §3)。
* 5. 三态空值与已删字段降级(契约 §3);
* 6. **通道选择=双探针**(契约 v2 C15,T15):先用原生 fetch 探有没有会话,无会话再探这张表
* 是不是匿名表单;有会话→登录通道(/api/form/*),无会话且含 ANONYMOUS→匿名通道
* (/anon/form/*),无会话且不含→page 跳 CAS / embed 发 needLogin。ANONYMOUS 不算登录身份。
*/
import { computed, h, onMounted, onUnmounted, ref, watch } from "vue";
import { computed, h, onMounted, onUnmounted, provide, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { fieldWidget } from "@/components/fields";
import type { DesignField } from "@/types/formSchema";
import type { ResolvedForm, MyAnswerItem } from "@/types/answer";
import type { ResolvedForm, MyAnswerItem, SubmitRequest } from "@/types/answer";
import { resolveForm, submitAnswer, listMyAnswers, deleteMyAnswer, editMyAnswer, getMyAnswerDetail } from "@/api/module/fill";
import { probeSession, probeAnonForm, submitAnonAnswer, anonUploadPath } from "@/api/module/anon";
import { startHeightReporting, reportSubmitResult, reportNeedLogin } from "@/utils/embed";
import Utils from "@/utils";
import { Message, Modal } from "view-ui-plus";
@@ -46,25 +50,70 @@ const form = ref<ResolvedForm | null>(null);
const loading = ref(true);
const error = ref<string | null>(null);
/** 本次填报走的通道(null=还没判出来);提交、上传、"我的提交"段都按它分派 */
type FillChannel = "session" | "anon";
const channel = ref<FillChannel | null>(null);
/**
* 附件控件的上传端点(upload/image 控件 inject 它,缺省=登录面那条)。
* 匿名必须走 `/anon/form/upload?code=`:后端只有表单能定 {@code form_file.sch_id}=表单归属校,
* 落错校的话提交时的附件归属校验永远查不到这一行(C6/C8)。
*/
provide(
"fcUploadUrl",
computed(() =>
channel.value === "anon"
? anonUploadPath(code.value, appCode.value || undefined)
: "/api/form/upload"
)
);
/**
* 引导(C15 双探针)。两跳都用原生 fetch:alova 的 302 拦截对全部 `/api/*` 生效且没有豁免位,
* 用会话请求本身去问"有没有会话"会当场把无会话者整页带去 CAS,第二跳就没机会跑了。
*/
async function loadForm() {
loading.value = true;
error.value = null;
const queryAppCode = appCode.value || undefined;
try {
form.value = await resolveForm(code.value, appCode.value || undefined);
if ((await probeSession()).logged) {
channel.value = "session";
form.value = await resolveForm(code.value, queryAppCode);
await loadMyAnswers();
return;
}
const anonymous = await probeAnonForm(code.value, queryAppCode);
if (anonymous) {
channel.value = "anon";
form.value = anonymous;
// 匿名填报人没有任何查询能力(C8/§2.1):编辑/删除/我的提交段整条不存在
myAnswers.value = [];
return;
}
guideToLogin();
} catch (e: any) {
error.value = e?.message || "表单加载失败";
if (e?.code === 4) {
if (isEmbedded.value) {
reportNeedLogin();
} else {
window.location.href = "/cas/login?service=" + encodeURIComponent(window.location.href);
}
}
} finally {
loading.value = false;
}
}
/**
* 未登录引导(**F1 顺修**)。旧判据 `e?.code === 4` 是个死分支——alova 的 errorHandle 抛的是
* 不带 code 的普通 Error,所以 embed 形态的 needLogin 从来没发出去过(契约 §7 要求 embed 未登录
* 走引导协议而不是白屏/iframe 内跳登录页)。通道既然由探针决定,这里就只在
* "无会话 ∧ 这又不是匿名表单"时被调用:embed 发 postMessage,page 整页跳 CAS。
*/
function guideToLogin() {
if (isEmbedded.value) {
error.value = "请先登录后填写";
reportNeedLogin();
return;
}
window.location.href = "/cas/login?service=" + encodeURIComponent(window.location.href);
}
// ==================== 字段渲染与校验 ====================
const formValues = ref<Record<string, any>>({});
@@ -137,12 +186,14 @@ async function handleSubmit() {
submitting.value = true;
try {
const idempotencyKey = Utils.uuid();
const result = await submitAnswer({
const payload: SubmitRequest = {
formCode: code.value,
appCode: appCode.value || undefined,
idempotencyKey,
values: formValues.value
});
};
// 两条通道同一条请求形状(匿名体里没有 subjectId,服务端也不读身份字段——C8/V17)
const result = channel.value === "anon" ? await submitAnonAnswer(payload) : await submitAnswer(payload);
submitResult.value = { answerId: result.answerId, answerNo: result.answerNo };
submitted.value = true;
if (isEmbedded.value) {
@@ -260,8 +311,8 @@ const containerRef = ref<HTMLElement | null>(null);
let stopHeightReporting: (() => void) | null = null;
onMounted(() => {
// 引导里含"我的提交"段(只有登录通道会取),不再并行走两遍鉴权
loadForm();
loadMyAnswers();
if (isEmbedded.value && containerRef.value) {
stopHeightReporting = startHeightReporting(containerRef.value);
}
@@ -273,7 +324,6 @@ onUnmounted(() => {
watch(code, () => {
loadForm();
loadMyAnswers();
});
// ==================== 只读态判定 ====================
@@ -308,7 +358,8 @@ const readonlyReason = computed(() => {
<div v-else-if="error" class="error-state">
<Alert show-icon type="error">
{{ error }}
<template v-if="!isEmbedded" slot desc>
<!-- F2 顺修:Vue 2 的 slot="desc" 写法在 Vue 3 下不成立(静默不进插槽),改 #desc -->
<template v-if="!isEmbedded" #desc>
<a href="/cas/login">重新登录</a>
</template>
</Alert>
@@ -334,7 +385,8 @@ const readonlyReason = computed(() => {
<div v-if="submitted && submitResult" class="success-state">
<Alert show-icon type="success">
提交成功!
<template v-if="submitResult.answerNo" slot desc>
<!-- F2 顺修:同上,#desc 才进 Alert 的描述插槽(报名号那条真渲染得出来) -->
<template v-if="submitResult.answerNo" #desc>
报名号:<b>{{ submitResult.answerNo }}</b>
</template>
</Alert>
+7
View File
@@ -99,6 +99,13 @@ export default defineConfig(({ mode }: ConfigEnv): UserConfig => {
target: "http://localhost:8086",
changeOrigin: true,
},
// 匿名面(契约 v2 C6/T15):后端把 /anon/** 整族放在 CAS 墙外(yh.sso.url-pattern 仍只 /api/*),
// dev 同样要同源代理过去,否则填报页的匿名探针与提交在浏览器里是 404(生产由 Caddy 反代
// /form-center/anon/*,T15 部署前置的轻运维项)
"/anon": {
target: "http://localhost:8086",
changeOrigin: true,
},
// 附件/图片回显同源代理(P93:URL={serviceHost}/upload/{key},dev 下 serviceHost 空)
"/upload": {
target: "http://localhost:8086",