feat(form): 表单卡支持审批流/部门名镜像/附件对象数组,并落地两步提交
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 项断言全绿
This commit is contained in:
lijing
2026-10-13 11:29:12 +08:00
parent da347f21b5
commit 97cd25b0e0
4 changed files with 326 additions and 23 deletions
+100 -11
View File
@@ -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 };