main #2

Merged
jingrow merged 250 commits from main into v1 2026-01-13 22:45:50 +08:00
16 changed files with 2881 additions and 0 deletions
Showing only changes of commit de0b85f471 - Show all commits

View File

@ -0,0 +1,303 @@
// Copyright (c) 2025, Jingrow and contributors
// For license information, please see license.txt
jingrow.ui.form.on("AI Node Schema", {
refresh(frm) {
console.log("AI Node Schema refresh called", frm);
// 添加编辑节点Schema按钮到右上角
if (frm.pg.node_type) {
frm.add_custom_button(__('编辑节点Schema'), function() {
open_schema_editor(frm);
});
frm.add_custom_button(__('保存到文件'), function() {
save_schema_to_file(frm);
});
}
console.log("AI Node Schema 按钮添加成功");
},
node_type(frm) {
// 当节点类型改变时,自动加载对应的 schema
if (frm.pg.node_type && !frm.pg.node_schema) {
load_schema_for_node_type(frm, frm.pg.node_type);
}
}
});
function open_schema_editor(frm) {
// 检查是否有 node_type
if (!frm.pg.node_type) {
jingrow.msgprint(__("请先选择节点类型"), __("提示"));
return;
}
// 创建 schema 编辑器对话框
let d = new jingrow.ui.Dialog({
title: __("编辑 {0} Schema", [frm.pg.node_type]),
size: "extra-large",
fields: [
{
fieldtype: "HTML",
fieldname: "schema_editor",
options: `
<div id="schema-builder-container" style="height: 80vh;">
<div id="schema-builder"></div>
</div>
`
}
],
primary_action_label: __("保存"),
primary_action: function() {
save_schema_from_editor(frm, d);
},
secondary_action_label: __("关闭"),
secondary_action: function() {
d.hide();
}
});
d.show();
// 初始化 Vue 编辑器
setTimeout(() => {
init_vue_schema_editor(d, frm);
}, 100);
}
function init_vue_schema_editor(dialog, frm) {
const container = dialog.fields_dict.schema_editor.$wrapper.find('#schema-builder')[0];
// 动态加载 Vue.js 和组件
load_vue_js().then(() => {
// Vue.js 加载完成后,加载 schema builder 组件
load_schema_builder_components().then(() => {
create_vue_app(container, frm, dialog);
}).catch(error => {
jingrow.msgprint(__("加载 Schema 编辑器失败: {0}", [error.message]), __("错误"));
});
}).catch(error => {
jingrow.msgprint(__("加载 Vue.js 失败: {0}", [error.message]), __("错误"));
});
}
function load_vue_js() {
return new Promise((resolve, reject) => {
// 检查是否已经加载
if (typeof Vue !== 'undefined' && typeof Pinia !== 'undefined') {
resolve();
return;
}
// 先加载 Vue.js
const vueScript = document.createElement('script');
vueScript.src = 'https://unpkg.com/vue@3/dist/vue.global.js';
vueScript.onload = () => {
// 然后加载 VueDemi
const vueDemiScript = document.createElement('script');
vueDemiScript.src = 'https://unpkg.com/vue-demi@0.14.6/lib/index.iife.js';
vueDemiScript.onload = () => {
// 最后加载 Pinia
const piniaScript = document.createElement('script');
piniaScript.src = 'https://unpkg.com/pinia@2/dist/pinia.iife.js';
piniaScript.onload = () => {
// 等待加载完成
setTimeout(() => {
if (typeof Vue !== 'undefined' && typeof Pinia !== 'undefined') {
resolve();
} else {
reject(new Error('Vue.js 或 Pinia 加载失败'));
}
}, 100);
};
piniaScript.onerror = () => {
reject(new Error('无法加载 Pinia'));
};
document.head.appendChild(piniaScript);
};
vueDemiScript.onerror = () => {
reject(new Error('无法加载 VueDemi'));
};
document.head.appendChild(vueDemiScript);
};
vueScript.onerror = () => {
reject(new Error('无法加载 Vue.js'));
};
document.head.appendChild(vueScript);
});
}
function create_vue_app(container, frm, dialog) {
// 创建 Vue 应用
const { createApp } = Vue;
const { createPinia } = Pinia;
const app = createApp({
template: `
<SchemaBuilder
:initial-schema="initialSchema"
:node-type="nodeType"
@save="handleSave"
@change="handleChange"
/>
`,
components: {
SchemaBuilder: window.SchemaBuilder
},
setup() {
const nodeType = frm.pg.node_type || "";
// 确保 initialSchema 是对象而不是字符串
let initialSchema = {};
if (frm.pg.node_schema) {
if (typeof frm.pg.node_schema === 'string') {
try {
initialSchema = JSON.parse(frm.pg.node_schema);
} catch (e) {
console.error('解析 node_schema 失败:', e);
initialSchema = {};
}
} else if (typeof frm.pg.node_schema === 'object') {
initialSchema = frm.pg.node_schema;
}
}
function handleSave(schemaData) {
// 保存时也需要转换为字符串
frm.set_value('node_schema', JSON.stringify(schemaData, null, 2));
frm.save().then(() => {
jingrow.msgprint(__("Schema 保存成功"));
dialog.hide();
});
}
function handleChange(schemaData) {
// 实时更新表单数据 - 需要转换为字符串
frm.set_value('node_schema', JSON.stringify(schemaData, null, 2));
}
return {
nodeType,
initialSchema,
handleSave,
handleChange
};
}
});
// 使用 Pinia
app.use(createPinia());
// 挂载应用
app.mount(container);
// 保存应用引用到对话框
dialog.vue_app = app;
}
function load_schema_builder_components() {
return new Promise((resolve, reject) => {
// 检查是否已经加载
if (window.SchemaBuilder && window.SchemaStore) {
resolve();
return;
}
// 动态加载组件(不使用 module 类型)
const script = document.createElement('script');
script.src = '/assets/jcloud/js/schema_builder/schema_builder.bundle.js';
script.onload = () => {
// 等待组件加载完成
setTimeout(() => {
if (window.SchemaBuilder && window.SchemaStore) {
resolve();
} else {
reject(new Error('组件加载失败'));
}
}, 100);
};
script.onerror = () => {
reject(new Error('无法加载组件文件'));
};
document.head.appendChild(script);
});
}
function save_schema_from_editor(frm, dialog) {
try {
// 如果使用 Vue 编辑器
if (dialog.vue_app) {
// Vue 编辑器会自动保存,这里只需要关闭对话框
dialog.hide();
return;
}
// 如果使用 JSON 编辑器
if (dialog.json_editor) {
const schema_data = dialog.json_editor.getValue();
frm.set_value('node_schema', schema_data);
frm.save().then(() => {
jingrow.msgprint(__("Schema 保存成功"));
dialog.hide();
});
}
} catch (e) {
jingrow.msgprint(__("保存失败: {0}", [e.message]), __("错误"));
}
}
function load_schema_for_node_type(frm, node_type) {
jingrow.call('jingrow.ai.utils.node_schema.get_node_schema', {
node_type: node_type
}).then(schema_data => {
// 确保 schema_data 是对象
let parsedSchema = {};
if (typeof schema_data === 'string') {
try {
parsedSchema = JSON.parse(schema_data);
} catch (e) {
console.error('解析 schema 失败:', e);
parsedSchema = {};
}
} else if (typeof schema_data === 'object') {
parsedSchema = schema_data;
}
frm.set_value('node_schema', parsedSchema);
jingrow.msgprint(__("节点 Schema 加载成功"));
}).catch(e => {
jingrow.msgprint(__("加载失败: {0}", [e.message]), __("错误"));
});
}
function save_schema_to_file(frm) {
if (!frm.pg.node_type || !frm.pg.node_schema) {
jingrow.msgprint(__("请先选择节点类型并设置 Schema 数据"), __("提示"));
return;
}
// 确保 schema_data 是对象
let schemaData = frm.pg.node_schema;
if (typeof schemaData === 'string') {
try {
schemaData = JSON.parse(schemaData);
} catch (e) {
jingrow.msgprint(__("Schema 数据格式错误"), __("错误"));
return;
}
}
// 使用现有的保存方法
jingrow.call('jcloud.jcloud.pagetype.ai_node_schema.ai_node_schema.save_node_schema', {
node_type: frm.pg.node_type,
schema_data: schemaData
}).then(() => {
jingrow.msgprint(__("Schema 已保存到文件"));
}).catch(e => {
jingrow.msgprint(__("保存失败: {0}", [e.message]), __("错误"));
});
}
// 简化实现,移除复杂的对话框和状态指示器

View File

@ -0,0 +1,49 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2025-08-27 01:45:28.762568",
"engine": "InnoDB",
"field_order": [
"node_type",
"node_schema"
],
"fields": [
{
"fieldname": "node_type",
"fieldtype": "Data",
"label": "Node Type"
},
{
"fieldname": "node_schema",
"fieldtype": "JSON",
"label": "Node Schema"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-08-27 01:46:44.409389",
"modified_by": "Administrator",
"module": "Jcloud",
"name": "AI Node Schema",
"owner": "Administrator",
"pagetype": "PageType",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}

View File

@ -0,0 +1,102 @@
# Copyright (c) 2025, Jingrow and contributors
# For license information, please see license.txt
import jingrow
from jingrow.model.document import Document
class AINodeSchema(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from jingrow.types import DF
node_schema: DF.JSON | None
node_type: DF.Data | None
# end: auto-generated types
def validate(self):
"""验证 schema JSON 格式"""
if self.node_schema:
try:
import json
json.loads(json.dumps(self.node_schema))
except Exception as e:
jingrow.throw(__("Schema JSON 格式错误: {0}", str(e)))
def before_save(self):
"""保存前验证"""
self.validate()
@jingrow.whitelist(allow_guest=True)
def get_available_node_types(self):
"""获取可用的节点类型"""
import os
import json
node_types = []
nodes_path = jingrow.get_app_path("jingrow", "ai", "pagetype", "ai_agent", "nodes")
if os.path.exists(nodes_path):
for item in os.listdir(nodes_path):
item_path = os.path.join(nodes_path, item)
if os.path.isdir(item_path):
schema_file = os.path.join(item_path, f"{item}.json")
if os.path.exists(schema_file):
try:
with open(schema_file, 'r', encoding='utf-8') as f:
schema_data = json.load(f)
node_types.append({
"value": item,
"label": schema_data.get("title", item),
"description": schema_data.get("description", "")
})
except Exception as e:
jingrow.log_error("加载节点 schema 失败", f"节点: {item}, 错误: {str(e)}")
return node_types
@jingrow.whitelist(allow_guest=True)
def load_node_schema(self, node_type):
"""加载指定节点类型的 schema"""
import os
import json
schema_file = jingrow.get_app_path("jingrow", "ai", "pagetype", "ai_agent", "nodes", node_type, f"{node_type}.json")
if os.path.exists(schema_file):
try:
with open(schema_file, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
jingrow.throw(__("加载节点 schema 失败: {0}", str(e)))
else:
jingrow.throw(__("节点类型 {0} 的 schema 文件不存在", node_type))
@jingrow.whitelist(allow_guest=True)
def save_node_schema(self, node_type, schema_data):
"""保存节点 schema 到文件"""
import os
import json
# 验证 schema 格式
try:
json.loads(json.dumps(schema_data))
except Exception as e:
jingrow.throw(__("Schema JSON 格式错误: {0}", str(e)))
schema_file = jingrow.get_app_path("jingrow", "ai", "pagetype", "ai_agent", "nodes", node_type, f"{node_type}.json")
try:
# 确保目录存在
os.makedirs(os.path.dirname(schema_file), exist_ok=True)
with open(schema_file, 'w', encoding='utf-8') as f:
json.dump(schema_data, f, ensure_ascii=False, indent=2)
jingrow.msgprint(__("节点 schema 保存成功"))
except Exception as e:
jingrow.throw(__("保存节点 schema 失败: {0}", str(e)))

View File

@ -0,0 +1,9 @@
# Copyright (c) 2025, Jingrow and Contributors
# See license.txt
# import jingrow
from jingrow.tests.utils import JingrowTestCase
class TestAINodeSchema(JingrowTestCase):
pass

View File

@ -0,0 +1,41 @@
<template>
<div class="schema-builder-container">
<div class="schema-main" :class="[store.preview ? 'preview' : '']">
<SchemaCanvas />
</div>
<div class="schema-sidebar">
<SchemaSidebar />
</div>
</div>
</template>
<script setup>
import SchemaCanvas from "./components/SchemaCanvas.vue";
import SchemaSidebar from "./components/SchemaSidebar.vue";
import { useSchemaStore } from "./store";
const store = useSchemaStore();
</script>
<style lang="scss" scoped>
.schema-builder-container {
display: flex;
height: 100%;
.schema-main {
flex: 1;
background-color: var(--disabled-control-bg);
border-radius: var(--border-radius);
border: 1px solid var(--border-color);
background-color: var(--card-bg);
margin: 5px;
}
.schema-sidebar {
width: 300px;
border-left: 1px solid var(--border-color);
border-bottom-right-radius: var(--border-radius);
background-color: var(--fg-color);
}
}
</style>

View File

@ -0,0 +1,104 @@
<template>
<span
class="editable-input"
:class="{ 'is-editing': isEditing }"
@click="startEdit"
@keydown.enter="finishEdit"
@keydown.escape="cancelEdit"
@blur="finishEdit"
tabindex="0"
>
<span v-if="!isEditing" class="editable-text">
{{ displayText }}
</span>
<input
v-else
ref="inputRef"
type="text"
class="form-control"
v-model="editValue"
@blur="finishEdit"
@keydown.enter="finishEdit"
@keydown.escape="cancelEdit"
/>
</span>
</template>
<script setup>
import { ref, computed, nextTick } from "vue";
const props = defineProps({
text: {
type: String,
default: ""
},
placeholder: {
type: String,
default: ""
},
emptyLabel: {
type: String,
default: ""
}
});
const emit = defineEmits(["update:modelValue"]);
const isEditing = ref(false);
const editValue = ref("");
const inputRef = ref(null);
const displayText = computed(() => {
if (props.text) return props.text;
if (props.emptyLabel) return props.emptyLabel;
return props.placeholder || "点击编辑";
});
async function startEdit() {
isEditing.value = true;
editValue.value = props.text;
await nextTick();
inputRef.value?.focus();
inputRef.value?.select();
}
function finishEdit() {
if (isEditing.value) {
emit("update:modelValue", editValue.value);
isEditing.value = false;
}
}
function cancelEdit() {
editValue.value = props.text;
isEditing.value = false;
}
</script>
<style lang="scss" scoped>
.editable-input {
display: inline-block;
cursor: pointer;
&:hover {
background-color: var(--bg-light-gray);
border-radius: var(--border-radius-sm);
padding: 0.2rem 0.4rem;
margin: -0.2rem -0.4rem;
}
&.is-editing {
.form-control {
border: 1px solid var(--border-primary);
outline: none;
background-color: var(--fg-color);
}
}
.editable-text {
display: inline-block;
min-width: 1rem;
min-height: 1.2rem;
}
}
</style>

View File

@ -0,0 +1,171 @@
<template>
<div class="property-dialog-overlay" @click="close">
<div class="property-dialog" @click.stop>
<div class="dialog-header">
<h5>{{ isEditing ? '编辑属性' : '添加属性' }}</h5>
<button type="button" class="btn-close" @click="close">×</button>
</div>
<div class="dialog-body">
<PropertyEditor
:property="localProperty"
:property-key="propertyKey"
@update="updateProperty"
/>
</div>
<div class="dialog-footer">
<button type="button" class="btn btn-secondary" @click="close">
取消
</button>
<button type="button" class="btn btn-primary" @click="save">
保存
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import PropertyEditor from './PropertyEditor.vue'
const props = defineProps({
property: {
type: Object,
required: true
},
propertyKey: {
type: String,
default: ''
}
})
const emit = defineEmits(['save', 'close'])
//
const localProperty = ref({ ...props.property })
//
const isEditing = computed(() => !!props.propertyKey)
//
watch(() => props.property, (newProperty) => {
localProperty.value = { ...newProperty }
}, { deep: true })
//
function updateProperty(updatedProperty) {
localProperty.value = updatedProperty
}
function save() {
emit('save', localProperty.value)
}
function close() {
emit('close')
}
</script>
<style lang="scss" scoped>
.property-dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1050;
}
.property-dialog {
background-color: var(--fg-color);
border-radius: var(--border-radius);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
width: 90%;
max-width: 600px;
max-height: 80vh;
display: flex;
flex-direction: column;
}
.dialog-header {
padding: 20px;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
h5 {
margin: 0;
color: var(--text-color);
}
.btn-close {
background: none;
border: none;
font-size: 24px;
color: var(--text-muted);
cursor: pointer;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
&:hover {
color: var(--text-color);
}
}
}
.dialog-body {
padding: 20px;
overflow-y: auto;
flex: 1;
}
.dialog-footer {
padding: 20px;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: flex-end;
gap: 10px;
}
.btn {
padding: 8px 16px;
border: 1px solid transparent;
border-radius: var(--border-radius);
cursor: pointer;
font-size: 14px;
transition: all 0.2s ease;
&.btn-primary {
background-color: var(--primary-color);
color: white;
border-color: var(--primary-color);
&:hover {
background-color: var(--primary-color-dark);
border-color: var(--primary-color-dark);
}
}
&.btn-secondary {
background-color: var(--secondary-color);
color: white;
border-color: var(--secondary-color);
&:hover {
background-color: var(--secondary-color-dark);
border-color: var(--secondary-color-dark);
}
}
}
</style>

View File

@ -0,0 +1,152 @@
<template>
<div class="property-editor">
<div class="field" v-for="(df, i) in propertyFields" :key="i">
<component
:is="df.fieldtype.replaceAll(' ', '') + 'Control'"
:args="args"
:df="df"
:read_only="false"
:value="property[df.fieldname]"
v-model="property[df.fieldname]"
:data-fieldname="df.fieldname"
:data-fieldtype="df.fieldtype"
/>
</div>
</div>
</template>
<script setup>
import { ref, computed } from "vue";
const props = defineProps({
property: {
type: Object,
required: true
},
propertyKey: {
type: String,
required: true
}
});
const emit = defineEmits(['update']);
const args = ref({});
//
const propertyFields = computed(() => {
return [
{
fieldtype: "Data",
fieldname: "title",
label: "标题",
reqd: 1
},
{
fieldtype: "Select",
fieldname: "type",
label: "类型",
options: [
["字符串", "string"],
["数字", "number"],
["整数", "integer"],
["布尔值", "boolean"],
["数组", "array"],
["对象", "object"]
],
reqd: 1
},
{
fieldtype: "Small Text",
fieldname: "description",
label: "描述"
},
{
fieldtype: "Check",
fieldname: "required",
label: "必填"
},
{
fieldtype: "Data",
fieldname: "default",
label: "默认值"
},
{
fieldtype: "Small Text",
fieldname: "format",
label: "格式",
depends_on: "type",
depends_on_value: "string"
},
{
fieldtype: "Int",
fieldname: "minLength",
label: "最小长度",
depends_on: "type",
depends_on_value: "string"
},
{
fieldtype: "Int",
fieldname: "maxLength",
label: "最大长度",
depends_on: "type",
depends_on_value: "string"
},
{
fieldtype: "Float",
fieldname: "minimum",
label: "最小值",
depends_on: "type",
depends_on_value: ["number", "integer"]
},
{
fieldtype: "Float",
fieldname: "maximum",
label: "最大值",
depends_on: "type",
depends_on_value: ["number", "integer"]
},
{
fieldtype: "Small Text",
fieldname: "enum",
label: "枚举选项",
description: "每行一个选项"
}
].filter(field => {
//
if (field.depends_on) {
const dependsOnValue = Array.isArray(field.depends_on_value)
? field.depends_on_value
: [field.depends_on_value];
return dependsOnValue.includes(props.property.type);
}
return true;
});
});
</script>
<style lang="scss" scoped>
.property-editor {
.field {
margin-bottom: 1rem;
:deep(.form-control:disabled) {
color: var(--disabled-text-color);
background-color: var(--disabled-control-bg);
cursor: default;
}
:deep(.label) {
font-weight: 500;
margin-bottom: 0.3rem;
color: var(--text-color);
}
:deep(.description) {
font-size: var(--text-xs);
color: var(--text-muted);
margin-top: 0.2rem;
}
}
}
</style>

View File

@ -0,0 +1,146 @@
<template>
<div
:class="[
'property-item',
hovered ? 'hovered' : '',
selected ? 'selected' : '',
]"
:title="propertyKey"
@click.stop="selectProperty"
@mouseover.stop="hovered = true"
@mouseout.stop="hovered = false"
>
<div class="property-header">
<div class="property-info">
<span class="property-name">{{ property.title || propertyKey }}</span>
<span class="property-type">{{ property.type }}</span>
</div>
<div class="property-actions" v-if="!store.readOnly">
<button class="btn btn-xs btn-outline-secondary" @click.stop="editProperty">
编辑
</button>
<button class="btn btn-xs btn-outline-danger" @click.stop="deleteProperty">
删除
</button>
</div>
</div>
<div class="property-details" v-if="selected">
<PropertyEditor
:property="property"
:property-key="propertyKey"
@update="updateProperty"
/>
</div>
</div>
</template>
<script setup>
import PropertyEditor from "./PropertyEditor.vue";
import { ref } from "vue";
import { useSchemaStore } from "../store";
const props = defineProps({
property: {
type: Object,
required: true
},
propertyKey: {
type: String,
required: true
},
selected: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['select', 'edit', 'delete']);
const store = useSchemaStore();
const hovered = ref(false);
function selectProperty() {
emit('select', props.propertyKey);
}
function editProperty() {
emit('edit', props.propertyKey);
}
function deleteProperty() {
emit('delete', props.propertyKey);
}
function updateProperty(updatedProperty) {
store.updateProperty(props.propertyKey, updatedProperty);
}
</script>
<style lang="scss" scoped>
.property-item {
background-color: var(--fg-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
padding: 12px;
margin-bottom: 8px;
cursor: pointer;
transition: all 0.2s ease;
&:hover,
&.hovered {
border-color: var(--border-primary);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
&.selected {
border-color: var(--border-primary);
background-color: var(--bg-light-gray);
}
.property-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
.property-info {
display: flex;
align-items: center;
gap: 8px;
.property-name {
font-weight: 600;
color: var(--text-color);
}
.property-type {
background-color: var(--bg-gray);
color: var(--text-muted);
padding: 2px 6px;
border-radius: var(--border-radius-sm);
font-size: var(--text-xs);
}
}
.property-actions {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.2s ease;
}
}
&:hover .property-actions,
&.selected .property-actions {
opacity: 1;
}
.property-details {
border-top: 1px solid var(--border-color);
padding-top: 12px;
margin-top: 8px;
}
}
</style>

View File

@ -0,0 +1,207 @@
<template>
<div class="header">
<div class="property-title">
<h6>{{ store.selectedProperty }}</h6>
</div>
<button
class="close-btn btn btn-xs"
:title="__('关闭属性面板')"
@click="store.selectedProperty = null"
>
<div v-html="jingrow.utils.icon('remove', 'sm')"></div>
</button>
</div>
<div class="control-data">
<div v-if="store.selectedProperty">
<div class="field" v-for="(df, i) in propertyFields" :key="i">
<component
:is="df.fieldtype.replaceAll(' ', '') + 'Control'"
:args="args"
:df="df"
:read_only="false"
:value="getPropertyValue(df.fieldname)"
v-model="propertyData[df.fieldname]"
:data-fieldname="df.fieldname"
:data-fieldtype="df.fieldtype"
/>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from "vue";
import { useSchemaStore } from "../store";
const store = useSchemaStore();
const args = ref({});
const propertyData = ref({});
//
const propertyFields = computed(() => {
return [
{
fieldtype: "Data",
fieldname: "title",
label: "标题",
reqd: 1
},
{
fieldtype: "Select",
fieldname: "type",
label: "类型",
options: [
["字符串", "string"],
["数字", "number"],
["整数", "integer"],
["布尔值", "boolean"],
["数组", "array"],
["对象", "object"]
],
reqd: 1
},
{
fieldtype: "Small Text",
fieldname: "description",
label: "描述"
},
{
fieldtype: "Check",
fieldname: "required",
label: "必填"
},
{
fieldtype: "Data",
fieldname: "default",
label: "默认值"
},
{
fieldtype: "Small Text",
fieldname: "format",
label: "格式",
depends_on: "type",
depends_on_value: "string"
},
{
fieldtype: "Int",
fieldname: "minLength",
label: "最小长度",
depends_on: "type",
depends_on_value: "string"
},
{
fieldtype: "Int",
fieldname: "maxLength",
label: "最大长度",
depends_on: "type",
depends_on_value: "string"
},
{
fieldtype: "Float",
fieldname: "minimum",
label: "最小值",
depends_on: "type",
depends_on_value: ["number", "integer"]
},
{
fieldtype: "Float",
fieldname: "maximum",
label: "最大值",
depends_on: "type",
depends_on_value: ["number", "integer"]
},
{
fieldtype: "Small Text",
fieldname: "enum",
label: "枚举选项",
description: "每行一个选项"
}
].filter(field => {
//
if (field.depends_on) {
const currentType = propertyData.value.type || store.getProperty(store.selectedProperty)?.type;
const dependsOnValue = Array.isArray(field.depends_on_value)
? field.depends_on_value
: [field.depends_on_value];
return dependsOnValue.includes(currentType);
}
return true;
});
});
function getPropertyValue(fieldname) {
const property = store.getProperty(store.selectedProperty);
return property ? property[fieldname] : "";
}
// store
watch(propertyData, (newData) => {
if (store.selectedProperty) {
store.updateProperty(store.selectedProperty, newData);
}
}, { deep: true });
//
watch(() => store.selectedProperty, (newPropertyKey) => {
if (newPropertyKey) {
const property = store.getProperty(newPropertyKey);
if (property) {
propertyData.value = { ...property };
}
}
}, { immediate: true });
</script>
<style lang="scss" scoped>
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 5px;
border-bottom: 1px solid var(--border-color);
.property-title {
h6 {
margin: 0;
color: var(--heading-color);
}
}
.close-btn {
margin-right: -5px;
}
}
.control-data {
height: calc(100vh - 202px);
overflow-y: auto;
padding: 8px;
.field {
margin: 5px;
margin-top: 0;
margin-bottom: 1rem;
:deep(.form-control:disabled) {
color: var(--disabled-text-color);
background-color: var(--disabled-control-bg);
cursor: default;
}
:deep(.label) {
font-weight: 500;
margin-bottom: 0.3rem;
color: var(--text-color);
}
:deep(.description) {
font-size: var(--text-xs);
color: var(--text-muted);
margin-top: 0.2rem;
}
}
}
</style>

View File

@ -0,0 +1,157 @@
<template>
<div class="schema-canvas">
<div class="schema-header" v-if="!store.preview">
<div class="schema-title">
<EditableInput
:text="store.schema.title || 'Schema 标题'"
:placeholder="__('Schema Title')"
v-model="store.schema.title"
/>
</div>
<div class="schema-actions">
<button
class="btn btn-sm btn-secondary"
@click="store.togglePreview"
>
{{ store.preview ? '编辑' : '预览' }}
</button>
<button
class="btn btn-sm btn-primary"
@click="saveSchema"
>
保存
</button>
</div>
</div>
<div class="schema-content">
<div class="schema-properties" v-if="!store.preview">
<div class="properties-header">
<h5>属性配置</h5>
</div>
<div class="properties-list">
<draggable
v-model="store.properties"
group="properties"
:animation="200"
item-key="key"
:disabled="store.readOnly"
>
<template #item="{ element }">
<SchemaField
:field="element"
:field-key="element.key"
:selected="store.selectedField === element.key"
@select="selectField"
@edit="editField"
@delete="deleteField"
/>
</template>
</draggable>
</div>
</div>
<div class="schema-preview" v-if="store.preview">
<pre>{{ JSON.stringify(store.schema, null, 2) }}</pre>
</div>
</div>
</div>
</template>
<script setup>
import draggable from "vuedraggable";
import SchemaField from "./SchemaField.vue";
import EditableInput from "./EditableInput.vue";
import { useSchemaStore } from "../store";
import { useMagicKeys, whenever } from "@vueuse/core";
const store = useSchemaStore();
// delete/backspace to delete the field
const { Backspace } = useMagicKeys();
whenever(Backspace, (value) => {
if (value && store.selectedField && store.notUsingInput) {
deleteField(store.selectedField);
}
});
function selectField(fieldKey) {
store.selectedField = fieldKey;
}
function editField(fieldKey) {
store.selectedField = fieldKey;
//
}
function deleteField(fieldKey) {
store.removeProperty(fieldKey);
}
function saveSchema() {
//
store.saveSchema();
}
</script>
<style lang="scss" scoped>
.schema-canvas {
padding: 1rem;
.schema-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color);
.schema-title {
flex: 1;
:deep(span) {
font-weight: 600;
color: var(--heading-color);
font-size: 1.2rem;
}
}
.schema-actions {
display: flex;
gap: 0.5rem;
}
}
.schema-content {
.properties-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
h5 {
margin: 0;
color: var(--heading-color);
}
}
.properties-list {
min-height: 200px;
border: 1px dashed var(--border-color);
border-radius: var(--border-radius);
padding: 1rem;
}
.schema-preview {
pre {
background-color: var(--bg-light-gray);
padding: 1rem;
border-radius: var(--border-radius);
overflow-x: auto;
font-size: var(--text-sm);
}
}
}
}
</style>

View File

@ -0,0 +1,165 @@
<template>
<div
:class="['schema-field', selected ? 'selected' : hovered ? 'hovered' : '']"
:title="fieldKey"
@click.stop="selectField"
@mouseover.stop="hovered = true"
@mouseout.stop="hovered = false"
>
<div class="field-header">
<div class="field-info">
<EditableInput
:text="field.title || '新属性'"
:placeholder="__('Property Title')"
v-model="field.title"
/>
<span class="field-type">{{ field.type }}</span>
</div>
<div class="field-actions" v-if="!store.readOnly">
<button
class="btn btn-xs btn-outline-secondary"
@click.stop="editField"
>
编辑
</button>
<button
class="btn btn-xs btn-outline-danger"
@click.stop="deleteField"
>
删除
</button>
</div>
</div>
<div class="field-description" v-if="field.description">
<EditableInput
:text="field.description"
:placeholder="__('Description')"
v-model="field.description"
/>
</div>
<div class="field-properties" v-if="field.properties">
<div class="properties-count">
{{ Object.keys(field.properties).length }} 个子属性
</div>
</div>
</div>
</template>
<script setup>
import EditableInput from "./EditableInput.vue";
import { useSchemaStore } from "../store";
import { ref } from "vue";
const props = defineProps({
field: {
type: Object,
required: true
},
fieldKey: {
type: String,
required: true
},
selected: {
type: Boolean,
default: false
}
});
const store = useSchemaStore();
const hovered = ref(false);
function selectField() {
store.selectedField = props.fieldKey;
}
function editField() {
store.selectedField = props.fieldKey;
//
}
function deleteField() {
store.removeProperty(props.fieldKey);
}
</script>
<style lang="scss" scoped>
.schema-field {
background-color: var(--bg-light-gray);
border-radius: var(--border-radius-sm);
border: 1px solid transparent;
padding: 0.75rem;
margin-bottom: 0.5rem;
cursor: pointer;
&:not(:last-child) {
margin-bottom: 0.5rem;
}
&.hovered,
&.selected {
border-color: var(--border-primary);
background-color: var(--fg-color);
}
.field-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
.field-info {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
:deep(span) {
font-weight: 500;
color: var(--heading-color);
}
.field-type {
background-color: var(--gray-200);
color: var(--text-muted);
padding: 0.2rem 0.5rem;
border-radius: var(--border-radius-sm);
font-size: var(--text-xs);
font-weight: normal;
}
}
.field-actions {
display: flex;
gap: 0.25rem;
opacity: 0;
transition: opacity 0.2s;
}
}
&:hover .field-actions {
opacity: 1;
}
.field-description {
margin-bottom: 0.5rem;
:deep(span) {
color: var(--text-muted);
font-size: var(--text-sm);
}
}
.field-properties {
.properties-count {
color: var(--text-muted);
font-size: var(--text-xs);
background-color: var(--gray-100);
padding: 0.2rem 0.5rem;
border-radius: var(--border-radius-sm);
display: inline-block;
}
}
}
</style>

View File

@ -0,0 +1,237 @@
<template>
<div class="schema-sidebar">
<div class="sidebar-header">
<h5>属性类型</h5>
</div>
<div class="sidebar-content">
<div class="property-types">
<div
v-for="type in propertyTypes"
:key="type.value"
class="property-type-item"
:draggable="true"
@dragstart="onDragStart($event, type)"
>
<div class="type-icon">
<div v-html="jingrow.utils.icon(type.icon, 'sm')"></div>
</div>
<div class="type-info">
<div class="type-name">{{ type.label }}</div>
<div class="type-description">{{ type.description }}</div>
</div>
</div>
</div>
<div class="sidebar-actions" v-if="store.selectedField">
<h6>属性设置</h6>
<div class="field-settings">
<div class="setting-item">
<label>属性名称</label>
<input
type="text"
class="form-control"
v-model="selectedField.title"
@input="updateField"
/>
</div>
<div class="setting-item">
<label>属性类型</label>
<select
class="form-control"
v-model="selectedField.type"
@change="updateField"
>
<option value="string">字符串</option>
<option value="number">数字</option>
<option value="boolean">布尔值</option>
<option value="array">数组</option>
<option value="object">对象</option>
</select>
</div>
<div class="setting-item">
<label>描述</label>
<textarea
class="form-control"
v-model="selectedField.description"
@input="updateField"
rows="3"
></textarea>
</div>
<div class="setting-item">
<label>
<input
type="checkbox"
v-model="selectedField.required"
@change="updateField"
/>
必填
</label>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { useSchemaStore } from "../store";
import { computed } from "vue";
const store = useSchemaStore();
const propertyTypes = [
{
value: "string",
label: "字符串",
description: "文本输入",
icon: "text"
},
{
value: "number",
label: "数字",
description: "数字输入",
icon: "number"
},
{
value: "boolean",
label: "布尔值",
description: "是/否选择",
icon: "check"
},
{
value: "array",
label: "数组",
description: "列表数据",
icon: "list"
},
{
value: "object",
label: "对象",
description: "嵌套对象",
icon: "object"
}
];
const selectedField = computed(() => {
if (!store.selectedField) return null;
return store.properties.find(p => p.key === store.selectedField);
});
function onDragStart(event, type) {
event.dataTransfer.setData("application/json", JSON.stringify(type));
}
function updateField() {
if (selectedField.value) {
store.updateProperty(selectedField.value.key, selectedField.value);
}
}
</script>
<style lang="scss" scoped>
.schema-sidebar {
height: 100%;
display: flex;
flex-direction: column;
.sidebar-header {
padding: 1rem;
border-bottom: 1px solid var(--border-color);
h5 {
margin: 0;
color: var(--heading-color);
}
}
.sidebar-content {
flex: 1;
overflow-y: auto;
padding: 1rem;
.property-types {
margin-bottom: 2rem;
.property-type-item {
display: flex;
align-items: center;
padding: 0.75rem;
margin-bottom: 0.5rem;
background-color: var(--bg-light-gray);
border-radius: var(--border-radius);
cursor: grab;
border: 1px solid transparent;
transition: all 0.2s;
&:hover {
border-color: var(--border-primary);
background-color: var(--fg-color);
}
&:active {
cursor: grabbing;
}
.type-icon {
margin-right: 0.75rem;
color: var(--text-muted);
}
.type-info {
flex: 1;
.type-name {
font-weight: 500;
color: var(--heading-color);
margin-bottom: 0.25rem;
}
.type-description {
font-size: var(--text-sm);
color: var(--text-muted);
}
}
}
}
.sidebar-actions {
h6 {
margin-bottom: 1rem;
color: var(--heading-color);
}
.field-settings {
.setting-item {
margin-bottom: 1rem;
label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: var(--heading-color);
}
.form-control {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
font-size: var(--text-sm);
&:focus {
outline: none;
border-color: var(--border-primary);
}
}
textarea.form-control {
resize: vertical;
min-height: 80px;
}
}
}
}
}
}
</style>

View File

@ -0,0 +1,892 @@
// Schema Builder Bundle - 完全按照 Jingrow 表单构建器模式重新实现
// 检查依赖
if (typeof Vue === 'undefined') {
console.error('Vue.js 未加载');
}
if (typeof Pinia === 'undefined') {
console.error('Pinia 未加载');
}
// 使用全局 Vue 和 Pinia
const { createApp, ref, computed, nextTick, watch } = Vue;
const { createPinia, defineStore } = Pinia;
// Schema Store
const useSchemaStore = defineStore("schema-builder-store", () => {
// State
const schema = ref({
$schema: "https://json-schema.org/draft/2020-12/schema",
type: "object",
title: "AI 节点配置",
properties: {},
required: []
});
const properties = ref([]);
const selectedField = ref(null);
const preview = ref(false);
const readOnly = ref(false);
const dirty = ref(false);
// Getters
const notUsingInput = computed(() => {
const activeElement = document.activeElement;
return !activeElement ||
activeElement.readOnly ||
activeElement.disabled ||
(activeElement.tagName !== "INPUT" && activeElement.tagName !== "TEXTAREA");
});
// Actions
function addProperty(type = "string") {
const key = `property_${Date.now()}`;
const newProperty = {
key,
type,
title: "新属性",
description: "",
required: false
};
properties.value.push(newProperty);
updateSchema();
selectedField.value = key;
dirty.value = true;
}
function removeProperty(key) {
const index = properties.value.findIndex(p => p.key === key);
if (index > -1) {
properties.value.splice(index, 1);
updateSchema();
if (selectedField.value === key) {
selectedField.value = null;
}
dirty.value = true;
}
}
function updateProperty(key, updatedProperty) {
const index = properties.value.findIndex(p => p.key === key);
if (index > -1) {
properties.value[index] = { ...properties.value[index], ...updatedProperty };
updateSchema();
dirty.value = true;
}
}
function updateSchema() {
schema.value.properties = {};
schema.value.required = [];
properties.value.forEach(prop => {
schema.value.properties[prop.key] = {
type: prop.type,
title: prop.title,
description: prop.description
};
if (prop.required) {
schema.value.required.push(prop.key);
}
});
}
function togglePreview() {
preview.value = !preview.value;
}
function saveSchema() {
dirty.value = false;
}
function loadSchema(schemaData) {
if (typeof schemaData === "string") {
try {
schemaData = JSON.parse(schemaData);
} catch (e) {
console.error("Invalid JSON schema:", e);
return;
}
}
schema.value = schemaData;
properties.value = [];
if (schemaData.properties) {
Object.entries(schemaData.properties).forEach(([key, prop]) => {
properties.value.push({
key,
type: prop.type || "string",
title: prop.title || key,
description: prop.description || "",
required: schemaData.required?.includes(key) || false
});
});
}
dirty.value = false;
}
return {
schema,
properties,
selectedField,
preview,
readOnly,
dirty,
notUsingInput,
addProperty,
removeProperty,
updateProperty,
updateSchema,
togglePreview,
saveSchema,
loadSchema
};
});
// EditableInput 组件
const EditableInput = {
props: {
text: {
type: String,
default: ""
},
placeholder: {
type: String,
default: ""
},
emptyLabel: {
type: String,
default: ""
}
},
emits: ['update:modelValue'],
setup(props, { emit }) {
const isEditing = ref(false);
const editValue = ref("");
const inputRef = ref(null);
const displayText = computed(() => {
if (props.text) return props.text;
if (props.emptyLabel) return props.emptyLabel;
return props.placeholder || "点击编辑";
});
async function startEdit() {
isEditing.value = true;
editValue.value = props.text;
await nextTick();
inputRef.value?.focus();
inputRef.value?.select();
}
function finishEdit() {
if (isEditing.value) {
emit("update:modelValue", editValue.value);
isEditing.value = false;
}
}
function cancelEdit() {
editValue.value = props.text;
isEditing.value = false;
}
return {
isEditing,
editValue,
inputRef,
displayText,
startEdit,
finishEdit,
cancelEdit
};
},
template: `
<span
class="editable-input"
:class="{ 'is-editing': isEditing }"
@click="startEdit"
@keydown.enter="finishEdit"
@keydown.escape="cancelEdit"
@blur="finishEdit"
tabindex="0"
>
<span v-if="!isEditing" class="editable-text">
{{ displayText }}
</span>
<input
v-else
ref="inputRef"
type="text"
class="form-control"
v-model="editValue"
@blur="finishEdit"
@keydown.enter="finishEdit"
@keydown.escape="cancelEdit"
/>
</span>
`
};
// SchemaField 组件
const SchemaField = {
props: {
field: {
type: Object,
required: true
},
fieldKey: {
type: String,
required: true
},
selected: {
type: Boolean,
default: false
}
},
emits: ['select', 'edit', 'delete'],
setup(props, { emit }) {
const store = useSchemaStore();
const hovered = ref(false);
function selectField() {
emit('select', props.fieldKey);
}
function editField() {
emit('edit', props.fieldKey);
}
function deleteField() {
emit('delete', props.fieldKey);
}
return {
store,
hovered,
selectField,
editField,
deleteField
};
},
components: {
EditableInput
},
template: `
<div
:class="['schema-field', selected ? 'selected' : hovered ? 'hovered' : '']"
:title="fieldKey"
@click.stop="selectField"
@mouseover.stop="hovered = true"
@mouseout.stop="hovered = false"
>
<div class="field-header">
<div class="field-info">
<EditableInput
:text="field.title || '新属性'"
:placeholder="__('Property Title')"
v-model="field.title"
/>
<span class="field-type">{{ field.type }}</span>
</div>
<div class="field-actions" v-if="!store.readOnly">
<button
class="btn btn-xs btn-outline-secondary"
@click.stop="editField"
>
编辑
</button>
<button
class="btn btn-xs btn-outline-danger"
@click.stop="deleteField"
>
删除
</button>
</div>
</div>
<div class="field-description" v-if="field.description">
<EditableInput
:text="field.description"
:placeholder="__('Description')"
v-model="field.description"
/>
</div>
</div>
`
};
// SchemaCanvas 组件
const SchemaCanvas = {
setup() {
const store = useSchemaStore();
function selectField(fieldKey) {
store.selectedField = fieldKey;
}
function editField(fieldKey) {
store.selectedField = fieldKey;
}
function deleteField(fieldKey) {
store.removeProperty(fieldKey);
}
function saveSchema() {
store.saveSchema();
}
return {
store,
selectField,
editField,
deleteField,
saveSchema
};
},
components: {
SchemaField,
EditableInput
},
template: `
<div class="schema-canvas">
<div class="schema-header" v-if="!store.preview">
<div class="schema-title">
<EditableInput
:text="store.schema.title || 'Schema 标题'"
:placeholder="__('Schema Title')"
v-model="store.schema.title"
/>
</div>
<div class="schema-actions">
<button
class="btn btn-sm btn-secondary"
@click="store.togglePreview"
>
{{ store.preview ? '编辑' : '预览' }}
</button>
<button
class="btn btn-sm btn-primary"
@click="saveSchema"
>
保存
</button>
</div>
</div>
<div class="schema-content">
<div class="schema-properties" v-if="!store.preview">
<div class="properties-header">
<h5>属性配置</h5>
</div>
<div class="properties-list">
<div
v-for="element in store.properties"
:key="element.key"
class="property-item-wrapper"
>
<SchemaField
:field="element"
:field-key="element.key"
:selected="store.selectedField === element.key"
@select="selectField"
@edit="editField"
@delete="deleteField"
/>
</div>
</div>
</div>
<div class="schema-preview" v-if="store.preview">
<pre>{{ JSON.stringify(store.schema, null, 2) }}</pre>
</div>
</div>
</div>
`
};
// SchemaSidebar 组件
const SchemaSidebar = {
setup() {
const store = useSchemaStore();
const propertyTypes = [
{
value: "string",
label: "字符串",
description: "文本输入",
icon: "text",
iconText: "T"
},
{
value: "number",
label: "数字",
description: "数字输入",
icon: "number",
iconText: "123"
},
{
value: "boolean",
label: "布尔值",
description: "是/否选择",
icon: "check",
iconText: "✓"
},
{
value: "array",
label: "数组",
description: "列表数据",
icon: "list",
iconText: "[]"
},
{
value: "object",
label: "对象",
description: "嵌套对象",
icon: "object",
iconText: "{}"
}
];
const selectedField = computed(() => {
if (!store.selectedField) return null;
return store.properties.find(p => p.key === store.selectedField);
});
function onDragStart(event, type) {
event.dataTransfer.setData("application/json", JSON.stringify(type));
}
function updateField() {
if (selectedField.value) {
store.updateProperty(selectedField.value.key, selectedField.value);
}
}
return {
store,
propertyTypes,
selectedField,
onDragStart,
updateField
};
},
template: `
<div class="schema-sidebar">
<div class="sidebar-header">
<h5>属性类型</h5>
</div>
<div class="sidebar-content">
<div class="property-types">
<div
v-for="type in propertyTypes"
:key="type.value"
class="property-type-item"
:draggable="true"
@dragstart="onDragStart($event, type)"
>
<div class="type-icon">
<span class="icon-text">{{ type.iconText }}</span>
</div>
<div class="type-info">
<div class="type-name">{{ type.label }}</div>
<div class="type-description">{{ type.description }}</div>
</div>
</div>
</div>
<div class="sidebar-actions" v-if="store.selectedField">
<h6>属性设置</h6>
<div class="field-settings">
<div class="setting-item">
<label>属性名称</label>
<input
type="text"
class="form-control"
v-model="selectedField.title"
@input="updateField"
/>
</div>
<div class="setting-item">
<label>属性类型</label>
<select
class="form-control"
v-model="selectedField.type"
@change="updateField"
>
<option value="string">字符串</option>
<option value="number">数字</option>
<option value="boolean">布尔值</option>
<option value="array">数组</option>
<option value="object">对象</option>
</select>
</div>
<div class="setting-item">
<label>描述</label>
<textarea
class="form-control"
v-model="selectedField.description"
@input="updateField"
rows="3"
></textarea>
</div>
<div class="setting-item">
<label>
<input
type="checkbox"
v-model="selectedField.required"
@change="updateField"
/>
必填
</label>
</div>
</div>
</div>
</div>
</div>
`
};
// 主 SchemaBuilder 组件
const SchemaBuilder = {
props: {
initialSchema: {
type: Object,
default: () => ({})
},
nodeType: {
type: String,
default: ""
}
},
emits: ['save', 'change'],
setup(props, { emit }) {
const store = useSchemaStore();
// 加载初始 schema
if (props.initialSchema && Object.keys(props.initialSchema).length > 0) {
store.loadSchema(props.initialSchema);
}
// 监听 schema 变化
watch(() => store.schema, (newSchema) => {
emit('change', newSchema);
}, { deep: true });
function handleSave() {
emit('save', store.schema);
}
return {
store,
handleSave
};
},
components: {
SchemaCanvas,
SchemaSidebar
},
template: `
<div class="schema-builder-container">
<div class="schema-main" :class="[store.preview ? 'preview' : '']">
<SchemaCanvas />
</div>
<div class="schema-sidebar">
<SchemaSidebar />
</div>
</div>
`
};
// 添加样式
const style = document.createElement('style');
style.textContent = `
.icon-text {
display: inline-block;
width: 20px;
height: 20px;
line-height: 20px;
text-align: center;
background-color: var(--gray-200);
border-radius: 50%;
font-size: 12px;
font-weight: bold;
color: var(--text-muted);
}
.schema-builder-container {
display: flex;
height: 100%;
}
.schema-main {
flex: 1;
background-color: var(--disabled-control-bg);
border-radius: var(--border-radius);
border: 1px solid var(--border-color);
background-color: var(--card-bg);
margin: 5px;
}
.schema-sidebar {
width: 300px;
border-left: 1px solid var(--border-color);
border-bottom-right-radius: var(--border-radius);
background-color: var(--fg-color);
}
.schema-canvas {
padding: 1rem;
}
.schema-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color);
}
.schema-title {
flex: 1;
}
.schema-title span {
font-weight: 600;
color: var(--heading-color);
font-size: 1.2rem;
}
.schema-actions {
display: flex;
gap: 0.5rem;
}
.properties-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.properties-header h5 {
margin: 0;
color: var(--heading-color);
}
.properties-list {
min-height: 200px;
border: 1px dashed var(--border-color);
border-radius: var(--border-radius);
padding: 1rem;
}
.schema-preview pre {
background-color: var(--bg-light-gray);
padding: 1rem;
border-radius: var(--border-radius);
overflow-x: auto;
font-size: var(--text-sm);
}
.schema-field {
background-color: var(--bg-light-gray);
border-radius: var(--border-radius-sm);
border: 1px solid transparent;
padding: 0.75rem;
margin-bottom: 0.5rem;
cursor: pointer;
}
.schema-field:not(:last-child) {
margin-bottom: 0.5rem;
}
.schema-field.hovered,
.schema-field.selected {
border-color: var(--border-primary);
background-color: var(--fg-color);
}
.field-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.field-info {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
}
.field-info span {
font-weight: 500;
color: var(--heading-color);
}
.field-type {
background-color: var(--gray-200);
color: var(--text-muted);
padding: 0.2rem 0.5rem;
border-radius: var(--border-radius-sm);
font-size: var(--text-xs);
font-weight: normal;
}
.field-actions {
display: flex;
gap: 0.25rem;
opacity: 0;
transition: opacity 0.2s;
}
.schema-field:hover .field-actions {
opacity: 1;
}
.field-description {
margin-bottom: 0.5rem;
}
.field-description span {
color: var(--text-muted);
font-size: var(--text-sm);
}
.sidebar-header {
padding: 1rem;
border-bottom: 1px solid var(--border-color);
}
.sidebar-header h5 {
margin: 0;
color: var(--heading-color);
}
.sidebar-content {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
.property-types {
margin-bottom: 2rem;
}
.property-type-item {
display: flex;
align-items: center;
padding: 0.75rem;
margin-bottom: 0.5rem;
background-color: var(--bg-light-gray);
border-radius: var(--border-radius);
cursor: grab;
border: 1px solid transparent;
transition: all 0.2s;
}
.property-type-item:hover {
border-color: var(--border-primary);
background-color: var(--fg-color);
}
.property-type-item:active {
cursor: grabbing;
}
.type-icon {
margin-right: 0.75rem;
color: var(--text-muted);
}
.type-info {
flex: 1;
}
.type-name {
font-weight: 500;
color: var(--heading-color);
margin-bottom: 0.25rem;
}
.type-description {
font-size: var(--text-sm);
color: var(--text-muted);
}
.sidebar-actions h6 {
margin-bottom: 1rem;
color: var(--heading-color);
}
.field-settings .setting-item {
margin-bottom: 1rem;
}
.field-settings label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: var(--heading-color);
}
.field-settings .form-control {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
font-size: var(--text-sm);
}
.field-settings .form-control:focus {
outline: none;
border-color: var(--border-primary);
}
.field-settings textarea.form-control {
resize: vertical;
min-height: 80px;
}
.editable-input {
display: inline-block;
cursor: pointer;
}
.editable-input:hover {
background-color: var(--bg-light-gray);
border-radius: var(--border-radius-sm);
padding: 0.2rem 0.4rem;
margin: -0.2rem -0.4rem;
}
.editable-input.is-editing .form-control {
border: 1px solid var(--border-primary);
outline: none;
background-color: var(--fg-color);
}
.editable-text {
display: inline-block;
min-width: 1rem;
min-height: 1.2rem;
}
`;
document.head.appendChild(style);
// 导出到全局
window.SchemaBuilder = SchemaBuilder;
window.SchemaStore = useSchemaStore;
console.log("Schema Builder Bundle 加载完成");

View File

@ -0,0 +1,146 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
export const useSchemaStore = defineStore("schema-builder-store", () => {
// State
const schema = ref({
$schema: "https://json-schema.org/draft/2020-12/schema",
type: "object",
title: "AI 节点配置",
properties: {},
required: []
});
const properties = ref([]);
const selectedField = ref(null);
const preview = ref(false);
const readOnly = ref(false);
const dirty = ref(false);
// Getters
const notUsingInput = computed(() => {
// 检查是否正在使用输入框
const activeElement = document.activeElement;
return !activeElement ||
activeElement.readOnly ||
activeElement.disabled ||
(activeElement.tagName !== "INPUT" && activeElement.tagName !== "TEXTAREA");
});
// Actions
function addProperty(type = "string") {
const key = `property_${Date.now()}`;
const newProperty = {
key,
type,
title: "新属性",
description: "",
required: false
};
properties.value.push(newProperty);
updateSchema();
selectedField.value = key;
dirty.value = true;
}
function removeProperty(key) {
const index = properties.value.findIndex(p => p.key === key);
if (index > -1) {
properties.value.splice(index, 1);
updateSchema();
if (selectedField.value === key) {
selectedField.value = null;
}
dirty.value = true;
}
}
function updateProperty(key, updatedProperty) {
const index = properties.value.findIndex(p => p.key === key);
if (index > -1) {
properties.value[index] = { ...properties.value[index], ...updatedProperty };
updateSchema();
dirty.value = true;
}
}
function updateSchema() {
// 更新 schema 对象
schema.value.properties = {};
schema.value.required = [];
properties.value.forEach(prop => {
schema.value.properties[prop.key] = {
type: prop.type,
title: prop.title,
description: prop.description
};
if (prop.required) {
schema.value.required.push(prop.key);
}
});
}
function togglePreview() {
preview.value = !preview.value;
}
function saveSchema() {
// 触发保存事件
dirty.value = false;
// 这里可以触发父组件的保存事件
}
function loadSchema(schemaData) {
if (typeof schemaData === "string") {
try {
schemaData = JSON.parse(schemaData);
} catch (e) {
console.error("Invalid JSON schema:", e);
return;
}
}
schema.value = schemaData;
properties.value = [];
// 解析 properties
if (schemaData.properties) {
Object.entries(schemaData.properties).forEach(([key, prop]) => {
properties.value.push({
key,
type: prop.type || "string",
title: prop.title || key,
description: prop.description || "",
required: schemaData.required?.includes(key) || false
});
});
}
dirty.value = false;
}
return {
// State
schema,
properties,
selectedField,
preview,
readOnly,
dirty,
// Getters
notUsingInput,
// Actions
addProperty,
removeProperty,
updateProperty,
updateSchema,
togglePreview,
saveSchema,
loadSchema
};
});