Files
admin-ui/src/components/json-schema-editor/index.vue
T
2910410219andClaude fd287a0d92 fix(json-schema-editor): getNodeJsonPath 路径修复 - 包装对象父节点增加 .attrs. 前缀
根节点直子之外的 { type, attrs } 包装对象,其子字段路径中缺少 .attrs. 段。
通过给包装节点添加 _isWrapped 标记,让路径生成逻辑正确插入 .attrs. 前缀。

例如 messages.attrs[0].content 改为 messages.attrs[0].attrs.content

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 11:08:57 +08:00

639 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="json-editor-split">
<div class="tree-panel">
<div class="tree-root">
<JsonNode
:node="rootNode" :depth="0" :is-root="true"
@change="emitChange"
@add-requested="openAddChildDialog"
/>
</div>
</div>
<div v-if="selectNodeId" class="config-sidebar">
<FieldConfigDialog
:key="selectNodeId"
:panel="true"
:visible="true"
:form-data="fieldDialog.formData"
:field-type-options="fieldDialog.typeOptions"
:has-config="fieldDialog.hasConfig"
@update:visible="clearSelection"
@save="handleFieldSave"
@remove="handleFieldRemove"
/>
</div>
<div v-else class="config-sidebar config-sidebar-empty">
<div class="config-placeholder">
<p>选择一个字段</p>
<p class="config-placeholder-hint">点击字段右侧的齿轮按钮进行配置</p>
</div>
</div>
<el-dialog v-model="showPasteDialog" title="粘贴 JSON" width="560px" :close-on-click-modal="false" destroy-on-close>
<div class="paste-tip">
<el-alert type="info" :closable="false" show-icon>
<template #default>
JSON 字符串粘贴到下方文本框系统将解析并<strong>替换</strong>当前节点的内容
将替换整个根节点
</template>
</el-alert>
</div>
<el-input
v-model="pasteJsonText"
type="textarea"
:rows="12"
placeholder="例如:&#10;{&#10; &quot;name&quot;: &quot;test&quot;,&#10; &quot;age&quot;: 18&#10;}"
:autosize="{ minRows: 8, maxRows: 24 }"
/>
<template #footer>
<el-button @click="showPasteDialog = false; pasteJsonText = ''">取消</el-button>
<el-button type="primary" @click="handlePasteJson">解析并替换</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, watch, provide } from 'vue';
import { ElMessage } from 'element-plus';
import JsonNode from './JsonNode.vue';
import FieldConfigDialog from './FieldConfigDialog.vue';
defineOptions({ name: 'JsonEditor' });
const props = defineProps<{ modelValue: any }>();
const emit = defineEmits<{
(e: 'update:modelValue', value: any): void;
(e: 'change', value: any): void;
}>();
// --- types ---
interface FieldConfig {
fieldType?: string;
label?: string;
description?: string;
role?: string;
isForm?: boolean;
required?: boolean;
loop?: boolean;
isContainer?: boolean;
linkRules?: Array<Record<string, string>>;
defaultValue?: string | number;
options?: Array<{ label: string; value: string }>;
constraint?: {
minLength?: number; maxLength?: number; pattern?: string;
min?: number; max?: number; numberType?: string;
accept?: string; maxSize?: number; maxCount?: number;
uploadRules?: Array<{ format: string; maxSize?: number; maxCount?: number }>;
uploadTotalMaxCount?: number;
uploadTotalMaxSize?: number;
};
}
interface AvailableField {
key: string;
path: string;
}
interface JsonNodeData {
_id: string; key: string; keyEditable: boolean;
type: 'string' | 'number' | 'boolean' | 'null' | 'object' | 'array';
primitiveValue: string | number | boolean | null;
children: JsonNodeData[];
config?: FieldConfig;
_isWrapped?: boolean;
}
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[];
constraint: {
minLength?: number; maxLength?: number; pattern?: string;
min?: number; max?: number; numberType?: string;
accept?: string; maxSize?: number; maxCount?: number;
uploadRules?: Array<{ format: string; maxSize?: number; maxCount?: number }>;
uploadTotalMaxCount?: number;
uploadTotalMaxSize?: number;
};
_createParentId?: string;
_isArrayParent?: boolean;
_childrenCount?: number;
}
// --- helpers ---
let idCounter = 0;
function genId(): string {
return `jn_${Date.now()}_${++idCounter}_${Math.random().toString(36).slice(2, 6)}`;
}
function getDefaultPrimitive(type: string): string | number | boolean | null {
switch (type) {
case 'number': return 0;
case 'boolean': return false;
case 'null': return null;
default: return '';
}
}
function valueToNode(value: any, key: string, keyEditable: boolean): JsonNodeData {
const id = genId();
if (value === null || value === undefined)
return { _id: id, key, keyEditable, type: 'null', primitiveValue: null, children: [] };
// Detect embedded config: { type: '<jsonType>', value/attrs: <actualValue>, ...config }
if (typeof value === 'object' && !Array.isArray(value) && 'type' in value) {
const jtype = value.type;
if (typeof jtype === 'string' && ['string','number','boolean','null','object','array'].includes(jtype)) {
const dataKey = (jtype === 'object' || jtype === 'array') ? 'attrs' : 'value';
if (dataKey in value) {
const rawVal = value[dataKey];
const cfg: Record<string, any> = {};
for (const k of Object.keys(value)) {
if (k !== 'type' && k !== dataKey) cfg[k] = (value as Record<string, any>)[k];
}
const hasCfg = Object.keys(cfg).length > 0;
if (jtype === 'object') {
const children = rawVal ? Object.entries(rawVal).map(([k, v]) => valueToNode(v, k, true)) : [];
return { _id: id, key, keyEditable, type: 'object', primitiveValue: null, children, config: hasCfg ? cfg : undefined, _isWrapped: true };
}
if (jtype === 'array') {
const children = Array.isArray(rawVal) ? rawVal.map((item, idx) => valueToNode(item, String(idx), false)) : [];
return { _id: id, key, keyEditable, type: 'array', primitiveValue: null, children, config: hasCfg ? cfg : undefined, _isWrapped: true };
}
return {
_id: id, key, keyEditable,
type: jtype as 'string' | 'number' | 'boolean' | 'null' | 'object' | 'array',
primitiveValue: rawVal ?? getDefaultPrimitive(jtype),
children: [],
config: hasCfg ? cfg : undefined,
_isWrapped: true,
};
}
}
}
if (Array.isArray(value))
return { _id: id, key, keyEditable, type: 'array', primitiveValue: null, children: value.map((item, idx) => valueToNode(item, String(idx), false)) };
if (typeof value === 'object')
return { _id: id, key, keyEditable, type: 'object', primitiveValue: null, children: Object.entries(value).map(([k, v]) => valueToNode(v, k, true)) };
return { _id: id, key, keyEditable, type: typeof value as 'string' | 'number' | 'boolean', primitiveValue: value, children: [] };
}
function nodeToValue(node: JsonNodeData): any {
let rawValue: any;
if (node.type === 'object') { const obj: Record<string, any> = {}; for (const c of node.children) obj[c.key] = nodeToValue(c); rawValue = obj; }
else if (node.type === 'array') rawValue = node.children.map(c => nodeToValue(c));
else if (node.type === 'null') rawValue = null;
else if (node.type === 'number') rawValue = Number(node.primitiveValue);
else if (node.type === 'boolean') rawValue = node.primitiveValue === true || node.primitiveValue === 'true';
else rawValue = String(node.primitiveValue);
// 最外层根 object 不套 { type, attrs },直接返回原始对象
if (node === rootNode.value) return rawValue;
// 容器类型(object/array)始终输出 { type, attrs, ...config }
if (node.type === 'object' || node.type === 'array') {
const result: Record<string, any> = { type: node.type, attrs: rawValue };
if (node.config && hasAnyConfig(node)) {
Object.assign(result, node.config);
}
return result;
}
// 标量类型也始终输出 { type, value, ...config }
const result: Record<string, any> = { type: node.type, value: rawValue };
if (node.config && hasAnyConfig(node)) {
Object.assign(result, node.config);
}
return result;
}
function defaultFormType(jsonType: string): string {
const m: Record<string, string> = { string: 'string', number: 'number', boolean: 'switch' };
return m[jsonType] || '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);
const pasteJsonText = ref('');
const pastingNodeId = ref<string | null>(null);
function openPasteDialog(nodeId: string | null) {
pastingNodeId.value = nodeId;
pasteJsonText.value = '';
showPasteDialog.value = true;
}
function handlePasteJson() {
const text = pasteJsonText.value.trim();
if (!text) { ElMessage.warning('请粘贴 JSON 内容'); return; }
try {
const parsed = JSON.parse(text);
if (pastingNodeId.value) {
const node = findNodeById(pastingNodeId.value);
if (!node || (node.type !== 'object' && node.type !== 'array')) {
ElMessage.warning('只能在对象或数组节点上粘贴 JSON');
return;
}
if (Array.isArray(parsed)) {
node.type = 'array';
node.children = parsed.map((item, idx) => valueToNode(item, String(idx), false));
} else if (typeof parsed === 'object' && parsed !== null) {
node.type = 'object';
node.children = Object.entries(parsed).map(([k, v]) => valueToNode(v, k, true));
} else {
ElMessage.warning('粘贴的内容必须是对象或数组');
return;
}
} else {
rootNode.value = valueToNode(parsed, '', false);
}
showPasteDialog.value = false;
pasteJsonText.value = '';
emitChange();
} catch (e: any) {
ElMessage.error('JSON 格式错误: ' + (e.message || ''));
}
}
// --- field config dialog ---
const typeOptionList: { label: string; value: string }[] = [
{ label: '文本框', value: 'string' },
{ label: '多行文本', value: 'textarea' },
{ label: '数字输入', value: 'number' },
{ label: '开关', value: 'switch' },
{ label: '下拉选择', value: 'select' },
{ label: '文件上传', value: 'upload' },
];
const selectNodeId = ref<string | null>(null);
const fieldDialog = ref({
visible: false,
formData: null as FieldFormData | null,
typeOptions: [] as { label: string; value: string }[],
hasConfig: false,
});
function clearSelection() {
selectNodeId.value = null;
fieldDialog.value.visible = false;
fieldDialog.value.formData = null;
}
/** Extract FieldConfig from form data */
function formToConfig(form: FieldFormData): FieldConfig {
const cfg: FieldConfig = {};
// 骨架字段:始终发送,保证后端收到完整结构
cfg.label = form.label || '';
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];
const hasC = Object.values(form.constraint).some((v: any) => {
if (Array.isArray(v)) return v.length > 0;
return v !== undefined && v !== null && v !== '';
});
if (hasC) {
cfg.constraint = { ...form.constraint };
if (cfg.constraint.uploadRules) {
cfg.constraint.uploadRules = cfg.constraint.uploadRules.map(r => ({ ...r }));
}
}
return cfg;
}
/** Check if a node has any meaningful config beyond default skeleton values */
function hasAnyConfig(node: JsonNodeData): boolean {
const c = node.config;
if (!c) return false;
if (c.label || c.description || c.role || c.defaultValue) return true;
if (c.options?.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;
}
/** Open dialog in edit mode for an existing node */
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,
hasConfig: hasAnyConfig(node),
formData: {
nodeId: node._id,
nodeKey: node.key,
jsonType: node.type,
fieldType: cfg.fieldType || defaultFormType(node.type),
label: cfg.label || '',
description: cfg.description || '',
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 } : {},
_childrenCount: node.children.length,
},
};
selectNodeId.value = nodeId;
}
provide('onOpenFieldDialog', openFieldDialog);
/** Open dialog in create mode for adding a new child to a parent node */
function openAddChildDialog(parentNodeId: string) {
const parent = findNodeById(parentNodeId);
if (!parent || (parent.type !== 'object' && parent.type !== 'array')) return;
const isArray = parent.type === 'array';
fieldDialog.value = {
visible: true,
typeOptions: typeOptionList,
hasConfig: false,
formData: {
nodeId: '',
nodeKey: '',
jsonType: 'string',
fieldType: 'string',
label: '',
description: '',
role: '',
isForm: true,
required: false,
loop: false,
isContainer: false,
linkRules: [],
_levelFields: [],
defaultValue: '',
options: [],
constraint: {},
_createParentId: parentNodeId,
_isArrayParent: isArray,
_childrenCount: 0,
},
};
selectNodeId.value = parentNodeId;
}
/** Handle save from the config dialog — supports both create and edit modes */
function handleFieldSave(form: FieldFormData) {
if (form._createParentId) {
// --- create mode ---
const parent = findNodeById(form._createParentId);
if (!parent || (parent.type !== 'object' && parent.type !== 'array')) return;
const isArray = parent.type === 'array';
const newNode: JsonNodeData = {
_id: genId(),
key: isArray ? (form.nodeKey || String(parent.children.length)) : (form.nodeKey || 'newKey'),
keyEditable: !isArray,
type: form.jsonType as JsonNodeData['type'],
primitiveValue: getDefaultPrimitive(form.jsonType),
children: (form.jsonType === 'object' || form.jsonType === 'array') ? [] : [],
config: formToConfig(form),
};
parent.children.push(newNode);
fieldDialog.value.visible = false;
emitChange();
clearSelection();
} else {
// --- edit mode ---
const node = findNodeById(form.nodeId);
if (!node) return;
node.key = form.nodeKey;
node.type = form.jsonType as JsonNodeData['type'];
node.config = formToConfig(form);
if (node.type === 'object' || node.type === 'array') {
if (!node.children) node.children = [];
} else {
node.children = [];
if (node.primitiveValue === undefined || node.primitiveValue === null || node.type === 'null') {
node.primitiveValue = getDefaultPrimitive(node.type);
}
}
fieldDialog.value.visible = false;
emitChange();
clearSelection();
}
}
function handleFieldRemove() {
const form = fieldDialog.value.formData;
if (!form) return;
if (form._createParentId) {
fieldDialog.value.visible = false;
clearSelection();
return;
}
const node = findNodeById(form.nodeId);
if (node) {
delete node.config;
fieldDialog.value.visible = false;
emitChange();
clearSelection();
}
}
// --- root node management ---
function createDefaultNode(type: 'string' | 'number' | 'boolean' | 'null' | 'object' | 'array', key: string, keyEditable: boolean): JsonNodeData {
const id = genId();
const base = { _id: id, key, keyEditable, children: [] as JsonNodeData[] };
switch (type) {
case 'object': return { ...base, type: 'object', primitiveValue: null };
case 'array': return { ...base, type: 'array', primitiveValue: null };
case 'null': return { ...base, type: 'null', primitiveValue: null };
case 'number': return { ...base, type: 'number', primitiveValue: 0 };
case 'boolean': return { ...base, type: 'boolean', primitiveValue: false };
default: return { ...base, type: 'string', primitiveValue: '' };
}
}
const rootNode = ref<JsonNodeData>(createDefaultNode('object', '', false));
let skipNextWatch = false;
watch(() => props.modelValue, (val) => {
if (skipNextWatch) { skipNextWatch = false; return; }
rootNode.value = val === undefined || val === null ? createDefaultNode('object', '', false) : valueToNode(val, '', false);
}, { deep: true, immediate: true });
function emitChange() {
skipNextWatch = true;
const json = nodeToValue(rootNode.value);
emit('update:modelValue', json);
emit('change', json);
}
function findNodeById(nodeId: string, from?: JsonNodeData): JsonNodeData | null {
const s = (n: JsonNodeData): JsonNodeData | null => {
if (n._id === nodeId) return n;
for (const c of n.children) { const f = s(c); if (f) return f; }
return null;
};
return s(from || rootNode.value);
}
defineExpose({
getData: () => nodeToValue(rootNode.value),
setData: (d: any) => { rootNode.value = valueToNode(d, '', false); },
openPasteDialog: () => openPasteDialog(null),
getSelectedNodeId: () => selectNodeId.value,
});
</script>
<style scoped lang="scss">
.json-editor-split {
display: flex;
height: 100%;
.tree-panel {
flex: 1;
min-width: 0;
overflow: auto;
.tree-root {
padding: 4px 0;
}
}
.config-sidebar {
width: 320px;
flex-shrink: 0;
border-left: 1px solid #e2e8f0;
overflow: hidden;
}
.config-sidebar-empty {
display: flex;
align-items: center;
justify-content: center;
.config-placeholder {
text-align: center;
color: #94a3b8;
font-size: 13px;
p { margin: 4px 0; }
.config-placeholder-hint { font-size: 11px; color: #c0c8d4; }
}
}
.paste-tip { margin-bottom: 12px;
:deep(.el-alert__description) { font-size: 13px; }
}
}
</style>