418 lines
13 KiB
Vue
418 lines
13 KiB
Vue
<template>
|
||
<div class="json-editor-tree">
|
||
<div class="tree-root">
|
||
<JsonNode
|
||
:node="rootNode" :depth="0" :is-root="true"
|
||
@change="emitChange"
|
||
@add-requested="openAddChildDialog"
|
||
/>
|
||
</div>
|
||
<FieldConfigDialog
|
||
v-model:visible="fieldDialog.visible"
|
||
:form-data="fieldDialog.formData"
|
||
:field-type-options="fieldDialog.typeOptions"
|
||
:has-config="fieldDialog.hasConfig"
|
||
@save="handleFieldSave"
|
||
@remove="handleFieldRemove"
|
||
/>
|
||
<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="例如: { "name": "test", "age": 18 }"
|
||
: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;
|
||
defaultValue?: string | number;
|
||
options?: 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 JsonNodeData {
|
||
_id: string; key: string; keyEditable: boolean;
|
||
type: 'string' | 'number' | 'boolean' | 'null' | 'object' | 'array';
|
||
primitiveValue: string | number | boolean | null;
|
||
children: JsonNodeData[];
|
||
config?: FieldConfig;
|
||
}
|
||
|
||
interface FieldFormData {
|
||
nodeKey: string;
|
||
nodeId: string;
|
||
jsonType: string;
|
||
fieldType: string; label: string; description: string; role: string;
|
||
isForm: boolean; required: boolean; defaultValue: string; options: 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;
|
||
};
|
||
_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: [] };
|
||
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 {
|
||
if (node.type === 'object') { const obj: Record<string, any> = {}; for (const c of node.children) obj[c.key] = nodeToValue(c); return obj; }
|
||
if (node.type === 'array') return node.children.map(c => nodeToValue(c));
|
||
if (node.type === 'null') return null;
|
||
if (node.type === 'number') return Number(node.primitiveValue);
|
||
if (node.type === 'boolean') return node.primitiveValue === true || node.primitiveValue === 'true';
|
||
return String(node.primitiveValue);
|
||
}
|
||
|
||
function defaultFormType(jsonType: string): string {
|
||
const m: Record<string, string> = { string: 'string', number: 'number', boolean: 'switch' };
|
||
return m[jsonType] || 'string';
|
||
}
|
||
|
||
// --- 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 fieldDialog = ref({
|
||
visible: false,
|
||
formData: null as FieldFormData | null,
|
||
typeOptions: [] as { label: string; value: string }[],
|
||
hasConfig: false,
|
||
});
|
||
|
||
/** Extract FieldConfig from form data */
|
||
function formToConfig(form: FieldFormData): FieldConfig {
|
||
const cfg: FieldConfig = {};
|
||
if (form.label) cfg.label = form.label;
|
||
if (form.description) cfg.description = form.description;
|
||
if (form.fieldType && form.fieldType !== defaultFormType(form.jsonType)) cfg.fieldType = form.fieldType;
|
||
if (form.role) cfg.role = form.role;
|
||
if (!form.isForm) cfg.isForm = false;
|
||
if (form.required) cfg.required = true;
|
||
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 };
|
||
// Deep copy uploadRules array
|
||
if (cfg.constraint.uploadRules) {
|
||
cfg.constraint.uploadRules = cfg.constraint.uploadRules.map(r => ({ ...r }));
|
||
}
|
||
}
|
||
return cfg;
|
||
}
|
||
|
||
/** Check if a node has any meaningful config */
|
||
function hasAnyConfig(node: JsonNodeData): boolean {
|
||
const c = node.config;
|
||
if (!c) return false;
|
||
return !!(c.label || c.description || c.fieldType || c.role || c.isForm === false || c.required || c.defaultValue || c.options?.length || (c.constraint && Object.keys(c.constraint).length > 0));
|
||
}
|
||
|
||
/** Open dialog in edit mode for an existing node */
|
||
function openFieldDialog(nodeId: string) {
|
||
const node = findNodeById(nodeId);
|
||
if (!node) return;
|
||
const cfg = node.config || {};
|
||
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,
|
||
defaultValue: String(cfg.defaultValue ?? ''),
|
||
options: cfg.options || [],
|
||
constraint: cfg.constraint ? { ...cfg.constraint } : {},
|
||
_childrenCount: node.children.length,
|
||
},
|
||
};
|
||
}
|
||
|
||
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,
|
||
defaultValue: '',
|
||
options: [],
|
||
constraint: {},
|
||
_createParentId: parentNodeId,
|
||
_isArrayParent: isArray,
|
||
_childrenCount: 0,
|
||
},
|
||
};
|
||
}
|
||
|
||
/** 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();
|
||
} else {
|
||
// --- edit mode ---
|
||
const node = findNodeById(form.nodeId);
|
||
if (!node) return;
|
||
|
||
node.key = form.nodeKey;
|
||
node.type = form.jsonType as JsonNodeData['type'];
|
||
|
||
// Store config on node
|
||
node.config = formToConfig(form);
|
||
|
||
// Reset value/children when type changes
|
||
if (node.type === 'object' || node.type === 'array') {
|
||
if (!node.children) node.children = [];
|
||
// Preserve existing children when switching between object/array
|
||
} else {
|
||
// Switching to primitive type — clear children, set default value
|
||
node.children = [];
|
||
if (node.primitiveValue === undefined || node.primitiveValue === null || node.type === 'null') {
|
||
node.primitiveValue = getDefaultPrimitive(node.type);
|
||
}
|
||
}
|
||
|
||
fieldDialog.value.visible = false;
|
||
emitChange();
|
||
}
|
||
}
|
||
|
||
function handleFieldRemove() {
|
||
const form = fieldDialog.value.formData;
|
||
if (!form) return;
|
||
if (form._createParentId) {
|
||
// Create mode — nothing to remove, just close
|
||
fieldDialog.value.visible = false;
|
||
return;
|
||
}
|
||
const node = findNodeById(form.nodeId);
|
||
if (node) {
|
||
delete node.config;
|
||
fieldDialog.value.visible = false;
|
||
emitChange();
|
||
}
|
||
}
|
||
|
||
// --- 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),
|
||
});
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.json-editor-tree {
|
||
|
||
.paste-tip { margin-bottom: 12px;
|
||
:deep(.el-alert__description) { font-size: 13px; }
|
||
}
|
||
}
|
||
</style>
|