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
+196 -10
View File
@@ -52,9 +52,17 @@ const errors = reactive<Record<string, string>>({});
/** 提交后的摘要(用于只读回显) */
const summary = ref<{ label: string; text: string }[]>([]);
/**
* 「镜像字段」(如 deptName 镜像 deptId)不渲染输入控件:用户选好部门后,
* 提交时自动带上所选下选项的名称 —— 与模块页「字典反查部门名」的做法一致。
*/
const renderFields = computed(() => (props.form?.fields || []).filter(f => !f.mirrorOf));
/* ==================== 附件上传(widget=file) ==================== */
/** 已上传附件的文件名(按字段名),仅用于界面展示 */
const uploadNames = reactive<Record<string, string[]>>({});
/** 已上传附件的原始信息(按字段名):作业模块要按它组装 attachments 对象数组 */
const uploadMeta = reactive<Record<string, { id: number; name: string; type: string }[]>>({});
/** 上传状态:idle / uploading */
const uploadState = reactive<Record<string, "idle" | "uploading">>({});
@@ -64,7 +72,7 @@ const uploadState = reactive<Record<string, "idle" | "uploading">>({});
* 为什么不走后端:模块的用户态接口只认浏览器 CAS 会话,服务端代调必 302。
* 与下拉代取同理 —— 先直接发,遇到登录跳转就先预热模块会话再重试一次。
*/
async function uploadOne(path: string, file: File): Promise<number | null> {
async function uploadOne(path: string, file: File): Promise<{ id: number; name: string; type: string } | null> {
const doSend = async () => {
const fd = new FormData();
fd.append("file", file);
@@ -84,8 +92,11 @@ async function uploadOne(path: string, file: File): Promise<number | null> {
try {
const j = JSON.parse(r.body);
if (Number(j.code) === 0 && j.data) {
const id = j.data.fileid ?? j.data.fileId ?? j.data.id;
if (id !== undefined && id !== null) return Number(id);
const d = j.data;
const id = d.fileid ?? d.fileId ?? d.id;
if (id !== undefined && id !== null) {
return { id: Number(id), name: d.name || d.fileName || file.name, type: String(d.type || "") };
}
}
Message.error(j.msg || `「${file.name}」上传失败`);
} catch {
@@ -106,16 +117,19 @@ async function onPickFiles(f: FormField, e: Event) {
uploadState[f.name] = "uploading";
const ids: any[] = Array.isArray(values[f.name]) ? [...values[f.name]] : [];
const names: string[] = [...(uploadNames[f.name] || [])];
const metas = [...(uploadMeta[f.name] || [])];
try {
for (const file of files) {
const id = await uploadOne(f.uploadPath, file);
if (id !== null) {
ids.push(id);
names.push(file.name);
const up = await uploadOne(f.uploadPath, file);
if (up !== null) {
ids.push(up.id);
names.push(up.name);
metas.push(up);
}
}
values[f.name] = ids;
uploadNames[f.name] = names;
uploadMeta[f.name] = metas;
delete errors[f.name];
} finally {
uploadState[f.name] = "idle";
@@ -126,10 +140,54 @@ async function onPickFiles(f: FormField, e: Event) {
function removeFile(f: FormField, idx: number) {
const ids = Array.isArray(values[f.name]) ? [...values[f.name]] : [];
const names = [...(uploadNames[f.name] || [])];
const metas = [...(uploadMeta[f.name] || [])];
ids.splice(idx, 1);
names.splice(idx, 1);
metas.splice(idx, 1);
values[f.name] = ids;
uploadNames[f.name] = names;
uploadMeta[f.name] = metas;
}
/** 附件按模块要求组装成对象数组(作业模块 attachments:[{fileId,fileUrl,fileType,fileName,sort}]) */
function attachmentObjects(f: FormField) {
return (uploadMeta[f.name] || []).map((m, i) => ({
fileId: m.id,
fileUrl: `/api/file/Preview?id=${m.id}`,
fileType: /^image/i.test(m.type) || /\.(jpe?g|gif|png|bmp|webp)$/i.test(m.name) ? "image" : "file",
fileName: m.name,
sort: i,
}));
}
/* ==================== 审批流(widget=flow) ==================== */
/** 是否走审批流程(按字段名) */
const flowEnabled = reactive<Record<string, boolean>>({});
/** 审批步骤:每步一个表决方式 + 审批人列表(按字段名) */
const flowSteps = reactive<Record<string, { vote: "any" | "all"; ids: any[] }[]>>({});
function addFlowStep(f: FormField) {
(flowSteps[f.name] ||= []).push({ vote: "any", ids: [] });
}
function removeFlowStep(f: FormField, i: number) {
const list = flowSteps[f.name] || [];
if (list.length <= 1) return;
list.splice(i, 1);
}
/** 组装 flow 参数:[{name,vote,approverTeaIds}](与模块页 ApprovalFlowEditor 的提交口径一致) */
function buildFlow(f: FormField) {
if (!flowEnabled[f.name]) return undefined;
const steps = (flowSteps[f.name] || []).filter(s => (s.ids || []).length);
if (!steps.length) return undefined;
return steps.map((s, i) => ({ name: `第${i + 1}步`, vote: s.vote, approverTeaIds: s.ids.map(x => Number(x)) }));
}
/** 审批人下拉的显示名(把 teaId 翻成姓名,用于提交后的只读摘要) */
function approverNames(f: FormField, ids: any[]): string {
const opts = fieldOptions(f);
return ids.map(id => opts.find(o => String(o.value) === String(id))?.label || String(id)).join("、");
}
/* ==================== 下拉选项:动态代取 + 联动 ==================== */
@@ -175,6 +233,8 @@ function depSignature(f: FormField) {
*/
function widgetOf(f: FormField) {
const st = optState[f.name];
// 审批流即使取不到教师名单也不退化 —— 它要渲染的是「步骤编辑器」,退成文本框毫无意义
if (f.widget === "flow") return "flow";
if (f.optionCall && st === "fail" && !baseOptions(f).length) {
// 多选(含推送范围)退化成文本(用户可手填逗号串编号),单选退化成数字/文本
if (f.widget === "multiselect" || f.widget === "scopes") return "text";
@@ -257,13 +317,21 @@ function initValues() {
Object.keys(values).forEach(k => delete values[k]);
Object.keys(errors).forEach(k => delete errors[k]);
Object.keys(uploadNames).forEach(k => delete uploadNames[k]);
Object.keys(uploadMeta).forEach(k => delete uploadMeta[k]);
Object.keys(uploadState).forEach(k => delete uploadState[k]);
Object.keys(flowEnabled).forEach(k => delete flowEnabled[k]);
Object.keys(flowSteps).forEach(k => delete flowSteps[k]);
resetOptionState();
// 「全字段表单」把已识别的参数直接预填进控件(不再只做只读回显)
const preset = props.form?.preset || {};
for (const f of props.form?.fields || []) {
const seed = preset[f.name] !== undefined && preset[f.name] !== null ? preset[f.name] : f.defaultValue;
if (f.widget === "datetime" || f.widget === "date") {
if (f.mirrorOf) {
continue;
} else if (f.widget === "flow") {
flowEnabled[f.name] = false;
flowSteps[f.name] = [{ vote: "any", ids: [] }];
} else if (f.widget === "datetime" || f.widget === "date") {
values[f.name] = toDate(seed) ?? "";
} else if (f.widget === "multiselect" || f.widget === "scopes") {
const arr = Array.isArray(seed) ? [...seed] : seed ? [seed] : [];
@@ -401,8 +469,35 @@ function onSubmit() {
// 2) 用户填写的字段
const rows: { label: string; text: string }[] = [];
for (const f of props.form?.fields || []) {
// 镜像字段(deptName 等):不带输入控件,取源字段「选中项的显示名」一起提交,与模块页一致
if (f.mirrorOf) {
const src = values[f.mirrorOf];
if (src === undefined || src === null || String(src).trim() === "") continue;
const srcField = (props.form?.fields || []).find(x => x.name === f.mirrorOf);
const opt = srcField ? fieldOptions(srcField).find(o => String(o.value) === String(src)) : undefined;
args[f.name] = opt ? opt.label : String(src);
continue;
}
// 审批流:拼成 [{name,vote,approverTeaIds}](只勾了「走审批」但一个人都没选 → 不提交该字段)
if (f.widget === "flow") {
const flow = buildFlow(f);
if (flow && flow.length) {
args[f.name] = flow;
rows.push({
label: f.label,
text: flow.map((s, i) => `第${i + 1}步(${s.vote === "all" ? "会签" : "或签"}):${approverNames(f, (flowSteps[f.name][i] || {}).ids || [])}`).join(";"),
});
}
continue;
}
const v = values[f.name];
if (v === undefined || v === null || String(v).trim() === "") continue;
// 数组类字段即使是空数组也要提交(模块页就是恒带 fileIds:"" / attachments:[]),
// 否则「没传」与「传空」在 wire 上不一致,后端拿到的可能是 null 而不是空集合。
if (Array.isArray(v)) {
// 落到下面的分支自行拼装
} else if (v === undefined || v === null || String(v).trim() === "") {
continue;
}
// 推送范围(校园公告 pushScopes):把多选结果拼成 [{scopeType,scopeId,scopeName}];
// 只勾「全部」→ [{scopeType:1,scopeId:0,scopeName:"全部班级"}]
if (f.widget === "scopes") {
@@ -418,11 +513,23 @@ function onSubmit() {
rows.push({ label: f.label, text: payload.map(p => p.scopeName).join("、") });
continue;
}
// 附件:作业模块要对象数组(attachments),其余模块是 fileId 数组(后端再按 join 拼串)
if (f.widget === "file" && f.fileAsObject) {
const objs = attachmentObjects(f);
args[f.name] = objs;
rows.push({ label: f.label, text: objs.map(o => o.fileName).join("、") || `${objs.length} 个附件` });
continue;
}
const normalized = normalize(f, v);
args[f.name] = normalized;
const opt = fieldOptions(f).find(o => String(o.value) === String(normalized));
rows.push({ label: f.label, text: opt ? opt.label : String(normalized) });
}
// 选了「提交审批」却没配审批人 → 与模块页一致地拦下来(后端也会拒)
if (args.publishType === "approve" && !(Array.isArray(args.flow) && args.flow.length)) {
Message.warning("发布方式选了「提交审批」,请先在「审批流程」里勾选并选择审批人");
return;
}
summary.value = rows;
emit("submit", { tool: props.form.tool, args });
}
@@ -465,7 +572,12 @@ function reset() {
<!-- 填写区 -->
<div v-if="!submitted" class="form-card__fields">
<div v-for="f in form.fields" :key="f.name" class="field">
<div
v-for="f in renderFields"
:key="f.name"
class="field"
:class="{ 'field--wide': f.widget === 'flow' }"
>
<label class="field__label">
<span v-if="f.required" class="field__star">*</span>{{ f.label }}
</label>
@@ -512,6 +624,38 @@ function reset() {
<span v-else class="file-tip">可选,支持图片 / 文档 / 压缩包</span>
</div>
<div v-else-if="widgetOf(f) === 'flow'" class="field__flow">
<Checkbox v-model="flowEnabled[f.name]" :disabled="disabled">
选择审批人后走审批流程(不勾选 = 直接发布)
</Checkbox>
<template v-if="flowEnabled[f.name]">
<div v-for="(st, i) in flowSteps[f.name] || []" :key="i" class="flow-step">
<span class="flow-step__no">第 {{ i + 1 }} 步</span>
<Select v-model="st.vote" size="small" :disabled="disabled" style="width: 92px; flex: none">
<Option value="any">或签</Option>
<Option value="all">会签</Option>
</Select>
<Select
v-model="st.ids"
multiple
size="small"
:placeholder="optState[f.name] === 'loading' ? '正在读取审批人…' : '选择审批人(可多选)'"
:disabled="disabled"
:loading="optState[f.name] === 'loading'"
style="flex: 1; min-width: 150px"
>
<Option v-for="o in fieldOptions(f)" :key="o.value" :value="o.value">{{ o.label }}</Option>
</Select>
<a v-if="(flowSteps[f.name] || []).length > 1" class="flow-step__del" @click="removeFlowStep(f, i)">删除</a>
</div>
<a class="flow-add" @click="addFlowStep(f)">+ 再加一步(多步依次审批)</a>
<p class="field__tip">按顺序流转:上一步通过后进入下一步,任一步驳回则退回发布人。</p>
<p v-if="optState[f.name] === 'fail'" class="field__warn">
审批人名单暂时没取到(教师树接口未通),可以先不勾选「走审批流程」直接发布,或稍后重试。
</p>
</template>
</div>
<DatePicker
v-else-if="widgetOf(f) === 'datetime'"
v-model="values[f.name]"
@@ -728,6 +872,48 @@ function reset() {
color: var(--agent-danger);
}
/* ------------------------------ 审批流(widget=flow) ------------------------------ */
.field--wide {
grid-column: 1 / -1;
}
.field__flow {
display: flex;
flex-direction: column;
gap: 7px;
}
.flow-step {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 9px;
border: 1px dashed var(--agent-border-strong);
border-radius: 9px;
background: var(--agent-surface-2);
}
.flow-step__no {
flex: none;
font-size: 11.5px;
color: var(--agent-text-mute);
width: 42px;
}
.flow-step__del {
flex: none;
font-size: 11.5px;
color: var(--agent-danger);
cursor: pointer;
}
.flow-add {
align-self: flex-start;
font-size: 12px;
color: var(--agent-primary-dark);
cursor: pointer;
}
/* ------------------------------ 附件上传 ------------------------------ */
.file-btn {
display: inline-flex;
+27 -1
View File
@@ -36,7 +36,7 @@ export interface FormField {
/** 中文标签,如「开始时间」 */
label: string;
/** 控件类型 */
widget: "text" | "textarea" | "number" | "date" | "datetime" | "select" | "multiselect" | "file";
widget: "text" | "textarea" | "number" | "date" | "datetime" | "select" | "multiselect" | "file" | "scopes" | "flow";
type?: string;
required: boolean;
/** 字段说明(含义与格式) */
@@ -59,6 +59,18 @@ export interface FormField {
allTokenLabel?: string;
/** widget=scopes(校园公告 pushScopes):节点类型 1 全校 / 2 学部 / 3 年级 / 4 班级 / 5 训练队 / 6 学员 */
scopeType?: number;
/**
* 「镜像字段」:本字段不渲染输入控件,提交时自动填入同一表单中该字段**选中项的显示名**。
* 例:deptName 镜像 deptId —— 模块页会「选部门 → 用字典反查名称 → 一起提交」。
*/
mirrorOf?: string;
/**
* 「连续请假」展开说明:{ count, start, end }。
* 本字段取 1 时由后端按天数把起止时间复制成逐天时间段(continuousLeaveTime)。
*/
dailyExpand?: { count: string; start: string; end: string };
/** widget=file 时提交**附件对象数组**(作业模块 attachments),而非 fileId 数组 */
fileAsObject?: boolean;
/** 下拉选项 */
options?: { value: string; label: string }[];
/**
@@ -139,6 +151,20 @@ export interface BrowserToolCall {
pathAndQuery: string;
body?: string;
contentType?: string;
/**
* 非空 = 「两步提交」的第二步(首步成功后自动续跑)。
* 模块里有些动作没有一步到位的接口 —— 作业发布就是 `Create`(落草稿拿 id)
* → `Publish`/`Schedule`。路径里的 `{data}` 由首步响应 data 节点替换。
*/
followUp?: FollowUp;
}
/** 两步提交的第二步描述 */
export interface FollowUp {
method: "GET" | "POST" | "POST_JSON" | "POST_FORM";
pathAndQuery: string;
body?: string;
contentType?: string;
}
/** 代执行结果回传项(与 BrowserToolCall 按 name 对应) */
+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 };
+3 -1
View File
@@ -197,7 +197,9 @@ async function runBrowserTool(placeholder: ChatItem, pending: any) {
placeholder.text = pending?.reply || pending?.browserTool?.notice || "正在用你的登录态查询业务模块…";
const results: ToolResultItem[] = [];
for (const call of calls) {
placeholder.stage = `正在用你的登录态${call.write ? "提交" : "查询"}「${call.title}」…`;
placeholder.stage = call.followUp
? `正在用你的登录态提交并完成「${call.title}」…`
: `正在用你的登录态${call.write ? "提交" : "查询"}「${call.title}」…`;
results.push(await execBrowserCall(call, () => {
const ctx = call.pathAndQuery.split("/").filter(Boolean)[0] || "该模块";
placeholder.stage = `「${ctx}」模块还没建立登录会话,正在为你自动登录…`;