json编辑器组件相关

This commit is contained in:
2026-07-04 17:57:28 +08:00
parent 50f430def2
commit 175d2e47ce
7 changed files with 1329 additions and 16 deletions
+1 -3
View File
@@ -5,6 +5,4 @@ ENV = 'development'
# 开发环境走本地代理,避免 CORS
VITE_API_URL = 'http://116.204.74.41:8000'
# VITE_API_URL = 'http://192.168.3.30:8000'
# VITE_API_URL = 'http://192.168.3.135:8000'
# VITE_API_URL = 'http://192.168.3.135:8000'
+70 -12
View File
@@ -51,7 +51,8 @@
"vue-i18n": "11.1.2",
"vue-router": "^4.3.0",
"vue-simple-uploader": "^1.0.0-beta.5",
"vue-ueditor-wrap": "^3.0.8"
"vue-ueditor-wrap": "^3.0.8",
"vuedraggable": "^4.1.0"
},
"devDependencies": {
"@types/node": "^20.11.28",
@@ -927,6 +928,7 @@
"resolved": "https://registry.npmjs.org/@interactjs/core/-/core-1.10.27.tgz",
"integrity": "sha512-SliUr/3ZbLAdED8LokzYzWHWMdCB5Cq+UnpXuRy+BIod1j97m4IUFf/D1iIKUBBjBcucgXbz28z96WnenVCB7Q==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"@interactjs/utils": "1.10.27"
}
@@ -997,6 +999,7 @@
"resolved": "https://registry.npmjs.org/@interactjs/modifiers/-/modifiers-1.10.27.tgz",
"integrity": "sha512-ei/qfoQ+9/8k6WzNzdNqHI6cWkIV576N4Ap16r5CoqOWwhA6Xzj3OMHf1g0t1O4eSq2HdJsVJn3eLNfw9HsbeQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@interactjs/snappers": "1.10.27"
},
@@ -1063,7 +1066,8 @@
"version": "1.10.27",
"resolved": "https://registry.npmjs.org/@interactjs/utils/-/utils-1.10.27.tgz",
"integrity": "sha512-+qfLOio2OxQqg1cXSnRaCl+N8MQDQLDS9w+aOGxH8YLAhIMyt7Asxx/46//sT8orgsi16pmlBPtngPHT9s8zKw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@intlify/core-base": {
"version": "11.1.2",
@@ -1155,6 +1159,7 @@
"resolved": "https://registry.npmjs.org/@logicflow/core/-/core-2.2.1.tgz",
"integrity": "sha512-VzLPrCrT4eXnOLjoGQ5v4GUSay3+6rd3YNZD0qOJw4vME5e4WjQ5fd+hKK2zlIzgdRI4D54dXiEFJrS6xdV6yQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"classnames": "^2.3.2",
"lodash-es": "^4.17.21",
@@ -1908,6 +1913,7 @@
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/lodash": "*"
}
@@ -1918,6 +1924,7 @@
"integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -1989,6 +1996,7 @@
"integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "7.18.0",
"@typescript-eslint/types": "7.18.0",
@@ -2164,6 +2172,7 @@
"resolved": "https://registry.npmjs.org/@uppy/core/-/core-2.3.4.tgz",
"integrity": "sha512-iWAqppC8FD8mMVqewavCz+TNaet6HPXitmGXpGGREGrakZ4FeuWytVdrelydzTdXx6vVKkOmI2FLztGg73sENQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@transloadit/prettier-bytes": "0.0.7",
"@uppy/store-default": "^2.1.1",
@@ -2195,6 +2204,7 @@
"resolved": "https://registry.npmjs.org/@uppy/xhr-upload/-/xhr-upload-2.1.3.tgz",
"integrity": "sha512-YWOQ6myBVPs+mhNjfdWsQyMRWUlrDLMoaG7nvf/G6Y3GKZf8AyjFDjvvJ49XWQ+DaZOftGkHmF1uh/DBeGivJQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@uppy/companion-client": "^2.2.2",
"@uppy/utils": "^4.1.2",
@@ -2272,6 +2282,7 @@
"resolved": "https://registry.npmjs.org/@vue-flow/core/-/core-1.48.2.tgz",
"integrity": "sha512-raxhgKWE+G/mcEvXJjGFUDYW9rAI3GOtiHR3ZkNpwBWuIaCC1EYiBmKGwJOoNzVFgwO7COgErnK7i08i287AFA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vueuse/core": "^10.5.0",
"d3-drag": "^3.0.0",
@@ -2515,6 +2526,7 @@
"resolved": "https://registry.npmjs.org/@wangeditor/basic-modules/-/basic-modules-1.1.7.tgz",
"integrity": "sha512-cY9CPkLJaqF05STqfpZKWG4LpxTMeGSIIF1fHvfm/mz+JXatCagjdkbxdikOuKYlxDdeqvOeBmsUBItufDLXZg==",
"license": "MIT",
"peer": true,
"dependencies": {
"is-url": "^1.2.4"
},
@@ -2547,6 +2559,7 @@
"resolved": "https://registry.npmjs.org/@wangeditor/core/-/core-1.1.19.tgz",
"integrity": "sha512-KevkB47+7GhVszyYF2pKGKtCSj/YzmClsD03C3zTt+9SR2XWT5T0e3yQqg8baZpcMvkjs1D8Dv4fk8ok/UaS2Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/event-emitter": "^0.3.3",
"event-emitter": "^0.3.5",
@@ -2665,6 +2678,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2920,6 +2934,7 @@
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0",
@@ -3103,6 +3118,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@@ -3242,6 +3258,7 @@
"resolved": "https://registry.npmjs.org/dom7/-/dom7-3.0.0.tgz",
"integrity": "sha512-oNlcUdHsC4zb7Msx7JN3K0Nro1dzJ48knvBOnDPKJ2GV9wl1i5vydJZUSyOfrkKFDZEud/jBsTk92S/VGSAe/g==",
"license": "MIT",
"peer": true,
"dependencies": {
"ssr-window": "^3.0.0-alpha.1"
}
@@ -3278,6 +3295,7 @@
"resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz",
"integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"tslib": "2.3.0",
"zrender": "5.6.1"
@@ -3506,6 +3524,7 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -4298,7 +4317,8 @@
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/is-hotkey/-/is-hotkey-0.2.0.tgz",
"integrity": "sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/is-number": {
"version": "7.0.0",
@@ -4485,13 +4505,15 @@
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/lodash-es": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/lodash-unified": {
"version": "1.0.3",
@@ -4508,32 +4530,37 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
"integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/lodash.clonedeep": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/lodash.debounce": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/lodash.foreach": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.foreach/-/lodash.foreach-4.5.0.tgz",
"integrity": "sha512-aEXTF4d+m05rVOAUG3z4vZZ4xVexLKZGF0lIxuHZ1Hplpk/3B6Z1+/ICICYRLm7c41Z2xiejbkCkJoTlypoXhQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/lodash.merge": {
"version": "4.6.2",
@@ -4546,13 +4573,15 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
"integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/lodash.toarray": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.toarray/-/lodash.toarray-4.4.0.tgz",
"integrity": "sha512-QyffEA3i5dma5q2490+SgCvDN0pXLmRGSyAANuVi0HQ01Pkfr9fuoKQW8wm1wGBnJITs/mS7wQvS6VshUEBFCw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/magic-string": {
"version": "0.30.21",
@@ -4665,6 +4694,7 @@
"resolved": "https://registry.npmjs.org/mobx/-/mobx-5.15.7.tgz",
"integrity": "sha512-wyM3FghTkhmC+hQjyPGGFdpehrcX1KOXsDuERhfK2YbJemkUhEB+6wzEN639T21onxlfYBmriA1PFnvxTUhcKw==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mobx"
@@ -4729,6 +4759,7 @@
}
],
"license": "MIT",
"peer": true,
"bin": {
"nanoid": "bin/nanoid.cjs"
},
@@ -5015,6 +5046,7 @@
"resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz",
"integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
@@ -5275,6 +5307,7 @@
"integrity": "sha512-N+7WK20/wOr7CzA2snJcUSSNTCzeCGUTFY3OgeQP3mZ1aj9NMQ0mSTXwlrnd89j33zzQJGqIN52GIOmYrfq46A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"chokidar": "^4.0.0",
"immutable": "^5.0.2",
@@ -5493,6 +5526,7 @@
"resolved": "https://registry.npmjs.org/slate/-/slate-0.72.8.tgz",
"integrity": "sha512-/nJwTswQgnRurpK+bGJFH1oM7naD5qDmHd89JyiKNT2oOKD8marW0QSBtuFnwEbL5aGCS8AmrhXQgNOsn4osAw==",
"license": "MIT",
"peer": true,
"dependencies": {
"immer": "^9.0.6",
"is-plain-object": "^5.0.0",
@@ -5516,6 +5550,7 @@
"resolved": "https://registry.npmjs.org/snabbdom/-/snabbdom-3.6.3.tgz",
"integrity": "sha512-W2lHLLw2qR2Vv0DcMmcxXqcfdBaIcoN+y/86SmHv8fn4DazEQSH6KN3TjZcWvwujW56OHiiirsbHWZb4vx/0fg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12.17.0"
}
@@ -5681,6 +5716,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -5758,6 +5794,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -5827,6 +5864,7 @@
"integrity": "sha512-BiReIiMS2fyFqbqNT/Qqt4CVITDU9M9vE+DKcVAsB+ZV0wvTKd+3hMbkpxz1b+NmEDMegpVbisKiAZOnvO92Sw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@@ -5995,6 +6033,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -6014,6 +6053,7 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.25.tgz",
"integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.25",
"@vue/compiler-sfc": "3.5.25",
@@ -6207,6 +6247,24 @@
"vue": "^3.0.0"
}
},
"node_modules/vuedraggable": {
"version": "4.1.0",
"resolved": "https://registry.npmmirror.com/vuedraggable/-/vuedraggable-4.1.0.tgz",
"integrity": "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==",
"license": "MIT",
"dependencies": {
"sortablejs": "1.14.0"
},
"peerDependencies": {
"vue": "^3.0.1"
}
},
"node_modules/vuedraggable/node_modules/sortablejs": {
"version": "1.14.0",
"resolved": "https://registry.npmmirror.com/sortablejs/-/sortablejs-1.14.0.tgz",
"integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==",
"license": "MIT"
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
+2 -1
View File
@@ -56,7 +56,8 @@
"vue-i18n": "11.1.2",
"vue-router": "^4.3.0",
"vue-simple-uploader": "^1.0.0-beta.5",
"vue-ueditor-wrap": "^3.0.8"
"vue-ueditor-wrap": "^3.0.8",
"vuedraggable": "^4.1.0"
},
"devDependencies": {
"@types/node": "^20.11.28",
@@ -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>
+220
View File
@@ -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">&#123;...&#125;</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>
+417
View File
@@ -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="例如:&#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;
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>
+201
View File
@@ -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>