feat(json-schema-editor): 数组字段支持枚举值配置,移除循环生成与容器联动
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -25,9 +25,15 @@
|
||||
</el-select>
|
||||
<div class="form-hint">JSON 数据类型</div>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.jsonType === 'object'" label="循环生成">
|
||||
<el-switch v-model="form.loop" />
|
||||
<span class="form-hint">此对象字段是否循环生成</span>
|
||||
|
||||
<el-form-item v-if="form.jsonType === 'array'" label="枚举配置">
|
||||
<div class="enum-config-inline">
|
||||
<el-button size="small" @click="enumDialogVisible = true">
|
||||
{{ form.enumValues.length > 0 ? '编辑枚举值' : '配置枚举值' }}
|
||||
</el-button>
|
||||
<span v-if="form.enumValues.length > 0" class="form-hint">已配置 {{ form.enumValues.length }} 个枚举值</span>
|
||||
<span v-else class="form-hint">设置数组允许的固定值列表</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="jsonTypeIsScalar" label="默认值">
|
||||
<template v-if="form.jsonType === 'boolean'">
|
||||
@@ -38,15 +44,6 @@
|
||||
</template>
|
||||
<el-input v-else v-model="form.defaultValue" placeholder="留空则无默认值" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="jsonTypeIsScalar" label="容器字段">
|
||||
<el-switch v-model="form.isContainer" />
|
||||
<span class="form-hint">{{ form.isContainer ? '此字段为容器触发器,将联动隐藏其他字段' : '' }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.isContainer && form._levelFields.length > 0" label="联动隐藏">
|
||||
<el-select multiple collapse-tags collapse-tags-tooltip :model-value="selectedKeys" @change="onSelectChange" placeholder="选择要隐藏的字段" style="width:100%">
|
||||
<el-option v-for="field in form._levelFields" :key="field.key" :label="`${field.key} — ${field.path}`" :value="field.key" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<template v-if="jsonTypeIsScalar">
|
||||
<el-divider content-position="left">表单属性</el-divider>
|
||||
<el-form-item label="表单显示">
|
||||
@@ -139,9 +136,15 @@
|
||||
</el-select>
|
||||
<div class="form-hint">JSON 数据类型</div>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.jsonType === 'object'" label="循环生成">
|
||||
<el-switch v-model="form.loop" />
|
||||
<span class="form-hint">此对象字段是否循环生成</span>
|
||||
|
||||
<el-form-item v-if="form.jsonType === 'array'" label="枚举配置">
|
||||
<div class="enum-config-inline">
|
||||
<el-button size="small" @click="enumDialogVisible = true">
|
||||
{{ form.enumValues.length > 0 ? '编辑枚举值' : '配置枚举值' }}
|
||||
</el-button>
|
||||
<span v-if="form.enumValues.length > 0" class="form-hint">已配置 {{ form.enumValues.length }} 个枚举值</span>
|
||||
<span v-else class="form-hint">设置数组允许的固定值列表</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="jsonTypeIsScalar" label="默认值">
|
||||
<template v-if="form.jsonType === 'boolean'">
|
||||
@@ -152,15 +155,6 @@
|
||||
</template>
|
||||
<el-input v-else v-model="form.defaultValue" placeholder="留空则无默认值" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="jsonTypeIsScalar" label="容器字段">
|
||||
<el-switch v-model="form.isContainer" />
|
||||
<span class="form-hint">{{ form.isContainer ? '此字段为容器触发器,将联动隐藏其他字段' : '' }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.isContainer && form._levelFields.length > 0" label="联动隐藏">
|
||||
<el-select multiple collapse-tags collapse-tags-tooltip :model-value="selectedKeys" @change="onSelectChange" placeholder="选择要隐藏的字段" style="width:100%">
|
||||
<el-option v-for="field in form._levelFields" :key="field.key" :label="`${field.key} — ${field.path}`" :value="field.key" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<template v-if="jsonTypeIsScalar">
|
||||
<el-divider content-position="left">表单属性</el-divider>
|
||||
<el-form-item label="表单显示">
|
||||
@@ -220,27 +214,37 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
<!-- Enum editor dialog -->
|
||||
<el-dialog v-model="enumDialogVisible" title="枚举值配置" width="640px" :close-on-click-modal="false" destroy-on-close>
|
||||
<el-alert type="info" :closable="false" show-icon style="margin-bottom: 12px;">
|
||||
<template #default>
|
||||
编辑数组字段允许的固定值列表。每个值为一个允许的枚举项。
|
||||
</template>
|
||||
</el-alert>
|
||||
<div class="enum-editor-container">
|
||||
<JsonEditor v-model="form.enumValues" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="enumDialogVisible = false">关闭</el-button>
|
||||
<el-button type="primary" @click="enumDialogVisible = false">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch } from 'vue';
|
||||
import { ref, reactive, computed, watch, defineAsyncComponent } from 'vue';
|
||||
import { ElMessageBox } from 'element-plus';
|
||||
import { Plus } from '@element-plus/icons-vue';
|
||||
|
||||
interface UploadRule { format: string; maxSize?: number; maxCount?: number; }
|
||||
const JsonEditor = defineAsyncComponent(() => import('./index.vue'));
|
||||
|
||||
interface AvailableField {
|
||||
key: string;
|
||||
path: string;
|
||||
}
|
||||
interface UploadRule { format: string; maxSize?: number; maxCount?: number; }
|
||||
|
||||
interface FieldFormData {
|
||||
nodeKey: string; nodeId: string; jsonType: string; fieldType: string;
|
||||
label: string; description: string; role: string;
|
||||
isForm: boolean; required: boolean; loop: boolean; defaultValue: string; options: Array<{ label: string; value: string }>;
|
||||
isContainer: boolean;
|
||||
linkRules: Array<Record<string, string>>;
|
||||
_levelFields: AvailableField[];
|
||||
isForm: boolean; required: boolean; defaultValue: string; options: Array<{ label: string; value: string }>;
|
||||
enumValues: any[];
|
||||
constraint: {
|
||||
minLength?: number; maxLength?: number; pattern?: string;
|
||||
min?: number; max?: number; numberType?: string;
|
||||
@@ -267,13 +271,12 @@ const emit = defineEmits<{
|
||||
const form = reactive<FieldFormData>({
|
||||
nodeKey: '', nodeId: '', jsonType: 'string', fieldType: 'string',
|
||||
label: '', description: '', role: '',
|
||||
isForm: true, required: false, loop: false, defaultValue: '', options: [], constraint: {},
|
||||
isContainer: false,
|
||||
linkRules: [],
|
||||
_levelFields: [],
|
||||
isForm: true, required: false, defaultValue: '', options: [], constraint: {},
|
||||
enumValues: [],
|
||||
});
|
||||
|
||||
const uploadRules = ref<UploadRule[]>([]);
|
||||
const enumDialogVisible = ref(false);
|
||||
const prevJsonType = ref('');
|
||||
|
||||
const isCreateMode = computed(() => !!form._createParentId);
|
||||
@@ -286,30 +289,16 @@ const dialogTitle = computed(() => {
|
||||
: '字段配置 —— ' + form.nodeKey;
|
||||
});
|
||||
|
||||
// 当前选中的字段 key 列表(用于 v-model)
|
||||
const selectedKeys = computed(() =>
|
||||
form.linkRules.map((r) => Object.keys(r)[0]).filter(Boolean) as string[]
|
||||
);
|
||||
|
||||
function onSelectChange(keys: string[]) {
|
||||
form.linkRules = keys.map((key) => {
|
||||
const match = form._levelFields.find((f) => f.key === key);
|
||||
return { [key]: match ? match.path : key };
|
||||
});
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.nodeKey = ''; form.nodeId = ''; form.jsonType = 'string'; form.fieldType = 'string';
|
||||
form.label = ''; form.description = ''; form.role = '';
|
||||
form.isForm = true; form.required = false; form.defaultValue = '';
|
||||
form.loop = false;
|
||||
form.isContainer = false;
|
||||
form.linkRules = [];
|
||||
form._levelFields = [];
|
||||
form.options = []; form.constraint = {};
|
||||
form.enumValues = [];
|
||||
// @ts-ignore
|
||||
form._createParentId = undefined; form._isArrayParent = false;
|
||||
uploadRules.value = [];
|
||||
enumDialogVisible.value = false;
|
||||
}
|
||||
|
||||
function loadForm(data: FieldFormData | null) {
|
||||
@@ -320,20 +309,17 @@ function loadForm(data: FieldFormData | null) {
|
||||
form.jsonType = data.jsonType || 'string'; form.fieldType = data.fieldType;
|
||||
form.label = data.label || ''; form.description = data.description || ''; form.role = data.role || '';
|
||||
form.isForm = data.isForm ?? true; form.required = data.required || false;
|
||||
form.loop = data.loop ?? false;
|
||||
form.defaultValue = data.defaultValue || '';
|
||||
form.options = data.options ? [...data.options] : [];
|
||||
if (form.options.length > 0 && typeof (form.options as any)[0] === 'string') {
|
||||
form.options = (data.options as string[]).map(s => ({ label: s, value: s }));
|
||||
form.options = (data.options as unknown as string[]).map(s => ({ label: s, value: s }));
|
||||
}
|
||||
form.constraint = data.constraint ? { ...data.constraint } : {};
|
||||
form.enumValues = data.enumValues ? JSON.parse(JSON.stringify(data.enumValues)) : [];
|
||||
// @ts-ignore
|
||||
form._createParentId = data._createParentId; form._isArrayParent = data._isArrayParent ?? false;
|
||||
// @ts-ignore
|
||||
form._childrenCount = data._childrenCount ?? 0;
|
||||
form.isContainer = data.isContainer ?? false;
|
||||
form.linkRules = data.linkRules ? data.linkRules.map((r) => ({ ...r })) : [];
|
||||
form._levelFields = data._levelFields ? data._levelFields.map((l) => ({ ...l })) : [];
|
||||
if (data.constraint?.uploadRules?.length) {
|
||||
uploadRules.value = data.constraint.uploadRules.map((r) => ({ ...r }));
|
||||
} else if (data.constraint?.accept) {
|
||||
@@ -411,6 +397,8 @@ function handleClosed() { resetForm(); }
|
||||
}
|
||||
}
|
||||
.form-hint { font-size: 11px; color: #94a3b8; margin-left: 8px; }
|
||||
.enum-config-inline { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.enum-editor-container { height: 360px; border: 1px solid #e2e8f0; border-radius: 4px; overflow: hidden; }
|
||||
.options-editor { width: 100%;
|
||||
.option-row { display: flex; gap: 4px; margin-bottom: 4px; }
|
||||
.add-option-btn { font-size: 12px; margin-top: 4px; }
|
||||
|
||||
@@ -75,11 +75,9 @@ interface FieldConfig {
|
||||
role?: string;
|
||||
isForm?: boolean;
|
||||
required?: boolean;
|
||||
loop?: boolean;
|
||||
isContainer?: boolean;
|
||||
linkRules?: Array<Record<string, string>>;
|
||||
defaultValue?: string | number;
|
||||
options?: Array<{ label: string; value: string }>;
|
||||
enumValues?: any[];
|
||||
constraint?: {
|
||||
minLength?: number; maxLength?: number; pattern?: string;
|
||||
min?: number; max?: number; numberType?: string;
|
||||
@@ -90,11 +88,6 @@ interface FieldConfig {
|
||||
};
|
||||
}
|
||||
|
||||
interface AvailableField {
|
||||
key: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface JsonNodeData {
|
||||
_id: string; key: string; keyEditable: boolean;
|
||||
type: 'string' | 'number' | 'boolean' | 'null' | 'object' | 'array';
|
||||
@@ -109,10 +102,8 @@ interface FieldFormData {
|
||||
nodeId: string;
|
||||
jsonType: string;
|
||||
fieldType: string; label: string; description: string; role: string;
|
||||
isForm: boolean; required: boolean; loop: boolean; defaultValue: string; options: Array<{ label: string; value: string }>;
|
||||
isContainer: boolean;
|
||||
linkRules: Array<Record<string, string>>;
|
||||
_levelFields: AvailableField[];
|
||||
isForm: boolean; required: boolean; defaultValue: string; options: Array<{ label: string; value: string }>;
|
||||
enumValues: any[];
|
||||
constraint: {
|
||||
minLength?: number; maxLength?: number; pattern?: string;
|
||||
min?: number; max?: number; numberType?: string;
|
||||
@@ -221,84 +212,6 @@ function defaultFormType(jsonType: string): string {
|
||||
}
|
||||
|
||||
/** 收集当前字段同级及以上的所有可选字段(平铺,含完整路径) */
|
||||
function getAvailableFields(nodeId: string): AvailableField[] {
|
||||
const result: AvailableField[] = [];
|
||||
const currentKey = findNodeById(nodeId)?.key;
|
||||
if (!currentKey) return [];
|
||||
|
||||
// 构建祖先链 [root, ..., currentNode]
|
||||
const path: { node: JsonNodeData; parent: JsonNodeData | null }[] = [];
|
||||
(function walk(n: JsonNodeData, p: JsonNodeData | null): boolean {
|
||||
if (n._id === nodeId) { path.push({ node: n, parent: p }); return true; }
|
||||
for (const c of n.children) { if (walk(c, n)) { path.push({ node: n, parent: p }); return true; } }
|
||||
return false;
|
||||
})(rootNode.value, null);
|
||||
if (path.length < 2) return [];
|
||||
|
||||
// 从当前字段开始逐层向上,收集同辈字段(含容器自身)
|
||||
const seen = new Set<string>();
|
||||
// path = [current, ..., root],所以从倒数第 2 个(root 的直子)开始向前遍历
|
||||
for (let i = path.length - 2; i >= 0; i--) {
|
||||
const container = path[i];
|
||||
const parent = container.parent;
|
||||
|
||||
// 跳过数组层级(数组元素的兄弟是索引号,无意义)
|
||||
if (parent && parent.type === 'array') continue;
|
||||
|
||||
// 根级(path.length-2 = root 的直子)只包含容器自身
|
||||
if (i === path.length - 2) {
|
||||
if (container.node.key !== currentKey && !seen.has(container.node.key)) {
|
||||
seen.add(container.node.key);
|
||||
result.push({ key: container.node.key, path: getNodeJsonPath(container.node._id) });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 其他层级:收集所有同辈字段(含容器自身)
|
||||
const siblings = parent ? parent.children : container.node.children;
|
||||
for (const sib of siblings) {
|
||||
if (sib.key === currentKey) continue;
|
||||
if (seen.has(sib.key)) continue;
|
||||
seen.add(sib.key);
|
||||
|
||||
result.push({ key: sib.key, path: getNodeJsonPath(sib._id) });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 计算某个节点在 JSON 中的完整路径(如 messages.attrs[0].attrs.content.attrs[0].attrs.type) */
|
||||
function getNodeJsonPath(nodeId: string): string {
|
||||
// 构建祖先链 [root, ..., target]
|
||||
const chain: JsonNodeData[] = [];
|
||||
(function walk(n: JsonNodeData): boolean {
|
||||
if (n._id === nodeId) { chain.unshift(n); return true; }
|
||||
for (const c of n.children) { if (walk(c)) { chain.unshift(n); return true; } }
|
||||
return false;
|
||||
})(rootNode.value);
|
||||
if (chain.length < 1) return '';
|
||||
|
||||
let path = '';
|
||||
|
||||
for (let i = 1; i < chain.length; i++) {
|
||||
const node = chain[i];
|
||||
const parent = chain[i - 1];
|
||||
|
||||
if (i === 1) {
|
||||
path = node.key;
|
||||
} else if (parent.type === 'array') {
|
||||
// 数组父节点:{ type: "array", attrs: [...] } 固定走 .attrs[index]
|
||||
path += `.attrs[${node.key}]`;
|
||||
} else if (parent._isWrapped) {
|
||||
// 包装对象父节点(来自 { type, attrs }):字段通过 .attrs. 访问
|
||||
path += `.attrs.${node.key}`;
|
||||
} else {
|
||||
// 普通对象父节点:直接 . 访问
|
||||
path += `.${node.key}`;
|
||||
}
|
||||
}
|
||||
return path;
|
||||
}
|
||||
// --- paste JSON ---
|
||||
|
||||
const showPasteDialog = ref(false);
|
||||
@@ -379,19 +292,12 @@ function formToConfig(form: FieldFormData): FieldConfig {
|
||||
cfg.fieldType = form.fieldType || defaultFormType(form.jsonType);
|
||||
cfg.isForm = form.isForm ?? true;
|
||||
cfg.required = form.required ?? false;
|
||||
cfg.loop = form.loop ?? false;
|
||||
// isContainer + linkRules
|
||||
if (form.isContainer && form.linkRules?.length > 0) {
|
||||
cfg.isContainer = true;
|
||||
cfg.linkRules = form.linkRules
|
||||
.filter((r) => r && typeof r === 'object' && Object.keys(r).length > 0)
|
||||
.map((r) => ({ ...r }));
|
||||
}
|
||||
// 非骨架字段:有值才发
|
||||
if (form.description) cfg.description = form.description;
|
||||
if (form.role) cfg.role = form.role;
|
||||
if (form.defaultValue) cfg.defaultValue = form.jsonType === 'number' ? Number(form.defaultValue) : form.defaultValue;
|
||||
if (form.options?.length > 0) cfg.options = [...form.options];
|
||||
if (form.enumValues?.length > 0) cfg.enumValues = JSON.parse(JSON.stringify(form.enumValues));
|
||||
const hasC = Object.values(form.constraint).some((v: any) => {
|
||||
if (Array.isArray(v)) return v.length > 0;
|
||||
return v !== undefined && v !== null && v !== '';
|
||||
@@ -411,11 +317,9 @@ function hasAnyConfig(node: JsonNodeData): boolean {
|
||||
if (!c) return false;
|
||||
if (c.label || c.description || c.role || c.defaultValue) return true;
|
||||
if (c.options?.length) return true;
|
||||
if (c.enumValues?.length) return true;
|
||||
if (c.required) return true;
|
||||
if (c.loop) return true;
|
||||
if (c.isForm === false) return true;
|
||||
if (c.isContainer) return true;
|
||||
if (c.linkRules?.length) return true;
|
||||
if (c.constraint && Object.keys(c.constraint).length > 0) return true;
|
||||
if (c.fieldType && c.fieldType !== defaultFormType(node.type)) return true;
|
||||
return false;
|
||||
@@ -426,8 +330,6 @@ function openFieldDialog(nodeId: string) {
|
||||
const node = findNodeById(nodeId);
|
||||
if (!node) return;
|
||||
const cfg = node.config || {};
|
||||
const isScalar = node.type !== 'object' && node.type !== 'array';
|
||||
const levelFields = isScalar ? getAvailableFields(nodeId) : [];
|
||||
fieldDialog.value = {
|
||||
visible: true,
|
||||
typeOptions: typeOptionList,
|
||||
@@ -442,13 +344,10 @@ function openFieldDialog(nodeId: string) {
|
||||
role: cfg.role || '',
|
||||
isForm: cfg.isForm ?? true,
|
||||
required: cfg.required ?? false,
|
||||
loop: cfg.loop ?? false,
|
||||
isContainer: cfg.isContainer ?? false,
|
||||
linkRules: cfg.linkRules ? cfg.linkRules.map((r) => ({ ...r })) : [],
|
||||
_levelFields: levelFields,
|
||||
defaultValue: String(cfg.defaultValue ?? ''),
|
||||
options: cfg.options || [],
|
||||
constraint: cfg.constraint ? { ...cfg.constraint } : {},
|
||||
enumValues: cfg.enumValues ? JSON.parse(JSON.stringify(cfg.enumValues)) : [],
|
||||
_childrenCount: node.children.length,
|
||||
},
|
||||
};
|
||||
@@ -477,13 +376,10 @@ function openAddChildDialog(parentNodeId: string) {
|
||||
role: '',
|
||||
isForm: true,
|
||||
required: false,
|
||||
loop: false,
|
||||
isContainer: false,
|
||||
linkRules: [],
|
||||
_levelFields: [],
|
||||
defaultValue: '',
|
||||
options: [],
|
||||
constraint: {},
|
||||
enumValues: [],
|
||||
_createParentId: parentNodeId,
|
||||
_isArrayParent: isArray,
|
||||
_childrenCount: 0,
|
||||
|
||||
Reference in New Issue
Block a user