Files
yunhai-agent-front/src/utils/module-session.ts
T
lijing 97cd25b0e0
build / build (push) Successful in 1m58s
feat(form): 表单卡支持审批流/部门名镜像/附件对象数组,并落地两步提交
配合后端把 5 个模块「新建/发布」页的字段完整搬进助手表单:

- FormCard 新增审批流控件(widget=flow):勾「走审批流程」后按步配置,
  每步可选或签/会签并多选审批人(来自教师组织树),提交时拼
  flow:[{name,vote,approverTeaIds}];发布方式选了「提交审批」却没配审批人则当场拦下
- 镜像字段(deptName)不渲染控件,提交时按源字段选中项反查显示名一起带上,
  与模块页「字典 id + 反查名称」的提交口径一致
- 附件支持对象数组形态(作业 attachments:[{fileId,fileUrl,fileType,fileName,sort}]);
  数组类字段在为空时也照常提交(模块页就是恒带 fileIds:"" / attachments:[])
- module-session:callModuleApi 支持 followUp —— 首步业务成功后用返回的资源 id 续跑第二步,
  并带上「第一步成功但没拿回编号」的如实报错
- types:BrowserToolCall 增加 followUp;widget 联合类型补 scopes / flow

验证:npm run build 通过;headless Chrome 真机跑「表单 → 浏览器代执行 → 模块接口」,
公告(镜像 + 审批流 + pushScopes)/ 作业(Create→Publish、Create→Schedule)/
宿舍(镜像 + 全部宿舍哨兵)/ 活动(分类存名称)共 50 项断言全绿
2026-10-13 11:29:12 +08:00

284 lines
12 KiB
TypeScript
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.
/**
* 业务模块「用户态接口」的浏览器侧调用工具。
*
* 背景(本工程的核心约束):各业务模块的 `/api/*` 只认**模块自己容器里的 CAS 会话**
* (`_const_cas_assertion_`),服务端代调必然拿到 `{"code":302,urlToRedirectTo}`。
* 所以凡是"要用用户身份读模块数据",都得由前端带同源 Cookie 去发这个请求。
*
* 两个使用场景共用本文件:
* 1. 对话里的「浏览器代执行」(模型决定的工具调用,见 `views/Chat.vue`);
* 2. 表单下拉的「代取选项」(班级 / 学生名单等,见 `components/FormCard.vue`)。
*/
import type { BrowserToolCall, ToolResultItem } from "@/types/agent";
/** 每个模块最近一次「会话预热」的时间戳(避免失败后每条请求都白等一轮) */
const warmTriedAt = new Map<string, number>();
const WARM_RETRY_MS = 60_000;
/**
* 判断模块响应是否为「未登录」信号。
*
* 平台 CAS 过滤器对 AJAX 不真的 302,而是返回 **HTTP 200 + `{"code":302,urlToRedirectTo}`**
* (带 `yschapirequest: 1` 时的约定)。body 以 `<` 开头是「落到网页」的老问题,另有守卫处理。
*/
export function isLoginRedirect(status: number, body?: string): boolean {
if (status < 200 || status >= 300 || !body || !body.includes("302")) return false;
try {
const j = JSON.parse(body);
return j != null && String(j.code) === "302" && !!j.urlToRedirectTo;
} catch {
return false;
}
}
/**
* 模块会话预热:用一次<b>真实导航</b>把业务模块的浏览器 CAS 会话建起来。
*
* 为什么必须这么做:模块接口只认模块自己容器里的 CAS 会话。同源部署只保证 Cookie 送得到,
* 送不到「还没建出来的会话」。而模块 SPA 建会话靠的是整页跳 SSO 签票回跳 —— fetch 永远拿不到
* 这一步(平台对 AJAX 只回 JSON `code=302`,不真跳转),于是每次请求都必然 302。
*
* 做法:同源隐藏 iframe 导航到刚失败的那个接口地址(**不带** `yschapirequest`,
* 平台即回真 302 → SSO 见已有 TGT 直接签票回跳 → 模块校验票据后落会话),随后重试原请求。
* 整个过程用户无感;只在首次访问某模块时发生一次。
*
* @param pathAndQuery 刚失败的接口相对路径(`/homeleave/api/...`),首段即模块上下文
* @returns 是否完成了这轮预热尝试(false = 60s 内已试过,或拿不到模块段)
*/
export function warmModuleSession(pathAndQuery: string, onStart?: () => void, timeoutMs = 6000): Promise<boolean> {
const ctx = pathAndQuery.split("/").filter(Boolean)[0] || "";
if (!ctx) return Promise.resolve(false);
const now = Date.now();
if (now - (warmTriedAt.get(ctx) || 0) < WARM_RETRY_MS) return Promise.resolve(false);
warmTriedAt.set(ctx, now);
onStart?.();
const url = `${location.origin}${pathAndQuery}`;
return new Promise<boolean>(resolve => {
const frame = document.createElement("iframe");
frame.setAttribute("aria-hidden", "true");
frame.style.cssText = "position:fixed;left:-9999px;top:0;width:1px;height:1px;border:0;";
let settled = false;
const done = (ok: boolean) => {
if (settled) return;
settled = true;
window.clearTimeout(cap);
window.setTimeout(() => frame.remove(), 0);
resolve(ok);
};
const cap = window.setTimeout(() => done(false), timeoutMs);
// 导航链(模块 302 → SSO 签票 → 回跳模块)走完才会触发 load,再留一点沉降时间
frame.onload = () => window.setTimeout(() => done(true), 700);
frame.src = url;
document.body.appendChild(frame);
});
}
/** 一次模块接口请求的描述(首步用 BrowserToolCall,第二步用它的 followUp) */
interface ModuleRequest {
method: string;
pathAndQuery: string;
body?: string;
contentType?: string;
}
/**
* 业务是否成功(模块统一包装 `{code,msg,data}`,`code=0` 才算成功)。
* 无包装(老接口直接回数组/对象)也当成功 —— 与服务端 {@code parseResponse} 口径一致。
*/
function isBizSuccess(status: number, body?: string): boolean {
if (status < 200 || status >= 300) return false;
const s = (body || "").trim();
if (!s) return true;
if (s.startsWith("<")) return false; // 拿回网页 = 没落到接口上
try {
const j = JSON.parse(s);
if (j && typeof j === "object" && "code" in j) return Number((j as any).code) === 0;
return true;
} catch {
return true;
}
}
/**
* 从首步响应里取「资料编号」用于第二步路径(`{data}`)。
*
* 两种常见形态:直接回 id(作业创建 `data=123`),或回一个对象(`data={id:123,...}`)——
* 对象必须取 `id` 再填路径,直接 JSON 序列化会拼出 `/homeworks/{"id":123}/publish`。
*/
function extractDataId(body?: string): string | null {
const s = (body || "").trim();
if (!s) return null;
try {
const j = JSON.parse(s);
const d = j && typeof j === "object" ? (j as any).data : undefined;
if (d === undefined || d === null) return null;
if (typeof d === "object") {
const v = (d as any).id;
return v === undefined || v === null || String(v) === "" ? null : String(v);
}
const v = String(d);
return v === "" || v === "null" ? null : v;
} catch {
return null;
}
}
/**
* 用用户自己的登录态调用一次模块接口;遇到 `code=302` 会先预热会话再重试一次。
*
* 若该调用声明了 `followUp`(两步提交,如作业「先落草稿 → 再发布/定时」),
* 首步**业务成功**后自动用返回的资源 id 续跑第二步,回传的是第二步的结果。
*
* @param call 接口描述(`pathAndQuery` 相对当前站点根,同站部署 ⇒ 同源)
* @param onWarm 识别出「需要建会话」时的回调(用于更新界面文案)
*/
export async function callModuleApi(call: BrowserToolCall, onWarm?: () => void): Promise<ToolResultItem> {
const base = { name: call.name, arguments: call.arguments };
const send = async (req: ModuleRequest) => {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 15000);
try {
const headers: Record<string, string> = { yschapirequest: "1" };
let body: string | undefined;
if (req.method !== "GET") {
headers["Content-Type"] = req.contentType || "application/json;charset=UTF-8";
body = req.body;
}
const res = await fetch(`${location.origin}${req.pathAndQuery}`, {
method: req.method === "GET" ? "GET" : "POST",
headers,
body,
credentials: "same-origin",
signal: ctrl.signal,
});
return { status: res.status, body: await res.text() };
} finally {
clearTimeout(timer);
}
};
/** 发一次请求,遇 `code=302` 先预热模块会话再重试 */
const sendWithWarm = async (req: ModuleRequest) => {
let r = await send(req);
if (isLoginRedirect(r.status, r.body)) {
const warmed = await warmModuleSession(req.pathAndQuery, onWarm);
if (warmed) r = await send(req);
}
return r;
};
try {
const fu = (call as any).followUp as ModuleRequest | undefined;
const r = await sendWithWarm({
method: call.method,
pathAndQuery: call.pathAndQuery,
body: call.body,
contentType: call.contentType,
});
if (!fu || !fu.pathAndQuery) {
return { ...base, status: r.status, body: r.body };
}
// 首步没成功(HTTP 错 / code≠0 / 拿回网页)就不发第二步,如实回传首步结果
if (!isBizSuccess(r.status, r.body)) {
return { ...base, status: r.status, body: r.body };
}
const id = extractDataId(r.body);
if (!id) {
return {
...base,
status: r.status,
body: r.body,
error: "第一步已提交,但没拿回资料编号,无法继续第二步(发布 / 定时发布)",
};
}
const r2 = await sendWithWarm({
method: fu.method || "POST",
pathAndQuery: fu.pathAndQuery.replace("{data}", encodeURIComponent(id)),
body: fu.body,
contentType: fu.contentType,
});
return { ...base, status: r2.status, body: r2.body };
} catch (e: any) {
const reason = e?.name === "AbortError" ? "请求超时(15 秒)" : e?.message || "网络请求失败";
return { ...base, error: reason };
}
}
/**
* 把「字典接口原始响应」解析成表单下拉项。
*
* 解析是**确定性**的:由后端在字段定义里显式给出 {@code valueKey / labelKey}
* (如 `classId/className`),前端只按这两个键取值,不猜结构。
*/
export function parseOptionItems(
body?: string,
valueKey?: string,
labelKey?: string
): { value: string; label: string }[] {
if (!body) return [];
let payload: any;
try {
payload = JSON.parse(body);
} catch {
return [];
}
if (payload == null) return [];
// 平台统一包装 {code,msg,data}:非 0 即业务失败
if (typeof payload === "object" && "code" in payload) {
if (Number(payload.code) !== 0) return [];
payload = payload.data;
}
const arr = findArray(payload, 0);
if (!arr) return [];
const vk = valueKey || "id";
const lk = labelKey || "name";
const out: { value: string; label: string }[] = [];
// 组织树(年级→班级 / 学部→年级→班级)要拍平成叶子,否则下拉里只会出现「年级」这一层的 id。
// 判定:任一项带非空 children 数组即视为树;否则按平铺字典处理(多数字典接口是平铺的)。
const flat = arr.some(it => it && Array.isArray((it as any).children) && (it as any).children.length)
? flattenLeaves(arr)
: arr;
for (const item of flat) {
if (item == null || typeof item !== "object") continue;
const v = (item as any)[vk];
if (v === undefined || v === null || String(v) === "") continue;
// labelKey 支持用「+」拼多个字段(如房间的 buildingName+roomNo →「1号楼 305」),
// 因为这类 VO 里没有现成的展示名,靠单一字段会出现「同名房间分不清楼栋」。
const l = lk.includes("+")
? lk.split("+").map(k => (item as any)[k.trim()]).filter(V => V !== undefined && V !== null && String(V) !== "").join(" ")
: (item as any)[lk];
out.push({
value: String(v),
label: l === undefined || l === null || String(l) === "" ? String(v) : String(l),
});
}
return out;
}
/** 把「组→子」的树拍平成叶子列表(叶子 = 没有非空 children 的节点) */
function flattenLeaves(nodes: any[]): any[] {
const out: any[] = [];
for (const n of nodes || []) {
const kids = n && Array.isArray(n.children) ? n.children : null;
if (kids && kids.length) out.push(...flattenLeaves(kids));
else out.push(n);
}
return out;
}
/** 递归找第一个数组(限深,容忍 data 外面再包一层 list/records/rows) */
function findArray(node: any, depth: number): any[] | null {
if (node == null || depth > 3) return null;
if (Array.isArray(node)) return node;
if (typeof node === "object") {
for (const k of Object.keys(node)) {
const a = findArray(node[k], depth + 1);
if (a) return a;
}
}
return null;
}