Restore AI workflow design nodes
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
"""
|
||||
Lightweight form metadata API for AI workflow panels.
|
||||
"""
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.base_schema import PaginatedResponse
|
||||
from app.database import get_db
|
||||
from core.application.model import Application
|
||||
from online_dev.form_manager.model import FormMeta
|
||||
|
||||
router = APIRouter(prefix="/forms", tags=["AI-Forms"])
|
||||
|
||||
|
||||
def _build_form_list_out(form, application_name: str = "", application_code: str = "") -> dict:
|
||||
return {
|
||||
"id": str(form.id),
|
||||
"application_id": form.application_id,
|
||||
"application_name": application_name or "Main App",
|
||||
"application_code": application_code or "",
|
||||
"name": form.name,
|
||||
"code": form.code,
|
||||
"status": form.status,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/list", summary="List form metadata for AI workflow form-data nodes")
|
||||
async def list_forms(
|
||||
application_id: Optional[str] = Query(None, alias="applicationId"),
|
||||
name: Optional[str] = Query(None),
|
||||
code: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
include_globally_visible: bool = Query(False, alias="includeGloballyVisible"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100, alias="pageSize"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
conditions = [FormMeta.is_deleted.is_(False)]
|
||||
|
||||
if application_id:
|
||||
app_condition = FormMeta.application_id == application_id
|
||||
if include_globally_visible:
|
||||
app_condition = or_(
|
||||
app_condition,
|
||||
and_(
|
||||
FormMeta.globally_visible.is_(True),
|
||||
FormMeta.status == "published",
|
||||
),
|
||||
)
|
||||
conditions.append(app_condition)
|
||||
elif not include_globally_visible:
|
||||
conditions.append(FormMeta.application_id.is_(None))
|
||||
else:
|
||||
conditions.append(
|
||||
or_(
|
||||
FormMeta.application_id.is_(None),
|
||||
and_(
|
||||
FormMeta.globally_visible.is_(True),
|
||||
FormMeta.status == "published",
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
if name:
|
||||
conditions.append(FormMeta.name.ilike(f"%{name}%"))
|
||||
if code:
|
||||
conditions.append(FormMeta.code.ilike(f"%{code}%"))
|
||||
if status:
|
||||
conditions.append(FormMeta.status == status)
|
||||
|
||||
count_stmt = select(func.count(FormMeta.id)).where(and_(*conditions))
|
||||
total = (await db.execute(count_stmt)).scalar() or 0
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
stmt = (
|
||||
select(
|
||||
FormMeta,
|
||||
Application.name.label("application_name"),
|
||||
Application.code.label("application_code"),
|
||||
)
|
||||
.outerjoin(Application, FormMeta.application_id == Application.id)
|
||||
.where(and_(*conditions))
|
||||
.order_by(FormMeta.sort, FormMeta.sys_create_datetime.desc())
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
|
||||
items = [
|
||||
_build_form_list_out(form, application_name, application_code)
|
||||
for form, application_name, application_code in result
|
||||
]
|
||||
return PaginatedResponse(items=items, total=total)
|
||||
@@ -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'],
|
||||
}
|
||||
@@ -12,53 +12,53 @@ logger = logging.getLogger(__name__)
|
||||
class NodeRegistry:
|
||||
"""
|
||||
节点注册中心
|
||||
|
||||
|
||||
管理所有工作流节点的注册和获取
|
||||
"""
|
||||
|
||||
|
||||
_nodes: Dict[str, Type[BaseNode]] = {}
|
||||
|
||||
|
||||
@classmethod
|
||||
def register(cls, node_class: Type[BaseNode]) -> Type[BaseNode]:
|
||||
"""
|
||||
注册节点(可作为装饰器使用)
|
||||
|
||||
|
||||
Args:
|
||||
node_class: 节点类
|
||||
|
||||
|
||||
Returns:
|
||||
节点类
|
||||
"""
|
||||
node_type = node_class.node_type
|
||||
if not node_type:
|
||||
raise ValueError(f'Node {node_class.__name__} must have a node_type')
|
||||
|
||||
|
||||
cls._nodes[node_type] = node_class
|
||||
logger.info(f'Registered AI workflow node: {node_type}')
|
||||
return node_class
|
||||
|
||||
|
||||
@classmethod
|
||||
def get(cls, node_type: str) -> Optional[Type[BaseNode]]:
|
||||
"""
|
||||
获取节点类
|
||||
|
||||
|
||||
Args:
|
||||
node_type: 节点类型
|
||||
|
||||
|
||||
Returns:
|
||||
节点类或 None
|
||||
"""
|
||||
return cls._nodes.get(node_type)
|
||||
|
||||
|
||||
@classmethod
|
||||
def create_instance(cls, node_type: str, config: Dict = None) -> Optional[BaseNode]:
|
||||
"""
|
||||
创建节点实例
|
||||
|
||||
|
||||
Args:
|
||||
node_type: 节点类型
|
||||
config: 节点配置
|
||||
|
||||
|
||||
Returns:
|
||||
节点实例或 None
|
||||
"""
|
||||
@@ -66,24 +66,24 @@ class NodeRegistry:
|
||||
if not node_class:
|
||||
logger.warning(f'Unknown node type: {node_type}')
|
||||
return None
|
||||
|
||||
|
||||
return node_class(config=config)
|
||||
|
||||
|
||||
@classmethod
|
||||
def get_all_schemas(cls) -> List[Dict]:
|
||||
"""
|
||||
获取所有节点 Schema
|
||||
|
||||
|
||||
Returns:
|
||||
节点 Schema 列表
|
||||
"""
|
||||
return [node.get_schema() for node in cls._nodes.values()]
|
||||
|
||||
|
||||
@classmethod
|
||||
def get_schemas_by_category(cls) -> Dict[str, List[Dict]]:
|
||||
"""
|
||||
按分类获取节点 Schema
|
||||
|
||||
|
||||
Returns:
|
||||
按分类分组的节点 Schema
|
||||
"""
|
||||
@@ -94,12 +94,12 @@ class NodeRegistry:
|
||||
result[category] = []
|
||||
result[category].append(node.get_schema())
|
||||
return result
|
||||
|
||||
|
||||
@classmethod
|
||||
def get_all_types(cls) -> List[str]:
|
||||
"""
|
||||
获取所有已注册的节点类型
|
||||
|
||||
|
||||
Returns:
|
||||
节点类型列表
|
||||
"""
|
||||
@@ -130,6 +130,23 @@ def _load_builtin_nodes():
|
||||
'text_to_sql_node',
|
||||
'snowflake_cortex_node',
|
||||
'knowledge_retrieval_node',
|
||||
'form_basic_info_node',
|
||||
'form_database_design_node',
|
||||
'form_database_create_node',
|
||||
'form_ui_design_node',
|
||||
'form_list_design_node',
|
||||
'form_create_node',
|
||||
'form_publish_node',
|
||||
'form_data_node',
|
||||
'app_create_node',
|
||||
'app_design_node',
|
||||
'app_settings_node',
|
||||
'app_update_node',
|
||||
'dashboard_basic_info_node',
|
||||
'dashboard_design_node',
|
||||
'dashboard_create_node',
|
||||
'dashboard_publish_node',
|
||||
'system_summary_node',
|
||||
]
|
||||
|
||||
from importlib import import_module
|
||||
|
||||
@@ -7,6 +7,7 @@ from ai_platform.api.chat_api import router as chat_router
|
||||
from ai_platform.api.workflow_api import router as workflow_router
|
||||
from ai_platform.api.agent_api import router as agent_router
|
||||
from ai_platform.api.speech_api import router as speech_router
|
||||
from ai_platform.api.form_api import router as form_router
|
||||
from ai_platform.knowledge.api import router as knowledge_router
|
||||
|
||||
# 创建总路由
|
||||
@@ -20,4 +21,5 @@ router.include_router(chat_router)
|
||||
router.include_router(workflow_router)
|
||||
router.include_router(agent_router)
|
||||
router.include_router(speech_router)
|
||||
router.include_router(knowledge_router)
|
||||
router.include_router(form_router)
|
||||
router.include_router(knowledge_router)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
表单数据管理模块
|
||||
"""
|
||||
@@ -0,0 +1,300 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""表单数据数据库执行适配层:平台库 vs 第三方连接"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from online_dev.form_data_manager.dynamic_sql_builder import DynamicSQLBuilder
|
||||
from online_dev.form_data_manager.exceptions import (
|
||||
FormDataConnectionError,
|
||||
FormDataException,
|
||||
QueryError,
|
||||
)
|
||||
from utils.sql_param_compile import compile_sql_with_named_params
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PLATFORM_DB_TYPE = "postgresql"
|
||||
PLATFORM_SCHEMA = "public"
|
||||
|
||||
|
||||
class FormDataWriteForbidden(FormDataException):
|
||||
"""系统连接不允许表单业务数据写入"""
|
||||
|
||||
error_code = "FORM_WRITE_FORBIDDEN"
|
||||
http_status = 403
|
||||
|
||||
def __init__(self, db_config: str = ""):
|
||||
super().__init__(
|
||||
"系统数据库连接不允许写入表单业务数据,请使用第三方数据库连接",
|
||||
context={"db_config": db_config},
|
||||
)
|
||||
|
||||
|
||||
class FormDataDbAdapter:
|
||||
"""表单 CRUD SQL 执行抽象"""
|
||||
|
||||
db_type: str
|
||||
db_config: str
|
||||
is_system: bool
|
||||
is_external: bool
|
||||
|
||||
async def execute_query(
|
||||
self,
|
||||
sql: str,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
*,
|
||||
database: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
raise NotImplementedError
|
||||
|
||||
async def execute_command(
|
||||
self,
|
||||
sql: str,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
*,
|
||||
database: Optional[str] = None,
|
||||
) -> int:
|
||||
raise NotImplementedError
|
||||
|
||||
def ensure_write_allowed(self) -> None:
|
||||
"""写操作前校验(系统连接禁止写业务表)"""
|
||||
|
||||
@asynccontextmanager
|
||||
async def transaction(self, database: Optional[str] = None):
|
||||
"""请求级事务;平台库由 API Session 提交,第三方由 handler 事务域。"""
|
||||
yield
|
||||
|
||||
|
||||
class PlatformSessionAdapter(FormDataDbAdapter):
|
||||
"""平台 AsyncSession(db_config=default)"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
platform_db: AsyncSession,
|
||||
db_type: str,
|
||||
db_config: str = "default",
|
||||
default_database: str = "",
|
||||
):
|
||||
self._db = platform_db
|
||||
self.db_type = db_type
|
||||
self.db_config = db_config
|
||||
self.default_database = (default_database or "").strip()
|
||||
self.is_system = db_config == "default"
|
||||
self.is_external = False
|
||||
|
||||
def ensure_write_allowed(self) -> None:
|
||||
return
|
||||
|
||||
async def execute_query(
|
||||
self,
|
||||
sql: str,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
*,
|
||||
database: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
result = await self._db.execute(text(sql), params or {})
|
||||
rows = result.fetchall()
|
||||
columns = result.keys()
|
||||
return [dict(zip(columns, row)) for row in rows]
|
||||
|
||||
async def execute_command(
|
||||
self,
|
||||
sql: str,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
*,
|
||||
database: Optional[str] = None,
|
||||
) -> int:
|
||||
result = await self._db.execute(text(sql), params or {})
|
||||
return result.rowcount
|
||||
|
||||
@asynccontextmanager
|
||||
async def transaction(self, database: Optional[str] = None):
|
||||
yield
|
||||
|
||||
|
||||
class ExternalConnectionAdapter(FormDataDbAdapter):
|
||||
"""第三方连接:经 ConnectionResolver + database_manager 执行"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
platform_db: AsyncSession,
|
||||
db_config: str,
|
||||
db_type: str,
|
||||
is_system: bool,
|
||||
default_database: str = "",
|
||||
):
|
||||
self._platform_db = platform_db
|
||||
self.db_config = db_config
|
||||
self.db_type = db_type
|
||||
self.default_database = (default_database or "").strip()
|
||||
self.is_system = is_system
|
||||
self.is_external = True
|
||||
self._manager_service = None
|
||||
|
||||
def ensure_write_allowed(self) -> None:
|
||||
if self.is_system:
|
||||
raise FormDataWriteForbidden(self.db_config)
|
||||
|
||||
async def _get_manager_service(self):
|
||||
if self._manager_service is None:
|
||||
from core.database_manager.service import AsyncDatabaseManagerService
|
||||
|
||||
self._manager_service = await AsyncDatabaseManagerService.create(
|
||||
self.db_config, self._platform_db
|
||||
)
|
||||
return self._manager_service
|
||||
|
||||
async def execute_query(
|
||||
self,
|
||||
sql: str,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
*,
|
||||
database: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
compiled = compile_sql_with_named_params(sql, params or {}, self.db_type)
|
||||
service = await self._get_manager_service()
|
||||
result_data = await service.execute_sql(
|
||||
compiled, is_query=True, database=database
|
||||
)
|
||||
if not result_data.get("success"):
|
||||
msg = result_data.get("message", "查询失败")
|
||||
logger.error(
|
||||
"External form query failed [%s] db_type=%s: %s | SQL: %s",
|
||||
self.db_config,
|
||||
self.db_type,
|
||||
msg,
|
||||
compiled[:500] if len(compiled) > 500 else compiled,
|
||||
)
|
||||
raise QueryError(detail=msg)
|
||||
return result_data.get("rows") or []
|
||||
|
||||
async def execute_command(
|
||||
self,
|
||||
sql: str,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
*,
|
||||
database: Optional[str] = None,
|
||||
) -> int:
|
||||
self.ensure_write_allowed()
|
||||
compiled = compile_sql_with_named_params(sql, params or {}, self.db_type)
|
||||
service = await self._get_manager_service()
|
||||
result_data = await service.execute_sql(
|
||||
compiled, is_query=False, database=database
|
||||
)
|
||||
if not result_data.get("success"):
|
||||
msg = result_data.get("message", "执行失败")
|
||||
logger.error(
|
||||
"External form command failed [%s] db_type=%s: %s | SQL: %s",
|
||||
self.db_config,
|
||||
self.db_type,
|
||||
msg,
|
||||
compiled[:500] if len(compiled) > 500 else compiled,
|
||||
)
|
||||
raise QueryError(detail=msg)
|
||||
affected = result_data.get("affected_rows")
|
||||
return int(affected) if affected is not None else 0
|
||||
|
||||
@asynccontextmanager
|
||||
async def transaction(self, database: Optional[str] = None):
|
||||
service = await self._get_manager_service()
|
||||
if not await service.begin_transaction(database=database):
|
||||
raise QueryError(detail="无法开启数据库事务")
|
||||
try:
|
||||
yield
|
||||
await service.commit_transaction()
|
||||
except Exception:
|
||||
await service.rollback_transaction()
|
||||
raise
|
||||
|
||||
async def create_savepoint(self, name: str) -> None:
|
||||
service = await self._get_manager_service()
|
||||
await service.create_savepoint(name)
|
||||
|
||||
async def release_savepoint(self, name: str) -> None:
|
||||
service = await self._get_manager_service()
|
||||
await service.release_savepoint(name)
|
||||
|
||||
async def rollback_to_savepoint(self, name: str) -> None:
|
||||
service = await self._get_manager_service()
|
||||
await service.rollback_to_savepoint(name)
|
||||
|
||||
|
||||
async def create_platform_adapter(platform_db: AsyncSession) -> PlatformSessionAdapter:
|
||||
"""平台库适配器(固定 default,用于 core_* 元数据查询)。"""
|
||||
from core.database_connection.resolver import ConnectionResolver
|
||||
|
||||
info = await ConnectionResolver.resolve("default", platform_db)
|
||||
return PlatformSessionAdapter(
|
||||
platform_db,
|
||||
PLATFORM_DB_TYPE,
|
||||
"default",
|
||||
default_database=info.database or "",
|
||||
)
|
||||
|
||||
|
||||
async def create_form_data_adapter(
|
||||
db_config: str,
|
||||
platform_db: AsyncSession,
|
||||
) -> FormDataDbAdapter:
|
||||
"""按表单 db_config 创建业务库执行适配器"""
|
||||
from core.database_connection.resolver import ConnectionResolver
|
||||
|
||||
code = (db_config or "default").strip() or "default"
|
||||
try:
|
||||
info = await ConnectionResolver.resolve(code, platform_db)
|
||||
except ValueError as e:
|
||||
raise FormDataConnectionError(detail=str(e)) from e
|
||||
|
||||
if code == "default":
|
||||
return PlatformSessionAdapter(
|
||||
platform_db,
|
||||
info.db_type,
|
||||
code,
|
||||
default_database=info.database or "",
|
||||
)
|
||||
|
||||
return ExternalConnectionAdapter(
|
||||
platform_db,
|
||||
code,
|
||||
info.db_type,
|
||||
info.is_system,
|
||||
default_database=info.database or "",
|
||||
)
|
||||
|
||||
|
||||
def create_platform_sql_builder() -> DynamicSQLBuilder:
|
||||
return DynamicSQLBuilder(PLATFORM_DB_TYPE)
|
||||
|
||||
|
||||
def create_sql_builder_for_adapter(adapter: FormDataDbAdapter) -> DynamicSQLBuilder:
|
||||
return DynamicSQLBuilder(
|
||||
adapter.db_type,
|
||||
default_database=getattr(adapter, "default_database", "") or "",
|
||||
)
|
||||
|
||||
|
||||
async def resolve_form_sql_context(
|
||||
platform_db: AsyncSession,
|
||||
form_meta,
|
||||
*,
|
||||
adapter_cache: Optional[Dict[str, FormDataDbAdapter]] = None,
|
||||
builder_cache: Optional[Dict[str, DynamicSQLBuilder]] = None,
|
||||
) -> Tuple[FormDataDbAdapter, DynamicSQLBuilder]:
|
||||
"""按 FormMeta 解析业务 adapter 与 sql_builder(支持请求内缓存)。"""
|
||||
code = (form_meta.db_config or "default").strip() or "default"
|
||||
adapters = adapter_cache if adapter_cache is not None else {}
|
||||
builders = builder_cache if builder_cache is not None else {}
|
||||
|
||||
if code not in adapters:
|
||||
adapters[code] = await create_form_data_adapter(code, platform_db)
|
||||
if code not in builders:
|
||||
builders[code] = create_sql_builder_for_adapter(adapters[code])
|
||||
|
||||
return adapters[code], builders[code]
|
||||
@@ -0,0 +1,551 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
数据库异常转换器
|
||||
|
||||
将 SQLAlchemy / DBAPI 原生异常转换为 FormData 业务异常。
|
||||
同时提供 API 层的统一错误响应构建函数。
|
||||
|
||||
典型用法:
|
||||
from .db_error_handler import translate_db_error, build_error_response
|
||||
|
||||
# Service 层 — 在 _execute_command / _execute_query 中
|
||||
try:
|
||||
await db.execute(...)
|
||||
except Exception as e:
|
||||
raise translate_db_error(e) from e
|
||||
|
||||
# API 层 — 在路由函数中
|
||||
except FormDataException as e:
|
||||
raise build_error_response(e)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.exc import (
|
||||
DBAPIError,
|
||||
IntegrityError,
|
||||
OperationalError,
|
||||
ProgrammingError,
|
||||
DataError,
|
||||
InternalError,
|
||||
StatementError,
|
||||
TimeoutError as SATimeoutError,
|
||||
)
|
||||
|
||||
from online_dev.form_data_manager.exceptions import (
|
||||
FormDataException,
|
||||
FormDataValidationError,
|
||||
UniqueConstraintError,
|
||||
NotNullConstraintError,
|
||||
CheckConstraintError,
|
||||
ForeignKeyConstraintError,
|
||||
DataTypeMismatchError,
|
||||
DataTooLongError,
|
||||
TableNotFoundError,
|
||||
ColumnNotFoundError,
|
||||
DatabaseConnectionError,
|
||||
ConnectionPoolExhaustedError,
|
||||
ConnectionTimeoutError,
|
||||
DeadlockError,
|
||||
LockTimeoutError,
|
||||
QueryError,
|
||||
InternalDatabaseError,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 正则:提取数据库错误中的关键信息
|
||||
# =====================================================================
|
||||
_RE_COLUMN = re.compile(r'column\s+["\']?(\w+)["\']?', re.IGNORECASE)
|
||||
_RE_TABLE = re.compile(r'(?:relation|table)\s+["\']?([\w."]+)["\']?', re.IGNORECASE)
|
||||
_RE_CONSTRAINT = re.compile(r'constraint\s+["\']?(\w+)["\']?', re.IGNORECASE)
|
||||
_RE_PG_DETAIL = re.compile(r'DETAIL:\s*(.*?)(?:\n|$)', re.IGNORECASE)
|
||||
_RE_REFERENCED_FROM_TABLE = re.compile(
|
||||
r'referenced from table\s+"?([\w.]+)"?',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_RE_FK_ON_TABLE = re.compile(
|
||||
r'foreign key constraint\s+"?(\w+)"?\s+on table\s+"?([\w.]+)"?',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_RE_NOT_PRESENT_IN_TABLE = re.compile(
|
||||
r'not present in table\s+"?([\w.]+)"?',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _extract_column(msg: str) -> str:
|
||||
m = _RE_COLUMN.search(msg)
|
||||
return m.group(1) if m else ""
|
||||
|
||||
|
||||
def _extract_table(msg: str) -> str:
|
||||
m = _RE_TABLE.search(msg)
|
||||
return m.group(1) if m else ""
|
||||
|
||||
|
||||
def _extract_constraint(msg: str) -> str:
|
||||
m = _RE_CONSTRAINT.search(msg)
|
||||
return m.group(1) if m else ""
|
||||
|
||||
|
||||
def _normalize_table_name(table: str) -> str:
|
||||
if not table:
|
||||
return ""
|
||||
return table.strip().strip('"').split(".")[-1]
|
||||
|
||||
|
||||
def _extract_fk_referencing_table(msg: str) -> str:
|
||||
detail_match = _RE_PG_DETAIL.search(msg)
|
||||
detail = detail_match.group(1).strip() if detail_match else msg
|
||||
|
||||
referenced_match = _RE_REFERENCED_FROM_TABLE.search(detail)
|
||||
if referenced_match:
|
||||
return _normalize_table_name(referenced_match.group(1))
|
||||
|
||||
not_present_match = _RE_NOT_PRESENT_IN_TABLE.search(detail)
|
||||
if not_present_match:
|
||||
return _normalize_table_name(not_present_match.group(1))
|
||||
|
||||
fk_on_table_match = _RE_FK_ON_TABLE.search(msg)
|
||||
if fk_on_table_match:
|
||||
return _normalize_table_name(fk_on_table_match.group(2))
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 核心转换函数
|
||||
# =====================================================================
|
||||
|
||||
def translate_db_error(exc: Exception) -> FormDataException:
|
||||
"""
|
||||
将数据库异常转换为业务异常。
|
||||
|
||||
优先使用 SQLAlchemy 异常类型进行判断,再辅以错误信息字符串匹配,
|
||||
确保对 PostgreSQL 和 MySQL 两种方言都有良好的覆盖。
|
||||
"""
|
||||
msg = str(exc).lower()
|
||||
orig_msg = str(exc)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 1. IntegrityError — 约束违反
|
||||
# SQLAlchemy 会将 asyncpg/MySQL 的约束错误包装为 IntegrityError,
|
||||
# 实际 DBAPI 异常在 exc.orig 中,类型判断必须用外层包装类。
|
||||
# ------------------------------------------------------------------
|
||||
if isinstance(exc, IntegrityError):
|
||||
return _handle_integrity_error(exc, msg, orig_msg)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 2. DataError — 数据格式/长度问题
|
||||
# ------------------------------------------------------------------
|
||||
if isinstance(exc, DataError):
|
||||
return _handle_data_error(exc, msg, orig_msg)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 3. OperationalError — 连接、死锁、超时等运行时问题
|
||||
# ------------------------------------------------------------------
|
||||
if isinstance(exc, OperationalError):
|
||||
return _handle_operational_error(exc, msg, orig_msg)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 4. ProgrammingError — SQL 语法、表/列不存在
|
||||
# ------------------------------------------------------------------
|
||||
if isinstance(exc, ProgrammingError):
|
||||
return _handle_programming_error(exc, msg, orig_msg)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 5. InternalError — 数据库内部错误(部分 MySQL 死锁也走这里)
|
||||
# ------------------------------------------------------------------
|
||||
if isinstance(exc, InternalError):
|
||||
if "deadlock" in msg:
|
||||
return DeadlockError()
|
||||
return InternalDatabaseError(detail=orig_msg)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 6. SQLAlchemy TimeoutError — 连接池超时
|
||||
# ------------------------------------------------------------------
|
||||
if isinstance(exc, SATimeoutError):
|
||||
return ConnectionPoolExhaustedError()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 7. MissingGreenlet(异步上下文错误)
|
||||
# ------------------------------------------------------------------
|
||||
if "missinggreenlet" in msg or "MissingGreenlet" in orig_msg:
|
||||
return InternalDatabaseError(detail="async context error (MissingGreenlet)")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 8. 其他 DBAPIError(含 PG 事务已中止)
|
||||
# ------------------------------------------------------------------
|
||||
if isinstance(exc, DBAPIError):
|
||||
if _is_failed_transaction_error(msg):
|
||||
return _handle_failed_transaction_error(orig_msg)
|
||||
return InternalDatabaseError(detail=orig_msg)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 9. 如果已经是业务异常,原样返回
|
||||
# ------------------------------------------------------------------
|
||||
if isinstance(exc, FormDataException):
|
||||
return exc
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 10. 兜底 — 通过字符串匹配再尝试识别一轮
|
||||
# ------------------------------------------------------------------
|
||||
return _fallback_string_match(exc, msg, orig_msg)
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 分类处理器
|
||||
# =====================================================================
|
||||
|
||||
def _handle_integrity_error(
|
||||
exc: IntegrityError, msg: str, orig_msg: str
|
||||
) -> FormDataException:
|
||||
"""处理 IntegrityError 的各种子类型"""
|
||||
|
||||
# --- 唯一约束 ---
|
||||
if any(kw in msg for kw in ("unique", "duplicate key", "duplicate entry", "uniqueviolation")):
|
||||
fields = _extract_unique_fields(orig_msg)
|
||||
constraint = _extract_constraint(orig_msg)
|
||||
if fields:
|
||||
labels = ", ".join(f"'{f}'" for f in fields)
|
||||
return UniqueConstraintError(
|
||||
f"以下字段的值已存在: {labels}",
|
||||
fields=fields,
|
||||
constraint_name=constraint,
|
||||
)
|
||||
return UniqueConstraintError(constraint_name=constraint)
|
||||
|
||||
# --- 非空约束 ---
|
||||
if any(kw in msg for kw in ("not-null", "notnullviolation", "null value in column", "cannot be null", "doesn't have a default")):
|
||||
col = _extract_column(orig_msg)
|
||||
return NotNullConstraintError(column=col)
|
||||
|
||||
# --- 外键约束 ---
|
||||
if any(
|
||||
kw in msg
|
||||
for kw in (
|
||||
"foreign key",
|
||||
"foreignkeyviolation",
|
||||
"restrictviolation",
|
||||
"a foreign key constraint fails",
|
||||
"is still referenced",
|
||||
"referenced from table",
|
||||
"restrict setting of foreign key",
|
||||
)
|
||||
):
|
||||
return _build_foreign_key_error(orig_msg)
|
||||
|
||||
# --- CHECK 约束 ---
|
||||
if any(kw in msg for kw in ("check constraint", "checkviolation", "check_violation")):
|
||||
constraint = _extract_constraint(orig_msg)
|
||||
return CheckConstraintError(constraint_name=constraint)
|
||||
|
||||
# --- 无法识别的 IntegrityError ---
|
||||
return FormDataValidationError(
|
||||
"数据违反约束条件,请检查是否存在重复或无效数据",
|
||||
)
|
||||
|
||||
|
||||
def _handle_data_error(
|
||||
exc: DataError, msg: str, orig_msg: str
|
||||
) -> FormDataException:
|
||||
"""处理 DataError"""
|
||||
|
||||
# 数据超长
|
||||
if any(kw in msg for kw in ("too long", "value too long", "data too long", "string data, right truncation")):
|
||||
col = _extract_column(orig_msg)
|
||||
return DataTooLongError(column=col)
|
||||
|
||||
# 数据类型不匹配
|
||||
if any(kw in msg for kw in (
|
||||
"invalid input syntax", "invalid text representation", "invalid input for query argument",
|
||||
"incorrect", "out of range", "numeric value", "expected str", "expected int",
|
||||
)):
|
||||
return DataTypeMismatchError(detail=orig_msg)
|
||||
|
||||
return DataTypeMismatchError(detail=orig_msg)
|
||||
|
||||
|
||||
def _handle_operational_error(
|
||||
exc: OperationalError, msg: str, orig_msg: str
|
||||
) -> FormDataException:
|
||||
"""处理 OperationalError"""
|
||||
|
||||
# 死锁
|
||||
if any(kw in msg for kw in ("deadlock", "deadlock detected")):
|
||||
return DeadlockError()
|
||||
|
||||
# 锁等待超时
|
||||
if any(kw in msg for kw in ("lock wait timeout", "lock timeout", "could not obtain lock")):
|
||||
return LockTimeoutError()
|
||||
|
||||
# 连接相关
|
||||
if any(kw in msg for kw in (
|
||||
"connection refused", "connection reset",
|
||||
"connection timed out", "connection is closed",
|
||||
"server closed the connection", "broken pipe",
|
||||
"can't connect", "unable to connect",
|
||||
"lost connection", "gone away",
|
||||
)):
|
||||
return DatabaseConnectionError(detail=orig_msg)
|
||||
|
||||
# 连接池超时
|
||||
if "queuepool" in msg and ("timeout" in msg or "limit" in msg):
|
||||
return ConnectionPoolExhaustedError()
|
||||
|
||||
# 查询超时 (statement_timeout / max_execution_time)
|
||||
if any(kw in msg for kw in ("statement timeout", "query timeout", "max_execution_time")):
|
||||
return ConnectionTimeoutError()
|
||||
|
||||
# 表不存在(MySQL 的 OperationalError 1146)
|
||||
if any(kw in msg for kw in ("doesn't exist", "does not exist", "no such table")):
|
||||
table = _extract_table(orig_msg)
|
||||
return TableNotFoundError(table=table)
|
||||
|
||||
return DatabaseConnectionError(detail=orig_msg)
|
||||
|
||||
|
||||
def _handle_programming_error(
|
||||
exc: ProgrammingError, msg: str, orig_msg: str
|
||||
) -> FormDataException:
|
||||
"""处理 ProgrammingError"""
|
||||
|
||||
# 表不存在
|
||||
if any(kw in msg for kw in ("relation", "table")) and "does not exist" in msg:
|
||||
table = _extract_table(orig_msg)
|
||||
return TableNotFoundError(table=table)
|
||||
|
||||
# 列不存在
|
||||
if any(kw in msg for kw in ("column", "field", "unknown column")) and any(
|
||||
kw in msg for kw in ("does not exist", "not found", "unknown")
|
||||
):
|
||||
col = _extract_column(orig_msg)
|
||||
return ColumnNotFoundError(column=col)
|
||||
|
||||
# SQL 语法错误
|
||||
if any(kw in msg for kw in ("syntax error", "you have an error in your sql")):
|
||||
return QueryError(detail=orig_msg)
|
||||
|
||||
return QueryError(detail=orig_msg)
|
||||
|
||||
|
||||
def _fallback_string_match(
|
||||
exc: Exception, msg: str, orig_msg: str
|
||||
) -> FormDataException:
|
||||
"""兜底的字符串匹配,尽可能识别常见场景"""
|
||||
|
||||
if "column" in msg and "does not exist" in msg:
|
||||
col = _extract_column(orig_msg)
|
||||
return ColumnNotFoundError(column=col)
|
||||
|
||||
if ("relation" in msg or "table" in msg) and "does not exist" in msg:
|
||||
table = _extract_table(orig_msg)
|
||||
return TableNotFoundError(table=table)
|
||||
|
||||
if "duplicate key" in msg or "unique" in msg:
|
||||
return UniqueConstraintError()
|
||||
|
||||
if "null value" in msg or "cannot be null" in msg:
|
||||
col = _extract_column(orig_msg)
|
||||
return NotNullConstraintError(column=col)
|
||||
|
||||
if any(
|
||||
kw in msg
|
||||
for kw in (
|
||||
"foreign key",
|
||||
"referenced from table",
|
||||
"restrict setting of foreign key",
|
||||
"a foreign key constraint fails",
|
||||
)
|
||||
):
|
||||
return _build_foreign_key_error(orig_msg)
|
||||
|
||||
if _is_failed_transaction_error(msg):
|
||||
return _handle_failed_transaction_error(orig_msg)
|
||||
|
||||
if "connection" in msg:
|
||||
return DatabaseConnectionError(detail=orig_msg)
|
||||
|
||||
return InternalDatabaseError(detail=orig_msg)
|
||||
|
||||
|
||||
def _build_foreign_key_error(orig_msg: str) -> ForeignKeyConstraintError:
|
||||
detail_match = _RE_PG_DETAIL.search(orig_msg)
|
||||
detail = detail_match.group(1).strip() if detail_match else ""
|
||||
referenced_table = _extract_fk_referencing_table(orig_msg)
|
||||
constraint = _extract_constraint(orig_msg)
|
||||
msg_lower = orig_msg.lower()
|
||||
|
||||
if any(kw in msg_lower for kw in ("delete", "referenced from table", "restrict setting")):
|
||||
if referenced_table:
|
||||
return ForeignKeyConstraintError(
|
||||
message=(
|
||||
f"无法删除,该数据已被「{referenced_table}」引用,"
|
||||
"请先删除或解除关联数据"
|
||||
),
|
||||
detail=detail,
|
||||
referenced_table=referenced_table,
|
||||
constraint_name=constraint,
|
||||
)
|
||||
return ForeignKeyConstraintError(
|
||||
message="无法删除,该数据已被其他数据引用,请先删除或解除关联数据",
|
||||
detail=detail,
|
||||
constraint_name=constraint,
|
||||
)
|
||||
|
||||
if any(kw in msg_lower for kw in ("insert", "update")):
|
||||
if referenced_table:
|
||||
return ForeignKeyConstraintError(
|
||||
message=f"操作失败,引用的「{referenced_table}」数据不存在或无效",
|
||||
detail=detail,
|
||||
referenced_table=referenced_table,
|
||||
constraint_name=constraint,
|
||||
)
|
||||
return ForeignKeyConstraintError(
|
||||
message="操作失败,引用的关联数据不存在或无效",
|
||||
detail=detail,
|
||||
constraint_name=constraint,
|
||||
)
|
||||
|
||||
return ForeignKeyConstraintError(
|
||||
detail=detail,
|
||||
referenced_table=referenced_table,
|
||||
constraint_name=constraint,
|
||||
)
|
||||
|
||||
|
||||
def _is_failed_transaction_error(msg: str) -> bool:
|
||||
return any(
|
||||
kw in msg
|
||||
for kw in (
|
||||
"infailedsqltransaction",
|
||||
"current transaction is aborted",
|
||||
"commands ignored until end of transaction block",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def is_aborted_transaction_error(exc: Exception) -> bool:
|
||||
"""判断异常是否由 PostgreSQL 已中止事务引起。"""
|
||||
messages = [str(exc).lower()]
|
||||
orig = getattr(exc, "orig", None)
|
||||
if orig is not None:
|
||||
messages.append(str(orig).lower())
|
||||
return any(_is_failed_transaction_error(msg) for msg in messages)
|
||||
|
||||
|
||||
def _handle_failed_transaction_error(orig_msg: str) -> FormDataException:
|
||||
"""事务已失败后再次执行 SQL 的兜底处理,尽量保留原始约束错误语义。"""
|
||||
if any(
|
||||
kw in orig_msg.lower()
|
||||
for kw in (
|
||||
"foreign key",
|
||||
"referenced from table",
|
||||
"restrict setting of foreign key",
|
||||
"a foreign key constraint fails",
|
||||
)
|
||||
):
|
||||
return _build_foreign_key_error(orig_msg)
|
||||
|
||||
logger.error("Database operation attempted inside failed transaction: %s", orig_msg)
|
||||
return InternalDatabaseError(
|
||||
detail="transaction aborted before error could be translated"
|
||||
)
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 辅助函数
|
||||
# =====================================================================
|
||||
|
||||
def _extract_unique_fields(msg: str) -> list[str]:
|
||||
"""
|
||||
从唯一约束错误中尝试提取冲突的字段名。
|
||||
|
||||
PostgreSQL DETAIL: Key (email)=(xxx) already exists.
|
||||
MySQL: Duplicate entry 'xxx' for key 'uq_email'
|
||||
"""
|
||||
# PostgreSQL: Key (col1, col2)=(...) already exists
|
||||
pg_match = re.search(r'Key\s*\(([^)]+)\)', msg)
|
||||
if pg_match:
|
||||
return [f.strip().strip('"') for f in pg_match.group(1).split(",")]
|
||||
|
||||
# MySQL: for key 'index_name'
|
||||
mysql_match = re.search(r"for key\s+'(\w+)'", msg, re.IGNORECASE)
|
||||
if mysql_match:
|
||||
return [mysql_match.group(1)]
|
||||
|
||||
return []
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# API 层:统一错误响应
|
||||
# =====================================================================
|
||||
|
||||
def build_error_response(exc: FormDataException) -> HTTPException:
|
||||
"""
|
||||
将 FormDataException 转换为 FastAPI HTTPException。
|
||||
|
||||
响应 body 结构:
|
||||
{
|
||||
"detail": "用户友好的错误信息",
|
||||
"error_code": "UNIQUE_CONSTRAINT_VIOLATION",
|
||||
"context": { ... } // 可选的结构化上下文
|
||||
}
|
||||
"""
|
||||
return HTTPException(
|
||||
status_code=exc.http_status,
|
||||
detail={
|
||||
"message": str(exc),
|
||||
"error_code": exc.error_code,
|
||||
"context": exc.context,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def format_error_message(exc: Exception, *, max_length: int = 300) -> str:
|
||||
"""将异常转为面向用户的简短错误信息(用于导入行级错误等场景)"""
|
||||
if isinstance(exc, FormDataException):
|
||||
return str(exc)[:max_length]
|
||||
return str(translate_db_error(exc))[:max_length]
|
||||
|
||||
|
||||
def _log_form_data_exception(exc: FormDataException) -> None:
|
||||
"""记录表单数据业务异常的完整信息(含 context 中的原始错误详情,不截断)"""
|
||||
log_fn = logger.error if exc.http_status >= 500 else logger.warning
|
||||
log_fn("表单数据业务异常: [%s] %s", exc.error_code, exc)
|
||||
|
||||
if not exc.context:
|
||||
return
|
||||
|
||||
if detail := exc.context.get("detail"):
|
||||
log_fn("原始错误详情:\n%s", detail)
|
||||
|
||||
other = {k: v for k, v in exc.context.items() if k != "detail" and v not in (None, "", [], {})}
|
||||
if other:
|
||||
log_fn("异常上下文: %s", other)
|
||||
|
||||
|
||||
def handle_db_error(e: Exception) -> HTTPException:
|
||||
"""
|
||||
API 层的统一入口:先转换为业务异常,再构建 HTTP 响应。
|
||||
|
||||
用法:
|
||||
except Exception as e:
|
||||
raise handle_db_error(e)
|
||||
"""
|
||||
if isinstance(e, FormDataException):
|
||||
_log_form_data_exception(e)
|
||||
return build_error_response(e)
|
||||
|
||||
logger.error("数据库操作错误: %s", e, exc_info=True)
|
||||
biz_exc = translate_db_error(e)
|
||||
_log_form_data_exception(biz_exc)
|
||||
return build_error_response(biz_exc)
|
||||
@@ -0,0 +1,979 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
动态 SQL 构建器(异步版本)
|
||||
支持 PostgreSQL、MySQL、SQL Server、Oracle 的 SQL 语法差异
|
||||
"""
|
||||
import json
|
||||
from datetime import date, datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from app.timezone import APP_TIMEZONE
|
||||
from core.database_manager.sql_utils import quote_identifier as _quote_identifier
|
||||
from core.database_manager.sql_utils import quote_table as _quote_table
|
||||
|
||||
|
||||
def _normalize_db_type(db_type: str) -> str:
|
||||
normalized = (db_type or "postgresql").lower()
|
||||
if normalized in ("mssql",):
|
||||
return "sqlserver"
|
||||
if normalized in ("postgres", "psql"):
|
||||
return "postgresql"
|
||||
return normalized
|
||||
|
||||
|
||||
class DynamicSQLBuilder:
|
||||
"""动态 SQL 构建器 - 适配多种数据库"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db_type: str,
|
||||
default_database: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
初始化 SQL 构建器
|
||||
|
||||
Args:
|
||||
db_type: postgresql | mysql | sqlserver | oracle
|
||||
default_database: 连接默认库(SQL Server 与之一致时不生成三段表名)
|
||||
"""
|
||||
self.db_type = _normalize_db_type(db_type)
|
||||
self.default_database = (default_database or "").strip()
|
||||
|
||||
def _uses_postgres_like(self) -> bool:
|
||||
return self.db_type == "postgresql"
|
||||
|
||||
def is_deleted_predicate(self) -> str:
|
||||
"""未删除行的 WHERE 条件片段(不含 AND)"""
|
||||
if self.db_type == "postgresql":
|
||||
return "is_deleted = false"
|
||||
return "is_deleted = 0"
|
||||
|
||||
def is_deleted_and_clause(self) -> str:
|
||||
"""未删除行的 AND 条件片段"""
|
||||
return f" AND {self.is_deleted_predicate()}"
|
||||
|
||||
def _like_operator(self, case_sensitive: bool) -> str:
|
||||
if self.db_type == "mysql":
|
||||
return "LIKE BINARY" if case_sensitive else "LIKE"
|
||||
if self._uses_postgres_like():
|
||||
return "LIKE" if case_sensitive else "ILIKE"
|
||||
return "LIKE"
|
||||
|
||||
# ============ 标识符引用 ============
|
||||
|
||||
def quote_identifier(self, name: str) -> str:
|
||||
"""引用标识符(表名、列名等)"""
|
||||
return _quote_identifier(name, self.db_type)
|
||||
|
||||
def _split_table_identifiers(
|
||||
self,
|
||||
table: str,
|
||||
schema: Optional[str] = None,
|
||||
database: Optional[str] = None,
|
||||
) -> Tuple[str, Optional[str], Optional[str]]:
|
||||
"""
|
||||
解析表名中的限定符,避免 SQL Server 将 schema.table 当成单段对象名。
|
||||
|
||||
- MySQL: database.table(表名含点且无 database 时拆分)
|
||||
- PG / SQL Server / Oracle: schema.table
|
||||
"""
|
||||
table = (table or "").strip()
|
||||
schema = (schema or "").strip() or None
|
||||
database = (database or "").strip() or None
|
||||
if not table:
|
||||
return table, schema, database
|
||||
|
||||
if self.db_type == "mysql":
|
||||
if "." in table and not database:
|
||||
db_part, tbl_part = table.split(".", 1)
|
||||
if db_part.strip() and tbl_part.strip():
|
||||
return tbl_part.strip(), schema, db_part.strip()
|
||||
return table, schema, database
|
||||
|
||||
if "." in table:
|
||||
if not schema:
|
||||
sch, tbl = table.split(".", 1)
|
||||
if sch.strip() and tbl.strip():
|
||||
return tbl.strip(), sch.strip(), database
|
||||
else:
|
||||
prefix = f"{schema}."
|
||||
if table.lower().startswith(prefix.lower()):
|
||||
return table[len(prefix) :].strip(), schema, database
|
||||
if table.count(".") >= 1:
|
||||
return table.rsplit(".", 1)[-1].strip(), schema, database
|
||||
|
||||
return table, schema, database
|
||||
|
||||
def build_table_name(
|
||||
self,
|
||||
table: str,
|
||||
schema: Optional[str] = None,
|
||||
database: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
构建完整的表名
|
||||
|
||||
PostgreSQL / SQL Server / Oracle: schema.table
|
||||
MySQL: database.table
|
||||
SQL Server 可选: database.schema.table(main_table_database)
|
||||
"""
|
||||
table, schema, database = self._split_table_identifiers(table, schema, database)
|
||||
|
||||
if self.db_type == "mysql":
|
||||
if database:
|
||||
return (
|
||||
f"{_quote_identifier(database, self.db_type)}"
|
||||
f".{_quote_identifier(table, self.db_type)}"
|
||||
)
|
||||
return _quote_identifier(table, self.db_type)
|
||||
|
||||
effective_schema = schema
|
||||
if self.db_type == "sqlserver" and not effective_schema:
|
||||
effective_schema = "dbo"
|
||||
|
||||
table_ref = (
|
||||
_quote_table(effective_schema, table, self.db_type)
|
||||
if effective_schema
|
||||
else _quote_identifier(table, self.db_type)
|
||||
)
|
||||
if self.db_type == "sqlserver" and database:
|
||||
conn_db = self.default_database.lower()
|
||||
if conn_db and database.lower() == conn_db:
|
||||
return table_ref
|
||||
return f"{_quote_identifier(database, self.db_type)}.{table_ref}"
|
||||
return table_ref
|
||||
|
||||
def _default_paging_order_by(self) -> str:
|
||||
"""SQL Server / Oracle 使用 OFFSET/FETCH 时必须带 ORDER BY。"""
|
||||
if self.db_type == "sqlserver":
|
||||
return "(SELECT NULL)"
|
||||
if self.db_type == "oracle":
|
||||
return "1"
|
||||
return ""
|
||||
|
||||
def _append_order_by_for_paging(self, sql: str, order_by: Optional[str]) -> str:
|
||||
if order_by:
|
||||
return f"{sql} ORDER BY {order_by}"
|
||||
if self.db_type in ("sqlserver", "oracle"):
|
||||
placeholder = self._default_paging_order_by()
|
||||
if placeholder:
|
||||
return f"{sql} ORDER BY {placeholder}"
|
||||
return sql
|
||||
|
||||
def _append_limit_offset(
|
||||
self, sql: str, limit: Optional[int], offset: Optional[int]
|
||||
) -> str:
|
||||
"""追加分页子句"""
|
||||
if limit is None:
|
||||
return sql
|
||||
off = offset or 0
|
||||
if self.db_type == "sqlserver":
|
||||
return f"{sql} OFFSET {off} ROWS FETCH NEXT {limit} ROWS ONLY"
|
||||
if self.db_type == "oracle":
|
||||
if off:
|
||||
return f"{sql} OFFSET {off} ROWS FETCH NEXT {limit} ROWS ONLY"
|
||||
return f"{sql} FETCH FIRST {limit} ROWS ONLY"
|
||||
sql += f" LIMIT {limit}"
|
||||
if off:
|
||||
sql += f" OFFSET {off}"
|
||||
return sql
|
||||
|
||||
# ============ 参数占位符 ============
|
||||
|
||||
def get_placeholder(self, index: int = 0) -> str:
|
||||
"""
|
||||
获取参数占位符
|
||||
|
||||
PostgreSQL: $1, $2, ...
|
||||
MySQL: %s
|
||||
"""
|
||||
if self.db_type == "postgresql":
|
||||
return f"${index + 1}"
|
||||
return "%s"
|
||||
|
||||
def get_placeholders(self, count: int, start_index: int = 0) -> List[str]:
|
||||
"""获取多个占位符"""
|
||||
return [self.get_placeholder(start_index + i) for i in range(count)]
|
||||
|
||||
# ============ 数据类型转换 ============
|
||||
|
||||
@staticmethod
|
||||
def _convert_value(value: Any) -> Any:
|
||||
"""
|
||||
转换数据值为适合数据库的类型
|
||||
|
||||
注意:日期时间的转换应该由 service.py 的 _convert_data_types 方法
|
||||
根据字段类型来处理,这里只处理基本的数据结构转换
|
||||
|
||||
Args:
|
||||
value: 原始值
|
||||
|
||||
Returns:
|
||||
转换后的值
|
||||
"""
|
||||
# 处理列表和字典类型
|
||||
if isinstance(value, list):
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
if isinstance(value, dict):
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
|
||||
return value
|
||||
|
||||
def _build_like_expr(self, quoted_field: str, case_sensitive: bool) -> str:
|
||||
"""根据数据库类型和大小写敏感标志构建 LIKE 表达式
|
||||
|
||||
Args:
|
||||
quoted_field: 已引用的字段名
|
||||
case_sensitive: True=大小写敏感, False=大小写不敏感
|
||||
|
||||
Returns:
|
||||
如: "CAST(name AS TEXT) LIKE :p" 或 "CAST(name AS TEXT) ILIKE :p"
|
||||
"""
|
||||
if self._uses_postgres_like():
|
||||
if case_sensitive:
|
||||
return f"CAST({quoted_field} AS TEXT) LIKE :{{}}"
|
||||
return f"CAST({quoted_field} AS TEXT) ILIKE :{{}}"
|
||||
if case_sensitive:
|
||||
return f"CAST({quoted_field} AS VARCHAR(4000)) LIKE :{{}}"
|
||||
return f"LOWER(CAST({quoted_field} AS VARCHAR(4000))) LIKE LOWER(:{{}})"
|
||||
|
||||
# ============ SELECT 构建 ============
|
||||
|
||||
def build_select(
|
||||
self,
|
||||
table: str,
|
||||
columns: List[str] = None,
|
||||
schema: Optional[str] = None,
|
||||
database: Optional[str] = None,
|
||||
where: Optional[Dict[str, Any]] = None,
|
||||
order_by: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
offset: Optional[int] = None
|
||||
) -> Tuple[str, Dict[str, Any]]:
|
||||
"""构建 SELECT 语句,返回命名参数"""
|
||||
full_table = self.build_table_name(table, schema, database)
|
||||
|
||||
# 列
|
||||
if columns:
|
||||
cols = ", ".join(self.quote_identifier(c) for c in columns)
|
||||
else:
|
||||
cols = "*"
|
||||
|
||||
sql = f"SELECT {cols} FROM {full_table}"
|
||||
params = {}
|
||||
|
||||
# WHERE
|
||||
if where:
|
||||
where_clause, where_params = self._build_where_named(where)
|
||||
if where_clause:
|
||||
sql += f" WHERE {where_clause}"
|
||||
params.update(where_params)
|
||||
|
||||
sql = self._append_order_by_for_paging(sql, order_by)
|
||||
sql = self._append_limit_offset(sql, limit, offset)
|
||||
|
||||
return sql, params
|
||||
|
||||
def build_count(
|
||||
self,
|
||||
table: str,
|
||||
schema: Optional[str] = None,
|
||||
database: Optional[str] = None,
|
||||
where: Optional[Dict[str, Any]] = None
|
||||
) -> Tuple[str, Dict[str, Any]]:
|
||||
"""构建 COUNT 查询,返回命名参数"""
|
||||
full_table = self.build_table_name(table, schema, database)
|
||||
sql = f"SELECT COUNT(*) as total FROM {full_table}"
|
||||
params = {}
|
||||
|
||||
if where:
|
||||
where_clause, where_params = self._build_where_named(where)
|
||||
if where_clause:
|
||||
sql += f" WHERE {where_clause}"
|
||||
params.update(where_params)
|
||||
|
||||
return sql, params
|
||||
|
||||
def build_cursor_select(
|
||||
self,
|
||||
table: str,
|
||||
columns: List[str] = None,
|
||||
schema: Optional[str] = None,
|
||||
database: Optional[str] = None,
|
||||
where: Optional[Dict[str, Any]] = None,
|
||||
order_fields: Optional[List[Tuple[str, str]]] = None,
|
||||
cursor_values: Optional[Dict[str, Any]] = None,
|
||||
direction: str = "next",
|
||||
limit: int = 20
|
||||
) -> Tuple[str, Dict[str, Any]]:
|
||||
"""构建游标分页 SELECT 语句
|
||||
|
||||
Args:
|
||||
order_fields: 排序字段列表 [(field, "ASC"|"DESC"), ...],最后一个必须是唯一键(id)
|
||||
cursor_values: 游标值字典 {field: value, ...},键与 order_fields 的 field 对应
|
||||
direction: 翻页方向 "next"(下一页) 或 "prev"(上一页)
|
||||
limit: 每页条数(内部会 +1 来判断 has_more)
|
||||
"""
|
||||
full_table = self.build_table_name(table, schema, database)
|
||||
|
||||
if columns:
|
||||
cols = ", ".join(self.quote_identifier(c) for c in columns)
|
||||
else:
|
||||
cols = "*"
|
||||
|
||||
sql = f"SELECT {cols} FROM {full_table}"
|
||||
params = {}
|
||||
|
||||
where_clauses = []
|
||||
|
||||
# 先构建业务 WHERE 条件
|
||||
if where:
|
||||
biz_clause, biz_params = self._build_where_named(where)
|
||||
if biz_clause:
|
||||
where_clauses.append(biz_clause)
|
||||
params.update(biz_params)
|
||||
|
||||
# 构建游标 WHERE 条件(行值比较)
|
||||
if cursor_values and order_fields:
|
||||
cursor_clause, cursor_params = self._build_cursor_where(
|
||||
order_fields, cursor_values, direction
|
||||
)
|
||||
if cursor_clause:
|
||||
where_clauses.append(cursor_clause)
|
||||
params.update(cursor_params)
|
||||
|
||||
if where_clauses:
|
||||
sql += f" WHERE {' AND '.join(where_clauses)}"
|
||||
|
||||
# 构建 ORDER BY(prev 方向需要反转排序)
|
||||
order_by = None
|
||||
if order_fields:
|
||||
order_parts = []
|
||||
for field, raw_dir in order_fields:
|
||||
if direction == "prev":
|
||||
actual_dir = "ASC" if raw_dir.upper() == "DESC" else "DESC"
|
||||
else:
|
||||
actual_dir = raw_dir.upper()
|
||||
order_parts.append(f"{self.quote_identifier(field)} {actual_dir}")
|
||||
order_by = ", ".join(order_parts)
|
||||
|
||||
sql = self._append_order_by_for_paging(sql, order_by)
|
||||
sql = self._append_limit_offset(sql, limit + 1, 0)
|
||||
|
||||
return sql, params
|
||||
|
||||
def _cursor_comparison_op(
|
||||
self,
|
||||
fields_with_values: List[Tuple[str, str]],
|
||||
direction: str,
|
||||
) -> str:
|
||||
"""游标翻页比较符(与首列排序方向、翻页方向一致)。"""
|
||||
first_dir = fields_with_values[0][1].upper()
|
||||
if direction == "prev":
|
||||
return ">" if first_dir == "DESC" else "<"
|
||||
return "<" if first_dir == "DESC" else ">"
|
||||
|
||||
def _build_cursor_where_lexographic(
|
||||
self,
|
||||
fields_with_values: List[Tuple[str, str]],
|
||||
cursor_values: Dict[str, Any],
|
||||
direction: str,
|
||||
) -> Tuple[str, Dict[str, Any]]:
|
||||
"""SQL Server / Oracle:等值前缀 + OR 链,避免行值比较语法不兼容。"""
|
||||
op = self._cursor_comparison_op(fields_with_values, direction)
|
||||
params: Dict[str, Any] = {}
|
||||
or_parts: List[str] = []
|
||||
|
||||
for i, (field, _) in enumerate(fields_with_values):
|
||||
conjuncts: List[str] = []
|
||||
for j in range(i):
|
||||
prev_field = fields_with_values[j][0]
|
||||
eq_name = f"cursor_eq_{i}_{j}"
|
||||
conjuncts.append(
|
||||
f"{self.quote_identifier(prev_field)} = :{eq_name}"
|
||||
)
|
||||
params[eq_name] = cursor_values[prev_field]
|
||||
cmp_name = f"cursor_cmp_{i}"
|
||||
conjuncts.append(
|
||||
f"{self.quote_identifier(field)} {op} :{cmp_name}"
|
||||
)
|
||||
params[cmp_name] = cursor_values[field]
|
||||
or_parts.append(f"({' AND '.join(conjuncts)})")
|
||||
|
||||
return f"({' OR '.join(or_parts)})", params
|
||||
|
||||
def _build_cursor_where(
|
||||
self,
|
||||
order_fields: List[Tuple[str, str]],
|
||||
cursor_values: Dict[str, Any],
|
||||
direction: str
|
||||
) -> Tuple[str, Dict[str, Any]]:
|
||||
"""构建游标 WHERE 条件。
|
||||
|
||||
PostgreSQL / MySQL:行值比较 (a, b) > (:a, :b)
|
||||
SQL Server / Oracle:等值前缀 + OR 链
|
||||
"""
|
||||
fields_with_values = [
|
||||
(f, d) for f, d in order_fields if f in cursor_values
|
||||
]
|
||||
if not fields_with_values:
|
||||
return "", {}
|
||||
|
||||
if self.db_type in ("sqlserver", "oracle"):
|
||||
return self._build_cursor_where_lexographic(
|
||||
fields_with_values, cursor_values, direction
|
||||
)
|
||||
|
||||
params = {}
|
||||
quoted_fields = []
|
||||
param_placeholders = []
|
||||
|
||||
for i, (field, _) in enumerate(fields_with_values):
|
||||
quoted_fields.append(self.quote_identifier(field))
|
||||
param_name = f"cursor_{i}"
|
||||
param_placeholders.append(f":{param_name}")
|
||||
params[param_name] = cursor_values[field]
|
||||
|
||||
op = self._cursor_comparison_op(fields_with_values, direction)
|
||||
lhs = f"({', '.join(quoted_fields)})"
|
||||
rhs = f"({', '.join(param_placeholders)})"
|
||||
clause = f"{lhs} {op} {rhs}"
|
||||
|
||||
return clause, params
|
||||
|
||||
# ============ INSERT 构建 ============
|
||||
|
||||
def build_insert(
|
||||
self,
|
||||
table: str,
|
||||
data: Dict[str, Any],
|
||||
schema: Optional[str] = None,
|
||||
database: Optional[str] = None,
|
||||
return_id: bool = True
|
||||
) -> Tuple[str, Dict[str, Any]]:
|
||||
"""构建 INSERT 语句,返回 SQL 和命名参数字典"""
|
||||
full_table = self.build_table_name(table, schema, database)
|
||||
|
||||
# 清理字段名中的空格,并转换数据类型
|
||||
cleaned_data = {}
|
||||
|
||||
for key, value in data.items():
|
||||
cleaned_key = key.strip()
|
||||
# 转换日期时间字符串为 Python 对象
|
||||
converted_value = self._convert_value(value)
|
||||
cleaned_data[cleaned_key] = converted_value
|
||||
|
||||
columns = list(cleaned_data.keys())
|
||||
|
||||
cols = ", ".join(self.quote_identifier(c) for c in columns)
|
||||
# 使用命名参数 :param_name(使用清理后的字段名)
|
||||
placeholders = ", ".join(f":{col}" for col in columns)
|
||||
|
||||
sql = f"INSERT INTO {full_table} ({cols}) VALUES ({placeholders})"
|
||||
|
||||
# 返回自增 ID
|
||||
if return_id and self.db_type == "postgresql":
|
||||
sql += " RETURNING id"
|
||||
|
||||
return sql, cleaned_data
|
||||
|
||||
def build_batch_insert(
|
||||
self,
|
||||
table: str,
|
||||
columns: List[str],
|
||||
rows: List[List[Any]],
|
||||
schema: Optional[str] = None,
|
||||
database: Optional[str] = None
|
||||
) -> Tuple[str, List[Any]]:
|
||||
"""构建批量 INSERT 语句"""
|
||||
full_table = self.build_table_name(table, schema, database)
|
||||
cols = ", ".join(self.quote_identifier(c) for c in columns)
|
||||
|
||||
# 构建多行 VALUES
|
||||
row_placeholders = []
|
||||
params = []
|
||||
param_index = 0
|
||||
|
||||
for row in rows:
|
||||
placeholders = ", ".join(self.get_placeholders(len(row), param_index))
|
||||
row_placeholders.append(f"({placeholders})")
|
||||
params.extend(row)
|
||||
param_index += len(row)
|
||||
|
||||
sql = f"INSERT INTO {full_table} ({cols}) VALUES {', '.join(row_placeholders)}"
|
||||
return sql, params
|
||||
|
||||
def build_batch_insert_named(
|
||||
self,
|
||||
table: str,
|
||||
data_list: List[Dict[str, Any]],
|
||||
schema: Optional[str] = None,
|
||||
database: Optional[str] = None
|
||||
) -> Tuple[str, Any]:
|
||||
"""
|
||||
构建批量 INSERT 语句
|
||||
|
||||
Args:
|
||||
table: 表名
|
||||
data_list: 数据列表,每个元素是一个字典
|
||||
schema: Schema 名
|
||||
database: 数据库名
|
||||
|
||||
Returns:
|
||||
PostgreSQL: (SQL 语句, 位置参数列表)
|
||||
MySQL: (SQL 语句, 命名参数字典)
|
||||
"""
|
||||
if not data_list:
|
||||
raise ValueError("data_list 不能为空")
|
||||
|
||||
full_table = self.build_table_name(table, schema, database)
|
||||
|
||||
# 获取所有列名(使用第一条数据的键)
|
||||
columns = list(data_list[0].keys())
|
||||
cols = ", ".join(self.quote_identifier(c) for c in columns)
|
||||
|
||||
if self.db_type == "postgresql":
|
||||
# PostgreSQL 使用位置参数 $1, $2, ...
|
||||
row_placeholders = []
|
||||
params = []
|
||||
param_index = 0
|
||||
|
||||
for data in data_list:
|
||||
placeholders = []
|
||||
for col in columns:
|
||||
placeholders.append(f"${param_index + 1}")
|
||||
# 转换数据类型并添加到参数列表
|
||||
params.append(self._convert_value(data.get(col)))
|
||||
param_index += 1
|
||||
row_placeholders.append(f"({', '.join(placeholders)})")
|
||||
|
||||
sql = f"INSERT INTO {full_table} ({cols}) VALUES {', '.join(row_placeholders)}"
|
||||
return sql, params
|
||||
else:
|
||||
# MySQL 使用命名参数
|
||||
row_placeholders = []
|
||||
params = {}
|
||||
|
||||
for row_idx, data in enumerate(data_list):
|
||||
placeholders = []
|
||||
for col in columns:
|
||||
param_name = f"p{row_idx}_{col}"
|
||||
placeholders.append(f":{param_name}")
|
||||
# 转换数据类型
|
||||
params[param_name] = self._convert_value(data.get(col))
|
||||
row_placeholders.append(f"({', '.join(placeholders)})")
|
||||
|
||||
sql = f"INSERT INTO {full_table} ({cols}) VALUES {', '.join(row_placeholders)}"
|
||||
return sql, params
|
||||
|
||||
# ============ UPDATE 构建 ============
|
||||
|
||||
def build_update(
|
||||
self,
|
||||
table: str,
|
||||
data: Dict[str, Any],
|
||||
pk_field: str,
|
||||
pk_value: Any,
|
||||
schema: Optional[str] = None,
|
||||
database: Optional[str] = None
|
||||
) -> Tuple[str, Dict[str, Any]]:
|
||||
"""构建 UPDATE 语句,返回 SQL 和命名参数字典"""
|
||||
full_table = self.build_table_name(table, schema, database)
|
||||
|
||||
set_clauses = []
|
||||
params = {}
|
||||
param_counter = 0
|
||||
|
||||
# 清理字段名中的空格,并转换数据类型
|
||||
for col, val in data.items():
|
||||
cleaned_col = col.strip()
|
||||
# 转换日期时间字符串为 Python 对象
|
||||
converted_val = self._convert_value(val)
|
||||
param_name = f"param_{param_counter}"
|
||||
set_clauses.append(f"{self.quote_identifier(cleaned_col)} = :{param_name}")
|
||||
params[param_name] = converted_val
|
||||
param_counter += 1
|
||||
|
||||
# 清理主键字段名
|
||||
cleaned_pk_field = pk_field.strip()
|
||||
pk_param_name = f"param_{param_counter}"
|
||||
sql = f"UPDATE {full_table} SET {', '.join(set_clauses)} WHERE {self.quote_identifier(cleaned_pk_field)} = :{pk_param_name}"
|
||||
params[pk_param_name] = pk_value
|
||||
|
||||
return sql, params
|
||||
|
||||
# ============ DELETE 构建 ============
|
||||
|
||||
def build_delete(
|
||||
self,
|
||||
table: str,
|
||||
pk_field: str,
|
||||
pk_value: Any,
|
||||
schema: Optional[str] = None,
|
||||
database: Optional[str] = None
|
||||
) -> Tuple[str, Dict[str, Any]]:
|
||||
"""构建 DELETE 语句,返回 SQL 和命名参数字典"""
|
||||
full_table = self.build_table_name(table, schema, database)
|
||||
sql = f"DELETE FROM {full_table} WHERE {self.quote_identifier(pk_field)} = :pk_value"
|
||||
return sql, {"pk_value": pk_value}
|
||||
|
||||
def build_delete_by_foreign_key(
|
||||
self,
|
||||
table: str,
|
||||
fk_field: str,
|
||||
fk_value: Any,
|
||||
schema: Optional[str] = None,
|
||||
database: Optional[str] = None
|
||||
) -> Tuple[str, Dict[str, Any]]:
|
||||
"""根据外键删除,返回 SQL 和命名参数字典"""
|
||||
full_table = self.build_table_name(table, schema, database)
|
||||
sql = f"DELETE FROM {full_table} WHERE {self.quote_identifier(fk_field)} = :fk_value"
|
||||
return sql, {"fk_value": fk_value}
|
||||
|
||||
# ============ WHERE 条件构建 ============
|
||||
|
||||
def _build_where_named(self, conditions: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]:
|
||||
"""构建 WHERE 子句(使用命名参数)"""
|
||||
clauses = []
|
||||
params = {}
|
||||
param_counter = 0
|
||||
|
||||
for field, condition in conditions.items():
|
||||
if condition is None:
|
||||
continue
|
||||
|
||||
# 处理多字段搜索(OR 关系)
|
||||
if field == '__search__' and isinstance(condition, dict):
|
||||
keyword = condition.get('keyword')
|
||||
search_fields = condition.get('fields', [])
|
||||
if keyword and search_fields:
|
||||
search_clauses = []
|
||||
for search_field in search_fields:
|
||||
quoted_search_field = self.quote_identifier(search_field)
|
||||
param_name = f"search_param_{param_counter}"
|
||||
# 使用 ILIKE(PostgreSQL)或 LIKE(其他数据库)进行不区分大小写的模糊搜索
|
||||
if self._uses_postgres_like():
|
||||
search_clauses.append(
|
||||
f"CAST({quoted_search_field} AS TEXT) ILIKE :{param_name}"
|
||||
)
|
||||
else:
|
||||
search_clauses.append(
|
||||
f"LOWER(CAST({quoted_search_field} AS VARCHAR(4000))) "
|
||||
f"LIKE LOWER(:{param_name})"
|
||||
)
|
||||
params[param_name] = f"%{keyword}%"
|
||||
param_counter += 1
|
||||
if search_clauses:
|
||||
clauses.append(f"({' OR '.join(search_clauses)})")
|
||||
continue
|
||||
|
||||
quoted_field = self.quote_identifier(field)
|
||||
|
||||
if isinstance(condition, dict):
|
||||
cond_type = condition.get("type", "eq")
|
||||
value = condition.get("value")
|
||||
|
||||
if cond_type == "like" and value:
|
||||
case_sensitive = condition.get("case_sensitive", True)
|
||||
param_name = f"param_{param_counter}"
|
||||
like_expr = self._build_like_expr(quoted_field, case_sensitive)
|
||||
clauses.append(like_expr.format(param_name))
|
||||
params[param_name] = f"%{value}%"
|
||||
param_counter += 1
|
||||
elif cond_type == "eq" and value is not None:
|
||||
param_name = f"param_{param_counter}"
|
||||
case_sensitive = condition.get("case_sensitive", True)
|
||||
if case_sensitive:
|
||||
clauses.append(f"{quoted_field} = :{param_name}")
|
||||
else:
|
||||
clauses.append(f"LOWER({quoted_field}) = LOWER(:{param_name})")
|
||||
params[param_name] = value
|
||||
param_counter += 1
|
||||
elif cond_type == "ne" and value is not None:
|
||||
param_name = f"param_{param_counter}"
|
||||
clauses.append(f"{quoted_field} != :{param_name}")
|
||||
params[param_name] = value
|
||||
param_counter += 1
|
||||
elif cond_type == "gt" and value is not None:
|
||||
param_name = f"param_{param_counter}"
|
||||
clauses.append(f"{quoted_field} > :{param_name}")
|
||||
params[param_name] = value
|
||||
param_counter += 1
|
||||
elif cond_type == "gte" and value is not None:
|
||||
param_name = f"param_{param_counter}"
|
||||
clauses.append(f"{quoted_field} >= :{param_name}")
|
||||
params[param_name] = value
|
||||
param_counter += 1
|
||||
elif cond_type == "lt" and value is not None:
|
||||
param_name = f"param_{param_counter}"
|
||||
clauses.append(f"{quoted_field} < :{param_name}")
|
||||
params[param_name] = value
|
||||
param_counter += 1
|
||||
elif cond_type == "lte" and value is not None:
|
||||
param_name = f"param_{param_counter}"
|
||||
clauses.append(f"{quoted_field} <= :{param_name}")
|
||||
params[param_name] = value
|
||||
param_counter += 1
|
||||
elif cond_type == "range" and value and isinstance(value, list) and len(value) == 2:
|
||||
param_name_start = f"param_{param_counter}"
|
||||
param_name_end = f"param_{param_counter + 1}"
|
||||
clauses.append(f"{quoted_field} BETWEEN :{param_name_start} AND :{param_name_end}")
|
||||
params[param_name_start] = value[0]
|
||||
params[param_name_end] = value[1]
|
||||
param_counter += 2
|
||||
elif cond_type == "in" and value:
|
||||
# IN 条件:value 是一个列表
|
||||
if isinstance(value, list) and len(value) > 0:
|
||||
placeholders = []
|
||||
for v in value:
|
||||
param_name = f"param_{param_counter}"
|
||||
placeholders.append(f":{param_name}")
|
||||
params[param_name] = v
|
||||
param_counter += 1
|
||||
clauses.append(f"{quoted_field} IN ({', '.join(placeholders)})")
|
||||
elif cond_type == "eq_or_null" and value is not None:
|
||||
# 等值 OR 字段为空(用于数据权限:字段为空时所有人可见)
|
||||
param_name = f"param_{param_counter}"
|
||||
clauses.append(f"({quoted_field} = :{param_name} OR {quoted_field} IS NULL)")
|
||||
params[param_name] = value
|
||||
param_counter += 1
|
||||
elif cond_type == "in_or_null" and value:
|
||||
# IN 条件 OR 字段为空(用于数据权限:字段为空时所有人可见)
|
||||
if isinstance(value, list) and len(value) > 0:
|
||||
placeholders = []
|
||||
for v in value:
|
||||
param_name = f"param_{param_counter}"
|
||||
placeholders.append(f":{param_name}")
|
||||
params[param_name] = v
|
||||
param_counter += 1
|
||||
clauses.append(f"({quoted_field} IN ({', '.join(placeholders)}) OR {quoted_field} IS NULL)")
|
||||
elif cond_type == "space_like_and" and value:
|
||||
# 空格模糊且:按空格拆分关键词,用 AND + LIKE 连接(范围逐渐缩小)
|
||||
case_sensitive = condition.get("case_sensitive", True)
|
||||
keywords = str(value).split()
|
||||
if keywords:
|
||||
keyword_clauses = []
|
||||
for keyword in keywords:
|
||||
param_name = f"param_{param_counter}"
|
||||
like_expr = self._build_like_expr(quoted_field, case_sensitive)
|
||||
keyword_clauses.append(like_expr.format(param_name))
|
||||
params[param_name] = f"%{keyword}%"
|
||||
param_counter += 1
|
||||
clauses.append(f"({' AND '.join(keyword_clauses)})")
|
||||
elif cond_type == "space_like_or" and value:
|
||||
# 空格模糊或:按空格拆分关键词,用 OR + LIKE 连接(范围逐渐扩大)
|
||||
case_sensitive = condition.get("case_sensitive", True)
|
||||
keywords = str(value).split()
|
||||
if keywords:
|
||||
keyword_clauses = []
|
||||
for keyword in keywords:
|
||||
param_name = f"param_{param_counter}"
|
||||
like_expr = self._build_like_expr(quoted_field, case_sensitive)
|
||||
keyword_clauses.append(like_expr.format(param_name))
|
||||
params[param_name] = f"%{keyword}%"
|
||||
param_counter += 1
|
||||
clauses.append(f"({' OR '.join(keyword_clauses)})")
|
||||
elif cond_type == "space_eq_and" and value:
|
||||
# 空格精确且:按空格拆分关键词,用 AND + = 连接(范围逐渐缩小)
|
||||
case_sensitive = condition.get("case_sensitive", True)
|
||||
keywords = str(value).split()
|
||||
if keywords:
|
||||
keyword_clauses = []
|
||||
for keyword in keywords:
|
||||
param_name = f"param_{param_counter}"
|
||||
if case_sensitive:
|
||||
keyword_clauses.append(f"{quoted_field} = :{param_name}")
|
||||
else:
|
||||
keyword_clauses.append(f"LOWER({quoted_field}) = LOWER(:{param_name})")
|
||||
params[param_name] = keyword
|
||||
param_counter += 1
|
||||
clauses.append(f"({' AND '.join(keyword_clauses)})")
|
||||
elif cond_type == "space_eq_or" and value:
|
||||
# 空格精确或:按空格拆分关键词,用 OR + = 连接(范围逐渐扩大)
|
||||
case_sensitive = condition.get("case_sensitive", True)
|
||||
keywords = str(value).split()
|
||||
if keywords:
|
||||
keyword_clauses = []
|
||||
for keyword in keywords:
|
||||
param_name = f"param_{param_counter}"
|
||||
if case_sensitive:
|
||||
keyword_clauses.append(f"{quoted_field} = :{param_name}")
|
||||
else:
|
||||
keyword_clauses.append(f"LOWER({quoted_field}) = LOWER(:{param_name})")
|
||||
params[param_name] = keyword
|
||||
param_counter += 1
|
||||
clauses.append(f"({' OR '.join(keyword_clauses)})")
|
||||
elif cond_type == "null":
|
||||
clauses.append(f"{quoted_field} IS NULL")
|
||||
elif cond_type == "not_null":
|
||||
clauses.append(f"{quoted_field} IS NOT NULL")
|
||||
else:
|
||||
# 简单等值条件
|
||||
if condition is not None and condition != "":
|
||||
param_name = f"param_{param_counter}"
|
||||
clauses.append(f"{quoted_field} = :{param_name}")
|
||||
params[param_name] = condition
|
||||
param_counter += 1
|
||||
|
||||
return " AND ".join(clauses), params
|
||||
|
||||
def _build_where(self, conditions: Dict[str, Any], start_index: int = 0) -> Tuple[str, List[Any], int]:
|
||||
"""
|
||||
构建 WHERE 子句
|
||||
|
||||
支持的条件格式:
|
||||
- {"field": value} -> field = value
|
||||
- {"field": {"type": "like", "value": "xxx"}} -> field LIKE '%xxx%'
|
||||
- {"field": {"type": "eq", "value": xxx}} -> field = xxx
|
||||
- {"field": {"type": "ne", "value": xxx}} -> field != xxx
|
||||
- {"field": {"type": "gt", "value": xxx}} -> field > xxx
|
||||
- {"field": {"type": "gte", "value": xxx}} -> field >= xxx
|
||||
- {"field": {"type": "lt", "value": xxx}} -> field < xxx
|
||||
- {"field": {"type": "lte", "value": xxx}} -> field <= xxx
|
||||
- {"field": {"type": "in", "value": [...]}} -> field IN (...)
|
||||
- {"field": {"type": "range", "value": [start, end]}} -> field BETWEEN start AND end
|
||||
- {"field": {"type": "null"}} -> field IS NULL
|
||||
- {"field": {"type": "not_null"}} -> field IS NOT NULL
|
||||
"""
|
||||
clauses = []
|
||||
params = []
|
||||
param_index = start_index
|
||||
|
||||
for field, condition in conditions.items():
|
||||
quoted_field = self.quote_identifier(field)
|
||||
|
||||
if condition is None:
|
||||
continue
|
||||
|
||||
if isinstance(condition, dict):
|
||||
cond_type = condition.get("type", "eq")
|
||||
value = condition.get("value")
|
||||
|
||||
if cond_type == "like" and value:
|
||||
case_sensitive = condition.get("case_sensitive", True)
|
||||
if self.db_type == "postgresql":
|
||||
like_op = "LIKE" if case_sensitive else "ILIKE"
|
||||
clauses.append(f"CAST({quoted_field} AS TEXT) {like_op} {self.get_placeholder(param_index)}")
|
||||
else:
|
||||
like_op = self._like_operator(case_sensitive)
|
||||
cast_type = "VARCHAR(4000)" if self.db_type in ("sqlserver", "oracle") else "CHAR"
|
||||
clauses.append(
|
||||
f"CAST({quoted_field} AS {cast_type}) {like_op} "
|
||||
f"{self.get_placeholder(param_index)}"
|
||||
)
|
||||
params.append(f"%{value}%")
|
||||
param_index += 1
|
||||
elif cond_type == "eq" and value is not None:
|
||||
case_sensitive = condition.get("case_sensitive", True)
|
||||
if case_sensitive:
|
||||
clauses.append(f"{quoted_field} = {self.get_placeholder(param_index)}")
|
||||
else:
|
||||
clauses.append(f"LOWER({quoted_field}) = LOWER({self.get_placeholder(param_index)})")
|
||||
params.append(value)
|
||||
param_index += 1
|
||||
elif cond_type == "ne" and value is not None:
|
||||
clauses.append(f"{quoted_field} != {self.get_placeholder(param_index)}")
|
||||
params.append(value)
|
||||
param_index += 1
|
||||
elif cond_type == "gt" and value is not None:
|
||||
clauses.append(f"{quoted_field} > {self.get_placeholder(param_index)}")
|
||||
params.append(value)
|
||||
param_index += 1
|
||||
elif cond_type == "gte" and value is not None:
|
||||
clauses.append(f"{quoted_field} >= {self.get_placeholder(param_index)}")
|
||||
params.append(value)
|
||||
param_index += 1
|
||||
elif cond_type == "lt" and value is not None:
|
||||
clauses.append(f"{quoted_field} < {self.get_placeholder(param_index)}")
|
||||
params.append(value)
|
||||
param_index += 1
|
||||
elif cond_type == "lte" and value is not None:
|
||||
clauses.append(f"{quoted_field} <= {self.get_placeholder(param_index)}")
|
||||
params.append(value)
|
||||
param_index += 1
|
||||
elif cond_type == "in" and value:
|
||||
placeholders = ", ".join(self.get_placeholders(len(value), param_index))
|
||||
clauses.append(f"{quoted_field} IN ({placeholders})")
|
||||
params.extend(value)
|
||||
param_index += len(value)
|
||||
elif cond_type == "range" and value and len(value) == 2:
|
||||
clauses.append(f"{quoted_field} BETWEEN {self.get_placeholder(param_index)} AND {self.get_placeholder(param_index + 1)}")
|
||||
params.extend(value)
|
||||
param_index += 2
|
||||
elif cond_type == "space_like_and" and value:
|
||||
case_sensitive = condition.get("case_sensitive", True)
|
||||
keywords = str(value).split()
|
||||
if keywords:
|
||||
keyword_clauses = []
|
||||
for keyword in keywords:
|
||||
if self.db_type == "postgresql":
|
||||
like_op = "LIKE" if case_sensitive else "ILIKE"
|
||||
keyword_clauses.append(f"CAST({quoted_field} AS TEXT) {like_op} {self.get_placeholder(param_index)}")
|
||||
else:
|
||||
like_op = self._like_operator(case_sensitive)
|
||||
cast_type = "VARCHAR(4000)" if self.db_type in ("sqlserver", "oracle") else "CHAR"
|
||||
keyword_clauses.append(
|
||||
f"CAST({quoted_field} AS {cast_type}) {like_op} "
|
||||
f"{self.get_placeholder(param_index)}"
|
||||
)
|
||||
params.append(f"%{keyword}%")
|
||||
param_index += 1
|
||||
clauses.append(f"({' AND '.join(keyword_clauses)})")
|
||||
elif cond_type == "space_like_or" and value:
|
||||
case_sensitive = condition.get("case_sensitive", True)
|
||||
keywords = str(value).split()
|
||||
if keywords:
|
||||
keyword_clauses = []
|
||||
for keyword in keywords:
|
||||
if self.db_type == "postgresql":
|
||||
like_op = "LIKE" if case_sensitive else "ILIKE"
|
||||
keyword_clauses.append(f"CAST({quoted_field} AS TEXT) {like_op} {self.get_placeholder(param_index)}")
|
||||
else:
|
||||
like_op = self._like_operator(case_sensitive)
|
||||
cast_type = "VARCHAR(4000)" if self.db_type in ("sqlserver", "oracle") else "CHAR"
|
||||
keyword_clauses.append(
|
||||
f"CAST({quoted_field} AS {cast_type}) {like_op} "
|
||||
f"{self.get_placeholder(param_index)}"
|
||||
)
|
||||
params.append(f"%{keyword}%")
|
||||
param_index += 1
|
||||
clauses.append(f"({' OR '.join(keyword_clauses)})")
|
||||
elif cond_type == "space_eq_and" and value:
|
||||
case_sensitive = condition.get("case_sensitive", True)
|
||||
keywords = str(value).split()
|
||||
if keywords:
|
||||
keyword_clauses = []
|
||||
for keyword in keywords:
|
||||
if case_sensitive:
|
||||
keyword_clauses.append(f"{quoted_field} = {self.get_placeholder(param_index)}")
|
||||
else:
|
||||
keyword_clauses.append(f"LOWER({quoted_field}) = LOWER({self.get_placeholder(param_index)})")
|
||||
params.append(keyword)
|
||||
param_index += 1
|
||||
clauses.append(f"({' AND '.join(keyword_clauses)})")
|
||||
elif cond_type == "space_eq_or" and value:
|
||||
case_sensitive = condition.get("case_sensitive", True)
|
||||
keywords = str(value).split()
|
||||
if keywords:
|
||||
keyword_clauses = []
|
||||
for keyword in keywords:
|
||||
if case_sensitive:
|
||||
keyword_clauses.append(f"{quoted_field} = {self.get_placeholder(param_index)}")
|
||||
else:
|
||||
keyword_clauses.append(f"LOWER({quoted_field}) = LOWER({self.get_placeholder(param_index)})")
|
||||
params.append(keyword)
|
||||
param_index += 1
|
||||
clauses.append(f"({' OR '.join(keyword_clauses)})")
|
||||
elif cond_type == "null":
|
||||
clauses.append(f"{quoted_field} IS NULL")
|
||||
elif cond_type == "not_null":
|
||||
clauses.append(f"{quoted_field} IS NOT NULL")
|
||||
else:
|
||||
# 简单等值条件
|
||||
if condition is not None and condition != "":
|
||||
clauses.append(f"{quoted_field} = {self.get_placeholder(param_index)}")
|
||||
params.append(condition)
|
||||
param_index += 1
|
||||
|
||||
return " AND ".join(clauses), params, param_index
|
||||
@@ -0,0 +1,308 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
表单数据操作 — 细粒度异常体系
|
||||
|
||||
异常层次:
|
||||
FormDataException (所有表单数据异常的基类)
|
||||
├── FormNotFoundException (表单不存在)
|
||||
├── RecordNotFoundException (记录不存在)
|
||||
├── FormDataValidationError (数据校验失败的基类)
|
||||
│ ├── UniqueConstraintError (唯一约束冲突)
|
||||
│ ├── NotNullConstraintError (非空约束违反)
|
||||
│ ├── CheckConstraintError (CHECK 约束违反)
|
||||
│ ├── ForeignKeyConstraintError (外键约束违反)
|
||||
│ ├── DataTypeMismatchError (数据类型不匹配)
|
||||
│ └── DataTooLongError (数据超长)
|
||||
├── FormDataSchemaError (表结构/配置错误的基类)
|
||||
│ ├── TableNotFoundError (表不存在)
|
||||
│ └── ColumnNotFoundError (列不存在)
|
||||
├── FormDataConnectionError (连接相关错误的基类)
|
||||
│ ├── DatabaseConnectionError (连接失败/断开)
|
||||
│ ├── ConnectionPoolExhaustedError (连接池耗尽)
|
||||
│ └── ConnectionTimeoutError (连接超时)
|
||||
├── FormDataConcurrencyError (并发相关错误的基类)
|
||||
│ ├── DeadlockError (死锁)
|
||||
│ └── LockTimeoutError (锁等待超时)
|
||||
├── QueryError (SQL 查询构建/执行错误)
|
||||
└── InternalDatabaseError (其他数据库内部错误)
|
||||
|
||||
每个异常类携带 error_code 和结构化的上下文信息 (context),
|
||||
方便 API 层映射到 HTTP 响应 + 前端根据 error_code 做差异化处理。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 基类
|
||||
# =====================================================================
|
||||
|
||||
class FormDataException(Exception):
|
||||
"""表单数据操作异常基类"""
|
||||
|
||||
error_code: str = "FORM_DATA_ERROR"
|
||||
http_status: int = 400
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
error_code: Optional[str] = None,
|
||||
http_status: Optional[int] = None,
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
super().__init__(message)
|
||||
if error_code is not None:
|
||||
self.error_code = error_code
|
||||
if http_status is not None:
|
||||
self.http_status = http_status
|
||||
self.context: Dict[str, Any] = context or {}
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 资源不存在
|
||||
# =====================================================================
|
||||
|
||||
class FormNotFoundException(FormDataException):
|
||||
"""表单定义不存在"""
|
||||
error_code = "FORM_NOT_FOUND"
|
||||
http_status = 404
|
||||
|
||||
def __init__(self, form_code: str):
|
||||
super().__init__(
|
||||
f"表单不存在: {form_code}",
|
||||
context={"form_code": form_code},
|
||||
)
|
||||
|
||||
|
||||
class RecordNotFoundException(FormDataException):
|
||||
"""记录不存在"""
|
||||
error_code = "RECORD_NOT_FOUND"
|
||||
http_status = 404
|
||||
|
||||
def __init__(self, pk: Any, *, table: str = ""):
|
||||
super().__init__(
|
||||
f"数据不存在: {pk}",
|
||||
context={"pk": str(pk), "table": table},
|
||||
)
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 数据校验 / 约束违反
|
||||
# =====================================================================
|
||||
|
||||
class FormDataValidationError(FormDataException):
|
||||
"""数据校验失败的基类"""
|
||||
error_code = "VALIDATION_ERROR"
|
||||
http_status = 400
|
||||
|
||||
|
||||
class UniqueConstraintError(FormDataValidationError):
|
||||
"""唯一约束冲突(含重复数据信息)"""
|
||||
error_code = "UNIQUE_CONSTRAINT_VIOLATION"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str = "数据重复,违反唯一约束",
|
||||
*,
|
||||
fields: Optional[list[str]] = None,
|
||||
constraint_name: str = "",
|
||||
):
|
||||
super().__init__(
|
||||
message,
|
||||
context={"fields": fields or [], "constraint_name": constraint_name},
|
||||
)
|
||||
|
||||
|
||||
class NotNullConstraintError(FormDataValidationError):
|
||||
"""非空约束违反"""
|
||||
error_code = "NOT_NULL_VIOLATION"
|
||||
|
||||
def __init__(self, column: str = ""):
|
||||
msg = f"字段 '{column}' 不能为空" if column else "存在必填字段为空"
|
||||
super().__init__(msg, context={"column": column})
|
||||
|
||||
|
||||
class CheckConstraintError(FormDataValidationError):
|
||||
"""CHECK 约束违反"""
|
||||
error_code = "CHECK_CONSTRAINT_VIOLATION"
|
||||
|
||||
def __init__(self, constraint_name: str = "", detail: str = ""):
|
||||
msg = "数据不满足校验规则"
|
||||
if constraint_name:
|
||||
msg += f" ({constraint_name})"
|
||||
super().__init__(msg, context={"constraint_name": constraint_name, "detail": detail})
|
||||
|
||||
|
||||
class ForeignKeyConstraintError(FormDataValidationError):
|
||||
"""外键约束违反(删除/更新时关联数据存在)"""
|
||||
error_code = "FOREIGN_KEY_VIOLATION"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str = "",
|
||||
*,
|
||||
detail: str = "",
|
||||
referenced_table: str = "",
|
||||
constraint_name: str = "",
|
||||
):
|
||||
if not message:
|
||||
message = "操作失败,该数据被其他数据引用"
|
||||
if referenced_table:
|
||||
message = (
|
||||
f"无法删除,该数据已被「{referenced_table}」引用,"
|
||||
"请先删除或解除关联数据"
|
||||
)
|
||||
elif detail:
|
||||
message += f"({detail})"
|
||||
super().__init__(
|
||||
message,
|
||||
context={
|
||||
"detail": detail,
|
||||
"referenced_table": referenced_table,
|
||||
"constraint_name": constraint_name,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class DataTypeMismatchError(FormDataValidationError):
|
||||
"""数据类型不匹配"""
|
||||
error_code = "DATA_TYPE_MISMATCH"
|
||||
|
||||
def __init__(self, detail: str = ""):
|
||||
msg = "数据类型不匹配,请检查输入数据格式"
|
||||
if detail:
|
||||
msg += f"({detail})"
|
||||
super().__init__(msg, context={"detail": detail})
|
||||
|
||||
|
||||
class DataTooLongError(FormDataValidationError):
|
||||
"""数据长度超出字段限制"""
|
||||
error_code = "DATA_TOO_LONG"
|
||||
|
||||
def __init__(self, column: str = "", max_length: int = 0):
|
||||
msg = f"字段 '{column}' 的数据过长" if column else "数据长度超出限制"
|
||||
if max_length:
|
||||
msg += f"(最大 {max_length} 个字符)"
|
||||
super().__init__(
|
||||
msg,
|
||||
context={"column": column, "max_length": max_length},
|
||||
)
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 表结构 / 配置错误
|
||||
# =====================================================================
|
||||
|
||||
class FormDataSchemaError(FormDataException):
|
||||
"""表结构或配置相关错误基类"""
|
||||
error_code = "SCHEMA_ERROR"
|
||||
http_status = 400
|
||||
|
||||
|
||||
class TableNotFoundError(FormDataSchemaError):
|
||||
"""数据库表不存在"""
|
||||
error_code = "TABLE_NOT_FOUND"
|
||||
|
||||
def __init__(self, table: str = ""):
|
||||
msg = f"数据库表 {table} 不存在,请检查数据源配置" if table else "数据库表不存在"
|
||||
super().__init__(msg, context={"table": table})
|
||||
|
||||
|
||||
class ColumnNotFoundError(FormDataSchemaError):
|
||||
"""数据库列不存在"""
|
||||
error_code = "COLUMN_NOT_FOUND"
|
||||
|
||||
def __init__(self, column: str = "", table: str = ""):
|
||||
msg = f"数据库字段 '{column}' 不存在" if column else "数据库字段不存在"
|
||||
msg += ",请检查表单配置与数据库表结构是否一致"
|
||||
super().__init__(msg, context={"column": column, "table": table})
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 连接相关
|
||||
# =====================================================================
|
||||
|
||||
class FormDataConnectionError(FormDataException):
|
||||
"""数据库连接相关错误基类"""
|
||||
error_code = "CONNECTION_ERROR"
|
||||
http_status = 503
|
||||
|
||||
|
||||
class DatabaseConnectionError(FormDataConnectionError):
|
||||
"""数据库连接失败或断开"""
|
||||
error_code = "DB_CONNECTION_FAILED"
|
||||
|
||||
def __init__(self, detail: str = ""):
|
||||
msg = "数据库连接异常,请稍后重试"
|
||||
super().__init__(msg, context={"detail": detail})
|
||||
|
||||
|
||||
class ConnectionPoolExhaustedError(FormDataConnectionError):
|
||||
"""连接池耗尽"""
|
||||
error_code = "CONNECTION_POOL_EXHAUSTED"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
"服务器繁忙(数据库连接池已满),请稍后重试",
|
||||
)
|
||||
|
||||
|
||||
class ConnectionTimeoutError(FormDataConnectionError):
|
||||
"""连接超时"""
|
||||
error_code = "CONNECTION_TIMEOUT"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("数据库连接超时,请稍后重试")
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 并发相关
|
||||
# =====================================================================
|
||||
|
||||
class FormDataConcurrencyError(FormDataException):
|
||||
"""并发相关错误基类"""
|
||||
error_code = "CONCURRENCY_ERROR"
|
||||
http_status = 409
|
||||
|
||||
|
||||
class DeadlockError(FormDataConcurrencyError):
|
||||
"""死锁"""
|
||||
error_code = "DEADLOCK"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("操作冲突(死锁),请重试")
|
||||
|
||||
|
||||
class LockTimeoutError(FormDataConcurrencyError):
|
||||
"""锁等待超时"""
|
||||
error_code = "LOCK_TIMEOUT"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("操作等待超时,请稍后重试")
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 查询 / 其他
|
||||
# =====================================================================
|
||||
|
||||
class QueryError(FormDataException):
|
||||
"""SQL 查询构建或执行错误"""
|
||||
error_code = "QUERY_ERROR"
|
||||
http_status = 400
|
||||
|
||||
def __init__(self, detail: str = ""):
|
||||
msg = "数据库查询异常,请检查表单配置是否正确"
|
||||
super().__init__(msg, context={"detail": detail})
|
||||
|
||||
|
||||
class InternalDatabaseError(FormDataException):
|
||||
"""其他未分类的数据库内部错误"""
|
||||
error_code = "INTERNAL_DB_ERROR"
|
||||
http_status = 500
|
||||
|
||||
def __init__(self, detail: str = ""):
|
||||
msg = "数据库操作失败,请联系管理员"
|
||||
super().__init__(msg, context={"detail": detail})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
表单管理模块
|
||||
"""
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
表单管理数据模型
|
||||
"""
|
||||
from sqlalchemy import Column, String, Text, Integer, Index, JSON, Boolean
|
||||
|
||||
from app.base_model import BaseModel
|
||||
|
||||
|
||||
class FormMeta(BaseModel):
|
||||
"""表单元数据"""
|
||||
__tablename__ = "form_meta"
|
||||
|
||||
# 所属应用(逻辑外键关联 core_application)
|
||||
application_id = Column(String(21), nullable=True, index=True, comment="所属应用ID")
|
||||
|
||||
name = Column(String(100), nullable=False, comment="表单名称")
|
||||
code = Column(String(100), unique=True, nullable=False, comment="表单编码")
|
||||
form_type = Column(String(20), default="normal", comment="表单类型: normal/workflow")
|
||||
description = Column(Text, default="", comment="描述")
|
||||
status = Column(String(20), default="draft", index=True, comment="状态: draft/published")
|
||||
version = Column(Integer, default=1, comment="版本号")
|
||||
|
||||
# 数据源配置
|
||||
db_config = Column(String(100), nullable=False, comment="数据库配置名")
|
||||
main_table = Column(String(100), nullable=False, comment="主表名")
|
||||
main_table_schema = Column(String(100), default="", comment="主表Schema")
|
||||
main_table_database = Column(String(100), default="", comment="主表数据库")
|
||||
|
||||
# 移动端配置
|
||||
show_in_mobile = Column(Boolean, default=False, comment="是否在移动端显示")
|
||||
|
||||
# 跨应用引用
|
||||
globally_visible = Column(Boolean, default=False, comment="是否全局可见(供其他应用引用)")
|
||||
|
||||
# 图标配置
|
||||
icon = Column(String(100), default="", comment="图标")
|
||||
icon_bg_color = Column(String(200), default="", comment="图标背景色")
|
||||
|
||||
# JSON 配置
|
||||
form_config = Column(JSON, default=dict, comment="表单设计配置")
|
||||
list_config = Column(JSON, default=dict, comment="列表设计配置")
|
||||
|
||||
|
||||
class FormSubTable(BaseModel):
|
||||
"""表单子表关联"""
|
||||
__tablename__ = "form_sub_table"
|
||||
|
||||
# 所属表单(逻辑外键)
|
||||
form_id = Column(String(50), nullable=False, index=True, comment="所属表单ID")
|
||||
|
||||
table_name = Column(String(100), nullable=False, comment="从表名")
|
||||
table_schema = Column(String(100), default="", comment="从表Schema")
|
||||
table_database = Column(String(100), default="", comment="从表数据库")
|
||||
alias = Column(String(100), default="", comment="别名")
|
||||
foreign_key = Column(String(100), nullable=False, comment="外键字段")
|
||||
related_field = Column(String(100), default="id", comment="关联主表字段")
|
||||
relation_type = Column(String(20), default="one-to-many", comment="关联类型: one-to-one/one-to-many")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
from .service import PageService, PageServiceException
|
||||
|
||||
__all__ = ["PageService", "PageServiceException"]
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
页面管理数据模型
|
||||
"""
|
||||
from sqlalchemy import Column, String, Text, Integer, JSON
|
||||
|
||||
from app.base_model import BaseModel
|
||||
|
||||
|
||||
class PageMeta(BaseModel):
|
||||
"""页面元数据"""
|
||||
__tablename__ = "page_meta"
|
||||
|
||||
# 所属应用(逻辑外键关联 core_application)
|
||||
application_id = Column(String(21), nullable=True, index=True, comment="所属应用ID")
|
||||
|
||||
name = Column(String(100), nullable=False, comment="页面名称")
|
||||
code = Column(String(100), unique=True, nullable=False, comment="页面编码")
|
||||
category = Column(String(50), default="", comment="分类")
|
||||
description = Column(Text, default="", comment="描述")
|
||||
status = Column(String(20), default="draft", index=True, comment="状态: draft/published")
|
||||
version = Column(Integer, default=1, comment="版本号")
|
||||
|
||||
# 页面配置(存储 dashboard-design 的配置)
|
||||
page_config = Column(JSON, default=dict, comment="页面设计配置")
|
||||
@@ -0,0 +1,507 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
页面元数据管理服务(异步版本)
|
||||
"""
|
||||
import logging
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from sqlalchemy import select, update, delete, func, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from online_dev.page_manager.model import PageMeta
|
||||
from app.data_scope_utils import get_data_scope_filter, apply_data_scope_to_conditions
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 资源类型(用于数据权限配置)
|
||||
RESOURCE_TYPE = "page"
|
||||
RESOURCE_DISPLAY_NAME = "页面管理"
|
||||
|
||||
|
||||
class PageServiceException(Exception):
|
||||
"""页面服务异常"""
|
||||
pass
|
||||
|
||||
|
||||
class PageService:
|
||||
"""
|
||||
页面元数据管理服务
|
||||
|
||||
数据权限:
|
||||
- 使用 list_with_data_scope() 自动应用数据权限
|
||||
- 支持本人、本部门、本部门及下级、全部等数据范围
|
||||
"""
|
||||
|
||||
# ============ 查询 ============
|
||||
|
||||
@staticmethod
|
||||
async def list(
|
||||
db: AsyncSession,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
application_id: str = None,
|
||||
name: str = None,
|
||||
code: str = None,
|
||||
category: str = None,
|
||||
status: str = None
|
||||
) -> Dict[str, Any]:
|
||||
"""分页查询页面列表"""
|
||||
conditions = [PageMeta.is_deleted == False]
|
||||
|
||||
# 应用过滤
|
||||
if application_id:
|
||||
conditions.append(PageMeta.application_id == application_id)
|
||||
else:
|
||||
# 如果没有指定 application_id,只返回主应用的页面(application_id 为 NULL)
|
||||
conditions.append(PageMeta.application_id.is_(None))
|
||||
|
||||
if name:
|
||||
conditions.append(PageMeta.name.ilike(f"%{name}%"))
|
||||
if code:
|
||||
conditions.append(PageMeta.code.ilike(f"%{code}%"))
|
||||
if category:
|
||||
conditions.append(PageMeta.category == category)
|
||||
if status:
|
||||
conditions.append(PageMeta.status == status)
|
||||
|
||||
# 获取总数
|
||||
count_stmt = select(func.count(PageMeta.id)).where(and_(*conditions))
|
||||
total_result = await db.execute(count_stmt)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# 获取列表
|
||||
offset = (page - 1) * page_size
|
||||
stmt = select(PageMeta).where(and_(*conditions)).order_by(
|
||||
PageMeta.sort, PageMeta.sys_create_datetime.desc()
|
||||
).offset(offset).limit(page_size)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
items = list(result.scalars().all())
|
||||
|
||||
return {
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def list_with_data_scope(
|
||||
db: AsyncSession,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
application_id: str = None,
|
||||
name: str = None,
|
||||
code: str = None,
|
||||
category: str = None,
|
||||
status: str = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
分页查询页面列表(带数据权限过滤)
|
||||
|
||||
自动从上下文获取当前用户信息,应用数据权限过滤
|
||||
"""
|
||||
conditions = [PageMeta.is_deleted == False]
|
||||
|
||||
# 应用过滤
|
||||
if application_id:
|
||||
conditions.append(PageMeta.application_id == application_id)
|
||||
else:
|
||||
conditions.append(PageMeta.application_id.is_(None))
|
||||
|
||||
if name:
|
||||
conditions.append(PageMeta.name.ilike(f"%{name}%"))
|
||||
if code:
|
||||
conditions.append(PageMeta.code.ilike(f"%{code}%"))
|
||||
if category:
|
||||
conditions.append(PageMeta.category == category)
|
||||
if status:
|
||||
conditions.append(PageMeta.status == status)
|
||||
|
||||
# 获取数据权限过滤条件并应用
|
||||
data_scope_filter = await get_data_scope_filter(db, RESOURCE_TYPE)
|
||||
scope_conditions = apply_data_scope_to_conditions(PageMeta, data_scope_filter)
|
||||
conditions.extend(scope_conditions)
|
||||
|
||||
# 获取总数
|
||||
count_stmt = select(func.count(PageMeta.id)).where(and_(*conditions))
|
||||
total_result = await db.execute(count_stmt)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# 获取列表
|
||||
offset = (page - 1) * page_size
|
||||
stmt = select(PageMeta).where(and_(*conditions)).order_by(
|
||||
PageMeta.sort, PageMeta.sys_create_datetime.desc()
|
||||
).offset(offset).limit(page_size)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
items = list(result.scalars().all())
|
||||
|
||||
return {
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def get(db: AsyncSession, page_id: str) -> PageMeta:
|
||||
"""获取页面详情"""
|
||||
stmt = select(PageMeta).where(
|
||||
PageMeta.id == page_id,
|
||||
PageMeta.is_deleted == False
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
page = result.scalar_one_or_none()
|
||||
|
||||
if not page:
|
||||
raise PageServiceException(f"页面不存在: {page_id}")
|
||||
|
||||
return page
|
||||
|
||||
@staticmethod
|
||||
async def get_by_code(db: AsyncSession, code: str) -> PageMeta:
|
||||
"""根据编码获取页面"""
|
||||
stmt = select(PageMeta).where(
|
||||
PageMeta.code == code,
|
||||
PageMeta.is_deleted == False
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
page = result.scalar_one_or_none()
|
||||
|
||||
if not page:
|
||||
raise PageServiceException(f"页面不存在: {code}")
|
||||
|
||||
return page
|
||||
|
||||
# ============ 创建 ============
|
||||
|
||||
@staticmethod
|
||||
async def create(
|
||||
db: AsyncSession,
|
||||
data: Dict[str, Any],
|
||||
user_id: str = None
|
||||
) -> PageMeta:
|
||||
"""创建页面"""
|
||||
code = data.get("code")
|
||||
|
||||
# 检查编码唯一性
|
||||
stmt = select(PageMeta).where(
|
||||
PageMeta.code == code,
|
||||
PageMeta.is_deleted == False
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
if result.scalar_one_or_none():
|
||||
raise PageServiceException(f"页面编码已存在: {code}")
|
||||
|
||||
# 从上下文获取用户信息
|
||||
from utils.context import get_current_user_info_from_context
|
||||
user_info = get_current_user_info_from_context()
|
||||
|
||||
page = PageMeta(
|
||||
application_id=data.get("application_id"),
|
||||
name=data.get("name"),
|
||||
code=code,
|
||||
category=data.get("category", ""),
|
||||
description=data.get("description", ""),
|
||||
page_config=data.get("page_config", {}),
|
||||
sort=data.get("sort", 0),
|
||||
sys_creator_id=user_id or (user_info.get('user_id') if user_info else None),
|
||||
sys_modifier_id=user_id or (user_info.get('user_id') if user_info else None),
|
||||
)
|
||||
|
||||
# 自动填充部门ID
|
||||
if user_info and user_info.get('dept_id'):
|
||||
page.sys_dept_id = user_info.get('dept_id')
|
||||
|
||||
db.add(page)
|
||||
await db.commit()
|
||||
await db.refresh(page)
|
||||
|
||||
logger.info(f"页面创建成功: {page.code}")
|
||||
return page
|
||||
|
||||
# ============ 更新 ============
|
||||
|
||||
@staticmethod
|
||||
async def update(
|
||||
db: AsyncSession,
|
||||
page_id: str,
|
||||
data: Dict[str, Any],
|
||||
user_id: str = None
|
||||
) -> PageMeta:
|
||||
"""更新页面"""
|
||||
page = await PageService.get(db, page_id)
|
||||
|
||||
# 更新基本字段
|
||||
if "name" in data and data["name"] is not None:
|
||||
page.name = data["name"]
|
||||
if "category" in data and data["category"] is not None:
|
||||
page.category = data["category"]
|
||||
if "description" in data and data["description"] is not None:
|
||||
page.description = data["description"]
|
||||
if "sort" in data and data["sort"] is not None:
|
||||
page.sort = data["sort"]
|
||||
if "page_config" in data and data["page_config"] is not None:
|
||||
page.page_config = data["page_config"]
|
||||
|
||||
page.sys_modifier_id = user_id
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(page)
|
||||
|
||||
logger.info(f"页面更新成功: {page.code}")
|
||||
return page
|
||||
|
||||
# ============ 删除 ============
|
||||
|
||||
@staticmethod
|
||||
async def _cleanup_page_publish_resources(db: AsyncSession, page: PageMeta) -> None:
|
||||
"""清理页面发布产生的菜单(取消发布、删除时共用)"""
|
||||
from core.menu.model import Menu
|
||||
|
||||
delete_menu_stmt = delete(Menu).where(
|
||||
Menu.path == f"/page-render/{page.code}"
|
||||
)
|
||||
result = await db.execute(delete_menu_stmt)
|
||||
if result.rowcount > 0:
|
||||
logger.info(
|
||||
"物理删除页面菜单: %s, 删除数量: %s",
|
||||
page.code,
|
||||
result.rowcount,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def delete(db: AsyncSession, page_id: str) -> bool:
|
||||
"""删除页面(软删除)"""
|
||||
from core.menu.service import MenuService
|
||||
|
||||
page = await PageService.get(db, page_id)
|
||||
await PageService._cleanup_page_publish_resources(db, page)
|
||||
page.is_deleted = True
|
||||
page.status = "draft"
|
||||
await db.commit()
|
||||
await MenuService.invalidate_cache()
|
||||
logger.info("页面删除成功: %s", page.code)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def batch_delete(db: AsyncSession, page_ids: List[str]) -> int:
|
||||
"""批量删除页面"""
|
||||
from core.menu.service import MenuService
|
||||
|
||||
stmt = select(PageMeta).where(
|
||||
PageMeta.id.in_(page_ids),
|
||||
PageMeta.is_deleted == False,
|
||||
)
|
||||
pages = list((await db.execute(stmt)).scalars().all())
|
||||
for page in pages:
|
||||
await PageService._cleanup_page_publish_resources(db, page)
|
||||
|
||||
update_stmt = update(PageMeta).where(
|
||||
PageMeta.id.in_(page_ids),
|
||||
PageMeta.is_deleted == False,
|
||||
).values(is_deleted=True, status="draft")
|
||||
result = await db.execute(update_stmt)
|
||||
await db.commit()
|
||||
|
||||
count = result.rowcount
|
||||
if count > 0:
|
||||
await MenuService.invalidate_cache()
|
||||
logger.info("批量删除页面成功: %s 个", count)
|
||||
return count
|
||||
|
||||
# ============ 发布/取消发布 ============
|
||||
|
||||
@staticmethod
|
||||
async def publish(
|
||||
db: AsyncSession,
|
||||
page_id: str,
|
||||
publish_config: Dict[str, Any] = None
|
||||
) -> PageMeta:
|
||||
"""发布页面并创建菜单"""
|
||||
from core.menu.model import Menu
|
||||
from core.menu.service import MenuService
|
||||
|
||||
page = await PageService.get(db, page_id)
|
||||
|
||||
if page.status == "published":
|
||||
raise PageServiceException("页面已发布")
|
||||
|
||||
# 更新页面状态
|
||||
page.status = "published"
|
||||
page.version += 1
|
||||
|
||||
# 创建或更新菜单
|
||||
if publish_config:
|
||||
menu_parent_id = publish_config.get("menu_parent_id")
|
||||
menu_path = f"/page-render/{page.code}"
|
||||
|
||||
# 检查是否已存在该页面的菜单
|
||||
menu_stmt = select(Menu).where(
|
||||
Menu.path == menu_path
|
||||
)
|
||||
menu_result = await db.execute(menu_stmt)
|
||||
existing_menu = menu_result.scalar_one_or_none()
|
||||
|
||||
if existing_menu:
|
||||
# 更新现有菜单
|
||||
existing_menu.name = publish_config.get("menu_name", page.name)
|
||||
existing_menu.title = publish_config.get("menu_name", page.name)
|
||||
existing_menu.parent_id = menu_parent_id
|
||||
existing_menu.icon = publish_config.get("menu_icon", "lucide:layout-dashboard")
|
||||
existing_menu.order = publish_config.get("menu_order", 0)
|
||||
existing_menu.type = "online_page"
|
||||
existing_menu.application_id = page.application_id
|
||||
logger.info(f"更新页面菜单: {page.code}")
|
||||
else:
|
||||
# 创建新菜单
|
||||
new_menu = Menu(
|
||||
application_id=page.application_id,
|
||||
name=publish_config.get("menu_name", page.name),
|
||||
title=publish_config.get("menu_name", page.name),
|
||||
path=menu_path,
|
||||
component="online-dev/page-render/index",
|
||||
type="online_page",
|
||||
parent_id=menu_parent_id,
|
||||
icon=publish_config.get("menu_icon", "lucide:layout-dashboard"),
|
||||
order=publish_config.get("menu_order", 0),
|
||||
)
|
||||
db.add(new_menu)
|
||||
logger.info(f"创建页面菜单: {page.code}")
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(page)
|
||||
|
||||
# 清空菜单缓存
|
||||
await MenuService.invalidate_cache()
|
||||
logger.info("已清空菜单缓存")
|
||||
|
||||
logger.info(f"页面发布成功: {page.code}, version={page.version}")
|
||||
return page
|
||||
|
||||
@staticmethod
|
||||
async def unpublish(db: AsyncSession, page_id: str) -> PageMeta:
|
||||
"""取消发布页面并物理删除菜单"""
|
||||
from core.menu.service import MenuService
|
||||
|
||||
page = await PageService.get(db, page_id)
|
||||
|
||||
if page.status == "draft":
|
||||
raise PageServiceException("页面未发布")
|
||||
|
||||
page.status = "draft"
|
||||
|
||||
await PageService._cleanup_page_publish_resources(db, page)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(page)
|
||||
|
||||
# 清空菜单缓存
|
||||
await MenuService.invalidate_cache()
|
||||
logger.info("已清空菜单缓存")
|
||||
|
||||
logger.info("页面取消发布: %s", page.code)
|
||||
return page
|
||||
|
||||
# ============ 复制 ============
|
||||
|
||||
@staticmethod
|
||||
async def copy(
|
||||
db: AsyncSession,
|
||||
page_id: str,
|
||||
new_code: str,
|
||||
new_name: str = None,
|
||||
user_id: str = None
|
||||
) -> PageMeta:
|
||||
"""复制页面"""
|
||||
source = await PageService.get(db, page_id)
|
||||
|
||||
# 检查新编码唯一性
|
||||
stmt = select(PageMeta).where(
|
||||
PageMeta.code == new_code,
|
||||
PageMeta.is_deleted == False
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
if result.scalar_one_or_none():
|
||||
raise PageServiceException(f"页面编码已存在: {new_code}")
|
||||
|
||||
new_page = PageMeta(
|
||||
application_id=source.application_id,
|
||||
name=new_name or f"{source.name}_副本",
|
||||
code=new_code,
|
||||
category=source.category,
|
||||
description=source.description,
|
||||
status="draft",
|
||||
version=1,
|
||||
page_config=source.page_config,
|
||||
sort=source.sort,
|
||||
sys_creator_id=user_id,
|
||||
sys_modifier_id=user_id,
|
||||
)
|
||||
db.add(new_page)
|
||||
await db.commit()
|
||||
await db.refresh(new_page)
|
||||
|
||||
logger.info(f"页面复制成功: {source.code} -> {new_code}")
|
||||
return new_page
|
||||
|
||||
# ============ 导入/导出 ============
|
||||
|
||||
@staticmethod
|
||||
async def export_config(db: AsyncSession, page_id: str) -> Dict[str, Any]:
|
||||
"""导出页面配置"""
|
||||
page = await PageService.get(db, page_id)
|
||||
|
||||
return {
|
||||
"name": page.name,
|
||||
"code": page.code,
|
||||
"category": page.category,
|
||||
"description": page.description,
|
||||
"page_config": page.page_config,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def check_import(db: AsyncSession, code: str) -> Dict[str, Any]:
|
||||
"""导入预检查:编码是否冲突"""
|
||||
code_exists = False
|
||||
if code:
|
||||
stmt = select(PageMeta).where(
|
||||
PageMeta.code == code,
|
||||
PageMeta.is_deleted == False,
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
code_exists = result.scalar_one_or_none() is not None
|
||||
|
||||
return {
|
||||
"code_exists": code_exists,
|
||||
"can_import": not code_exists,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def import_config(
|
||||
db: AsyncSession,
|
||||
data: Dict[str, Any],
|
||||
user_id: str = None
|
||||
) -> PageMeta:
|
||||
"""导入页面配置"""
|
||||
required_fields = ["name", "code"]
|
||||
for field in required_fields:
|
||||
if not data.get(field):
|
||||
raise PageServiceException(f"缺少必要字段: {field}")
|
||||
|
||||
return await PageService.create(db, data, user_id)
|
||||
|
||||
# ============ 获取分类列表 ============
|
||||
|
||||
@staticmethod
|
||||
async def get_categories(db: AsyncSession) -> List[str]:
|
||||
"""获取所有分类"""
|
||||
stmt = select(PageMeta.category).where(
|
||||
PageMeta.is_deleted == False,
|
||||
PageMeta.category != ""
|
||||
).distinct()
|
||||
|
||||
result = await db.execute(stmt)
|
||||
return [row[0] for row in result.fetchall()]
|
||||
Reference in New Issue
Block a user