配合后端「发布表单与模块页对齐」:前端补齐三类控件与序列化。
- 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:
+219
-9
@@ -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
@@ -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) */
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user