feat(form): 表单支持多选接收对象 / 附件上传 / 全字段渲染
build / build (push) Successful in 2m50s

配合后端「发布表单与模块页对齐」:前端补齐三类控件与序列化。

- 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 通过
This commit is contained in:
lijing
2026-10-12 22:05:16 +08:00
parent 2b114829ab
commit da347f21b5
3 changed files with 262 additions and 12 deletions
+219 -9
View File
@@ -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<Record<string, string>>({});
/** 提交后的摘要(用于只读回显) */
const summary = ref<{ label: string; text: string }[]>([]);
/* ==================== 附件上传(widget=file) ==================== */
/** 已上传附件的文件名(按字段名),仅用于界面展示 */
const uploadNames = reactive<Record<string, string[]>>({});
/** 上传状态:idle / uploading */
const uploadState = reactive<Record<string, "idle" | "uploading">>({});
/**
* 附件上传:带**用户自己的登录态**把文件 POST 到模块的 FileUpload 接口(multipart)。
*
* 为什么不走后端:模块的用户态接口只认浏览器 CAS 会话,服务端代调必 302。
* 与下拉代取同理 —— 先直接发,遇到登录跳转就先预热模块会话再重试一次。
*/
async function uploadOne(path: string, file: File): Promise<number | null> {
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<Record<string, "idle" | "loading" | "ok" | "fail">>({}
/** 依赖字段值指纹:只有依赖真的变了才重新拉取,避免每次输入都打接口 */
const depSig = reactive<Record<string, string>>({});
/** 去掉「全部」哨兵后的真实选项(用于判断"字典到底取到没取到") */
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() {
<Option v-for="o in fieldOptions(f)" :key="o.value" :value="o.value">{{ o.label }}</Option>
</Select>
<Select
v-else-if="widgetOf(f) === 'multiselect' || widgetOf(f) === 'scopes'"
v-model="values[f.name]"
multiple
:placeholder="!depsReady(f) ? '请先选择上级项' : (f.example || '可多选')"
:disabled="disabled || !depsReady(f) || optState[f.name] === 'loading'"
:loading="optState[f.name] === 'loading'"
>
<Option v-for="o in fieldOptions(f)" :key="o.value" :value="o.value">{{ o.label }}</Option>
</Select>
<div v-else-if="widgetOf(f) === 'file'" class="field__file">
<label class="file-btn" :class="{ 'file-btn--busy': uploadState[f.name] === 'uploading' }">
<input
type="file"
:multiple="f.multiple !== false"
:disabled="disabled || uploadState[f.name] === 'uploading'"
@change="onPickFiles(f, $event)"
/>
{{ uploadState[f.name] === "uploading" ? "上传中…" : "选择文件" }}
</label>
<ul v-if="(uploadNames[f.name] || []).length" class="file-list">
<li v-for="(n, i) in uploadNames[f.name]" :key="i">
<span class="file-list__name">{{ n }}</span>
<a v-if="!disabled" @click="removeFile(f, i)">移除</a>
</li>
</ul>
<span v-else class="file-tip">可选,支持图片 / 文档 / 压缩包</span>
</div>
<DatePicker
v-else-if="widgetOf(f) === 'datetime'"
v-model="values[f.name]"
@@ -579,6 +728,67 @@ function reset() {
color: var(--agent-danger);
}
/* ------------------------------ 附件上传 ------------------------------ */
.file-btn {
display: inline-flex;
align-items: center;
height: 32px;
padding: 0 14px;
border: 1px dashed var(--agent-border-strong);
border-radius: 8px;
font-size: 12.5px;
color: var(--agent-text-sub);
background: var(--agent-surface-2);
cursor: pointer;
transition: all 0.16s ease;
}
.file-btn:hover {
border-color: var(--agent-primary);
color: var(--agent-primary-dark);
}
.file-btn--busy {
opacity: 0.6;
cursor: progress;
}
.file-btn input[type="file"] {
display: none;
}
.file-list {
list-style: none;
margin: 8px 0 0;
padding: 0;
}
.file-list li {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--agent-text-sub);
padding: 2px 0;
}
.file-list__name {
word-break: break-all;
}
.file-list a {
flex: none;
color: var(--agent-danger);
cursor: pointer;
}
.file-tip {
display: inline-block;
margin-left: 8px;
font-size: 11px;
color: var(--agent-text-mute);
}
/* ------------------------------ 提交后摘要 ------------------------------ */
.form-card__summary {
padding: 2px 0 6px;
+21 -1
View File
@@ -36,7 +36,7 @@ export interface FormField {
/** 中文标签,如「开始时间」 */
label: string;
/** 控件类型 */
widget: "text" | "textarea" | "number" | "date" | "datetime" | "select";
widget: "text" | "textarea" | "number" | "date" | "datetime" | "select" | "multiselect" | "file";
type?: string;
required: boolean;
/** 字段说明(含义与格式) */
@@ -44,6 +44,21 @@ export interface FormField {
/** 格式示例,用作 placeholder */
example?: string;
defaultValue?: string;
/**
* 数组值的序列化分隔符(后端会在发请求前把数组拼成模块要的逗号串,前端原样提交数组即可)。
* 这里只用于「提交前如实呈现最终值」。
*/
joinSeparator?: string;
/** widget=file:附件上传地址(相对站点根,如 /dormnotice/api/base/FileUpload) */
uploadPath?: string;
/** widget=file:是否允许多个附件 */
multiple?: boolean;
/** 「全部」哨兵值(如 ALL):作为多选的第一项,可选中代表「全部班级 / 全部宿舍」 */
allToken?: string;
/** 「全部」选项的显示名 */
allTokenLabel?: string;
/** widget=scopes(校园公告 pushScopes):节点类型 1 全校 / 2 学部 / 3 年级 / 4 班级 / 5 训练队 / 6 学员 */
scopeType?: number;
/** 下拉选项 */
options?: { value: string; label: string }[];
/**
@@ -84,6 +99,11 @@ export interface FormRequest {
write: boolean;
landingPath?: string;
fields: FormField[];
/**
* fields 是否包含该工具的**全部字段**(含可选项)—— 即与业务模块自己的「新建/发布」页一致。
* true 时不再单独渲染「已识别」只读标签(值已直接填进控件)。
*/
allFields?: boolean;
/** 已从对话中确定的参数(只读回显) */
preset?: Record<string, any>;
/** 已识别参数的可读回显(text 已按字典翻译成中文,如「病假」而非 1) */
+22 -2
View File
@@ -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;