配合后端把 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 项断言全绿
This commit is contained in:
+100
-11
@@ -74,26 +74,80 @@ export function warmModuleSession(pathAndQuery: string, onStart?: () => void, ti
|
||||
});
|
||||
}
|
||||
|
||||
/** 一次模块接口请求的描述(首步用 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 () => {
|
||||
|
||||
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 (call.method !== "GET") {
|
||||
headers["Content-Type"] = call.contentType || "application/json;charset=UTF-8";
|
||||
body = call.body;
|
||||
if (req.method !== "GET") {
|
||||
headers["Content-Type"] = req.contentType || "application/json;charset=UTF-8";
|
||||
body = req.body;
|
||||
}
|
||||
const res = await fetch(`${location.origin}${call.pathAndQuery}`, {
|
||||
method: call.method === "GET" ? "GET" : "POST",
|
||||
const res = await fetch(`${location.origin}${req.pathAndQuery}`, {
|
||||
method: req.method === "GET" ? "GET" : "POST",
|
||||
headers,
|
||||
body,
|
||||
credentials: "same-origin",
|
||||
@@ -104,13 +158,48 @@ export async function callModuleApi(call: BrowserToolCall, onWarm?: () => void):
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
try {
|
||||
let r = await send();
|
||||
|
||||
/** 发一次请求,遇 `code=302` 先预热模块会话再重试 */
|
||||
const sendWithWarm = async (req: ModuleRequest) => {
|
||||
let r = await send(req);
|
||||
if (isLoginRedirect(r.status, r.body)) {
|
||||
const warmed = await warmModuleSession(call.pathAndQuery, onWarm);
|
||||
if (warmed) r = await send();
|
||||
const warmed = await warmModuleSession(req.pathAndQuery, onWarm);
|
||||
if (warmed) r = await send(req);
|
||||
}
|
||||
return { ...base, status: r.status, body: r.body };
|
||||
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 };
|
||||
|
||||
Reference in New Issue
Block a user