Restore AI workflow design nodes

This commit is contained in:
2026-06-09 21:18:33 +08:00
parent fbd726ebdf
commit cb45c0a068
71 changed files with 22676 additions and 21 deletions
@@ -0,0 +1,258 @@
"""
应用创建节点
创建一个新的应用(子应用)
"""
import logging
from typing import Any, Dict, Optional
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
logger = logging.getLogger(__name__)
@NodeRegistry.register
class AppCreateNode(BaseNode):
"""
应用创建节点
创建一个新的应用(低代码平台的顶层容器)
"""
node_type = 'app_create'
node_name = '创建应用'
node_category = 'application'
node_icon = 'app-window'
node_description = '创建一个新的应用(子应用)'
inputs = [
{
'name': 'name',
'type': 'string',
'description': '应用名称',
'required': True,
},
{
'name': 'code',
'type': 'string',
'description': '应用编码(唯一标识,用于URL路由,只能包含字母、数字、下划线和连字符,必须以字母开头)',
'required': True,
},
{
'name': 'description',
'type': 'string',
'description': '应用描述',
'required': False,
},
{
'name': 'icon',
'type': 'string',
'description': '应用图标',
'required': False,
},
{
'name': 'app_type',
'type': 'string',
'description': '应用类型: form-表单应用, workflow-流程应用, dashboard-数据应用, screen-大屏应用, mixed-混合应用',
'required': False,
},
]
outputs = [
{
'name': 'app_id',
'type': 'string',
'description': '创建的应用ID',
},
{
'name': 'app_code',
'type': 'string',
'description': '应用编码',
},
{
'name': 'app_name',
'type': 'string',
'description': '应用名称',
},
{
'name': 'success',
'type': 'boolean',
'description': '是否创建成功',
},
]
def execute(self, context: NodeContext) -> NodeResult:
"""执行节点(同步方法,通过运行异步方法实现)"""
import asyncio
loop = asyncio.get_event_loop()
if loop.is_running():
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(asyncio.run, self.execute_async(context))
return future.result()
else:
return loop.run_until_complete(self.execute_async(context))
async def execute_async(self, context: NodeContext) -> NodeResult:
"""异步执行节点"""
try:
# 获取输入参数并解析变量引用
name = context.resolve_template(self.config.get('name', ''))
code_base = context.resolve_template(self.config.get('code', ''))
description = context.resolve_template(self.config.get('description', ''))
icon = context.resolve_template(self.config.get('icon', ''))
app_type = context.resolve_template(self.config.get('app_type', 'mixed'))
# 配置选项
update_if_exists = self.config.get('update_if_exists', False)
logger.info(f'AppCreateNode - 开始创建应用: name={name}, code={code_base}')
# 验证必要参数
if not name:
return NodeResult(
success=False,
error='应用名称不能为空',
)
if not code_base:
return NodeResult(
success=False,
error='应用编码不能为空',
)
# 验证编码格式(字母开头,只能包含字母、数字、下划线和连字符)
import re
if not re.match(r'^[a-zA-Z][a-zA-Z0-9_-]*$', code_base):
return NodeResult(
success=False,
error='应用编码格式不正确,必须以字母开头,只能包含字母、数字、下划线和连字符',
)
# 导入服务和数据库会话
from app.database import AsyncSessionLocal
from core.application.service import ApplicationService
from core.application.schema import ApplicationCreate, ApplicationUpdate
async with AsyncSessionLocal() as db:
# 生成唯一的 code(如果已存在则自动添加后缀)
code = await self._get_unique_code(db, code_base, ApplicationService)
logger.info(f'AppCreateNode - 使用 code: {code} (原始: {code_base})')
# 检查应用编码是否已存在(理论上不应该存在,因为已经生成了唯一 code)
existing_app = await ApplicationService.get_by_code(db, code)
if existing_app:
if update_if_exists:
# 更新已存在的应用
update_data = ApplicationUpdate(
name=name,
description=description or None,
icon=icon or None,
app_type=app_type or None,
)
updated_app = await ApplicationService.update(
db,
record_id=existing_app.id,
data=update_data,
auto_commit=True
)
if updated_app:
logger.info(f'AppCreateNode - 应用已存在,更新成功: id={updated_app.id}')
return NodeResult(
success=True,
output=f'应用 {name} 更新成功',
output_variables={
'app_id': updated_app.id,
'app_code': updated_app.code,
'app_name': updated_app.name,
'success': True,
}
)
else:
return NodeResult(
success=False,
error=f'更新应用失败',
)
else:
# 应用已存在,返回已存在的应用信息
logger.info(f'AppCreateNode - 应用编码已存在: code={code}, id={existing_app.id}')
return NodeResult(
success=True,
output=f'应用编码 {code} 已存在',
output_variables={
'app_id': existing_app.id,
'app_code': existing_app.code,
'app_name': existing_app.name,
'success': False, # 标记为未创建新应用
}
)
# 创建新应用
create_data = ApplicationCreate(
name=name,
code=code,
description=description or '',
icon=icon or '',
app_type=app_type or 'mixed',
)
new_app = await ApplicationService.create(db, data=create_data, auto_commit=True)
if new_app:
logger.info(f'AppCreateNode - 应用创建成功: id={new_app.id}, code={new_app.code}')
return NodeResult(
success=True,
output=f'应用 {name} 创建成功',
output_variables={
'app_id': new_app.id,
'app_code': new_app.code,
'app_name': new_app.name,
'success': True,
}
)
else:
return NodeResult(
success=False,
error='创建应用失败',
)
except Exception as e:
logger.exception(f'AppCreateNode - 执行异常: {e}')
return NodeResult(
success=False,
error=f'创建应用异常: {str(e)}',
)
async def _get_unique_code(self, db, base_code: str, service) -> str:
"""
生成唯一的 code,如果已存在则自动添加数字后缀
Args:
db: 数据库会话
base_code: 基础 code
service: 服务类(需要有 get_by_code 方法)
Returns:
唯一的 code
"""
code = base_code
counter = 1
while True:
try:
existing = await service.get_by_code(db, code)
if not existing:
# code 不存在,可以使用
break
# code 已存在,添加后缀
code = f"{base_code}_{counter}"
counter += 1
except Exception:
# 查询出错,认为 code 可用
break
return code
@@ -0,0 +1,205 @@
"""
应用设计节点
接收LLM生成的应用设计方案,展示给用户确认或编辑
"""
import json
import logging
from typing import Any, Dict, Optional
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
logger = logging.getLogger(__name__)
@NodeRegistry.register
class AppDesignNode(BaseNode):
"""
应用设计节点
接收LLM生成的应用设计方案(Markdown格式),展示给用户确认或编辑
用户可以查看设计方案,编辑后确认继续
"""
node_type = 'app_design'
node_name = '应用设计'
node_category = 'application'
node_icon = 'layout'
node_description = '展示应用设计方案,支持用户确认或编辑'
inputs = [
{
'name': 'design_content',
'type': 'string',
'description': '设计方案内容(Markdown格式)',
'required': True,
},
{
'name': 'design_title',
'type': 'string',
'description': '设计方案标题',
'required': False,
},
]
outputs = [
{
'name': 'design_content',
'type': 'string',
'description': '确认后的设计方案内容',
},
{
'name': 'confirmed',
'type': 'boolean',
'description': '是否已确认',
},
]
def execute(self, context: NodeContext) -> NodeResult:
"""执行节点"""
try:
# 检查是否有用户编辑的数据(从设计预览面板提交)
user_input = context.variables.get('__user_input__')
if user_input:
edited_data = self._parse_user_edit(user_input)
if edited_data:
logger.info(f'AppDesignNode - 使用用户编辑的数据')
# 使用编辑后的数据,清除用户输入
context.variables.pop('__user_input__', None)
return self._build_result(edited_data, require_confirmation=False)
# 获取输入参数
design_content = context.resolve_template(self.config.get('design_content', ''))
design_title = context.resolve_template(self.config.get('design_title', '应用设计方案'))
logger.info(f'AppDesignNode - 输入参数: title={design_title}, content_length={len(design_content)}')
# 验证必填字段
if not design_content or not design_content.strip():
return NodeResult(
success=False,
error='设计方案内容不能为空',
)
design_content = design_content.strip()
design_title = design_title.strip() if design_title else '应用设计方案'
# 构建设计数据
design_data = {
'title': design_title,
'content': design_content,
}
logger.info(f'AppDesignNode - 输出设计方案: title={design_title}')
# 检查是否需要确认(支持布尔值、字符串模式和变量引用)
require_confirmation = self.resolve_require_confirmation(context, default=True)
return self._build_result(design_data, require_confirmation)
except Exception as e:
logger.exception(f'应用设计节点执行失败: {e}')
return NodeResult(
success=False,
error=f'节点执行失败: {str(e)}',
)
async def execute_async(self, context: NodeContext) -> NodeResult:
"""异步执行节点(直接调用同步方法)"""
return self.execute(context)
def _parse_user_edit(self, user_input: Any) -> Optional[Dict[str, Any]]:
"""解析用户编辑的数据"""
if isinstance(user_input, dict):
# 检查是否包含设计内容字段
if 'content' in user_input:
return user_input
return None
if isinstance(user_input, str):
try:
data = json.loads(user_input)
if isinstance(data, dict) and 'content' in data:
return data
except (json.JSONDecodeError, TypeError):
# 如果不是JSON,可能直接是编辑后的内容
return {'content': user_input, 'title': '应用设计方案'}
return None
def _build_result(self, design_data: Dict[str, Any], require_confirmation: bool = False) -> NodeResult:
"""构建节点结果"""
title = design_data.get('title', '应用设计方案')
content = design_data.get('content', '')
# 构建预览数据
preview_data = {
'type': 'app_design',
'title': title,
'data': design_data,
'editable': True,
} if require_confirmation else None
return NodeResult(
success=True,
output=design_data,
output_variables={
'design_content': content,
'design_title': title,
'confirmed': not require_confirmation,
},
preview=preview_data,
waiting_for_input=require_confirmation,
waiting_config={
'type': 'design_preview',
'preview_type': 'app_design',
'title': title,
'message': '请确认或编辑应用设计方案',
'data': design_data,
} if require_confirmation else {},
)
@classmethod
def get_config_schema(cls) -> Dict[str, Any]:
"""
获取节点配置 Schema(供前端表单渲染)
Returns:
配置 Schema
"""
return {
'type': 'object',
'properties': {
'design_content': {
'type': 'string',
'title': '设计方案内容',
'description': '设计方案内容(Markdown格式),支持变量引用如 {{llm_output}}',
'required': True,
'x-component': 'Textarea',
'x-component-props': {
'placeholder': '请输入设计方案内容或使用变量 {{llm_output}}',
'rows': 6,
},
},
'design_title': {
'type': 'string',
'title': '设计方案标题',
'description': '设计方案的标题,支持变量引用',
'required': False,
'default': '应用设计方案',
'x-component': 'Input',
'x-component-props': {
'placeholder': '请输入标题,默认为"应用设计方案"',
},
},
'require_confirmation': {
'type': 'boolean',
'title': '需要确认',
'description': '设计完成后是否暂停等待用户确认或编辑',
'default': True,
'x-component': 'Switch',
},
},
'required': ['design_content'],
}
@@ -0,0 +1,432 @@
"""
应用设置节点
接收应用设置配置,展示给用户确认或编辑
包括:应用配置、Logo配置、内置主题和布局
"""
import json
import logging
from typing import Any, Dict, Optional
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
logger = logging.getLogger(__name__)
@NodeRegistry.register
class AppSettingsNode(BaseNode):
"""
应用设置节点
接收应用设置配置,展示给用户确认或编辑
包括:应用配置、Logo配置、内置主题和布局
"""
node_type = 'app_settings'
node_name = '应用设置'
node_category = 'application'
node_icon = 'settings'
node_description = '配置应用设置,包括应用信息、Logo、主题和布局'
inputs = [
{
'name': 'app_name',
'type': 'string',
'description': '应用名称',
'required': False,
},
{
'name': 'home_path',
'type': 'string',
'description': '首页路径',
'required': False,
},
{
'name': 'logo_source',
'type': 'string',
'description': 'Logo图片URL或文件ID',
'required': False,
},
{
'name': 'theme_builtin_type',
'type': 'string',
'description': '内置主题类型',
'required': False,
},
{
'name': 'theme_color_primary',
'type': 'string',
'description': '主题主色调',
'required': False,
},
{
'name': 'app_layout',
'type': 'string',
'description': '应用布局类型',
'required': False,
},
]
outputs = [
{
'name': 'settings',
'type': 'object',
'description': '应用设置配置',
},
{
'name': 'confirmed',
'type': 'boolean',
'description': '是否已确认',
},
]
# 默认配置
DEFAULT_SETTINGS = {
'app': {
'name': '',
'locale': 'zh-CN',
'dynamicTitle': True,
'watermark': False,
'watermarkContent': '',
'enableCheckUpdates': True,
'defaultHomePath': '/analytics',
'enablePreferences': True,
},
'logo': {
'enable': True,
'source': '',
'fit': 'contain',
},
'theme': {
'colorPrimary': 'hsl(212 100% 45%)',
'builtinType': 'default',
'mode': 'light',
'radius': '0.5',
'semiDarkSidebar': False,
'semiDarkHeader': False,
},
'sidebar': {
'enable': True,
'width': 230,
'collapsed': False,
'collapsedShowTitle': False,
'autoActivateChild': False,
'expandOnHover': True,
'collapsedButton': True,
'fixedButton': True,
},
'header': {
'enable': True,
'mode': 'fixed',
'menuAlign': 'start',
},
'footer': {
'enable': True,
'fixed': True,
},
'copyright': {
'enable': True,
'companyName': '',
'companySiteLink': '',
'date': '',
'icp': '',
'icpLink': '',
},
'navigation': {
'styleType': 'rounded',
'split': True,
'accordion': True,
},
'tabbar': {
'enable': True,
'showIcon': True,
'showMore': True,
'showMaximize': True,
'persist': True,
'draggable': True,
'wheelable': True,
'styleType': 'chrome',
'maxCount': 0,
'middleClickToClose': True,
},
'breadcrumb': {
'enable': True,
'showIcon': True,
'showHome': True,
'styleType': 'normal',
'hideOnlyOne': False,
},
'transition': {
'progress': True,
'name': 'fade-slide',
'loading': True,
'enable': True,
},
'widget': {
'globalSearch': True,
'fullscreen': True,
'languageToggle': True,
'notification': True,
'themeToggle': True,
'sidebarToggle': True,
'lockScreen': True,
'refresh': True,
},
'shortcutKeys': {
'enable': True,
'globalSearch': True,
'globalLogout': True,
'globalLockScreen': True,
},
}
# 布局类型选项
LAYOUT_OPTIONS = [
{'label': '侧边导航', 'value': 'sidebar-nav'},
{'label': '侧边混合导航', 'value': 'sidebar-mixed-nav'},
{'label': '顶部导航', 'value': 'header-nav'},
{'label': '混合导航', 'value': 'mixed-nav'},
{'label': '全屏内容', 'value': 'full-content'},
]
# 内置主题选项
BUILTIN_THEME_OPTIONS = [
{'label': '默认', 'value': 'default'},
{'label': '紫罗兰', 'value': 'violet'},
{'label': '粉红', 'value': 'pink'},
{'label': '玫瑰', 'value': 'rose'},
{'label': '天蓝', 'value': 'sky'},
{'label': '青色', 'value': 'cyan'},
{'label': '绿色', 'value': 'green'},
{'label': '橙色', 'value': 'orange'},
{'label': '黄色', 'value': 'yellow'},
{'label': '锌色', 'value': 'zinc'},
{'label': '中性', 'value': 'neutral'},
{'label': '石板', 'value': 'slate'},
{'label': '灰色', 'value': 'gray'},
{'label': '深灰', 'value': 'deep-blue'},
{'label': '深绿', 'value': 'deep-green'},
]
def execute(self, context: NodeContext) -> NodeResult:
"""执行节点"""
try:
# 检查是否有用户编辑的数据(从设计预览面板提交)
user_input = context.variables.get('__user_input__')
if user_input:
edited_data = self._parse_user_edit(user_input)
if edited_data:
logger.info(f'AppSettingsNode - 使用用户编辑的数据')
# 使用编辑后的数据,清除用户输入
context.variables.pop('__user_input__', None)
return self._build_result(edited_data, require_confirmation=False)
# 获取输入参数
app_name = context.resolve_template(self.config.get('app_name', ''))
home_path = context.resolve_template(self.config.get('home_path', ''))
logo_source = context.resolve_template(self.config.get('logo_source', ''))
theme_builtin_type = context.resolve_template(self.config.get('theme_builtin_type', 'default'))
theme_color_primary = context.resolve_template(self.config.get('theme_color_primary', ''))
app_layout = context.resolve_template(self.config.get('app_layout', 'sidebar-nav'))
logger.info(f'AppSettingsNode - 输入参数: app_name={app_name}, home_path={home_path}, layout={app_layout}')
# 构建设置数据(合并默认值和输入值)
settings = self._build_settings(
app_name=app_name,
home_path=home_path,
logo_source=logo_source,
theme_builtin_type=theme_builtin_type,
theme_color_primary=theme_color_primary,
app_layout=app_layout,
)
logger.info(f'AppSettingsNode - 输出设置配置')
# 检查是否需要确认(支持布尔值、字符串模式和变量引用)
require_confirmation = self.resolve_require_confirmation(context, default=True)
return self._build_result(settings, require_confirmation)
except Exception as e:
logger.exception(f'应用设置节点执行失败: {e}')
return NodeResult(
success=False,
error=f'节点执行失败: {str(e)}',
)
async def execute_async(self, context: NodeContext) -> NodeResult:
"""异步执行节点(直接调用同步方法)"""
return self.execute(context)
def _parse_user_edit(self, user_input: Any) -> Optional[Dict[str, Any]]:
"""解析用户编辑的数据"""
if isinstance(user_input, dict):
# 检查是否包含设置字段
if 'app' in user_input or 'theme' in user_input or 'logo' in user_input:
return user_input
return None
if isinstance(user_input, str):
try:
data = json.loads(user_input)
if isinstance(data, dict) and ('app' in data or 'theme' in data or 'logo' in data):
return data
except (json.JSONDecodeError, TypeError):
pass
return None
def _build_settings(
self,
app_name: str = '',
home_path: str = '',
logo_source: str = '',
theme_builtin_type: str = 'default',
theme_color_primary: str = '',
app_layout: str = 'sidebar-nav',
) -> Dict[str, Any]:
"""构建设置配置"""
import copy
settings = copy.deepcopy(self.DEFAULT_SETTINGS)
# 应用配置
if app_name:
settings['app']['name'] = app_name
# 首页路径配置
if home_path:
settings['app']['defaultHomePath'] = home_path
# 布局配置
if app_layout:
settings['app']['layout'] = app_layout
# Logo配置
if logo_source:
settings['logo']['source'] = logo_source
# 主题配置
if theme_builtin_type:
settings['theme']['builtinType'] = theme_builtin_type
if theme_color_primary:
settings['theme']['colorPrimary'] = theme_color_primary
return settings
def _build_result(self, settings: Dict[str, Any], require_confirmation: bool = False) -> NodeResult:
"""构建节点结果"""
# 构建预览数据
preview_data = {
'type': 'app_settings',
'title': '应用设置',
'data': settings,
'editable': True,
'layoutOptions': self.LAYOUT_OPTIONS,
'themeOptions': self.BUILTIN_THEME_OPTIONS,
} if require_confirmation else None
return NodeResult(
success=True,
output=settings,
output_variables={
'settings': settings,
'confirmed': not require_confirmation,
},
preview=preview_data,
waiting_for_input=require_confirmation,
waiting_config={
'type': 'design_preview',
'preview_type': 'app_settings',
'title': '应用设置',
'message': '请确认或编辑应用设置',
'data': settings,
'layoutOptions': self.LAYOUT_OPTIONS,
'themeOptions': self.BUILTIN_THEME_OPTIONS,
} if require_confirmation else {},
)
@classmethod
def get_config_schema(cls) -> Dict[str, Any]:
"""
获取节点配置 Schema(供前端表单渲染)
Returns:
配置 Schema
"""
return {
'type': 'object',
'properties': {
'app_name': {
'type': 'string',
'title': '应用名称',
'description': '应用的显示名称,支持变量引用如 {{llm_output}}',
'required': False,
'x-component': 'Input',
'x-component-props': {
'placeholder': '请输入应用名称',
},
},
'home_path': {
'type': 'string',
'title': '首页路径',
'description': '应用首页路径,如 /dashboard 或 /analytics',
'required': False,
'x-component': 'Input',
'x-component-props': {
'placeholder': '如 /dashboard',
},
},
'logo_source': {
'type': 'string',
'title': 'Logo图片',
'description': 'Logo图片URL或文件ID,支持变量引用',
'required': False,
'x-component': 'Input',
'x-component-props': {
'placeholder': '请输入Logo图片URL',
},
},
'theme_builtin_type': {
'type': 'string',
'title': '内置主题',
'description': '选择内置主题类型',
'default': 'default',
'enum': ['default', 'violet', 'pink', 'rose', 'sky', 'cyan', 'green', 'orange', 'yellow', 'zinc', 'neutral', 'slate', 'gray', 'deep-blue', 'deep-green'],
'x-component': 'Select',
'x-component-props': {
'placeholder': '请选择内置主题',
},
},
'theme_color_primary': {
'type': 'string',
'title': '主题主色调',
'description': '主题主色调,如 hsl(212 100% 45%)',
'required': False,
'x-component': 'Input',
'x-component-props': {
'placeholder': '如 hsl(212 100% 45%)',
},
},
'app_layout': {
'type': 'string',
'title': '应用布局',
'description': '应用布局类型',
'default': 'sidebar-nav',
'enum': ['sidebar-nav', 'sidebar-mixed-nav', 'header-nav', 'mixed-nav', 'full-content'],
'x-component': 'Select',
'x-component-props': {
'placeholder': '请选择布局类型',
},
},
'require_confirmation': {
'type': 'boolean',
'title': '需要确认',
'description': '设置完成后是否暂停等待用户确认或编辑',
'default': True,
'x-component': 'Switch',
},
},
'required': [],
}
@@ -0,0 +1,302 @@
"""
应用更新节点
执行应用配置的更新操作,将设置保存到数据库
"""
import json
import logging
from typing import Any, Dict, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
logger = logging.getLogger(__name__)
@NodeRegistry.register
class AppUpdateNode(BaseNode):
"""
应用更新节点
接收应用设置配置,执行实际的更新操作
"""
node_type = 'app_update'
node_name = '应用更新'
node_category = 'application'
node_icon = 'save'
node_description = '执行应用配置的更新操作,将设置保存到数据库'
inputs = [
{
'name': 'settings',
'type': 'object',
'description': '应用设置配置(来自应用设置节点)',
'required': True,
},
{
'name': 'application_id',
'type': 'string',
'description': '应用ID,不传则更新主应用配置',
'required': False,
},
]
outputs = [
{
'name': 'success',
'type': 'boolean',
'description': '更新是否成功',
},
{
'name': 'config_id',
'type': 'string',
'description': '配置记录ID',
},
{
'name': 'message',
'type': 'string',
'description': '更新结果消息',
},
]
async def execute_async(self, context: NodeContext) -> NodeResult:
"""异步执行节点"""
try:
# 获取输入参数
settings = self._get_settings(context)
application_id = self._get_application_id(context)
if not settings:
return NodeResult(
success=False,
error='缺少应用设置配置',
)
logger.info(f'AppUpdateNode - 开始更新应用配置, application_id={application_id}')
# 获取数据库会话
db: AsyncSession = context.db_session
if not db:
return NodeResult(
success=False,
error='数据库会话不可用',
)
# 执行更新
config_id = await self._update_preferences(db, settings, application_id)
if config_id:
logger.info(f'AppUpdateNode - 应用配置更新成功, config_id={config_id}')
return NodeResult(
success=True,
output={
'success': True,
'config_id': config_id,
'message': '应用配置更新成功',
},
output_variables={
'update_success': True,
'config_id': config_id,
'update_message': '应用配置更新成功',
},
)
else:
return NodeResult(
success=False,
error='应用配置更新失败',
)
except Exception as e:
logger.exception(f'应用更新节点执行失败: {e}')
return NodeResult(
success=False,
error=f'节点执行失败: {str(e)}',
)
def execute(self, context: NodeContext) -> NodeResult:
"""同步执行节点(不支持,需要数据库操作)"""
return NodeResult(
success=False,
error='应用更新节点需要异步执行',
)
def _get_settings(self, context: NodeContext) -> Optional[Dict[str, Any]]:
"""获取应用设置配置"""
import re
# 优先从配置中获取(支持变量引用)
settings_config = self.config.get('settings', '')
logger.info(f'AppUpdateNode - settings_config from config: {settings_config}')
if settings_config:
# 检查是否是变量引用格式 {{node_id.key}}
var_pattern = r'\{\{([^}]+)\}\}'
match = re.match(var_pattern, settings_config.strip())
if match:
# 是变量引用,直接从上下文获取对象
var_ref = match.group(1).strip()
logger.info(f'AppUpdateNode - 解析变量引用: {var_ref}')
# 解析 node_id.key 格式
# 注意:node_id 可能包含连字符,如 app_settings-1769009671187
# 所以只按最后一个点分割
if '.' in var_ref:
parts = var_ref.rsplit('.', 1) # 从右边分割,只分割一次
node_id = parts[0]
key = parts[1] if len(parts) > 1 else None
logger.info(f'AppUpdateNode - 查找节点输出: node_id={node_id}, key={key}')
# 从节点输出命名空间获取
node_outputs = context.variables.get(f'_node_{node_id}')
logger.info(f'AppUpdateNode - node_outputs type: {type(node_outputs)}, keys: {node_outputs.keys() if isinstance(node_outputs, dict) else "N/A"}')
if isinstance(node_outputs, dict) and key and key in node_outputs:
value = node_outputs[key]
logger.info(f'AppUpdateNode - 找到值, type: {type(value)}')
if isinstance(value, dict):
return value
if isinstance(value, str):
try:
return json.loads(value)
except (json.JSONDecodeError, TypeError):
logger.warning(f'AppUpdateNode - JSON解析失败: {value[:100]}')
pass
else:
logger.warning(f'AppUpdateNode - 未找到节点输出或key不存在')
else:
# 直接变量名
value = context.variables.get(var_ref)
logger.info(f'AppUpdateNode - 直接变量引用, type: {type(value)}')
if isinstance(value, dict):
return value
if isinstance(value, str):
try:
return json.loads(value)
except (json.JSONDecodeError, TypeError):
pass
else:
# 不是变量引用,尝试解析为 JSON
try:
return json.loads(settings_config)
except (json.JSONDecodeError, TypeError):
pass
# 从上下文变量获取
settings = context.variables.get('settings')
logger.info(f'AppUpdateNode - 从上下文获取settings, type: {type(settings)}')
if isinstance(settings, dict):
return settings
if isinstance(settings, str):
try:
return json.loads(settings)
except (json.JSONDecodeError, TypeError):
pass
logger.warning(f'AppUpdateNode - 未能获取有效的settings配置')
return None
def _get_application_id(self, context: NodeContext) -> Optional[str]:
"""获取应用ID"""
# 优先从配置中获取
app_id = self.config.get('application_id', '')
if app_id:
resolved = context.resolve_template(app_id)
if resolved and resolved != 'main':
return resolved
# 从上下文变量获取
app_id = context.variables.get('application_id')
if app_id and app_id != 'main':
return app_id
return None
async def _update_preferences(
self,
db: AsyncSession,
settings: Dict[str, Any],
application_id: Optional[str] = None,
) -> Optional[str]:
"""更新偏好配置"""
from core.ui_config.model import UIConfig
# 配置键:使用与前端 API 一致的键名
config_key = 'frontend_preferences'
# 查找现有配置
stmt = select(UIConfig).where(
UIConfig.config_key == config_key,
UIConfig.is_deleted == False,
)
# 根据 application_id 过滤
if application_id:
stmt = stmt.where(UIConfig.application_id == application_id)
else:
stmt = stmt.where(UIConfig.application_id.is_(None))
result = await db.execute(stmt)
config = result.scalar_one_or_none()
# 配置值
config_value = json.dumps(settings, ensure_ascii=False)
if config:
# 更新现有配置
config.config_value = config_value
await db.commit()
await db.refresh(config)
return config.id
else:
# 创建新配置
new_config = UIConfig(
application_id=application_id,
config_key=config_key,
config_value=config_value,
config_type='preferences',
description='前端UI偏好配置' if not application_id else '子应用UI偏好配置',
status=True,
sort=0,
)
db.add(new_config)
await db.commit()
await db.refresh(new_config)
return new_config.id
@classmethod
def get_config_schema(cls) -> Dict[str, Any]:
"""
获取节点配置 Schema(供前端表单渲染)
"""
return {
'type': 'object',
'properties': {
'settings': {
'type': 'string',
'title': '应用设置',
'description': '应用设置配置,支持变量引用如 {{app_settings-1.settings}}',
'required': True,
'x-component': 'Input',
'x-component-props': {
'placeholder': '请输入应用设置或使用变量引用',
},
},
'application_id': {
'type': 'string',
'title': '应用ID',
'description': '目标应用ID,不填则更新主应用配置。支持变量引用如 {{application_id}}',
'required': False,
'x-component': 'Input',
'x-component-props': {
'placeholder': '留空则更新主应用配置',
},
},
},
'required': ['settings'],
}
@@ -0,0 +1,232 @@
"""
仪表盘基础信息处理节点
接收LLM提取的基础信息,进行验证和补充
"""
import logging
import re
from typing import Any, Dict, Optional
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
logger = logging.getLogger(__name__)
@NodeRegistry.register
class DashboardBasicInfoNode(BaseNode):
"""
仪表盘基础信息处理节点
接收LLM提取的基础信息,进行验证和补充
"""
node_type = 'dashboard_basic_info'
node_name = '仪表盘基础信息'
node_category = 'dashboard'
node_icon = 'layout-dashboard'
node_description = '处理仪表盘基础信息,验证和补充名称、编码等'
inputs = [
{
'name': 'name',
'type': 'string',
'description': '仪表盘名称',
'required': True,
},
{
'name': 'code',
'type': 'string',
'description': '仪表盘编码',
'required': False,
},
{
'name': 'category',
'type': 'string',
'description': '分类(dashboard/portal/databoard/other',
'required': False,
},
{
'name': 'description',
'type': 'string',
'description': '仪表盘描述',
'required': False,
},
{
'name': 'sort',
'type': 'number',
'description': '排序',
'required': False,
},
]
outputs = [
{
'name': 'dashboard_basic_info',
'type': 'object',
'description': '处理后的仪表盘基础信息',
},
{
'name': 'dashboard_name',
'type': 'string',
'description': '仪表盘名称',
},
{
'name': 'dashboard_code',
'type': 'string',
'description': '仪表盘编码',
},
]
def execute(self, context: NodeContext) -> NodeResult:
"""执行节点"""
try:
# 检查是否有用户编辑的数据(从设计预览面板提交)
user_input = context.variables.get('__user_input__')
if user_input:
edited_data = self._parse_user_edit(user_input)
if edited_data:
logger.info(f'DashboardBasicInfoNode - 使用用户编辑的数据: {edited_data}')
# 使用编辑后的数据,清除用户输入
context.variables.pop('__user_input__', None)
return self._build_result(edited_data, require_confirmation=False)
# 获取输入参数
name = context.resolve_template(self.config.get('name', ''))
code = context.resolve_template(self.config.get('code', ''))
category = context.resolve_template(self.config.get('category', 'dashboard'))
description = context.resolve_template(self.config.get('description', ''))
# 处理sort(可能是字符串或数字)
sort_value = self.config.get('sort', 0)
sort_str = context.resolve_template(str(sort_value))
try:
sort = int(sort_str) if sort_str else 0
except (ValueError, TypeError):
sort = 0
logger.info(f'DashboardBasicInfoNode - 输入参数: name={name}, code={code}, category={category}')
# 验证必填字段
if not name:
return NodeResult(
success=False,
error='仪表盘名称不能为空',
)
# 自动生成编码(如果未提供)
if not code:
code = self._generate_code(name)
# 验证编码格式
if not self._validate_code(code):
return NodeResult(
success=False,
error=f'仪表盘编码格式不正确: {code},只能包含字母、数字、下划线和连字符',
)
# 验证分类
valid_categories = ['dashboard', 'portal', 'databoard', 'other']
if category and category not in valid_categories:
category = 'dashboard'
# 构建基础信息
basic_info = {
'name': name,
'code': code,
'category': category or 'dashboard',
'description': description or '',
'sort': sort,
}
# 检查是否需要确认(支持布尔值、字符串模式和变量引用)
require_confirmation = self.resolve_require_confirmation(context, default=True)
return self._build_result(basic_info, require_confirmation)
except Exception as e:
logger.error(f'DashboardBasicInfoNode - 执行失败: {str(e)}')
return NodeResult(
success=False,
error=f'处理仪表盘基础信息失败: {str(e)}',
)
def _parse_user_edit(self, user_input: str) -> Optional[Dict[str, Any]]:
"""解析用户编辑的数据"""
try:
import json
if isinstance(user_input, str):
data = json.loads(user_input)
else:
data = user_input
# 验证必要字段
if isinstance(data, dict) and data.get('name'):
return data
return None
except Exception:
return None
def _build_result(self, basic_info: Dict[str, Any], require_confirmation: bool) -> NodeResult:
"""构建节点结果"""
output_data = {
'dashboard_basic_info': basic_info,
'dashboard_name': basic_info.get('name', ''),
'dashboard_code': basic_info.get('code', ''),
}
if require_confirmation:
return NodeResult(
success=True,
output=output_data,
output_variables=output_data, # 添加 output_variables
waiting_for_input=True,
waiting_config={
'type': 'design_preview',
'preview_type': 'dashboard_basic_info',
'title': '仪表盘基础信息确认',
'data': basic_info,
'editable': True,
'fields': [
{'key': 'name', 'label': '仪表盘名称', 'type': 'text', 'required': True},
{'key': 'code', 'label': '仪表盘编码', 'type': 'text', 'required': True},
{'key': 'category', 'label': '分类', 'type': 'select', 'options': [
{'label': '仪表盘', 'value': 'dashboard'},
{'label': '门户', 'value': 'portal'},
{'label': '数据看板', 'value': 'databoard'},
{'label': '其他', 'value': 'other'},
]},
{'key': 'description', 'label': '描述', 'type': 'textarea'},
{'key': 'sort', 'label': '排序', 'type': 'number'},
],
},
)
return NodeResult(
success=True,
output=output_data,
output_variables=output_data, # 添加 output_variables
)
def _generate_code(self, name: str) -> str:
"""根据名称生成编码"""
try:
from pypinyin import lazy_pinyin
# 使用拼音生成编码
pinyin_list = lazy_pinyin(name)
code = '_'.join(pinyin_list)
# 清理非法字符
code = re.sub(r'[^a-zA-Z0-9_]', '', code)
return f'page_{code}'
except ImportError:
# 如果没有pypinyin,使用简单的处理
code = re.sub(r'[^a-zA-Z0-9_]', '', name)
return f'page_{code}' if code else f'page_{id(name)}'
def _validate_code(self, code: str) -> bool:
"""验证编码格式"""
if not code:
return False
# 只允许字母、数字、下划线和连字符
pattern = r'^[a-zA-Z][a-zA-Z0-9_-]*$'
return bool(re.match(pattern, code))
@@ -0,0 +1,338 @@
"""
仪表盘创建节点
将仪表盘配置保存到数据库
"""
import json
import logging
from typing import Any, Dict
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
logger = logging.getLogger(__name__)
@NodeRegistry.register
class DashboardCreateNode(BaseNode):
"""
仪表盘创建节点
将仪表盘基础信息和页面配置保存到数据库
"""
node_type = 'dashboard_create'
node_name = '仪表盘创建'
node_category = 'dashboard'
node_icon = 'file-plus'
node_description = '将仪表盘配置保存到数据库'
inputs = [
{
'name': 'dashboard_basic_info',
'type': 'object',
'description': '仪表盘基础信息(来自基础信息节点)',
'required': True,
},
{
'name': 'page_config',
'type': 'object',
'description': '页面设计配置(来自设计节点)',
'required': True,
},
{
'name': 'application_id',
'type': 'string',
'description': '所属应用ID(可选)',
'required': False,
},
]
outputs = [
{
'name': 'dashboard_id',
'type': 'string',
'description': '创建的仪表盘ID',
},
{
'name': 'dashboard_code',
'type': 'string',
'description': '仪表盘编码',
},
{
'name': 'page_meta',
'type': 'object',
'description': '完整的页面元数据',
},
]
def execute(self, context: NodeContext) -> NodeResult:
"""执行节点(同步方法,通过运行异步方法实现)"""
import asyncio
loop = asyncio.get_event_loop()
if loop.is_running():
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(asyncio.run, self.execute_async(context))
return future.result()
else:
return loop.run_until_complete(self.execute_async(context))
async def execute_async(self, context: NodeContext) -> NodeResult:
"""异步执行节点"""
try:
# 获取输入参数
dashboard_basic_info_raw = self.config.get('dashboard_basic_info', '{}')
page_config_raw = self.config.get('page_config', '{}')
# 解析变量引用
dashboard_basic_info = self._resolve_variable_value(context, dashboard_basic_info_raw)
page_config = self._resolve_variable_value(context, page_config_raw)
application_id = context.resolve_template(self.config.get('application_id', ''))
# 配置选项
update_if_exists = self.config.get('update_if_exists', True)
logger.info(f'DashboardCreateNode - 开始创建仪表盘')
logger.info(f'DashboardCreateNode - 原始配置: dashboard_basic_info_raw={dashboard_basic_info_raw}, page_config_raw={page_config_raw}')
logger.info(f'DashboardCreateNode - 上下文变量: {list(context.variables.keys())}')
logger.info(f'DashboardCreateNode - dashboard_basic_info type: {type(dashboard_basic_info)}, value: {dashboard_basic_info}')
logger.info(f'DashboardCreateNode - page_config type: {type(page_config)}, value: {str(page_config)[:200]}')
# 确保 dashboard_basic_info 是字典
if isinstance(dashboard_basic_info, str):
try:
dashboard_basic_info = json.loads(dashboard_basic_info)
logger.info(f'DashboardCreateNode - 成功解析 dashboard_basic_info 字符串为字典')
except json.JSONDecodeError as e:
logger.error(f'DashboardCreateNode - 解析 dashboard_basic_info 失败: {e}')
return NodeResult(
success=False,
error=f'仪表盘基础信息格式错误: {str(e)}',
)
# 确保 page_config 是字典
if isinstance(page_config, str):
try:
page_config = json.loads(page_config)
logger.info(f'DashboardCreateNode - 成功解析 page_config 字符串为字典')
except json.JSONDecodeError as e:
logger.error(f'DashboardCreateNode - 解析 page_config 失败: {e}')
return NodeResult(
success=False,
error=f'页面配置格式错误: {str(e)}',
)
# 验证必要配置
if not dashboard_basic_info:
return NodeResult(
success=False,
error='仪表盘基础信息不能为空',
)
if not page_config:
return NodeResult(
success=False,
error='页面设计配置不能为空',
)
# 提取基础信息
name = dashboard_basic_info.get('name', '')
code_base = dashboard_basic_info.get('code', '')
category = dashboard_basic_info.get('category', 'dashboard')
description = dashboard_basic_info.get('description', '')
sort = dashboard_basic_info.get('sort', 0)
if not name or not code_base:
return NodeResult(
success=False,
error='仪表盘名称和编码不能为空',
)
# 保存到数据库
from app.database import AsyncSessionLocal
from online_dev.page_manager.model import PageMeta
from sqlalchemy import select
async with AsyncSessionLocal() as db:
# 生成唯一的 code(如果已存在则自动添加后缀)
code = await self._get_unique_code(db, code_base)
logger.info(f'DashboardCreateNode - 使用 code: {code} (原始: {code_base})')
# 检查是否已存在(理论上不应该存在,因为已经生成了唯一 code)
stmt = select(PageMeta).where(PageMeta.code == code, PageMeta.is_deleted == False)
result = await db.execute(stmt)
existing_page = result.scalar_one_or_none()
if existing_page:
if update_if_exists:
# 更新现有记录
existing_page.name = name
existing_page.category = category
existing_page.description = description
existing_page.sort = sort
existing_page.page_config = page_config
if application_id:
existing_page.application_id = application_id
existing_page.version = (existing_page.version or 1) + 1
await db.commit()
await db.refresh(existing_page)
page_meta = self._page_to_dict(existing_page)
logger.info(f'DashboardCreateNode - 更新仪表盘成功: {code}')
else:
return NodeResult(
success=False,
error=f'仪表盘编码已存在: {code}',
)
else:
# 创建新记录
new_page = PageMeta(
name=name,
code=code,
category=category,
description=description,
sort=sort,
status='draft',
version=1,
page_config=page_config,
application_id=application_id if application_id else None,
)
db.add(new_page)
await db.commit()
await db.refresh(new_page)
page_meta = self._page_to_dict(new_page)
logger.info(f'DashboardCreateNode - 创建仪表盘成功: {code}')
output_data = {
'dashboard_id': page_meta['id'],
'dashboard_code': page_meta['code'],
'page_meta': page_meta,
}
return NodeResult(
success=True,
output=output_data,
output_variables=output_data,
)
except Exception as e:
logger.error(f'DashboardCreateNode - 执行失败: {str(e)}')
return NodeResult(
success=False,
error=f'创建仪表盘失败: {str(e)}',
)
def _resolve_variable_value(self, context: NodeContext, value: Any) -> Any:
"""解析变量引用,保持原始类型"""
import re
import ast
# 如果已经是字典,直接返回
if isinstance(value, dict):
return value
# 如果不是字符串,返回原值
if not isinstance(value, str):
return value
value = value.strip()
if not value or value == '{}':
return {}
# 检查是否是单个变量引用 {{node_id.variable_name}}
single_var_pattern = r'^\{\{([^}]+)\}\}$'
match = re.match(single_var_pattern, value)
if match:
var_ref = match.group(1).strip()
# 解析 node_id.variable_name 格式
if '.' in var_ref:
parts = var_ref.split('.', 1)
node_id = parts[0]
key = parts[1]
# 从节点输出命名空间获取
node_outputs = context.variables.get(f'_node_{node_id}')
if isinstance(node_outputs, dict) and key in node_outputs:
return node_outputs[key]
# 直接从变量中获取
if key in context.variables:
return context.variables[key]
logger.warning(f'变量引用未找到: {var_ref}')
else:
# 直接变量引用
if var_ref in context.variables:
return context.variables[var_ref]
logger.warning(f'变量引用未找到: {var_ref}')
# 不是变量引用,尝试解析为 JSON 或 Python dict
# 尝试 JSON 解析
try:
return json.loads(value)
except json.JSONDecodeError:
pass
# 尝试 Python literal_eval(处理单引号格式)
try:
result = ast.literal_eval(value)
if isinstance(result, dict):
return result
return result
except (ValueError, SyntaxError) as e:
logger.warning(f'配置解析失败: {e}, 值: {value[:200]}')
return value
async def _get_unique_code(self, db, base_code: str) -> str:
"""
生成唯一的 code,如果已存在则自动添加数字后缀
Args:
db: 数据库会话
base_code: 基础 code
Returns:
唯一的 code
"""
from online_dev.page_manager import PageService
code = base_code
counter = 1
while True:
try:
existing = await PageService.get_by_code(db, code)
if not existing:
# code 不存在,可以使用
break
# code 已存在,添加后缀
code = f"{base_code}_{counter}"
counter += 1
except Exception:
# 查询出错,认为 code 可用
break
return code
def _page_to_dict(self, page: Any) -> Dict[str, Any]:
"""将PageMeta对象转换为字典"""
return {
'id': page.id,
'name': page.name,
'code': page.code,
'category': page.category,
'description': page.description,
'status': page.status,
'version': page.version,
'sort': page.sort,
'page_config': page.page_config,
'application_id': page.application_id,
}
@@ -0,0 +1,483 @@
"""
仪表盘设计节点
提供可视化设计界面,让用户设计仪表盘布局
"""
import ast
import json
import logging
import random
from typing import Any, Dict, List, Optional
from uuid import uuid4
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
logger = logging.getLogger(__name__)
@NodeRegistry.register
class DashboardDesignNode(BaseNode):
"""
仪表盘设计节点
提供可视化设计界面,让用户设计仪表盘布局和组件
"""
node_type = 'dashboard_design'
node_name = '仪表盘设计'
node_category = 'dashboard'
node_icon = 'layout-dashboard'
node_description = '可视化设计仪表盘布局和组件'
inputs = [
{
'name': 'dashboard_code',
'type': 'string',
'description': '仪表盘编码(来自基础信息节点)',
'required': False,
},
{
'name': 'design_suggestion',
'type': 'string',
'description': 'LLM生成的设计建议(可选)',
'required': False,
},
{
'name': 'initial_config',
'type': 'object',
'description': '初始页面配置(可选)',
'required': False,
},
]
outputs = [
{
'name': 'page_config',
'type': 'object',
'description': '页面设计配置(dashboard-design的JSON配置)',
},
{
'name': 'design_title',
'type': 'string',
'description': '设计方案标题',
},
{
'name': 'confirmed',
'type': 'boolean',
'description': '是否已确认',
},
]
def execute(self, context: NodeContext) -> NodeResult:
"""执行节点"""
try:
# 检查是否有用户提交的设计配置
user_input = context.variables.get('__user_input__')
if user_input:
design_data = self._parse_user_design(user_input)
if design_data:
logger.info(f'DashboardDesignNode - 使用用户设计的配置')
context.variables.pop('__user_input__', None)
return self._build_result(design_data, confirmed=True)
# 获取输入参数
dashboard_code = context.resolve_template(self.config.get('dashboard_code', ''))
design_suggestion = context.resolve_template(self.config.get('design_suggestion', ''))
initial_config = self.config.get('initial_config', {})
# 如果initial_config是变量引用,解析它
if isinstance(initial_config, str) and initial_config.startswith('{{'):
initial_config = self._resolve_variable_value(context, initial_config) or {}
logger.info(f'DashboardDesignNode - dashboard_code={dashboard_code}')
# 检查是否需要确认(支持布尔值、字符串模式和变量引用)
require_confirmation = self.resolve_require_confirmation(context, default=True)
design_title = self.config.get('design_title', '仪表盘设计')
# 如果没有初始配置,根据 design_suggestion 生成默认模板
if not initial_config or not initial_config.get('widgets'):
initial_config = self._generate_dashboard_template(design_suggestion, dashboard_code)
# 构建设计数据
design_data = {
'dashboard_code': dashboard_code,
'design_title': design_title,
'design_suggestion': design_suggestion,
'page_config': initial_config,
}
if require_confirmation:
return NodeResult(
success=True,
output={
'page_config': design_data.get('page_config', {}),
'design_title': design_title,
'confirmed': False,
},
waiting_for_input=True,
waiting_config={
'type': 'design_preview',
'preview_type': 'dashboard_design',
'title': design_title,
'data': design_data,
'editable': True,
'fullscreen': True,
},
)
return self._build_result(design_data, confirmed=True)
except Exception as e:
logger.error(f'DashboardDesignNode - 执行失败: {str(e)}')
return NodeResult(
success=False,
error=f'仪表盘设计节点执行失败: {str(e)}',
)
def _parse_user_design(self, user_input: Any) -> Optional[Dict[str, Any]]:
"""解析用户提交的设计配置"""
try:
if isinstance(user_input, str):
data = json.loads(user_input)
else:
data = user_input
if isinstance(data, dict):
return data
return None
except Exception as e:
logger.error(f'解析用户设计配置失败: {e}')
return None
def _build_result(self, design_data: Dict[str, Any], confirmed: bool) -> NodeResult:
"""构建节点结果"""
page_config = design_data.get('page_config', {})
design_title = design_data.get('design_title', '仪表盘设计')
output_data = {
'page_config': page_config,
'design_title': design_title,
'confirmed': confirmed,
}
return NodeResult(
success=True,
output=output_data,
output_variables=output_data, # 添加 output_variables
)
def _resolve_variable_value(self, context: NodeContext, value: Any) -> Any:
"""解析变量引用"""
if isinstance(value, str):
resolved = context.resolve_template(value)
if resolved != value:
try:
return json.loads(resolved) if isinstance(resolved, str) else resolved
except (json.JSONDecodeError, TypeError):
return resolved
return value
def _generate_dashboard_template(self, design_suggestion: str, dashboard_code: str) -> Dict[str, Any]:
"""根据 LLM 建议生成仪表盘模板"""
try:
# 解析 design_suggestion(可能是字符串形式的字典)
modules = self._parse_design_suggestion(design_suggestion)
# 生成基础模板
template = {
"id": str(uuid4()),
"name": "我的仪表盘",
"columns": 12,
"rowHeight": 50,
"margin": [12, 12],
"widgets": []
}
# 添加欢迎卡片
welcome_id = str(uuid4())
template["widgets"].append({
"id": welcome_id,
"i": welcome_id,
"type": "welcome-card",
"x": 0,
"y": 0,
"w": 8,
"h": 2,
"minW": 4,
"minH": 2,
"title": "欢迎卡片",
"props": {
"title": "欢迎回来",
"subtitle": "今天是个好日子",
"showTime": True,
"showWeather": False
}
})
# 添加天气组件
weather_id = str(uuid4())
template["widgets"].append({
"id": weather_id,
"i": weather_id,
"type": "weather",
"x": 8,
"y": 0,
"w": 4,
"h": 2,
"minW": 2,
"minH": 2,
"title": "天气",
"props": {
"title": "今日天气",
"city": "北京",
"temperature": 25,
"weather": "",
"humidity": 45,
"wind": "东北风 3级",
"icon": "sunny"
}
})
# 添加日历
calendar_id = str(uuid4())
template["widgets"].append({
"id": calendar_id,
"i": calendar_id,
"type": "calendar",
"x": 8,
"y": 2,
"w": 4,
"h": 5,
"minW": 3,
"minH": 4,
"title": "日历",
"props": {
"title": "日历",
"showLunar": False
}
})
# 根据模块生成图表
y_offset = 2
if modules and len(modules) > 0:
# 生成折线图(基于第一个模块)
module1 = modules[0] if len(modules) > 0 else {"module_name": "数据", "moduld_fields": []}
line_chart = self._generate_line_chart(module1, 0, y_offset)
template["widgets"].append(line_chart)
y_offset += 5
# 生成柱状图(基于第二个模块)
module2 = modules[1] if len(modules) > 1 else module1
bar_chart = self._generate_bar_chart(module2, 0, y_offset)
template["widgets"].append(bar_chart)
# 生成饼图(基于第三个模块)
module3 = modules[2] if len(modules) > 2 else module1
pie_chart = self._generate_pie_chart(module3, 8, y_offset)
template["widgets"].append(pie_chart)
else:
# 没有模块信息,使用默认图表
template["widgets"].extend([
self._generate_line_chart({"module_name": "访问趋势", "moduld_fields": []}, 0, y_offset),
self._generate_bar_chart({"module_name": "销售统计", "moduld_fields": []}, 0, y_offset + 5),
self._generate_pie_chart({"module_name": "流量来源", "moduld_fields": []}, 8, y_offset + 5)
])
return template
except Exception as e:
logger.error(f'生成仪表盘模板失败: {e}')
# 返回空模板
return {
"id": str(uuid4()),
"name": "我的仪表盘",
"columns": 12,
"rowHeight": 50,
"margin": [12, 12],
"widgets": []
}
def _parse_design_suggestion(self, design_suggestion: str) -> List[Dict[str, Any]]:
"""解析 LLM 生成的设计建议"""
try:
if not design_suggestion:
return []
# 尝试解析为 JSON
if isinstance(design_suggestion, str):
try:
data = json.loads(design_suggestion)
except json.JSONDecodeError:
# 尝试使用 ast.literal_eval
try:
data = ast.literal_eval(design_suggestion)
except (ValueError, SyntaxError):
return []
else:
data = design_suggestion
# 提取模块信息
if isinstance(data, dict):
modules = data.get('app_modules', [])
if isinstance(modules, list):
return modules
return []
except Exception as e:
logger.error(f'解析设计建议失败: {e}')
return []
def _generate_line_chart(self, module: Dict[str, Any], x: int, y: int) -> Dict[str, Any]:
"""生成折线图"""
module_name = module.get('module_name', '访问趋势')
fields = module.get('moduld_fields', [])
# 生成模拟数据
months = ["1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"]
series_data = []
# 根据字段生成系列(最多2个)
for i, field in enumerate(fields[:2]):
data = [random.randint(500, 1500) for _ in range(12)]
series_data.append({
"name": field if isinstance(field, str) else f"系列{i+1}",
"data": data
})
# 如果没有字段,使用默认系列
if not series_data:
series_data = [
{"name": "访问量", "data": [820, 932, 901, 934, 1290, 1330, 1320, 1450, 1200, 1100, 1350, 1500]},
{"name": "下载量", "data": [620, 732, 701, 734, 1090, 1130, 1120, 1250, 1000, 900, 1150, 1300]}
]
line_id = str(uuid4())
return {
"id": line_id,
"i": line_id,
"type": "chart-line",
"x": x,
"y": y,
"w": 8,
"h": 5,
"minW": 4,
"minH": 3,
"title": "折线图",
"props": {
"title": module_name,
"smooth": True,
"showArea": False,
"showSymbol": True,
"symbolSize": 6,
"lineWidth": 2,
"showLegend": True,
"legendPosition": "top",
"colors": ["#5470c6", "#91cc75", "#fac858", "#ee6666", "#73c0de"],
"xAxisData": months,
"seriesData": series_data
}
}
def _generate_bar_chart(self, module: Dict[str, Any], x: int, y: int) -> Dict[str, Any]:
"""生成柱状图"""
module_name = module.get('module_name', '销售统计')
fields = module.get('moduld_fields', [])
# 生成模拟数据
regions = ["华东", "华南", "华北", "华中", "西南", "西北", "东北"]
series_data = []
# 根据字段生成系列(最多2个)
for i, field in enumerate(fields[:2]):
data = [random.randint(200, 500) for _ in range(7)]
series_data.append({
"name": field if isinstance(field, str) else f"{2023+i}",
"data": data
})
# 如果没有字段,使用默认系列
if not series_data:
series_data = [
{"name": "2023", "data": [320, 302, 301, 334, 390, 330, 320]},
{"name": "2024", "data": [420, 382, 391, 434, 490, 430, 420]}
]
bar_id = str(uuid4())
return {
"id": bar_id,
"i": bar_id,
"type": "chart-bar",
"x": x,
"y": y,
"w": 8,
"h": 6,
"minW": 4,
"minH": 3,
"title": "柱状图",
"props": {
"title": module_name,
"horizontal": False,
"stack": False,
"barWidth": "auto",
"barRadius": 4,
"showBackground": False,
"showLegend": True,
"legendPosition": "top",
"colors": ["#5470c6", "#91cc75", "#fac858", "#ee6666", "#73c0de"],
"xAxisData": regions,
"seriesData": series_data
}
}
def _generate_pie_chart(self, module: Dict[str, Any], x: int, y: int) -> Dict[str, Any]:
"""生成饼图"""
module_name = module.get('module_name', '流量来源')
fields = module.get('moduld_fields', [])
# 根据字段生成数据
series_data = []
if fields and len(fields) > 0:
# 使用字段名作为分类(最多5个)
for field in fields[:5]:
series_data.append({
"name": field if isinstance(field, str) else f"分类{len(series_data)+1}",
"value": random.randint(300, 1000)
})
# 如果没有字段,使用默认数据
if not series_data:
series_data = [
{"name": "搜索引擎", "value": 1048},
{"name": "直接访问", "value": 735},
{"name": "邮件营销", "value": 580},
{"name": "联盟广告", "value": 484},
{"name": "视频广告", "value": 300}
]
pie_id = str(uuid4())
return {
"id": pie_id,
"i": pie_id,
"type": "chart-pie",
"x": x,
"y": y,
"w": 4,
"h": 6,
"minW": 3,
"minH": 3,
"title": "饼图",
"props": {
"title": module_name,
"pieType": "rose",
"radius": ["0%", "70%"],
"showLabel": True,
"labelPosition": "outside",
"showLegend": True,
"legendPosition": "bottom",
"colors": ["#5470c6", "#91cc75", "#fac858", "#ee6666", "#73c0de"],
"seriesData": series_data
}
}
@@ -0,0 +1,311 @@
"""
仪表盘发布节点
将仪表盘发布到菜单系统
"""
import logging
from typing import Any, Dict
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
logger = logging.getLogger(__name__)
@NodeRegistry.register
class DashboardPublishNode(BaseNode):
"""
仪表盘发布节点
将仪表盘发布到菜单系统,创建菜单项
"""
node_type = 'dashboard_publish'
node_name = '仪表盘发布'
node_category = 'dashboard'
node_icon = 'upload'
node_description = '将仪表盘发布到菜单系统'
inputs = [
{
'name': 'dashboard_id',
'type': 'string',
'description': '仪表盘ID(来自创建节点)',
'required': True,
},
{
'name': 'menu_name',
'type': 'string',
'description': '菜单名称',
'required': True,
},
{
'name': 'menu_parent_id',
'type': 'string',
'description': '父菜单ID(可选)',
'required': False,
},
{
'name': 'menu_icon',
'type': 'string',
'description': '菜单图标',
'required': False,
},
{
'name': 'menu_order',
'type': 'number',
'description': '菜单排序',
'required': False,
},
{
'name': 'application_id',
'type': 'string',
'description': '所属应用ID',
'required': False,
},
]
outputs = [
{
'name': 'menu_id',
'type': 'string',
'description': '创建的菜单ID',
},
{
'name': 'route_path',
'type': 'string',
'description': '访问路径',
},
{
'name': 'publish_result',
'type': 'object',
'description': '发布结果',
},
]
def execute(self, context: NodeContext) -> NodeResult:
"""执行节点(同步方法,通过运行异步方法实现)"""
import asyncio
loop = asyncio.get_event_loop()
if loop.is_running():
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(asyncio.run, self.execute_async(context))
return future.result()
else:
return loop.run_until_complete(self.execute_async(context))
async def execute_async(self, context: NodeContext) -> NodeResult:
"""异步执行节点"""
try:
# 检查是否需要确认(支持布尔值、字符串模式和变量引用)
require_confirmation = self.resolve_require_confirmation(context, default=True)
# 检查是否有用户输入(确认后的数据)
user_input = context.user_input
logger.info(f'DashboardPublishNode - user_input 类型: {type(user_input)}, 值: {user_input}')
logger.info(f'DashboardPublishNode - require_confirmation: {require_confirmation}')
# 解析 user_input(可能是字符串或字典)
user_input_dict = None
if user_input:
if isinstance(user_input, str):
try:
import json
user_input_dict = json.loads(user_input)
logger.info(f'DashboardPublishNode - 解析 JSON 字符串为字典: {user_input_dict}')
except json.JSONDecodeError as e:
logger.warning(f'DashboardPublishNode - JSON 解析失败: {e}')
elif isinstance(user_input, dict):
user_input_dict = user_input
# 检查 user_input 是否包含发布节点需要的字段(dashboard_id 和 menu_name
# 如果包含,说明是用户确认后的数据;否则是从上一个节点传递过来的数据
if user_input_dict and 'dashboard_id' in user_input_dict and 'menu_name' in user_input_dict:
# 用户已确认,使用确认后的数据
logger.info(f'DashboardPublishNode - 使用用户确认的发布配置')
return await self._execute_publish(context, user_input_dict)
# 获取输入参数
dashboard_id = context.resolve_template(self.config.get('dashboard_id', ''))
menu_name = context.resolve_template(self.config.get('menu_name', ''))
menu_parent_id = context.resolve_template(self.config.get('menu_parent_id', ''))
menu_icon = context.resolve_template(self.config.get('menu_icon', 'lucide:layout-dashboard'))
application_id = context.resolve_template(self.config.get('application_id', ''))
# 处理menu_order
menu_order_value = self.config.get('menu_order', 0)
menu_order_str = context.resolve_template(str(menu_order_value))
try:
menu_order = int(menu_order_str) if menu_order_str else 0
except (ValueError, TypeError):
menu_order = 0
logger.info(f'DashboardPublishNode - 开始发布仪表盘: {dashboard_id}')
# 验证必要参数
if not dashboard_id:
return NodeResult(
success=False,
error='仪表盘ID不能为空',
)
if not menu_name:
return NodeResult(
success=False,
error='菜单名称不能为空',
)
# 获取仪表盘的 code(用于显示路由路径)
dashboard_code = ''
try:
from app.database import AsyncSessionLocal
from online_dev.page_manager.model import PageMeta
from sqlalchemy import select
async with AsyncSessionLocal() as db:
stmt = select(PageMeta.code).where(PageMeta.id == dashboard_id, PageMeta.is_deleted == False)
result = await db.execute(stmt)
dashboard_code = result.scalar_one_or_none() or ''
except Exception as e:
logger.warning(f'获取仪表盘 code 失败: {e}')
# 构建发布数据
publish_data = {
'dashboard_id': dashboard_id,
'dashboard_code': dashboard_code,
'menu_name': menu_name,
'menu_parent_id': menu_parent_id,
'menu_icon': menu_icon,
'menu_order': menu_order,
'application_id': application_id,
}
# 如果需要确认,返回等待用户输入
if require_confirmation:
return NodeResult(
success=True,
output=publish_data,
output_variables=publish_data,
waiting_for_input=True,
waiting_config={
'type': 'design_preview',
'preview_type': 'dashboard_publish',
'title': '仪表盘发布确认',
'data': publish_data,
'editable': True,
},
)
# 不需要确认,直接发布
return await self._execute_publish(context, publish_data)
except Exception as e:
logger.error(f'DashboardPublishNode - 执行失败: {str(e)}')
return NodeResult(
success=False,
error=f'发布仪表盘失败: {str(e)}',
)
async def _execute_publish(self, context: NodeContext, publish_data: Dict[str, Any]) -> NodeResult:
"""执行发布操作"""
try:
dashboard_id = publish_data.get('dashboard_id', '')
menu_name = publish_data.get('menu_name', '')
menu_parent_id = publish_data.get('menu_parent_id', '')
menu_icon = publish_data.get('menu_icon', 'lucide:layout-dashboard')
menu_order = publish_data.get('menu_order', 0)
application_id = publish_data.get('application_id', '')
if not dashboard_id or not menu_name:
return NodeResult(
success=False,
error='仪表盘ID和菜单名称不能为空',
)
# 发布到数据库
from app.database import AsyncSessionLocal
from online_dev.page_manager.model import PageMeta
from core.menu.model import Menu
from sqlalchemy import select
async with AsyncSessionLocal() as db:
# 获取仪表盘信息
stmt = select(PageMeta).where(PageMeta.id == dashboard_id, PageMeta.is_deleted == False)
result = await db.execute(stmt)
page = result.scalar_one_or_none()
if not page:
return NodeResult(
success=False,
error=f'仪表盘不存在: {dashboard_id}',
)
# 生成路由路径
route_path = f'/page-render/{page.code}'
# 检查菜单是否已存在
menu_stmt = select(Menu).where(
Menu.path == route_path,
Menu.is_deleted == False
)
menu_result = await db.execute(menu_stmt)
existing_menu = menu_result.scalar_one_or_none()
if existing_menu:
# 更新现有菜单
existing_menu.title = menu_name
existing_menu.icon = menu_icon
existing_menu.order = menu_order
if menu_parent_id:
existing_menu.parent_id = menu_parent_id
menu_id = existing_menu.id
logger.info(f'DashboardPublishNode - 更新菜单: {menu_id}')
else:
# 创建新菜单
new_menu = Menu(
name=menu_name,
title=menu_name,
path=route_path,
component='_core/page-render/index',
icon=menu_icon,
order=menu_order,
type='online_page',
parent_id=menu_parent_id if menu_parent_id else None,
application_id=application_id if application_id else page.application_id,
)
db.add(new_menu)
await db.flush()
menu_id = new_menu.id
logger.info(f'DashboardPublishNode - 创建菜单: {menu_id}')
# 更新页面状态为已发布
page.status = 'published'
await db.commit()
output_data = {
'menu_id': menu_id,
'route_path': route_path,
'dashboard_id': dashboard_id,
'dashboard_code': page.code,
'dashboard_name': page.name,
'menu_name': menu_name,
'status': 'published',
}
return NodeResult(
success=True,
output=output_data,
output_variables=output_data,
)
except Exception as e:
logger.error(f'DashboardPublishNode - 执行失败: {str(e)}')
return NodeResult(
success=False,
error=f'发布仪表盘失败: {str(e)}',
)
@@ -0,0 +1,333 @@
"""
表单基础信息处理节点
接收LLM提取的基础信息,进行验证和补充
"""
import logging
import re
from typing import Any, Dict, Optional
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
logger = logging.getLogger(__name__)
@NodeRegistry.register
class FormBasicInfoNode(BaseNode):
"""
表单基础信息处理节点
接收LLM提取的基础信息,进行验证和补充
"""
node_type = 'form_basic_info'
node_name = '表单基础信息'
node_category = 'form'
node_icon = 'file-text'
node_description = '处理表单基础信息,验证和补充表单名称、编码等'
inputs = [
{
'name': 'name',
'type': 'string',
'description': '表单名称',
'required': True,
},
{
'name': 'code',
'type': 'string',
'description': '表单编码',
'required': False,
},
{
'name': 'form_type',
'type': 'string',
'description': '表单类型(normal/workflow',
'required': False,
},
{
'name': 'description',
'type': 'string',
'description': '表单描述',
'required': False,
},
{
'name': 'sort',
'type': 'number',
'description': '排序',
'required': False,
},
]
outputs = [
{
'name': 'form_basic_info',
'type': 'object',
'description': '处理后的表单基础信息',
},
]
def execute(self, context: NodeContext) -> NodeResult:
"""执行节点"""
try:
# 检查是否有用户编辑的数据(从设计预览面板提交)
user_input = context.variables.get('__user_input__')
if user_input:
edited_data = self._parse_user_edit(user_input)
if edited_data:
logger.info(f'FormBasicInfoNode - 使用用户编辑的数据: {edited_data}')
# 使用编辑后的数据,清除用户输入
context.variables.pop('__user_input__', None)
return self._build_result(edited_data, require_confirmation=False)
# 获取输入参数
name = context.resolve_template(self.config.get('name', ''))
code = context.resolve_template(self.config.get('code', ''))
form_type = context.resolve_template(self.config.get('form_type', 'normal'))
description = context.resolve_template(self.config.get('description', ''))
# 处理sort(可能是字符串或数字)
sort_value = self.config.get('sort', 0)
# 先转换为字符串,再调用resolve_template
sort_str = context.resolve_template(str(sort_value))
try:
sort = int(sort_str) if sort_str else 0
except (ValueError, TypeError):
sort = 0
logger.info(f'FormBasicInfoNode - 输入参数: name={name}, code={code}, form_type={form_type}')
# 验证必填字段
if not name or not name.strip():
return NodeResult(
success=False,
error='表单名称不能为空',
)
name = name.strip()
# 自动生成编码(如果未提供或配置了自动生成)
auto_generate = self.config.get('auto_generate_code', False)
if not code or not code.strip() or auto_generate:
code = self._generate_code(name)
logger.info(f'自动生成编码: {code}')
else:
code = code.strip()
# 验证编码格式
if not re.match(r'^[a-z][a-z0-9_]*$', code):
return NodeResult(
success=False,
error=f'表单编码格式不正确:{code},必须以字母开头,只能包含小写字母、数字和下划线',
)
# 验证表单类型
if form_type not in ['normal', 'workflow']:
logger.warning(f'表单类型 {form_type} 不合法,使用默认值 normal')
form_type = 'normal'
# 构建输出
basic_info = {
'name': name,
'code': code,
'form_type': form_type,
'description': description.strip() if description else name,
'sort': sort,
}
logger.info(f'FormBasicInfoNode - 输出: {basic_info}')
# 检查是否需要确认(支持布尔值、字符串模式和变量引用)
require_confirmation = self.resolve_require_confirmation(context, default=True)
return self._build_result(basic_info, require_confirmation)
except Exception as e:
logger.exception(f'表单基础信息节点执行失败: {e}')
return NodeResult(
success=False,
error=f'节点执行失败: {str(e)}',
)
async def execute_async(self, context: NodeContext) -> NodeResult:
"""异步执行节点(直接调用同步方法)"""
return self.execute(context)
def _parse_user_edit(self, user_input: Any) -> Optional[Dict[str, Any]]:
"""解析用户编辑的数据"""
import json
if isinstance(user_input, dict):
# 检查是否包含表单基础信息字段
if 'name' in user_input:
return user_input
return None
if isinstance(user_input, str):
try:
data = json.loads(user_input)
if isinstance(data, dict) and 'name' in data:
return data
except (json.JSONDecodeError, TypeError):
pass
return None
def _build_result(self, basic_info: Dict[str, Any], require_confirmation: bool = False) -> NodeResult:
"""构建节点结果"""
name = basic_info.get('name', '')
code = basic_info.get('code', '')
form_type = basic_info.get('form_type', 'normal')
description = basic_info.get('description', name)
sort = basic_info.get('sort', 0)
# 构建预览数据
preview_data = {
'type': 'form_basic_info',
'title': '表单基础信息',
'data': basic_info,
'editable': True,
} if require_confirmation else None
return NodeResult(
success=True,
output=basic_info,
output_variables={
'form_basic_info': basic_info,
'form_name': name,
'form_code': code,
'form_type': form_type,
'form_description': description,
'form_sort': sort,
},
preview=preview_data,
waiting_for_input=require_confirmation,
waiting_config={
'type': 'design_preview',
'preview_type': 'form_basic_info',
'title': '表单基础信息',
'message': '请确认或编辑表单基础信息',
'data': basic_info,
} if require_confirmation else {},
)
def _generate_code(self, name: str) -> str:
"""
根据表单名称自动生成编码
Args:
name: 表单名称
Returns:
生成的编码
"""
try:
from pypinyin import lazy_pinyin
# 将中文转拼音
pinyin_list = lazy_pinyin(name)
code = '_'.join(pinyin_list)
except ImportError:
logger.warning('pypinyin 未安装,使用简单的编码生成策略')
# 如果没有安装 pypinyin,使用简单策略
code = name.lower()
# 只保留字母、数字和下划线
code = re.sub(r'[^a-z0-9_]', '', code.lower())
# 确保以字母开头
if code and not code[0].isalpha():
code = 'form_' + code
# 如果为空,使用默认值
if not code:
code = 'form_default'
return code
@classmethod
def get_config_schema(cls) -> Dict[str, Any]:
"""
获取节点配置 Schema(供前端表单渲染)
Returns:
配置 Schema
"""
return {
'type': 'object',
'properties': {
'name': {
'type': 'string',
'title': '表单名称',
'description': '表单的显示名称,支持变量引用如 {{llm_basic_name}}',
'required': True,
'x-component': 'Input',
'x-component-props': {
'placeholder': '请输入表单名称或使用变量 {{llm_basic_name}}',
},
},
'code': {
'type': 'string',
'title': '表单编码',
'description': '表单的唯一编码,支持变量引用如 {{llm_basic_code}}',
'required': False,
'x-component': 'Input',
'x-component-props': {
'placeholder': '留空则自动生成,或使用变量 {{llm_basic_code}}',
},
},
'form_type': {
'type': 'string',
'title': '表单类型',
'description': '表单类型:normal(普通表单)或 workflow(工作流表单)',
'default': 'normal',
'enum': ['normal', 'workflow'],
'x-component': 'Select',
'x-component-props': {
'placeholder': '请选择表单类型',
'options': [
{'label': '普通表单', 'value': 'normal'},
{'label': '工作流表单', 'value': 'workflow'},
],
},
},
'description': {
'type': 'string',
'title': '表单描述',
'description': '表单的详细描述,支持变量引用',
'required': False,
'x-component': 'Textarea',
'x-component-props': {
'placeholder': '请输入表单描述',
'rows': 3,
},
},
'sort': {
'type': 'number',
'title': '排序',
'description': '表单的排序值,数字越小越靠前',
'default': 0,
'x-component': 'InputNumber',
'x-component-props': {
'placeholder': '请输入排序值',
'min': 0,
},
},
'auto_generate_code': {
'type': 'boolean',
'title': '自动生成编码',
'description': '是否自动根据表单名称生成编码(拼音转换)',
'default': False,
'x-component': 'Switch',
},
'require_confirmation': {
'type': 'boolean',
'title': '需要确认',
'description': '设计完成后是否暂停等待用户确认或编辑',
'default': True,
'x-component': 'Switch',
},
},
'required': ['name'],
}
@@ -0,0 +1,499 @@
"""
表单创建节点
将表单配置保存到数据库,创建完整的表单元数据
"""
import ast
import json
import logging
from typing import Any, Dict, List, Optional
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
logger = logging.getLogger(__name__)
@NodeRegistry.register
class FormCreateNode(BaseNode):
"""
表单创建节点
将表单基础信息、表单UI设计、列表设计等配置保存到数据库
"""
node_type = 'form_create'
node_name = '表单创建'
node_category = 'form'
node_icon = 'file-plus'
node_description = '将表单配置保存到数据库,创建完整的表单元数据'
inputs = [
{
'name': 'form_basic_info',
'type': 'object',
'description': '表单基础信息(来自表单基础信息节点)',
'required': True,
},
{
'name': 'database_design',
'type': 'object',
'description': '数据库设计配置(来自数据库设计节点)',
'required': True,
},
{
'name': 'form_ui_design',
'type': 'object',
'description': '表单UI设计配置(来自表单UI设计节点)',
'required': True,
},
{
'name': 'list_config',
'type': 'object',
'description': '列表配置(来自列表UI设计节点)',
'required': True,
},
{
'name': 'application_id',
'type': 'string',
'description': '所属应用ID(可选)',
'required': False,
},
]
outputs = [
{
'name': 'form_id',
'type': 'string',
'description': '创建的表单ID',
},
{
'name': 'form_code',
'type': 'string',
'description': '表单编码',
},
{
'name': 'form_meta',
'type': 'object',
'description': '完整的表单元数据',
},
]
def execute(self, context: NodeContext) -> NodeResult:
"""执行节点(同步方法,通过运行异步方法实现)"""
import asyncio
loop = asyncio.get_event_loop()
if loop.is_running():
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(asyncio.run, self.execute_async(context))
return future.result()
else:
return loop.run_until_complete(self.execute_async(context))
async def execute_async(self, context: NodeContext) -> NodeResult:
"""异步执行节点"""
try:
# 获取输入参数(原始配置值)
form_basic_info_raw = self.config.get('form_basic_info', '{}')
database_design_raw = self.config.get('database_design', '{}')
form_ui_design_raw = self.config.get('form_ui_design', '{}')
list_config_raw = self.config.get('list_config', '{}')
# 解析变量引用,保持原始类型
form_basic_info = self._resolve_variable_value(context, form_basic_info_raw)
database_design = self._resolve_variable_value(context, database_design_raw)
form_ui_design = self._resolve_variable_value(context, form_ui_design_raw)
list_config = self._resolve_variable_value(context, list_config_raw)
application_id = context.resolve_template(self.config.get('application_id', ''))
logger.info(f'FormCreateNode - 解析结果: form_basic_info={bool(form_basic_info)}, database_design={bool(database_design)}, form_ui_design={bool(form_ui_design)}, list_config={bool(list_config)}')
# 配置选项
update_if_exists = self.config.get('update_if_exists', True)
logger.info(f'FormCreateNode - 开始创建表单')
# 验证必要配置
if not form_basic_info:
return NodeResult(
success=False,
error='表单基础信息配置不能为空',
)
if not database_design:
return NodeResult(
success=False,
error='数据库设计配置不能为空',
)
if not form_ui_design:
return NodeResult(
success=False,
error='表单UI设计配置不能为空',
)
if not list_config:
return NodeResult(
success=False,
error='列表配置不能为空',
)
# 提取基础信息
form_name = form_basic_info.get('name', '')
form_code_base = form_basic_info.get('code', '')
form_type = form_basic_info.get('form_type', 'normal')
description = form_basic_info.get('description', '')
sort = form_basic_info.get('sort', 0)
if not form_name or not form_code_base:
return NodeResult(
success=False,
error='表单名称和编码不能为空',
)
# 提取数据库配置
table_info = database_design.get('table', {})
db_config = database_design.get('dbConfig', 'default')
meta = table_info.get('meta', {})
main_table = table_info.get('tableName', '')
# 优先使用原始 schema 配置(可能包含变量),如果不存在则使用解析后的值
main_table_schema = meta.get('schemaRaw', meta.get('schema', ''))
main_table_database = meta.get('database', '')
if not main_table:
return NodeResult(
success=False,
error='主表名不能为空',
)
# 构建表配置(用于 form_config.tableConfigs
table_configs = self._build_table_configs(database_design)
# 构建 form_config
form_config = {
'items': form_ui_design.get('items', []),
'labelWidth': form_ui_design.get('labelWidth', 100),
'labelPosition': form_ui_design.get('labelPosition', 'right'),
'size': form_ui_design.get('size', 'default'),
'tableConfigs': table_configs,
}
# 调用 FormService 创建表单
from app.database import AsyncSessionLocal
from online_dev.form_manager.service import FormService, FormServiceException
async with AsyncSessionLocal() as db:
try:
# 生成唯一的 code(如果已存在则自动添加后缀)
form_code = await self._get_unique_code(db, form_code_base, FormService)
logger.info(f'FormCreateNode - 使用 code: {form_code} (原始: {form_code_base})')
# 构建表单数据
form_data = {
'application_id': application_id if application_id else None,
'name': form_name,
'code': form_code,
'form_type': form_type,
'description': description,
'sort': sort,
'db_config': db_config,
'main_table': main_table,
'main_table_schema': main_table_schema,
'main_table_database': main_table_database,
'form_config': form_config,
'list_config': list_config,
'sub_tables': [], # 暂不支持子表
}
logger.info(f'FormCreateNode - 表单数据: name={form_name}, code={form_code}, main_table={main_table}')
# 由于 _get_unique_code 已确保 code 唯一,直接创建表单
# 如果仍然出现重复(极端并发情况),捕获异常并处理
try:
form = await FormService.create(db, form_data)
logger.info(f'表单创建成功: {form_code}')
except FormServiceException as e:
# 如果是编码重复错误且允许更新,尝试更新
if '已存在' in str(e) and update_if_exists:
logger.warning(f'表单编码冲突,尝试更新: {form_code}')
try:
existing_form = await FormService.get_by_code(db, form_code)
form = await FormService.update(db, existing_form.id, form_data)
logger.info(f'表单更新成功: {form_code}')
except Exception as update_error:
return NodeResult(
success=False,
error=f'表单创建和更新均失败: {str(update_error)}',
)
else:
return NodeResult(
success=False,
error=f'表单创建失败: {str(e)}',
)
# 构建输出
form_meta = {
'id': form.id,
'name': form.name,
'code': form.code,
'form_type': form.form_type,
'description': form.description,
'status': form.status,
'version': form.version,
'db_config': form.db_config,
'main_table': form.main_table,
'main_table_schema': form.main_table_schema,
'main_table_database': form.main_table_database,
}
logger.info(f'FormCreateNode - 表单创建成功: id={form.id}, code={form.code}')
return NodeResult(
success=True,
output=form_meta,
output_variables={
'form_id': form.id,
'form_code': form.code,
'form_meta': form_meta,
},
)
except FormServiceException as e:
logger.error(f'表单服务异常: {e}')
return NodeResult(
success=False,
error=f'表单创建失败: {str(e)}',
)
except Exception as e:
logger.exception(f'表单创建异常: {e}')
return NodeResult(
success=False,
error=f'表单创建异常: {str(e)}',
)
except Exception as e:
logger.exception(f'表单创建节点执行失败: {e}')
return NodeResult(
success=False,
error=f'节点执行失败: {str(e)}',
)
def _resolve_variable_value(self, context: NodeContext, raw_value: Any) -> Optional[Dict]:
"""
解析变量引用,保持原始类型
支持格式:
- {{node_id.variable_name}} - 节点变量引用
- 直接的字典值
- JSON 字符串
"""
import re
# 如果已经是字典,直接返回
if isinstance(raw_value, dict):
return raw_value if raw_value else None
# 如果不是字符串,尝试解析
if not isinstance(raw_value, str):
return None
raw_value = raw_value.strip()
if not raw_value or raw_value == '{}':
return None
# 检查是否是单个变量引用 {{node_id.variable_name}}
single_var_pattern = r'^\{\{([^}]+)\}\}$'
match = re.match(single_var_pattern, raw_value)
if match:
var_ref = match.group(1).strip()
# 解析 node_id.variable_name 格式
if '.' in var_ref:
parts = var_ref.split('.', 1)
node_id = parts[0]
key = parts[1]
# 从节点输出命名空间获取
node_outputs = context.variables.get(f'_node_{node_id}')
if isinstance(node_outputs, dict) and key in node_outputs:
value = node_outputs[key]
if isinstance(value, dict):
return value if value else None
return self._parse_config(value)
# 变量未找到
logger.warning(f'变量引用未找到: {var_ref}')
else:
# 直接变量引用
if var_ref in context.variables:
value = context.variables[var_ref]
if isinstance(value, dict):
return value if value else None
return self._parse_config(value)
logger.warning(f'变量引用未找到: {var_ref}')
# 不是变量引用,尝试解析为 JSON 或 Python dict
return self._parse_config(raw_value)
def _parse_config(self, config_str: Any) -> Optional[Dict]:
"""解析配置"""
# 如果已经是字典,直接返回
if isinstance(config_str, dict):
return config_str if config_str else None
# 如果是列表,返回 None(不是有效的配置)
if isinstance(config_str, list):
return None
# 如果是字符串,尝试解析
if isinstance(config_str, str):
config_str = config_str.strip()
if not config_str or config_str == '{}' or config_str == 'None':
return None
# 检查是否是未解析的模板变量(仍然包含 {{ }})
if '{{' in config_str and '}}' in config_str:
logger.warning(f'配置值仍包含未解析的模板变量: {config_str[:100]}')
return None
# 尝试 JSON 解析
try:
result = json.loads(config_str)
if isinstance(result, dict):
return result if result else None
return None
except json.JSONDecodeError:
pass
# 尝试 Python literal_eval(处理单引号格式)
try:
result = ast.literal_eval(config_str)
if isinstance(result, dict):
return result if result else None
return None
except (ValueError, SyntaxError) as e:
logger.warning(f'配置解析失败: {e}, 值: {config_str[:200]}')
return None
return None
async def _get_unique_code(self, db, base_code: str, service) -> str:
"""
生成唯一的 code,如果已存在则自动添加数字后缀
Args:
db: 数据库会话
base_code: 基础 code
service: 服务类(需要有 get_by_code 方法)
Returns:
唯一的 code
"""
from sqlalchemy import select
from online_dev.form_manager.model import FormMeta
code = base_code
counter = 1
while True:
# 直接查询数据库,避免 get_by_code 的异常处理问题
stmt = select(FormMeta).where(
FormMeta.code == code,
FormMeta.is_deleted == False
)
result = await db.execute(stmt)
existing = result.scalar_one_or_none()
if not existing:
# code 不存在,可以使用
break
# code 已存在,添加后缀
code = f"{base_code}_{counter}"
counter += 1
# 防止无限循环
if counter > 100:
logger.warning(f'生成唯一 code 失败,已尝试 {counter}')
break
return code
def _build_table_configs(self, database_design: Dict) -> List[Dict]:
"""构建表配置列表"""
table_configs = []
table_info = database_design.get('table', {})
if not table_info:
return table_configs
meta = table_info.get('meta', {}).copy() if table_info.get('meta') else {}
# 确保 meta 中包含 dbName(从 database_design.dbConfig 获取,如果 meta 中没有的话)
if not meta.get('dbName') and database_design.get('dbConfig'):
meta['dbName'] = database_design.get('dbConfig')
# 主表配置
main_config = {
'type': 'main',
'tableName': table_info.get('tableName', ''),
'alias': table_info.get('alias', table_info.get('tableName', '')),
'fields': table_info.get('fields', []),
'meta': meta,
}
table_configs.append(main_config)
return table_configs
@classmethod
def get_config_schema(cls) -> Dict[str, Any]:
"""获取节点配置 Schema"""
return {
'type': 'object',
'properties': {
'form_basic_info': {
'type': 'string',
'title': '表单基础信息',
'description': '来自表单基础信息节点的输出',
'x-component': 'SmartInput',
},
'database_design': {
'type': 'string',
'title': '数据库设计配置',
'description': '来自数据库设计节点的输出',
'x-component': 'SmartInput',
},
'form_ui_design': {
'type': 'string',
'title': '表单UI设计配置',
'description': '来自表单UI设计节点的输出',
'x-component': 'SmartInput',
},
'list_config': {
'type': 'string',
'title': '列表配置',
'description': '来自列表UI设计节点的输出',
'x-component': 'SmartInput',
},
'application_id': {
'type': 'string',
'title': '所属应用ID',
'description': '表单所属的应用ID(子应用模式下自动获取)',
'default': '{{application_id}}',
'x-component': 'SmartInput',
},
'update_if_exists': {
'type': 'boolean',
'title': '存在时更新',
'description': '表单编码已存在时是否更新',
'default': True,
'x-component': 'Switch',
},
},
'required': ['form_basic_info', 'database_design', 'form_ui_design', 'list_config'],
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,349 @@
"""
数据库表创建节点
根据数据库设计配置实际创建Schema和表
"""
import logging
from typing import Any, Dict, List
from core.database_connection.resolver import ConnectionResolver
from core.database_manager.ddl_builder import (
build_create_schema_sql,
build_create_table_ddl,
normalize_db_type,
)
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
from ..utils.config_utils import resolve_object_config
logger = logging.getLogger(__name__)
@NodeRegistry.register
class FormDatabaseCreateNode(BaseNode):
"""
数据库表创建节点
根据数据库设计配置实际创建Schema和表
"""
node_type = 'form_database_create'
node_name = '数据库表创建'
node_category = 'form'
node_icon = 'database-zap'
node_description = '根据设计配置实际创建数据库Schema和表'
inputs = [
{
'name': 'database_design',
'type': 'object',
'description': '数据库设计配置',
'required': True,
},
{
'name': 'if_exists',
'type': 'string',
'description': '表已存在时的处理方式',
'required': False,
},
{
'name': 'create_schema_if_not_exists',
'type': 'boolean',
'description': '自动创建Schema',
'required': False,
},
]
outputs = [
{
'name': 'creation_result',
'type': 'object',
'description': '创建结果',
},
{
'name': 'all_tables_ready',
'type': 'boolean',
'description': '所有表是否就绪',
},
]
def execute(self, context: NodeContext) -> NodeResult:
"""执行节点(同步方法,通过运行异步方法实现)"""
import asyncio
loop = asyncio.get_event_loop()
if loop.is_running():
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(asyncio.run, self.execute_async(context))
return future.result()
return loop.run_until_complete(self.execute_async(context))
async def _resolve_db_type(self, db_name: str, meta_db_type: str, context: NodeContext) -> str:
configured = normalize_db_type(meta_db_type or 'postgresql')
try:
conn_info = await ConnectionResolver.resolve(db_name, context.db_session)
resolved = normalize_db_type(conn_info.db_type)
if configured != resolved:
logger.warning(
'FormDatabaseCreateNode - db_type 不一致: meta=%s, 连接 %s 解析为 %s,以连接为准',
configured,
db_name,
resolved,
)
return resolved
except Exception as exc:
logger.warning(
'FormDatabaseCreateNode - 无法解析连接 %s 的 db_type: %s,使用 meta.dbType=%s',
db_name,
exc,
configured,
)
return configured
async def execute_async(self, context: NodeContext) -> NodeResult:
"""异步执行节点"""
try:
database_design = resolve_object_config(
context, self.config.get('database_design', '')
)
if_exists = context.resolve_template(self.config.get('if_exists', 'skip'))
create_schema = self.config.get('create_schema_if_not_exists', True)
node_schema_raw = self.config.get('db_schema', '')
node_schema = context.resolve_template(node_schema_raw)
node_database_raw = self.config.get('db_database', '')
node_database = context.resolve_template(node_database_raw)
logger.info(
'FormDatabaseCreateNode - database: raw=%s resolved=%s schema: raw=%s resolved=%s',
node_database_raw,
node_database,
node_schema_raw,
node_schema,
)
if database_design is None:
return NodeResult(
success=False,
error='数据库设计配置不能为空或解析失败',
)
design_type = database_design.get('type', '')
db_config = database_design.get('dbConfig', 'default')
if design_type in ['main', 'sub']:
table_info = database_design.get('table', {})
if not table_info:
return NodeResult(
success=False,
error='数据库设计配置中缺少 table 信息',
)
all_tables = [table_info]
main_table = table_info
elif database_design.get('mainTable'):
main_table = database_design.get('mainTable', {})
sub_tables = database_design.get('subTables', [])
all_tables = [main_table] + sub_tables
else:
return NodeResult(
success=False,
error='数据库设计配置格式无效,需要包含 type+table 或 mainTable',
)
meta = main_table.get('meta', {})
db_name = meta.get('dbName', db_config)
database = node_database if node_database else meta.get('database', '')
meta_schema = meta.get('schema', '')
schema = node_schema if node_schema else meta_schema
db_type = await self._resolve_db_type(
db_name,
meta.get('dbType', 'postgresql'),
context,
)
logger.info(
'FormDatabaseCreateNode - dbName=%s dbType=%s database=%s schema=%s',
db_name,
db_type,
database,
schema,
)
created_tables: List[str] = []
skipped_tables: List[str] = []
errors: List[str] = []
sql_statements: List[str] = []
comment_statements: List[str] = []
if create_schema and schema and db_type in ('postgresql', 'sqlserver'):
schema_sql = build_create_schema_sql(schema, db_type)
if schema_sql:
sql_statements.append(schema_sql)
for table in all_tables:
table_name = table.get('tableName', '')
if not table_name:
errors.append('表配置缺少 tableName')
continue
try:
ddl_result = build_create_table_ddl(
table,
db_type=db_type,
if_exists=if_exists,
effective_schema=schema,
)
if ddl_result.skipped:
skipped_tables.append(table_name)
continue
if ddl_result.create_sql:
sql_statements.append(ddl_result.create_sql)
created_tables.append(table_name)
comment_statements.extend(ddl_result.comment_sqls)
except Exception as exc:
error_msg = f'生成表 {table_name} 的SQL失败: {str(exc)}'
errors.append(error_msg)
logger.error(error_msg)
if sql_statements:
try:
from core.database_manager.service import AsyncDatabaseManagerService
full_sql = '\n\n'.join(sql_statements)
logger.info('FormDatabaseCreateNode - 执行 DDL,共 %d', len(sql_statements))
db_service = await AsyncDatabaseManagerService.create(db_name)
result = await db_service.execute_ddl(full_sql, database, schema)
if not result.get('success', False):
error_msg = result.get('message', '执行DDL失败')
errors.append(
f'[{db_name}/{db_type}] {error_msg}; SQL开头: {full_sql[:200]}...'
)
logger.error('DDL执行失败: %s', error_msg)
elif comment_statements and if_exists != 'skip':
try:
comment_sql = '\n'.join(comment_statements)
comment_result = await db_service.execute_ddl(
comment_sql, database, schema,
)
if not comment_result.get('success', False):
logger.warning(
'字段注释执行失败: %s',
comment_result.get('message', '未知错误'),
)
except Exception as comment_error:
logger.warning('执行字段注释时发生异常: %s', comment_error)
except Exception as exc:
error_msg = f'执行DDL时发生异常: {str(exc)}'
errors.append(error_msg)
logger.exception(error_msg)
all_tables_ready = len(errors) == 0 and (len(created_tables) > 0 or len(skipped_tables) > 0)
creation_result = {
'success': all_tables_ready,
'created_tables': created_tables,
'skipped_tables': skipped_tables,
'errors': errors,
'sql_statements': sql_statements,
'total_tables': len(all_tables),
'db_name': db_name,
'db_type': db_type,
}
first_table_name = created_tables[0] if created_tables else (
skipped_tables[0] if skipped_tables else ''
)
output_vars = {
'creation_result': creation_result,
'all_tables_ready': all_tables_ready,
'created_count': len(created_tables),
'error_count': len(errors),
'schema_name': schema,
'table_name': first_table_name,
'db_name': db_name,
'db_type': db_type,
}
if errors:
return NodeResult(
success=False,
error=f'创建表时发生错误: {"; ".join(errors)}',
output=creation_result,
output_variables=output_vars,
)
return NodeResult(
success=True,
output=creation_result,
output_variables=output_vars,
)
except Exception as exc:
logger.exception('数据库表创建节点执行失败: %s', exc)
return NodeResult(
success=False,
error=f'节点执行失败: {str(exc)}',
)
@classmethod
def get_config_schema(cls) -> Dict[str, Any]:
"""获取节点配置 Schema"""
return {
'type': 'object',
'properties': {
'db_schema': {
'type': 'string',
'title': '数据库 Schema(可选)',
'description': 'PostgreSQL/SQL Server schema 名称(如 public、app),支持变量如 {{schema_name}}。如果配置则优先使用此值,否则使用设计节点的 schema',
'default': '',
'x-component': 'SmartInput',
},
'db_database': {
'type': 'string',
'title': '目标数据库(可选)',
'description': 'PostgreSQL/MySQL/SQL Server 目标库名,支持变量。如果配置则优先使用此值,否则使用设计节点的 database',
'default': '',
'x-component': 'SmartInput',
},
'database_design': {
'type': 'string',
'title': '数据库设计配置',
'description': '从上一节点获取的数据库设计配置,支持变量引用如 {{database_design}}',
'required': True,
'x-component': 'Textarea',
'x-component-props': {
'placeholder': '请使用变量 {{database_design}}',
'rows': 4,
},
},
'if_exists': {
'type': 'string',
'title': '表已存在时',
'description': '表已存在时的处理方式',
'default': 'skip',
'enum': ['skip', 'error', 'replace'],
'x-component': 'Select',
'x-component-props': {
'placeholder': '请选择处理方式',
'options': [
{'label': '跳过(推荐)', 'value': 'skip'},
{'label': '报错', 'value': 'error'},
{'label': '删除重建(危险)', 'value': 'replace'},
],
},
},
'create_schema_if_not_exists': {
'type': 'boolean',
'title': '自动创建Schema',
'description': 'Schema不存在时自动创建(PostgreSQL/SQL Server',
'default': True,
'x-component': 'Switch',
},
},
'required': ['database_design'],
}
@@ -0,0 +1,507 @@
"""
数据库表结构设计节点
接收LLM提取的表结构,进行验证、标准化和补充系统字段
"""
import asyncio
import json
import logging
import uuid
from typing import Any, Dict, List, Optional
from core.database_connection.resolver import ConnectionResolver
from core.database_manager.ddl_builder import (
SYSTEM_FIELDS,
normalize_db_type,
process_canonical_field,
)
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
from ..utils.config_utils import resolve_list_config
logger = logging.getLogger(__name__)
@NodeRegistry.register
class FormDatabaseDesignNode(BaseNode):
"""
数据库表结构设计节点
接收LLM提取的表结构,验证、标准化并补充系统字段
"""
node_type = 'form_database_design'
node_name = '数据库表结构设计'
node_category = 'form'
node_icon = 'database'
node_description = '验证和标准化数据库表结构,自动补充系统字段'
inputs = [
{
'name': 'design_mode',
'type': 'string',
'description': '设计模式:main(主表)或 sub(从表)',
'required': True,
},
{
'name': 'table_name',
'type': 'string',
'description': '主表名(主表模式)',
'required': False,
},
{
'name': 'fields',
'type': 'array',
'description': '主表字段列表(主表模式)',
'required': False,
},
{
'name': 'sub_table_name',
'type': 'string',
'description': '从表名(从表模式)',
'required': False,
},
{
'name': 'sub_fields',
'type': 'array',
'description': '从表字段列表(从表模式)',
'required': False,
},
{
'name': 'parent_table',
'type': 'string',
'description': '关联主表名(从表模式)',
'required': False,
},
{
'name': 'foreign_key',
'type': 'string',
'description': '外键字段名(从表模式)',
'required': False,
},
{
'name': 'db_config',
'type': 'string',
'description': '数据库配置名',
'required': False,
},
{
'name': 'auto_add_system_fields',
'type': 'boolean',
'description': '是否自动添加系统字段',
'required': False,
},
]
outputs = [
{
'name': 'database_design',
'type': 'object',
'description': '标准化的数据库设计配置',
},
{
'name': 'table_name',
'type': 'string',
'description': '表名',
},
{
'name': 'field_count',
'type': 'number',
'description': '字段数量',
},
]
def execute(self, context: NodeContext) -> NodeResult:
"""执行节点"""
loop = asyncio.get_event_loop()
if loop.is_running():
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(asyncio.run, self.execute_async(context))
return future.result()
return loop.run_until_complete(self.execute_async(context))
async def execute_async(self, context: NodeContext) -> NodeResult:
"""异步执行节点"""
try:
user_input = context.variables.get('__user_input__')
if user_input:
edited_data = self._parse_user_edit(user_input)
if edited_data:
logger.info('FormDatabaseDesignNode - 使用用户编辑的数据')
context.variables.pop('__user_input__', None)
return self._build_result_from_edit(edited_data)
design_mode = self.config.get('design_mode', 'main')
db_config = context.resolve_template(self.config.get('db_config', 'default'))
db_schema_raw = self.config.get('db_schema', '')
db_schema = context.resolve_template(db_schema_raw)
db_database_raw = self.config.get('db_database', '')
db_database = context.resolve_template(db_database_raw)
config_db_type = self.config.get('db_type', 'postgresql')
db_type, db_type_resolved = await self._resolve_db_type(
db_config, config_db_type, context,
)
auto_add_system = self.config.get('auto_add_system_fields', True)
logger.info(
'FormDatabaseDesignNode - mode=%s database=%s schema=%s db_config=%s db_type=%s resolved=%s',
design_mode,
db_database,
db_schema,
db_config,
db_type,
db_type_resolved,
)
if design_mode == 'main':
table_name_str = context.resolve_template(self.config.get('table_name', ''))
fields = resolve_list_config(context, self.config.get('fields', '[]'))
if not table_name_str:
return NodeResult(success=False, error='主表名不能为空')
if fields is None:
return NodeResult(success=False, error='字段列表解析失败')
if not fields:
return NodeResult(success=False, error='字段列表不能为空')
table_config = {'tableName': table_name_str, 'fields': fields}
processed_table = self._process_table(
table_config,
'main',
auto_add_system,
db_config,
db_database,
db_database_raw,
db_schema,
db_schema_raw,
db_type,
db_type_resolved,
)
database_design = {
'type': 'main',
'table': processed_table,
'dbConfig': db_config,
}
field_count = len(processed_table['fields'])
require_confirmation = self.resolve_require_confirmation(context, default=True)
preview_data = {
'type': 'database_design',
'title': '数据库表结构设计',
'data': database_design,
'editable': True,
} if require_confirmation else None
return NodeResult(
success=True,
output=database_design,
output_variables={
'database_design': database_design,
'table_name': table_name_str,
'field_count': field_count,
},
preview=preview_data,
waiting_for_input=require_confirmation,
waiting_config={
'type': 'design_preview',
'preview_type': 'database_design',
'title': '数据库表结构设计',
'message': '请确认或编辑数据库表结构',
'data': database_design,
} if require_confirmation else {},
)
sub_table_name_str = context.resolve_template(self.config.get('sub_table_name', ''))
sub_fields = resolve_list_config(context, self.config.get('sub_fields', '[]'))
parent_table = context.resolve_template(self.config.get('parent_table', ''))
foreign_key = context.resolve_template(self.config.get('foreign_key', ''))
if not sub_table_name_str:
return NodeResult(success=False, error='从表名不能为空')
if not parent_table:
return NodeResult(success=False, error='关联主表不能为空')
if sub_fields is None:
return NodeResult(success=False, error='从表字段列表解析失败')
if not sub_fields:
return NodeResult(success=False, error='从表字段列表不能为空')
if not foreign_key:
foreign_key = f'{parent_table}_id'
table_config = {
'tableName': sub_table_name_str,
'fields': sub_fields,
'foreignKey': foreign_key,
'parentTable': parent_table,
}
processed_table = self._process_table(
table_config,
'sub',
auto_add_system,
db_config,
db_database,
db_database_raw,
db_schema,
db_schema_raw,
db_type,
db_type_resolved,
)
database_design = {
'type': 'sub',
'table': processed_table,
'parentTable': parent_table,
'foreignKey': foreign_key,
'dbConfig': db_config,
}
field_count = len(processed_table['fields'])
return NodeResult(
success=True,
output=database_design,
output_variables={
'database_design': database_design,
'table_name': sub_table_name_str,
'field_count': field_count,
},
)
except Exception as exc:
logger.exception('数据库表结构设计节点执行失败: %s', exc)
return NodeResult(success=False, error=f'节点执行失败: {str(exc)}')
async def _resolve_db_type(
self,
db_config: str,
config_db_type: str,
context: NodeContext,
) -> tuple[str, bool]:
configured = normalize_db_type(config_db_type or 'postgresql')
try:
conn_info = await ConnectionResolver.resolve(db_config, context.db_session)
resolved = normalize_db_type(conn_info.db_type)
if configured != resolved:
logger.warning(
'FormDatabaseDesignNode - db_type 配置=%s 与连接 %s 解析=%s 不一致,以连接为准',
configured,
db_config,
resolved,
)
return resolved, True
except Exception as exc:
logger.warning(
'FormDatabaseDesignNode - 无法解析连接 %s: %s,使用配置 db_type=%s',
db_config,
exc,
configured,
)
return configured, False
def _parse_user_edit(self, user_input: Any) -> Optional[Dict[str, Any]]:
if isinstance(user_input, dict):
if 'table' in user_input or 'type' in user_input:
return user_input
return None
if isinstance(user_input, str):
try:
data = json.loads(user_input)
if isinstance(data, dict) and ('table' in data or 'type' in data):
return data
except (json.JSONDecodeError, TypeError):
pass
return None
def _build_result_from_edit(self, edited_data: Dict[str, Any]) -> NodeResult:
table_info = edited_data.get('table', {})
table_name = table_info.get('tableName', '')
field_count = len(table_info.get('fields', []))
return NodeResult(
success=True,
output=edited_data,
output_variables={
'database_design': edited_data,
'table_name': table_name,
'field_count': field_count,
},
)
def _process_table(
self,
table: dict,
table_type: str,
auto_add_system: bool,
db_config: str,
db_database: str = '',
db_database_raw: str = '',
db_schema: str = '',
db_schema_raw: str = '',
db_type: str = 'postgresql',
db_type_resolved: bool = False,
) -> dict:
table_name = table.get('tableName', '')
if not table_name:
raise ValueError(f'{table_type} 表必须包含 tableName 字段')
alias = table.get('alias', table_name)
fields = table.get('fields', [])
if not isinstance(fields, list):
raise ValueError(f'{table_name} 的 fields 必须是数组')
processed_fields = []
existing_field_names = set()
for fld in fields:
if not isinstance(fld, dict):
logger.warning('跳过无效的字段配置: %s', fld)
continue
field_name = fld.get('name', '')
if not field_name:
logger.warning('跳过没有名称的字段: %s', fld)
continue
if field_name in existing_field_names:
logger.warning('跳过重复字段: %s', field_name)
continue
existing_field_names.add(field_name)
processed_fields.append(process_canonical_field(fld))
if auto_add_system:
for sys_field in SYSTEM_FIELDS:
if sys_field['name'] not in existing_field_names:
processed_fields.append(sys_field.copy())
final_schema = db_schema or table.get('schema', '')
final_database = db_database or table.get('database', '')
result = {
'id': str(uuid.uuid4()).replace('-', ''),
'type': table_type,
'tableName': table_name,
'alias': alias,
'fields': processed_fields,
'meta': {
'dbName': db_config,
'database': final_database,
'databaseRaw': db_database_raw,
'schema': final_schema,
'schemaRaw': db_schema_raw,
'dbType': normalize_db_type(db_type),
'dbTypeResolved': db_type_resolved,
},
}
if table_type == 'sub':
result['foreignKey'] = table.get('foreignKey', '')
result['parentTable'] = table.get('parentTable', '')
result['relatedField'] = table.get('relatedField', 'id')
result['relationType'] = table.get('relationType', 'one-to-many')
return result
@classmethod
def get_config_schema(cls) -> Dict[str, Any]:
return {
'type': 'object',
'properties': {
'design_mode': {
'type': 'string',
'title': '设计模式',
'description': '选择设计主表或从表',
'default': 'main',
'enum': ['main', 'sub'],
'x-component': 'RadioGroup',
},
'table_name': {
'type': 'string',
'title': '表名',
'description': '主表名(string类型)',
'x-component': 'SmartInput',
},
'fields': {
'type': 'string',
'title': '字段列表',
'description': '主表字段配置(array类型)',
'x-component': 'Textarea',
},
'sub_table_name': {
'type': 'string',
'title': '从表名',
'description': '从表名(string类型)',
'x-component': 'SmartInput',
},
'sub_fields': {
'type': 'string',
'title': '从表字段列表',
'description': '从表字段配置(array类型)',
'x-component': 'Textarea',
},
'parent_table': {
'type': 'string',
'title': '关联主表',
'description': '从表关联的主表名(string类型)',
'x-component': 'SmartInput',
},
'foreign_key': {
'type': 'string',
'title': '外键字段',
'description': '外键字段名(string类型),不填则自动生成',
'x-component': 'SmartInput',
},
'db_config': {
'type': 'string',
'title': '数据库配置',
'description': '数据库配置名称',
'default': 'default',
'x-component': 'Input',
},
'db_database': {
'type': 'string',
'title': '目标数据库',
'description': 'PostgreSQL/MySQL/SQL Server 的目标库名,为空则使用连接默认库',
'default': '',
'x-component': 'SmartInput',
},
'db_schema': {
'type': 'string',
'title': '数据库 Schema',
'description': 'PostgreSQL schema 名称(如 public、app),为空则使用默认',
'default': '',
'x-component': 'SmartInput',
},
'db_type': {
'type': 'string',
'title': '数据库类型',
'description': '由数据库连接自动解析,一般无需手动修改',
'default': 'postgresql',
'enum': ['postgresql', 'mysql', 'oracle', 'sqlserver'],
'x-component': 'Select',
},
'auto_add_system_fields': {
'type': 'boolean',
'title': '自动添加系统字段',
'description': '是否自动添加系统字段(id, sys_create_datetime等)',
'default': True,
'x-component': 'Switch',
},
'require_confirmation': {
'type': 'boolean',
'title': '需要确认',
'description': '设计完成后是否暂停等待用户确认或编辑',
'default': True,
'x-component': 'Switch',
},
},
'required': ['design_mode'],
}
@@ -0,0 +1,748 @@
"""
列表UI设计节点
根据表单UI设计配置自动生成列表配置
"""
import json
import logging
import re
import uuid
from typing import Any, Dict, List, Optional, Tuple
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
from ..utils.config_utils import resolve_object_config
logger = logging.getLogger(__name__)
@NodeRegistry.register
class FormListDesignNode(BaseNode):
"""
列表UI设计节点
根据表单UI设计配置自动生成列表配置,包括:
- 查询字段配置
- 列表列配置
- 表格属性配置
- 按钮配置
"""
node_type = 'form_list_design'
node_name = '列表UI设计'
node_category = 'form'
node_icon = 'table'
node_description = '根据表单UI设计自动生成列表配置'
# ========== 查询字段推断规则 ==========
# 默认添加为查询字段的字段模式
QUERY_INCLUDE_PATTERNS = [
r'^(name|title|code|no|status|type|category)$',
r'(creator|modifier)_id$',
]
# name 相关字段模式(优先级最高,作为第一个查询字段)
NAME_FIELD_PATTERNS = [
r'^name$',
r'_name$',
r'^title$',
]
# 排除的查询字段模式
QUERY_EXCLUDE_PATTERNS = [
r'^id$',
r'^is_deleted$',
r'^sort$',
r'(remark|note|description|content|address)$',
r'(password|pwd|secret)$',
]
# ========== 列表列推断规则 ==========
# 默认显示的列模式
COLUMN_INCLUDE_PATTERNS = [
r'^(name|title|code|no|status|type|category)$',
r'(amount|price|count|quantity|total)$',
r'sys_create_datetime$',
]
# 排除的列模式
COLUMN_EXCLUDE_PATTERNS = [
r'^id$',
r'^is_deleted$',
r'^sort$',
r'(password|pwd|secret)$',
r'^sys_(creator|modifier|dept)_id$',
]
# 固定在左侧的列模式
FIXED_LEFT_PATTERNS = [
r'^(name|title|code)$',
]
inputs = [
{
'name': 'form_ui_design',
'type': 'object',
'description': '表单UI设计配置(来自表单UI设计节点)',
'required': True,
},
{
'name': 'database_design',
'type': 'object',
'description': '数据库设计配置(可选,用于补充字段信息)',
'required': False,
},
]
outputs = [
{
'name': 'list_config',
'type': 'object',
'description': '完整的列表配置',
},
{
'name': 'query_field_count',
'type': 'number',
'description': '查询字段数量',
},
{
'name': 'column_count',
'type': 'number',
'description': '列数量',
},
]
def execute(self, context: NodeContext) -> NodeResult:
"""执行节点"""
try:
# 检查是否有用户编辑的数据(从设计预览面板提交)
user_input = context.variables.get('__user_input__')
if user_input:
edited_data = self._parse_user_edit(user_input)
if edited_data:
logger.info(f'FormListDesignNode - 使用用户编辑的数据')
context.variables.pop('__user_input__', None)
return self._build_result_from_edit(edited_data)
# 获取配置
form_ui_design = resolve_object_config(
context, self.config.get('form_ui_design', '{}')
)
database_design = resolve_object_config(
context, self.config.get('database_design', '{}')
)
# 列表配置选项
auto_query_fields = self.config.get('auto_query_fields', True)
auto_columns = self.config.get('auto_columns', True)
container_type = self.config.get('container_type', 'drawer')
page_size = self.config.get('page_size', 20)
show_index = self.config.get('show_index', True)
show_selection = self.config.get('show_selection', True)
enable_export = self.config.get('enable_export', True)
enable_import = self.config.get('enable_import', False)
logger.info(f'FormListDesignNode - 开始生成列表配置')
# 解析表单UI设计
if not form_ui_design:
return NodeResult(
success=False,
error='表单UI设计配置解析失败或为空',
)
# 解析数据库设计(可选)
# 获取表单项
form_items = form_ui_design.get('items', [])
if not form_items:
return NodeResult(
success=False,
error='表单项列表为空',
)
# 提取表单字段列表(用于前端列表设计组件)
form_fields = self._extract_form_fields(form_items, database_design)
# 生成查询字段
query_fields = []
if auto_query_fields:
query_fields = self._generate_query_fields(form_items)
# 生成列表列
columns = []
if auto_columns:
columns = self._generate_columns(form_items, database_design)
# 构建列表配置
list_config = {
'queryFields': query_fields,
'columns': columns,
'containerType': container_type,
'table': {
'showPagination': True,
'pageSize': page_size,
'showIndex': show_index,
'showSelection': show_selection,
'stripe': True,
'border': True,
'size': 'default',
'height': 'auto',
'defaultSort': [],
'showSummary': False,
'summaryType': 'sum',
'summaryPrecision': 2,
},
'dialog': {
'width': '800px',
'fullscreen': False,
'draggable': True,
'closeOnClickModal': False,
'closeOnPressEscape': True,
},
'drawer': {
'size': '800px',
'direction': 'rtl',
'withHeader': True,
'closeOnClickModal': False,
'closeOnPressEscape': True,
},
'page': {
'showBackButton': True,
'openInNewTab': True,
},
'buttons': {
'showAdd': True,
'showEdit': True,
'showDelete': True,
'showView': True,
'showExport': enable_export,
'showImport': enable_import,
'showBatchDelete': True,
},
}
query_field_count = len(query_fields)
column_count = len(columns)
logger.info(f'FormListDesignNode - 列表配置生成完成: 查询字段数: {query_field_count}, 列数: {column_count}')
# 检查是否需要确认(支持布尔值、字符串模式和变量引用)
require_confirmation = self.resolve_require_confirmation(context, default=True)
# 构建预览数据(包含 form_fields 供前端列表设计组件使用)
preview_data = {
'type': 'list_config',
'title': '列表UI设计',
'data': list_config,
'form_fields': form_fields,
'editable': True,
} if require_confirmation else None
return NodeResult(
success=True,
output=list_config,
output_variables={
'list_config': list_config,
'form_fields': form_fields,
'query_field_count': query_field_count,
'column_count': column_count,
},
preview=preview_data,
waiting_for_input=require_confirmation,
waiting_config={
'type': 'design_preview',
'preview_type': 'list_config',
'title': '列表UI设计',
'message': '请确认或编辑列表UI设计',
'data': list_config,
'form_fields': form_fields,
} if require_confirmation else {},
)
except Exception as e:
logger.exception(f'列表UI设计节点执行失败: {e}')
return NodeResult(
success=False,
error=f'节点执行失败: {str(e)}',
)
async def execute_async(self, context: NodeContext) -> NodeResult:
"""异步执行节点(直接调用同步方法)"""
return self.execute(context)
def _parse_user_edit(self, user_input: Any) -> Optional[Dict[str, Any]]:
"""解析用户编辑的数据"""
if isinstance(user_input, dict):
if 'columns' in user_input or 'queryFields' in user_input:
return user_input
return None
if isinstance(user_input, str):
try:
data = json.loads(user_input)
if isinstance(data, dict) and ('columns' in data or 'queryFields' in data):
return data
except (json.JSONDecodeError, TypeError):
pass
return None
def _build_result_from_edit(self, edited_data: Dict[str, Any]) -> NodeResult:
"""从用户编辑的数据构建节点结果"""
query_fields = edited_data.get('queryFields', [])
columns = edited_data.get('columns', [])
return NodeResult(
success=True,
output=edited_data,
output_variables={
'list_config': edited_data,
'query_field_count': len(query_fields),
'column_count': len(columns),
},
)
def _parse_config(self, config_str: Any) -> Optional[Dict]:
"""解析配置"""
if isinstance(config_str, dict):
return config_str
if isinstance(config_str, str):
config_str = config_str.strip()
if not config_str or config_str == '{}':
return None
try:
return json.loads(config_str)
except json.JSONDecodeError:
import ast
try:
return ast.literal_eval(config_str)
except (ValueError, SyntaxError):
return None
return None
def _extract_form_fields(self, form_items: List[Dict], database_design: Optional[Dict] = None) -> List[Dict]:
"""
从表单项中提取字段列表,供前端列表设计组件使用
Args:
form_items: 表单项列表
database_design: 数据库设计配置(可选,用于获取字段类型)
Returns:
字段列表,每个字段包含 label, field, component, options, props, dbType 等
"""
fields = []
# 获取数据库字段类型映射
db_field_types = {}
if database_design:
table_data = database_design.get('table', database_design)
db_fields = table_data.get('fields', [])
for db_field in db_fields:
db_field_types[db_field.get('name', '')] = db_field.get('type', '')
def extract_from_items(items: List[Dict]):
"""递归提取字段"""
for item in items:
item_type = item.get('type', '')
# 跳过布局组件,递归处理其子元素
if item_type in ['grid', 'row', 'card', 'tabs', 'collapse', 'divider', 'alert', 'timeline', 'text', 'html', 'spacer', 'title', 'steps']:
if item.get('children'):
extract_from_items(item.get('children', []))
if item.get('columns'):
for col in item.get('columns', []):
if col.get('children'):
extract_from_items(col.get('children', []))
if item.get('items'):
for sub_item in item.get('items', []):
if sub_item.get('children'):
extract_from_items(sub_item.get('children', []))
continue
# 提取有字段名的组件
field_name = item.get('field', '')
if field_name:
db_type = db_field_types.get(field_name, '')
fields.append({
'label': item.get('label', field_name),
'field': field_name,
'component': item_type,
'options': item.get('options', []),
'props': item.get('props', {}),
'dbType': db_type,
'isNumeric': db_type.lower() in ['int', 'integer', 'bigint', 'smallint', 'decimal', 'numeric', 'float', 'double', 'real'],
})
extract_from_items(form_items)
# 添加系统字段
system_fields = [
{'name': 'sys_create_datetime', 'label': '创建时间', 'component': 'date-picker', 'dbType': 'timestamp'},
{'name': 'sys_update_datetime', 'label': '更新时间', 'component': 'date-picker', 'dbType': 'timestamp'},
{'name': 'sys_creator_id', 'label': '创建人', 'component': 'user-select', 'dbType': 'varchar'},
{'name': 'sys_modifier_id', 'label': '修改人', 'component': 'user-select', 'dbType': 'varchar'},
{'name': 'sys_dept_id', 'label': '所属部门', 'component': 'dept-select', 'dbType': 'varchar'},
]
existing_field_names = [f['field'] for f in fields]
for sys_field in system_fields:
if sys_field['name'] not in existing_field_names:
fields.append({
'label': sys_field['label'],
'field': sys_field['name'],
'component': sys_field['component'],
'options': [],
'props': {},
'dbType': sys_field['dbType'],
'isNumeric': False,
'isSystemField': True,
})
return fields
def _generate_query_fields(self, form_items: List[Dict]) -> List[Dict]:
"""生成查询字段配置"""
name_fields = [] # name 相关字段(优先级最高)
other_fields = [] # 其他查询字段
for item in form_items:
field_name = item.get('field', '')
if not field_name:
continue
# 检查是否应该排除
if self._matches_patterns(field_name, self.QUERY_EXCLUDE_PATTERNS):
continue
# 检查是否为 name 相关字段
is_name_field = self._matches_patterns(field_name, self.NAME_FIELD_PATTERNS)
# 检查是否应该包含
should_include = False
if is_name_field:
should_include = True
elif self._matches_patterns(field_name, self.QUERY_INCLUDE_PATTERNS):
should_include = True
else:
# 如果不在包含列表中,检查是否是常用查询字段
component = item.get('type', 'input')
# 选择类组件通常需要查询
if component in ['select', 'radio', 'checkbox', 'user-selector', 'dept-selector', 'date']:
should_include = True
if not should_include:
continue
# 生成查询字段配置
query_field = self._build_query_field(item)
if query_field:
# name 字段放在前面,其他字段放在后面
if is_name_field:
name_fields.append(query_field)
else:
other_fields.append(query_field)
# name 字段排在最前面
return name_fields + other_fields
def _build_query_field(self, item: Dict) -> Optional[Dict]:
"""构建单个查询字段配置"""
field_name = item.get('field', '')
label = item.get('label', field_name)
component = item.get('type', 'input')
props = item.get('props', {})
options = item.get('options', [])
# 推断查询组件类型
query_component = self._infer_query_component(component)
# 推断查询类型
query_type = self._infer_query_type(component, field_name)
# 判断是否为日期时间类型
is_datetime = component in ['date', 'datetime'] or props.get('type') in ['datetime', 'datetimerange']
return {
'label': label,
'field': field_name,
'type': query_type,
'component': query_component,
'originalComponent': component,
'options': options,
'props': props,
'dbType': '',
'width': 6,
'defaultValue': '',
'hidden': False,
'multiple': props.get('multiple', False),
'showTime': is_datetime,
}
def _infer_query_component(self, component: str) -> str:
"""推断查询组件类型"""
# 用户/部门/岗位选择器保持原组件
if component in ['user-selector', 'dept-selector', 'role-selector', 'post-selector']:
return component
# 带选项的组件映射为 select
if component in ['select', 'radio', 'checkbox', 'cascader', 'tree-select']:
return 'select'
# 日期类组件保持日期选择
if component in ['date', 'datetime', 'date-picker', 'time']:
return 'date'
# 其他组件默认使用输入框
return 'input'
def _infer_query_type(self, component: str, field_name: str) -> str:
"""推断查询类型"""
# 用户/部门/岗位选择器使用 in 查询
if component in ['user-selector', 'dept-selector', 'role-selector', 'post-selector']:
return 'in'
# 选项组件默认精确匹配
if component in ['select', 'radio', 'checkbox', 'cascader', 'tree-select']:
return 'eq'
# 日期类组件默认范围查询
if component in ['date', 'datetime', 'date-picker', 'time']:
return 'range'
# 文本类默认模糊匹配
return 'like'
def _generate_columns(self, form_items: List[Dict], database_design: Optional[Dict]) -> List[Dict]:
"""生成列表列配置"""
columns = []
# 获取数据库字段信息(用于补充类型信息)
db_fields = {}
if database_design:
table_info = database_design.get('table', {})
for field in table_info.get('fields', []):
db_fields[field.get('name', '')] = field
for item in form_items:
field_name = item.get('field', '')
if not field_name:
continue
# 检查是否应该排除
if self._matches_patterns(field_name, self.COLUMN_EXCLUDE_PATTERNS):
continue
# 生成列配置
column = self._build_column(item, db_fields.get(field_name, {}))
if column:
columns.append(column)
return columns
def _build_column(self, item: Dict, db_field: Dict) -> Optional[Dict]:
"""构建单个列配置"""
field_name = item.get('field', '')
label = item.get('label', field_name)
component = item.get('type', 'input')
props = item.get('props', {})
options = item.get('options', [])
# 判断是否为数值类型
db_type = db_field.get('type', '')
is_numeric = self._is_numeric_type(db_type)
# 判断是否为选项组件
has_options = component in ['select', 'radio', 'checkbox', 'cascader', 'tree-select']
# 判断是否为关联组件
is_relation = component in ['user-selector', 'dept-selector', 'role-selector', 'post-selector']
# 判断是否固定在左侧
fixed = 'left' if self._matches_patterns(field_name, self.FIXED_LEFT_PATTERNS) else False
# 推断对齐方式
align = 'right' if is_numeric else ('center' if component in ['date', 'datetime', 'switch'] else 'left')
# 判断是否为日期时间类型
is_datetime = db_type.lower() in ['datetime', 'timestamp'] or component in ['datetime']
# 推断过滤类型
filter_type = self._infer_filter_type(component)
# 深拷贝选项并添加 tagType
cloned_options = None
if options:
cloned_options = [
{**opt, 'tagType': opt.get('tagType', '')}
for opt in options
]
return {
'label': label,
'field': field_name,
# 排序配置
'sortable': not self._is_no_sort_component(component),
'sortType': 'backend',
# 过滤配置
'filterable': False,
'filterType': filter_type,
'filterQueryType': 'range',
'filterShowTime': is_datetime,
'filterMultiple': True,
# 列显示配置
'fixed': fixed,
'align': align,
'width': '',
'minWidth': '',
'resizable': True,
'showOverflowTooltip': True,
'ellipsis': True,
'formatter': 'none',
'formatPattern': '',
'prefix': '',
'suffix': '',
# 选项显示
'originalComponent': component,
'options': cloned_options,
'showAsTag': has_options,
# 关联字段
'isRelation': is_relation,
'showDisplayName': is_relation,
'displayField': self._generate_display_field(field_name) if is_relation else '',
# 统计
'dbType': db_type,
'isNumeric': is_numeric,
'summaryEnabled': False,
}
def _infer_filter_type(self, component: str) -> str:
"""推断过滤类型"""
if component in ['date', 'datetime', 'date-picker']:
return 'date-range'
elif component in ['user-selector', 'user-select']:
return 'user-select'
elif component in ['dept-selector', 'dept-select', 'department-selector']:
return 'dept-select'
elif component in ['select', 'radio', 'checkbox', 'cascader', 'tree-select']:
return 'select'
return 'input'
def _is_numeric_type(self, db_type: str) -> bool:
"""判断是否为数值类型"""
if not db_type:
return False
db_type_lower = db_type.lower()
return any(t in db_type_lower for t in ['int', 'decimal', 'numeric', 'float', 'double', 'real', 'money'])
def _is_no_sort_component(self, component: str) -> bool:
"""判断是否为不支持排序的组件类型"""
return component in ['file-selector', 'image-selector', 'rich-text', 'textarea']
def _generate_display_field(self, field_name: str) -> str:
"""生成显示字段名"""
if field_name.endswith('_id'):
return field_name.replace('_id', '_name')
if field_name.endswith('Id'):
return field_name.replace('Id', 'Name')
return f'{field_name}_name'
def _matches_patterns(self, field_name: str, patterns: List[str]) -> bool:
"""检查字段名是否匹配任一模式"""
field_name_lower = field_name.lower()
for pattern in patterns:
if re.search(pattern, field_name_lower):
return True
return False
@classmethod
def get_config_schema(cls) -> Dict[str, Any]:
"""获取节点配置 Schema"""
return {
'type': 'object',
'properties': {
'form_ui_design': {
'type': 'string',
'title': '表单UI设计配置',
'description': '来自表单UI设计节点的输出',
'x-component': 'SmartInput',
},
'database_design': {
'type': 'string',
'title': '数据库设计配置',
'description': '来自数据库设计节点的输出(可选,用于补充字段类型信息)',
'x-component': 'SmartInput',
},
'auto_query_fields': {
'type': 'boolean',
'title': '自动生成查询字段',
'description': '根据表单字段自动生成查询条件',
'default': True,
'x-component': 'Switch',
},
'auto_columns': {
'type': 'boolean',
'title': '自动生成列配置',
'description': '根据表单字段自动生成列表列',
'default': True,
'x-component': 'Switch',
},
'container_type': {
'type': 'string',
'title': '容器类型',
'description': '表单编辑的容器类型',
'default': 'drawer',
'enum': ['drawer', 'dialog', 'page'],
'enumNames': ['抽屉', '弹窗', '页面'],
'x-component': 'Select',
},
'page_size': {
'type': 'number',
'title': '每页条数',
'description': '列表每页显示的数据条数',
'default': 20,
'enum': [10, 20, 50, 100],
'x-component': 'Select',
},
'show_index': {
'type': 'boolean',
'title': '显示序号列',
'description': '是否显示行序号',
'default': True,
'x-component': 'Switch',
},
'show_selection': {
'type': 'boolean',
'title': '显示选择列',
'description': '是否显示多选框列',
'default': True,
'x-component': 'Switch',
},
'enable_export': {
'type': 'boolean',
'title': '启用导出',
'description': '是否显示导出按钮',
'default': True,
'x-component': 'Switch',
},
'enable_import': {
'type': 'boolean',
'title': '启用导入',
'description': '是否显示导入按钮',
'default': False,
'x-component': 'Switch',
},
'require_confirmation': {
'type': 'boolean',
'title': '需要确认',
'description': '设计完成后是否暂停等待用户确认或编辑',
'default': True,
'x-component': 'Switch',
},
},
'required': ['form_ui_design'],
}
@@ -0,0 +1,313 @@
"""
表单发布节点
将已创建的表单发布到菜单系统
"""
import logging
from typing import Any, Dict, Optional
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
logger = logging.getLogger(__name__)
@NodeRegistry.register
class FormPublishNode(BaseNode):
"""
表单发布节点
将已创建的表单发布到菜单系统,生成菜单和权限
"""
node_type = 'form_publish'
node_name = '表单发布'
node_category = 'form'
node_icon = 'send'
node_description = '将已创建的表单发布到菜单系统'
inputs = [
{
'name': 'form_id',
'type': 'string',
'description': '表单ID(来自表单创建节点)',
'required': True,
},
{
'name': 'form_code',
'type': 'string',
'description': '表单编码(可选,用于生成路由)',
'required': False,
},
]
outputs = [
{
'name': 'menu_id',
'type': 'string',
'description': '创建的菜单ID',
},
{
'name': 'route_path',
'type': 'string',
'description': '表单访问路径',
},
{
'name': 'publish_result',
'type': 'object',
'description': '发布结果详情',
},
]
def execute(self, context: NodeContext) -> NodeResult:
"""执行节点(同步方法,通过运行异步方法实现)"""
import asyncio
loop = asyncio.get_event_loop()
if loop.is_running():
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(asyncio.run, self.execute_async(context))
return future.result()
else:
return loop.run_until_complete(self.execute_async(context))
async def execute_async(self, context: NodeContext) -> NodeResult:
"""异步执行节点"""
try:
# 获取输入参数
form_id = context.resolve_template(self.config.get('form_id', ''))
form_code = context.resolve_template(self.config.get('form_code', ''))
require_confirmation = self.resolve_require_confirmation(context, default=True)
logger.info(f'FormPublishNode - 开始发布表单: form_id={form_id}')
# 验证必要参数
if not form_id:
return NodeResult(
success=False,
error='表单ID不能为空',
)
# 检查是否有用户编辑的数据(从设计预览面板提交)
user_input = context.variables.get('__user_input__')
if user_input:
publish_config = self._parse_user_input(user_input)
if publish_config:
logger.info(f'FormPublishNode - 使用用户编辑的数据: {publish_config}')
# 清除用户输入,防止重复处理
context.variables.pop('__user_input__', None)
return await self._do_publish(context, form_id, publish_config)
# 如果需要确认,返回等待用户输入
if require_confirmation:
return await self._wait_for_confirmation(form_id)
# 不需要确认,直接发布(使用默认配置)
return await self._do_publish(context, form_id, {})
except Exception as e:
logger.exception(f'表单发布节点执行失败: {e}')
return NodeResult(
success=False,
error=f'节点执行失败: {str(e)}',
)
def _parse_user_input(self, user_input: Any) -> Optional[Dict]:
"""解析用户输入"""
import json
if isinstance(user_input, dict):
# 检查是否是 publishData 格式
if 'publishData' in user_input:
return user_input.get('publishData', {})
return user_input
if isinstance(user_input, str):
try:
data = json.loads(user_input)
if isinstance(data, dict):
if 'publishData' in data:
return data.get('publishData', {})
return data
except json.JSONDecodeError:
pass
return None
async def _wait_for_confirmation(self, form_id: str) -> NodeResult:
"""等待用户确认"""
from app.database import AsyncSessionLocal
from online_dev.form_manager.service import FormService
async with AsyncSessionLocal() as db:
form = await FormService.get(db, form_id)
if not form:
return NodeResult(
success=False,
error=f'表单不存在: {form_id}',
)
# 构建预览数据
preview_data = {
'type': 'form_publish',
'data': {
'publishData': {
'menu_name': form.name,
'menu_parent_id': None,
'menu_icon': 'lucide:file-text',
'menu_order': 1,
},
},
}
# 返回等待用户输入的结果
return NodeResult(
success=True,
waiting_for_input=True,
waiting_config={
'type': 'design_preview',
'preview_type': 'form_publish',
'title': '发布表单',
'message': '请确认或编辑发布配置',
'data': {
'menu_name': form.name,
'menu_parent_id': None,
'menu_icon': 'lucide:file-text',
'menu_order': 1,
},
},
preview=preview_data,
)
async def _do_publish(self, context: NodeContext, form_id: str, publish_config: Dict) -> NodeResult:
"""执行发布"""
from app.database import AsyncSessionLocal
from online_dev.form_manager.service import FormService, FormServiceException
async with AsyncSessionLocal() as db:
try:
# 获取表单
form = await FormService.get(db, form_id)
if not form:
return NodeResult(
success=False,
error=f'表单不存在: {form_id}',
)
# 构建发布配置
menu_name = publish_config.get('menu_name', form.name)
menu_parent_id = publish_config.get('menu_parent_id')
menu_icon = publish_config.get('menu_icon', 'lucide:file-text')
menu_order = publish_config.get('menu_order', 1)
publish_data = {
'menu_name': menu_name,
'menu_parent_id': menu_parent_id if menu_parent_id else None,
'menu_icon': menu_icon,
'menu_order': menu_order,
'allow_add': True,
'allow_edit': True,
'allow_delete': True,
'allow_export': True,
'allow_import': False,
}
# 构建路由路径
route_path = f'/form-render/{form.code}'
# 检查表单是否已发布
if form.status == 'published':
# 表单已发布,更新菜单配置
logger.info(f'FormPublishNode - 表单已发布,更新菜单配置: form_id={form_id}')
await self._update_menu_config(db, form, publish_data)
else:
# 发布表单
form = await FormService.publish(db, form_id, publish_data)
# 构建输出
publish_result = {
'form_id': form.id,
'form_code': form.code,
'form_name': form.name,
'status': form.status,
'menu_name': menu_name,
'menu_icon': menu_icon,
'route_path': route_path,
}
logger.info(f'FormPublishNode - 表单发布成功: form_id={form_id}, route_path={route_path}')
return NodeResult(
success=True,
output=publish_result,
output_variables={
'menu_id': form.menu_id if hasattr(form, 'menu_id') else '',
'route_path': route_path,
'publish_result': publish_result,
},
)
except FormServiceException as e:
logger.error(f'表单服务异常: {e}')
return NodeResult(
success=False,
error=f'表单发布失败: {str(e)}',
)
except Exception as e:
logger.exception(f'表单发布异常: {e}')
return NodeResult(
success=False,
error=f'表单发布异常: {str(e)}',
)
async def _update_menu_config(self, db, form, publish_data: Dict):
"""更新已发布表单的菜单配置"""
from sqlalchemy import select
from core.menu.model import Menu
# 查找表单对应的菜单
menu_stmt = select(Menu).where(
Menu.path == f"/form-render/{form.code}"
)
menu_result = await db.execute(menu_stmt)
existing_menu = menu_result.scalar_one_or_none()
if existing_menu:
# 更新菜单配置
existing_menu.name = publish_data.get('menu_name', form.name)
existing_menu.title = publish_data.get('menu_name', form.name)
existing_menu.parent_id = publish_data.get('menu_parent_id')
existing_menu.icon = publish_data.get('menu_icon', 'lucide:file-text')
existing_menu.order = publish_data.get('menu_order', 0)
await db.commit()
logger.info(f'更新表单菜单配置: {form.code}')
@classmethod
def get_config_schema(cls) -> Dict[str, Any]:
"""获取节点配置 Schema"""
return {
'type': 'object',
'properties': {
'form_id': {
'type': 'string',
'title': '表单ID',
'description': '来自表单创建节点的输出',
'x-component': 'SmartInput',
},
'form_code': {
'type': 'string',
'title': '表单编码',
'description': '用于生成访问路由(可选)',
'x-component': 'SmartInput',
},
'require_confirmation': {
'type': 'boolean',
'title': '需要确认',
'description': '发布前是否需要用户确认配置',
'default': True,
'x-component': 'Switch',
},
},
'required': ['form_id'],
}
@@ -0,0 +1,847 @@
"""
表单UI设计节点
根据数据库表结构自动生成表单UI配置
"""
import json
import logging
import re
import uuid
from typing import Any, Dict, List, Optional, Tuple
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
from ..utils.config_utils import resolve_object_config
logger = logging.getLogger(__name__)
@NodeRegistry.register
class FormUIDesignNode(BaseNode):
"""
表单UI设计节点
根据数据库表结构自动生成表单UI配置,包括:
- 字段到组件的映射
- 验证规则推断
- 组件属性配置
- 字段分组
- 布局配置
"""
node_type = 'form_ui_design'
node_name = '表单UI设计'
node_category = 'form'
node_icon = 'layout'
node_description = '根据数据库表结构自动生成表单UI配置'
# ========== 字段类型到组件的映射规则 ==========
FIELD_TYPE_MAPPING = {
# 字符串类型
'varchar': {
'default': 'input',
'long_text_threshold': 200, # 超过此长度使用 textarea
},
'char': {'default': 'input'},
'text': {'default': 'textarea'},
# 数字类型
'int': {'default': 'input-number'},
'integer': {'default': 'input-number'},
'bigint': {'default': 'input-number'},
'smallint': {'default': 'input-number'},
# 小数类型
'decimal': {'default': 'input-number'},
'numeric': {'default': 'input-number'},
'float': {'default': 'input-number'},
'double': {'default': 'input-number'},
# 日期时间类型
'datetime': {'default': 'date', 'props': {'type': 'datetime', 'format': 'YYYY-MM-DD HH:mm:ss'}},
'timestamp': {'default': 'date', 'props': {'type': 'datetime', 'format': 'YYYY-MM-DD HH:mm:ss'}},
'date': {'default': 'date', 'props': {'type': 'date', 'format': 'YYYY-MM-DD'}},
'time': {'default': 'time', 'props': {'format': 'HH:mm:ss'}},
# 布尔类型
'boolean': {'default': 'switch'},
'bool': {'default': 'switch'},
# JSON类型
'json': {'default': 'textarea', 'props': {'rows': 6}},
'jsonb': {'default': 'textarea', 'props': {'rows': 6}},
}
# ========== 字段名模式匹配规则 ==========
FIELD_NAME_PATTERNS = [
# 邮箱
(r'(email|mail)', 'input', {'type': 'email'}),
# 手机号
(r'(phone|mobile|tel)', 'input', {'type': 'tel'}),
# URL
(r'(url|link|website)', 'input', {'type': 'url'}),
# 密码
(r'(password|pwd)', 'input', {'type': 'password', 'show-password': True}),
# 颜色
(r'(color|colour)$', 'color', {}),
# 编码生成器
(r'(code|no|number)$', 'code-generator', {}),
# 长文本
(r'(remark|note|memo)', 'textarea', {'rows': 4}),
(r'(desc|description)', 'textarea', {'rows': 4}),
(r'(content)', 'textarea', {'rows': 6}),
(r'(address|addr)', 'textarea', {'rows': 3}),
# 富文本
(r'(html|rich_text|richtext)', 'rich-text', {}),
# 用户选择器
(r'(user|creator|modifier|operator)_id$', 'user-selector', {}),
# 部门选择器
(r'(dept|department)_id$', 'dept-selector', {}),
# 角色选择器
(r'(role)_id$', 'role-selector', {}),
# 岗位选择器
(r'(post|position)_id$', 'post-selector', {}),
# 通用外键 - 下拉选择
(r'_id$', 'select', {'filterable': True}),
# 评分
(r'(rate|rating|score)$', 'rate', {'max': 5}),
# 百分比
(r'(percent|percentage)$', 'slider', {'min': 0, 'max': 100}),
# 排序
(r'(sort|order|seq)$', 'input-number', {'min': 0, 'step': 1}),
# 年龄
(r'(age)$', 'input-number', {'min': 0, 'max': 150}),
# 金额
(r'(amount|price|money|fee|cost|salary|wage|budget)', 'money-input', {}),
# 图片
(r'(image|photo|picture|avatar|logo)', 'image-selector', {}),
# 文件
(r'(file|attachment|document)', 'file-selector', {}),
# Cron表达式
(r'(cron)', 'cron-selector', {}),
]
# ========== 字段分组规则 ==========
FIELD_GROUP_RULES = {
'basic_info': {
'label': '基本信息',
'patterns': [r'^(name|title|code|no|type|category|status)'],
'priority': 1,
'collapsed': False,
},
'detail_info': {
'label': '详细信息',
'patterns': [r'(desc|description|remark|note|content|detail)'],
'priority': 2,
'collapsed': False,
},
'contact_info': {
'label': '联系方式',
'patterns': [r'(phone|mobile|tel|email|address|contact|wechat|qq)'],
'priority': 3,
'collapsed': False,
},
'financial_info': {
'label': '金额信息',
'patterns': [r'(amount|price|money|fee|cost|salary|wage|budget)'],
'priority': 4,
'collapsed': False,
},
'time_info': {
'label': '时间信息',
'patterns': [r'(date|time|datetime|start|end|begin|finish|deadline)'],
'priority': 5,
'collapsed': False,
},
'attachment_info': {
'label': '附件信息',
'patterns': [r'(file|image|photo|picture|attachment|document)'],
'priority': 6,
'collapsed': False,
},
'system_info': {
'label': '系统信息',
'patterns': [r'^sys_', r'^is_', r'(creator|modifier|create_time|update_time)'],
'priority': 99,
'collapsed': True,
},
}
# 系统字段(默认隐藏)
SYSTEM_FIELDS = [
'id', 'sys_create_datetime', 'sys_update_datetime',
'sys_creator_id', 'sys_modifier_id', 'sys_dept_id',
'is_deleted', 'sort'
]
inputs = [
{
'name': 'database_design',
'type': 'object',
'description': '数据库设计配置(来自数据库设计节点)',
'required': True,
},
{
'name': 'form_name',
'type': 'string',
'description': '表单名称',
'required': False,
},
{
'name': 'form_code',
'type': 'string',
'description': '表单编码',
'required': False,
},
]
outputs = [
{
'name': 'form_ui_design',
'type': 'object',
'description': '表单UI设计配置',
},
{
'name': 'form_code',
'type': 'string',
'description': '表单编码',
},
{
'name': 'field_count',
'type': 'number',
'description': '字段数量',
},
{
'name': 'group_count',
'type': 'number',
'description': '分组数量',
},
]
def execute(self, context: NodeContext) -> NodeResult:
"""执行节点"""
try:
# 检查是否有用户编辑的数据(从设计预览面板提交)
user_input = context.variables.get('__user_input__')
if user_input:
edited_data = self._parse_user_edit(user_input)
if edited_data:
logger.info(f'FormUIDesignNode - 使用用户编辑的数据')
context.variables.pop('__user_input__', None)
return self._build_result_from_edit(edited_data)
# 获取配置
database_design = resolve_object_config(
context, self.config.get('database_design', '{}')
)
form_name = context.resolve_template(self.config.get('form_name', ''))
form_code = context.resolve_template(self.config.get('form_code', ''))
# 布局配置
layout_mode = self.config.get('layout_mode', 'auto')
label_width = self.config.get('label_width', 120)
enable_grouping = self.config.get('enable_grouping', True)
hide_system_fields = self.config.get('hide_system_fields', True)
logger.info(f'FormUIDesignNode - 开始生成表单UI配置')
# 解析数据库设计
if not database_design:
return NodeResult(
success=False,
error='数据库设计配置解析失败或为空',
)
# 获取表信息
table_info = database_design.get('table', {})
table_name = table_info.get('tableName', '')
fields = table_info.get('fields', [])
if not table_name:
return NodeResult(
success=False,
error='表名不能为空',
)
if not fields:
return NodeResult(
success=False,
error='字段列表不能为空',
)
# 使用表名作为默认表单编码
if not form_code:
form_code = table_name
if not form_name:
form_name = table_info.get('alias', table_name)
# 生成表单项
form_items = []
for field in fields:
field_name = field.get('name', '')
# 跳过系统字段
if hide_system_fields and field_name in self.SYSTEM_FIELDS:
continue
# 生成表单项配置
form_item = self._generate_form_item(field)
if form_item:
form_items.append(form_item)
# 计算布局 - 默认使用双列布局
col_span = self._calculate_col_span(len(form_items), layout_mode)
for item in form_items:
if 'colSpan' not in item or item.get('colSpan') == 12:
# 只更新默认值,保留特殊组件的全宽设置
if item.get('type') not in ['textarea', 'rich-text', 'sub-table']:
item['colSpan'] = col_span
# 将所有字段包裹在栅格布局容器中(默认两列)
grid_layout_item = {
'id': str(uuid.uuid4()).replace('-', ''),
'type': 'grid-layout',
'label': '',
'props': {
'columns': 2, # 默认两列
'gap': 16,
},
'children': form_items,
'colSpan': 24, # 栅格容器占满整行
}
# 用栅格容器替换原有的扁平列表
wrapped_items = [grid_layout_item]
# 分组处理
if enable_grouping:
grouped_items = self._group_fields(form_items)
else:
grouped_items = [{
'title': '',
'collapsed': False,
'fields': form_items,
}]
# 构建表单UI配置
form_ui_design = {
'formCode': form_code,
'formName': form_name,
'tableName': table_name,
'layout': {
'mode': layout_mode,
'labelWidth': label_width,
'labelPosition': 'right',
'size': 'default',
'colSpan': col_span,
},
'groups': grouped_items,
'items': wrapped_items, # 使用包裹了栅格布局的表单项
'tableConfigs': [], # 表单设计器需要的表配置
'dbConfig': database_design.get('dbConfig', 'default'),
}
field_count = len(form_items)
group_count = len([g for g in grouped_items if g.get('fields')])
logger.info(f'FormUIDesignNode - 表单UI配置生成完成: {form_code}, 字段数: {field_count}, 分组数: {group_count}')
# 构建 table_configs(供前端表单设计组件使用)
table_configs = [{
'id': 'main-table',
'type': 'main',
'tableName': table_name,
'alias': table_info.get('alias', table_name),
'fields': fields,
'meta': {
'schema': table_info.get('schema', 'public'),
'database': table_info.get('database', ''),
},
}]
# 检查是否需要确认(支持布尔值、字符串模式和变量引用)
require_confirmation = self.resolve_require_confirmation(context, default=True)
# 构建预览数据(包含 table_configs 供前端表单设计组件使用)
preview_data = {
'type': 'form_ui_design',
'title': '表单UI设计',
'data': form_ui_design,
'table_configs': table_configs,
'editable': True,
} if require_confirmation else None
return NodeResult(
success=True,
output=form_ui_design,
output_variables={
'form_ui_design': form_ui_design,
'form_code': form_code,
'field_count': field_count,
'group_count': group_count,
'table_configs': table_configs,
},
preview=preview_data,
waiting_for_input=require_confirmation,
waiting_config={
'type': 'design_preview',
'preview_type': 'form_ui_design',
'title': '表单UI设计',
'message': '请确认或编辑表单UI设计',
'data': form_ui_design,
'table_configs': table_configs,
} if require_confirmation else {},
)
except Exception as e:
logger.exception(f'表单UI设计节点执行失败: {e}')
return NodeResult(
success=False,
error=f'节点执行失败: {str(e)}',
)
async def execute_async(self, context: NodeContext) -> NodeResult:
"""异步执行节点(直接调用同步方法)"""
return self.execute(context)
def _parse_user_edit(self, user_input: Any) -> Optional[Dict[str, Any]]:
"""解析用户编辑的数据"""
if isinstance(user_input, dict):
if 'items' in user_input or 'groups' in user_input:
return user_input
return None
if isinstance(user_input, str):
try:
data = json.loads(user_input)
if isinstance(data, dict) and ('items' in data or 'groups' in data):
return data
except (json.JSONDecodeError, TypeError):
pass
return None
def _build_result_from_edit(self, edited_data: Dict[str, Any]) -> NodeResult:
"""从用户编辑的数据构建节点结果"""
form_code = edited_data.get('formCode', '')
items = edited_data.get('items', [])
groups = edited_data.get('groups', [])
return NodeResult(
success=True,
output=edited_data,
output_variables={
'form_ui_design': edited_data,
'form_code': form_code,
'field_count': len(items),
'group_count': len([g for g in groups if g.get('fields')]),
},
)
def _parse_database_design(self, design_str: Any) -> Optional[Dict]:
"""解析数据库设计配置"""
if isinstance(design_str, dict):
return design_str
if isinstance(design_str, str):
design_str = design_str.strip()
if not design_str or design_str == '{}':
return None
try:
return json.loads(design_str)
except json.JSONDecodeError:
import ast
try:
return ast.literal_eval(design_str)
except (ValueError, SyntaxError):
return None
return None
def _generate_form_item(self, field: Dict) -> Optional[Dict]:
"""生成单个表单项配置"""
field_name = field.get('name', '')
field_type = field.get('type', 'varchar').lower()
comment = field.get('comment', field_name)
nullable = field.get('nullable', True)
max_length = field.get('maxLength')
if not field_name:
return None
# 确定组件类型和属性
component, props = self._infer_component(field_name, field_type, field)
# 生成验证规则
rules = self._infer_validation_rules(field, component)
# 生成布局配置
layout = self._infer_layout(field_name, component)
# 构建表单项
form_item = {
'id': str(uuid.uuid4()).replace('-', ''),
'type': component,
'field': field_name,
'label': comment,
'props': props,
'rules': rules,
'colSpan': layout.get('colSpan', 12),
'order': layout.get('order', 0),
}
# 添加选项配置(如果需要)
if component in ['select', 'radio', 'checkbox']:
form_item['options'] = []
form_item['dataSource'] = self._infer_data_source(field_name)
return form_item
def _infer_component(self, field_name: str, field_type: str, field: Dict) -> Tuple[str, Dict]:
"""推断组件类型和属性"""
props = {}
component = 'input' # 默认组件
# 1. 先根据字段名模式匹配
field_name_lower = field_name.lower()
for pattern, comp, pattern_props in self.FIELD_NAME_PATTERNS:
if re.search(pattern, field_name_lower):
component = comp
props.update(pattern_props)
break
else:
# 2. 如果没有匹配到模式,根据字段类型映射
type_config = self.FIELD_TYPE_MAPPING.get(field_type, {'default': 'input'})
component = type_config.get('default', 'input')
# 添加类型默认属性
if 'props' in type_config:
props.update(type_config['props'])
# 特殊处理:varchar 长度判断
if field_type == 'varchar':
max_length = field.get('maxLength', 255)
# 确保 max_length 是整数
try:
max_length = int(max_length) if max_length else 255
except (ValueError, TypeError):
max_length = 255
threshold = type_config.get('long_text_threshold', 200)
if max_length > threshold:
component = 'textarea'
props['rows'] = 4
# 3. 添加通用属性
props = self._add_common_props(props, field, component)
return component, props
def _add_common_props(self, props: Dict, field: Dict, component: str) -> Dict:
"""添加通用组件属性"""
field_name = field.get('name', '')
comment = field.get('comment', field_name)
max_length = field.get('maxLength')
# 确保 max_length 是整数
if max_length:
try:
max_length = int(max_length)
except (ValueError, TypeError):
max_length = None
# 占位符
if component in ['input', 'textarea']:
props.setdefault('placeholder', f'请输入{comment}')
elif component in ['select', 'cascader', 'tree-select', 'user-selector', 'dept-selector', 'role-selector', 'post-selector']:
props.setdefault('placeholder', f'请选择{comment}')
elif component == 'date':
props.setdefault('placeholder', f'请选择{comment}')
# 可清空
props.setdefault('clearable', True)
# 字符串长度限制
if max_length and component in ['input', 'textarea']:
props['maxlength'] = max_length
props['show-word-limit'] = True
# textarea 行数
if component == 'textarea':
props.setdefault('rows', 4)
# 数字输入框
if component == 'input-number':
props.setdefault('controls-position', 'right')
# 小数精度
if field.get('type') in ['decimal', 'numeric', 'float', 'double']:
scale = field.get('scale', 2)
props.setdefault('precision', scale)
props.setdefault('step', 10 ** (-scale))
else:
props.setdefault('step', 1)
# 下拉选择
if component == 'select':
props.setdefault('filterable', True)
# 开关
if component == 'switch':
props.setdefault('active-text', '')
props.setdefault('inactive-text', '')
return props
def _infer_validation_rules(self, field: Dict, component: str) -> List[Dict]:
"""推断验证规则"""
rules = []
field_name = field.get('name', '').lower()
field_type = field.get('type', '')
nullable = field.get('nullable', True)
max_length = field.get('maxLength')
# 确保 max_length 是整数
if max_length:
try:
max_length = int(max_length)
except (ValueError, TypeError):
max_length = None
comment = field.get('comment', field_name)
# 必填规则
if not nullable:
rules.append({
'required': True,
'message': f'{comment}不能为空',
'trigger': 'blur' if component in ['input', 'textarea'] else 'change',
})
# 长度规则
if max_length and field_type in ['varchar', 'char']:
rules.append({
'max': max_length,
'message': f'最多{max_length}个字符',
'trigger': 'blur',
})
# 格式规则
if 'email' in field_name or 'mail' in field_name:
rules.append({
'type': 'email',
'message': '请输入正确的邮箱格式',
'trigger': 'blur',
})
if 'phone' in field_name or 'mobile' in field_name:
rules.append({
'pattern': r'^1[3-9]\d{9}$',
'message': '请输入正确的手机号码',
'trigger': 'blur',
})
if 'url' in field_name or 'link' in field_name:
rules.append({
'type': 'url',
'message': '请输入正确的URL格式',
'trigger': 'blur',
})
return rules
def _infer_layout(self, field_name: str, component: str) -> Dict:
"""推断布局配置"""
layout = {
'colSpan': 12, # 默认占一半宽度
'order': 0,
}
field_name_lower = field_name.lower()
# 全宽组件
if component in ['textarea', 'rich-text', 'sub-table', 'divider']:
layout['colSpan'] = 24
# 小组件
elif component in ['switch', 'rate']:
layout['colSpan'] = 8
# 根据字段名调整
if any(kw in field_name_lower for kw in ['remark', 'note', 'description', 'content', 'address']):
layout['colSpan'] = 24
return layout
def _calculate_col_span(self, field_count: int, layout_mode: str) -> int:
"""
根据字段数量计算列宽
规则:
- 字段数 <= 6: 单列布局 (colSpan=24)
- 字段数 7-12: 双列布局 (colSpan=12)
- 字段数 > 12: 三列布局 (colSpan=8)
- 最多三列
"""
if layout_mode == 'single':
return 24
elif layout_mode == 'double':
return 12
elif layout_mode == 'triple':
return 8
else: # auto
if field_count <= 6:
return 24 # 单列
elif field_count <= 12:
return 12 # 双列
else:
return 8 # 三列(最多)
def _infer_data_source(self, field_name: str) -> Optional[Dict]:
"""推断数据来源配置"""
field_name_lower = field_name.lower()
# 外键字段
if field_name_lower.endswith('_id'):
table_name = field_name_lower[:-3]
# 特殊业务字段
if 'user' in table_name:
return {
'type': 'api',
'apiUrl': '/api/v1/system/users',
'labelField': 'name',
'valueField': 'id',
}
elif 'dept' in table_name or 'department' in table_name:
return {
'type': 'api',
'apiUrl': '/api/v1/system/departments',
'labelField': 'name',
'valueField': 'id',
}
elif 'role' in table_name:
return {
'type': 'api',
'apiUrl': '/api/v1/system/roles',
'labelField': 'name',
'valueField': 'id',
}
# 枚举类型字段
if 'status' in field_name_lower or 'type' in field_name_lower or 'category' in field_name_lower:
return {
'type': 'dict',
'dictCode': f'{field_name_lower}_dict',
}
return None
def _group_fields(self, form_items: List[Dict]) -> List[Dict]:
"""将表单项分组"""
groups = {}
ungrouped = []
for item in form_items:
field_name = item.get('field', '').lower()
group_key = self._match_group(field_name)
if group_key:
if group_key not in groups:
group_config = self.FIELD_GROUP_RULES.get(group_key, {})
groups[group_key] = {
'key': group_key,
'title': group_config.get('label', group_key),
'priority': group_config.get('priority', 50),
'collapsed': group_config.get('collapsed', False),
'fields': [],
}
groups[group_key]['fields'].append(item)
else:
ungrouped.append(item)
# 将未分组的字段放入"其他信息"组
if ungrouped:
groups['other_info'] = {
'key': 'other_info',
'title': '其他信息',
'priority': 50,
'collapsed': False,
'fields': ungrouped,
}
# 按优先级排序
sorted_groups = sorted(groups.values(), key=lambda g: g.get('priority', 50))
# 移除空组
return [g for g in sorted_groups if g.get('fields')]
def _match_group(self, field_name: str) -> Optional[str]:
"""匹配字段所属分组"""
for group_key, group_config in self.FIELD_GROUP_RULES.items():
patterns = group_config.get('patterns', [])
for pattern in patterns:
if re.search(pattern, field_name):
return group_key
return None
@classmethod
def get_config_schema(cls) -> Dict[str, Any]:
"""获取节点配置 Schema"""
return {
'type': 'object',
'properties': {
'database_design': {
'type': 'string',
'title': '数据库设计配置',
'description': '来自数据库设计节点的输出',
'x-component': 'SmartInput',
},
'form_name': {
'type': 'string',
'title': '表单名称',
'description': '表单显示名称,不填则使用表别名',
'x-component': 'SmartInput',
},
'form_code': {
'type': 'string',
'title': '表单编码',
'description': '表单唯一编码,不填则使用表名',
'x-component': 'SmartInput',
},
'layout_mode': {
'type': 'string',
'title': '布局模式',
'description': '表单布局模式',
'default': 'auto',
'enum': ['auto', 'single', 'double', 'triple'],
'enumNames': ['自动', '单列', '双列', '三列'],
'x-component': 'Select',
},
'label_width': {
'type': 'number',
'title': '标签宽度',
'description': '表单标签宽度(像素)',
'default': 120,
'x-component': 'InputNumber',
},
'enable_grouping': {
'type': 'boolean',
'title': '启用分组',
'description': '是否按字段类型自动分组',
'default': True,
'x-component': 'Switch',
},
'hide_system_fields': {
'type': 'boolean',
'title': '隐藏系统字段',
'description': '是否隐藏系统字段(id, sys_*等)',
'default': True,
'x-component': 'Switch',
},
'require_confirmation': {
'type': 'boolean',
'title': '需要确认',
'description': '设计完成后是否暂停等待用户确认或编辑',
'default': True,
'x-component': 'Switch',
},
},
'required': ['database_design'],
}
@@ -0,0 +1,501 @@
"""
系统总结节点
收集和展示AI创建的完整系统信息,包括应用、表单模块和仪表盘
"""
import ast
import json
import logging
from datetime import datetime
from typing import Any, Dict, List, Optional
from ..base import BaseNode, NodeContext, NodeResult
from ..registry import NodeRegistry
logger = logging.getLogger(__name__)
@NodeRegistry.register
class SystemSummaryNode(BaseNode):
"""
系统总结节点
收集和展示AI创建的完整系统信息,支持以下场景:
1. 完整应用创建:应用 + 多个表单模块 + 仪表盘
2. 子应用内创建模块:仅多个表单模块
3. 单个表单创建:单个表单模块
"""
node_type = 'system_summary'
node_name = '系统总结'
node_category = 'application'
node_icon = 'clipboard-check'
node_description = '收集和展示AI创建的完整系统信息'
inputs = [
{
'name': 'app_info',
'type': 'object',
'description': '应用信息(来自AppCreate节点,可选)',
'required': False,
},
{
'name': 'form_results',
'type': 'array',
'description': '表单结果列表(来自循环节点或单个表单发布节点)',
'required': True,
},
{
'name': 'dashboard_info',
'type': 'object',
'description': '仪表盘信息(来自DashboardPublish节点,可选)',
'required': False,
},
{
'name': 'summary_title',
'type': 'string',
'description': '总结标题',
'required': False,
},
]
outputs = [
{
'name': 'summary',
'type': 'object',
'description': '完整的总结数据',
},
{
'name': 'total_forms',
'type': 'number',
'description': '表单总数',
},
{
'name': 'has_app',
'type': 'boolean',
'description': '是否包含应用',
},
{
'name': 'has_dashboard',
'type': 'boolean',
'description': '是否包含仪表盘',
},
]
def execute(self, context: NodeContext) -> NodeResult:
"""执行节点"""
try:
# 检查是否有用户确认(点击完成按钮)
user_input = context.variables.get('__user_input__')
if user_input:
# 用户已确认,直接返回成功
logger.info('SystemSummaryNode - 用户已确认,完成展示')
context.variables.pop('__user_input__', None)
# 从用户输入中获取之前构建的总结数据
summary_data = self._parse_user_input(user_input)
if summary_data:
return self._build_final_result(summary_data)
# 获取输入参数
app_info_raw = self.config.get('app_info', '')
form_results_raw = self.config.get('form_results', '')
dashboard_info_raw = self.config.get('dashboard_info', '')
summary_title = context.resolve_template(self.config.get('summary_title', ''))
show_statistics = self.config.get('show_statistics', True)
base_url = context.resolve_template(self.config.get('base_url', ''))
# 解析变量引用
app_info = self._resolve_variable_value(context, app_info_raw)
form_results = self._resolve_variable_value(context, form_results_raw)
dashboard_info = self._resolve_variable_value(context, dashboard_info_raw)
logger.info(f'SystemSummaryNode - 开始构建系统总结')
logger.info(f'SystemSummaryNode - app_info: {bool(app_info)}, form_results: {type(form_results)}, dashboard_info: {bool(dashboard_info)}')
# 处理表单结果
forms = self._process_form_results(form_results)
# 处理应用信息
app = self._process_app_info(app_info) if app_info else None
# 处理仪表盘信息
dashboard = self._process_dashboard_info(dashboard_info) if dashboard_info else None
# 构建总结数据
summary = self._build_summary(
title=summary_title or '系统创建完成',
app=app,
forms=forms,
dashboard=dashboard,
show_statistics=show_statistics,
base_url=base_url,
)
logger.info(f'SystemSummaryNode - 总结构建完成: {len(forms)} 个表单')
# 返回等待用户确认的结果
return NodeResult(
success=True,
output=summary,
output_variables={
'summary': summary,
'total_forms': len(forms),
'has_app': app is not None,
'has_dashboard': dashboard is not None,
},
waiting_for_input=True,
waiting_config={
'type': 'design_preview',
'preview_type': 'system_summary',
'title': summary.get('title', '系统创建完成'),
'data': summary,
'editable': False,
},
)
except Exception as e:
logger.exception(f'SystemSummaryNode - 执行失败: {e}')
return NodeResult(
success=False,
error=f'系统总结节点执行失败: {str(e)}',
)
def _parse_user_input(self, user_input: Any) -> Optional[Dict[str, Any]]:
"""解析用户输入"""
if isinstance(user_input, dict):
return user_input
if isinstance(user_input, str):
try:
return json.loads(user_input)
except (json.JSONDecodeError, TypeError):
pass
return None
def _resolve_variable_value(self, context: NodeContext, value: Any) -> Any:
"""解析变量引用,保持原始类型"""
import re
# 如果已经是字典或列表,直接返回
if isinstance(value, (dict, list)):
return value
# 如果不是字符串,返回原值
if not isinstance(value, str):
return value
value = value.strip()
if not value or value in ['{}', '[]', '']:
return None
# 检查是否是单个变量引用 {{node_id.variable_name}}
single_var_pattern = r'^\{\{([^}]+)\}\}$'
match = re.match(single_var_pattern, value)
if match:
var_ref = match.group(1).strip()
# 解析 node_id.variable_name 格式
if '.' in var_ref:
parts = var_ref.split('.', 1)
node_id = parts[0]
key = parts[1]
# 从节点输出命名空间获取
node_outputs = context.variables.get(f'_node_{node_id}')
if isinstance(node_outputs, dict) and key in node_outputs:
return node_outputs[key]
# 直接从变量中获取
if key in context.variables:
return context.variables[key]
logger.warning(f'变量引用未找到: {var_ref}')
else:
# 直接变量引用
if var_ref in context.variables:
return context.variables[var_ref]
logger.warning(f'变量引用未找到: {var_ref}')
# 不是变量引用,尝试解析为 JSON
try:
return json.loads(value)
except json.JSONDecodeError:
pass
# 尝试 Python literal_eval
try:
result = ast.literal_eval(value)
return result
except (ValueError, SyntaxError):
pass
return value
def _process_form_results(self, form_results: Any) -> List[Dict[str, Any]]:
"""处理表单结果列表"""
forms = []
if not form_results:
return forms
# 如果是单个表单结果(字典),包装成列表
if isinstance(form_results, dict):
form_results = [form_results]
if not isinstance(form_results, list):
logger.warning(f'form_results 类型不正确: {type(form_results)}')
return forms
for i, result in enumerate(form_results):
if not isinstance(result, dict):
continue
# 尝试从不同的数据结构中提取表单信息
form_info = self._extract_form_info(result, i)
if form_info:
forms.append(form_info)
return forms
def _extract_form_info(self, result: Dict[str, Any], index: int) -> Optional[Dict[str, Any]]:
"""从结果中提取表单信息"""
# 直接是表单发布节点的输出
if 'form_id' in result or 'menu_id' in result:
return {
'id': result.get('form_id', ''),
'name': result.get('form_name', result.get('menu_name', f'表单{index + 1}')),
'code': result.get('form_code', ''),
'description': result.get('description', ''),
'link': result.get('route_path', ''),
'menu_id': result.get('menu_id', ''),
'icon': result.get('menu_icon', 'lucide:file-text'),
}
# 循环节点的迭代结果(包含多个节点的输出)
# 尝试从 form_publish 或 form_create 节点输出中提取
for key in ['form_publish', 'form_create']:
if key in result and isinstance(result[key], dict):
sub_result = result[key]
return {
'id': sub_result.get('form_id', ''),
'name': sub_result.get('form_name', sub_result.get('menu_name', f'表单{index + 1}')),
'code': sub_result.get('form_code', ''),
'description': sub_result.get('description', ''),
'link': sub_result.get('route_path', ''),
'menu_id': sub_result.get('menu_id', ''),
'icon': sub_result.get('menu_icon', 'lucide:file-text'),
}
# 尝试从嵌套的 publish_result 中提取
if 'publish_result' in result and isinstance(result['publish_result'], dict):
pub = result['publish_result']
return {
'id': pub.get('form_id', result.get('form_id', '')),
'name': pub.get('form_name', pub.get('menu_name', f'表单{index + 1}')),
'code': pub.get('form_code', result.get('form_code', '')),
'description': pub.get('description', ''),
'link': pub.get('route_path', ''),
'menu_id': pub.get('menu_id', ''),
'icon': pub.get('menu_icon', 'lucide:file-text'),
}
# 尝试从 _node_xxx 格式的输出中提取
for key, value in result.items():
if key.startswith('_node_') and isinstance(value, dict):
if 'form_id' in value or 'route_path' in value:
return {
'id': value.get('form_id', ''),
'name': value.get('form_name', value.get('menu_name', f'表单{index + 1}')),
'code': value.get('form_code', ''),
'description': value.get('description', ''),
'link': value.get('route_path', ''),
'menu_id': value.get('menu_id', ''),
'icon': value.get('menu_icon', 'lucide:file-text'),
}
return None
def _process_app_info(self, app_info: Any) -> Optional[Dict[str, Any]]:
"""处理应用信息 - 只需要名称"""
if not app_info:
return None
# 如果是字符串,直接作为名称
if isinstance(app_info, str):
app_name = app_info.strip()
if not app_name:
return None
return {
'name': app_name,
'icon': 'lucide:app-window',
'link': '/', # 应用的基础URL
}
# 如果是字典,尝试提取名称
if isinstance(app_info, dict):
app_name = app_info.get('app_name') or app_info.get('name', '')
if not app_name:
return None
return {
'name': app_name,
'icon': app_info.get('icon', 'lucide:app-window'),
'link': '/',
}
return None
def _process_dashboard_info(self, dashboard_info: Any) -> Optional[Dict[str, Any]]:
"""处理仪表盘信息 - 只需要名称和路径"""
if not dashboard_info:
return None
# 如果是字符串,直接作为名称
if isinstance(dashboard_info, str):
dashboard_name = dashboard_info.strip()
if not dashboard_name:
return None
return {
'name': dashboard_name,
'icon': 'lucide:layout-dashboard',
'link': '', # 没有路径信息
}
# 如果是字典,提取名称和路径
if isinstance(dashboard_info, dict):
dashboard_name = (
dashboard_info.get('dashboard_name') or
dashboard_info.get('menu_name') or
dashboard_info.get('name', '')
)
if not dashboard_name:
return None
return {
'name': dashboard_name,
'icon': dashboard_info.get('menu_icon', 'lucide:layout-dashboard'),
'link': dashboard_info.get('route_path', ''),
}
return None
def _build_summary(
self,
title: str,
app: Optional[Dict[str, Any]],
forms: List[Dict[str, Any]],
dashboard: Optional[Dict[str, Any]],
show_statistics: bool,
base_url: str,
) -> Dict[str, Any]:
"""构建总结数据"""
# 计算统计信息
total_modules = len(forms)
if app:
total_modules += 1
if dashboard:
total_modules += 1
summary = {
'title': title,
'created_at': datetime.now().isoformat(),
'statistics': {
'total_modules': total_modules,
'forms_count': len(forms),
'has_app': app is not None,
'has_dashboard': dashboard is not None,
} if show_statistics else None,
'app': app,
'forms': forms,
'dashboard': dashboard,
'base_url': base_url,
}
return summary
def _build_final_result(self, summary_data: Dict[str, Any]) -> NodeResult:
"""构建最终结果(用户确认后)"""
forms = summary_data.get('forms', [])
app = summary_data.get('app')
dashboard = summary_data.get('dashboard')
return NodeResult(
success=True,
output=summary_data,
output_variables={
'summary': summary_data,
'total_forms': len(forms),
'has_app': app is not None,
'has_dashboard': dashboard is not None,
},
)
@classmethod
def get_config_schema(cls) -> Dict[str, Any]:
"""获取节点配置 Schema"""
return {
'type': 'object',
'properties': {
'app_info': {
'type': 'string',
'title': '应用信息',
'description': '应用信息(来自AppCreate节点),支持变量引用如 {{app_create.app_id}}',
'required': False,
'x-component': 'SmartInput',
'x-component-props': {
'placeholder': '留空则不显示应用信息',
},
},
'form_results': {
'type': 'string',
'title': '表单结果列表',
'description': '表单结果列表(来自循环节点或单个表单发布节点),支持变量引用如 {{loop.results}}',
'required': True,
'x-component': 'SmartInput',
'x-component-props': {
'placeholder': '{{loop.results}} 或 {{form_publish.publish_result}}',
},
},
'dashboard_info': {
'type': 'string',
'title': '仪表盘信息',
'description': '仪表盘信息(来自DashboardPublish节点),支持变量引用',
'required': False,
'x-component': 'SmartInput',
'x-component-props': {
'placeholder': '留空则不显示仪表盘信息',
},
},
'summary_title': {
'type': 'string',
'title': '总结标题',
'description': '总结页面的标题',
'default': '系统创建完成',
'x-component': 'Input',
'x-component-props': {
'placeholder': '系统创建完成',
},
},
'show_statistics': {
'type': 'boolean',
'title': '显示统计信息',
'description': '是否显示模块数量等统计信息',
'default': True,
'x-component': 'Switch',
},
'base_url': {
'type': 'string',
'title': '基础URL',
'description': '用于生成完整链接的基础URL(可选)',
'required': False,
'x-component': 'Input',
'x-component-props': {
'placeholder': '留空则使用相对路径',
},
},
},
'required': ['form_results'],
}