diff --git a/src/components/FormCard.vue b/src/components/FormCard.vue index 01479d8..af56a00 100644 --- a/src/components/FormCard.vue +++ b/src/components/FormCard.vue @@ -52,9 +52,17 @@ const errors = reactive>({}); /** 提交后的摘要(用于只读回显) */ const summary = ref<{ label: string; text: string }[]>([]); +/** + * 「镜像字段」(如 deptName 镜像 deptId)不渲染输入控件:用户选好部门后, + * 提交时自动带上所选下选项的名称 —— 与模块页「字典反查部门名」的做法一致。 + */ +const renderFields = computed(() => (props.form?.fields || []).filter(f => !f.mirrorOf)); + /* ==================== 附件上传(widget=file) ==================== */ /** 已上传附件的文件名(按字段名),仅用于界面展示 */ const uploadNames = reactive>({}); +/** 已上传附件的原始信息(按字段名):作业模块要按它组装 attachments 对象数组 */ +const uploadMeta = reactive>({}); /** 上传状态:idle / uploading */ const uploadState = reactive>({}); @@ -64,7 +72,7 @@ const uploadState = reactive>({}); * 为什么不走后端:模块的用户态接口只认浏览器 CAS 会话,服务端代调必 302。 * 与下拉代取同理 —— 先直接发,遇到登录跳转就先预热模块会话再重试一次。 */ -async function uploadOne(path: string, file: File): Promise { +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 { 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>({}); +/** 审批步骤:每步一个表决方式 + 审批人列表(按字段名) */ +const flowSteps = reactive>({}); + +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() {
-
+
@@ -512,6 +624,38 @@ function reset() { 可选,支持图片 / 文档 / 压缩包
+
+ + 选择审批人后走审批流程(不勾选 = 直接发布) + + +
+ 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 { 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 = { 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 }; diff --git a/src/views/Chat.vue b/src/views/Chat.vue index d9c2fd4..1a39556 100644 --- a/src/views/Chat.vue +++ b/src/views/Chat.vue @@ -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}」模块还没建立登录会话,正在为你自动登录…`;