From da347f21b5d86ae076dce156da7d80c1e5166409 Mon Sep 17 00:00:00 2001 From: lijing Date: Mon, 12 Oct 2026 22:05:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(form):=20=E8=A1=A8=E5=8D=95=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=A4=9A=E9=80=89=E6=8E=A5=E6=94=B6=E5=AF=B9=E8=B1=A1?= =?UTF-8?q?=20/=20=E9=99=84=E4=BB=B6=E4=B8=8A=E4=BC=A0=20/=20=E5=85=A8?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 配合后端「发布表单与模块页对齐」:前端补齐三类控件与序列化。 - FormCard: 新增 multiselect 与 scopes 多选控件(「全部 XX」哨兵恒为首项, 选中后仍可叠加具体项;提交时若只有哨兵则按 scopeType 折成 [{scopeType:1,scopeId:0,scopeName}]);新增 file 附件控件(带登录态 POST multipart 到 uploadPath,遇登录跳转先预热模块会话再重试) - FormCard: allFields 的表单渲染全部字段(不再只渲染缺失必填);file 控件 显示已传文件名并可移除 - types: widget 联合加 multiselect/file,FormField 补 joinSeparator/uploadPath/ multiple/allToken/allTokenLabel/scopeType,FormRequest 补 allFields - module-session: parseOptionItems 支持 labelKey 用「+」拼多字段 (如 buildingName+roomNo →「1 号楼 101」),组织树自动拍平到叶子 验证:.scratch/verify-module-publish-form.mjs 真机断言三个发布表单 (notice 24/24、dorm 35/35、activity 33/33 通过),npm run build 通过 --- src/components/FormCard.vue | 228 ++++++++++++++++++++++++++++++++++-- src/types/agent.ts | 22 +++- src/utils/module-session.ts | 24 +++- 3 files changed, 262 insertions(+), 12 deletions(-) diff --git a/src/components/FormCard.vue b/src/components/FormCard.vue index b53468d..01479d8 100644 --- a/src/components/FormCard.vue +++ b/src/components/FormCard.vue @@ -15,7 +15,7 @@ */ import { reactive, computed, ref, watch } from "vue"; import { Message } from "view-ui-plus"; -import { callModuleApi, parseOptionItems } from "@/utils/module-session"; +import { callModuleApi, parseOptionItems, isLoginRedirect, warmModuleSession } from "@/utils/module-session"; import type { FormField, FormRequest } from "@/types/agent"; const props = defineProps<{ @@ -52,6 +52,86 @@ const errors = reactive>({}); /** 提交后的摘要(用于只读回显) */ const summary = ref<{ label: string; text: string }[]>([]); +/* ==================== 附件上传(widget=file) ==================== */ +/** 已上传附件的文件名(按字段名),仅用于界面展示 */ +const uploadNames = reactive>({}); +/** 上传状态:idle / uploading */ +const uploadState = reactive>({}); + +/** + * 附件上传:带**用户自己的登录态**把文件 POST 到模块的 FileUpload 接口(multipart)。 + * + * 为什么不走后端:模块的用户态接口只认浏览器 CAS 会话,服务端代调必 302。 + * 与下拉代取同理 —— 先直接发,遇到登录跳转就先预热模块会话再重试一次。 + */ +async function uploadOne(path: string, file: File): Promise { + const doSend = async () => { + const fd = new FormData(); + fd.append("file", file); + const res = await fetch(`${location.origin}${path}`, { + method: "POST", + headers: { yschapirequest: "1" }, + body: fd, + credentials: "same-origin", + }); + return { status: res.status, body: await res.text() }; + }; + let r = await doSend(); + if (isLoginRedirect(r.status, r.body)) { + const warmed = await warmModuleSession(path); + if (warmed) r = await doSend(); + } + 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); + } + Message.error(j.msg || `「${file.name}」上传失败`); + } catch { + Message.error(`「${file.name}」上传失败`); + } + return null; +} + +async function onPickFiles(f: FormField, e: Event) { + const input = e.target as HTMLInputElement; + const files = Array.from(input.files || []); + if (!files.length) return; + if (!f.uploadPath) { + Message.error("附件上传地址缺失,请稍后重试"); + input.value = ""; + return; + } + uploadState[f.name] = "uploading"; + const ids: any[] = Array.isArray(values[f.name]) ? [...values[f.name]] : []; + const names: string[] = [...(uploadNames[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); + } + } + values[f.name] = ids; + uploadNames[f.name] = names; + delete errors[f.name]; + } finally { + uploadState[f.name] = "idle"; + input.value = ""; + } +} + +function removeFile(f: FormField, idx: number) { + const ids = Array.isArray(values[f.name]) ? [...values[f.name]] : []; + const names = [...(uploadNames[f.name] || [])]; + ids.splice(idx, 1); + names.splice(idx, 1); + values[f.name] = ids; + uploadNames[f.name] = names; +} + /* ==================== 下拉选项:动态代取 + 联动 ==================== */ /** 动态取回的选项(按字段名);取到后覆盖字段自带的兜底选项 */ @@ -61,11 +141,20 @@ const optState = reactive>({} /** 依赖字段值指纹:只有依赖真的变了才重新拉取,避免每次输入都打接口 */ const depSig = reactive>({}); +/** 去掉「全部」哨兵后的真实选项(用于判断"字典到底取到没取到") */ +function baseOptions(f: FormField) { + const dyn = dynOptions[f.name]; + return dyn && dyn.length ? dyn : f.options || []; +} + /** 当前该显示的选项:动态取回的优先,否则用后端给的(可能是内置兜底) */ function fieldOptions(f: FormField) { - const dyn = dynOptions[f.name]; - if (dyn && dyn.length) return dyn; - return f.options || []; + const base = baseOptions(f); + // 「全部」哨兵(全部班级 / 全部宿舍)永远作为第一个选项,无论选项是不是动态取回的 + if (f.allToken) { + return [{ value: f.allToken, label: f.allTokenLabel || "全部" }, ...base]; + } + return base; } /** 依赖字段是否都已填好 —— 没填好就不该去拉从属选项,也不该让用户点开一个空下拉 */ @@ -86,7 +175,9 @@ function depSignature(f: FormField) { */ function widgetOf(f: FormField) { const st = optState[f.name]; - if (f.optionCall && st === "fail" && !fieldOptions(f).length) { + if (f.optionCall && st === "fail" && !baseOptions(f).length) { + // 多选(含推送范围)退化成文本(用户可手填逗号串编号),单选退化成数字/文本 + if (f.widget === "multiselect" || f.widget === "scopes") return "text"; return f.type === "integer" || f.type === "number" ? "number" : "text"; } return f.widget; @@ -165,14 +256,25 @@ function fmt(d: Date, withTime: boolean): string { 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(uploadState).forEach(k => delete uploadState[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") { - values[f.name] = toDate(f.defaultValue) ?? ""; + values[f.name] = toDate(seed) ?? ""; + } else if (f.widget === "multiselect" || f.widget === "scopes") { + const arr = Array.isArray(seed) ? [...seed] : seed ? [seed] : []; + // 接收对象默认「全部」(与模块页的默认口径一致);用户可取消后另选具体项 + values[f.name] = arr.length ? arr : f.allToken ? [f.allToken] : []; + } else if (f.widget === "file") { + values[f.name] = Array.isArray(seed) ? [...seed] : []; } else if (f.widget === "select" || f.widget === "number") { - values[f.name] = f.defaultValue ?? undefined; + values[f.name] = seed ?? undefined; } else { - values[f.name] = f.defaultValue ?? ""; + values[f.name] = seed ?? ""; } } // 初值就位后立刻拉一次动态选项(含只有一层依赖的情况) @@ -194,6 +296,8 @@ watch( /** 只读回显:已从对话里识别出的参数(优先用后端翻译好的可读文本) */ const presetList = computed(() => { const f = props.form; + // 全字段表单:已经识别的值直接预填进控件了,再列一遍「已识别」只会重复 + if (f?.allFields) return []; if (f?.recognized?.length) { return f.recognized.map(r => ({ name: r.name, label: r.label, text: r.text })); } @@ -299,9 +403,24 @@ function onSubmit() { for (const f of props.form?.fields || []) { const v = values[f.name]; if (v === undefined || v === null || String(v).trim() === "") continue; + // 推送范围(校园公告 pushScopes):把多选结果拼成 [{scopeType,scopeId,scopeName}]; + // 只勾「全部」→ [{scopeType:1,scopeId:0,scopeName:"全部班级"}] + if (f.widget === "scopes") { + const arr = Array.isArray(v) ? v : []; + const specific = arr.filter(x => x !== f.allToken); + const payload = specific.length + ? specific.map(id => { + const o = fieldOptions(f).find(x => String(x.value) === String(id)); + return { scopeType: f.scopeType || 4, scopeId: Number(id), scopeName: o ? o.label : String(id) }; + }) + : [{ scopeType: 1, scopeId: 0, scopeName: f.allTokenLabel || "全部" }]; + args[f.name] = payload; + rows.push({ label: f.label, text: payload.map(p => p.scopeName).join("、") }); + continue; + } const normalized = normalize(f, v); args[f.name] = normalized; - const opt = f.options?.find(o => String(o.value) === String(normalized)); + const opt = fieldOptions(f).find(o => String(o.value) === String(normalized)); rows.push({ label: f.label, text: opt ? opt.label : String(normalized) }); } summary.value = rows; @@ -363,6 +482,36 @@ function reset() { + + +
+ + + 可选,支持图片 / 文档 / 压缩包 +
+ ; /** 已识别参数的可读回显(text 已按字典翻译成中文,如「病假」而非 1) */ diff --git a/src/utils/module-session.ts b/src/utils/module-session.ts index dc29a23..caf9252 100644 --- a/src/utils/module-session.ts +++ b/src/utils/module-session.ts @@ -147,11 +147,20 @@ export function parseOptionItems( const vk = valueKey || "id"; const lk = labelKey || "name"; const out: { value: string; label: string }[] = []; - for (const item of arr) { + // 组织树(年级→班级 / 学部→年级→班级)要拍平成叶子,否则下拉里只会出现「年级」这一层的 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; - const l = (item as any)[lk]; + // 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), @@ -160,6 +169,17 @@ export function parseOptionItems( 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;