build / build (push) Successful in 1m58s
配合后端把 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 项断言全绿
284 lines
12 KiB
TypeScript
284 lines
12 KiB
TypeScript
/**
|
||
* 业务模块「用户态接口」的浏览器侧调用工具。
|
||
*
|
||
* 背景(本工程的核心约束):各业务模块的 `/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;
|
||
}
|