配合后端「发布表单与模块页对齐」:前端补齐三类控件与序列化。
- 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 { reactive, computed, ref, watch } from "vue";
|
||||||
import { Message } from "view-ui-plus";
|
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";
|
import type { FormField, FormRequest } from "@/types/agent";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -52,6 +52,86 @@ const errors = reactive<Record<string, string>>({});
|
|||||||
/** 提交后的摘要(用于只读回显) */
|
/** 提交后的摘要(用于只读回显) */
|
||||||
const summary = ref<{ label: string; text: 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>>({});
|
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) {
|
function fieldOptions(f: FormField) {
|
||||||
const dyn = dynOptions[f.name];
|
const base = baseOptions(f);
|
||||||
if (dyn && dyn.length) return dyn;
|
// 「全部」哨兵(全部班级 / 全部宿舍)永远作为第一个选项,无论选项是不是动态取回的
|
||||||
return f.options || [];
|
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) {
|
function widgetOf(f: FormField) {
|
||||||
const st = optState[f.name];
|
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.type === "integer" || f.type === "number" ? "number" : "text";
|
||||||
}
|
}
|
||||||
return f.widget;
|
return f.widget;
|
||||||
@@ -165,14 +256,25 @@ function fmt(d: Date, withTime: boolean): string {
|
|||||||
function initValues() {
|
function initValues() {
|
||||||
Object.keys(values).forEach(k => delete values[k]);
|
Object.keys(values).forEach(k => delete values[k]);
|
||||||
Object.keys(errors).forEach(k => delete errors[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();
|
resetOptionState();
|
||||||
|
// 「全字段表单」把已识别的参数直接预填进控件(不再只做只读回显)
|
||||||
|
const preset = props.form?.preset || {};
|
||||||
for (const f of props.form?.fields || []) {
|
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.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") {
|
} else if (f.widget === "select" || f.widget === "number") {
|
||||||
values[f.name] = f.defaultValue ?? undefined;
|
values[f.name] = seed ?? undefined;
|
||||||
} else {
|
} else {
|
||||||
values[f.name] = f.defaultValue ?? "";
|
values[f.name] = seed ?? "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 初值就位后立刻拉一次动态选项(含只有一层依赖的情况)
|
// 初值就位后立刻拉一次动态选项(含只有一层依赖的情况)
|
||||||
@@ -194,6 +296,8 @@ watch(
|
|||||||
/** 只读回显:已从对话里识别出的参数(优先用后端翻译好的可读文本) */
|
/** 只读回显:已从对话里识别出的参数(优先用后端翻译好的可读文本) */
|
||||||
const presetList = computed(() => {
|
const presetList = computed(() => {
|
||||||
const f = props.form;
|
const f = props.form;
|
||||||
|
// 全字段表单:已经识别的值直接预填进控件了,再列一遍「已识别」只会重复
|
||||||
|
if (f?.allFields) return [];
|
||||||
if (f?.recognized?.length) {
|
if (f?.recognized?.length) {
|
||||||
return f.recognized.map(r => ({ name: r.name, label: r.label, text: r.text }));
|
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 || []) {
|
for (const f of props.form?.fields || []) {
|
||||||
const v = values[f.name];
|
const v = values[f.name];
|
||||||
if (v === undefined || v === null || String(v).trim() === "") continue;
|
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);
|
const normalized = normalize(f, v);
|
||||||
args[f.name] = normalized;
|
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) });
|
rows.push({ label: f.label, text: opt ? opt.label : String(normalized) });
|
||||||
}
|
}
|
||||||
summary.value = rows;
|
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>
|
<Option v-for="o in fieldOptions(f)" :key="o.value" :value="o.value">{{ o.label }}</Option>
|
||||||
</Select>
|
</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
|
<DatePicker
|
||||||
v-else-if="widgetOf(f) === 'datetime'"
|
v-else-if="widgetOf(f) === 'datetime'"
|
||||||
v-model="values[f.name]"
|
v-model="values[f.name]"
|
||||||
@@ -579,6 +728,67 @@ function reset() {
|
|||||||
color: var(--agent-danger);
|
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 {
|
.form-card__summary {
|
||||||
padding: 2px 0 6px;
|
padding: 2px 0 6px;
|
||||||
|
|||||||
+21
-1
@@ -36,7 +36,7 @@ export interface FormField {
|
|||||||
/** 中文标签,如「开始时间」 */
|
/** 中文标签,如「开始时间」 */
|
||||||
label: string;
|
label: string;
|
||||||
/** 控件类型 */
|
/** 控件类型 */
|
||||||
widget: "text" | "textarea" | "number" | "date" | "datetime" | "select";
|
widget: "text" | "textarea" | "number" | "date" | "datetime" | "select" | "multiselect" | "file";
|
||||||
type?: string;
|
type?: string;
|
||||||
required: boolean;
|
required: boolean;
|
||||||
/** 字段说明(含义与格式) */
|
/** 字段说明(含义与格式) */
|
||||||
@@ -44,6 +44,21 @@ export interface FormField {
|
|||||||
/** 格式示例,用作 placeholder */
|
/** 格式示例,用作 placeholder */
|
||||||
example?: string;
|
example?: string;
|
||||||
defaultValue?: 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 }[];
|
options?: { value: string; label: string }[];
|
||||||
/**
|
/**
|
||||||
@@ -84,6 +99,11 @@ export interface FormRequest {
|
|||||||
write: boolean;
|
write: boolean;
|
||||||
landingPath?: string;
|
landingPath?: string;
|
||||||
fields: FormField[];
|
fields: FormField[];
|
||||||
|
/**
|
||||||
|
* fields 是否包含该工具的**全部字段**(含可选项)—— 即与业务模块自己的「新建/发布」页一致。
|
||||||
|
* true 时不再单独渲染「已识别」只读标签(值已直接填进控件)。
|
||||||
|
*/
|
||||||
|
allFields?: boolean;
|
||||||
/** 已从对话中确定的参数(只读回显) */
|
/** 已从对话中确定的参数(只读回显) */
|
||||||
preset?: Record<string, any>;
|
preset?: Record<string, any>;
|
||||||
/** 已识别参数的可读回显(text 已按字典翻译成中文,如「病假」而非 1) */
|
/** 已识别参数的可读回显(text 已按字典翻译成中文,如「病假」而非 1) */
|
||||||
|
|||||||
@@ -147,11 +147,20 @@ export function parseOptionItems(
|
|||||||
const vk = valueKey || "id";
|
const vk = valueKey || "id";
|
||||||
const lk = labelKey || "name";
|
const lk = labelKey || "name";
|
||||||
const out: { value: string; label: string }[] = [];
|
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;
|
if (item == null || typeof item !== "object") continue;
|
||||||
const v = (item as any)[vk];
|
const v = (item as any)[vk];
|
||||||
if (v === undefined || v === null || String(v) === "") continue;
|
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({
|
out.push({
|
||||||
value: String(v),
|
value: String(v),
|
||||||
label: l === undefined || l === null || String(l) === "" ? String(v) : String(l),
|
label: l === undefined || l === null || String(l) === "" ? String(v) : String(l),
|
||||||
@@ -160,6 +169,17 @@ export function parseOptionItems(
|
|||||||
return out;
|
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) */
|
/** 递归找第一个数组(限深,容忍 data 外面再包一层 list/records/rows) */
|
||||||
function findArray(node: any, depth: number): any[] | null {
|
function findArray(node: any, depth: number): any[] | null {
|
||||||
if (node == null || depth > 3) return null;
|
if (node == null || depth > 3) return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user