json编辑器组件相关
This commit is contained in:
@@ -0,0 +1,418 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
:title="dialogTitle"
|
||||
width="560px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
@update:model-value="$emit('update:visible', $event)"
|
||||
@closed="handleClosed"
|
||||
>
|
||||
<el-form ref="formRef" :model="form" label-width="110px" size="small">
|
||||
<!-- ===== 基础信息 ===== -->
|
||||
<el-divider content-position="left">基础信息</el-divider>
|
||||
|
||||
<el-form-item v-if="showKeyField" label="字段英文名">
|
||||
<el-input v-model="form.nodeKey" placeholder="字段名称(key)" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="!isArrayElement" label="字段中文名">
|
||||
<el-input v-model="form.label" placeholder="表单中显示的字段名称" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="!isArrayElement" label="字段描述">
|
||||
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="此字段的用途说明" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="字段类型">
|
||||
<el-select v-model="form.jsonType" @change="onJsonTypeChange">
|
||||
<el-option label="字符串" value="string" />
|
||||
<el-option label="数字" value="number" />
|
||||
<el-option label="布尔" value="boolean" />
|
||||
<el-option label="空值" value="null" />
|
||||
<el-option label="对象" value="object" />
|
||||
<el-option label="数组" value="array" />
|
||||
</el-select>
|
||||
<div class="form-hint">JSON 数据类型</div>
|
||||
</el-form-item>
|
||||
<!-- ===== 表单属性 & 约束规则(仅标量类型) ===== -->
|
||||
<template v-if="jsonTypeIsScalar">
|
||||
<el-divider content-position="left">表单属性</el-divider>
|
||||
|
||||
<el-form-item label="表单显示">
|
||||
<el-switch v-model="form.isForm" />
|
||||
<span class="form-hint">{{ form.isForm ? '在表单中展示' : '隐藏字段' }}</span>
|
||||
</el-form-item>
|
||||
<template v-if="form.isForm">
|
||||
<el-form-item label="表单控件">
|
||||
<el-select v-model="form.fieldType" @change="onTypeChange">
|
||||
<el-option v-for="opt in fieldTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
<div class="form-hint">此字段在表单中以什么组件展示</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="必填项">
|
||||
<el-switch v-model="form.required" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
<el-form-item label="默认值">
|
||||
<el-input v-model="form.defaultValue" placeholder="留空则无默认值" clearable />
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">约束规则</el-divider>
|
||||
|
||||
<el-form-item v-if="form.fieldType === 'select'" label="选项列表">
|
||||
<div class="options-editor">
|
||||
<div v-for="(opt, idx) in form.options" :key="idx" class="option-row">
|
||||
<el-input v-model="form.options[idx]" size="small" placeholder="选项值" />
|
||||
<el-button size="small" text type="danger" @click="form.options.splice(idx, 1)">×</el-button>
|
||||
</div>
|
||||
<el-button size="small" class="add-option-btn" @click="form.options.push('')"
|
||||
><el-icon><Plus /></el-icon> 添加选项</el-button
|
||||
>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<template v-if="form.fieldType === 'string' || form.fieldType === 'textarea'">
|
||||
<el-form-item label="最小长度"
|
||||
><el-input-number
|
||||
v-model="form.constraint.minLength"
|
||||
:min="0"
|
||||
:step="100"
|
||||
controls-position="right"
|
||||
placeholder="不限"
|
||||
style="width: 100%"
|
||||
/></el-form-item>
|
||||
<el-form-item label="最大长度"
|
||||
><el-input-number
|
||||
v-model="form.constraint.maxLength"
|
||||
:min="0"
|
||||
:step="1000"
|
||||
controls-position="right"
|
||||
placeholder="不限"
|
||||
style="width: 100%"
|
||||
/></el-form-item>
|
||||
<el-form-item label="正则验证"><el-input v-model="form.constraint.pattern" placeholder="如 ^https?://" clearable /></el-form-item>
|
||||
</template>
|
||||
|
||||
<template v-if="form.fieldType === 'upload'">
|
||||
<el-form-item label="总数量">
|
||||
<el-input-number v-model="form.constraint.uploadTotalMaxCount" :min="1" controls-position="right" placeholder="不限" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="总容量(MB)">
|
||||
<el-input-number v-model="form.constraint.uploadTotalMaxSize" :min="1" controls-position="right" placeholder="不限" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-divider content-position="left">按格式限制(可选)</el-divider>
|
||||
<div class="upload-rules">
|
||||
<div v-for="(rule, idx) in uploadRules" :key="idx" class="upload-rule-row">
|
||||
<el-input v-model="rule.format" size="small" placeholder="格式(如 jpg)" style="width: 90px" />
|
||||
<el-input-number v-model="rule.maxSize" :min="1" controls-position="right" size="small" placeholder="大小(MB)" style="width: 140px" />
|
||||
<el-input-number v-model="rule.maxCount" :min="1" controls-position="right" size="small" placeholder="数量" style="width: 110px" />
|
||||
<el-button size="small" text type="danger" @click="removeUploadRule(idx)">×</el-button>
|
||||
</div>
|
||||
<el-button size="small" class="add-rule-btn" @click="addUploadRule"
|
||||
><el-icon><Plus /></el-icon> 添加规则</el-button
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="form.fieldType === 'number'">
|
||||
<el-form-item label="最小值"
|
||||
><el-input-number
|
||||
v-model="form.constraint.min"
|
||||
:min="Number.MIN_SAFE_INTEGER"
|
||||
:max="Number.MAX_SAFE_INTEGER"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
/></el-form-item>
|
||||
<el-form-item label="最大值"
|
||||
><el-input-number
|
||||
v-model="form.constraint.max"
|
||||
:min="Number.MIN_SAFE_INTEGER"
|
||||
:max="Number.MAX_SAFE_INTEGER"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
/></el-form-item>
|
||||
<el-form-item label="数值类型"
|
||||
><el-select v-model="form.constraint.numberType" clearable placeholder="不限"
|
||||
><el-option label="整数" value="integer" /><el-option label="浮点数" value="float" /></el-select
|
||||
></el-form-item>
|
||||
</template>
|
||||
</template>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button :disabled="!hasConfig" @click="handleRemove">清除配置</el-button>
|
||||
<el-button @click="handleCancel">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch } from 'vue';
|
||||
import { ElMessageBox } from 'element-plus';
|
||||
import { Plus } from '@element-plus/icons-vue';
|
||||
|
||||
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;
|
||||
defaultValue: string;
|
||||
|
||||
options: string[];
|
||||
constraint: {
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
pattern?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
numberType?: string;
|
||||
accept?: string;
|
||||
maxSize?: number;
|
||||
maxCount?: number;
|
||||
uploadRules?: UploadRule[];
|
||||
uploadTotalMaxCount?: number;
|
||||
uploadTotalMaxSize?: number;
|
||||
};
|
||||
_createParentId?: string;
|
||||
_isArrayParent?: boolean;
|
||||
_childrenCount?: number;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
formData: FieldFormData | null;
|
||||
fieldTypeOptions: { label: string; value: string }[];
|
||||
hasConfig: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', v: boolean): void;
|
||||
(e: 'save', form: FieldFormData): void;
|
||||
(e: 'remove'): void;
|
||||
}>();
|
||||
|
||||
const form = reactive<FieldFormData>({
|
||||
nodeKey: '',
|
||||
nodeId: '',
|
||||
jsonType: 'string',
|
||||
fieldType: 'string',
|
||||
label: '',
|
||||
description: '',
|
||||
role: '',
|
||||
isForm: true,
|
||||
required: false,
|
||||
defaultValue: '',
|
||||
|
||||
options: [],
|
||||
constraint: {},
|
||||
});
|
||||
|
||||
const uploadRules = ref<UploadRule[]>([]);
|
||||
const prevJsonType = ref('');
|
||||
|
||||
const isCreateMode = computed(() => !!form._createParentId);
|
||||
const jsonTypeIsScalar = computed(() => form.jsonType !== 'object' && form.jsonType !== 'array');
|
||||
const isArrayElement = computed(() => isCreateMode.value && form._isArrayParent);
|
||||
const showKeyField = computed(() => !isArrayElement.value);
|
||||
const dialogTitle = computed(() => {
|
||||
return isCreateMode.value ? (form._isArrayParent ? '添加元素' : '添加字段 —— ' + (form.nodeKey || '新字段')) : '字段配置 —— ' + form.nodeKey;
|
||||
});
|
||||
|
||||
|
||||
|
||||
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.options = [];
|
||||
form.constraint = {};
|
||||
// @ts-ignore
|
||||
form._createParentId = undefined;
|
||||
// @ts-ignore
|
||||
form._isArrayParent = false;
|
||||
uploadRules.value = [];
|
||||
}
|
||||
|
||||
function loadForm(data: FieldFormData | null) {
|
||||
resetForm();
|
||||
if (!data) return;
|
||||
form.nodeId = data.nodeId;
|
||||
form.nodeKey = data.nodeKey || '';
|
||||
prevJsonType.value = data.jsonType || 'string';
|
||||
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.defaultValue = data.defaultValue || '';
|
||||
form.options = data.options ? [...data.options] : [];
|
||||
form.constraint = data.constraint ? { ...data.constraint } : {};
|
||||
// @ts-ignore
|
||||
form._createParentId = data._createParentId;
|
||||
// @ts-ignore
|
||||
form._isArrayParent = data._isArrayParent ?? false;
|
||||
// @ts-ignore
|
||||
form._childrenCount = data._childrenCount ?? 0;
|
||||
|
||||
// Load upload rules — support both old (accept string) and new format
|
||||
if (data.constraint?.uploadRules && data.constraint.uploadRules.length > 0) {
|
||||
uploadRules.value = data.constraint.uploadRules.map((r) => ({ ...r }));
|
||||
} else if (data.constraint?.accept) {
|
||||
const formats = data.constraint.accept
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (formats.length > 0) {
|
||||
uploadRules.value = formats.map((f) => ({
|
||||
format: f,
|
||||
maxSize: data.constraint?.maxSize,
|
||||
maxCount: data.constraint?.maxCount,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function syncUploadRulesToConstraint() {
|
||||
if (uploadRules.value.length > 0 && uploadRules.value.some((r) => r.format)) {
|
||||
form.constraint.uploadRules = uploadRules.value
|
||||
.filter((r) => r.format)
|
||||
.map((r) => ({ format: r.format, maxSize: r.maxSize, maxCount: r.maxCount }));
|
||||
} else {
|
||||
delete form.constraint.uploadRules;
|
||||
}
|
||||
}
|
||||
|
||||
function addUploadRule() {
|
||||
uploadRules.value.push({ format: '', maxSize: undefined, maxCount: undefined });
|
||||
}
|
||||
|
||||
function removeUploadRule(idx: number) {
|
||||
uploadRules.value.splice(idx, 1);
|
||||
}
|
||||
|
||||
function onJsonTypeChange(val: string) {
|
||||
const childrenCount = (form as any)._childrenCount || 0;
|
||||
const prevType = prevJsonType.value;
|
||||
|
||||
const m: Record<string, string> = { string: 'string', number: 'number', boolean: 'switch', null: 'string', object: 'string', array: 'string' };
|
||||
form.fieldType = m[val] || 'string';
|
||||
|
||||
if (!form._createParentId && childrenCount > 0 && prevType && prevType !== val) {
|
||||
ElMessageBox.confirm('更改字段类型将删除该节点下所有子节点数据,是否继续?', '确认操作', {
|
||||
confirmButtonText: '确认',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
draggable: true,
|
||||
})
|
||||
.then(() => {
|
||||
prevJsonType.value = val;
|
||||
})
|
||||
.catch(() => {
|
||||
form.jsonType = prevType;
|
||||
});
|
||||
} else {
|
||||
prevJsonType.value = val;
|
||||
}
|
||||
}
|
||||
|
||||
function onTypeChange() {
|
||||
if (form.fieldType !== 'upload') {
|
||||
delete form.constraint.accept;
|
||||
delete form.constraint.maxSize;
|
||||
delete form.constraint.maxCount;
|
||||
delete form.constraint.uploadRules;
|
||||
uploadRules.value = [];
|
||||
}
|
||||
if (form.fieldType !== 'string' && form.fieldType !== 'textarea') {
|
||||
delete form.constraint.minLength;
|
||||
delete form.constraint.maxLength;
|
||||
delete form.constraint.pattern;
|
||||
}
|
||||
if (form.fieldType !== 'number') {
|
||||
delete form.constraint.min;
|
||||
delete form.constraint.max;
|
||||
delete form.constraint.numberType;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
if (val) loadForm(props.formData);
|
||||
}
|
||||
);
|
||||
|
||||
function handleSave() {
|
||||
syncUploadRulesToConstraint();
|
||||
emit('save', { ...form, constraint: { ...form.constraint } });
|
||||
}
|
||||
|
||||
function handleRemove() {
|
||||
emit('remove');
|
||||
}
|
||||
function handleCancel() {
|
||||
emit('update:visible', false);
|
||||
}
|
||||
function handleClosed() {
|
||||
resetForm();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
.form-hint {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.options-editor {
|
||||
width: 100%;
|
||||
.option-row {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.add-option-btn {
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-rules {
|
||||
width: 100%;
|
||||
.upload-rule-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.add-rule-btn {
|
||||
font-size: 12px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,220 @@
|
||||
<template>
|
||||
<div class="json-node">
|
||||
<template v-if="node.type === 'object'">
|
||||
<div class="node-row node-row-object" :class="{ 'is-collapsed': collapsed }" :style="{ paddingLeft: depth * 20 + 'px' }">
|
||||
<span class="collapse-btn" @click="toggleCollapse">
|
||||
<el-icon><CaretRight v-if="collapsed" /><CaretBottom v-else /></el-icon>
|
||||
</span>
|
||||
<span class="key-label">{{ node.key }}</span>
|
||||
<span class="colon">:</span>
|
||||
<span class="type-label">object</span>
|
||||
<span class="node-badge badge-object">{...}</span>
|
||||
<span class="node-count">{{ node.children.length }} 项</span>
|
||||
<span v-if="node.config?.label" class="node-label-tag" :title="node.config.label">{{ node.config.label }}</span>
|
||||
<el-button size="small" text class="node-action-btn always-visible" @click.stop="emit('add-requested', node._id)">
|
||||
<el-icon><Plus /></el-icon>
|
||||
</el-button>
|
||||
<el-button size="small" text class="node-action-btn field-btn" @click.stop="onOpenFieldDialog?.(node._id)" title="字段配置"><svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><rect x="2" y="4" width="20" height="16" rx="2"/><circle cx="7" cy="9" r="1.5" fill="#fff"/><circle cx="12" cy="9" r="1.5" fill="#fff"/><circle cx="17" cy="9" r="1.5" fill="#fff"/><path d="M4 14h16v1H4zm0 3h12v1H4z" fill="#fff"/></svg></el-button>
|
||||
<el-button size="small" text class="node-action-btn copy-btn" @click.stop="copyNodeJson" title="复制 JSON">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
|
||||
</el-button>
|
||||
<el-button v-if="!isRoot && parentCanDelete" size="small" text class="node-action-btn node-action-delete" @click.stop="deleteSelf">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-show="!collapsed" class="node-children" :style="{ marginLeft: depth * 20 + 18 + 'px' }">
|
||||
<JsonNode v-for="child in node.children" :key="child._id" :node="child" :depth="depth + 1" :is-root="false" :parent-type="'object'" :parent-can-delete="true" @delete-node="removeChild(child._id)" @change="emitChange" @add-requested="(id: string) => emit('add-requested', id)" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="node.type === 'array'">
|
||||
<div class="node-row node-row-array" :class="{ 'is-collapsed': collapsed }" :style="{ paddingLeft: depth * 20 + 'px' }">
|
||||
<span class="collapse-btn" @click="toggleCollapse">
|
||||
<el-icon><CaretRight v-if="collapsed" /><CaretBottom v-else /></el-icon>
|
||||
</span>
|
||||
<span class="key-label">{{ node.key }}</span>
|
||||
<span class="colon">:</span>
|
||||
<span class="type-label">array</span>
|
||||
<span class="node-badge badge-array">[...]</span>
|
||||
<span class="node-count">{{ node.children.length }} 项</span>
|
||||
<span v-if="node.config?.label" class="node-label-tag" :title="node.config.label">{{ node.config.label }}</span>
|
||||
<el-button size="small" text class="node-action-btn always-visible" @click.stop="emit('add-requested', node._id)">
|
||||
<el-icon><Plus /></el-icon>
|
||||
</el-button>
|
||||
<el-button size="small" text class="node-action-btn field-btn" @click.stop="onOpenFieldDialog?.(node._id)" title="字段配置"><svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><rect x="2" y="4" width="20" height="16" rx="2"/><circle cx="7" cy="9" r="1.5" fill="#fff"/><circle cx="12" cy="9" r="1.5" fill="#fff"/><circle cx="17" cy="9" r="1.5" fill="#fff"/><path d="M4 14h16v1H4zm0 3h12v1H4z" fill="#fff"/></svg></el-button>
|
||||
<el-button size="small" text class="node-action-btn copy-btn" @click.stop="copyNodeJson" title="复制 JSON">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
|
||||
</el-button>
|
||||
<el-button v-if="!isRoot && parentCanDelete" size="small" text class="node-action-btn node-action-delete" @click.stop="deleteSelf">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-show="!collapsed" class="node-children" :style="{ marginLeft: depth * 20 + 18 + 'px' }">
|
||||
<JsonNode v-for="child in node.children" :key="child._id" :node="child" :depth="depth + 1" :is-root="false" :parent-type="'array'" :parent-can-delete="true" @delete-node="removeChild(child._id)" @change="emitChange" @add-requested="(id: string) => emit('add-requested', id)" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="node-row node-row-primitive" :style="{ paddingLeft: depth * 20 + 'px' }">
|
||||
<span class="indent-placeholder" v-if="depth > 0"></span>
|
||||
<span class="key-label">{{ node.key }}</span>
|
||||
<span class="colon">:</span>
|
||||
<span class="type-label">{{ node.type }}</span>
|
||||
<template v-if="node.type === 'string'">
|
||||
<span class="value-display">{{ node.primitiveValue || '(空)' }}</span>
|
||||
</template>
|
||||
<template v-else-if="node.type === 'number'">
|
||||
<span class="value-display">{{ node.primitiveValue }}</span>
|
||||
</template>
|
||||
<template v-else-if="node.type === 'boolean'">
|
||||
<el-tag :type="node.primitiveValue ? 'success' : 'info'" size="small" effect="plain">{{ node.primitiveValue ? 'true' : 'false' }}</el-tag>
|
||||
</template>
|
||||
<span v-else-if="node.type === 'null'" class="null-label">null</span>
|
||||
<span v-if="node.config?.label" class="node-label-tag" :title="node.config.label">{{ node.config.label }}</span>
|
||||
<el-button size="small" text class="node-action-btn field-btn" @click.stop="onOpenFieldDialog?.(node._id)" title="字段配置"><svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><rect x="2" y="4" width="20" height="16" rx="2"/><circle cx="7" cy="9" r="1.5" fill="#fff"/><circle cx="12" cy="9" r="1.5" fill="#fff"/><circle cx="17" cy="9" r="1.5" fill="#fff"/><path d="M4 14h16v1H4zm0 3h12v1H4z" fill="#fff"/></svg></el-button>
|
||||
<el-button size="small" text class="node-action-btn copy-btn" @click.stop="copyNodeJson" title="复制 JSON">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
|
||||
</el-button>
|
||||
<el-button v-if="!isRoot && parentCanDelete" size="small" text class="node-action-btn node-action-delete" @click="deleteSelf">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, inject } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { Plus, Delete, CaretRight, CaretBottom } from '@element-plus/icons-vue';
|
||||
|
||||
defineOptions({ name: 'JsonNode' });
|
||||
|
||||
interface JsonNodeData {
|
||||
_id: string; key: string; keyEditable: boolean;
|
||||
type: 'string' | 'number' | 'boolean' | 'null' | 'object' | 'array';
|
||||
primitiveValue: string | number | boolean | null;
|
||||
children: JsonNodeData[];
|
||||
config?: {
|
||||
fieldType?: string;
|
||||
label?: string;
|
||||
role?: string;
|
||||
isForm?: boolean;
|
||||
required?: boolean;
|
||||
defaultValue?: string | number;
|
||||
options?: string[];
|
||||
constraint?: Record<string, any>;
|
||||
};
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
node: JsonNodeData; depth: number; isRoot?: boolean;
|
||||
parentType?: 'object' | 'array' | 'root'; parentCanDelete?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'delete-node'): void;
|
||||
(e: 'change'): void;
|
||||
(e: 'add-requested', parentNodeId: string): void;
|
||||
}>();
|
||||
const onOpenFieldDialog = inject<((nodeId: string) => void) | undefined>("onOpenFieldDialog");
|
||||
|
||||
const collapsed = ref(false);
|
||||
function toggleCollapse() { collapsed.value = !collapsed.value; }
|
||||
|
||||
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 copyNodeJson() {
|
||||
const val = nodeToValue(props.node);
|
||||
const json = JSON.stringify(val, null, 2);
|
||||
navigator.clipboard.writeText(json).then(() => {
|
||||
ElMessage.success('JSON 已复制到剪贴板');
|
||||
}).catch(() => {
|
||||
// Fallback
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = json;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
ElMessage.success('JSON 已复制到剪贴板');
|
||||
});
|
||||
}
|
||||
|
||||
function removeChild(childId: string) {
|
||||
const n = props.node;
|
||||
const idx = n.children.findIndex((c) => c._id === childId);
|
||||
if (idx === -1) return;
|
||||
n.children.splice(idx, 1);
|
||||
if (n.type === 'array') {
|
||||
n.children.forEach((child, i) => { child.key = String(i); });
|
||||
}
|
||||
emitChange();
|
||||
}
|
||||
|
||||
function deleteSelf() { emit('delete-node'); }
|
||||
function emitChange() { emit('change'); }
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.json-node {
|
||||
.node-row {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
padding: 3px 4px; border-radius: 3px; transition: background 0.15s;
|
||||
min-height: 30px; flex-wrap: nowrap;
|
||||
&:hover { background: #f5f7fa;
|
||||
.node-action-btn:not(.always-visible) { opacity: 1; }
|
||||
}
|
||||
.collapse-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 18px; height: 18px; cursor: pointer; color: #94a3b8;
|
||||
flex-shrink: 0; font-size: 12px;
|
||||
&:hover { color: #3b82f6; }
|
||||
}
|
||||
.indent-placeholder { display: inline-block; width: 18px; flex-shrink: 0; }
|
||||
.key-label { font-family: 'Consolas', monospace; font-size: 13px; color: #881391; font-weight: 500; white-space: nowrap; min-width: 20px; max-width: 180px; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; cursor: default; }
|
||||
.colon { color: #94a3b8; font-weight: 600; flex-shrink: 0; margin-right: 2px; }
|
||||
.type-label {
|
||||
font-family: 'Consolas', monospace; font-size: 11px; color: #64748b;
|
||||
background: #f1f5f9; padding: 1px 5px; border-radius: 3px;
|
||||
flex-shrink: 0; line-height: 18px;
|
||||
}
|
||||
.node-badge {
|
||||
font-size: 11px; padding: 1px 6px; border-radius: 3px; font-weight: 500; flex-shrink: 0;
|
||||
&.badge-object { color: #2563eb; background: #eff6ff; }
|
||||
&.badge-array { color: #7c3aed; background: #f5f3ff; }
|
||||
}
|
||||
.node-count { font-size: 11px; color: #94a3b8; flex-shrink: 0; }
|
||||
.node-label-tag {
|
||||
font-size: 11px; color: #0891b2; background: #ecfeff;
|
||||
padding: 0 6px; border-radius: 3px; white-space: nowrap;
|
||||
max-width: 120px; overflow: hidden; text-overflow: ellipsis; flex-shrink: 0;
|
||||
}
|
||||
.value-display {
|
||||
font-family: 'Consolas', monospace; font-size: 13px; color: #475569;
|
||||
padding: 0 6px; min-width: 60px; flex: 1;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.null-label { font-size: 13px; color: #94a3b8; font-style: italic; flex: 1; }
|
||||
.node-action-btn { opacity: 0; transition: opacity 0.15s; margin-left: 2px; flex-shrink: 0; font-size: 14px; padding: 2px; }
|
||||
.node-action-btn.always-visible { opacity: 1; }
|
||||
&:hover .node-action-btn { opacity: 1; }
|
||||
.node-action-delete { color: #ef4444; }
|
||||
}
|
||||
.node-row-object, .node-row-array {
|
||||
background: #fafbfc; border: 1px solid transparent;
|
||||
&:hover { border-color: #e2e8f0; }
|
||||
&.is-collapsed { border-color: transparent; background: transparent; }
|
||||
}
|
||||
.node-children {
|
||||
border-left: 1px solid #e2e8f0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,417 @@
|
||||
<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>
|
||||
@@ -0,0 +1,201 @@
|
||||
<template>
|
||||
<div class="json-editor-page layout-padding">
|
||||
<el-card shadow="hover" class="layout-padding-auto">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="header-left">
|
||||
<span class="card-title">自描述 JSON 编辑器</span>
|
||||
<span class="card-desc">每个字段的值自带类型、标签、约束等元数据</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button size="small" @click="loadExample">加载示例</el-button>
|
||||
<el-button size="small" @click="handlePasteJSON">
|
||||
<el-icon><UploadFilled /></el-icon>
|
||||
粘贴 JSON
|
||||
</el-button>
|
||||
<el-button size="small" type="primary" @click="handleCopyJSON">
|
||||
<el-icon><CopyDocument /></el-icon>
|
||||
复制 JSON
|
||||
</el-button>
|
||||
<el-button size="small" type="warning" @click="jsonDialogVisible = true">
|
||||
<el-icon><View /></el-icon>
|
||||
查看 JSON
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="editor-wrapper">
|
||||
<JsonEditor ref="editorRef" v-model="jsonData" @change="handleChange" />
|
||||
</div>
|
||||
|
||||
<div class="footer-bar">
|
||||
<span class="change-hint" v-if="changeLog">{{ changeLog }}</span>
|
||||
<span v-else class="change-hint muted">数据本身即字段定义,每个字段为 {key, type, value, ...} 格式</span>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="jsonDialogVisible" title="JSON 输出" width="800px" :close-on-click-modal="false" destroy-on-close>
|
||||
<pre class="json-output">{{ formattedJSON }}</pre>
|
||||
<template #footer>
|
||||
<el-button @click="jsonDialogVisible = false">关闭</el-button>
|
||||
<el-button type="primary" @click="handleCopyJSON">
|
||||
<el-icon><CopyDocument /></el-icon>
|
||||
复制 JSON
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { CopyDocument, UploadFilled, View } from '@element-plus/icons-vue';
|
||||
import JsonEditor from '/@/components/JsonEditor/index.vue';
|
||||
|
||||
const editorRef = ref<InstanceType<typeof JsonEditor>>();
|
||||
const jsonDialogVisible = ref(false);
|
||||
const changeLog = ref('');
|
||||
|
||||
const jsonData = ref<any>({
|
||||
model: {
|
||||
key: 'model', type: 'string', label: '模型名称',
|
||||
value: 'doubao-seed-2-0-lite-260428',
|
||||
required: true,
|
||||
defaultValue: 'doubao-seed-2-0-lite-260428',
|
||||
},
|
||||
max_tokens: {
|
||||
key: 'max_tokens', type: 'number', label: '最大Token',
|
||||
value: 131072, defaultValue: 131072,
|
||||
fieldConstraint: { min: 1, max: 131072, numberType: 'integer' },
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
role: { key: 'role', type: 'string', value: 'system' },
|
||||
content: '你是一个智能助手',
|
||||
},
|
||||
{
|
||||
role: { key: 'role', type: 'string', value: 'user' },
|
||||
content: [
|
||||
{
|
||||
type: { key: 'type', value: 'text' },
|
||||
text: {
|
||||
key: 'text', type: 'string', label: '用户提示词',
|
||||
value: '', required: true,
|
||||
fieldConstraint: { maxLength: 100000 },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: { key: 'type', value: 'image_url' },
|
||||
image_url: {
|
||||
url: {
|
||||
key: 'url', type: 'upload', label: '参考图片',
|
||||
role: 'image', value: '',
|
||||
fieldConstraint: { accept: 'jpg,png,webp', maxCount: 10, maxSize: 10 },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: { key: 'type', value: 'video_url' },
|
||||
video_url: {
|
||||
url: {
|
||||
key: 'url', type: 'upload', label: '参考视频',
|
||||
role: 'video', value: '',
|
||||
fieldConstraint: { accept: 'mp4,mov', maxCount: 3, maxSize: 10240 },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: { key: 'type', value: 'input_audio' },
|
||||
input_audio: {
|
||||
url: {
|
||||
key: 'url', type: 'upload', label: '参考音频',
|
||||
role: 'audio', value: '',
|
||||
fieldConstraint: { accept: 'mp3,wav', maxCount: 1, maxSize: 200 },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const formattedJSON = computed(() => JSON.stringify(jsonData.value, null, 2));
|
||||
|
||||
function loadExample() {
|
||||
jsonData.value = {
|
||||
model: {
|
||||
key: 'model', type: 'string', label: '模型名称',
|
||||
value: 'doubao-seed-2-0-lite-260428',
|
||||
required: true,
|
||||
defaultValue: 'doubao-seed-2-0-lite-260428',
|
||||
},
|
||||
max_tokens: {
|
||||
key: 'max_tokens', type: 'number', label: '最大Token',
|
||||
value: 131072, defaultValue: 131072,
|
||||
fieldConstraint: { min: 1, max: 131072, numberType: 'integer' },
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
role: { key: 'role', type: 'string', value: 'system' },
|
||||
content: '你是一个智能助手',
|
||||
},
|
||||
{
|
||||
role: { key: 'role', type: 'string', value: 'user' },
|
||||
content: [
|
||||
{ type: { key: 'type', value: 'text' }, text: { key: 'text', type: 'string', label: '用户提示词', value: '', required: true, fieldConstraint: { maxLength: 100000 } } },
|
||||
{ type: { key: 'type', value: 'image_url' }, image_url: { url: { key: 'url', type: 'upload', label: '参考图片', role: 'image', value: '', fieldConstraint: { accept: 'jpg,png,webp', maxCount: 10, maxSize: 10 } } } },
|
||||
{ type: { key: 'type', value: 'video_url' }, video_url: { url: { key: 'url', type: 'upload', label: '参考视频', role: 'video', value: '', fieldConstraint: { accept: 'mp4,mov', maxCount: 3, maxSize: 10240 } } } },
|
||||
{ type: { key: 'type', value: 'input_audio' }, input_audio: { url: { key: 'url', type: 'upload', label: '参考音频', role: 'audio', value: '', fieldConstraint: { accept: 'mp3,wav', maxCount: 1, maxSize: 200 } } } },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
changeLog.value = '';
|
||||
ElMessage.success('已加载示例数据');
|
||||
}
|
||||
|
||||
function handleChange(data: any) {
|
||||
changeLog.value = `已更新,共 ${JSON.stringify(data).length} 字符`;
|
||||
}
|
||||
|
||||
function handlePasteJSON() {
|
||||
editorRef.value?.openPasteDialog();
|
||||
}
|
||||
|
||||
async function handleCopyJSON() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(formattedJSON.value);
|
||||
ElMessage.success('JSON 已复制到剪贴板');
|
||||
} catch { jsonDialogVisible.value = true; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.json-editor-page {
|
||||
.card-header {
|
||||
display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px;
|
||||
.header-left {
|
||||
.card-title { font-size: 16px; font-weight: 600; }
|
||||
.card-desc { font-size: 12px; color: #94a3b8; margin-left: 12px; }
|
||||
}
|
||||
.header-actions { display: flex; gap: 6px; }
|
||||
}
|
||||
.editor-wrapper {
|
||||
min-height: 300px; max-height: 600px; overflow: auto; padding: 8px 0;
|
||||
border: 1px solid #eef2f6; border-radius: 6px; background: #fff;
|
||||
}
|
||||
.footer-bar {
|
||||
margin-top: 12px; padding: 8px 12px; background: #f8fafc; border-radius: 4px;
|
||||
.change-hint { font-size: 12px; color: #0f172a;
|
||||
&.muted { color: #94a3b8; }
|
||||
}
|
||||
}
|
||||
}
|
||||
.json-output {
|
||||
padding: 16px; background: #1e293b; color: #e2e8f0; border-radius: 6px;
|
||||
font-size: 13px; line-height: 1.6; font-family: 'Consolas', monospace;
|
||||
max-height: 450px; overflow: auto; margin: 0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user