From cb45c0a06846eba32d6e503f553637f7618e8747 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?cls=5F=E5=AE=81=E6=B3=A2=E6=9C=AC=E6=9C=BA?= <908705107@qq.com> Date: Tue, 9 Jun 2026 21:18:33 +0800 Subject: [PATCH] Restore AI workflow design nodes --- .gitignore | 28 + backend-fastapi/ai_platform/api/form_api.py | 96 + .../nodes/builtin/app_create_node.py | 258 + .../nodes/builtin/app_design_node.py | 205 + .../nodes/builtin/app_settings_node.py | 432 ++ .../nodes/builtin/app_update_node.py | 302 + .../builtin/dashboard_basic_info_node.py | 232 + .../nodes/builtin/dashboard_create_node.py | 338 + .../nodes/builtin/dashboard_design_node.py | 483 ++ .../nodes/builtin/dashboard_publish_node.py | 311 + .../nodes/builtin/form_basic_info_node.py | 333 + .../nodes/builtin/form_create_node.py | 499 ++ .../nodes/builtin/form_data_node.py | 1016 +++ .../builtin/form_database_create_node.py | 349 ++ .../builtin/form_database_design_node.py | 507 ++ .../nodes/builtin/form_list_design_node.py | 748 +++ .../nodes/builtin/form_publish_node.py | 313 + .../nodes/builtin/form_ui_design_node.py | 847 +++ .../nodes/builtin/system_summary_node.py | 501 ++ backend-fastapi/ai_platform/nodes/registry.py | 55 +- backend-fastapi/ai_platform/router.py | 4 +- backend-fastapi/online_dev/__init__.py | 0 .../online_dev/form_data_manager/__init__.py | 5 + .../form_data_manager/db_adapter.py | 300 + .../form_data_manager/db_error_handler.py | 551 ++ .../form_data_manager/dynamic_sql_builder.py | 979 +++ .../form_data_manager/exceptions.py | 308 + .../online_dev/form_data_manager/service.py | 5519 +++++++++++++++++ .../online_dev/form_manager/__init__.py | 5 + .../online_dev/form_manager/model.py | 59 + .../online_dev/form_manager/service.py | 1627 +++++ .../online_dev/page_manager/__init__.py | 3 + .../online_dev/page_manager/model.py | 26 + .../online_dev/page_manager/service.py | 507 ++ .../src/api/ai-platform/form-manager-lite.ts | 35 + .../workflow/editor/components/Panel.vue | 66 + .../ai-platform/workflow/editor/index.vue | 220 +- .../workflow/editor/nodes/AppCreateNode.vue | 59 + .../workflow/editor/nodes/AppDesignNode.vue | 57 + .../workflow/editor/nodes/AppSettingsNode.vue | 69 + .../workflow/editor/nodes/AppUpdateNode.vue | 57 + .../editor/nodes/DashboardBasicInfoNode.vue | 58 + .../editor/nodes/DashboardCreateNode.vue | 65 + .../editor/nodes/DashboardDesignNode.vue | 55 + .../editor/nodes/DashboardPublishNode.vue | 57 + .../editor/nodes/FormBasicInfoNode.vue | 75 + .../workflow/editor/nodes/FormCreateNode.vue | 51 + .../workflow/editor/nodes/FormDataNode.vue | 189 + .../editor/nodes/FormDatabaseCreateNode.vue | 74 + .../editor/nodes/FormDatabaseDesignNode.vue | 80 + .../editor/nodes/FormListDesignNode.vue | 86 + .../workflow/editor/nodes/FormPublishNode.vue | 56 + .../editor/nodes/FormUIDesignNode.vue | 89 + .../editor/nodes/SystemSummaryNode.vue | 80 + .../workflow/editor/panels/AppCreatePanel.vue | 189 + .../workflow/editor/panels/AppDesignPanel.vue | 114 + .../editor/panels/AppSettingsPanel.vue | 196 + .../workflow/editor/panels/AppUpdatePanel.vue | 103 + .../editor/panels/DashboardBasicInfoPanel.vue | 164 + .../editor/panels/DashboardCreatePanel.vue | 135 + .../editor/panels/DashboardDesignPanel.vue | 119 + .../editor/panels/DashboardPublishPanel.vue | 165 + .../editor/panels/FormBasicInfoPanel.vue | 206 + .../editor/panels/FormCreatePanel.vue | 228 + .../workflow/editor/panels/FormDataPanel.vue | 308 + .../editor/panels/FormDatabaseCreatePanel.vue | 206 + .../editor/panels/FormDatabaseDesignPanel.vue | 360 ++ .../editor/panels/FormListDesignPanel.vue | 286 + .../editor/panels/FormPublishPanel.vue | 132 + .../editor/panels/FormUIDesignPanel.vue | 248 + .../editor/panels/SystemSummaryPanel.vue | 214 + 71 files changed, 22676 insertions(+), 21 deletions(-) create mode 100644 backend-fastapi/ai_platform/api/form_api.py create mode 100644 backend-fastapi/ai_platform/nodes/builtin/app_create_node.py create mode 100644 backend-fastapi/ai_platform/nodes/builtin/app_design_node.py create mode 100644 backend-fastapi/ai_platform/nodes/builtin/app_settings_node.py create mode 100644 backend-fastapi/ai_platform/nodes/builtin/app_update_node.py create mode 100644 backend-fastapi/ai_platform/nodes/builtin/dashboard_basic_info_node.py create mode 100644 backend-fastapi/ai_platform/nodes/builtin/dashboard_create_node.py create mode 100644 backend-fastapi/ai_platform/nodes/builtin/dashboard_design_node.py create mode 100644 backend-fastapi/ai_platform/nodes/builtin/dashboard_publish_node.py create mode 100644 backend-fastapi/ai_platform/nodes/builtin/form_basic_info_node.py create mode 100644 backend-fastapi/ai_platform/nodes/builtin/form_create_node.py create mode 100644 backend-fastapi/ai_platform/nodes/builtin/form_data_node.py create mode 100644 backend-fastapi/ai_platform/nodes/builtin/form_database_create_node.py create mode 100644 backend-fastapi/ai_platform/nodes/builtin/form_database_design_node.py create mode 100644 backend-fastapi/ai_platform/nodes/builtin/form_list_design_node.py create mode 100644 backend-fastapi/ai_platform/nodes/builtin/form_publish_node.py create mode 100644 backend-fastapi/ai_platform/nodes/builtin/form_ui_design_node.py create mode 100644 backend-fastapi/ai_platform/nodes/builtin/system_summary_node.py create mode 100644 backend-fastapi/online_dev/__init__.py create mode 100644 backend-fastapi/online_dev/form_data_manager/__init__.py create mode 100644 backend-fastapi/online_dev/form_data_manager/db_adapter.py create mode 100644 backend-fastapi/online_dev/form_data_manager/db_error_handler.py create mode 100644 backend-fastapi/online_dev/form_data_manager/dynamic_sql_builder.py create mode 100644 backend-fastapi/online_dev/form_data_manager/exceptions.py create mode 100644 backend-fastapi/online_dev/form_data_manager/service.py create mode 100644 backend-fastapi/online_dev/form_manager/__init__.py create mode 100644 backend-fastapi/online_dev/form_manager/model.py create mode 100644 backend-fastapi/online_dev/form_manager/service.py create mode 100644 backend-fastapi/online_dev/page_manager/__init__.py create mode 100644 backend-fastapi/online_dev/page_manager/model.py create mode 100644 backend-fastapi/online_dev/page_manager/service.py create mode 100644 web/apps/web-ele/src/api/ai-platform/form-manager-lite.ts create mode 100644 web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/AppCreateNode.vue create mode 100644 web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/AppDesignNode.vue create mode 100644 web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/AppSettingsNode.vue create mode 100644 web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/AppUpdateNode.vue create mode 100644 web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/DashboardBasicInfoNode.vue create mode 100644 web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/DashboardCreateNode.vue create mode 100644 web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/DashboardDesignNode.vue create mode 100644 web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/DashboardPublishNode.vue create mode 100644 web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/FormBasicInfoNode.vue create mode 100644 web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/FormCreateNode.vue create mode 100644 web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/FormDataNode.vue create mode 100644 web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/FormDatabaseCreateNode.vue create mode 100644 web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/FormDatabaseDesignNode.vue create mode 100644 web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/FormListDesignNode.vue create mode 100644 web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/FormPublishNode.vue create mode 100644 web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/FormUIDesignNode.vue create mode 100644 web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/SystemSummaryNode.vue create mode 100644 web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/AppCreatePanel.vue create mode 100644 web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/AppDesignPanel.vue create mode 100644 web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/AppSettingsPanel.vue create mode 100644 web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/AppUpdatePanel.vue create mode 100644 web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/DashboardBasicInfoPanel.vue create mode 100644 web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/DashboardCreatePanel.vue create mode 100644 web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/DashboardDesignPanel.vue create mode 100644 web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/DashboardPublishPanel.vue create mode 100644 web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/FormBasicInfoPanel.vue create mode 100644 web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/FormCreatePanel.vue create mode 100644 web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/FormDataPanel.vue create mode 100644 web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/FormDatabaseCreatePanel.vue create mode 100644 web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/FormDatabaseDesignPanel.vue create mode 100644 web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/FormListDesignPanel.vue create mode 100644 web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/FormPublishPanel.vue create mode 100644 web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/FormUIDesignPanel.vue create mode 100644 web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/SystemSummaryPanel.vue diff --git a/.gitignore b/.gitignore index 0aa0e63..f777bb6 100644 --- a/.gitignore +++ b/.gitignore @@ -204,12 +204,38 @@ web/apps/web-ele/src/router/routes/modules/online-dev.ts web/apps/web-ele/src/router/routes/modules/zq-smart-table.ts web/apps/web-ele/src/components/report-design/ backend-fastapi/online_dev/ +!backend-fastapi/online_dev/ +backend-fastapi/online_dev/* +!backend-fastapi/online_dev/__init__.py +!backend-fastapi/online_dev/form_manager/ +!backend-fastapi/online_dev/form_manager/** +!backend-fastapi/online_dev/form_data_manager/ +!backend-fastapi/online_dev/form_data_manager/** +!backend-fastapi/online_dev/page_manager/ +!backend-fastapi/online_dev/page_manager/** backend-fastapi/zq_demo/ backend-fastapi/zq_smart_table/ backend-fastapi/ai_platform/nodes/builtin/app_*_node.py backend-fastapi/ai_platform/nodes/builtin/dashboard_*_node.py backend-fastapi/ai_platform/nodes/builtin/form_*_node.py backend-fastapi/ai_platform/nodes/builtin/system_summary_node.py +!backend-fastapi/ai_platform/nodes/builtin/app_create_node.py +!backend-fastapi/ai_platform/nodes/builtin/app_design_node.py +!backend-fastapi/ai_platform/nodes/builtin/app_settings_node.py +!backend-fastapi/ai_platform/nodes/builtin/app_update_node.py +!backend-fastapi/ai_platform/nodes/builtin/dashboard_basic_info_node.py +!backend-fastapi/ai_platform/nodes/builtin/dashboard_create_node.py +!backend-fastapi/ai_platform/nodes/builtin/dashboard_design_node.py +!backend-fastapi/ai_platform/nodes/builtin/dashboard_publish_node.py +!backend-fastapi/ai_platform/nodes/builtin/form_basic_info_node.py +!backend-fastapi/ai_platform/nodes/builtin/form_create_node.py +!backend-fastapi/ai_platform/nodes/builtin/form_data_node.py +!backend-fastapi/ai_platform/nodes/builtin/form_database_create_node.py +!backend-fastapi/ai_platform/nodes/builtin/form_database_design_node.py +!backend-fastapi/ai_platform/nodes/builtin/form_list_design_node.py +!backend-fastapi/ai_platform/nodes/builtin/form_publish_node.py +!backend-fastapi/ai_platform/nodes/builtin/form_ui_design_node.py +!backend-fastapi/ai_platform/nodes/builtin/system_summary_node.py web/apps/web-ele/src/api/core/database-monitor.ts web/apps/web-ele/src/api/core/demo.ts web/apps/web-ele/src/api/core/dingtalk-sync.ts @@ -240,6 +266,8 @@ web/apps/web-ele/src/views/_core/system-config/modules/feishu-sync-form.vue web/apps/web-ele/src/views/_core/system-config/modules/wecom-sync-form.vue web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/SystemSummaryNode.vue web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/SystemSummaryPanel.vue +!web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/SystemSummaryNode.vue +!web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/SystemSummaryPanel.vue web/apps/web-ele/src/views/wiki/ web/apps/web-ele/src/components/zq-form/table-selector/types.ts diff --git a/backend-fastapi/ai_platform/api/form_api.py b/backend-fastapi/ai_platform/api/form_api.py new file mode 100644 index 0000000..48017cc --- /dev/null +++ b/backend-fastapi/ai_platform/api/form_api.py @@ -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) diff --git a/backend-fastapi/ai_platform/nodes/builtin/app_create_node.py b/backend-fastapi/ai_platform/nodes/builtin/app_create_node.py new file mode 100644 index 0000000..cbcfb86 --- /dev/null +++ b/backend-fastapi/ai_platform/nodes/builtin/app_create_node.py @@ -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 diff --git a/backend-fastapi/ai_platform/nodes/builtin/app_design_node.py b/backend-fastapi/ai_platform/nodes/builtin/app_design_node.py new file mode 100644 index 0000000..0442cd8 --- /dev/null +++ b/backend-fastapi/ai_platform/nodes/builtin/app_design_node.py @@ -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'], + } diff --git a/backend-fastapi/ai_platform/nodes/builtin/app_settings_node.py b/backend-fastapi/ai_platform/nodes/builtin/app_settings_node.py new file mode 100644 index 0000000..7010273 --- /dev/null +++ b/backend-fastapi/ai_platform/nodes/builtin/app_settings_node.py @@ -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': [], + } diff --git a/backend-fastapi/ai_platform/nodes/builtin/app_update_node.py b/backend-fastapi/ai_platform/nodes/builtin/app_update_node.py new file mode 100644 index 0000000..f5a9814 --- /dev/null +++ b/backend-fastapi/ai_platform/nodes/builtin/app_update_node.py @@ -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'], + } diff --git a/backend-fastapi/ai_platform/nodes/builtin/dashboard_basic_info_node.py b/backend-fastapi/ai_platform/nodes/builtin/dashboard_basic_info_node.py new file mode 100644 index 0000000..21f0bd3 --- /dev/null +++ b/backend-fastapi/ai_platform/nodes/builtin/dashboard_basic_info_node.py @@ -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)) diff --git a/backend-fastapi/ai_platform/nodes/builtin/dashboard_create_node.py b/backend-fastapi/ai_platform/nodes/builtin/dashboard_create_node.py new file mode 100644 index 0000000..0694190 --- /dev/null +++ b/backend-fastapi/ai_platform/nodes/builtin/dashboard_create_node.py @@ -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, + } diff --git a/backend-fastapi/ai_platform/nodes/builtin/dashboard_design_node.py b/backend-fastapi/ai_platform/nodes/builtin/dashboard_design_node.py new file mode 100644 index 0000000..612ebbb --- /dev/null +++ b/backend-fastapi/ai_platform/nodes/builtin/dashboard_design_node.py @@ -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 + } + } diff --git a/backend-fastapi/ai_platform/nodes/builtin/dashboard_publish_node.py b/backend-fastapi/ai_platform/nodes/builtin/dashboard_publish_node.py new file mode 100644 index 0000000..ae010d5 --- /dev/null +++ b/backend-fastapi/ai_platform/nodes/builtin/dashboard_publish_node.py @@ -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)}', + ) diff --git a/backend-fastapi/ai_platform/nodes/builtin/form_basic_info_node.py b/backend-fastapi/ai_platform/nodes/builtin/form_basic_info_node.py new file mode 100644 index 0000000..dabc189 --- /dev/null +++ b/backend-fastapi/ai_platform/nodes/builtin/form_basic_info_node.py @@ -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'], + } diff --git a/backend-fastapi/ai_platform/nodes/builtin/form_create_node.py b/backend-fastapi/ai_platform/nodes/builtin/form_create_node.py new file mode 100644 index 0000000..c23be0f --- /dev/null +++ b/backend-fastapi/ai_platform/nodes/builtin/form_create_node.py @@ -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'], + } diff --git a/backend-fastapi/ai_platform/nodes/builtin/form_data_node.py b/backend-fastapi/ai_platform/nodes/builtin/form_data_node.py new file mode 100644 index 0000000..2538f4e --- /dev/null +++ b/backend-fastapi/ai_platform/nodes/builtin/form_data_node.py @@ -0,0 +1,1016 @@ +""" +表单数据操作节点 + +支持对在线表单数据进行 CRUD 操作: +- FormDataCreateNode: 创建表单数据 +- FormDataReadNode: 读取单条表单数据 +- FormDataUpdateNode: 更新表单数据 +- FormDataDeleteNode: 删除表单数据 +- FormDataListNode: 查询表单数据列表 +- FormSchemaToLLMNode: 将表单配置转换为 LLM 结构化输出配置 +""" +import json +import logging +import time +from datetime import date, datetime +from decimal import Decimal +from typing import Any, Dict, List, Optional +from uuid import UUID + +from ..base import BaseNode, NodeContext, NodeResult +from ..registry import NodeRegistry + +logger = logging.getLogger(__name__) + + +def serialize_value(value: Any) -> Any: + """将数据库值转换为可 JSON 序列化的格式""" + if value is None: + return None + if isinstance(value, datetime): + return value.isoformat() + if isinstance(value, date): + return value.isoformat() + if isinstance(value, Decimal): + return float(value) + if isinstance(value, UUID): + return str(value) + if isinstance(value, bytes): + return value.decode('utf-8', errors='replace') + if isinstance(value, (list, tuple)): + return [serialize_value(v) for v in value] + if isinstance(value, dict): + return {k: serialize_value(v) for k, v in value.items()} + return value + + +def serialize_row(row: Dict[str, Any]) -> Dict[str, Any]: + """序列化数据库行""" + return {k: serialize_value(v) for k, v in row.items()} + + +class BaseFormDataNode(BaseNode): + """表单数据操作节点基类""" + + node_category = 'data' + node_icon = 'file-text' + + async def _get_form_data_service(self, context: NodeContext, form_code: str): + """获取表单数据服务实例""" + from online_dev.form_data_manager.service import FormDataService + + db = context.db_session + if not db: + raise ValueError('数据库会话不可用') + + service = await FormDataService.create_service(db, form_code) + return service, db + + +@NodeRegistry.register +class FormDataCreateNode(BaseFormDataNode): + """ + 表单数据创建节点 + + 根据表单编码创建一条或多条数据 + """ + + node_type = 'form_data_create' + node_name = '创建表单数据' + node_description = '向指定表单创建一条或多条数据' + + inputs = [ + { + 'name': 'form_code', + 'type': 'string', + 'description': '表单编码', + 'required': True, + }, + { + 'name': 'data', + 'type': 'object', + 'description': '要创建的数据(对象或数组)', + 'required': True, + }, + ] + + outputs = [ + { + 'name': 'result', + 'type': 'object', + 'description': '创建结果(包含新记录ID)', + }, + { + 'name': 'count', + 'type': 'number', + '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: + """异步执行创建操作""" + start_time = time.time() + + try: + # 获取配置 + form_code = self.config.get('form_code', '') + data = self.config.get('data', {}) + output_variable = self.config.get('output_variable', 'form_data_create_result') + + # 解析变量 + if isinstance(form_code, str) and '{{' in form_code: + form_code = context.resolve_template(form_code) + + # 解析 data 变量 + if isinstance(data, str): + if '{{' in data: + data = context.resolve_template(data) + # 尝试解析为 JSON 或 Python 字面量 + try: + data = json.loads(data) + except json.JSONDecodeError: + # 尝试使用 ast.literal_eval 解析 Python 字典字符串(单引号格式) + try: + import ast + data = ast.literal_eval(data) + except (ValueError, SyntaxError): + pass + + logger.info(f'[FormDataCreate] 解析后 data 类型: {type(data).__name__}, 值: {str(data)[:200]}') + + # 如果在循环中且 data 为空或未配置,使用循环的当前项 + if not data or (isinstance(data, dict) and not data): + loop_item = context.get_variable('_loop_item') + logger.info(f'[FormDataCreate] 尝试获取循环项,_loop_item 类型: {type(loop_item).__name__}, 值: {loop_item}') + if loop_item is not None: + data = loop_item + logger.info(f'[FormDataCreate] 使用循环项作为数据: {data}') + + logger.info(f'[FormDataCreate] 包装前 data 类型: {type(data).__name__}, 值: {str(data)[:200]}') + + # 如果 data 不包含 main 键,自动包装 + if isinstance(data, dict) and 'main' not in data: + # 检查是否是表单字段数据(不是嵌套的配置对象) + data = {'main': data} + logger.info(f'[FormDataCreate] 自动包装数据为 main 格式') + + logger.info(f'[FormDataCreate] 最终 data 类型: {type(data).__name__}, 值: {str(data)[:200]}') + + if not form_code: + return NodeResult( + success=False, + error='未指定表单编码', + elapsed_time=int((time.time() - start_time) * 1000), + ) + + # 获取服务 + service, db = await self._get_form_data_service(context, form_code) + + # 判断是单条还是多条 + results = [] + if isinstance(data, list): + # 批量创建 + for item in data: + result = await service.create(db, item) + results.append(serialize_row(result)) + await db.commit() + else: + # 单条创建 + result = await service.create(db, data) + await db.commit() + results.append(serialize_row(result)) + + elapsed_time = int((time.time() - start_time) * 1000) + + output = results if len(results) > 1 else results[0] if results else None + + return NodeResult( + success=True, + output=output, + output_variables={ + output_variable: output, + f'{output_variable}_count': len(results), + }, + elapsed_time=elapsed_time, + ) + + except Exception as e: + logger.exception(f'表单数据创建失败: {e}') + return NodeResult( + success=False, + error=str(e), + elapsed_time=int((time.time() - start_time) * 1000), + ) + + +@NodeRegistry.register +class FormDataReadNode(BaseFormDataNode): + """ + 表单数据读取节点 + + 根据 ID 读取单条表单数据 + """ + + node_type = 'form_data_read' + node_name = '读取表单数据' + node_description = '根据ID读取单条表单数据' + + inputs = [ + { + 'name': 'form_code', + 'type': 'string', + 'description': '表单编码', + 'required': True, + }, + { + 'name': 'id', + 'type': 'string', + 'description': '记录ID', + 'required': True, + }, + ] + + outputs = [ + { + 'name': 'data', + '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: + """异步执行读取操作""" + start_time = time.time() + + try: + # 获取配置 + form_code = self.config.get('form_code', '') + record_id = self.config.get('id', '') + output_variable = self.config.get('output_variable', 'form_data') + + # 解析变量 + if isinstance(form_code, str) and '{{' in form_code: + form_code = context.resolve_template(form_code) + if isinstance(record_id, str) and '{{' in record_id: + record_id = context.resolve_template(record_id) + + if not form_code: + return NodeResult( + success=False, + error='未指定表单编码', + elapsed_time=int((time.time() - start_time) * 1000), + ) + + if not record_id: + return NodeResult( + success=False, + error='未指定记录ID', + elapsed_time=int((time.time() - start_time) * 1000), + ) + + # 获取服务 + service, db = await self._get_form_data_service(context, form_code) + + # 读取数据 + result = await service.get(db, record_id) + result = serialize_row(result) + + elapsed_time = int((time.time() - start_time) * 1000) + + return NodeResult( + success=True, + output=result, + output_variables={ + output_variable: result, + }, + elapsed_time=elapsed_time, + ) + + except Exception as e: + logger.exception(f'表单数据读取失败: {e}') + return NodeResult( + success=False, + error=str(e), + elapsed_time=int((time.time() - start_time) * 1000), + ) + + +@NodeRegistry.register +class FormDataUpdateNode(BaseFormDataNode): + """ + 表单数据更新节点 + + 根据 ID 更新表单数据 + """ + + node_type = 'form_data_update' + node_name = '更新表单数据' + node_description = '根据ID更新表单数据' + + inputs = [ + { + 'name': 'form_code', + 'type': 'string', + 'description': '表单编码', + 'required': True, + }, + { + 'name': 'id', + 'type': 'string', + 'description': '记录ID', + 'required': True, + }, + { + 'name': 'data', + 'type': 'object', + 'description': '要更新的数据', + 'required': True, + }, + ] + + outputs = [ + { + 'name': '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: + """异步执行更新操作""" + start_time = time.time() + + try: + # 获取配置 + form_code = self.config.get('form_code', '') + record_id = self.config.get('id', '') + data = self.config.get('data', {}) + output_variable = self.config.get('output_variable', 'form_data_update_result') + + # 解析变量 + if isinstance(form_code, str) and '{{' in form_code: + form_code = context.resolve_template(form_code) + if isinstance(record_id, str) and '{{' in record_id: + record_id = context.resolve_template(record_id) + + # 解析 data 变量 + if isinstance(data, str): + if '{{' in data: + data = context.resolve_template(data) + # 尝试解析为 JSON 或 Python 字面量 + try: + data = json.loads(data) + except json.JSONDecodeError: + # 尝试使用 ast.literal_eval 解析 Python 字典字符串(单引号格式) + try: + import ast + data = ast.literal_eval(data) + except (ValueError, SyntaxError): + pass + + # 如果在循环中且 data 为空或未配置,使用循环的当前项 + if not data or (isinstance(data, dict) and not data): + loop_item = context.get_variable('_loop_item') + if loop_item is not None: + data = loop_item + + # 如果 data 不包含 main 键,自动包装 + if isinstance(data, dict) and 'main' not in data: + data = {'main': data} + + if not form_code: + return NodeResult( + success=False, + error='未指定表单编码', + elapsed_time=int((time.time() - start_time) * 1000), + ) + + if not record_id: + return NodeResult( + success=False, + error='未指定记录ID', + elapsed_time=int((time.time() - start_time) * 1000), + ) + + # 获取服务 + service, db = await self._get_form_data_service(context, form_code) + + # 更新数据 + result = await service.update(db, record_id, data) + await db.commit() + result = serialize_row(result) + + elapsed_time = int((time.time() - start_time) * 1000) + + return NodeResult( + success=True, + output=result, + output_variables={ + output_variable: result, + }, + elapsed_time=elapsed_time, + ) + + except Exception as e: + logger.exception(f'表单数据更新失败: {e}') + return NodeResult( + success=False, + error=str(e), + elapsed_time=int((time.time() - start_time) * 1000), + ) + + +@NodeRegistry.register +class FormDataDeleteNode(BaseFormDataNode): + """ + 表单数据删除节点 + + 根据 ID 删除表单数据(支持软删除和硬删除) + """ + + node_type = 'form_data_delete' + node_name = '删除表单数据' + node_description = '根据ID删除表单数据' + + inputs = [ + { + 'name': 'form_code', + 'type': 'string', + 'description': '表单编码', + 'required': True, + }, + { + 'name': 'id', + 'type': 'string', + 'description': '记录ID(支持单个或数组)', + 'required': True, + }, + { + 'name': 'hard_delete', + 'type': 'boolean', + 'description': '是否硬删除(默认软删除)', + 'default': False, + }, + ] + + outputs = [ + { + 'name': 'success', + 'type': 'boolean', + 'description': '是否成功', + }, + { + 'name': 'count', + 'type': 'number', + '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: + """异步执行删除操作""" + start_time = time.time() + + try: + # 获取配置 + form_code = self.config.get('form_code', '') + record_ids = self.config.get('id', '') + hard_delete = self.config.get('hard_delete', False) + output_variable = self.config.get('output_variable', 'form_data_delete_result') + + # 解析变量 + if isinstance(form_code, str) and '{{' in form_code: + form_code = context.resolve_template(form_code) + if isinstance(record_ids, str) and '{{' in record_ids: + record_ids = context.resolve_template(record_ids) + # 尝试解析为 JSON 或 Python 字面量 + try: + record_ids = json.loads(record_ids) + except json.JSONDecodeError: + # 尝试使用 ast.literal_eval 解析 Python 列表字符串(单引号格式) + try: + import ast + record_ids = ast.literal_eval(record_ids) + except (ValueError, SyntaxError): + pass + + if not form_code: + return NodeResult( + success=False, + error='未指定表单编码', + elapsed_time=int((time.time() - start_time) * 1000), + ) + + if not record_ids: + return NodeResult( + success=False, + error='未指定记录ID', + elapsed_time=int((time.time() - start_time) * 1000), + ) + + # 获取服务 + service, db = await self._get_form_data_service(context, form_code) + + # 转换为列表 + if isinstance(record_ids, str): + record_ids = [record_ids] + + # 删除数据 + delete_count = 0 + for record_id in record_ids: + await service.delete(db, record_id, hard=hard_delete) + delete_count += 1 + + await db.commit() + + elapsed_time = int((time.time() - start_time) * 1000) + + return NodeResult( + success=True, + output={'success': True, 'count': delete_count}, + output_variables={ + output_variable: {'success': True, 'count': delete_count}, + f'{output_variable}_count': delete_count, + }, + elapsed_time=elapsed_time, + ) + + except Exception as e: + logger.exception(f'表单数据删除失败: {e}') + return NodeResult( + success=False, + error=str(e), + elapsed_time=int((time.time() - start_time) * 1000), + ) + + +@NodeRegistry.register +class FormDataListNode(BaseFormDataNode): + """ + 表单数据列表查询节点 + + 查询表单数据列表,支持分页和过滤 + """ + + node_type = 'form_data_list' + node_name = '查询表单数据' + node_description = '查询表单数据列表,支持分页和过滤' + + inputs = [ + { + 'name': 'form_code', + 'type': 'string', + 'description': '表单编码', + 'required': True, + }, + { + 'name': 'page', + 'type': 'number', + 'description': '页码(默认1)', + 'default': 1, + }, + { + 'name': 'page_size', + 'type': 'number', + 'description': '每页条数(默认20)', + 'default': 20, + }, + { + 'name': 'filters', + 'type': 'object', + 'description': '过滤条件', + }, + { + 'name': 'sort', + 'type': 'array', + 'description': '排序配置', + }, + ] + + outputs = [ + { + 'name': 'items', + 'type': 'array', + 'description': '数据列表', + }, + { + 'name': 'total', + 'type': 'number', + '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: + """异步执行列表查询""" + start_time = time.time() + + try: + # 获取配置 + form_code = self.config.get('form_code', '') + page = self.config.get('page', 1) + page_size = self.config.get('page_size', 20) + filters = self.config.get('filters', {}) + sort_list = self.config.get('sort', []) + output_variable = self.config.get('output_variable', 'form_data_list') + + # 解析变量 + if isinstance(form_code, str) and '{{' in form_code: + form_code = context.resolve_template(form_code) + + # 解析 filters 变量 + if isinstance(filters, str): + if '{{' in filters: + filters = context.resolve_template(filters) + try: + filters = json.loads(filters) + except json.JSONDecodeError: + filters = {} + + if not form_code: + return NodeResult( + success=False, + error='未指定表单编码', + elapsed_time=int((time.time() - start_time) * 1000), + ) + + # 获取服务 + service, db = await self._get_form_data_service(context, form_code) + + # 查询数据 + result = await service.list( + db, + page=page, + page_size=page_size, + filters=filters, + sort_list=sort_list, + ) + + # 序列化结果 + items = [serialize_row(item) for item in result.get('items', [])] + total = result.get('total', 0) + + elapsed_time = int((time.time() - start_time) * 1000) + + return NodeResult( + success=True, + output={'items': items, 'total': total}, + output_variables={ + output_variable: items, + f'{output_variable}_total': total, + f'{output_variable}_page': page, + f'{output_variable}_page_size': page_size, + }, + elapsed_time=elapsed_time, + ) + + except Exception as e: + logger.exception(f'表单数据查询失败: {e}') + return NodeResult( + success=False, + error=str(e), + elapsed_time=int((time.time() - start_time) * 1000), + ) + + +@NodeRegistry.register +class FormSchemaToLLMNode(BaseFormDataNode): + """ + 表单配置转 LLM 结构化输出节点 + + 将表单的字段配置转换为 LLM Function Calling 的结构化输出配置 + 支持生成数组类型的输出(用于批量数据) + """ + + node_type = 'form_schema_to_llm' + node_name = '表单转LLM结构' + node_description = '将表单配置转换为LLM结构化输出配置' + + inputs = [ + { + 'name': 'form_code', + 'type': 'string', + 'description': '表单编码', + 'required': True, + }, + { + 'name': 'is_array', + 'type': 'boolean', + 'description': '是否生成数组类型(用于多条数据)', + 'default': False, + }, + { + 'name': 'include_fields', + 'type': 'array', + 'description': '要包含的字段列表(为空则包含所有)', + }, + { + 'name': 'exclude_fields', + 'type': 'array', + 'description': '要排除的字段列表', + }, + ] + + outputs = [ + { + 'name': 'schema', + 'type': 'array', + 'description': 'LLM 结构化输出配置', + }, + ] + + # 表单组件类型到 JSON Schema 类型的映射 + COMPONENT_TYPE_MAP = { + 'input': 'string', + 'textarea': 'string', + 'number': 'number', + 'select': 'string', + 'radio': 'string', + 'checkbox': 'array', + 'switch': 'boolean', + 'date': 'string', + 'date-picker': 'string', + 'time': 'string', + 'datetime': 'string', + 'rate': 'number', + 'slider': 'number', + 'color': 'string', + 'cascader': 'array', + 'tree-select': 'string', + 'upload': 'array', + 'rich-text': 'string', + 'user-selector': 'string', + 'dept-selector': 'string', + 'post-selector': 'string', + } + + 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: + """异步执行转换""" + start_time = time.time() + + try: + from sqlalchemy import select + from online_dev.form_manager.model import FormMeta + + # 获取配置 + form_code = self.config.get('form_code', '') + is_array = self.config.get('is_array', False) + include_fields = self.config.get('include_fields', []) + exclude_fields = self.config.get('exclude_fields', []) + output_variable = self.config.get('output_variable', 'llm_output_schema') + + # 解析变量 + if isinstance(form_code, str) and '{{' in form_code: + form_code = context.resolve_template(form_code) + + if not form_code: + return NodeResult( + success=False, + error='未指定表单编码', + elapsed_time=int((time.time() - start_time) * 1000), + ) + + db = context.db_session + if not db: + return NodeResult( + success=False, + error='数据库会话不可用', + elapsed_time=int((time.time() - start_time) * 1000), + ) + + # 获取表单配置 + stmt = select(FormMeta).where( + FormMeta.code == form_code, + FormMeta.is_deleted == False + ) + result = await db.execute(stmt) + form_meta = result.scalar_one_or_none() + + if not form_meta: + return NodeResult( + success=False, + error=f'表单不存在: {form_code}', + elapsed_time=int((time.time() - start_time) * 1000), + ) + + # 提取字段配置 + form_config = form_meta.form_config or {} + items = form_config.get('items', []) + + # 转换为 LLM 结构化输出配置 + schema_fields = self._extract_fields(items, include_fields, exclude_fields) + + # 如果是数组类型,包装为数组 + if is_array: + output_schema = [{ + 'name': 'items', + 'type': 'array', + 'description': f'{form_meta.name}数据列表', + 'required': True, + 'children': schema_fields, + }] + else: + output_schema = schema_fields + + elapsed_time = int((time.time() - start_time) * 1000) + + return NodeResult( + success=True, + output=output_schema, + output_variables={ + output_variable: output_schema, + }, + elapsed_time=elapsed_time, + ) + + except Exception as e: + logger.exception(f'表单配置转换失败: {e}') + return NodeResult( + success=False, + error=str(e), + elapsed_time=int((time.time() - start_time) * 1000), + ) + + def _extract_fields( + self, + items: List[Dict], + include_fields: List[str], + exclude_fields: List[str], + in_sub_table: str = None, + ) -> List[Dict]: + """ + 从表单配置中提取字段,转换为 LLM 结构化输出格式 + + Args: + items: 表单项列表 + include_fields: 要包含的字段 + exclude_fields: 要排除的字段 + in_sub_table: 当前是否在子表中 + + Returns: + LLM 结构化输出字段列表 + """ + schema_fields = [] + + for item in items: + item_type = item.get('type', '') + field = item.get('field', '') + label = item.get('label', field) + + # 跳过布局组件,递归处理其子项 + if item_type in ('grid', 'tabs', 'collapse', 'divider', 'alert', 'timeline', 'text', 'html', 'spacer', 'title', 'steps'): + if item.get('columns'): + for col in item['columns']: + schema_fields.extend( + self._extract_fields( + col.get('children', []), + include_fields, + exclude_fields, + in_sub_table, + ) + ) + if item.get('items'): + for sub_item in item['items']: + schema_fields.extend( + self._extract_fields( + sub_item.get('children', []), + include_fields, + exclude_fields, + in_sub_table, + ) + ) + continue + + # 子表组件 + if item_type == 'sub-table': + sub_table_name = field + children = item.get('children', []) + sub_fields = self._extract_fields( + children, + include_fields, + exclude_fields, + sub_table_name, + ) + if sub_fields: + schema_fields.append({ + 'name': sub_table_name, + 'type': 'array', + 'description': label or sub_table_name, + 'required': item.get('required', False), + 'children': sub_fields, + }) + continue + + # 普通字段 + if not field: + continue + + # 过滤字段 + if include_fields and field not in include_fields: + continue + if field in exclude_fields: + continue + + # 跳过系统字段 + if field.startswith('sys_') or field in ('id', 'is_deleted', 'sort'): + continue + + # 转换类型 + json_type = self.COMPONENT_TYPE_MAP.get(item_type, 'string') + + # 构建字段配置 + field_config = { + 'name': field, + 'type': json_type, + 'description': label or field, + 'required': item.get('required', False), + } + + # 为日期类型添加格式说明 + if item_type in ('date', 'date-picker'): + field_config['description'] = f'{label or field}(格式:YYYY-MM-DD,如 2024-01-15)' + elif item_type == 'datetime': + field_config['description'] = f'{label or field}(格式:YYYY-MM-DD HH:mm:ss,如 2024-01-15 14:30:00)' + elif item_type == 'time': + field_config['description'] = f'{label or field}(格式:HH:mm:ss,如 14:30:00)' + + # 添加枚举值(如果有选项) + options = item.get('options', []) + if options and json_type == 'string': + enum_values = [opt.get('value') for opt in options if opt.get('value')] + if enum_values: + field_config['enum'] = enum_values + + schema_fields.append(field_config) + + return schema_fields diff --git a/backend-fastapi/ai_platform/nodes/builtin/form_database_create_node.py b/backend-fastapi/ai_platform/nodes/builtin/form_database_create_node.py new file mode 100644 index 0000000..0dc93da --- /dev/null +++ b/backend-fastapi/ai_platform/nodes/builtin/form_database_create_node.py @@ -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'], + } diff --git a/backend-fastapi/ai_platform/nodes/builtin/form_database_design_node.py b/backend-fastapi/ai_platform/nodes/builtin/form_database_design_node.py new file mode 100644 index 0000000..5a04809 --- /dev/null +++ b/backend-fastapi/ai_platform/nodes/builtin/form_database_design_node.py @@ -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'], + } diff --git a/backend-fastapi/ai_platform/nodes/builtin/form_list_design_node.py b/backend-fastapi/ai_platform/nodes/builtin/form_list_design_node.py new file mode 100644 index 0000000..1677aa6 --- /dev/null +++ b/backend-fastapi/ai_platform/nodes/builtin/form_list_design_node.py @@ -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'], + } diff --git a/backend-fastapi/ai_platform/nodes/builtin/form_publish_node.py b/backend-fastapi/ai_platform/nodes/builtin/form_publish_node.py new file mode 100644 index 0000000..053968f --- /dev/null +++ b/backend-fastapi/ai_platform/nodes/builtin/form_publish_node.py @@ -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'], + } diff --git a/backend-fastapi/ai_platform/nodes/builtin/form_ui_design_node.py b/backend-fastapi/ai_platform/nodes/builtin/form_ui_design_node.py new file mode 100644 index 0000000..8e43995 --- /dev/null +++ b/backend-fastapi/ai_platform/nodes/builtin/form_ui_design_node.py @@ -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'], + } diff --git a/backend-fastapi/ai_platform/nodes/builtin/system_summary_node.py b/backend-fastapi/ai_platform/nodes/builtin/system_summary_node.py new file mode 100644 index 0000000..39b8af4 --- /dev/null +++ b/backend-fastapi/ai_platform/nodes/builtin/system_summary_node.py @@ -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'], + } diff --git a/backend-fastapi/ai_platform/nodes/registry.py b/backend-fastapi/ai_platform/nodes/registry.py index d077e33..9a08c07 100644 --- a/backend-fastapi/ai_platform/nodes/registry.py +++ b/backend-fastapi/ai_platform/nodes/registry.py @@ -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 diff --git a/backend-fastapi/ai_platform/router.py b/backend-fastapi/ai_platform/router.py index 56c6699..0d9bb01 100644 --- a/backend-fastapi/ai_platform/router.py +++ b/backend-fastapi/ai_platform/router.py @@ -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) \ No newline at end of file +router.include_router(form_router) +router.include_router(knowledge_router) diff --git a/backend-fastapi/online_dev/__init__.py b/backend-fastapi/online_dev/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend-fastapi/online_dev/form_data_manager/__init__.py b/backend-fastapi/online_dev/form_data_manager/__init__.py new file mode 100644 index 0000000..54e567d --- /dev/null +++ b/backend-fastapi/online_dev/form_data_manager/__init__.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +表单数据管理模块 +""" diff --git a/backend-fastapi/online_dev/form_data_manager/db_adapter.py b/backend-fastapi/online_dev/form_data_manager/db_adapter.py new file mode 100644 index 0000000..9ecdc59 --- /dev/null +++ b/backend-fastapi/online_dev/form_data_manager/db_adapter.py @@ -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] diff --git a/backend-fastapi/online_dev/form_data_manager/db_error_handler.py b/backend-fastapi/online_dev/form_data_manager/db_error_handler.py new file mode 100644 index 0000000..81f71bf --- /dev/null +++ b/backend-fastapi/online_dev/form_data_manager/db_error_handler.py @@ -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) diff --git a/backend-fastapi/online_dev/form_data_manager/dynamic_sql_builder.py b/backend-fastapi/online_dev/form_data_manager/dynamic_sql_builder.py new file mode 100644 index 0000000..867bc79 --- /dev/null +++ b/backend-fastapi/online_dev/form_data_manager/dynamic_sql_builder.py @@ -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 diff --git a/backend-fastapi/online_dev/form_data_manager/exceptions.py b/backend-fastapi/online_dev/form_data_manager/exceptions.py new file mode 100644 index 0000000..21ec781 --- /dev/null +++ b/backend-fastapi/online_dev/form_data_manager/exceptions.py @@ -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}) diff --git a/backend-fastapi/online_dev/form_data_manager/service.py b/backend-fastapi/online_dev/form_data_manager/service.py new file mode 100644 index 0000000..23d1481 --- /dev/null +++ b/backend-fastapi/online_dev/form_data_manager/service.py @@ -0,0 +1,5519 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +表单数据操作服务(异步版本) +支持动态操作不同数据表,适配 PostgreSQL、MySQL +""" +import asyncio +import json +import logging +import uuid +from contextlib import asynccontextmanager +from datetime import date, datetime +from decimal import Decimal +from io import BytesIO +from typing import Any, Dict, List, Optional, Set, Tuple + +from openpyxl import Workbook, load_workbook +from openpyxl.styles import Font, PatternFill, Alignment, Border, Side +from sqlalchemy import select, text +from sqlalchemy.ext.asyncio import AsyncSession + +from online_dev.form_manager.model import FormMeta, FormSubTable +from online_dev.form_data_manager.db_adapter import ( + PLATFORM_SCHEMA, + FormDataDbAdapter, + create_form_data_adapter, + create_platform_adapter, + create_platform_sql_builder, + resolve_form_sql_context, +) +from online_dev.form_data_manager.dynamic_sql_builder import DynamicSQLBuilder +from online_dev.form_data_manager.exceptions import ( + FormDataException, + FormNotFoundException, + RecordNotFoundException, + UniqueConstraintError, + ColumnNotFoundError, + FormDataValidationError, + ForeignKeyConstraintError, +) +from online_dev.form_data_manager.db_error_handler import ( + translate_db_error, + format_error_message, + is_aborted_transaction_error, +) +from core.database_manager.service import AsyncDatabaseManagerService +from app.timezone import APP_TIMEZONE + +logger = logging.getLogger(__name__) + + +def get_max_import_rows() -> int: + """根据服务器物理内存计算最大导入/导出行数""" + import psutil + total_gb = psutil.virtual_memory().total / (1024 ** 3) + thresholds = [(2, 100_000), (4, 200_000), (8, 600_000), (16, 1_048_575)] + if total_gb <= 2: + return 100_000 + for i in range(len(thresholds) - 1): + low_gb, low_rows = thresholds[i] + high_gb, high_rows = thresholds[i + 1] + if total_gb <= high_gb: + ratio = (total_gb - low_gb) / (high_gb - low_gb) + return int(low_rows + ratio * (high_rows - low_rows)) + return 1_048_575 + + +def get_server_memory_gb() -> float: + """获取服务器物理内存(GB)""" + import psutil + return round(psutil.virtual_memory().total / (1024 ** 3), 1) + + +MAX_IMPORT_EXPORT_ROWS = get_max_import_rows() +SERVER_MEMORY_GB = get_server_memory_gb() +logger.info(f"服务器内存: {SERVER_MEMORY_GB}GB, 导入导出行数上限: {MAX_IMPORT_EXPORT_ROWS}") + + +class FormDataService: + """ + 表单数据操作服务(异步版本) + + 根据表单配置动态操作数据表,支持主表和子表的 CRUD 操作 + """ + + def __init__( + self, + form_meta: FormMeta, + sub_tables: List[FormSubTable], + db_adapter: FormDataDbAdapter, + ): + """ + 初始化服务 + + Args: + form_meta: 表单元数据 + sub_tables: 子表配置列表 + db_adapter: 数据库执行适配器(平台库或第三方连接) + """ + self.form_meta = form_meta + self.sub_tables = sub_tables + self.db_adapter = db_adapter + self.db_type = db_adapter.db_type + self.db_config = db_adapter.db_config + from online_dev.form_data_manager.db_adapter import create_sql_builder_for_adapter + + self.sql_builder = create_sql_builder_for_adapter(db_adapter) + self._platform_adapter: Optional[FormDataDbAdapter] = None + self._platform_sql_builder = create_platform_sql_builder() + self._adapter_cache: Dict[str, FormDataDbAdapter] = {} + self._sql_builder_cache: Dict[str, DynamicSQLBuilder] = {} + # 缓存标量属性,避免 session 过期后懒加载导致 MissingGreenlet + self._form_code = form_meta.code + self._form_config = form_meta.form_config or {} + self._list_config = form_meta.list_config or {} + + async def _resolve_schema(self, db: AsyncSession, schema: str) -> Optional[str]: + """ + 解析 schema 中的变量 + + 如果 schema 包含 {{application_code}} 变量,则从表单关联的应用中获取 application_code + + Args: + db: 数据库会话 + schema: 原始 schema 配置(可能包含变量) + + Returns: + 解析后的 schema 值 + """ + if not schema: + return None + + # 检查是否包含变量语法 + if '{{' in schema and '}}' in schema: + # 提取变量名 + import re + match = re.search(r'\{\{([^}]+)\}\}', schema) + if match: + var_name = match.group(1).strip() + + # 如果是 application_code 变量,从应用中获取 + if 'application_code' in var_name and self.form_meta.application_id: + from core.application.model import Application + result = await db.execute( + select(Application.code).where(Application.id == self.form_meta.application_id) + ) + app_code = result.scalar_one_or_none() + if app_code: + # 替换变量 + return schema.replace(f'{{{{{var_name}}}}}', app_code) + + return schema + + @classmethod + async def create_service(cls, db: AsyncSession, form_code: str) -> "FormDataService": + """ + 工厂方法:创建服务实例 + + Args: + db: 数据库会话 + form_code: 表单编码 + """ + # 加载表单元数据 + stmt = select(FormMeta).where( + FormMeta.code == form_code, + FormMeta.is_deleted == False + ) + result = await db.execute(stmt) + form_meta = result.scalar_one_or_none() + + if not form_meta: + raise FormNotFoundException(form_code) + + # 加载子表配置 + sub_stmt = select(FormSubTable).where( + FormSubTable.form_id == form_meta.id, + FormSubTable.is_deleted == False + ).order_by(FormSubTable.sort) + sub_result = await db.execute(sub_stmt) + sub_tables = list(sub_result.scalars().all()) + + db_config = (form_meta.db_config or "default").strip() or "default" + db_adapter = await create_form_data_adapter(db_config, db) + service = cls(form_meta, sub_tables, db_adapter) + service._platform_adapter = await create_platform_adapter(db) + service._adapter_cache[db_config] = db_adapter + service._sql_builder_cache[db_config] = service.sql_builder + return service + + async def _get_platform_adapter(self, db: AsyncSession) -> FormDataDbAdapter: + if self._platform_adapter is None: + self._platform_adapter = await create_platform_adapter(db) + return self._platform_adapter + + async def _execute_platform_query( + self, db: AsyncSession, sql: str, params: Any = None + ) -> List[Dict[str, Any]]: + adapter = await self._get_platform_adapter(db) + return await adapter.execute_query(sql, params) + + async def _execute_platform_command( + self, db: AsyncSession, sql: str, params: Any = None + ) -> int: + adapter = await self._get_platform_adapter(db) + return await adapter.execute_command(sql, params) + + def _resolve_exec_database(self, database: Optional[str] = None) -> Optional[str]: + """第三方 PostgreSQL 执行/事务时切换连接库(连接未配置 default 时用表单/子表 database)。""" + if not self.db_adapter.is_external or self.db_adapter.db_type != "postgresql": + return None + explicit = (database or "").strip() + if explicit: + return explicit + form_db = (self.form_meta.main_table_database or "").strip() + if form_db: + return form_db + conn_default = (getattr(self.db_adapter, "default_database", "") or "").strip() + return conn_default or None + + def _table_exec_database(self, table_database: Optional[str] = None) -> Optional[str]: + """主表/子表操作对应的 PG 连接库名(子表 table_database 优先,否则主表)。""" + effective = (table_database or "").strip() or ( + self.form_meta.main_table_database or "" + ).strip() + return self._resolve_exec_database(effective or None) + + @asynccontextmanager + async def _business_transaction(self, database: Optional[str] = None): + """业务库事务:第三方走 handler 事务域,default 与平台 Session 一致。""" + if self.db_adapter.is_external: + exec_db = self._resolve_exec_database(database) + async with self.db_adapter.transaction(database=exec_db): + yield + else: + yield + + async def _create_savepoint(self, db: AsyncSession, name: str) -> None: + if self.db_adapter.is_external: + await self.db_adapter.create_savepoint(name) + else: + await db.execute(text(f"SAVEPOINT {name}")) + + async def _release_savepoint(self, db: AsyncSession, name: str) -> None: + if self.db_adapter.is_external: + await self.db_adapter.release_savepoint(name) + else: + await db.execute(text(f"RELEASE SAVEPOINT {name}")) + + async def _rollback_to_savepoint(self, db: AsyncSession, name: str) -> None: + if self.db_adapter.is_external: + await self.db_adapter.rollback_to_savepoint(name) + else: + await db.execute(text(f"ROLLBACK TO SAVEPOINT {name}")) + + # ============ 字段白名单 ============ + + def _get_allowed_fields(self, table_type: str = "main", table_name: str = None) -> Set[str]: + """ + 从表单配置中提取允许的字段(白名单) + """ + fields = set() + form_config = self._form_config + items = form_config.get("items", []) + + def traverse(item_list: List[Dict], in_sub_table: str = None): + """递归遍历表单项""" + for item in item_list: + item_type = item.get("type", "") + field = item.get("field", "") + + # 子表单组件 + if item_type == "sub-table": + sub_table_name = field + children = item.get("children", []) + traverse(children, sub_table_name) + continue + + # 布局/展示组件,递归处理或跳过 + if item_type in ("grid", "tabs", "collapse", "steps", "divider", "alert", "timeline", "text", "html", "spacer", "title", "button"): + if item.get("columns"): + for col in item["columns"]: + traverse(col.get("children", []), in_sub_table) + if item.get("items"): + for sub_item in item["items"]: + traverse(sub_item.get("children", []), in_sub_table) + continue + + # 普通字段(排除虚拟字段,虚拟字段不对应数据库列) + if field: + props = item.get("props", {}) + if props.get("isVirtualField"): + continue + if table_type == "main" and in_sub_table is None: + fields.add(field) + elif table_type == "sub" and in_sub_table == table_name: + fields.add(field) + + traverse(items) + + # 始终允许 id 字段 + fields.add("id") + + return fields + + def _filter_fields(self, data: Dict[str, Any], allowed_fields: Set[str]) -> Dict[str, Any]: + """过滤字段,只保留白名单中的字段""" + return {k: v for k, v in data.items() if k in allowed_fields} + + def _fill_system_fields_for_create(self, data: Dict[str, Any]) -> Dict[str, Any]: + """ + 填充新增时的系统字段 + sys_create_datetime, sys_update_datetime, sys_creator_id, sys_modifier_id, sys_dept_id, sort + """ + from utils.context import get_current_user_info_from_context + + now = datetime.now() + user_info = get_current_user_info_from_context() + + user_id = user_info.get('user_id') if user_info else None + dept_id = user_info.get('dept_id') if user_info else None + + # 设置创建时间和更新时间 + data['sys_create_datetime'] = now + data['sys_update_datetime'] = now + + # 设置创建人和修改人 + if user_id: + data['sys_creator_id'] = user_id + data['sys_modifier_id'] = user_id + + # 设置部门 + if dept_id: + data['sys_dept_id'] = dept_id + + # 设置排序字段默认值(如果表中有 sort 字段且未提供值) + if 'sort' not in data or data.get('sort') is None: + data['sort'] = 0 + + return data + + def _fill_system_fields_for_update(self, data: Dict[str, Any]) -> Dict[str, Any]: + """ + 填充更新时的系统字段 + sys_update_datetime, sys_modifier_id + """ + from utils.context import get_current_user_info_from_context + + now = datetime.now() + user_info = get_current_user_info_from_context() + + user_id = user_info.get('user_id') if user_info else None + + # 设置更新时间 + data['sys_update_datetime'] = now + + # 设置修改人 + if user_id: + data['sys_modifier_id'] = user_id + + # 移除不应该被更新的系统字段 + data.pop('sys_create_datetime', None) + data.pop('sys_creator_id', None) + data.pop('sys_dept_id', None) + + return data + + def _apply_data_scope_filter( + self, + filters: Dict[str, Any], + data_scope: Dict[str, Any] + ) -> Dict[str, Any]: + """ + 应用数据权限过滤条件 + + Args: + filters: 现有过滤条件 + data_scope: 数据权限配置,格式: + { + 'filter_type': 'all' | 'self' | 'dept' | 'dept_and_children' | 'custom', + 'scope': 0-4, + 'user_id': str | None, + 'dept_id': str | None, + 'dept_ids': List[str] | None + } + + Returns: + 合并后的过滤条件 + """ + if not filters: + filters = {} + + filter_type = data_scope.get('filter_type', 'all') + + if filter_type == 'all': + # 全部数据,不添加过滤条件 + return filters + elif filter_type == 'self': + # 仅本人数据:需要表中有 sys_creator_id 字段 + user_id = data_scope.get('user_id') + if user_id: + filters['sys_creator_id'] = user_id + elif filter_type == 'dept': + # 本部门数据:需要表中有 dept_id 字段 + dept_id = data_scope.get('dept_id') + if dept_id: + filters['dept_id'] = dept_id + elif filter_type == 'dept_and_children': + # 本部门及下级:需要表中有 dept_id 字段 + dept_ids = data_scope.get('dept_ids', []) + if dept_ids: + filters['dept_id'] = {"type": "in", "value": dept_ids} + elif filter_type == 'custom': + # 自定义部门 + dept_ids = data_scope.get('dept_ids', []) + if dept_ids: + filters['dept_id'] = {"type": "in", "value": dept_ids} + + return filters + + async def _apply_data_scope_filter_safe( + self, + db: AsyncSession, + table: str, + schema: Optional[str], + database: Optional[str], + filters: Dict[str, Any], + data_scope: Dict[str, Any] + ) -> Dict[str, Any]: + """ + 安全地应用数据权限过滤(检查字段是否存在) + + 如果表中不存在所需的字段,则跳过该过滤条件 + """ + if not filters: + filters = {} + + filter_type = data_scope.get('filter_type', 'all') + + if filter_type == 'all': + # 全部数据,不添加过滤条件 + return filters + + # 获取表的所有列 + columns = await self._get_table_columns(db, table, schema, database) + + if filter_type == 'self': + # 仅本人数据:需要表中有 sys_creator_id 字段 + # 注意:字段为空时所有人可见(使用 eq_or_null) + if 'sys_creator_id' in columns: + user_id = data_scope.get('user_id') + if user_id: + filters['sys_creator_id'] = {"type": "eq_or_null", "value": user_id} + else: + logger.warning(f"表 {table} 中不存在 sys_creator_id 字段,跳过数据权限过滤") + elif filter_type == 'dept': + # 本部门数据:需要表中有 dept_id 或 sys_dept_id 字段 + # 注意:字段为空时所有人可见(使用 eq_or_null) + dept_field = 'sys_dept_id' if 'sys_dept_id' in columns else ('dept_id' if 'dept_id' in columns else None) + if dept_field: + dept_id = data_scope.get('dept_id') + if dept_id: + filters[dept_field] = {"type": "eq_or_null", "value": dept_id} + else: + logger.warning(f"表 {table} 中不存在 dept_id 或 sys_dept_id 字段,跳过数据权限过滤") + elif filter_type == 'dept_and_children': + # 本部门及下级:需要表中有 dept_id 或 sys_dept_id 字段 + # 注意:字段为空时所有人可见(使用 in_or_null) + dept_field = 'sys_dept_id' if 'sys_dept_id' in columns else ('dept_id' if 'dept_id' in columns else None) + if dept_field: + dept_ids = data_scope.get('dept_ids', []) + if dept_ids: + filters[dept_field] = {"type": "in_or_null", "value": dept_ids} + else: + # 如果没有部门ID,只返回字段为空的记录 + filters[dept_field] = {"type": "null"} + else: + logger.warning(f"表 {table} 中不存在 dept_id 或 sys_dept_id 字段,跳过数据权限过滤") + elif filter_type == 'custom': + # 自定义部门 + # 注意:字段为空时所有人可见(使用 in_or_null) + dept_field = 'sys_dept_id' if 'sys_dept_id' in columns else ('dept_id' if 'dept_id' in columns else None) + if dept_field: + dept_ids = data_scope.get('dept_ids', []) + if dept_ids: + filters[dept_field] = {"type": "in_or_null", "value": dept_ids} + else: + # 如果没有部门ID,只返回字段为空的记录 + filters[dept_field] = {"type": "null"} + else: + logger.warning(f"表 {table} 中不存在 dept_id 或 sys_dept_id 字段,跳过数据权限过滤") + + return filters + + # ============ 数据库操作 ============ + + async def _try_enrich_foreign_key_error( + self, + db: AsyncSession, + exc: ForeignKeyConstraintError, + ) -> ForeignKeyConstraintError: + """在传入会话上解析引用表名;事务已中止时 rollback 后重试。""" + referenced_table = exc.context.get("referenced_table") + if not referenced_table: + return exc + + display_name: Optional[str] = None + try: + display_name = await self._resolve_table_display_name(db, referenced_table) + except Exception as first_err: + if not is_aborted_transaction_error(first_err): + logger.warning( + "Failed to enrich foreign key error for table %s: %s", + referenced_table, + first_err, + ) + return exc + try: + await db.rollback() + display_name = await self._resolve_table_display_name( + db, referenced_table + ) + except Exception as enrich_err: + logger.warning( + "Failed to enrich foreign key error for table %s: %s", + referenced_table, + enrich_err, + ) + return exc + + if not display_name or display_name == referenced_table: + return exc + + message = str(exc) + if "无法删除" in message: + message = ( + f"无法删除,该数据已被「{display_name}」引用," + "请先删除或解除关联数据" + ) + elif referenced_table in message: + message = message.replace(referenced_table, display_name) + else: + message = f"操作失败,引用的「{display_name}」数据不存在或无效" + + return ForeignKeyConstraintError( + message=message, + detail=exc.context.get("detail", ""), + referenced_table=referenced_table, + constraint_name=exc.context.get("constraint_name", ""), + ) + + async def _resolve_table_display_name( + self, + db: AsyncSession, + table_name: str, + ) -> str: + stmt = select(FormMeta.name).where( + FormMeta.main_table == table_name, + FormMeta.is_deleted == False, + ).limit(1) + result = await db.execute(stmt) + form_name = result.scalar_one_or_none() + if form_name: + return form_name + + sub_stmt = ( + select(FormMeta.name) + .join(FormSubTable, FormSubTable.form_id == FormMeta.id) + .where( + FormSubTable.table_name == table_name, + FormMeta.is_deleted == False, + ) + .limit(1) + ) + sub_result = await db.execute(sub_stmt) + return sub_result.scalar_one_or_none() or table_name + + async def _execute_query( + self, + db: AsyncSession, + sql: str, + params: Any = None, + *, + database: Optional[str] = None, + enrich_fk: bool = True, + ) -> List[Dict[str, Any]]: + """执行查询,自动将数据库异常转换为业务异常""" + if params is None: + params = {} + try: + exec_db = self._resolve_exec_database(database) + return await self.db_adapter.execute_query( + sql, params, database=exec_db + ) + except FormDataException: + raise + except Exception as e: + biz_exc = translate_db_error(e) + if enrich_fk and isinstance(biz_exc, ForeignKeyConstraintError): + biz_exc = await self._try_enrich_foreign_key_error(db, biz_exc) + raise biz_exc from e + + async def _execute_command( + self, + db: AsyncSession, + sql: str, + params: Any = None, + *, + database: Optional[str] = None, + enrich_fk: bool = True, + ) -> int: + """执行命令,返回影响行数,自动将数据库异常转换为业务异常""" + if params is None: + params = {} + try: + self.db_adapter.ensure_write_allowed() + exec_db = self._resolve_exec_database(database) + return await self.db_adapter.execute_command( + sql, params, database=exec_db + ) + except FormDataException: + raise + except Exception as e: + biz_exc = translate_db_error(e) + if enrich_fk and isinstance(biz_exc, ForeignKeyConstraintError): + biz_exc = await self._try_enrich_foreign_key_error(db, biz_exc) + raise biz_exc from e + + async def _get_table_columns( + self, + db: AsyncSession, + table: str, + schema: Optional[str], + database: Optional[str], + *, + sql_builder: Optional[DynamicSQLBuilder] = None, + db_adapter: Optional[FormDataDbAdapter] = None, + ) -> List[str]: + """获取表的所有列名""" + sb = sql_builder or self.sql_builder + adapter = db_adapter or self.db_adapter + try: + if sb.db_type == "postgresql": + schema_name = schema or "public" + sql = """ + SELECT column_name + FROM information_schema.columns + WHERE table_schema = :schema + AND table_name = :table + """ + params = {"schema": schema_name, "table": table} + elif sb.db_type == "mysql": + db_name = database or "information_schema" + sql = """ + SELECT column_name + FROM information_schema.columns + WHERE table_schema = :database + AND table_name = :table + """ + params = {"database": db_name, "table": table} + elif sb.db_type == "sqlserver": + schema_name = schema or "dbo" + sql = """ + SELECT column_name + FROM information_schema.columns + WHERE table_schema = :schema + AND table_name = :table + """ + params = {"schema": schema_name, "table": table} + elif sb.db_type == "oracle": + owner = (schema or "").upper() + sql = """ + SELECT column_name + FROM all_tab_columns + WHERE owner = :schema + AND table_name = :table + """ + params = {"schema": owner, "table": table.upper()} + else: + return [] + + exec_db = None + if adapter.is_external and adapter.db_type == "postgresql": + exec_db = (database or "").strip() or self._resolve_exec_database() + result = await adapter.execute_query(sql, params, database=exec_db) + return [row["column_name"] for row in result] + except Exception as e: + logger.error(f"获取表列信息失败: {str(e)}") + return [] + + async def _check_column_exists( + self, + db: AsyncSession, + table: str, + schema: Optional[str], + database: Optional[str], + column_name: str, + *, + sql_builder: Optional[DynamicSQLBuilder] = None, + db_adapter: Optional[FormDataDbAdapter] = None, + ) -> bool: + """检查表中是否存在指定列""" + columns = await self._get_table_columns( + db, table, schema, database, + sql_builder=sql_builder, db_adapter=db_adapter, + ) + return column_name in columns + + async def _validate_sort_field( + self, + db: AsyncSession, + table: str, + schema: Optional[str], + database: Optional[str], + sort_field: str + ) -> Optional[str]: + """验证排序字段是否存在于表中,如果不存在则返回 None""" + try: + # 构建表名 + table_name = self.sql_builder.build_table_name(table, schema, database) + + # 获取表的列信息 + columns = await self._get_table_columns(db, table, schema, database) + if not columns: + # 无法获取列信息,直接返回字段(不验证) + return sort_field + + # 检查排序字段是否存在 + if sort_field in columns: + return sort_field + + logger.warning(f"排序字段 {sort_field} 不存在于表 {table_name} 中,可用字段: {columns}") + return None + + except Exception as e: + logger.error(f"验证排序字段失败: {str(e)}") + # 验证失败时返回 None,使用默认排序 + return None + + # ============ 查询操作 ============ + + async def list( + self, + db: AsyncSession, + page: int = 1, + page_size: int = 20, + filters: Dict[str, Any] = None, + sort_list: List[Dict[str, str]] = None, + data_scope: Dict[str, Any] = None, + search: str = None, + search_fields: List[str] = None + ) -> Dict[str, Any]: + """分页查询列表(仅主表,支持数据权限过滤) + + Args: + sort_list: 排序列表,格式为 [{"field": "name", "order": "asc"}, ...] + data_scope: 数据权限过滤配置 + search: 搜索关键词 + search_fields: 搜索字段列表(多字段模糊搜索,OR 关系) + """ + import time + _t0 = time.perf_counter() + + table = self.form_meta.main_table + schema = await self._resolve_schema(db, self.form_meta.main_table_schema) or None + database = self.form_meta.main_table_database or None + + # 应用数据权限过滤(先检查字段是否存在) + if data_scope: + filters = await self._apply_data_scope_filter_safe(db, table, schema, database, filters, data_scope) + + _t1 = time.perf_counter() + logger.info(f"[list 耗时] 数据权限过滤: {(_t1 - _t0) * 1000:.1f}ms") + + # 处理关联字段的名称搜索(转换为 ID 搜索) + if filters: + filters = await self._convert_relation_name_to_id(db, filters) + # 转换日期字符串为 date 对象(PostgreSQL asyncpg 需要) + filters = self._convert_date_strings(filters) + + _t2 = time.perf_counter() + logger.info(f"[list 耗时] 关联字段转换+日期转换: {(_t2 - _t1) * 1000:.1f}ms") + + # 处理多字段搜索(OR 关系) + if search and search_fields: + if not filters: + filters = {} + # 使用特殊的 __search__ 键来标记多字段搜索 + filters['__search__'] = { + 'keyword': search, + 'fields': search_fields + } + + # 构建 ORDER BY(支持多字段排序) + order_by = None + if sort_list: + order_clauses = [] + for sort_item in sort_list: + sort_field = sort_item.get('field') + sort_order = sort_item.get('order', 'desc') + + if not sort_field: + continue + + # 验证排序字段是否存在于表中 + valid_sort_field = await self._validate_sort_field(db, table, schema, database, sort_field) + if valid_sort_field: + direction = "DESC" if sort_order.lower() == "desc" else "ASC" + order_clauses.append(f"{self.sql_builder.quote_identifier(valid_sort_field)} {direction}") + else: + logger.warning(f"排序字段 {sort_field} 不存在于表 {table} 中,跳过") + + if order_clauses: + order_by = ", ".join(order_clauses) + + _t3 = time.perf_counter() + logger.info(f"[list 耗时] 排序字段验证: {(_t3 - _t2) * 1000:.1f}ms") + + # 查询总数 + count_sql, count_params = self.sql_builder.build_count( + table, schema, database, filters + ) + main_exec_db = self._table_exec_database(database) + count_result = await self._execute_query( + db, count_sql, count_params, database=main_exec_db + ) + total = count_result[0]["total"] if count_result else 0 + + _t4 = time.perf_counter() + logger.info(f"[list 耗时] COUNT 查询 (total={total}): {(_t4 - _t3) * 1000:.1f}ms | SQL: {count_sql}") + + # 查询数据 + offset = (page - 1) * page_size + data_sql, data_params = self.sql_builder.build_select( + table=table, + schema=schema, + database=database, + where=filters, + order_by=order_by, + limit=page_size, + offset=offset + ) + + rows = await self._execute_query( + db, data_sql, data_params, database=main_exec_db + ) + + _t5 = time.perf_counter() + logger.info(f"[list 耗时] 数据查询 (rows={len(rows)}): {(_t5 - _t4) * 1000:.1f}ms | SQL: {data_sql}") + + # 处理特殊类型 + items = [self._serialize_row(row) for row in rows] + + _t6 = time.perf_counter() + logger.info(f"[list 耗时] 序列化: {(_t6 - _t5) * 1000:.1f}ms") + + # 填充关联字段的显示名称 + relation_fields = self._get_relation_fields() + logger.debug(f"识别到的关联字段: {relation_fields}") + if relation_fields and items: + items = await self._fill_relation_display_names(db, items, relation_fields) + + _t7 = time.perf_counter() + logger.info(f"[list 耗时] 填充关联字段显示名称 (fields={len(relation_fields)}): {(_t7 - _t6) * 1000:.1f}ms") + + # 填充虚拟字段的值(基于值关联配置) + items = await self._fill_virtual_fields(db, items, relation_fields, context="list") + + _t8 = time.perf_counter() + logger.info(f"[list 耗时] 填充虚拟字段: {(_t8 - _t7) * 1000:.1f}ms") + + # 应用字段权限过滤 + items = await self.apply_field_permissions(items, db) + + _t9 = time.perf_counter() + logger.info(f"[list 耗时] 字段权限过滤: {(_t9 - _t8) * 1000:.1f}ms") + logger.info(f"[list 耗时] ===== 总耗时: {(_t9 - _t0) * 1000:.1f}ms (table={table}, page={page}, page_size={page_size}, total={total}) =====") + + return { + "items": items, + "total": total, + "page": page, + "page_size": page_size + } + + async def list_cursor( + self, + db: AsyncSession, + page_size: int = 20, + cursor: Optional[str] = None, + cursor_direction: str = "next", + filters: Dict[str, Any] = None, + sort_list: List[Dict[str, str]] = None, + data_scope: Dict[str, Any] = None, + search: str = None, + search_fields: List[str] = None + ) -> Dict[str, Any]: + """游标分页查询列表(跳过 COUNT,使用 keyset pagination)""" + import base64 + import time + _t0 = time.perf_counter() + + table = self.form_meta.main_table + schema = await self._resolve_schema(db, self.form_meta.main_table_schema) or None + database = self.form_meta.main_table_database or None + + if data_scope: + filters = await self._apply_data_scope_filter_safe(db, table, schema, database, filters, data_scope) + + if filters: + filters = await self._convert_relation_name_to_id(db, filters) + filters = self._convert_date_strings(filters) + + if search and search_fields: + if not filters: + filters = {} + filters['__search__'] = { + 'keyword': search, + 'fields': search_fields + } + + # 构建排序字段列表 [(field, "ASC"|"DESC"), ...] + order_fields: List[Tuple[str, str]] = [] + if sort_list: + for sort_item in sort_list: + sort_field = sort_item.get('field') + sort_order = sort_item.get('order', 'desc') + if not sort_field: + continue + valid_sort_field = await self._validate_sort_field(db, table, schema, database, sort_field) + if valid_sort_field: + direction = "DESC" if sort_order.lower() == "desc" else "ASC" + order_fields.append((valid_sort_field, direction)) + + # 确保 id 作为 tie-breaker 保证排序唯一性 + id_already = any(f == "id" for f, _ in order_fields) + if not id_already: + last_dir = order_fields[-1][1] if order_fields else "DESC" + order_fields.append(("id", last_dir)) + + if not order_fields: + order_fields = [("id", "DESC")] + + # 解码游标 + cursor_values = None + if cursor: + try: + padding = 4 - len(cursor) % 4 + if padding != 4: + cursor += "=" * padding + cursor_json = base64.urlsafe_b64decode(cursor).decode("utf-8") + cursor_data = json.loads(cursor_json) + cursor_values = cursor_data.get("v", {}) + # asyncpg 要求 datetime/date 列传入原生对象而非字符串 + if cursor_values: + for k, v in cursor_values.items(): + if not isinstance(v, str): + continue + try: + parsed = datetime.fromisoformat(v) + cursor_values[k] = parsed if "T" in v or " " in v else parsed.date() + except (ValueError, TypeError): + pass + except Exception as e: + logger.warning(f"游标解码失败: {e}, 忽略游标从头查询") + cursor_values = None + + _t1 = time.perf_counter() + + data_sql, data_params = self.sql_builder.build_cursor_select( + table=table, + schema=schema, + database=database, + where=filters, + order_fields=order_fields, + cursor_values=cursor_values, + direction=cursor_direction, + limit=page_size + ) + + rows = await self._execute_query(db, data_sql, data_params) + + _t2 = time.perf_counter() + logger.info(f"[list_cursor 耗时] 数据查询 (rows={len(rows)}): {(_t2 - _t1) * 1000:.1f}ms | SQL: {data_sql}") + + # 判断 has_more:查询了 page_size + 1 条 + has_more = len(rows) > page_size + if has_more: + rows = rows[:page_size] + + # prev 方向查询时结果是反序的,需要翻转回来 + if cursor_direction == "prev": + rows = list(reversed(rows)) + + items = [self._serialize_row(row) for row in rows] + + relation_fields = self._get_relation_fields() + if relation_fields and items: + items = await self._fill_relation_display_names(db, items, relation_fields) + + items = await self._fill_virtual_fields(db, items, relation_fields, context="list") + items = await self.apply_field_permissions(items, db) + + # 编码游标 + def _encode_cursor(row_data: Dict[str, Any]) -> Optional[str]: + if not row_data: + return None + v = {} + for field, _ in order_fields: + val = row_data.get(field) + if isinstance(val, (datetime, date)): + val = val.isoformat() + elif isinstance(val, Decimal): + val = float(val) + v[field] = val + cursor_obj = json.dumps({"v": v}, ensure_ascii=False, default=str) + return base64.urlsafe_b64encode(cursor_obj.encode("utf-8")).decode("utf-8").rstrip("=") + + next_cursor = None + prev_cursor = None + + if items: + # 用原始行数据(序列化前)生成游标,确保值精确 + last_raw = rows[-1] if rows else None + first_raw = rows[0] if rows else None + + if cursor_direction == "prev": + last_raw = rows[0] if rows else None + first_raw = rows[-1] if rows else None + + if has_more: + if cursor_direction == "next": + next_cursor = _encode_cursor(dict(last_raw) if last_raw else None) + else: + prev_cursor = _encode_cursor(dict(first_raw) if first_raw else None) + + # 始终提供反方向游标(如果有游标传入,说明可以往回翻) + if cursor: + if cursor_direction == "next": + prev_cursor = _encode_cursor(dict(rows[0]) if rows else None) + if has_more: + next_cursor = _encode_cursor(dict(rows[-1]) if rows else None) + else: + next_cursor = None + else: + next_cursor = _encode_cursor(dict(rows[-1]) if rows else None) + if has_more: + prev_cursor = _encode_cursor(dict(rows[0]) if rows else None) + else: + prev_cursor = None + else: + # 第一页:没有 prev_cursor + prev_cursor = None + if has_more: + next_cursor = _encode_cursor(dict(rows[-1]) if rows else None) + + _t3 = time.perf_counter() + logger.info(f"[list_cursor 耗时] ===== 总耗时: {(_t3 - _t0) * 1000:.1f}ms (table={table}, page_size={page_size}, rows={len(items)}) =====") + + return { + "items": items, + "has_more": has_more if cursor_direction == "next" else (cursor is not None), + "next_cursor": next_cursor, + "prev_cursor": prev_cursor, + "page_size": page_size + } + + async def get_tree_children( + self, + db: AsyncSession, + parent_id: str = None, + parent_field: str = "parent_id", + data_scope: Dict[str, Any] = None + ) -> List[Dict[str, Any]]: + """获取树形数据的子节点(用于懒加载模式) + + Args: + parent_id: 父节点ID,为空时获取根节点 + parent_field: 父节点字段名,默认为 parent_id + data_scope: 数据权限过滤配置 + + Returns: + 子节点列表,每个节点包含 has_children 标记 + """ + table = self.form_meta.main_table + schema = await self._resolve_schema(db, self.form_meta.main_table_schema) or None + database = self.form_meta.main_table_database or None + + # 验证 parent_field 是否存在 + valid_parent_field = await self._validate_sort_field(db, table, schema, database, parent_field) + if not valid_parent_field: + raise ColumnNotFoundError(column=parent_field, table=table) + + # 构建过滤条件 + filters = {} + if parent_id: + # 获取指定父节点的子节点 + filters[parent_field] = {"type": "eq", "value": parent_id} + else: + # 获取根节点(parent_field 为空或 NULL) + filters[parent_field] = {"type": "null"} + + # 应用数据权限过滤 + if data_scope: + filters = await self._apply_data_scope_filter_safe(db, table, schema, database, filters, data_scope) + + # 查询数据(按 sort 字段排序) + order_by = f"{self.sql_builder.quote_identifier('sort')} ASC" + + data_sql, data_params = self.sql_builder.build_select( + table=table, + schema=schema, + database=database, + where=filters, + order_by=order_by + ) + + rows = await self._execute_query(db, data_sql, data_params) + items = [self._serialize_row(row) for row in rows] + + # 填充关联字段的显示名称 + relation_fields = self._get_relation_fields() + if relation_fields and items: + items = await self._fill_relation_display_names(db, items, relation_fields) + + # 为每个节点检查是否有子节点 + if items: + table_name = self.sql_builder.build_table_name(table, schema, database) + quoted_parent_field = self.sql_builder.quote_identifier(parent_field) + + # 检查表是否有 is_deleted 字段 + has_is_deleted = await self._check_column_exists(db, table, schema, database, "is_deleted") + + # 批量查询所有节点的子节点数量 + ids = [item.get('id') for item in items if item.get('id')] + if ids: + # 构建 IN 查询 + placeholders = ", ".join([f":id_{i}" for i in range(len(ids))]) + where_clause = f"{quoted_parent_field} IN ({placeholders})" + if has_is_deleted: + where_clause += self.sql_builder.is_deleted_and_clause() + + count_sql = f""" + SELECT {quoted_parent_field} as parent_id, COUNT(*) as child_count + FROM {table_name} + WHERE {where_clause} + GROUP BY {quoted_parent_field} + """ + count_params = {f"id_{i}": id_val for i, id_val in enumerate(ids)} + + try: + count_result = await self._execute_query(db, count_sql, count_params) + # 构建父ID到子节点数量的映射 + child_count_map = {row['parent_id']: row['child_count'] for row in count_result} + + # 为每个节点设置 has_children 标记 + for item in items: + item_id = item.get('id') + item['has_children'] = child_count_map.get(item_id, 0) > 0 + except Exception as e: + logger.warning(f"查询子节点数量失败: {e}") + # 如果查询失败,默认设置为 False + for item in items: + item['has_children'] = False + + return items + + async def get_field_values( + self, + db: AsyncSession, + field_name: str, + page: int = 1, + page_size: int = 20, + search: str = None + ) -> Dict[str, Any]: + """ + 获取指定字段的唯一值列表(用于过滤选项) + + Args: + db: 数据库会话 + field_name: 字段名 + page: 页码 + page_size: 每页数量 + search: 搜索关键词(模糊匹配) + + Returns: + { + "items": [{"value": "值", "label": "显示文本", "count": 数量}], + "total": 总数, + "hasMore": 是否有更多 + } + """ + table = self.form_meta.main_table + schema = await self._resolve_schema(db, self.form_meta.main_table_schema) or None + database = self.form_meta.main_table_database or None + + # 验证字段是否存在 + valid_field = await self._validate_sort_field(db, table, schema, database, field_name) + if not valid_field: + raise ColumnNotFoundError(column=field_name, table=table) + + # 构建表名 + table_name = self.sql_builder.build_table_name(table, schema, database) + quoted_field = self.sql_builder.quote_identifier(field_name) + + # 构建 WHERE 条件(只检查 NOT NULL,不检查空字符串,因为非字符串类型会报错) + where_clause = f"{quoted_field} IS NOT NULL" + params = {} + + if search: + if self.sql_builder.db_type == "postgresql": + where_clause += f" AND CAST({quoted_field} AS TEXT) ILIKE :search" + else: + where_clause += ( + f" AND LOWER(CAST({quoted_field} AS VARCHAR(4000))) " + f"LIKE LOWER(:search)" + ) + params["search"] = f"%{search}%" + + # 检查表是否有 is_deleted 字段 + has_is_deleted = await self._check_column_exists(db, table, schema, database, "is_deleted") + if has_is_deleted: + where_clause += self.sql_builder.is_deleted_and_clause() + + # 查询总数 + count_sql = f""" + SELECT COUNT(DISTINCT {quoted_field}) as total + FROM {table_name} + WHERE {where_clause} + """ + count_result = await self._execute_query(db, count_sql, params) + total = count_result[0]["total"] if count_result else 0 + + # 查询唯一值(带分页) + offset = (page - 1) * page_size + data_sql = f""" + SELECT {quoted_field} as value, COUNT(*) as count + FROM {table_name} + WHERE {where_clause} + GROUP BY {quoted_field} + ORDER BY count DESC, {quoted_field} ASC + """ + data_sql = self.sql_builder._append_limit_offset(data_sql, page_size, offset) + + rows = await self._execute_query(db, data_sql, params) + + # 处理结果,获取显示标签 + items = [] + field_options = self._get_field_options(field_name) + + for row in rows: + value = row["value"] + # 序列化特殊类型 + if isinstance(value, (datetime, date)): + value = value.isoformat() + elif isinstance(value, Decimal): + value = float(value) + elif isinstance(value, uuid.UUID): + value = str(value) + + # 获取显示标签 + label = str(value) + if field_options: + for opt in field_options: + if str(opt.get("value")) == str(value): + label = opt.get("label", str(value)) + break + + items.append({ + "value": value, + "label": label, + "count": row["count"] + }) + + return { + "items": items, + "total": total, + "hasMore": (page * page_size) < total + } + + def _get_unique_check_fields(self) -> List[str]: + """ + 从表单配置中提取启用了唯一性校验的主表字段名列表 + + 支持两种配置方式: + 1. 表单设计器中的 props.uniqueCheck(字段级别) + 2. 数据库设计中的 tableConfigs[].fields[].uniqueCheck(表级别) + """ + unique_fields = set() + form_config = self._form_config + + # 方式1:从表单设计器的 items 中获取 + items = form_config.get("items", []) + + def traverse(item_list: List[Dict], in_sub_table: str = None): + for item in item_list: + item_type = item.get("type", "") + field = item.get("field", "") + + if item_type == "sub-table": + traverse(item.get("children", []), field) + continue + + if item_type in ("grid", "tabs", "collapse", "steps", "divider", "alert", "timeline", "text", "html", "spacer", "title", "button"): + if item.get("columns"): + for col in item["columns"]: + traverse(col.get("children", []), in_sub_table) + if item.get("items"): + for sub_item in item["items"]: + traverse(sub_item.get("children", []), in_sub_table) + continue + + if field and in_sub_table is None: + props = item.get("props", {}) + if props.get("uniqueCheck"): + unique_fields.add(field) + + traverse(items) + + # 方式2:从数据库设计的 tableConfigs 中获取(主表字段) + table_configs = form_config.get("tableConfigs", []) + for table_config in table_configs: + if table_config.get("type") == "main": + for field in table_config.get("fields", []): + if field.get("uniqueCheck"): + unique_fields.add(field.get("name")) + + return list(unique_fields) + + async def _validate_unique_fields( + self, + db: AsyncSession, + data: Dict[str, Any], + exclude_id: str = None + ) -> None: + """ + 批量校验数据中启用了唯一性校验的字段,不通过则抛出 FormDataException + + Args: + db: 数据库会话 + data: 待写入的主表数据 + exclude_id: 编辑时排除的记录ID + """ + unique_fields = self._get_unique_check_fields() + if not unique_fields: + return + + duplicate_fields = [] + for field_name in unique_fields: + value = data.get(field_name) + if value is None or value == '': + continue + is_unique = await self.check_unique(db, field_name, str(value), exclude_id) + if not is_unique: + duplicate_fields.append(field_name) + + if duplicate_fields: + field_labels = self._get_field_labels(duplicate_fields) + messages = [f"'{field_labels.get(f, f)}'" for f in duplicate_fields] + raise UniqueConstraintError( + f"以下字段的值已存在: {', '.join(messages)}", + fields=duplicate_fields, + ) + + def _get_field_labels(self, field_names: List[str]) -> Dict[str, str]: + """获取字段名到标签的映射""" + labels = {} + form_config = self._form_config + items = form_config.get("items", []) + + def traverse(item_list: List[Dict]): + for item in item_list: + field = item.get("field", "") + label = item.get("label", "") + if field and label and field in field_names: + labels[field] = label + + if item.get("columns"): + for col in item["columns"]: + traverse(col.get("children", [])) + if item.get("items"): + for sub_item in item["items"]: + traverse(sub_item.get("children", [])) + if item.get("children"): + traverse(item["children"]) + + traverse(items) + return labels + + async def check_unique( + self, + db: AsyncSession, + field_name: str, + value: str, + exclude_id: str = None + ) -> bool: + """ + 检查字段值在主表中是否唯一 + + Args: + db: 数据库会话 + field_name: 字段名 + value: 字段值 + exclude_id: 排除的记录ID(编辑时排除自身) + + Returns: + True 表示唯一(可用),False 表示已存在 + """ + table = self.form_meta.main_table + schema = await self._resolve_schema(db, self.form_meta.main_table_schema) or None + database = self.form_meta.main_table_database or None + + # 验证字段是否存在 + valid_field = await self._validate_sort_field(db, table, schema, database, field_name) + if not valid_field: + raise ColumnNotFoundError(column=field_name, table=table) + + # 构建查询 + table_name = self.sql_builder.build_table_name(table, schema, database) + quoted_field = self.sql_builder.quote_identifier(field_name) + + where_clause = f"{quoted_field} = :value" + params: Dict[str, Any] = {"value": value} + + # 编辑时排除自身 + if exclude_id: + quoted_id = self.sql_builder.quote_identifier("id") + where_clause += f" AND {quoted_id} != :exclude_id" + params["exclude_id"] = exclude_id + + # 检查 is_deleted 字段 + has_is_deleted = await self._check_column_exists(db, table, schema, database, "is_deleted") + if has_is_deleted: + where_clause += self.sql_builder.is_deleted_and_clause() + + sql = f""" + SELECT COUNT(*) as cnt + FROM {table_name} + WHERE {where_clause} + """ + result = await self._execute_query(db, sql, params) + count = result[0]["cnt"] if result else 0 + return count == 0 + + def _get_field_options(self, field_name: str) -> Optional[List[Dict]]: + """从表单配置中获取字段的选项列表""" + form_config = self._form_config + items = form_config.get("items", []) + + def find_field(item_list: List[Dict]) -> Optional[List[Dict]]: + for item in item_list: + if item.get("field") == field_name: + return item.get("options") + # 递归查找子项 + for key in ["children", "columns", "items"]: + if key in item: + sub_items = item[key] + if isinstance(sub_items, list): + if key == "columns": + for col in sub_items: + if "children" in col: + result = find_field(col["children"]) + if result is not None: + return result + else: + result = find_field(sub_items) + if result is not None: + return result + return None + + return find_field(items) + + async def get(self, db: AsyncSession, pk: Any) -> Dict[str, Any]: + """获取单条数据(含子表)""" + table = self.form_meta.main_table + schema = await self._resolve_schema(db, self.form_meta.main_table_schema) or None + database = self.form_meta.main_table_database or None + + # 查询主表 + sql, params = self.sql_builder.build_select( + table=table, + schema=schema, + database=database, + where={"id": pk} + ) + rows = await self._execute_query(db, sql, params) + + if not rows: + raise RecordNotFoundException(pk, table=table) + + result = self._serialize_row(rows[0]) + + # 查询子表数据 + result["sub_tables"] = {} + for sub_table in self.sub_tables: + sub_data = await self._query_sub_table_data(db, sub_table, pk) + result["sub_tables"][sub_table.table_name] = sub_data + + # 填充关联字段的显示名称和虚拟字段 + relation_fields = self._get_relation_fields() + all_relation_fields = self._get_all_selector_relation_fields(relation_fields) + if all_relation_fields: + await self._fill_relation_display_names(db, [result], all_relation_fields) + filled = await self._fill_virtual_fields(db, [result], relation_fields, context="form") + if filled: + result = filled[0] + + # 应用字段权限过滤 + result = await self.apply_field_permissions(result, db) + + return result + + async def _query_sub_table_data( + self, + db: AsyncSession, + sub_table: FormSubTable, + main_pk: Any + ) -> List[Dict[str, Any]]: + """查询子表数据""" + sql, params = self.sql_builder.build_select( + table=sub_table.table_name, + schema=sub_table.table_schema or None, + database=sub_table.table_database or None, + where={sub_table.foreign_key: main_pk} + ) + sub_db = sub_table.table_database or None + rows = await self._execute_query( + db, sql, params, database=self._table_exec_database(sub_db) + ) + return [self._serialize_row(row) for row in rows] + + def _is_uuid_like(self, value: str) -> bool: + """检查值是否像 UUID(用于判断是 ID 还是名称)""" + if not isinstance(value, str): + return False + # UUID 格式: 8-4-4-4-12 或 32位无连字符 + import re + uuid_pattern = re.compile( + r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$|^[0-9a-f]{32}$', + re.IGNORECASE + ) + return bool(uuid_pattern.match(value)) + + def _is_date_string(self, value: str) -> bool: + """检查值是否为日期字符串格式 YYYY-MM-DD""" + if not isinstance(value, str): + return False + import re + return bool(re.match(r'^\d{4}-\d{2}-\d{2}$', value)) + + def _is_datetime_string(self, value: str) -> bool: + """检查值是否为日期时间字符串格式 YYYY-MM-DD HH:MM:SS 或 ISO 格式""" + if not isinstance(value, str): + return False + import re + # 支持多种格式:YYYY-MM-DD HH:MM:SS, YYYY-MM-DDTHH:MM:SS, 带毫秒和时区 + return bool(re.match(r'^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}', value)) + + def _parse_datetime_string(self, value: str) -> Optional[datetime]: + """解析日期时间字符串为 datetime 对象""" + if not isinstance(value, str): + return None + + # 尝试多种格式 + formats = [ + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%dT%H:%M:%S", + "%Y-%m-%dT%H:%M:%S.%f", + "%Y-%m-%d", + ] + + # 处理带时区的格式 + clean_value = value + if value.endswith('Z'): + clean_value = value[:-1] + elif '+' in value[-6:] or (value.count('-') > 2 and ':' in value[-6:]): + # 移除时区信息 + clean_value = value.rsplit('+', 1)[0].rsplit('-', 1)[0] if '+' in value[-6:] else value + + for fmt in formats: + try: + return datetime.strptime(clean_value, fmt) + except ValueError: + continue + + return None + + @staticmethod + def _convert_bool_string(value: str): + """将字符串 'true'/'false' 转换为 Python bool,非布尔字符串返回 None""" + if value.lower() == 'true': + return True + if value.lower() == 'false': + return False + return None + + def _convert_date_strings(self, filters: Dict[str, Any]) -> Dict[str, Any]: + """将过滤器中的日期/日期时间/布尔字符串转换为原生类型(PostgreSQL asyncpg 需要)""" + if not filters: + return filters + + converted = {} + for field, value in filters.items(): + if isinstance(value, dict): + filter_type = value.get("type") + filter_value = value.get("value") + + # 处理布尔字符串 + if filter_type in ("eq",) and isinstance(filter_value, str): + bool_val = self._convert_bool_string(filter_value) + if bool_val is not None: + converted[field] = {"type": filter_type, "value": bool_val} + continue + + # 处理 gte, lte, gt, lt, eq 等类型的日期/日期时间值 + # 注意:如果 case_sensitive 明确设为 false,说明是文本比较,不转换日期 + case_sensitive = value.get("case_sensitive", True) + if filter_type in ("gte", "lte", "gt", "lt", "eq") and isinstance(filter_value, str) and case_sensitive: + if self._is_datetime_string(filter_value): + # 优先尝试解析为 datetime + parsed = self._parse_datetime_string(filter_value) + if parsed: + converted[field] = {"type": filter_type, "value": parsed} + else: + converted[field] = value + elif self._is_date_string(filter_value): + # 对于 datetime 字段使用日期格式查询时,需要特殊处理 + # lte/lt: 使用当天的 23:59:59.999999(包含当天所有时间) + # gte/gt: 使用当天的 00:00:00 + try: + parsed_date = date.fromisoformat(filter_value) + if filter_type == "lte": + # 小于等于:转换为当天结束时间 + end_of_day = datetime.combine(parsed_date, datetime.max.time()) + converted[field] = {"type": filter_type, "value": end_of_day} + elif filter_type == "lt": + # 小于:转换为当天开始时间(不包含当天) + start_of_day = datetime.combine(parsed_date, datetime.min.time()) + converted[field] = {"type": filter_type, "value": start_of_day} + elif filter_type == "gte": + # 大于等于:转换为当天开始时间 + start_of_day = datetime.combine(parsed_date, datetime.min.time()) + converted[field] = {"type": filter_type, "value": start_of_day} + elif filter_type == "gt": + # 大于:转换为当天结束时间(不包含当天) + end_of_day = datetime.combine(parsed_date, datetime.max.time()) + converted[field] = {"type": filter_type, "value": end_of_day} + elif filter_type == "eq": + # 精确匹配日期:转换为当天范围查询(00:00:00 - 23:59:59) + # 这样 2026-01-14 可以匹配 2026-01-14 16:16:09 + start_of_day = datetime.combine(parsed_date, datetime.min.time()) + end_of_day = datetime.combine(parsed_date, datetime.max.time()) + converted[field] = {"type": "range", "value": [start_of_day, end_of_day]} + else: + converted[field] = {"type": filter_type, "value": parsed_date} + except ValueError: + converted[field] = value + else: + converted[field] = value + # 处理 in 类型的日期值列表 + elif filter_type == "in" and isinstance(filter_value, list): + converted_values = [] + for v in filter_value: + if isinstance(v, str): + if self._is_datetime_string(v): + parsed = self._parse_datetime_string(v) + converted_values.append(parsed if parsed else v) + elif self._is_date_string(v): + try: + converted_values.append(date.fromisoformat(v)) + except ValueError: + converted_values.append(v) + else: + converted_values.append(v) + else: + converted_values.append(v) + converted[field] = {"type": filter_type, "value": converted_values} + # 处理 range 类型 + elif filter_type == "range" and isinstance(filter_value, list) and len(filter_value) == 2: + converted_values = [] + for idx, v in enumerate(filter_value): + if isinstance(v, str): + if self._is_datetime_string(v): + parsed = self._parse_datetime_string(v) + converted_values.append(parsed if parsed else v) + elif self._is_date_string(v): + # 对于 range 查询,第一个值(开始)使用当天开始,第二个值(结束)使用当天结束 + try: + parsed_date = date.fromisoformat(v) + if idx == 0: + # 开始日期:当天 00:00:00 + start_of_day = datetime.combine(parsed_date, datetime.min.time()) + converted_values.append(start_of_day) + else: + # 结束日期:当天 23:59:59.999999 + end_of_day = datetime.combine(parsed_date, datetime.max.time()) + converted_values.append(end_of_day) + except ValueError: + converted_values.append(v) + else: + converted_values.append(v) + else: + converted_values.append(v) + converted[field] = {"type": filter_type, "value": converted_values} + else: + converted[field] = value + elif isinstance(value, str): + # 简单等值条件:布尔字符串 + bool_val = self._convert_bool_string(value) + if bool_val is not None: + converted[field] = bool_val + # 简单等值条件的日期/日期时间字符串 + elif self._is_datetime_string(value): + parsed = self._parse_datetime_string(value) + converted[field] = parsed if parsed else value + elif self._is_date_string(value): + try: + converted[field] = date.fromisoformat(value) + except ValueError: + converted[field] = value + else: + converted[field] = value + else: + converted[field] = value + + return converted + + async def _convert_form_data_filter_to_ids( + self, + db: AsyncSession, + relation_config: Dict[str, str], + value: Any, + ) -> Any: + """将表单选择器字段的显示名筛选转为关联表主键 ID(支持第三方库)。""" + form_code = relation_config.get("form_code") + display_column = relation_config.get("display_column", "name") + relation_key = relation_config.get("relation_key", "id") + if not form_code: + return value + + is_list, parsed = self._parse_list_value(value) + if is_list and parsed and all(self._is_uuid_like(v) for v in parsed): + if len(parsed) == 1: + return parsed[0] + return {"type": "in", "value": parsed} + if isinstance(value, list) and all(self._is_uuid_like(v) for v in value): + if len(value) == 1: + return value[0] + return {"type": "in", "value": value} + if isinstance(value, str) and self._is_uuid_like(value): + return value + + from online_dev.form_manager.model import FormMeta as RelatedFormMeta + + stmt = select(RelatedFormMeta).where( + RelatedFormMeta.code == form_code, + RelatedFormMeta.is_deleted == False, + ) + result = await db.execute(stmt) + related_form = result.scalar_one_or_none() + if not related_form: + return value + + rel_adapter, rel_builder = await resolve_form_sql_context( + db, + related_form, + adapter_cache=self._adapter_cache, + builder_cache=self._sql_builder_cache, + ) + table = related_form.main_table + schema = await self._resolve_schema(db, related_form.main_table_schema) or None + rel_database = related_form.main_table_database or None + table_name = rel_builder.build_table_name(table, schema, rel_database) + key_col = rel_builder.quote_identifier(relation_key) + display_col = rel_builder.quote_identifier(display_column) + rel_exec_db = self._table_exec_database(rel_database) + + has_is_deleted = await self._check_column_exists( + db, + table, + related_form.main_table_schema, + related_form.main_table_database, + "is_deleted", + sql_builder=rel_builder, + db_adapter=rel_adapter, + ) + deleted_clause = ( + f" AND {rel_builder.is_deleted_predicate()}" if has_is_deleted else "" + ) + + if isinstance(value, dict) and "type" in value: + filter_type = value.get("type") + filter_value = value.get("value") + if filter_type in ("space_like_and", "space_like_or", "space_eq_and", "space_eq_or"): + return value + if filter_type == "in" and isinstance(filter_value, list): + if all(self._is_uuid_like(v) for v in filter_value): + return value + if filter_type == "like": + search_sql = ( + f"SELECT {key_col} as id FROM {table_name} " + f"WHERE {display_col} LIKE :search_value{deleted_clause}" + ) + params = {"search_value": f"%{filter_value}%"} + else: + case_sensitive = value.get("case_sensitive", True) + if case_sensitive: + where_clause = f"{display_col} = :search_value" + elif rel_builder.db_type == "postgresql": + where_clause = ( + f"CAST({display_col} AS TEXT) ILIKE :search_value" + ) + else: + where_clause = ( + f"LOWER(CAST({display_col} AS VARCHAR(4000))) " + f"= LOWER(:search_value)" + ) + search_sql = ( + f"SELECT {key_col} as id FROM {table_name} " + f"WHERE {where_clause}{deleted_clause}" + ) + params = {"search_value": filter_value} + else: + if rel_builder.db_type == "postgresql": + where_clause = f"CAST({display_col} AS TEXT) ILIKE :search_value" + else: + where_clause = f"{display_col} LIKE :search_value" + search_sql = ( + f"SELECT {key_col} as id FROM {table_name} " + f"WHERE {where_clause}{deleted_clause}" + ) + params = {"search_value": f"%{value}%"} + + try: + rows = await rel_adapter.execute_query( + search_sql, params, database=rel_exec_db + ) + except Exception as e: + logger.error("表单关联字段名称转 ID 失败 [%s]: %s", form_code, e) + return value + + if not rows: + return "00000000-0000-0000-0000-000000000000" + ids = [row["id"] for row in rows] + if len(ids) == 1: + return ids[0] + return {"type": "in", "value": ids} + + async def _convert_relation_name_to_id(self, db: AsyncSession, filters: Dict[str, Any]) -> Dict[str, Any]: + """将关联字段的名称搜索转换为 ID 搜索,或直接使用 ID 列表""" + if not filters: + return filters + + # 获取关联字段配置 + relation_fields = self._get_relation_fields() + + if not relation_fields: + return filters + + converted_filters = {} + + for field, value in filters.items(): + # 检查是否是关联字段 + if field in relation_fields: + relation_config = relation_fields[field] + relation_type = relation_config.get("relation_type", "") + + if relation_type == "form_data": + try: + converted_filters[field] = await self._convert_form_data_filter_to_ids( + db, relation_config, value + ) + except Exception as e: + logger.error(f"转换表单关联字段 {field} 搜索失败: {e}") + converted_filters[field] = value + continue + + relation_table = relation_config.get("relation_table") + display_column = relation_config.get("display_column", "name") + + if not relation_table: + converted_filters[field] = value + continue + + try: + is_list, parsed = self._parse_list_value(value) + if is_list and parsed and all(self._is_uuid_like(v) for v in parsed): + if len(parsed) == 1: + converted_filters[field] = parsed[0] + else: + converted_filters[field] = {"type": "in", "value": parsed} + continue + if isinstance(value, list) and all(self._is_uuid_like(v) for v in value): + if len(value) == 1: + converted_filters[field] = value[0] + else: + converted_filters[field] = {"type": "in", "value": value} + continue + + # 检查是否是单个 UUID(直接传递的 ID) + if isinstance(value, str) and self._is_uuid_like(value): + converted_filters[field] = value + continue + + # 根据名称查询平台关联表获取 ID(始终在平台 PG) + pb = self._platform_sql_builder + table_name = pb.build_table_name(relation_table, schema=PLATFORM_SCHEMA) + + # 处理不同的过滤类型 + if isinstance(value, dict) and "type" in value: + filter_type = value.get("type") + filter_value = value.get("value") + + # 空格搜索类型是纯文本搜索,不需要转换关联字段 + if filter_type in ("space_like_and", "space_like_or", "space_eq_and", "space_eq_or"): + converted_filters[field] = value + continue + + # 如果 filter_value 是 ID 列表,直接使用 + if filter_type == "in" and isinstance(filter_value, list): + if all(self._is_uuid_like(v) for v in filter_value): + converted_filters[field] = value + continue + + if filter_type == "like": + query_sql = f""" + SELECT {pb.quote_identifier("id")} + FROM {table_name} + WHERE {pb.quote_identifier(display_column)} LIKE :search_value + AND {pb.is_deleted_predicate()} + """ + params = {"search_value": f"%{filter_value}%"} + else: + case_sensitive = value.get("case_sensitive", True) + if case_sensitive: + where_clause = f"{pb.quote_identifier(display_column)} = :search_value" + else: + where_clause = ( + f"LOWER({pb.quote_identifier(display_column)}) " + f"= LOWER(:search_value)" + ) + query_sql = f""" + SELECT {pb.quote_identifier("id")} + FROM {table_name} + WHERE {where_clause} + AND {pb.is_deleted_predicate()} + """ + params = {"search_value": filter_value} + else: + query_sql = f""" + SELECT {pb.quote_identifier("id")} + FROM {table_name} + WHERE {pb.quote_identifier(display_column)} LIKE :search_value + AND {pb.is_deleted_predicate()} + """ + params = {"search_value": f"%{value}%"} + + result = await self._execute_platform_query(db, query_sql, params) + + if result: + # 找到匹配的 ID,使用 ID 进行搜索 + ids = [row["id"] for row in result] + if len(ids) == 1: + converted_filters[field] = ids[0] + else: + # 多个匹配,使用 IN 查询 + converted_filters[field] = {"type": "in", "value": ids} + else: + # 没有找到匹配的名称,返回一个不存在的 ID(确保搜索结果为空) + converted_filters[field] = "00000000-0000-0000-0000-000000000000" + + except Exception as e: + logger.error(f"转换关联字段 {field} 搜索失败: {e}") + converted_filters[field] = value + else: + # 非关联字段,保持原样 + converted_filters[field] = value + + return converted_filters + + def _get_relation_fields(self) -> Dict[str, Dict[str, str]]: + """从表单配置中提取需要关联查询的字段""" + relation_fields = {} + list_config = self._list_config + columns = list_config.get("columns", []) + form_config = self._form_config + + logger.debug(f"表单配置 form_config 类型: {type(form_config)}") + logger.debug(f"表单配置内容: {form_config}") + logger.debug(f"表单配置 items 数量: {len(form_config.get('items', []))}") + logger.debug(f"列表配置 columns 数量: {len(columns)}") + + # 构建字段到组件配置的映射 + field_to_component = {} + + def traverse_items(items): + """递归遍历表单项,构建字段映射""" + if not items: + return + for item in items: + item_type = item.get("type", "") + field = item.get("field", "") + + # 容器类型,递归处理子项 + if item_type == "grid": + for col in item.get("columns", []): + traverse_items(col.get("children", [])) + elif item_type in ("collapse", "steps"): + for panel in item.get("items", []): + traverse_items(panel.get("children", [])) + elif item_type == "tabs": + for panel in item.get("children", []): + traverse_items(panel.get("children", [])) + elif item_type == "sub-table": + traverse_items(item.get("children", [])) + elif field: + # 保存字段对应的组件配置 + field_to_component[field] = item + logger.debug(f"找到字段: {field}, 类型: {item_type}") + + traverse_items(form_config.get("items", [])) + + logger.debug(f"构建的字段映射: {list(field_to_component.keys())}") + + # 遍历列表配置,查找需要关联查询的字段 + for col in columns: + field = col.get("field") + display_field = col.get("displayField") + + if not field or not display_field: + continue + + logger.debug(f"检查字段 {field},displayField: {display_field}") + + # 从表单配置中找到对应的组件 + component = field_to_component.get(field) + if not component: + # 组件可能在未遍历到的容器中,尝试从列配置的保存信息中恢复 + if col.get("isFormDataSelector") and col.get("formCode"): + display_field_name = col.get("displayFieldName", "") + form_code = col.get("formCode", "") + relation_key = col.get("valueField", "id") or "id" + display_column = display_field_name or col.get("labelField", "name") or "name" + relation_fields[field] = { + "display_field": display_field, + "relation_type": "form_data", + "form_code": form_code, + "relation_key": relation_key, + "display_column": display_column, + } + logger.debug(f"字段 {field} 组件未找到,从列配置恢复: formCode={form_code}, displayColumn={display_column}") + else: + logger.warning(f"字段 {field} 在表单配置中未找到对应组件") + continue + + # 获取组件的 props + props = component.get("props", {}) + logger.debug(f"字段 {field} 的组件类型: {component.get('type')}, props: {props}") + + # 从 props 中提取关联表信息 + relation_table = props.get("relationTable") + relation_key = props.get("relationKey", "id") + display_column = props.get("displayColumn", "name") + + # 如果 props 中没有配置,尝试根据组件类型和字段名推断 + if not relation_table: + component_type = component.get("type", "") + # 根据组件类型推断 + type_mapping = { + "department-selector": "core_dept", + "dept-selector": "core_dept", + "dept-select": "core_dept", + "user-selector": "core_user", + "user-select": "core_user", + "position-selector": "core_post", + "post-selector": "core_post", + "role-selector": "core_role", + "org-selector": "core_dept", + "region-selector": "core_region", + } + relation_table = type_mapping.get(component_type) + + # 如果还是没有,根据字段名推断 + if not relation_table: + field_mapping = { + "dept_id": "core_dept", + "department_id": "core_dept", + "user_id": "core_user", + "manger_id": "core_user", + "manager_id": "core_user", + "position_id": "core_post", + "positon_id": "core_post", + } + relation_table = field_mapping.get(field) + + if relation_table: + relation_fields[field] = { + "display_field": display_field, + "relation_table": relation_table, + "relation_key": relation_key, + "display_column": display_column, + } + logger.debug(f"字段 {field} 配置了关联表: {relation_table}") + else: + # 检查是否为表单数据选择器(select 或 table-selector 组件,数据源类型为 formData) + # 或者是新的 form-selector 组件 + component_type = component.get("type", "") + + # form-selector 组件的配置存储在 formSelectorConfig 中 + form_selector_config = component.get("formSelectorConfig") or {} + + # 数据源配置存储在 dataSource 中,而不是 props 中 + data_source = component.get("dataSource") or {} + data_source_type = data_source.get("type", "") if data_source else "" + if not data_source_type: + data_source_type = props.get("dataSourceType", "") + + # 获取 formCode:优先从 formSelectorConfig 获取,其次从 dataSource 获取 + form_code = form_selector_config.get("formCode", "") or data_source.get("formCode", "") or props.get("formCode", "") + + display_field_name = col.get("displayFieldName", "") # 从列表配置中获取要显示的字段名 + + # form-selector 组件直接处理 + if component_type == "form-selector" and form_code: + relation_key = form_selector_config.get("valueField", "id") or "id" + label_field = form_selector_config.get("labelField", "name") or "name" + relation_fields[field] = { + "display_field": display_field, + "relation_type": "form_data", + "form_code": form_code, + "relation_key": relation_key, + "display_column": display_field_name or label_field, + } + logger.debug(f"字段 {field} 配置了表单选择器关联: formCode={form_code}, relationKey={relation_key}, displayColumn={display_field_name or label_field}") + elif component_type in ["select", "tree-select", "radio", "checkbox", "cascader", "table-selector"] and data_source_type == "formData" and form_code: + relation_key = data_source.get("formValueField") or data_source.get("valueField", "id") or "id" + label_field = data_source.get("formLabelField") or data_source.get("labelField", "name") or "name" + relation_fields[field] = { + "display_field": display_field, + "relation_type": "form_data", + "form_code": form_code, + "relation_key": relation_key, + "display_column": display_field_name or label_field, + } + logger.debug(f"字段 {field} 配置了表单数据关联: formCode={form_code}, relationKey={relation_key}, displayColumn={display_field_name or label_field}") + else: + logger.warning(f"字段 {field} 未配置 relationTable 且无法自动推断") + + # 添加系统字段的关联配置(这些字段不在表单配置中,但需要关联查询) + system_field_mapping = { + "sys_creator_id": {"relation_table": "core_user", "display_column": "name"}, + "sys_modifier_id": {"relation_table": "core_user", "display_column": "name"}, + "sys_dept_id": {"relation_table": "core_dept", "display_column": "name"}, + } + + for col in columns: + field = col.get("field") + display_field = col.get("displayField") + + if field in system_field_mapping and display_field: + config = system_field_mapping[field] + relation_fields[field] = { + "display_field": display_field, + "relation_table": config["relation_table"], + "relation_key": "id", + "display_column": config["display_column"], + } + logger.debug(f"系统字段 {field} 配置了关联表: {config['relation_table']}") + + # 识别没有 displayField 的数据源字段(字典 / 表单数据),原地替换 value 为 label + column_fields = {col.get("field") for col in columns if col.get("field")} + for field, component in field_to_component.items(): + if field in relation_fields or field not in column_fields: + continue + data_source = component.get("dataSource") or {} + ds_type = data_source.get("type", "") + + if ds_type == "dict" and data_source.get("dictCode"): + display_field = f"{field}_label" + relation_fields[field] = { + "display_field": display_field, + "relation_type": "dict", + "dict_code": data_source["dictCode"], + } + logger.debug(f"字段 {field} 配置了字典数据源: dictCode={data_source['dictCode']}, displayField={display_field}") + elif ds_type == "formData" and data_source.get("formCode"): + component_type = component.get("type", "") + if component_type in ["select", "tree-select", "radio", "checkbox", "cascader", "table-selector"]: + form_code = data_source["formCode"] + relation_key = data_source.get("formValueField") or data_source.get("valueField", "id") or "id" + label_field = data_source.get("formLabelField") or data_source.get("labelField", "name") or "name" + display_field = f"{field}_name" + relation_fields[field] = { + "display_field": display_field, + "relation_type": "form_data", + "form_code": form_code, + "relation_key": relation_key, + "display_column": label_field, + } + logger.debug(f"字段 {field} 无 displayField,自动补充表单数据关联: formCode={form_code}, displayField={display_field}") + elif ds_type == "dataSource" and data_source.get("dataSourceCode"): + display_field = f"{field}_label" + relation_fields[field] = { + "display_field": display_field, + "relation_type": "data_source", + "data_source_code": data_source["dataSourceCode"], + "value_field": data_source.get("valueField") or "value", + "label_field": data_source.get("labelField") or "label", + } + # 静态选项(select/radio/checkbox/cascader/tree-select 等组件的硬编码 options) + elif component.get("type") in ["select", "radio", "checkbox", "cascader", "tree-select"] and component.get("options"): + if not ds_type or ds_type == "static": + display_field = f"{field}_label" + relation_fields[field] = { + "display_field": display_field, + "relation_type": "static", + "options": component.get("options", []), + } + logger.debug(f"字段 {field} 配置了静态选项: displayField={display_field}, 选项数={len(component.get('options', []))}") + + return relation_fields + + def _get_all_selector_relation_fields(self, existing: Dict[str, Dict[str, str]]) -> Dict[str, Dict[str, str]]: + """从 form_config 中提取所有选择器字段的关联配置,补充 _get_relation_fields 可能遗漏的字段。 + + _get_relation_fields 依赖 list_config.columns,不在列表中的选择器字段不会被识别。 + 此方法直接从 form_config.items 遍历所有选择器组件,确保 get() 上下文下也能填充显示名。 + """ + result = dict(existing) + form_config = self._form_config + + system_type_mapping = { + "dept-selector": "core_dept", + "user-selector": "core_user", + "role-selector": "core_role", + "post-selector": "core_post", + "region-selector": "core_region", + } + + def traverse(items): + if not items: + return + for item in items: + t = item.get("type", "") + field = item.get("field", "") + + if t == "grid": + for col in item.get("columns", []): + traverse(col.get("children", [])) + elif t in ("collapse", "steps"): + for panel in item.get("items", []): + traverse(panel.get("children", [])) + elif t == "tabs": + for panel in item.get("children", []): + traverse(panel.get("children", [])) + elif t == "sub-table": + traverse(item.get("children", [])) + elif field and field not in result: + if t in system_type_mapping: + display_field = field.replace("_id", "_name") if field.endswith("_id") else f"{field}_name" + result[field] = { + "display_field": display_field, + "relation_table": system_type_mapping[t], + "relation_key": "id", + "display_column": "name", + } + elif t == "form-selector": + fsc = item.get("formSelectorConfig") or {} + form_code = fsc.get("formCode") or item.get("props", {}).get("formCode") or "" + if form_code: + label_field = fsc.get("labelField") or item.get("props", {}).get("labelField") or "name" + value_field = fsc.get("valueField") or "id" + if label_field not in ("name", "label"): + display_field = f"{field}_{label_field}" + elif field.endswith("_id"): + display_field = field.replace("_id", "_name") + else: + display_field = f"{field}_name" + result[field] = { + "display_field": display_field, + "relation_type": "form_data", + "form_code": form_code, + "relation_key": value_field, + "display_column": label_field, + } + elif t == "table-selector": + ds = item.get("dataSource") or {} + form_code = ds.get("formCode") or item.get("props", {}).get("formCode") or "" + if form_code: + label_field = ds.get("formLabelField") or item.get("props", {}).get("labelField") or "label" + value_field = ds.get("formValueField") or ds.get("valueField") or "id" + if label_field not in ("name", "label"): + display_field = f"{field}_{label_field}" + elif field.endswith("_id"): + display_field = field.replace("_id", "_name") + else: + display_field = f"{field}_name" + result[field] = { + "display_field": display_field, + "relation_type": "form_data", + "form_code": form_code, + "relation_key": value_field, + "display_column": label_field, + } + # 静态选项(select/radio/checkbox/cascader/tree-select 的硬编码 options) + elif t in ["select", "radio", "checkbox", "cascader", "tree-select"]: + ds = item.get("dataSource") or {} + ds_type = ds.get("type", "") + options = item.get("options", []) + if options and (not ds_type or ds_type == "static"): + display_field = f"{field}_label" if not field.endswith("_id") else field.replace("_id", "_label") + result[field] = { + "display_field": display_field, + "relation_type": "static", + "options": options, + } + + traverse(form_config.get("items", [])) + return result + + async def _fill_relation_display_names( + self, + db: AsyncSession, + items: List[Dict[str, Any]], + relation_fields: Dict[str, Dict[str, str]] + ) -> List[Dict[str, Any]]: + """填充关联字段的显示名称""" + for field, config in relation_fields.items(): + relation_type = config.get("relation_type", "") + relation_table = config.get("relation_table", "") + + # 表单数据选择器特殊处理 + if relation_type == "form_data": + try: + await self._fill_form_data_display_field(db, items, field, config) + except Exception as e: + logger.warning(f"填充表单数据字段 {field} 失败: {str(e)}") + try: + await db.rollback() + except Exception: + pass + continue + + if relation_type == "data_source": + code = config.get("data_source_code") + value_field = config.get("value_field", "value") + label_field = config.get("label_field", "label") + display_field = config.get("display_field", f"{field}_label") + if not code: + continue + try: + from core.data_source.service import DataSourceService + + rows = await DataSourceService.execute(db, code, {}) + if not isinstance(rows, list): + rows = [] + value_to_label = {} + for row in rows: + if isinstance(row, dict): + vk = row.get(value_field) + if vk is not None: + value_to_label[str(vk)] = row.get(label_field, vk) + for item in items: + v = item.get(field) + if v is not None: + is_list, parsed = self._parse_list_value(v) + if is_list: + item[display_field] = [ + value_to_label.get(str(x), x) for x in parsed + ] + else: + item[display_field] = value_to_label.get(str(v), v) + except Exception as e: + logger.warning(f"填充平台数据源字段 {field} 失败: {str(e)}") + continue + + # 字典数据源:批量查询 dict_item,将 label 写入显示字段(不覆盖原值) + if relation_type == "dict": + dict_code = config.get("dict_code") + if not dict_code: + continue + try: + values = set() + for item in items: + v = item.get(field) + if v is not None: + is_list, parsed = self._parse_list_value(v) + if is_list: + values.update(str(x) for x in parsed if x is not None) + else: + values.add(str(v)) + if not values: + continue + value_to_label = await self._query_dict_labels(db, dict_code, list(values)) + + # 确定显示字段名:优先使用配置的 display_field,否则自动生成 {field}_label + display_field = config.get("display_field", f"{field}_label") + + for item in items: + v = item.get(field) + if v is not None: + is_list, parsed = self._parse_list_value(v) + if is_list: + item[display_field] = [value_to_label.get(str(x), x) for x in parsed] + else: + item[display_field] = value_to_label.get(str(v), v) + logger.debug(f"字典字段 {field} 翻译完成: dictCode={dict_code}, 映射数={len(value_to_label)}, displayField={display_field}") + except Exception as e: + logger.warning(f"填充字典字段 {field} 失败: {str(e)}") + continue + + # 静态选项:从 options 中查找 label + if relation_type == "static": + options = config.get("options", []) + if not options: + continue + display_field = config.get("display_field", f"{field}_label") + try: + value_to_label = {} + for opt in options: + opt_value = opt.get("value") + if isinstance(opt_value, bool) or opt_value is not None: + value_to_label[str(opt_value)] = opt.get("label", str(opt_value)) + for item in items: + v = item.get(field) + if v is not None: + is_list, parsed = self._parse_list_value(v) + if is_list: + item[display_field] = [value_to_label.get(str(x), x) for x in parsed] + else: + item[display_field] = value_to_label.get(str(v), v) + logger.debug(f"静态选项字段 {field} 翻译完成: displayField={display_field}, 选项数={len(options)}") + except Exception as e: + logger.warning(f"填充静态选项字段 {field} 失败: {str(e)}") + continue + + # 如果没有关联表,跳过 + if not relation_table: + logger.warning(f"字段 {field} 没有配置关联表,跳过") + continue + + # 省市区组件特殊处理 + if relation_table == "core_region": + try: + codes = set() + for item in items: + is_list, parsed = self._parse_list_value(item.get(field)) + if is_list: + codes.update(str(v) for v in parsed if v) + + if not codes: + continue + + code_to_name = await self._query_region_names(db, list(codes)) + display_field = config["display_field"] + + for item in items: + is_list, parsed = self._parse_list_value(item.get(field)) + if is_list: + names = [ + code_to_name.get(str(c), str(c)) for c in parsed if c + ] + item[display_field] = " / ".join(names) + except Exception as e: + logger.warning(f"填充省市区字段 {field} 失败: {str(e)}") + continue + elif relation_table: + # 其他关联组件的处理逻辑(用户、部门等) + ids = self._collect_field_ids(items, field) + if not ids: + continue + + logger.info(f"准备查询关联字段 {field},关联表: {relation_table},IDs: {ids}") + + try: + # 批量查询关联数据 + relation_data = await self._query_relation_data( + db, + config["relation_table"], + config["relation_key"], + config["display_column"], + list(ids) + ) + + # 构建 ID 到名称的映射 + id_to_name = {str(row["id"]): row["name"] for row in relation_data} + display_field = config["display_field"] + + logger.info(f"字段 {field} 的 ID 到名称映射: {id_to_name}") + + for item in items: + self._set_display_from_id_map(item, field, display_field, id_to_name) + except Exception as e: + logger.warning(f"填充关联字段 {field} 失败: {str(e)}") + # 回滚事务,避免影响后续查询 + try: + await db.rollback() + except Exception: + pass + # 填充失败不影响主流程,继续处理其他字段 + continue + + return items + + @staticmethod + def _parse_list_value(v: Any) -> Tuple[bool, list]: + """将字段值解析为列表。 + 级联/多选组件的值可能是 Python list(json/jsonb 列)、JSON 字符串(varchar/text 列), + 或历史数据中 Python str(list) 产生的单引号格式(如 "['a', 'b']")。 + 返回 (is_list, parsed_list);非列表值返回 (False, [])。 + """ + if isinstance(v, list): + return True, v + if isinstance(v, str) and v.startswith("["): + try: + parsed = json.loads(v) + if isinstance(parsed, list): + return True, parsed + except (json.JSONDecodeError, ValueError): + pass + # 兼容历史数据:str(list) 产生的单引号格式,如 "['a', 'b']" + try: + import ast + parsed = ast.literal_eval(v) + if isinstance(parsed, list): + return True, parsed + except (ValueError, SyntaxError): + pass + return False, [] + + @staticmethod + def _collect_field_ids(items: List[Dict[str, Any]], field: str) -> set: + """从列表行中收集关联字段 ID(兼容 JSON 字符串形式的多选值)。""" + ids: set = set() + for item in items: + v = item.get(field) + if v is None: + continue + is_list, parsed = FormDataService._parse_list_value(v) + if is_list: + ids.update(str(x) for x in parsed if x is not None) + else: + ids.add(str(v)) + return ids + + @staticmethod + def _set_display_from_id_map( + item: Dict[str, Any], + field: str, + display_field: str, + id_to_name: Dict[str, str], + ) -> None: + """将 ID(或 ID 列表)映射为显示名称写入 display_field。""" + value = item.get(field) + if not value: + return + is_list, parsed = FormDataService._parse_list_value(value) + if is_list: + names = [id_to_name.get(str(v), str(v)) for v in parsed if v] + item[display_field] = ", ".join(names) + else: + item[display_field] = id_to_name.get(str(value), str(value)) + + async def _query_dict_labels(self, db: AsyncSession, dict_code: str, values: List[str]) -> Dict[str, str]: + """批量查询字典项的 value->label 映射""" + from core.dict.model import Dict as DictModel + from core.dict_item.model import DictItem + + stmt = ( + select(DictItem.value, DictItem.label) + .join(DictModel, DictItem.dict_id == DictModel.id) + .where( + DictModel.code == dict_code, + DictItem.value.in_(values), + DictItem.is_deleted == False, + ) + ) + result = await db.execute(stmt) + return {row.value: row.label for row in result} + + async def _query_form_data_labels( + self, + db: AsyncSession, + field: str, + config: Dict[str, Any], + items: List[Dict[str, Any]] + ) -> Dict[str, str]: + """查询关联表单数据,返回 value->label 映射(用于无 displayField 的表单数据源字段)""" + form_code = config.get("form_code") + relation_key = config.get("relation_key", "id") + display_column = config.get("display_column", "name") + + # 收集所有不重复的 value(兼容 JSON 字符串数组) + ids = set() + for item in items: + v = item.get(field) + if v is not None: + is_list, parsed = self._parse_list_value(v) + if is_list: + ids.update(str(x) for x in parsed if x is not None) + else: + ids.add(str(v)) + + if not ids or not form_code: + return {} + + from online_dev.form_manager.model import FormMeta as RelatedFormMeta + + stmt = select(RelatedFormMeta).where( + RelatedFormMeta.code == form_code, + RelatedFormMeta.is_deleted == False + ) + result = await db.execute(stmt) + related_form = result.scalar_one_or_none() + + if not related_form: + logger.warning(f"关联表单 {form_code} 不存在") + return {} + + rel_adapter, rel_builder = await resolve_form_sql_context( + db, + related_form, + adapter_cache=self._adapter_cache, + builder_cache=self._sql_builder_cache, + ) + table = related_form.main_table + schema = await self._resolve_schema(db, related_form.main_table_schema) or None + database = related_form.main_table_database or None + + table_name = rel_builder.build_table_name(table, schema, database) + key_col = rel_builder.quote_identifier(relation_key) + display_col = rel_builder.quote_identifier(display_column) + + has_is_deleted = await self._check_column_exists( + db, + table, + related_form.main_table_schema, + related_form.main_table_database, + "is_deleted", + sql_builder=rel_builder, + db_adapter=rel_adapter, + ) + + ids_list = list(ids) + placeholders = ", ".join([f":{i}" for i in range(len(ids_list))]) + is_deleted_clause = ( + f"\n AND {rel_builder.is_deleted_predicate()}" + if has_is_deleted + else "" + ) + sql = f""" + SELECT {key_col} as id, {display_col} as name + FROM {table_name} + WHERE {key_col} IN ({placeholders}){is_deleted_clause} + """ + params = {str(i): ids_list[i] for i in range(len(ids_list))} + rel_exec_db = (database or "").strip() or None + rows = await rel_adapter.execute_query(sql, params, database=rel_exec_db) + + return {str(row["id"]): str(row["name"]) for row in rows} + + async def _fill_virtual_fields( + self, + db: AsyncSession, + items: List[Dict[str, Any]], + relation_fields: Dict[str, Dict[str, str]], + context: str = "list" + ) -> List[Dict[str, Any]]: + """填充虚拟字段的值 + + 虚拟字段通过值关联配置(isVirtualField=true),从源字段的关联数据中 + 提取指定属性填充到结果中,不对应数据库列。 + + Args: + db: 数据库会话 + items: 数据列表 + relation_fields: 已识别的关联字段配置(来自 _get_relation_fields) + context: 调用上下文,'list' 列表接口(受 showVirtualValue 开关控制),'form' 表单详情(始终填充) + """ + if not items: + return items + + # 从 form_config 中提取虚拟字段配置 + virtual_fields = self._get_virtual_fields() + if not virtual_fields: + return items + + # 构建 list_config 中虚拟字段的 showVirtualValue 映射 + list_config = self._list_config + columns = list_config.get("columns", []) + col_show_map = {} + for col in columns: + if col.get("isVirtualField"): + col_show_map[col.get("field", "")] = col.get("showVirtualValue", True) + + logger.info(f"识别到的虚拟字段: {virtual_fields}") + + for vf in virtual_fields: + vf_field = vf["field"] # 虚拟字段名 + source_field = vf["valueSourceField"] # 源字段名 + display_field = vf["valueDisplayField"] # 要提取的属性名 + + # 仅在列表上下文中检查 showVirtualValue 开关,表单详情始终填充 + if context == "list" and not col_show_map.get(vf_field, True): + logger.info(f"虚拟字段 {vf_field} 的显示关联值已关闭,跳过") + continue + + # 从 relation_fields 中获取源字段的关联配置 + source_config = relation_fields.get(source_field) + + # 如果源字段不在 relation_fields 中(可能未添加到列表列),从 form_config 推断 + if not source_config: + source_config = self._infer_relation_config(source_field) + + if not source_config: + logger.warning(f"虚拟字段 {vf_field} 的源字段 {source_field} 没有关联配置,跳过") + continue + + relation_type = source_config.get("relation_type", "") + relation_table = source_config.get("relation_table", "") + relation_key = source_config.get("relation_key", "id") + + # 省市区组件不支持虚拟字段 + if relation_table == "core_region": + logger.warning(f"虚拟字段 {vf_field} 的源字段 {source_field} 是省市区组件,不支持虚拟字段") + continue + + ids = self._collect_field_ids(items, source_field) + if not ids: + continue + + try: + id_to_value = {} + + if relation_type == "form_data": + # 表单数据选择器:查询关联表单的数据表 + form_code = source_config.get("form_code") + if not form_code: + continue + + from online_dev.form_manager.model import FormMeta as RelatedFormMeta + stmt = select(RelatedFormMeta).where( + RelatedFormMeta.code == form_code, + RelatedFormMeta.is_deleted == False + ) + result = await db.execute(stmt) + related_form = result.scalar_one_or_none() + + if not related_form: + logger.warning(f"虚拟字段 {vf_field} 关联表单 {form_code} 不存在") + continue + + rel_adapter, rel_builder = await resolve_form_sql_context( + db, + related_form, + adapter_cache=self._adapter_cache, + builder_cache=self._sql_builder_cache, + ) + table = related_form.main_table + schema = await self._resolve_schema(db, related_form.main_table_schema) or None + database = related_form.main_table_database or None + table_name = rel_builder.build_table_name(table, schema, database) + key_col = rel_builder.quote_identifier(relation_key) + val_col = rel_builder.quote_identifier(display_field) + + has_is_deleted = await self._check_column_exists( + db, + table, + related_form.main_table_schema, + related_form.main_table_database, + "is_deleted", + sql_builder=rel_builder, + db_adapter=rel_adapter, + ) + + placeholders = ", ".join([f":{i}" for i in range(len(ids))]) + ids_list = list(ids) + is_deleted_clause = ( + f"\n AND {rel_builder.is_deleted_predicate()}" + if has_is_deleted + else "" + ) + sql = f""" + SELECT {key_col} as id, {val_col} as val + FROM {table_name} + WHERE {key_col} IN ({placeholders}){is_deleted_clause} + """ + params = {str(i): ids_list[i] for i in range(len(ids_list))} + rel_exec_db = (database or "").strip() or None + rows = await rel_adapter.execute_query( + sql, params, database=rel_exec_db + ) + id_to_value = {str(row["id"]): row["val"] for row in rows} + + else: + if not relation_table: + continue + + pb = self._platform_sql_builder + table_name = pb.build_table_name(relation_table, schema=PLATFORM_SCHEMA) + key_col = pb.quote_identifier(relation_key) + val_col = pb.quote_identifier(display_field) + + placeholders = ", ".join([f":{i}" for i in range(len(ids))]) + ids_list = list(ids) + sql = f""" + SELECT {key_col} as id, {val_col} as val + FROM {table_name} + WHERE {key_col} IN ({placeholders}) + AND {pb.is_deleted_predicate()} + """ + params = {str(i): ids_list[i] for i in range(len(ids_list))} + async with db.begin_nested(): + rows = await self._execute_platform_query(db, sql, params) + id_to_value = {str(row["id"]): row["val"] for row in rows} + + for item in items: + source_value = item.get(source_field) + if not source_value: + item[vf_field] = "" + continue + is_list, parsed = self._parse_list_value(source_value) + if is_list: + vals = [ + id_to_value.get(str(v), "") for v in parsed if v + ] + item[vf_field] = ", ".join(str(v) for v in vals if v) + else: + item[vf_field] = id_to_value.get(str(source_value), "") + + except Exception as e: + logger.warning(f"填充虚拟字段 {vf_field} 失败: {str(e)}") + continue + + return items + + def _get_virtual_fields(self) -> List[Dict[str, str]]: + """从 form_config 中提取虚拟字段配置 + + Returns: + 虚拟字段配置列表,每项包含: + - field: 虚拟字段名 + - valueSourceField: 源字段名 + - valueDisplayField: 要提取的属性名 + """ + virtual_fields = [] + form_config = self._form_config + + def traverse(items): + if not items: + return + for item in items: + item_type = item.get("type", "") + field = item.get("field", "") + props = item.get("props", {}) + + # 检查是否为虚拟字段 + if (field and props.get("isVirtualField") and + props.get("enableValueLink") and + props.get("valueSourceField") and + props.get("valueDisplayField")): + virtual_fields.append({ + "field": field, + "valueSourceField": props["valueSourceField"], + "valueDisplayField": props["valueDisplayField"], + }) + + # 递归处理容器 + if item_type == "grid": + for col in item.get("columns", []): + traverse(col.get("children", [])) + elif item_type in ("collapse", "steps"): + for panel in item.get("items", []): + traverse(panel.get("children", [])) + elif item_type == "tabs": + for panel in item.get("children", []): + traverse(panel.get("children", [])) + elif item_type == "sub-table": + traverse(item.get("children", [])) + + traverse(form_config.get("items", [])) + return virtual_fields + + def _infer_relation_config(self, source_field: str) -> Optional[Dict[str, str]]: + """从 form_config 中推断源字段的关联表配置 + + 当源字段未出现在 list_config.columns 中时,需要从 form_config 的组件类型推断关联信息。 + + Args: + source_field: 源字段名 + + Returns: + 关联配置字典,包含 relation_table, relation_key, display_column 等,或 None + """ + form_config = self._form_config + + # 递归查找源字段的组件配置 + def find_component(items): + if not items: + return None + for item in items: + item_type = item.get("type", "") + field = item.get("field", "") + if field == source_field: + return item + if item_type == "grid": + for col in item.get("columns", []): + found = find_component(col.get("children", [])) + if found: + return found + elif item_type in ("collapse", "steps"): + for panel in item.get("items", []): + found = find_component(panel.get("children", [])) + if found: + return found + elif item_type == "tabs": + for panel in item.get("children", []): + found = find_component(panel.get("children", [])) + if found: + return found + elif item_type == "sub-table": + found = find_component(item.get("children", [])) + if found: + return found + return None + + component = find_component(form_config.get("items", [])) + if not component: + return None + + component_type = component.get("type", "") + props = component.get("props", {}) + + # 根据组件类型推断关联表 + type_mapping = { + "department-selector": "core_dept", + "dept-selector": "core_dept", + "dept-select": "core_dept", + "user-selector": "core_user", + "user-select": "core_user", + "position-selector": "core_post", + "post-selector": "core_post", + "role-selector": "core_role", + "org-selector": "core_dept", + "region-selector": "core_region", + } + + relation_table = props.get("relationTable") or type_mapping.get(component_type) + + if relation_table: + return { + "relation_table": relation_table, + "relation_key": props.get("relationKey", "id"), + "display_column": "name", + } + + # form-selector 组件 + if component_type == "form-selector": + form_selector_config = component.get("formSelectorConfig") or {} + form_code = form_selector_config.get("formCode", "") or props.get("formCode", "") + if form_code: + return { + "relation_type": "form_data", + "form_code": form_code, + "relation_key": form_selector_config.get("valueField", "id") or "id", + "display_column": form_selector_config.get("labelField", "name") or "name", + } + + # select/table-selector 组件,数据源为 formData + data_source = component.get("dataSource") or {} + data_source_type = data_source.get("type", "") or props.get("dataSourceType", "") + form_code = data_source.get("formCode", "") or props.get("formCode", "") + + if component_type in ["select", "tree-select", "radio", "checkbox", "cascader", "table-selector"] and data_source_type == "formData" and form_code: + return { + "relation_type": "form_data", + "form_code": form_code, + "relation_key": data_source.get("formValueField") or data_source.get("valueField", "id") or "id", + "display_column": data_source.get("formLabelField") or data_source.get("labelField", "name") or "name", + } + + # 根据字段名推断 + field_mapping = { + "dept_id": "core_dept", + "department_id": "core_dept", + "user_id": "core_user", + "manger_id": "core_user", + "manager_id": "core_user", + "position_id": "core_post", + "positon_id": "core_post", + } + relation_table = field_mapping.get(source_field) + if relation_table: + return { + "relation_table": relation_table, + "relation_key": "id", + "display_column": "name", + } + + return None + + def _get_non_virtual_linked_fields(self) -> List[Dict[str, str]]: + """从 form_config 中提取非虚拟的值关联字段配置 + + 非虚拟关联字段(enableValueLink=true, isVirtualField=false 或未设置) + 对应数据库列,需要在保存时将关联值写入数据库。 + + Returns: + 配置列表,每项包含: + - field: 字段名(对应数据库列) + - valueSourceField: 源字段名 + - valueDisplayField: 要从关联数据中提取的属性名 + """ + linked_fields = [] + form_config = self._form_config + + def traverse(items): + if not items: + return + for item in items: + item_type = item.get("type", "") + field = item.get("field", "") + props = item.get("props", {}) + + if (field and not props.get("isVirtualField") + and props.get("enableValueLink") + and props.get("valueSourceField") + and props.get("valueDisplayField")): + linked_fields.append({ + "field": field, + "valueSourceField": props["valueSourceField"], + "valueDisplayField": props["valueDisplayField"], + }) + + if item_type == "grid": + for col in item.get("columns", []): + traverse(col.get("children", [])) + elif item_type in ("collapse", "steps"): + for panel in item.get("items", []): + traverse(panel.get("children", [])) + elif item_type == "tabs": + for panel in item.get("children", []): + traverse(panel.get("children", [])) + elif item_type == "sub-table": + traverse(item.get("children", [])) + + traverse(form_config.get("items", [])) + return linked_fields + + async def _fill_linked_field_values( + self, + db: AsyncSession, + data: Dict[str, Any], + table_type: str = "main" + ) -> Dict[str, Any]: + """保存前填充非虚拟关联字段的值 + + 根据值关联配置,从源字段的值反查关联表,将结果写入对应的数据库列。 + + Args: + db: 数据库会话 + data: 待保存的数据 + table_type: "main" 或 "sub" + """ + linked_fields = self._get_non_virtual_linked_fields() + if not linked_fields: + return data + + relation_fields = self._get_relation_fields() + + for lf in linked_fields: + target_field = lf["field"] + source_field = lf["valueSourceField"] + display_field = lf["valueDisplayField"] + + source_value = data.get(source_field) + if not source_value: + continue + + source_config = relation_fields.get(source_field) + if not source_config: + source_config = self._infer_relation_config(source_field) + if not source_config: + logger.warning(f"非虚拟关联字段 {target_field} 的源字段 {source_field} 没有关联配置,跳过") + continue + + relation_type = source_config.get("relation_type", "") + relation_table = source_config.get("relation_table", "") + relation_key = source_config.get("relation_key", "id") + + if relation_table == "core_region": + continue + + ids = set() + if isinstance(source_value, list): + ids.update(str(v) for v in source_value if v) + else: + ids.add(str(source_value)) + + if not ids: + continue + + try: + id_to_value = {} + + if relation_type == "form_data": + form_code = source_config.get("form_code") + if not form_code: + continue + + from online_dev.form_manager.model import FormMeta as RelatedFormMeta + stmt = select(RelatedFormMeta).where( + RelatedFormMeta.code == form_code, + RelatedFormMeta.is_deleted == False + ) + result = await db.execute(stmt) + related_form = result.scalar_one_or_none() + + if not related_form: + logger.warning(f"关联字段 {target_field} 的关联表单 {form_code} 不存在") + continue + + rel_adapter, rel_builder = await resolve_form_sql_context( + db, + related_form, + adapter_cache=self._adapter_cache, + builder_cache=self._sql_builder_cache, + ) + table = related_form.main_table + schema = await self._resolve_schema(db, related_form.main_table_schema) or None + database = related_form.main_table_database or None + table_name = rel_builder.build_table_name(table, schema, database) + key_col = rel_builder.quote_identifier(relation_key) + val_col = rel_builder.quote_identifier(display_field) + + has_is_deleted = await self._check_column_exists( + db, + table, + related_form.main_table_schema, + related_form.main_table_database, + "is_deleted", + sql_builder=rel_builder, + db_adapter=rel_adapter, + ) + + placeholders = ", ".join([f":{i}" for i in range(len(ids))]) + ids_list = list(ids) + is_deleted_clause = ( + f"\n AND {rel_builder.is_deleted_predicate()}" + if has_is_deleted + else "" + ) + sql = f""" + SELECT {key_col} as id, {val_col} as val + FROM {table_name} + WHERE {key_col} IN ({placeholders}){is_deleted_clause} + """ + params = {str(i): ids_list[i] for i in range(len(ids_list))} + rel_exec_db = (database or "").strip() or None + rows = await rel_adapter.execute_query( + sql, params, database=rel_exec_db + ) + id_to_value = {str(row["id"]): row["val"] for row in rows} + + else: + if not relation_table: + continue + + pb = self._platform_sql_builder + table_name = pb.build_table_name(relation_table, schema=PLATFORM_SCHEMA) + key_col = pb.quote_identifier(relation_key) + val_col = pb.quote_identifier(display_field) + + placeholders = ", ".join([f":{i}" for i in range(len(ids))]) + ids_list = list(ids) + sql = f""" + SELECT {key_col} as id, {val_col} as val + FROM {table_name} + WHERE {key_col} IN ({placeholders}) + AND {pb.is_deleted_predicate()} + """ + params = {str(i): ids_list[i] for i in range(len(ids_list))} + rows = await self._execute_platform_query(db, sql, params) + id_to_value = {str(row["id"]): row["val"] for row in rows} + + if id_to_value: + if isinstance(source_value, list): + vals = [id_to_value.get(str(v), "") for v in source_value if v] + data[target_field] = ", ".join(str(v) for v in vals if v) + else: + resolved = id_to_value.get(str(source_value), "") + if resolved: + data[target_field] = resolved + + logger.debug(f"填充非虚拟关联字段 {target_field}: source={source_field}, value={data.get(target_field)}") + + except Exception as e: + logger.warning(f"填充非虚拟关联字段 {target_field} 失败: {str(e)}") + continue + + return data + + async def _fill_form_data_display_field( + self, + db: AsyncSession, + items: List[Dict[str, Any]], + field: str, + config: Dict[str, Any] + ) -> None: + """填充表单数据选择器的显示字段 + + Args: + db: 数据库会话 + items: 数据列表 + field: ID 字段名(如 customer_id) + config: 配置信息,包含 form_code, display_field, display_column, relation_key + """ + form_code = config.get("form_code") + display_field = config.get("display_field") + display_column = config.get("display_column", "name") + relation_key = config.get("relation_key", "id") + + if not form_code or not display_field: + return + + ids = self._collect_field_ids(items, field) + if not ids: + return + + try: + # 获取关联表单的元数据 + from online_dev.form_manager.model import FormMeta as RelatedFormMeta + from sqlalchemy import select + + stmt = select(RelatedFormMeta).where( + RelatedFormMeta.code == form_code, + RelatedFormMeta.is_deleted == False + ) + result = await db.execute(stmt) + related_form = result.scalar_one_or_none() + + if not related_form: + logger.warning(f"关联表单 {form_code} 不存在") + return + + # 获取关联表单的表信息 + table = related_form.main_table + schema = await self._resolve_schema(db, related_form.main_table_schema) or None + database = related_form.main_table_database or None + + rel_adapter, rel_builder = await resolve_form_sql_context( + db, + related_form, + adapter_cache=self._adapter_cache, + builder_cache=self._sql_builder_cache, + ) + table_name = rel_builder.build_table_name(table, schema, database) + key_col = rel_builder.quote_identifier(relation_key) + display_col = rel_builder.quote_identifier(display_column) + + has_is_deleted = await self._check_column_exists( + db, + table, + related_form.main_table_schema, + related_form.main_table_database, + "is_deleted", + sql_builder=rel_builder, + db_adapter=rel_adapter, + ) + + placeholders = ", ".join([f":{i}" for i in range(len(ids))]) + ids_list = list(ids) + + is_deleted_clause = ( + f"\n AND {rel_builder.is_deleted_predicate()}" + if has_is_deleted + else "" + ) + sql = f""" + SELECT {key_col} as id, {display_col} as name + FROM {table_name} + WHERE {key_col} IN ({placeholders}){is_deleted_clause} + """ + + params = {str(i): ids_list[i] for i in range(len(ids_list))} + rel_exec_db = (database or "").strip() or None + rows = await rel_adapter.execute_query(sql, params, database=rel_exec_db) + logger.debug(f"查询表单数据 {form_code}.{table},找到 {len(rows)} 条记录") + + # 构建 ID 到名称的映射 + id_to_name = {str(row["id"]): row["name"] for row in rows} + + for item in items: + self._set_display_from_id_map(item, field, display_field, id_to_name) + + except Exception as e: + logger.error(f"填充表单数据字段 {field} 失败: {str(e)}") + raise + + async def _query_region_names( + self, + db: AsyncSession, + codes: List[str] + ) -> Dict[str, str]: + """批量查询省市区名称 + + 省市区数据分散在 5 个表中: + - core_province: 2位代码(如 "14") + - core_city: 4位代码(如 "1404") + - core_area: 6位代码(如 "140406") + - core_street: 9位代码(如 "140406001") + - core_village: 12位代码(如 "140406001001") + + Args: + db: 数据库会话 + codes: 行政区划代码列表 + + Returns: + code 到 name 的映射字典 + """ + if not codes: + return {} + + result = {} + + try: + # 按代码长度分组 + codes_by_length = {} + for code in codes: + length = len(str(code)) + if length not in codes_by_length: + codes_by_length[length] = [] + codes_by_length[length].append(str(code)) + + # 定义表名映射(代码长度 -> 表名) + table_mapping = { + 2: "core_province", + 4: "core_city", + 6: "core_area", + 9: "core_street", + 12: "core_village", + } + + # 分别查询每个表 + for length, code_list in codes_by_length.items(): + table_name = table_mapping.get(length) + if not table_name: + logger.warning(f"未知的代码长度: {length},代码: {code_list}") + continue + + pb = self._platform_sql_builder + full_table_name = pb.build_table_name(table_name, schema=PLATFORM_SCHEMA) + code_col = pb.quote_identifier("code") + name_col = pb.quote_identifier("name") + + # 使用参数化查询 + placeholders = ", ".join([f":{i}" for i in range(len(code_list))]) + + sql = f""" + SELECT {code_col} as code, {name_col} as name + FROM {full_table_name} + WHERE {code_col} IN ({placeholders}) + """ + + # 构建参数字典 + params = {str(i): code_list[i] for i in range(len(code_list))} + + rows = await self._execute_platform_query(db, sql, params) + logger.info(f"查询 {table_name},找到 {len(rows)} 条记录") + + # 添加到结果映射 + for row in rows: + result[str(row["code"])] = row["name"] + + return result + except Exception as e: + logger.error(f"查询省市区名称失败: {str(e)}") + return {} + + async def _query_relation_data( + self, + db: AsyncSession, + table: str, + key_column: str, + display_column: str, + ids: List[str] + ) -> List[Dict[str, Any]]: + """批量查询关联表数据""" + if not ids: + return [] + + pb = self._platform_sql_builder + table_name = pb.build_table_name(table, schema=PLATFORM_SCHEMA) + key_col = pb.quote_identifier(key_column) + display_col = pb.quote_identifier(display_column) + + placeholders = ", ".join([f":{i}" for i in range(len(ids))]) + + sql = f""" + SELECT {key_col} as id, {display_col} as name + FROM {table_name} + WHERE {key_col} IN ({placeholders}) + AND {pb.is_deleted_predicate()} + """ + + params = {str(i): ids[i] for i in range(len(ids))} + + try: + logger.info(f"查询关联表 {table},SQL: {sql}, params: {params}") + rows = await self._execute_platform_query(db, sql, params) + logger.info(f"查询关联表 {table},找到 {len(rows)} 条记录,结果: {rows}") + return [dict(row) for row in rows] + except Exception as e: + logger.error(f"查询关联表 {table} 失败: {str(e)}, SQL: {sql}, params: {params}") + return [] + + # ============ 写入操作 ============ + + def _convert_data_types(self, data: Dict[str, Any]) -> Dict[str, Any]: + """转换数据类型,将字符串日期时间转换为 Python datetime 对象,处理空字符串""" + converted = {} + + # 获取字段类型映射(用于判断数字类型字段) + field_types = self._get_field_types() + + # 调试日志 + import logging + logger = logging.getLogger(__name__) + # logger.info(f"字段类型映射: {field_types}") + # logger.info(f"待转换数据: {data}") + + for key, value in data.items(): + if value is None: + converted[key] = value + continue + + # 处理空字符串 + if isinstance(value, str) and value.strip() == '': + # 获取字段类型 + field_type = field_types.get(key, '').lower() + # 数字类型字段的空字符串转为 None + if field_type in ('int', 'integer', 'bigint', 'smallint', 'decimal', 'numeric', 'float', 'double', 'real'): + converted[key] = None + continue + # 其他类型保持空字符串或转为 None + converted[key] = None + continue + + # 获取字段类型 + field_type = field_types.get(key, '').lower() + + # 处理非字符串类型到字符串的转换 + # 支持 PostgreSQL 的类型名:character varying, character, text 等 + is_string_type = any(t in field_type for t in ['varchar', 'text', 'char', 'string']) + if not isinstance(value, str) and is_string_type: + if isinstance(value, datetime): + if value.tzinfo is not None: + value = value.astimezone(APP_TIMEZONE).replace(tzinfo=None) + converted[key] = value.isoformat() + logger.info(f"字段 {key} 从 datetime 转换为 str: {value} -> {converted[key]}") + elif isinstance(value, (list, dict)): + converted[key] = json.dumps(value, ensure_ascii=False) + logger.info(f"字段 {key} 从 {type(value).__name__} 序列化为 JSON: {converted[key]}") + else: + converted[key] = str(value) + logger.info(f"字段 {key} 从 {type(value).__name__} 转换为 str: {value} -> {str(value)}") + continue + + # 处理已经是 datetime 对象的情况 + if isinstance(value, datetime): + # 如果是 offset-aware datetime,先转换为配置的时区再移除时区信息 + if value.tzinfo is not None: + value = value.astimezone(APP_TIMEZONE).replace(tzinfo=None) + + # 检查字段类型是否是 timestamp/datetime 类型 + is_datetime_type = any(t in field_type for t in ('date', 'datetime', 'timestamp', 'time')) + if is_datetime_type: + # 保持为 datetime 对象 + converted[key] = value + else: + # 字段类型未知或是字符串类型,转换为 ISO 格式字符串 + converted[key] = value.isoformat() + logger.info(f"字段 {key} 从 datetime 转换为 str (字段类型: {field_type}): {value} -> {converted[key]}") + continue + + # 转换字符串类型的值 + if isinstance(value, str): + + # 1. 尝试转换布尔类型 + if field_type in ('bool', 'boolean'): + # 支持多种布尔值表示 + value_lower = value.lower().strip() + if value_lower in ('true', '1', 'yes', 'y', 't', '是', '真'): + converted[key] = True + continue + elif value_lower in ('false', '0', 'no', 'n', 'f', '否', '假'): + converted[key] = False + continue + # 其他值保持原样 + + # 2. 尝试转换整数类型 + if field_type in ('int', 'integer', 'bigint', 'smallint'): + try: + # 先去除空格,支持 "25 " 这样的输入 + converted[key] = int(value.strip()) + continue + except (ValueError, TypeError): + pass + + # 3. 尝试转换浮点数类型 + elif field_type in ('decimal', 'numeric', 'float', 'double', 'real'): + try: + converted[key] = float(value.strip()) + continue + except (ValueError, TypeError): + pass + + # 4. 尝试解析日期时间(支持 'timestamp without time zone' 等完整类型名) + is_datetime_type = any(t in field_type for t in ('date', 'datetime', 'timestamp', 'time')) + if is_datetime_type: + try: + from datetime import time as time_type + + # 时间格式 + if field_type == 'time': + for fmt in ["%H:%M:%S", "%H:%M:%S.%f", "%H:%M"]: + try: + t = datetime.strptime(value, fmt).time() + converted[key] = t + break + except ValueError: + continue + else: + converted[key] = value + continue + + # 先尝试使用 fromisoformat 解析(支持更多 ISO 格式) + parsed_dt = None + try: + # 处理 Z 后缀(UTC 时区标识) + iso_value = value.replace('Z', '+00:00') if value.endswith('Z') else value + parsed_dt = datetime.fromisoformat(iso_value) + except ValueError: + # fromisoformat 失败,尝试其他格式 + for fmt in [ + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%d %H:%M:%S.%f", + "%Y-%m-%dT%H:%M:%S", + "%Y-%m-%dT%H:%M:%S.%f", + "%Y-%m-%d", + ]: + try: + parsed_dt = datetime.strptime(value, fmt) + break + except ValueError: + continue + + if parsed_dt is not None: + # 如果是日期格式(没有时间部分),只保留日期 + if len(value) == 10 and '-' in value: + converted[key] = parsed_dt.date() + else: + # 将 UTC 时间转换为本地时间后再移除时区信息 + if parsed_dt.tzinfo is not None: + # 转换为配置的时区 + parsed_dt = parsed_dt.astimezone(APP_TIMEZONE) + # 移除时区信息 + parsed_dt = parsed_dt.replace(tzinfo=None) + converted[key] = parsed_dt + else: + # 无法解析,保持原值 + converted[key] = value + continue + except Exception: + converted[key] = value + continue + + # 5. 尝试解析 JSON 类型(json, jsonb) + if field_type in ('json', 'jsonb'): + try: + converted[key] = json.loads(value) + continue + except (json.JSONDecodeError, TypeError): + # 如果解析失败,保持原字符串 + pass + + # 6. 数组类型(PostgreSQL array) + if field_type.endswith('[]') or field_type in ('array', 'text[]', 'varchar[]', 'integer[]'): + try: + parsed = json.loads(value) + if isinstance(parsed, list): + converted[key] = parsed + continue + except (json.JSONDecodeError, TypeError): + # 尝试按逗号分隔 + if ',' in value: + converted[key] = [item.strip() for item in value.split(',')] + continue + + # 默认保持原值 + converted[key] = value + else: + converted[key] = value + + return converted + + def _get_field_types(self) -> Dict[str, str]: + """获取字段名到类型的映射""" + field_types = {} + + # 从 form_config 中获取字段类型 + form_config = self._form_config + table_configs = form_config.get('tableConfigs', []) + + for table_config in table_configs: + fields = table_config.get('fields', []) + for field in fields: + field_name = field.get('name', '') + field_type = field.get('type', '') + if field_name and field_type: + field_types[field_name] = field_type + + return field_types + + async def create(self, db: AsyncSession, data: Dict[str, Any]) -> Dict[str, Any]: + """新增数据(含子表,事务)""" + main_data = data.get("main") or {} + sub_tables_data = data.get("sub_tables") or {} + field_perms = await self._get_merged_field_permissions(db) + + # 过滤主表字段 + allowed_main_fields = self._get_allowed_fields("main") + filtered_main = self._filter_writable_fields(main_data, allowed_main_fields, field_perms) + + # 转换数据类型 + filtered_main = self._convert_data_types(filtered_main) + + # 移除 id 字段,生成新的 UUID + filtered_main.pop("id", None) + generated_id = str(uuid.uuid4()) + filtered_main["id"] = generated_id + + # 填充系统字段(创建时间、创建人、部门等) + filtered_main = self._fill_system_fields_for_create(filtered_main) + + # 填充非虚拟关联字段的值(从源字段反查关联表写入数据库列) + filtered_main = await self._fill_linked_field_values(db, filtered_main, table_type="main") + + if not filtered_main: + raise FormDataValidationError("主表数据不能为空") + + # 唯一性校验(新增时不需要排除ID) + await self._validate_unique_fields(db, filtered_main) + + # 1. 插入主表 + table = self.form_meta.main_table + schema = await self._resolve_schema(db, self.form_meta.main_table_schema) or None + database = self.form_meta.main_table_database or None + + sql, params = self.sql_builder.build_insert( + table=table, + data=filtered_main, + schema=schema, + database=database, + return_id=False + ) + + main_pk = generated_id + + async with self._business_transaction(database=self._table_exec_database(database)): + await self._execute_command( + db, sql, params, database=self._table_exec_database(database) + ) + + for sub_table in self.sub_tables: + sub_data_list = sub_tables_data.get(sub_table.table_name, []) + if not sub_data_list: + continue + + allowed_sub_fields = self._get_allowed_fields("sub", sub_table.table_name) + + for sub_item in sub_data_list: + filtered_sub = self._filter_writable_fields( + sub_item, + allowed_sub_fields, + field_perms, + always_allow={sub_table.foreign_key}, + ) + filtered_sub = self._convert_data_types(filtered_sub) + filtered_sub.pop("id", None) + filtered_sub["id"] = str(uuid.uuid4()) + filtered_sub[sub_table.foreign_key] = main_pk + filtered_sub = self._fill_system_fields_for_create(filtered_sub) + filtered_sub = await self._fill_linked_field_values( + db, filtered_sub, table_type="sub" + ) + + if filtered_sub: + sub_sql, sub_params = self.sql_builder.build_insert( + table=sub_table.table_name, + data=filtered_sub, + schema=sub_table.table_schema or None, + database=sub_table.table_database or None, + return_id=False, + ) + sub_db = sub_table.table_database or None + await self._execute_command( + db, + sub_sql, + sub_params, + database=self._table_exec_database(sub_db), + ) + + await db.commit() + + logger.info(f"表单数据创建成功: form={self._form_code}, pk={main_pk}") + + return await self.get(db, main_pk) + + async def update(self, db: AsyncSession, pk: Any, data: Dict[str, Any]) -> Dict[str, Any]: + """更新数据(含子表,事务)""" + main_data = data.get("main") or {} + sub_tables_data = data.get("sub_tables") or {} + field_perms = await self._get_merged_field_permissions(db) + + await self.get(db, pk) + + main_schema_db = self.form_meta.main_table_database or None + async with self._business_transaction( + database=self._table_exec_database(main_schema_db) + ): + if main_data: + allowed_main_fields = self._get_allowed_fields("main") + filtered_main = self._filter_writable_fields( + main_data, allowed_main_fields, field_perms + ) + filtered_main = self._convert_data_types(filtered_main) + filtered_main.pop("id", None) + filtered_main = self._fill_system_fields_for_update(filtered_main) + filtered_main = await self._fill_linked_field_values( + db, filtered_main, table_type="main" + ) + await self._validate_unique_fields(db, filtered_main, exclude_id=str(pk)) + + if filtered_main: + table = self.form_meta.main_table + schema = await self._resolve_schema( + db, self.form_meta.main_table_schema + ) or None + database = self.form_meta.main_table_database or None + sql, params = self.sql_builder.build_update( + table=table, + data=filtered_main, + pk_field="id", + pk_value=pk, + schema=schema, + database=database, + ) + await self._execute_command( + db, + sql, + params, + database=self._table_exec_database(database), + ) + + for sub_table in self.sub_tables: + if sub_table.table_name not in sub_tables_data: + continue + new_sub_data = sub_tables_data[sub_table.table_name] + await self._handle_sub_table_update( + db, sub_table, pk, new_sub_data, field_perms + ) + + await db.commit() + + logger.info(f"表单数据更新成功: form={self._form_code}, pk={pk}") + + return await self.get(db, pk) + + async def _handle_sub_table_update( + self, + db: AsyncSession, + sub_table: FormSubTable, + main_pk: Any, + new_data: List[Dict[str, Any]], + field_perms: Optional[Dict[str, Dict]] = None, + ): + """处理子表更新(差异对比:新增/更新/删除)""" + table_name = sub_table.table_name + schema = sub_table.table_schema or None + database = sub_table.table_database or None + foreign_key = sub_table.foreign_key + + # 获取现有数据 + existing = await self._query_sub_table_data(db, sub_table, main_pk) + existing_map = {item["id"]: item for item in existing if "id" in item} + + allowed_fields = self._get_allowed_fields("sub", table_name) + field_perms = field_perms or {} + writable_allow = {foreign_key} + + # 分类处理 + new_ids = set() + to_insert = [] + to_update = [] + + for item in new_data: + item_id = item.get("id") + if item_id and item_id in existing_map: + new_ids.add(item_id) + to_update.append(item) + elif not item_id: + to_insert.append(item) + else: + to_insert.append(item) + + # 找出需要删除的 + to_delete = [eid for eid in existing_map.keys() if eid not in new_ids] + + # 执行删除 + for del_id in to_delete: + sql, params = self.sql_builder.build_delete( + table=table_name, + pk_field="id", + pk_value=del_id, + schema=schema, + database=database + ) + await self._execute_command( + db, sql, params, database=self._table_exec_database(database) + ) + + # 执行更新 + for item in to_update: + filtered = self._filter_writable_fields(item, allowed_fields, field_perms) + filtered = self._convert_data_types(filtered) + item_id = filtered.pop("id", None) + + # 填充子表系统字段(更新) + filtered = self._fill_system_fields_for_update(filtered) + + # 填充子表非虚拟关联字段的值 + filtered = await self._fill_linked_field_values(db, filtered, table_type="sub") + + if filtered and item_id: + sql, params = self.sql_builder.build_update( + table=table_name, + data=filtered, + pk_field="id", + pk_value=item_id, + schema=schema, + database=database + ) + await self._execute_command( + db, sql, params, database=self._table_exec_database(database) + ) + + # 执行新增 + for item in to_insert: + filtered = self._filter_writable_fields( + item, allowed_fields, field_perms, always_allow=writable_allow + ) + filtered = self._convert_data_types(filtered) + if "id" in filtered: + del filtered["id"] + filtered["id"] = str(uuid.uuid4()) + if foreign_key in filtered: + del filtered[foreign_key] + filtered[foreign_key] = main_pk + + # 填充子表系统字段(新增) + filtered = self._fill_system_fields_for_create(filtered) + + # 填充子表非虚拟关联字段的值 + filtered = await self._fill_linked_field_values(db, filtered, table_type="sub") + + if filtered and len(filtered) > 2: + sql, params = self.sql_builder.build_insert( + table=table_name, + data=filtered, + schema=schema, + database=database, + return_id=False + ) + await self._execute_command( + db, sql, params, database=self._table_exec_database(database) + ) + + async def delete(self, db: AsyncSession, pk: Any) -> bool: + """删除数据(含子表,事务)""" + # 验证数据存在(不存在时 get 会抛出 RecordNotFoundException) + await self.get(db, pk) + + main_database = self.form_meta.main_table_database or None + async with self._business_transaction( + database=self._table_exec_database(main_database) + ): + for sub_table in self.sub_tables: + sub_db = sub_table.table_database or None + sql, params = self.sql_builder.build_delete_by_foreign_key( + table=sub_table.table_name, + fk_field=sub_table.foreign_key, + fk_value=pk, + schema=sub_table.table_schema or None, + database=sub_db, + ) + await self._execute_command( + db, + sql, + params, + database=self._table_exec_database(sub_db), + ) + + table = self.form_meta.main_table + schema = await self._resolve_schema(db, self.form_meta.main_table_schema) or None + database = self.form_meta.main_table_database or None + sql, params = self.sql_builder.build_delete( + table=table, + pk_field="id", + pk_value=pk, + schema=schema, + database=database, + ) + affected = await self._execute_command( + db, sql, params, database=self._table_exec_database(database) + ) + + await db.commit() + + logger.info(f"表单数据删除成功: form={self._form_code}, pk={pk}") + + return affected > 0 + + async def batch_delete(self, db: AsyncSession, pks: List[Any]) -> int: + """ + 批量删除(优化版:使用 IN 子句批量删除,单次事务提交) + + Args: + db: 数据库会话 + pks: 主键列表 + + Returns: + 成功删除的数量 + """ + if not pks: + return 0 + + async with self._business_transaction(): + affected = await self._batch_delete_in_transaction(db, pks) + + await db.commit() + logger.info(f"批量删除成功: form={self._form_code}, count={affected}") + return affected + + async def _batch_delete_in_transaction(self, db: AsyncSession, pks: List[Any]) -> int: + for sub_table in self.sub_tables: + schema = await self._resolve_schema(db, sub_table.table_schema) if sub_table.table_schema else None + database = sub_table.table_database or None + + # 构建批量删除 SQL: DELETE FROM table WHERE fk_field IN (:pk0, :pk1, ...) + full_table = self.sql_builder.build_table_name(sub_table.table_name, schema, database) + fk_field = self.sql_builder.quote_identifier(sub_table.foreign_key) + + placeholders = ", ".join(f":pk{i}" for i in range(len(pks))) + sql = f"DELETE FROM {full_table} WHERE {fk_field} IN ({placeholders})" + params = {f"pk{i}": pk for i, pk in enumerate(pks)} + + await self._execute_command( + db, sql, params, database=self._table_exec_database(database) + ) + + # 2. 批量删除主表数据(使用 IN 子句) + table = self.form_meta.main_table + schema = await self._resolve_schema(db, self.form_meta.main_table_schema) or None + database = self.form_meta.main_table_database or None + + full_table = self.sql_builder.build_table_name(table, schema, database) + id_field = self.sql_builder.quote_identifier("id") + + placeholders = ", ".join(f":pk{i}" for i in range(len(pks))) + sql = f"DELETE FROM {full_table} WHERE {id_field} IN ({placeholders})" + params = {f"pk{i}": pk for i, pk in enumerate(pks)} + + return await self._execute_command( + db, sql, params, database=self._table_exec_database(database) + ) + + # ============ 工具方法 ============ + + def _serialize_row(self, row: Dict[str, Any]) -> Dict[str, Any]: + """序列化行数据(处理特殊类型)""" + result = {} + for key, value in row.items(): + if isinstance(value, datetime): + result[key] = value.strftime("%Y-%m-%d %H:%M:%S") + elif isinstance(value, date): + result[key] = value.strftime("%Y-%m-%d") + elif isinstance(value, Decimal): + result[key] = float(value) + elif isinstance(value, uuid.UUID): + result[key] = str(value) + elif isinstance(value, bytes): + result[key] = value.decode("utf-8", errors="ignore") + elif isinstance(value, str): + result[key] = value.strip() + else: + result[key] = value + return result + + # ============ 导入导出 ============ + + async def export_to_excel_streaming( + self, + db: AsyncSession, + selected_fields: List[str] = None, + include_sub_tables: bool = False, + batch_size: int = 1000, + data_scope: Dict[str, Any] = None, + filters: Dict[str, Any] = None, + sort_list: List[Dict[str, str]] = None, + search: str = None, + search_fields: List[str] = None, + on_progress: Any = None + ) -> BytesIO: + """ + 流式导出数据到 Excel(分批查询,避免内存溢出) + + Args: + db: 数据库会话 + selected_fields: 选中的字段列表 + include_sub_tables: 是否导出子表 + batch_size: 每批查询的数据量 + data_scope: 数据权限过滤配置 + filters: 过滤条件(与列表查询一致) + sort_list: 排序列表 + search: 搜索关键词 + search_fields: 搜索字段列表 + on_progress: 进度回调函数 async fn(processed, total),可选 + + Returns: + Excel 文件的 BytesIO 对象 + """ + wb = Workbook() + ws = wb.active + ws.title = "主表数据" + + # 从列表配置中获取列定义 + list_config = self._list_config + columns = list_config.get("columns", []) + + # 如果指定了选择的字段,只导出这些字段 + if selected_fields and columns: + columns = [col for col in columns if col.get("field") in selected_fields] + # 确保 selected_fields 中的字段都被包含 + existing_fields = {col.get("field") for col in columns} + for field in selected_fields: + if field not in existing_fields: + columns.append({"field": field, "label": field.upper()}) + + if columns: + headers = [col.get("field") for col in columns] + column_labels = [col.get("label", col.get("field")) for col in columns] + display_fields = { + col.get("field"): col.get("displayField") + for col in columns + if col.get("displayField") + } + else: + # 如果没有列配置,先查询一条数据获取字段 + first_result = await self.list(db=db, page=1, page_size=1, data_scope=data_scope) + if first_result["items"]: + headers = list(first_result["items"][0].keys()) + else: + headers = [] + column_labels = headers + display_fields = {} + + # 从 relation_fields 补充缺少 displayField 的关联字段映射 + relation_fields = self._get_relation_fields() + for field_name, config in relation_fields.items(): + if field_name not in display_fields and config.get("display_field"): + display_fields[field_name] = config["display_field"] + + # 先查询总数(用于进度计算) + total_count = 0 + if on_progress: + count_result = await self.list( + db=db, page=1, page_size=1, data_scope=data_scope, + filters=filters, sort_list=sort_list, + search=search, search_fields=search_fields + ) + total_count = min(count_result.get("total", 0), MAX_IMPORT_EXPORT_ROWS) + await on_progress(0, total_count, "querying") + + # 定义样式 + header_font = Font(name='微软雅黑', size=11, bold=True, color='FFFFFF') + header_fill = PatternFill(start_color='4472C4', end_color='4472C4', fill_type='solid') + header_alignment = Alignment(horizontal='center', vertical='center') + data_alignment = Alignment(horizontal='left', vertical='center') + thin_border = Border( + left=Side(style='thin', color='D0D0D0'), + right=Side(style='thin', color='D0D0D0'), + top=Side(style='thin', color='D0D0D0'), + bottom=Side(style='thin', color='D0D0D0') + ) + + # 写入表头 + ws.append(column_labels) + for col_idx, cell in enumerate(ws[1], 1): + cell.font = header_font + cell.fill = header_fill + cell.alignment = header_alignment + cell.border = thin_border + column_letter = cell.column_letter + header_length = len(str(cell.value)) + ws.column_dimensions[column_letter].width = max(12, header_length + 4) + + # 子表 sheet 初始化(延迟到第一批有数据时创建表头) + sub_sheets: Dict[str, Any] = {} + sub_headers_map: Dict[str, List[str]] = {} + + # 分批查询并写入数据(主表 + 子表同步处理) + page = 1 + total_rows = 0 + + while True: + result = await self.list( + db=db, page=page, page_size=batch_size, data_scope=data_scope, + filters=filters, sort_list=sort_list, + search=search, search_fields=search_fields + ) + items = result["items"] + + if not items: + break + + if page == 1 and total_count == 0: + total_count = min(result.get("total", 0), MAX_IMPORT_EXPORT_ROWS) + + batch_ids = [] + for item in items: + row = [] + for header in headers: + display_field = display_fields.get(header) + if display_field and display_field in item: + value = item.get(display_field, "") + else: + value = item.get(header, "") + if isinstance(value, (list, dict)): + value = str(value) + row.append(value) + ws.append(row) + total_rows += 1 + if include_sub_tables and item.get("id"): + batch_ids.append(item.get("id")) + + # 当前批次的子表数据立即查询并写入 + if include_sub_tables and self.sub_tables and batch_ids: + for sub_table in self.sub_tables: + sub_key = sub_table.alias or sub_table.table_name + sql, params = self.sql_builder.build_select( + table=sub_table.table_name, + schema=sub_table.table_schema or None, + database=sub_table.table_database or None, + where={sub_table.foreign_key: {"type": "in", "value": batch_ids}} + ) + rows = await self._execute_query(db, sql, params) + if not rows: + continue + + sub_items = [self._serialize_row(r) for r in rows] + + if sub_key not in sub_sheets: + ws_sub = wb.create_sheet(title=sub_key[:31]) + sub_hdrs = ["主表ID"] + [k for k in sub_items[0].keys()] + ws_sub.append(sub_hdrs) + for col_idx, cell in enumerate(ws_sub[1], 1): + cell.font = header_font + cell.fill = header_fill + cell.alignment = header_alignment + cell.border = thin_border + ws_sub.column_dimensions[cell.column_letter].width = max(12, len(str(cell.value)) + 4) + sub_sheets[sub_key] = ws_sub + sub_headers_map[sub_key] = sub_hdrs + else: + ws_sub = sub_sheets[sub_key] + sub_hdrs = sub_headers_map[sub_key] + + for sub_item in sub_items: + row = [sub_item.get(sub_table.foreign_key, "")] + for h in sub_hdrs[1:]: + value = sub_item.get(h, "") + if isinstance(value, (list, dict)): + value = str(value) + row.append(value) + ws_sub.append(row) + + if on_progress: + await on_progress(total_rows, total_count, "querying") + + if len(items) < batch_size: + break + page += 1 + if total_rows >= MAX_IMPORT_EXPORT_ROWS: + logger.warning(f"导出数据量达到上限 {MAX_IMPORT_EXPORT_ROWS} 条(服务器内存 {SERVER_MEMORY_GB}GB),停止导出") + break + + if on_progress: + await on_progress(total_rows, total_count, "generating") + + ws.freeze_panes = 'A2' + for ws_sub in sub_sheets.values(): + ws_sub.freeze_panes = 'A2' + + # 保存到内存 + output = BytesIO() + wb.save(output) + output.seek(0) + + logger.info(f"流式导出完成: 共 {total_rows} 条数据") + return output + + async def export_to_excel(self, items: List[Dict[str, Any]], selected_fields: List[str] = None, sub_tables_data: Dict[str, List[Dict[str, Any]]] = None) -> BytesIO: + """导出数据到 Excel(带样式,支持字段选择和子表导出)""" + wb = Workbook() + ws = wb.active + ws.title = "主表数据" + + if not items: + output = BytesIO() + wb.save(output) + output.seek(0) + return output + + # 从列表配置中获取列定义 + list_config = self._list_config + columns = list_config.get("columns", []) + + # 如果没有列配置,使用第一行数据的所有字段 + if not columns: + headers = list(items[0].keys()) + column_labels = headers + display_fields = {} + else: + # 如果指定了选择的字段,只导出这些字段 + if selected_fields: + columns = [col for col in columns if col.get("field") in selected_fields] + + # 确保 selected_fields 中的字段都被包含(即使不在 columns 配置中) + existing_fields = {col.get("field") for col in columns} + for field in selected_fields: + if field not in existing_fields: + # 添加缺失的字段(如 id) + columns.append({"field": field, "label": field.upper()}) + + headers = [col.get("field") for col in columns] + column_labels = [col.get("label", col.get("field")) for col in columns] + # 构建字段到 displayField 的映射 + display_fields = { + col.get("field"): col.get("displayField") + for col in columns + if col.get("displayField") + } + + # 定义样式 + # 表头样式:深蓝色背景,白色粗体文字 + header_font = Font(name='微软雅黑', size=11, bold=True, color='FFFFFF') + header_fill = PatternFill(start_color='4472C4', end_color='4472C4', fill_type='solid') + header_alignment = Alignment(horizontal='center', vertical='center') + + # 数据单元格样式:居中对齐 + data_alignment = Alignment(horizontal='left', vertical='center') + + # 边框样式 + thin_border = Border( + left=Side(style='thin', color='D0D0D0'), + right=Side(style='thin', color='D0D0D0'), + top=Side(style='thin', color='D0D0D0'), + bottom=Side(style='thin', color='D0D0D0') + ) + + # 写入表头 + ws.append(column_labels) + + # 设置表头样式 + for col_idx, cell in enumerate(ws[1], 1): + cell.font = header_font + cell.fill = header_fill + cell.alignment = header_alignment + cell.border = thin_border + # 设置列宽(根据表头长度自动调整) + column_letter = cell.column_letter + header_length = len(str(cell.value)) + ws.column_dimensions[column_letter].width = max(12, header_length + 4) + + # 写入数据 + for item in items: + row = [] + for header in headers: + # 优先使用 displayField 的值(如果配置了) + display_field = display_fields.get(header) + if display_field and display_field in item: + value = item.get(display_field, "") + else: + value = item.get(header, "") + + # 处理特殊类型 + if isinstance(value, (list, dict)): + value = str(value) + row.append(value) + ws.append(row) + + # 设置数据行样式 + for row_idx in range(2, ws.max_row + 1): + for col_idx in range(1, ws.max_column + 1): + cell = ws.cell(row=row_idx, column=col_idx) + cell.alignment = data_alignment + cell.border = thin_border + # 交替行背景色(浅灰色) + if row_idx % 2 == 0: + cell.fill = PatternFill(start_color='F2F2F2', end_color='F2F2F2', fill_type='solid') + + # 冻结首行(表头) + ws.freeze_panes = 'A2' + + # 导出子表数据 + if sub_tables_data: + for sub_table_name, sub_data_list in sub_tables_data.items(): + if not sub_data_list: + continue + + # 为每个子表创建新的工作表 + ws_sub = wb.create_sheet(title=sub_table_name[:31]) # Excel 工作表名称最多 31 字符 + + # 子表表头(包含主表ID) + sub_headers = ["主表ID"] + [k for k in sub_data_list[0].keys() if k != "_main_id"] + ws_sub.append(sub_headers) + + # 设置子表表头样式 + for col_idx, cell in enumerate(ws_sub[1], 1): + cell.font = header_font + cell.fill = header_fill + cell.alignment = header_alignment + cell.border = thin_border + column_letter = cell.column_letter + ws_sub.column_dimensions[column_letter].width = max(12, len(str(cell.value)) + 4) + + # 写入子表数据 + for sub_item in sub_data_list: + row = [sub_item.get("_main_id", "")] + for header in sub_headers[1:]: + value = sub_item.get(header, "") + # 处理特殊类型 + if isinstance(value, (list, dict)): + value = str(value) + row.append(value) + ws_sub.append(row) + + # 设置子表数据行样式 + for row_idx in range(2, ws_sub.max_row + 1): + for col_idx in range(1, ws_sub.max_column + 1): + cell = ws_sub.cell(row=row_idx, column=col_idx) + cell.alignment = data_alignment + cell.border = thin_border + if row_idx % 2 == 0: + cell.fill = PatternFill(start_color='F2F2F2', end_color='F2F2F2', fill_type='solid') + + ws_sub.freeze_panes = 'A2' + + # 保存到内存 + output = BytesIO() + wb.save(output) + output.seek(0) + return output + + async def get_import_template(self) -> BytesIO: + """生成导入模板(带样式)""" + wb = Workbook() + ws = wb.active + ws.title = "导入模板" + + # 从列表配置中获取列定义 + list_config = self._list_config + columns = list_config.get("columns", []) + + # 定义样式 + header_font = Font(name='微软雅黑', size=11, bold=True, color='FFFFFF') + header_fill = PatternFill(start_color='4472C4', end_color='4472C4', fill_type='solid') + header_alignment = Alignment(horizontal='center', vertical='center') + thin_border = Border( + left=Side(style='thin', color='D0D0D0'), + right=Side(style='thin', color='D0D0D0'), + top=Side(style='thin', color='D0D0D0'), + bottom=Side(style='thin', color='D0D0D0') + ) + + if columns: + # 使用列配置生成表头(排除系统字段和关联字段的 displayField) + headers = [] + for col in columns: + field = col.get("field") + # 排除系统字段 + if field not in ["id", "created_at", "updated_at", "sys_create_datetime", "sys_update_datetime"]: + # 排除 displayField(如 dept_name),只保留原始字段(如 dept_id) + display_field = col.get("displayField") + if not display_field or field != display_field: + headers.append(col.get("label", field)) + + ws.append(headers) + + # 设置表头样式 + for col_idx, cell in enumerate(ws[1], 1): + cell.font = header_font + cell.fill = header_fill + cell.alignment = header_alignment + cell.border = thin_border + # 设置列宽 + column_letter = cell.column_letter + header_length = len(str(cell.value)) + ws.column_dimensions[column_letter].width = max(15, header_length + 4) + + # 添加示例数据行(带浅黄色背景提示这是示例) + example_row = ["示例数据,请删除此行后填写实际数据"] + [""] * (len(headers) - 1) + ws.append(example_row) + for col_idx in range(1, len(headers) + 1): + cell = ws.cell(row=2, column=col_idx) + cell.fill = PatternFill(start_color='FFF2CC', end_color='FFF2CC', fill_type='solid') + cell.alignment = Alignment(horizontal='left', vertical='center') + cell.border = thin_border + cell.font = Font(name='微软雅黑', size=10, italic=True, color='808080') + else: + # 如果没有列配置,添加提示 + ws.append(["请配置列表字段后重新下载模板"]) + ws['A1'].font = Font(name='微软雅黑', size=12, bold=True, color='FF0000') + ws.column_dimensions['A'].width = 40 + + # 冻结首行 + ws.freeze_panes = 'A2' + + # 保存到内存 + output = BytesIO() + wb.save(output) + output.seek(0) + return output + + async def import_from_excel( + self, + db: AsyncSession, + file_content: bytes, + batch_size: int = 1000, + mode: str = "append", + validate_only: bool = False, + data_handling: str = "insert_only", + match_field: str = None, + on_progress=None + ) -> tuple[int, int, List[Dict[str, Any]]]: + """ + 从 Excel 批量导入数据 + + Args: + db: 数据库会话 + file_content: Excel 文件内容 + batch_size: 批量插入的批次大小,默认 1000 + mode: 导入模式,"append"(追加)或 "overwrite"(覆盖,先清空表再导入) + validate_only: 是否仅验证数据,不执行实际导入 + data_handling: 数据处理方式 insert_only / update_only / upsert + match_field: 更新模式下用于匹配的字段名 + on_progress: 可选的异步回调 (processed, total, stage, success, fail) + + Returns: + (成功数量, 失败数量, 错误详情列表) + """ + from utils.context import get_current_user_info_from_context + from sqlalchemy import text as sa_text + + file_io = BytesIO(file_content) + del file_content + wb = load_workbook(file_io, read_only=True) + ws = wb.active + + # read_only 模式下先读表头 + row_iter = ws.iter_rows(values_only=True) + headers = list(next(row_iter, [])) + + # read_only 模式 ws.max_row 可能不准,用预估值(后续会动态修正) + total_excel_rows = (ws.max_row - 1) if ws.max_row and ws.max_row > 1 else 0 + + # 从列表配置中获取字段映射 + list_config = self._list_config + columns = list_config.get("columns", []) + label_to_field = {col.get("label"): col.get("field") for col in columns if col.get("label") and col.get("field")} + + # 获取允许的字段 + allowed_fields = self._get_allowed_fields("main") + + # 系统字段(需要排除) + system_fields = {"id", "created_at", "updated_at", "sys_create_datetime", "sys_update_datetime", + "sys_creator_id", "sys_modifier_id", "sys_dept_id", "is_deleted", "sort"} + + success_count = 0 + fail_count = 0 + error_details = [] + + # 获取用户信息(用于填充系统字段) + user_info = get_current_user_info_from_context() + + if on_progress: + await on_progress(0, total_excel_rows, "parsing", 0, 0) + + # 根据预估总行数动态计算进度推送间隔(约推送 50~100 次,最少 50 行,最多 5000 行) + progress_interval = max(50, min(5000, total_excel_rows // 100)) if total_excel_rows > 0 else 100 + + # 流式逐行解析(read_only 模式下 iter_rows 是惰性的) + all_rows = [] + parsed_count = 0 + for row in row_iter: + row_num = parsed_count + 2 + try: + # 跳过空行 + if all(cell is None or (isinstance(cell, str) and cell.strip() == "") for cell in row): + parsed_count += 1 + continue + + # 构建数据字典 + data = {} + for idx, value in enumerate(row): + if idx < len(headers) and headers[idx]: + field = label_to_field.get(headers[idx], headers[idx]) + if field and field not in system_fields: + if field in allowed_fields: + data[field] = value + + if data: + data = self._convert_data_types(data) + data = self._filter_fields(data, allowed_fields) + + is_update_mode = mode == "append" and data_handling in ("update_only", "upsert") + if not is_update_mode: + data["id"] = str(uuid.uuid4()) + data = self._fill_system_fields_for_create(data) + + data = self._normalize_data_for_insert(data) + + validation_error = self._validate_data_against_schema(row_num, data) + if validation_error: + fail_count += 1 + error_details.append({"row": row_num, "error": validation_error}) + parsed_count += 1 + continue + + all_rows.append((row_num, data)) + if len(all_rows) >= MAX_IMPORT_EXPORT_ROWS: + logger.warning(f"导入数据量达到上限 {MAX_IMPORT_EXPORT_ROWS} 条(服务器内存 {SERVER_MEMORY_GB}GB),截断后续数据") + parsed_count += 1 + break + except Exception as e: + logger.error(f"解析第 {row_num} 行数据失败: {e}") + fail_count += 1 + error_details.append({"row": row_num, "error": str(e)}) + + parsed_count += 1 + if on_progress and parsed_count % progress_interval == 0: + if parsed_count > total_excel_rows: + total_excel_rows = parsed_count + progress_interval = max(50, min(5000, total_excel_rows // 100)) + await on_progress(parsed_count, total_excel_rows, "parsing", 0, fail_count) + await asyncio.sleep(0) + + if parsed_count != total_excel_rows: + total_excel_rows = parsed_count + if on_progress: + await on_progress(parsed_count, total_excel_rows, "parsing", 0, fail_count) + + wb.close() + file_io.close() + del wb, ws, row_iter, file_io + + if not all_rows: + return success_count, fail_count, error_details + + # 预先解析 schema(只解析一次) + table = self.form_meta.main_table + schema = await self._resolve_schema(db, self.form_meta.main_table_schema) or None + database = self.form_meta.main_table_database or None + + # **按导入模式执行不同的验证规则** + if on_progress: + await on_progress(0, len(all_rows), "validating", 0, fail_count) + + is_update_mode = mode == "append" and data_handling in ("update_only", "upsert") + unique_errors: Dict[int, str] = {} + existing_set: Set[str] = set() + + if mode == "overwrite": + unique_errors = await self._batch_check_unique_fields_internal_only(all_rows) + + elif data_handling == "insert_only": + unique_errors = await self._batch_check_unique_fields(db, all_rows) + + elif data_handling in ("update_only", "upsert") and match_field: + match_label = self._get_field_labels([match_field]).get(match_field, match_field) + + for row_num, data in all_rows: + val = data.get(match_field) + if val is None or val == '': + unique_errors[row_num] = f"匹配字段 '{match_label}' 不能为空" + + match_val_to_rows: Dict[str, list] = {} + for row_num, data in all_rows: + val = data.get(match_field) + if val is not None and val != '': + match_val_to_rows.setdefault(str(val), []).append(row_num) + for val, rows in match_val_to_rows.items(): + if len(rows) > 1: + for r in rows[1:]: + if r not in unique_errors: + unique_errors[r] = f"匹配字段 '{match_label}' 值 '{val}' 在导入数据中重复(第 {rows[0]} 行已存在)" + + if on_progress: + await on_progress(len(all_rows) // 3, len(all_rows), "validating", 0, fail_count) + + match_values = [str(data.get(match_field)) for _, data in all_rows + if data.get(match_field) not in (None, '')] + existing_set = await self._batch_query_existing(db, match_field, match_values) + + if on_progress: + await on_progress(len(all_rows) * 2 // 3, len(all_rows), "validating", 0, fail_count) + + if data_handling == "update_only": + for row_num, data in all_rows: + val = data.get(match_field) + if val is not None and val != '' and str(val) not in existing_set and row_num not in unique_errors: + unique_errors[row_num] = f"未找到匹配记录({match_label}={val})" + else: + new_rows = [(rn, d) for rn, d in all_rows + if d.get(match_field) is not None and d.get(match_field) != '' + and str(d.get(match_field)) not in existing_set + and rn not in unique_errors] + if new_rows: + insert_unique_errors = await self._batch_check_unique_fields(db, new_rows) + unique_errors.update(insert_unique_errors) + + if on_progress: + await on_progress(len(all_rows), len(all_rows), "validating", 0, fail_count) + + if unique_errors: + filtered_rows = [] + for row_num, data in all_rows: + if row_num in unique_errors: + fail_count += 1 + error_details.append({"row": row_num, "error": unique_errors[row_num]}) + else: + filtered_rows.append((row_num, data)) + all_rows = filtered_rows + + if not all_rows: + logger.info(f"Excel 导入完成: 成功 {success_count} 条, 失败 {fail_count} 条(所有数据验证失败)") + return success_count, fail_count, error_details[:100] + + # **仅验证模式**:返回验证结果,不执行实际导入 + if validate_only: + success_count = len(all_rows) + # 计算各模式下的操作预估 + validate_meta: Dict[str, Any] = {"_meta": True} + if mode == "overwrite": + validate_meta["will_insert"] = success_count + validate_meta["will_update"] = 0 + validate_meta["action"] = "overwrite" + elif data_handling == "insert_only": + validate_meta["will_insert"] = success_count + validate_meta["will_update"] = 0 + validate_meta["action"] = "insert_only" + elif data_handling == "update_only": + validate_meta["will_insert"] = 0 + validate_meta["will_update"] = success_count + validate_meta["action"] = "update_only" + elif data_handling == "upsert" and match_field: + will_update = sum( + 1 for _, d in all_rows + if d.get(match_field) is not None and d.get(match_field) != '' + and str(d.get(match_field)) in existing_set + ) + will_insert = success_count - will_update + validate_meta["will_insert"] = will_insert + validate_meta["will_update"] = will_update + validate_meta["action"] = "upsert" + else: + validate_meta["will_insert"] = success_count + validate_meta["will_update"] = 0 + validate_meta["action"] = data_handling + + truncated_errors = error_details[:100] + truncated_errors.append(validate_meta) + logger.info(f"Excel 数据验证完成: 通过 {success_count} 条, 失败 {fail_count} 条, " + f"预计新增 {validate_meta.get('will_insert', 0)} 条, " + f"预计更新 {validate_meta.get('will_update', 0)} 条") + return success_count, fail_count, truncated_errors + + main_exec_db = self._table_exec_database(database) + total_valid = len(all_rows) + imported_count = 0 + + if all_rows: + num_fields = len(all_rows[0][1]) + if num_fields > 0: + max_safe_batch = 32000 // num_fields + batch_size = max(1, min(batch_size, max_safe_batch)) + + if on_progress: + await on_progress(0, total_valid, "importing", success_count, fail_count) + + if is_update_mode and match_field: + async with self._business_transaction(database=main_exec_db): + success_count, fail_count, error_details = await self._import_with_update( + db, + table, + schema, + database, + all_rows, + match_field, + data_handling, + success_count, + fail_count, + error_details, + on_progress=on_progress, + ) + if not self.db_adapter.is_external: + await db.commit() + return success_count, fail_count, error_details[:100] + + async def _import_append_batch(batch_rows: List[tuple]) -> None: + nonlocal success_count, fail_count, imported_count + batch_data_list = [item[1] for item in batch_rows] + batch_row_nums = [item[0] for item in batch_rows] + sp_batch = f"sp_batch_{uuid.uuid4().hex[:8]}" + try: + await self._create_savepoint(db, sp_batch) + await self._batch_insert_raw( + db, table, schema, database, batch_data_list + ) + await self._release_savepoint(db, sp_batch) + success_count += len(batch_data_list) + except Exception as batch_err: + logger.warning( + f"批量插入失败(第 {batch_row_nums[0]}-{batch_row_nums[-1]} 行)," + f"降级逐条插入: {batch_err}" + ) + await self._rollback_to_savepoint(db, sp_batch) + for row_num, data in batch_rows: + sp_single = f"sp_{uuid.uuid4().hex[:8]}" + try: + await self._create_savepoint(db, sp_single) + sql, params = self.sql_builder.build_insert( + table=table, + data=data, + schema=schema, + database=database, + return_id=False, + ) + await self._execute_command( + db, + sql, + params, + enrich_fk=False, + database=main_exec_db, + ) + await self._release_savepoint(db, sp_single) + success_count += 1 + except Exception as single_err: + await self._rollback_to_savepoint(db, sp_single) + fail_count += 1 + error_details.append({ + "row": row_num, + "error": format_error_message(single_err), + }) + imported_count += len(batch_rows) + + if self.db_adapter.is_external: + # 第三方库:每批独立 Handler 事务提交,避免整表导入失败时全部回滚 + if mode == "overwrite": + async with self._business_transaction(database=main_exec_db): + await self._truncate_table(db, table, schema, database) + logger.info(f"覆盖模式:已清空表 {table}") + for batch_start in range(0, len(all_rows), batch_size): + batch = all_rows[batch_start:batch_start + batch_size] + async with self._business_transaction(database=main_exec_db): + await _import_append_batch(batch) + db.expire_all() + if on_progress: + await on_progress( + imported_count, + total_valid, + "importing", + success_count, + fail_count, + ) + else: + async with self._business_transaction(database=main_exec_db): + if mode == "overwrite": + await self._truncate_table(db, table, schema, database) + logger.info(f"覆盖模式:已清空表 {table}") + for batch_start in range(0, len(all_rows), batch_size): + batch = all_rows[batch_start:batch_start + batch_size] + await _import_append_batch(batch) + await db.commit() + db.expire_all() + if on_progress: + await on_progress( + imported_count, + total_valid, + "importing", + success_count, + fail_count, + ) + + logger.info(f"Excel 导入完成: 成功 {success_count} 条, 失败 {fail_count} 条") + return success_count, fail_count, error_details[:100] + + async def _import_with_update( + self, + db: AsyncSession, + table: str, + schema: Optional[str], + database: Optional[str], + all_rows: List[tuple], + match_field: str, + data_handling: str, + success_count: int, + fail_count: int, + error_details: List[Dict[str, Any]], + on_progress=None + ) -> tuple[int, int, List[Dict[str, Any]]]: + """ + 导入数据(更新/upsert 模式) + + 通过 match_field 匹配已有记录: + - update_only: 匹配到则更新,未匹配到则跳过 + - upsert: 匹配到则更新,未匹配到则新增 + """ + full_table = self.sql_builder.build_table_name(table, schema, database) + quoted_match = self.sql_builder.quote_identifier(match_field) + total_valid = len(all_rows) + + # 1. 收集所有行的匹配字段值 + match_values = [] + for row_num, data in all_rows: + val = data.get(match_field) + if val is not None: + match_values.append(val) + + # 2. 批量查询数据库中已存在的记录(match_field -> id 的映射) + existing_map = {} + if match_values: + for i in range(0, len(match_values), 500): + batch_vals = match_values[i:i + 500] + placeholders = ", ".join([f":mv_{j}" for j in range(len(batch_vals))]) + quoted_id = self.sql_builder.quote_identifier("id") + query_sql = ( + f"SELECT {quoted_id}, {quoted_match} FROM {full_table} " + f"WHERE {quoted_match} IN ({placeholders})" + ) + params = {f"mv_{j}": v for j, v in enumerate(batch_vals)} + try: + result = await self._execute_query( + db, + query_sql, + params, + database=self._table_exec_database(database), + ) + for row in result: + existing_map[row[match_field]] = row["id"] + except Exception as e: + logger.error(f"查询已有数据失败: {e}") + + # 3. 分批处理每行数据 + imported_count = 0 + commit_batch_size = 500 + main_exec_db = self._table_exec_database(database) + import_progress_interval = max(50, min(5000, total_valid // 100)) if total_valid > 0 else 100 + for row_num, data in all_rows: + match_val = data.get(match_field) + existing_id = existing_map.get(match_val) if match_val is not None else None + + sp = f"sp_{uuid.uuid4().hex[:8]}" + try: + await self._create_savepoint(db, sp) + + if existing_id: + update_data = {k: v for k, v in data.items() if k not in ("id", "sys_create_datetime", "sys_creator_id", "sys_dept_id")} + update_data = self._fill_system_fields_for_update(update_data) + + sql, params = self.sql_builder.build_update( + table=table, data=update_data, pk_field="id", pk_value=existing_id, + schema=schema, database=database + ) + await self._execute_command( + db, + sql, + params, + enrich_fk=False, + database=main_exec_db, + ) + await self._release_savepoint(db, sp) + success_count += 1 + elif data_handling == "upsert": + data["id"] = str(uuid.uuid4()) + data = self._fill_system_fields_for_create(data) + sql, params = self.sql_builder.build_insert( + table=table, data=data, schema=schema, + database=database, return_id=False + ) + await self._execute_command( + db, + sql, + params, + enrich_fk=False, + database=main_exec_db, + ) + await self._release_savepoint(db, sp) + success_count += 1 + else: + await self._release_savepoint(db, sp) + fail_count += 1 + error_details.append({"row": row_num, "error": f"未找到匹配记录({match_field}={match_val})"}) + except Exception as e: + await self._rollback_to_savepoint(db, sp) + fail_count += 1 + error_details.append({ + "row": row_num, + "error": format_error_message(e), + }) + + imported_count += 1 + if ( + self.db_adapter.is_external + and imported_count % commit_batch_size == 0 + ): + # 第三方在父级 _business_transaction 内由调用方整段提交;此处仅刷新 ORM + db.expire_all() + elif imported_count % commit_batch_size == 0 and not self.db_adapter.is_external: + await db.commit() + db.expire_all() + if on_progress and imported_count % import_progress_interval == 0: + await on_progress(imported_count, total_valid, "importing", success_count, fail_count) + + if not self.db_adapter.is_external: + await db.commit() + db.expire_all() + if on_progress: + await on_progress(imported_count, total_valid, "importing", success_count, fail_count) + logger.info(f"Excel 导入完成({data_handling}): 成功 {success_count} 条, 失败 {fail_count} 条") + return success_count, fail_count, error_details + + async def _truncate_table( + self, db: AsyncSession, table: str, schema: Optional[str], database: Optional[str] + ) -> None: + """ + 清空表数据(用于覆盖模式) + + Args: + db: 数据库会话 + table: 表名 + schema: Schema 名 + database: 数据库名 + """ + full_table = self.sql_builder.build_table_name(table, schema, database) + + for sub_table in self.sub_tables: + sub_schema = ( + await self._resolve_schema(db, sub_table.table_schema) + if sub_table.table_schema + else None + ) + sub_database = sub_table.table_database or None + sub_full_table = self.sql_builder.build_table_name( + sub_table.table_name, sub_schema, sub_database + ) + await self._execute_command( + db, + f"DELETE FROM {sub_full_table}", + database=self._table_exec_database(sub_database), + ) + + await self._execute_command( + db, + f"DELETE FROM {full_table}", + database=self._table_exec_database(database), + ) + + async def _batch_check_unique_fields_internal_only( + self, data_list: List[tuple[int, Dict[str, Any]]] + ) -> Dict[int, str]: + """ + 仅检查导入数据内部重复(用于覆盖模式,跳过数据库检查) + + Args: + data_list: [(row_num, data), ...] 数据列表 + + Returns: + {row_num: error_message} 违反唯一性约束的行号和错误信息 + """ + unique_fields = self._get_unique_check_fields() + if not unique_fields: + return {} + + errors = {} + + for field_name in unique_fields: + # 收集该字段的所有非空值及其行号 + value_to_rows = {} # {value: [row_num1, row_num2, ...]} + for row_num, data in data_list: + value = data.get(field_name) + if value is None or value == '': + continue + value_str = str(value) + if value_str not in value_to_rows: + value_to_rows[value_str] = [] + value_to_rows[value_str].append(row_num) + + # 检查内部重复 + for value, row_nums in value_to_rows.items(): + if len(row_nums) > 1: + field_label = self._get_field_labels([field_name]).get(field_name, field_name) + for row_num in row_nums[1:]: + errors[row_num] = f"字段 '{field_label}' 值 '{value}' 在导入数据中重复(第 {row_nums[0]} 行已存在)" + + return errors + + async def _batch_check_unique_fields( + self, db: AsyncSession, data_list: List[tuple[int, Dict[str, Any]]] + ) -> Dict[int, str]: + """ + 批量检查唯一性约束(高性能版) + + Args: + db: 数据库会话 + data_list: [(row_num, data), ...] 数据列表 + + Returns: + {row_num: error_message} 违反唯一性约束的行号和错误信息 + """ + unique_fields = self._get_unique_check_fields() + if not unique_fields: + return {} + + errors = {} + table = self.form_meta.main_table + schema = await self._resolve_schema(db, self.form_meta.main_table_schema) or None + database = self.form_meta.main_table_database or None + table_name = self.sql_builder.build_table_name(table, schema, database) + + for field_name in unique_fields: + # 收集该字段的所有非空值及其行号 + value_to_rows = {} # {value: [row_num1, row_num2, ...]} + for row_num, data in data_list: + value = data.get(field_name) + if value is None or value == '': + continue + value_str = str(value) + if value_str not in value_to_rows: + value_to_rows[value_str] = [] + value_to_rows[value_str].append(row_num) + + if not value_to_rows: + continue + + # 1. 检查导入数据内部重复 + for value, row_nums in value_to_rows.items(): + if len(row_nums) > 1: + # 内部重复:除了第一行,其他行都标记为错误 + field_label = self._get_field_labels([field_name]).get(field_name, field_name) + for row_num in row_nums[1:]: + errors[row_num] = f"字段 '{field_label}' 值 '{value}' 在导入数据中重复(第 {row_nums[0]} 行已存在)" + + # 2. 分批检查数据库中是否已存在(避免超出 32767 参数限制) + values = list(value_to_rows.keys()) + if not values: + continue + + quoted_field = self.sql_builder.quote_identifier(field_name) + has_is_deleted = await self._check_column_exists(db, table, schema, database, "is_deleted") + existing_values: set = set() + + for batch_start in range(0, len(values), 500): + batch_vals = values[batch_start:batch_start + 500] + placeholders = ", ".join(f":v{i}" for i in range(len(batch_vals))) + sql = f"SELECT {quoted_field} FROM {table_name} WHERE {quoted_field} IN ({placeholders})" + params = {f"v{i}": v for i, v in enumerate(batch_vals)} + + if has_is_deleted: + sql += f" AND {self.sql_builder.is_deleted_predicate()}" + + result = await self._execute_query(db, sql, params) + existing_values.update(str(row[field_name]) for row in result) + + # 标记数据库中已存在的值 + field_label = self._get_field_labels([field_name]).get(field_name, field_name) + for value in existing_values: + for row_num in value_to_rows[value]: + if row_num not in errors: # 避免覆盖内部重复错误 + errors[row_num] = f"字段 '{field_label}' 值 '{value}' 已存在于数据库中" + + return errors + + async def _batch_query_existing( + self, db: AsyncSession, field: str, values: List[str] + ) -> Set[str]: + """ + 批量查询数据库中某字段已存在的值集合 + + Args: + db: 数据库会话 + field: 要查询的字段名 + values: 要检查的值列表 + + Returns: + 数据库中已存在的值集合(字符串形式) + """ + if not values: + return set() + + table = self.form_meta.main_table + schema = await self._resolve_schema(db, self.form_meta.main_table_schema) or None + database = self.form_meta.main_table_database or None + table_name = self.sql_builder.build_table_name(table, schema, database) + quoted_field = self.sql_builder.quote_identifier(field) + + existing: Set[str] = set() + unique_values = list(dict.fromkeys(values)) + + for i in range(0, len(unique_values), 500): + batch_vals = unique_values[i:i + 500] + placeholders = ", ".join(f":v{j}" for j in range(len(batch_vals))) + sql = f"SELECT {quoted_field} FROM {table_name} WHERE {quoted_field} IN ({placeholders})" + params = {f"v{j}": v for j, v in enumerate(batch_vals)} + + if await self._check_column_exists(db, table, schema, database, "is_deleted"): + sql += f" AND {self.sql_builder.is_deleted_predicate()}" + + result = await self._execute_query(db, sql, params) + for row in result: + existing.add(str(row[field] if field in row else list(row.values())[0])) + + return existing + + def _validate_data_against_schema(self, row_num: int, data: Dict[str, Any]) -> Optional[str]: + """ + 根据数据库表结构验证数据 + + Args: + row_num: 行号 + data: 数据字典 + + Returns: + 错误信息,如果验证通过则返回 None + """ + form_config = self._form_config + table_configs = form_config.get('tableConfigs', []) + + # 找到主表配置 + main_table_config = None + for tc in table_configs: + if tc.get('type') == 'main': + main_table_config = tc + break + + if not main_table_config: + return None + + fields = main_table_config.get('fields', []) + field_map = {f.get('name'): f for f in fields if f.get('name')} + + # 验证每个字段 + for field_name, value in data.items(): + # 跳过系统字段 + if field_name in {'id', 'sys_create_datetime', 'sys_update_datetime', + 'sys_creator_id', 'sys_modifier_id', 'sys_dept_id', + 'is_deleted', 'sort'}: + continue + + field_config = field_map.get(field_name) + if not field_config: + continue + + # 1. NOT NULL 约束检查 + nullable = field_config.get('nullable', True) + if not nullable and (value is None or (isinstance(value, str) and value.strip() == '')): + return f"字段 '{field_name}' 不能为空" + + # 跳过 NULL 值的其他检查 + if value is None: + continue + + # 2. 字符串长度检查 + field_type = field_config.get('type', '').lower() + max_length = field_config.get('maxLength') + + if max_length and isinstance(value, str): + if 'varchar' in field_type or 'char' in field_type or 'text' in field_type: + if len(value) > max_length: + return f"字段 '{field_name}' 长度超限(最大 {max_length},实际 {len(value)})" + + # 3. 数值类型检查 + if 'int' in field_type or 'integer' in field_type: + if not isinstance(value, (int, float)): + try: + int(value) + except (ValueError, TypeError): + return f"字段 '{field_name}' 必须是整数" + + elif 'decimal' in field_type or 'numeric' in field_type or 'float' in field_type or 'double' in field_type: + if not isinstance(value, (int, float)): + try: + float(value) + except (ValueError, TypeError): + return f"字段 '{field_name}' 必须是数值" + + # 精度检查 + precision = field_config.get('precision') + scale = field_config.get('scale') + if precision and isinstance(value, (int, float)): + value_str = str(value) + if '.' in value_str: + int_part, dec_part = value_str.split('.') + if len(int_part) + len(dec_part) > precision: + return f"字段 '{field_name}' 精度超限(最大 {precision})" + if scale and len(dec_part) > scale: + return f"字段 '{field_name}' 小数位数超限(最大 {scale})" + + # 4. 日期时间类型检查 + elif 'date' in field_type or 'time' in field_type: + if not isinstance(value, (date, datetime)): + return f"字段 '{field_name}' 必须是日期时间类型" + + # 5. 布尔类型检查 + elif 'bool' in field_type: + if not isinstance(value, bool): + return f"字段 '{field_name}' 必须是布尔类型" + + return None + + async def _batch_insert_raw( + self, db: AsyncSession, table: str, schema: Optional[str], + database: Optional[str], data_list: List[Dict[str, Any]] + ) -> None: + """ + 真正的批量 INSERT:一条 SQL 插入多行数据 + + Args: + db: 数据库会话 + table: 表名 + schema: Schema 名 + database: 数据库名 + data_list: 数据列表(已标准化) + """ + if not data_list: + return + + columns = list(data_list[0].keys()) + cols = ", ".join(self.sql_builder.quote_identifier(c) for c in columns) + full_table = self.sql_builder.build_table_name(table, schema, database) + + # 构建多行 VALUES,使用命名参数 + 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] = data.get(col) + row_placeholders.append(f"({', '.join(placeholders)})") + + sql = f"INSERT INTO {full_table} ({cols}) VALUES {', '.join(row_placeholders)}" + await self._execute_command( + db, sql, params, database=self._table_exec_database(database) + ) + + def _normalize_data_for_insert(self, data: Dict[str, Any]) -> Dict[str, Any]: + """ + 标准化数据类型,确保所有值都是数据库可接受的类型 + + Args: + data: 原始数据字典 + + Returns: + 标准化后的数据字典 + """ + normalized = {} + for key, value in data.items(): + if value is None: + normalized[key] = None + elif isinstance(value, datetime): + # datetime 对象保持不变,数据库驱动会正确处理 + normalized[key] = value + elif isinstance(value, date): + # date 对象保持不变 + normalized[key] = value + elif isinstance(value, (int, float, bool)): + # 基本类型保持不变 + normalized[key] = value + elif isinstance(value, (list, dict)): + # 列表和字典转换为 JSON 字符串 + normalized[key] = json.dumps(value, ensure_ascii=False) + elif isinstance(value, str): + # 字符串保持不变 + normalized[key] = value + else: + # 其他类型转换为字符串 + normalized[key] = str(value) + return normalized + + # ============ 字段权限过滤 ============ + + async def _get_merged_field_permissions( + self, + db: AsyncSession, + role_ids: List[str] = None + ) -> Dict[str, Dict]: + """获取当前用户合并后的字段权限配置""" + from utils.context import get_current_user_info_from_context + from core.resource_scope.field_permission.service import ResourceFieldPermissionService + + if not role_ids: + user_info = get_current_user_info_from_context() + logger.debug(f"[字段权限] 从上下文获取用户信息: {user_info}") + if not user_info or not user_info.get('role_ids'): + logger.warning("[字段权限] 未获取到用户角色信息,跳过字段权限过滤") + return {} + role_ids = user_info['role_ids'] + + resource_type = f"form:{self._form_code}" + logger.debug(f"[字段权限] 资源类型: {resource_type}, 角色IDs: {role_ids}") + + configs = await ResourceFieldPermissionService.get_by_roles_and_resource( + db, role_ids, resource_type + ) + logger.debug(f"[字段权限] 获取到的配置数量: {len(configs) if configs else 0}") + + if not configs: + logger.debug("[字段权限] 未找到字段权限配置,跳过过滤") + return {} + + merged_perms = await ResourceFieldPermissionService.merge_field_permissions( + configs, "most_permissive" + ) + logger.debug(f"[字段权限] 合并后的权限: {merged_perms}") + return merged_perms or {} + + async def apply_field_permissions( + self, + data: Any, + db: AsyncSession, + role_ids: List[str] = None + ) -> Any: + """ + 应用字段权限过滤 + + Args: + data: 数据(单个对象或列表) + db: 数据库会话 + role_ids: 角色ID列表,如果不传则从上下文获取 + + Returns: + 过滤后的数据 + """ + merged_perms = await self._get_merged_field_permissions(db, role_ids) + if not merged_perms: + return data + + if isinstance(data, list): + return [self._apply_field_permissions(item, merged_perms) for item in data] + return self._apply_field_permissions(data, merged_perms) + + @staticmethod + def _get_field_permission_type(field_name: str, field_perms: Dict[str, Dict]) -> str: + """获取字段权限类型,未配置时默认可写""" + if not field_perms: + return 'write' + perm = field_perms.get(field_name, {}) + return perm.get('permission_type') or perm.get('permission', 'write') + + def _is_field_writable(self, field_name: str, field_perms: Dict[str, Dict]) -> bool: + """判断字段是否可写""" + return self._get_field_permission_type(field_name, field_perms) == 'write' + + def _filter_writable_fields( + self, + data: Dict[str, Any], + allowed_fields: Set[str], + field_perms: Dict[str, Dict], + always_allow: Optional[Set[str]] = None, + ) -> Dict[str, Any]: + """过滤字段白名单,并剔除只读/隐藏/脱敏字段的写入""" + filtered = self._filter_fields(data, allowed_fields) + if not field_perms: + return filtered + + allow = always_allow or set() + return { + key: value + for key, value in filtered.items() + if key in allow or self._is_field_writable(key, field_perms) + } + + def _apply_field_permissions(self, item: Dict[str, Any], field_perms: Dict[str, Dict]) -> Dict[str, Any]: + """ + 应用字段权限过滤(隐藏、脱敏) + + Args: + item: 数据项(字典) + field_perms: 字段权限配置 + + Returns: + 过滤后的字典 + """ + if not isinstance(item, dict): + return item + + # 收集需要隐藏或脱敏的字段及其关联的 _name 字段 + hidden_fields = set() + masked_fields = {} # field_name -> mask_rule + + for field_name, perm in field_perms.items(): + permission_type = perm.get('permission_type') or perm.get('permission', 'write') + if permission_type == 'hidden': + hidden_fields.add(field_name) + # 同时隐藏关联的 _name 字段 + # 支持多种命名模式: + # - field -> field_name (如 居住地 -> 居住地_name, post_id -> post_id_name) + # - field_id -> field_name (如 manger_id -> manger_name) + hidden_fields.add(f"{field_name}_name") + if field_name.endswith('_id'): + base_name = field_name[:-3] # 去掉 _id + hidden_fields.add(f"{base_name}_name") + elif permission_type == 'masked': + masked_fields[field_name] = perm.get('mask_rule') + # 同时脱敏关联的 _name 字段(使用默认脱敏规则) + masked_fields[f"{field_name}_name"] = 'default' + if field_name.endswith('_id'): + base_name = field_name[:-3] + masked_fields[f"{base_name}_name"] = 'name' + + filtered = {} + for field_name, value in item.items(): + # 递归处理内嵌子表数据 + if field_name == "sub_tables" and isinstance(value, dict): + filtered["sub_tables"] = { + table_name: [ + self._apply_field_permissions(row, field_perms) + if isinstance(row, dict) else row + for row in rows + ] if isinstance(rows, list) else rows + for table_name, rows in value.items() + } + continue + + # 检查是否需要隐藏 + if field_name in hidden_fields: + continue + + # 检查是否需要脱敏 + if field_name in masked_fields: + filtered[field_name] = self._mask_value(value, masked_fields[field_name]) + continue + + # 检查原始字段权限配置 + perm = field_perms.get(field_name, {}) + permission_type = perm.get('permission_type') or perm.get('permission', 'write') + + if permission_type == 'hidden': + continue + elif permission_type == 'masked': + filtered[field_name] = self._mask_value(value, perm.get('mask_rule')) + else: + filtered[field_name] = value + + return filtered + + def _mask_value(self, value: Any, mask_rule: Optional[str]) -> str: + """ + 脱敏处理 + + Args: + value: 原始值 + mask_rule: 脱敏规则 + + Returns: + 脱敏后的值 + """ + if not value: + return value + + value_str = str(value) + + if mask_rule == "phone": + # 手机号脱敏:138****5678 + if len(value_str) == 11: + return f"{value_str[:3]}****{value_str[-4:]}" + elif mask_rule == "email": + # 邮箱脱敏:abc***@example.com + if "@" in value_str: + local, domain = value_str.split("@", 1) + if len(local) > 3: + return f"{local[:3]}***@{domain}" + return f"{local[0]}***@{domain}" + elif mask_rule == "id_card": + # 身份证脱敏:110***********1234 + if len(value_str) >= 8: + return f"{value_str[:3]}***********{value_str[-4:]}" + elif mask_rule == "name": + # 姓名脱敏:张* + if len(value_str) > 1: + return f"{value_str[0]}*" + return "*" + + # 默认脱敏:显示前后各2个字符 + if len(value_str) > 4: + return f"{value_str[:2]}***{value_str[-2:]}" + return "***" diff --git a/backend-fastapi/online_dev/form_manager/__init__.py b/backend-fastapi/online_dev/form_manager/__init__.py new file mode 100644 index 0000000..db078f7 --- /dev/null +++ b/backend-fastapi/online_dev/form_manager/__init__.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +表单管理模块 +""" diff --git a/backend-fastapi/online_dev/form_manager/model.py b/backend-fastapi/online_dev/form_manager/model.py new file mode 100644 index 0000000..7b17710 --- /dev/null +++ b/backend-fastapi/online_dev/form_manager/model.py @@ -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") diff --git a/backend-fastapi/online_dev/form_manager/service.py b/backend-fastapi/online_dev/form_manager/service.py new file mode 100644 index 0000000..9be8844 --- /dev/null +++ b/backend-fastapi/online_dev/form_manager/service.py @@ -0,0 +1,1627 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +表单元数据管理服务(异步版本) + +数据权限: +- 使用 list_with_data_scope() 自动应用数据权限 +- 支持本人、本部门、本部门及下级、全部等数据范围 +""" +import logging +from typing import Any, Dict, List, Optional, Tuple + +from sqlalchemy import select, update, delete, func, and_, or_ +from sqlalchemy.ext.asyncio import AsyncSession + +from core.application.model import Application +from online_dev.form_manager.model import FormMeta, FormSubTable +from app.data_scope_utils import get_data_scope_filter, apply_data_scope_to_conditions + +logger = logging.getLogger(__name__) + +# 资源类型(用于数据权限配置) +RESOURCE_TYPE = "form" +RESOURCE_DISPLAY_NAME = "表单管理" + + +class FormServiceException(Exception): + """表单服务异常""" + pass + + +class FormService: + """表单元数据管理服务""" + + @staticmethod + def build_app_scope_condition( + application_id: str = None, + *, + for_selection: bool = False, + ): + """ + 构建表单应用范围过滤条件。 + for_selection=True 时,除本应用表单外,还包含其他应用中已发布且全局可见的表单。 + """ + if application_id: + local = FormMeta.application_id == application_id + else: + local = FormMeta.application_id.is_(None) + + if not for_selection: + return local + + shared = and_( + FormMeta.globally_visible == True, + FormMeta.status == "published", + ) + return or_(local, shared) + + # ============ 查询 ============ + + @staticmethod + async def list( + db: AsyncSession, + page: int = 1, + page_size: int = 20, + application_id: str = None, + name: str = None, + code: str = None, + form_type: str = None, + status: str = None, + include_globally_visible: bool = False, + ) -> Dict[str, Any]: + """分页查询表单列表(包含应用名称)""" + conditions = [FormMeta.is_deleted == False] + + # 应用过滤 + conditions.append( + FormService.build_app_scope_condition( + application_id, + for_selection=include_globally_visible, + ) + ) + + if name: + conditions.append(FormMeta.name.ilike(f"%{name}%")) + if code: + conditions.append(FormMeta.code.ilike(f"%{code}%")) + if form_type: + conditions.append(FormMeta.form_type == form_type) + if status: + conditions.append(FormMeta.status == status) + + # 获取总数 + count_stmt = select(func.count(FormMeta.id)).where(and_(*conditions)) + total_result = await db.execute(count_stmt) + total = total_result.scalar() or 0 + + # 获取列表(使用 LEFT JOIN 查询应用名称和编码) + 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 = [] + for form, app_name, app_code in result: + form.application_name = app_name or "主应用" + form.application_code = app_code or "" + items.append(form) + + 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, + form_type: str = None, + status: str = None, + include_globally_visible: bool = False, + ) -> Dict[str, Any]: + """ + 分页查询表单列表(带数据权限过滤) + + 自动从上下文获取当前用户信息,应用数据权限过滤 + """ + conditions = [FormMeta.is_deleted == False] + + # 应用过滤 + conditions.append( + FormService.build_app_scope_condition( + application_id, + for_selection=include_globally_visible, + ) + ) + + if name: + conditions.append(FormMeta.name.ilike(f"%{name}%")) + if code: + conditions.append(FormMeta.code.ilike(f"%{code}%")) + if form_type: + conditions.append(FormMeta.form_type == form_type) + if status: + conditions.append(FormMeta.status == status) + + # 获取数据权限过滤条件并应用 + data_scope_filter = await get_data_scope_filter(db, RESOURCE_TYPE) + scope_conditions = apply_data_scope_to_conditions(FormMeta, data_scope_filter) + conditions.extend(scope_conditions) + + # 获取总数 + count_stmt = select(func.count(FormMeta.id)).where(and_(*conditions)) + total_result = await db.execute(count_stmt) + total = total_result.scalar() or 0 + + # 获取列表(使用 LEFT JOIN 查询应用名称和编码) + 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 = [] + for form, app_name, app_code in result: + form.application_name = app_name or "主应用" + form.application_code = app_code or "" + items.append(form) + + return { + "items": items, + "total": total, + "page": page, + "page_size": page_size + } + + @staticmethod + async def get(db: AsyncSession, form_id: str) -> FormMeta: + """获取表单详情""" + stmt = select(FormMeta).where( + FormMeta.id == form_id, + FormMeta.is_deleted == False + ) + result = await db.execute(stmt) + form = result.scalar_one_or_none() + + if not form: + raise FormServiceException(f"表单不存在: {form_id}") + + return form + + @staticmethod + async def get_sub_tables(db: AsyncSession, form_id: str) -> List[FormSubTable]: + """获取表单子表配置""" + stmt = select(FormSubTable).where( + FormSubTable.form_id == form_id, + FormSubTable.is_deleted == False + ).order_by(FormSubTable.sort) + + result = await db.execute(stmt) + return list(result.scalars().all()) + + @staticmethod + async def get_by_code(db: AsyncSession, code: str) -> FormMeta: + """根据编码获取表单""" + stmt = select(FormMeta).where( + FormMeta.code == code, + FormMeta.is_deleted == False + ) + result = await db.execute(stmt) + form = result.scalar_one_or_none() + + if not form: + raise FormServiceException(f"表单不存在: {code}") + + return form + + # ============ 创建 ============ + + @staticmethod + async def create( + db: AsyncSession, + data: Dict[str, Any], + user_id: str = None + ) -> FormMeta: + """创建表单""" + code = data.get("code") + + # 检查编码唯一性 + stmt = select(FormMeta).where( + FormMeta.code == code, + FormMeta.is_deleted == False + ) + result = await db.execute(stmt) + if result.scalar_one_or_none(): + raise FormServiceException(f"表单编码已存在: {code}") + + sub_tables_data = data.pop("sub_tables", []) + + # 从上下文获取用户信息 + from utils.context import get_current_user_info_from_context + user_info = get_current_user_info_from_context() + creator_id = user_id or (user_info.get('user_id') if user_info else None) + dept_id = user_info.get('dept_id') if user_info else None + + # 创建主表单 + form = FormMeta( + application_id=data.get("application_id"), + name=data.get("name"), + code=code, + form_type=data.get("form_type", "normal"), + description=data.get("description", ""), + db_config=data.get("db_config"), + main_table=data.get("main_table"), + main_table_schema=data.get("main_table_schema", ""), + main_table_database=data.get("main_table_database", ""), + form_config=data.get("form_config", {}), + list_config=data.get("list_config", {}), + sort=data.get("sort", 0), + show_in_mobile=data.get("show_in_mobile", False), + globally_visible=data.get("globally_visible", False), + icon=data.get("icon", ""), + icon_bg_color=data.get("icon_bg_color", ""), + sys_creator_id=creator_id, + sys_modifier_id=creator_id, + sys_dept_id=dept_id, + ) + db.add(form) + await db.flush() + + # 创建子表关联 + for idx, sub_data in enumerate(sub_tables_data): + sub_table = FormSubTable( + form_id=form.id, + table_name=sub_data.get("table_name"), + table_schema=sub_data.get("table_schema", ""), + table_database=sub_data.get("table_database", ""), + alias=sub_data.get("alias", ""), + foreign_key=sub_data.get("foreign_key"), + related_field=sub_data.get("related_field", "id"), + relation_type=sub_data.get("relation_type", "one-to-many"), + sort=sub_data.get("sort", idx), + sys_creator_id=user_id, + sys_modifier_id=user_id, + ) + db.add(sub_table) + + await db.commit() + await db.refresh(form) + + logger.info(f"表单创建成功: {form.code}") + return form + + # ============ 更新 ============ + + @staticmethod + async def update( + db: AsyncSession, + form_id: str, + data: Dict[str, Any], + user_id: str = None + ) -> FormMeta: + """更新表单""" + form = await FormService.get(db, form_id) + + # 更新基本字段 + if "name" in data and data["name"] is not None: + form.name = data["name"] + if "form_type" in data and data["form_type"] is not None: + form.form_type = data["form_type"] + if "description" in data and data["description"] is not None: + form.description = data["description"] + if "sort" in data and data["sort"] is not None: + form.sort = data["sort"] + if "show_in_mobile" in data and data["show_in_mobile"] is not None: + form.show_in_mobile = data["show_in_mobile"] + if "globally_visible" in data and data["globally_visible"] is not None: + form.globally_visible = data["globally_visible"] + if "icon" in data and data["icon"] is not None: + form.icon = data["icon"] + if "icon_bg_color" in data and data["icon_bg_color"] is not None: + form.icon_bg_color = data["icon_bg_color"] + if "form_config" in data and data["form_config"] is not None: + form.form_config = data["form_config"] + if "list_config" in data and data["list_config"] is not None: + form.list_config = data["list_config"] + + # 更新数据库配置字段 + if "db_config" in data and data["db_config"] is not None: + form.db_config = data["db_config"] + if "main_table" in data and data["main_table"] is not None: + form.main_table = data["main_table"] + if "main_table_schema" in data and data["main_table_schema"] is not None: + form.main_table_schema = data["main_table_schema"] + if "main_table_database" in data and data["main_table_database"] is not None: + form.main_table_database = data["main_table_database"] + + form.sys_modifier_id = user_id + + # 更新子表关联 + if "sub_tables" in data and data["sub_tables"] is not None: + # 删除现有子表关联 + delete_stmt = update(FormSubTable).where( + FormSubTable.form_id == form_id + ).values(is_deleted=True) + await db.execute(delete_stmt) + + # 创建新的子表关联 + for idx, sub_data in enumerate(data["sub_tables"]): + sub_table = FormSubTable( + form_id=form_id, + table_name=sub_data.get("table_name"), + table_schema=sub_data.get("table_schema", ""), + table_database=sub_data.get("table_database", ""), + alias=sub_data.get("alias", ""), + foreign_key=sub_data.get("foreign_key"), + related_field=sub_data.get("related_field", "id"), + relation_type=sub_data.get("relation_type", "one-to-many"), + sort=sub_data.get("sort", idx), + sys_creator_id=user_id, + sys_modifier_id=user_id, + ) + db.add(sub_table) + + await db.commit() + await db.refresh(form) + + logger.info(f"表单更新成功: {form.code}") + return form + + # ============ 删除 ============ + + @staticmethod + async def _cleanup_form_publish_resources(db: AsyncSession, form: FormMeta) -> None: + """清理表单发布产生的菜单、API/字段/数据权限及资源注册(取消发布、删除时共用)""" + from core.menu.model import Menu + from core.permission.model import Permission + from core.resource_scope.field_permission.model import ResourceFieldPermissionConfig + from core.resource_scope.scope_permission.model import ResourceDataScopeConfig + from app.resource_registry import ResourceRegistry + + resource_type = f"form:{form.code}" + + # 1. 物理删除对应菜单 + delete_menu_stmt = delete(Menu).where( + Menu.path == f"/form-render/{form.code}" + ) + menu_result = await db.execute(delete_menu_stmt) + if menu_result.rowcount > 0: + logger.info(f"物理删除表单菜单: {form.code}, 删除数量: {menu_result.rowcount}") + + # 2. 物理删除 API 权限 + delete_perm_stmt = delete(Permission).where( + Permission.code.like(f"form:{form.code}:%") + ) + perm_result = await db.execute(delete_perm_stmt) + if perm_result.rowcount > 0: + logger.info(f"物理删除表单API权限: {form.code}, 删除数量: {perm_result.rowcount}") + + # 3. 物理删除字段权限配置 + delete_field_perm_stmt = delete(ResourceFieldPermissionConfig).where( + ResourceFieldPermissionConfig.resource_type == resource_type + ) + field_perm_result = await db.execute(delete_field_perm_stmt) + if field_perm_result.rowcount > 0: + logger.info(f"物理删除表单字段权限: {form.code}, 删除数量: {field_perm_result.rowcount}") + + # 4. 物理删除数据权限配置 + delete_scope_stmt = delete(ResourceDataScopeConfig).where( + ResourceDataScopeConfig.resource_type == resource_type + ) + scope_result = await db.execute(delete_scope_stmt) + if scope_result.rowcount > 0: + logger.info(f"物理删除表单数据权限: {form.code}, 删除数量: {scope_result.rowcount}") + + # 5. 从资源注册表中移除 + if ResourceRegistry.unregister(resource_type): + logger.info(f"从资源注册表移除: {resource_type}") + + @staticmethod + async def delete(db: AsyncSession, form_id: str) -> bool: + """删除表单(物理删除,并清理菜单与权限配置)""" + from core.menu.service import MenuService + + form = await FormService.get(db, form_id) + + await FormService._cleanup_form_publish_resources(db, form) + + delete_sub_stmt = delete(FormSubTable).where( + FormSubTable.form_id == form_id + ) + await db.execute(delete_sub_stmt) + + await db.delete(form) + await db.commit() + + await MenuService.invalidate_cache() + logger.info(f"表单删除成功: {form.code}") + return True + + @staticmethod + async def batch_delete(db: AsyncSession, form_ids: List[str]) -> int: + """批量删除表单(物理删除,并清理菜单与权限配置)""" + from core.menu.service import MenuService + + stmt = select(FormMeta).where( + FormMeta.id.in_(form_ids), + FormMeta.is_deleted == False, + ) + result = await db.execute(stmt) + forms = list(result.scalars().all()) + + for form in forms: + await FormService._cleanup_form_publish_resources(db, form) + + delete_sub_stmt = delete(FormSubTable).where( + FormSubTable.form_id.in_(form_ids) + ) + await db.execute(delete_sub_stmt) + + delete_stmt = delete(FormMeta).where( + FormMeta.id.in_(form_ids), + FormMeta.is_deleted == False, + ) + result = await db.execute(delete_stmt) + await db.commit() + + await MenuService.invalidate_cache() + count = result.rowcount + logger.info(f"批量删除表单成功: {count} 个") + return count + + # ============ 发布/取消发布 ============ + + @staticmethod + async def publish( + db: AsyncSession, + form_id: str, + publish_config: Dict[str, Any] = None + ) -> FormMeta: + """发布表单并创建菜单和权限""" + from core.menu.model import Menu + from core.menu.service import MenuService + from core.permission.model import Permission + + form = await FormService.get(db, form_id) + + if form.status == "published": + raise FormServiceException("表单已发布") + + sub_tables = await FormService.get_sub_tables(db, form_id) + table_validation = await FormService.validate_form_tables( + db, + { + "db_config": form.db_config, + "main_table": form.main_table, + "main_table_schema": form.main_table_schema or "", + "main_table_database": form.main_table_database or "", + "sub_tables": [ + { + "table_name": s.table_name, + "table_schema": s.table_schema or "", + "table_database": s.table_database or "", + "foreign_key": s.foreign_key, + } + for s in sub_tables + ], + }, + ) + if not table_validation.get("valid"): + if not table_validation.get("connection_ok"): + raise FormServiceException( + table_validation.get("connection_message") + or "数据库连接不可用,无法发布" + ) + if not table_validation.get("main_table_exists"): + raise FormServiceException( + f"主表在目标库中不存在: {form.main_table}" + ) + for check in table_validation.get("sub_table_checks") or []: + if not check.get("exists"): + raise FormServiceException( + f"子表在目标库中不存在: {check.get('table_name')}" + ) + + # 更新表单状态 + form.status = "published" + form.version += 1 + + # 保存发布配置到 list_config + if publish_config: + list_config = form.list_config or {} + list_config["publish_config"] = { + "allow_add": publish_config.get("allow_add", True), + "allow_edit": publish_config.get("allow_edit", True), + "allow_delete": publish_config.get("allow_delete", True), + "allow_export": publish_config.get("allow_export", True), + "allow_import": publish_config.get("allow_import", False), + } + form.list_config = list_config + + # 创建或更新菜单 + menu_record = None + if publish_config: + menu_parent_id = publish_config.get("menu_parent_id") + + # 检查是否已存在该表单的菜单 + 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_config.get("menu_name", form.name) + existing_menu.title = publish_config.get("menu_name", form.name) + existing_menu.parent_id = menu_parent_id + existing_menu.icon = publish_config.get("menu_icon", "lucide:file-text") + existing_menu.order = publish_config.get("menu_order", 0) + existing_menu.type = "online_form" + existing_menu.application_id = form.application_id + menu_record = existing_menu + logger.info(f"更新表单菜单: {form.code}") + else: + # 创建新菜单 + new_menu = Menu( + application_id=form.application_id, + name=publish_config.get("menu_name", form.name), + title=publish_config.get("menu_name", form.name), + path=f"/form-render/{form.code}", + component="online-dev/form-render/index", + type="online_form", + parent_id=menu_parent_id, + icon=publish_config.get("menu_icon", "lucide:file-text"), + order=publish_config.get("menu_order", 0), + ) + db.add(new_menu) + await db.flush() + menu_record = new_menu + logger.info(f"创建表单菜单: {form.code}") + + # 创建表单操作权限 + if menu_record: + await FormService._create_form_permissions( + db, form, menu_record.id, publish_config + ) + + await db.commit() + await db.refresh(form) + + # 清空菜单缓存 + await MenuService.invalidate_cache() + logger.info("已清空菜单缓存") + + logger.info(f"表单发布成功: {form.code}, version={form.version}") + return form + + @staticmethod + async def _create_form_permissions( + db: AsyncSession, + form: FormMeta, + menu_id: str, + publish_config: Dict[str, Any] = None + ): + """创建表单操作权限""" + from core.permission.model import Permission + from app.resource_registry import ResourceRegistry + + # 注册表单资源类型到资源注册表(用于数据权限和字段权限配置) + resource_type = f"form:{form.code}" + # 生成字段元数据 + field_metadata = FormService._generate_form_field_metadata(form) + ResourceRegistry.register( + resource_type=resource_type, + service_class=None, # 表单没有对应的 Service 类 + display_name=form.name, + application_id=form.application_id, + field_metadata=field_metadata + ) + logger.info(f"注册表单资源类型: {resource_type}, application_id={form.application_id}, fields={len(field_metadata)}") + + # 定义标准操作权限 + actions = [ + ("view", "查看", True, 0, "GET"), + ("add", "新增", publish_config.get("allow_add", True) if publish_config else True, 1, "POST"), + ("edit", "编辑", publish_config.get("allow_edit", True) if publish_config else True, 2, "PUT"), + ("delete", "删除", publish_config.get("allow_delete", True) if publish_config else True, 3, "DELETE"), + ("export", "导出", publish_config.get("allow_export", True) if publish_config else True, 1, "POST"), + ("import", "导入", publish_config.get("allow_import", False) if publish_config else False, 1, "POST"), + ] + + # HTTP 方法映射 + http_method_map = {"GET": 0, "POST": 1, "PUT": 2, "DELETE": 3, "PATCH": 4, "ALL": 5} + + for action, name, enabled, http_method_int, http_method_str in actions: + perm_code = f"form:{form.code}:{action}" + + # 检查权限是否已存在 + existing_stmt = select(Permission).where( + Permission.menu_id == menu_id, + Permission.code == perm_code, + Permission.is_deleted == False + ) + existing_result = await db.execute(existing_stmt) + existing_perm = existing_result.scalar_one_or_none() + + if existing_perm: + # 更新现有权限的启用状态 + existing_perm.is_active = enabled + existing_perm.name = f"{form.name}-{name}" + logger.info(f"更新表单权限: {perm_code}, enabled={enabled}") + else: + # 创建新权限 + perm = Permission( + menu_id=menu_id, + name=f"{form.name}-{name}", + code=perm_code, + permission_type=1, # API权限 + api_path=f"/api/core/form-data/{form.code}", + http_method=http_method_int, + is_active=enabled, + sort=actions.index((action, name, enabled, http_method_int, http_method_str)) + ) + db.add(perm) + logger.info(f"创建表单权限: {perm_code}, enabled={enabled}") + + @staticmethod + async def unpublish(db: AsyncSession, form_id: str) -> FormMeta: + """取消发布表单并删除菜单、权限等相关数据""" + from core.menu.service import MenuService + + form = await FormService.get(db, form_id) + + if form.status == "draft": + raise FormServiceException("表单未发布") + + form.status = "draft" + await FormService._cleanup_form_publish_resources(db, form) + + await db.commit() + await db.refresh(form) + + await MenuService.invalidate_cache() + logger.info("已清空菜单缓存") + + logger.info(f"表单取消发布完成: {form.code}") + return form + + # ============ 复制 ============ + + @staticmethod + async def copy( + db: AsyncSession, + form_id: str, + new_code: str, + new_name: str = None, + user_id: str = None + ) -> FormMeta: + """复制表单""" + source = await FormService.get(db, form_id) + + # 检查新编码唯一性 + stmt = select(FormMeta).where( + FormMeta.code == new_code, + FormMeta.is_deleted == False + ) + result = await db.execute(stmt) + if result.scalar_one_or_none(): + raise FormServiceException(f"表单编码已存在: {new_code}") + + # 创建新表单 + new_form = FormMeta( + application_id=source.application_id, + name=new_name or f"{source.name}_副本", + code=new_code, + form_type=source.form_type, + description=source.description, + status="draft", + version=1, + db_config=source.db_config, + main_table=source.main_table, + main_table_schema=source.main_table_schema, + main_table_database=source.main_table_database, + show_in_mobile=source.show_in_mobile, + globally_visible=False, + icon=source.icon, + icon_bg_color=source.icon_bg_color, + form_config=source.form_config, + list_config=source.list_config, + sort=source.sort, + sys_creator_id=user_id, + sys_modifier_id=user_id, + ) + db.add(new_form) + await db.flush() + + # 复制子表关联 + sub_tables = await FormService.get_sub_tables(db, form_id) + for sub in sub_tables: + new_sub = FormSubTable( + form_id=new_form.id, + table_name=sub.table_name, + table_schema=sub.table_schema, + table_database=sub.table_database, + alias=sub.alias, + foreign_key=sub.foreign_key, + related_field=sub.related_field, + relation_type=sub.relation_type, + sort=sub.sort, + sys_creator_id=user_id, + sys_modifier_id=user_id, + ) + db.add(new_sub) + + await db.commit() + await db.refresh(new_form) + + logger.info(f"表单复制成功: {source.code} -> {new_code}") + return new_form + + # ============ 导入/导出 ============ + + @staticmethod + async def export_config(db: AsyncSession, form_id: str) -> Dict[str, Any]: + """导出表单配置(含数据库表 DDL)""" + form = await FormService.get(db, form_id) + sub_tables = await FormService.get_sub_tables(db, form_id) + + sub_tables_data = [] + for sub in sub_tables: + sub_tables_data.append({ + "table_name": sub.table_name, + "table_schema": sub.table_schema, + "table_database": sub.table_database, + "alias": sub.alias, + "foreign_key": sub.foreign_key, + "related_field": sub.related_field, + "relation_type": sub.relation_type, + "sort": sub.sort + }) + + table_ddl = {"main_table": "", "sub_tables": {}} + export_db_type = "" + db_service = None + try: + from core.database_manager.service import AsyncDatabaseManagerService + db_service = await AsyncDatabaseManagerService.create(form.db_config or "default") + export_db_type = getattr(db_service, "db_type", "") or "" + schema_name = form.main_table_schema or None + if form.main_table: + table_ddl["main_table"] = await db_service.get_table_ddl( + form.main_table, schema_name + ) + for sub in sub_tables: + if sub.table_name: + sub_schema = sub.table_schema or schema_name + table_ddl["sub_tables"][sub.table_name] = await db_service.get_table_ddl( + sub.table_name, sub_schema + ) + except Exception as e: + logger.warning(f"导出表单 DDL 失败(不影响配置导出): {e}") + + return { + "name": form.name, + "code": form.code, + "form_type": form.form_type, + "description": form.description, + "show_in_mobile": form.show_in_mobile or False, + "globally_visible": form.globally_visible or False, + "export_db_type": export_db_type, + "db_config": form.db_config, + "main_table": form.main_table, + "main_table_schema": form.main_table_schema, + "main_table_database": form.main_table_database, + "form_config": form.form_config, + "list_config": form.list_config, + "sub_tables": sub_tables_data, + "table_ddl": table_ddl + } + + @staticmethod + async def _check_table_exists( + db_config: str, + table_name: str, + schema_name: str = None, + database: str = None, + ) -> bool: + """检查数据库表是否存在""" + try: + from core.database_manager.service import AsyncDatabaseManagerService + db_service = await AsyncDatabaseManagerService.create(db_config or "default") + tables = await db_service.get_tables( + database=database, + schema_name=schema_name, + ) + return any(t.get("table_name") == table_name for t in tables) + except Exception as e: + logger.warning(f"检查表是否存在失败: {table_name}, {e}") + return False + + @staticmethod + async def validate_form_tables(db: AsyncSession, data: Dict[str, Any]) -> Dict[str, Any]: + """校验表单主/子表在目标连接上是否存在""" + db_config = (data.get("db_config") or "default").strip() or "default" + main_table = data.get("main_table") or "" + main_schema = data.get("main_table_schema") or "" + main_database = data.get("main_table_database") or "" + sub_tables = data.get("sub_tables") or [] + + connection_ok = True + connection_message = "" + try: + from core.database_connection.resolver import ConnectionResolver + + await ConnectionResolver.resolve(db_config, db) + except Exception as e: + connection_ok = False + connection_message = str(e) + + main_exists = ( + await FormService._check_table_exists( + db_config, main_table, main_schema or None, main_database or None + ) + if main_table + else False + ) + + sub_checks = [] + for sub in sub_tables: + if isinstance(sub, dict): + sub_name = sub.get("table_name", "") + sub_schema = sub.get("table_schema", "") or main_schema + sub_database = sub.get("table_database", "") or main_database + else: + sub_name = getattr(sub, "table_name", "") + sub_schema = getattr(sub, "table_schema", "") or main_schema + sub_database = getattr(sub, "table_database", "") or main_database + exists = ( + await FormService._check_table_exists( + db_config, sub_name, sub_schema or None, sub_database or None + ) + if sub_name + else False + ) + sub_checks.append( + { + "table_name": sub_name, + "schema_name": sub_schema, + "exists": exists, + "has_ddl": False, + } + ) + + database_warnings: List[str] = [] + if connection_ok and db_config != "default": + try: + from core.database_connection.resolver import ConnectionResolver + + target_info = await ConnectionResolver.resolve(db_config, db) + if target_info.db_type == "mysql": + conn_db = (target_info.database or "").strip() + if main_database and conn_db and main_database.lower() != conn_db.lower(): + database_warnings.append( + f"主表库名「{main_database}」与连接默认库「{conn_db}」不一致," + "请确认 MySQL 账号具备跨库访问权限,或统一连接默认库与表单库名" + ) + elif main_database and not conn_db: + database_warnings.append( + f"连接未配置默认库,将依赖 SQL 中的库名「{main_database}」访问表" + ) + for sub in sub_tables: + if isinstance(sub, dict): + sub_name = sub.get("table_name", "") + sub_db = sub.get("table_database", "") or main_database + else: + sub_name = getattr(sub, "table_name", "") + sub_db = getattr(sub, "table_database", "") or main_database + if ( + sub_name + and sub_db + and conn_db + and sub_db.lower() != conn_db.lower() + ): + database_warnings.append( + f"子表「{sub_name}」库名「{sub_db}」与连接默认库「{conn_db}」不一致" + ) + elif target_info.db_type == "postgresql": + conn_db = (target_info.database or "").strip() + if not conn_db and not main_database: + database_warnings.append( + "PostgreSQL 连接未配置默认库且表单未填写主表 database," + "运行时将使用 postgres 系统库,可能导致找不到业务表" + ) + elif not conn_db and main_database: + database_warnings.append( + f"连接未配置默认库,运行时将使用表单主表 database「{main_database}」" + ) + elif target_info.db_type == "oracle": + conn_svc = (target_info.database or "").strip() + if main_database and conn_svc and main_database != conn_svc: + database_warnings.append( + f"表单主表 database「{main_database}」与连接 service「{conn_svc}」不一致," + "Oracle 不按库名切换连接,请统一连接 service 与表单配置" + ) + except Exception as e: + logger.warning("校验库名绑定失败: %s", e) + + all_ok = connection_ok and main_exists and all(c["exists"] for c in sub_checks) + return { + "valid": all_ok, + "connection_ok": connection_ok, + "connection_message": connection_message, + "db_config": db_config, + "main_table_exists": main_exists, + "sub_table_checks": sub_checks, + "database_warnings": database_warnings, + } + + @staticmethod + async def _get_available_schemas(db_config: str) -> List[str]: + """获取可用的Schema列表""" + try: + from core.database_manager.service import AsyncDatabaseManagerService + db_service = await AsyncDatabaseManagerService.create(db_config or "default") + schemas = await db_service.get_schemas() + return [s.get("name", "") for s in schemas if s.get("name")] + except Exception as e: + logger.warning(f"获取Schema列表失败: {e}") + return ["public"] + + @staticmethod + async def _resolve_table_schema( + db_config: str, + table_name: str, + preferred_schema: str = None, + ) -> str: + """解析表在目标库中实际所在的 schema(导入时 meta 可能与真实库不一致)""" + if not table_name: + return preferred_schema or "public" + + schemas_to_try: List[str] = [] + if preferred_schema: + schemas_to_try.append(preferred_schema) + for schema in await FormService._get_available_schemas(db_config): + if schema and schema not in schemas_to_try: + schemas_to_try.append(schema) + if "public" not in schemas_to_try: + schemas_to_try.append("public") + + for schema in schemas_to_try: + if await FormService._check_table_exists(db_config, table_name, schema): + return schema + + return preferred_schema or "public" + + @staticmethod + def _generate_create_schema_sql(schema: str, db_type: str) -> str: + """生成创建 Schema 的 SQL(PostgreSQL / SQL Server)""" + if not schema: + return "" + if db_type == "postgresql": + return f'CREATE SCHEMA IF NOT EXISTS "{schema}";' + if db_type == "sqlserver": + return f""" +IF NOT EXISTS (SELECT * FROM sys.schemas WHERE name = '{schema}') +BEGIN + EXEC('CREATE SCHEMA [{schema}]') +END; +""" + return "" + + @staticmethod + async def _ensure_schemas_exist( + db_config: str, + schemas: List[str], + database: str = None, + ) -> None: + """确保目标 Schema 存在(仅 PostgreSQL / SQL Server)""" + unique_schemas = sorted({s.strip() for s in schemas if s and s.strip()}) + if not unique_schemas: + return + + from core.database_manager.service import AsyncDatabaseManagerService + + db_service = await AsyncDatabaseManagerService.create(db_config) + db_type = getattr(db_service, "db_type", None) + if db_type not in ("postgresql", "sqlserver"): + return + + available = await FormService._get_available_schemas(db_config) + for schema in unique_schemas: + if schema in available: + continue + sql = FormService._generate_create_schema_sql(schema, db_type) + if not sql: + continue + result = await db_service.execute_ddl(sql, database=database) + if not result.get("success"): + raise FormServiceException( + f"创建 Schema [{schema}] 失败: {result.get('message')}" + ) + logger.info(f"导入时自动创建 Schema: {schema}") + + @staticmethod + def _collect_import_target_schemas(data: Dict[str, Any]) -> List[str]: + """收集导入配置中涉及的目标 Schema""" + schemas: List[str] = [] + main_schema = (data.get("main_table_schema") or "").strip() + if main_schema: + schemas.append(main_schema) + + for sub in data.get("sub_tables", []) or []: + if isinstance(sub, dict): + sub_schema = (sub.get("table_schema") or "").strip() + else: + sub_schema = (getattr(sub, "table_schema", "") or "").strip() + if sub_schema: + schemas.append(sub_schema) + + return schemas + + @staticmethod + def _sync_form_config_table_meta(data: Dict[str, Any]) -> None: + """将顶层库表绑定同步到 form_config.tableConfigs.meta""" + form_config = data.get("form_config") or {} + table_configs = form_config.get("tableConfigs") or [] + if not table_configs: + return + + db_config = data.get("db_config", "default") + main_schema = data.get("main_table_schema", "") + main_database = data.get("main_table_database", "") + sub_tables = data.get("sub_tables", []) or [] + sub_map = {} + for sub in sub_tables: + if isinstance(sub, dict): + sub_map[sub.get("table_name", "")] = sub + else: + sub_map[getattr(sub, "table_name", "")] = sub + + for tc in table_configs: + if not isinstance(tc, dict): + continue + meta = tc.setdefault("meta", {}) + meta["dbName"] = db_config + if tc.get("type") == "main": + if main_database: + meta["database"] = main_database + if main_schema: + meta["schema"] = main_schema + else: + sub = sub_map.get(tc.get("tableName", "")) + if sub: + if isinstance(sub, dict): + if sub.get("table_database"): + meta["database"] = sub["table_database"] + if sub.get("table_schema"): + meta["schema"] = sub["table_schema"] + else: + if sub.table_database: + meta["database"] = sub.table_database + if sub.table_schema: + meta["schema"] = sub.table_schema + + @staticmethod + async def check_import(db: AsyncSession, data: Dict[str, Any]) -> Dict[str, Any]: + """导入预检查:检查编码冲突、表和schema是否存在""" + code = data.get("code", "") + db_config = data.get("db_config", "default") + main_table = data.get("main_table", "") + main_table_schema = data.get("main_table_schema", "") or "public" + main_table_database = data.get("main_table_database", "") or "" + sub_tables = data.get("sub_tables", []) + table_ddl = data.get("table_ddl") or {} + + code_exists = False + if code: + stmt = select(FormMeta).where(FormMeta.code == code, FormMeta.is_deleted == False) + result = await db.execute(stmt) + code_exists = result.scalar_one_or_none() is not None + + main_exists = ( + await FormService._check_table_exists( + db_config, main_table, main_table_schema, main_table_database or None + ) + if main_table + else False + ) + main_has_ddl = bool(table_ddl.get("main_table")) + + sub_table_checks = [] + ddl_sub = table_ddl.get("sub_tables", {}) + for sub in sub_tables: + sub_name = sub.get("table_name", "") if isinstance(sub, dict) else sub.table_name + sub_schema = (sub.get("table_schema", "") if isinstance(sub, dict) else sub.table_schema) or main_table_schema + sub_database = ( + (sub.get("table_database", "") if isinstance(sub, dict) else getattr(sub, "table_database", "")) + or main_table_database + ) + exists = ( + await FormService._check_table_exists( + db_config, sub_name, sub_schema or None, sub_database or None + ) + if sub_name + else False + ) + sub_table_checks.append({ + "table_name": sub_name, + "schema_name": sub_schema, + "exists": exists, + "has_ddl": bool(ddl_sub.get(sub_name)) + }) + + all_tables_exist = main_exists and all(c["exists"] for c in sub_table_checks) + can_import = not code_exists and all_tables_exist + + available_schemas = await FormService._get_available_schemas(db_config) + + target_db_type = "" + try: + from core.database_connection.resolver import ConnectionResolver + + target_info = await ConnectionResolver.resolve(db_config, db) + target_db_type = getattr(target_info, "db_type", "") or "" + except Exception as e: + logger.warning("导入预检查解析目标库类型失败: %s", e) + + return { + "code_exists": code_exists, + "main_table_check": { + "table_name": main_table, + "schema_name": main_table_schema, + "exists": main_exists, + "has_ddl": main_has_ddl + }, + "sub_table_checks": sub_table_checks, + "can_import": can_import, + "available_schemas": available_schemas, + "target_db_type": target_db_type, + } + + @staticmethod + def _replace_table_name_in_ddl(ddl: str, old_name: str, new_name: str, new_schema: str = None) -> str: + """替换DDL中的表名和schema""" + import re + escaped = re.escape(old_name) + # CREATE TABLE "schema"."table" or CREATE TABLE schema.table + pattern_with_schema = rf'(CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?)"?[\w]+"?\."?{escaped}"?' + # CREATE TABLE "table" or CREATE TABLE table + pattern_without_schema = rf'(CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?)"?{escaped}"?' + + if new_schema: + target = f'\\1"{new_schema}"."{new_name}"' + else: + target = f'\\1"{new_name}"' + + result = re.sub(pattern_with_schema, target, ddl, count=1, flags=re.IGNORECASE) + if result == ddl: + if new_schema: + target_no_schema = f'\\1"{new_schema}"."{new_name}"' + else: + target_no_schema = f'\\1"{new_name}"' + result = re.sub(pattern_without_schema, target_no_schema, ddl, count=1, flags=re.IGNORECASE) + return result + + @staticmethod + async def import_config( + db: AsyncSession, + data: Dict[str, Any], + user_id: str = None + ) -> FormMeta: + """导入表单配置(支持自动建表、表名重映射)""" + required_fields = ["name", "code", "db_config", "main_table"] + for field in required_fields: + if not data.get(field): + raise FormServiceException(f"缺少必要字段: {field}") + + auto_create = data.pop("auto_create_tables", False) + create_schema = data.pop("create_schema_if_not_exists", False) + table_ddl = data.pop("table_ddl", None) or {} + rename_mappings = data.pop("table_rename_mappings", []) + export_db_type = (data.pop("export_db_type", None) or "").strip().lower() + db_config = data.get("db_config", "default") + + if auto_create and table_ddl and export_db_type: + from core.database_connection.resolver import ConnectionResolver + + def _norm_db_type(t: str) -> str: + t = (t or "").lower() + if t in ("postgres", "psql"): + return "postgresql" + if t == "mssql": + return "sqlserver" + return t + + target_info = await ConnectionResolver.resolve(db_config, db) + src_t = _norm_db_type(export_db_type) + tgt_t = _norm_db_type(target_info.db_type) + if src_t and tgt_t and src_t != tgt_t: + raise FormServiceException( + "CROSS_DIALECT_AUTO_CREATE:" + f"导出库类型为 {src_t},目标连接类型为 {tgt_t}" + ) + + rename_map = {} + for m in rename_mappings: + orig = m.get("original_name", "") if isinstance(m, dict) else m.original_name + new_n = m.get("new_name", "") if isinstance(m, dict) else m.new_name + new_s = m.get("new_schema") if isinstance(m, dict) else m.new_schema + if orig and new_n: + rename_map[orig] = {"new_name": new_n, "new_schema": new_s} + + original_main_table = data["main_table"] + if original_main_table in rename_map: + mapping = rename_map[original_main_table] + data["main_table"] = mapping["new_name"] + if mapping["new_schema"]: + data["main_table_schema"] = mapping["new_schema"] + + sub_tables = data.get("sub_tables", []) + for sub in sub_tables: + sub_name = sub.get("table_name", "") if isinstance(sub, dict) else sub.table_name + if sub_name in rename_map: + mapping = rename_map[sub_name] + if isinstance(sub, dict): + sub["table_name"] = mapping["new_name"] + if mapping["new_schema"]: + sub["table_schema"] = mapping["new_schema"] + else: + sub.table_name = mapping["new_name"] + if mapping["new_schema"]: + sub.table_schema = mapping["new_schema"] + + db_config = data.get("db_config", "default") + main_database = data.get("main_table_database", "") or None + + if create_schema: + await FormService._ensure_schemas_exist( + db_config, + FormService._collect_import_target_schemas(data), + main_database, + ) + + if auto_create and table_ddl: + main_table = data.get("main_table", "") + main_schema = data.get("main_table_schema", "") + + try: + from core.database_manager.service import AsyncDatabaseManagerService + db_service = await AsyncDatabaseManagerService.create(db_config) + + main_ddl = table_ddl.get("main_table", "") + if main_ddl and main_table: + if not await FormService._check_table_exists(db_config, main_table, main_schema): + if original_main_table in rename_map: + main_ddl = FormService._replace_table_name_in_ddl( + main_ddl, original_main_table, main_table, main_schema + ) + result = await db_service.execute_ddl(main_ddl, schema_name=main_schema or None) + if not result.get("success"): + raise FormServiceException(f"创建主表失败: {result.get('message')}") + logger.info(f"导入时自动创建主表: {main_schema}.{main_table}") + + sub_ddls = table_ddl.get("sub_tables", {}) + for sub in data.get("sub_tables", []): + sub_name = sub.get("table_name", "") if isinstance(sub, dict) else sub.table_name + sub_schema = sub.get("table_schema", "") if isinstance(sub, dict) else sub.table_schema + + original_sub_name = None + for orig_name, mp in rename_map.items(): + if mp["new_name"] == sub_name: + original_sub_name = orig_name + break + + ddl_key = original_sub_name or sub_name + ddl = sub_ddls.get(ddl_key, "") + if ddl and sub_name: + if not await FormService._check_table_exists(db_config, sub_name, sub_schema or main_schema): + if original_sub_name: + ddl = FormService._replace_table_name_in_ddl( + ddl, original_sub_name, sub_name, sub_schema or main_schema + ) + result = await db_service.execute_ddl(ddl, schema_name=sub_schema or main_schema or None) + if not result.get("success"): + raise FormServiceException(f"创建子表 {sub_name} 失败: {result.get('message')}") + logger.info(f"导入时自动创建子表: {sub_schema or main_schema}.{sub_name}") + except FormServiceException: + raise + except Exception as e: + raise FormServiceException(f"自动建表失败: {str(e)}") + + # 导入时校正 schema:导出环境的 schema 可能与目标库不一致 + main_table = data.get("main_table", "") + if main_table: + data["main_table_schema"] = await FormService._resolve_table_schema( + db_config, + main_table, + data.get("main_table_schema") or None, + ) + + for sub in data.get("sub_tables", []): + if isinstance(sub, dict): + sub_name = sub.get("table_name", "") + sub_schema = sub.get("table_schema", "") or data.get("main_table_schema", "") + else: + sub_name = sub.table_name + sub_schema = sub.table_schema or data.get("main_table_schema", "") + if sub_name: + resolved_sub_schema = await FormService._resolve_table_schema( + db_config, sub_name, sub_schema or None + ) + if isinstance(sub, dict): + sub["table_schema"] = resolved_sub_schema + else: + sub.table_schema = resolved_sub_schema + + FormService._sync_form_config_table_meta(data) + + return await FormService.create(db, data, user_id) + + # ============ 获取表单类型列表 ============ + + @staticmethod + def get_form_types() -> List[Dict[str, str]]: + """获取所有表单类型""" + return [ + {"value": "normal", "label": "普通表单"}, + {"value": "workflow", "label": "流程表单"}, + ] + + @staticmethod + def _generate_form_field_metadata(form: FormMeta) -> Dict[str, Dict[str, Any]]: + """ + 根据表单配置生成字段元数据 + 用于字段权限配置 + """ + field_metadata = {} + + # 从表单配置中提取字段信息 + form_config = form.form_config or {} + items = form_config.get('items', []) + + def extract_fields(items_list): + """递归提取字段信息""" + for item in items_list: + field_name = item.get('field') + item_type = item.get('type', '') + + # 处理容器类型,递归提取子项 + # collapse: 子项在 items[].children 中 + if item_type == 'collapse': + collapse_items = item.get('items', []) + for collapse_item in collapse_items: + collapse_children = collapse_item.get('children', []) + if collapse_children: + extract_fields(collapse_children) + continue + + # grid: 子项在 columns[].children 中 + if item_type == 'grid': + columns = item.get('columns', []) + for column in columns: + column_children = column.get('children', []) + if column_children: + extract_fields(column_children) + continue + + # tabs: 子项在 tabs[].children 中 + if item_type == 'tabs': + tabs = item.get('tabs', []) + for tab in tabs: + tab_children = tab.get('children', []) + if tab_children: + extract_fields(tab_children) + continue + + # 其他容器类型(card, row, col, 展示组件等): 子项在 children 中 + if item_type in ['card', 'row', 'col', 'divider', 'alert', 'timeline', 'text', 'html', 'spacer', 'title', 'steps']: + children = item.get('children', []) + if children: + extract_fields(children) + continue + + # sub-table: 子表字段也需要提取 + if item_type == 'sub-table': + # 子表本身作为一个字段 + if field_name and not field_name.startswith('_'): + field_metadata[field_name] = { + 'label': item.get('label', field_name), + 'field_type': 'sub-table', + 'required': item.get('props', {}).get('required', False), + 'sensitive': False, + 'maskable': False, + 'default_permission': 'write' + } + # 子表内的字段也提取 + children = item.get('children', []) + if children: + extract_fields(children) + continue + + # 跳过没有字段名的项 + if not field_name: + continue + + # 跳过内部字段 + if field_name.startswith('_'): + continue + + field_metadata[field_name] = { + 'label': item.get('label', field_name), + 'field_type': item_type or 'string', + 'required': item.get('props', {}).get('required', False), + 'sensitive': False, + 'maskable': False, + 'default_permission': 'write' + } + + extract_fields(items) + return field_metadata + + @staticmethod + async def get_published_forms_simple(db: AsyncSession, application_id: str = None, all_apps: bool = False) -> List[Dict[str, Any]]: + """ + 获取已发布表单的简单列表(用于下拉选择) + + Args: + application_id: 过滤指定应用,为 None 且 all_apps=False 时只返回无应用的表单 + all_apps: 为 True 时返回所有应用的已发布表单(移动端工作台使用) + Returns: + [{code, name, mainTable, application_id, application_name, fields: [{field, label, type}]}] + """ + from core.application.model import Application + + conditions = [ + FormMeta.status == "published", + FormMeta.is_deleted == False + ] + if application_id: + conditions.append( + FormService.build_app_scope_condition( + application_id, + for_selection=not all_apps, + ) + ) + elif not all_apps: + conditions.append( + FormService.build_app_scope_condition( + None, + for_selection=True, + ) + ) + + if all_apps: + conditions.append(FormMeta.show_in_mobile == True) + + stmt = select(FormMeta, Application.name.label("application_name")).outerjoin( + Application, Application.id == FormMeta.application_id + ).where( + *conditions + ).order_by(FormMeta.name) + + result = await db.execute(stmt) + rows = result.all() + + simple_list = [] + for row in rows: + form = row[0] + application_name = row[1] + # 提取表单字段信息 + fields = [] + form_config = form.form_config or {} + items = form_config.get("items", []) + + def extract_fields(item_list: List[Dict], in_sub_table: bool = False): + """递归提取字段""" + for item in item_list: + item_type = item.get("type", "") + field = item.get("field", "") + label = item.get("label", "") + + # 跳过子表和布局组件 + if item_type == "sub-table": + continue + + # 布局组件,递归处理 + if item_type in ("grid", "tabs", "collapse", "steps", "button"): + if item.get("columns"): + for col in item["columns"]: + extract_fields(col.get("children", []), in_sub_table) + if item.get("items"): + for sub_item in item["items"]: + extract_fields(sub_item.get("children", []), in_sub_table) + continue + + # 普通字段(排除非数据字段) + if field and not in_sub_table and item_type not in ("divider", "alert", "timeline", "text", "html", "spacer", "title", "button"): + fields.append({ + "field": field, + "label": label or field, + "type": item_type + }) + + extract_fields(items) + + # 添加常用的系统字段到字段列表开头 + system_fields = [ + {"field": "id", "label": "ID", "type": "string"}, + {"field": "sys_create_datetime", "label": "创建时间", "type": "datetime"}, + {"field": "sys_update_datetime", "label": "更新时间", "type": "datetime"}, + {"field": "sys_creator_id", "label": "创建人ID", "type": "string"}, + {"field": "sys_modifier_id", "label": "修改人ID", "type": "string"}, + {"field": "sys_dept_id", "label": "部门ID", "type": "string"}, + {"field": "sort", "label": "排序", "type": "number"}, + ] + # 将系统字段添加到开头 + fields = system_fields + fields + + simple_list.append({ + "code": form.code, + "name": form.name, + "mainTable": form.main_table, + "application_id": form.application_id, + "application_name": application_name, + "icon": form.icon or "", + "icon_bg_color": form.icon_bg_color or "", + "form_type": form.form_type or "normal", + "fields": fields + }) + + return simple_list + + @staticmethod + async def register_published_forms_to_registry(db: AsyncSession): + """ + 启动时加载已发布的表单并注册资源类型到 ResourceRegistry + 用于数据权限和字段权限配置 + """ + from app.resource_registry import ResourceRegistry + + # 查询所有已发布的表单 + stmt = select(FormMeta).where( + FormMeta.status == "published", + FormMeta.is_deleted == False + ) + result = await db.execute(stmt) + published_forms = result.scalars().all() + + for form in published_forms: + resource_type = f"form:{form.code}" + # 生成字段元数据 + field_metadata = FormService._generate_form_field_metadata(form) + + ResourceRegistry.register( + resource_type=resource_type, + service_class=None, + display_name=form.name, + application_id=form.application_id, + field_metadata=field_metadata + ) + logger.info(f"启动时注册表单资源类型: {resource_type}, application_id={form.application_id}, fields={len(field_metadata)}") diff --git a/backend-fastapi/online_dev/page_manager/__init__.py b/backend-fastapi/online_dev/page_manager/__init__.py new file mode 100644 index 0000000..3b69581 --- /dev/null +++ b/backend-fastapi/online_dev/page_manager/__init__.py @@ -0,0 +1,3 @@ +from .service import PageService, PageServiceException + +__all__ = ["PageService", "PageServiceException"] diff --git a/backend-fastapi/online_dev/page_manager/model.py b/backend-fastapi/online_dev/page_manager/model.py new file mode 100644 index 0000000..8a9c292 --- /dev/null +++ b/backend-fastapi/online_dev/page_manager/model.py @@ -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="页面设计配置") diff --git a/backend-fastapi/online_dev/page_manager/service.py b/backend-fastapi/online_dev/page_manager/service.py new file mode 100644 index 0000000..9c8985f --- /dev/null +++ b/backend-fastapi/online_dev/page_manager/service.py @@ -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()] diff --git a/web/apps/web-ele/src/api/ai-platform/form-manager-lite.ts b/web/apps/web-ele/src/api/ai-platform/form-manager-lite.ts new file mode 100644 index 0000000..5068806 --- /dev/null +++ b/web/apps/web-ele/src/api/ai-platform/form-manager-lite.ts @@ -0,0 +1,35 @@ +import { requestClient } from '#/api/request'; + +export interface FormMetaListItem { + id: string; + application_id?: string; + application_name?: string; + application_code?: string; + name: string; + code: string; + status: string; +} + +export interface FormListParams { + page?: number; + pageSize?: number; + applicationId?: string; + name?: string; + code?: string; + status?: string; + includeGloballyVisible?: boolean; +} + +interface FormPaginatedResponse { + items: T[]; + total: number; + page: number; + pageSize: number; +} + +export async function getFormListApi(params?: FormListParams) { + return requestClient.get>( + '/api/ai/forms/list', + { params }, + ); +} diff --git a/web/apps/web-ele/src/views/ai-platform/workflow/editor/components/Panel.vue b/web/apps/web-ele/src/views/ai-platform/workflow/editor/components/Panel.vue index 74c1c4e..8821629 100644 --- a/web/apps/web-ele/src/views/ai-platform/workflow/editor/components/Panel.vue +++ b/web/apps/web-ele/src/views/ai-platform/workflow/editor/components/Panel.vue @@ -54,6 +54,72 @@ const components: Record = { ), // 循环节点 loop: defineAsyncComponent(() => import('../panels/LoopPanel.vue')), + form_basic_info: defineAsyncComponent( + () => import('../panels/FormBasicInfoPanel.vue'), + ), + form_database_design: defineAsyncComponent( + () => import('../panels/FormDatabaseDesignPanel.vue'), + ), + form_database_create: defineAsyncComponent( + () => import('../panels/FormDatabaseCreatePanel.vue'), + ), + form_ui_design: defineAsyncComponent( + () => import('../panels/FormUIDesignPanel.vue'), + ), + form_list_design: defineAsyncComponent( + () => import('../panels/FormListDesignPanel.vue'), + ), + form_create: defineAsyncComponent( + () => import('../panels/FormCreatePanel.vue'), + ), + form_publish: defineAsyncComponent( + () => import('../panels/FormPublishPanel.vue'), + ), + app_create: defineAsyncComponent( + () => import('../panels/AppCreatePanel.vue'), + ), + app_design: defineAsyncComponent( + () => import('../panels/AppDesignPanel.vue'), + ), + app_settings: defineAsyncComponent( + () => import('../panels/AppSettingsPanel.vue'), + ), + app_update: defineAsyncComponent( + () => import('../panels/AppUpdatePanel.vue'), + ), + dashboard_basic_info: defineAsyncComponent( + () => import('../panels/DashboardBasicInfoPanel.vue'), + ), + dashboard_design: defineAsyncComponent( + () => import('../panels/DashboardDesignPanel.vue'), + ), + dashboard_create: defineAsyncComponent( + () => import('../panels/DashboardCreatePanel.vue'), + ), + dashboard_publish: defineAsyncComponent( + () => import('../panels/DashboardPublishPanel.vue'), + ), + system_summary: defineAsyncComponent( + () => import('../panels/SystemSummaryPanel.vue'), + ), + form_data_create: defineAsyncComponent( + () => import('../panels/FormDataPanel.vue'), + ), + form_data_read: defineAsyncComponent( + () => import('../panels/FormDataPanel.vue'), + ), + form_data_update: defineAsyncComponent( + () => import('../panels/FormDataPanel.vue'), + ), + form_data_delete: defineAsyncComponent( + () => import('../panels/FormDataPanel.vue'), + ), + form_data_list: defineAsyncComponent( + () => import('../panels/FormDataPanel.vue'), + ), + form_schema_to_llm: defineAsyncComponent( + () => import('../panels/FormDataPanel.vue'), + ), // 子流程节点 subflow: defineAsyncComponent(() => import('../panels/SubflowPanel.vue')), // Text-to-SQL 节点 diff --git a/web/apps/web-ele/src/views/ai-platform/workflow/editor/index.vue b/web/apps/web-ele/src/views/ai-platform/workflow/editor/index.vue index 49e308a..bac4e1a 100644 --- a/web/apps/web-ele/src/views/ai-platform/workflow/editor/index.vue +++ b/web/apps/web-ele/src/views/ai-platform/workflow/editor/index.vue @@ -19,6 +19,7 @@ import { Bot, Brain, CircleHelp, + ClipboardCheck, Clock, Code, Combine, @@ -26,11 +27,14 @@ import { Database, DatabaseBackup, DatabaseZap, + FilePlus, + FileText, Fullscreen, GitBranch, GitFork, Globe, HelpCircle, + LayoutDashboard, LayoutTemplate, ListChecks, Map, @@ -44,10 +48,13 @@ import { Save, Search, Send, + Settings, Snowflake, Square, + TableProperties, Trash2, Undo2, + Upload, Workflow, } from '@vben/icons'; import { $t } from '@vben/locales'; @@ -77,16 +84,32 @@ import ContextMenu from './components/ContextMenu.vue'; import Panel from './components/Panel.vue'; import VersionHistoryPanel from './components/VersionHistoryPanel.vue'; import AddButtonEdge from './edges/AddButtonEdge.vue'; +import AppCreateNode from './nodes/AppCreateNode.vue'; +import AppDesignNode from './nodes/AppDesignNode.vue'; +import AppSettingsNode from './nodes/AppSettingsNode.vue'; +import AppUpdateNode from './nodes/AppUpdateNode.vue'; import ChoiceNode from './nodes/ChoiceNode.vue'; import CodeNode from './nodes/CodeNode.vue'; import ConditionNode from './nodes/ConditionNode.vue'; import ConfirmNode from './nodes/ConfirmNode.vue'; +import DashboardBasicInfoNode from './nodes/DashboardBasicInfoNode.vue'; +import DashboardCreateNode from './nodes/DashboardCreateNode.vue'; +import DashboardDesignNode from './nodes/DashboardDesignNode.vue'; +import DashboardPublishNode from './nodes/DashboardPublishNode.vue'; import DbDeleteNode from './nodes/DbDeleteNode.vue'; import DbInsertNode from './nodes/DbInsertNode.vue'; import DbQueryNode from './nodes/DbQueryNode.vue'; import DbSqlNode from './nodes/DbSqlNode.vue'; import DbUpdateNode from './nodes/DbUpdateNode.vue'; import EndNode from './nodes/EndNode.vue'; +import FormBasicInfoNode from './nodes/FormBasicInfoNode.vue'; +import FormCreateNode from './nodes/FormCreateNode.vue'; +import FormDatabaseCreateNode from './nodes/FormDatabaseCreateNode.vue'; +import FormDatabaseDesignNode from './nodes/FormDatabaseDesignNode.vue'; +import FormDataNode from './nodes/FormDataNode.vue'; +import FormListDesignNode from './nodes/FormListDesignNode.vue'; +import FormPublishNode from './nodes/FormPublishNode.vue'; +import FormUIDesignNode from './nodes/FormUIDesignNode.vue'; import HttpNode from './nodes/HttpNode.vue'; import IntentNode from './nodes/IntentNode.vue'; import KnowledgeRetrievalNode from './nodes/KnowledgeRetrievalNode.vue'; @@ -100,6 +123,7 @@ import SnowflakeCortexAnalystNode from './nodes/SnowflakeCortexAnalystNode.vue'; import SnowflakeCortexLLMNode from './nodes/SnowflakeCortexLLMNode.vue'; import StartNode from './nodes/StartNode.vue'; import SubflowNode from './nodes/SubflowNode.vue'; +import SystemSummaryNode from './nodes/SystemSummaryNode.vue'; import TemplateNode from './nodes/TemplateNode.vue'; import TextToSqlNode from './nodes/TextToSqlNode.vue'; @@ -160,6 +184,28 @@ const nodeTypes = { snowflake_cortex_analyst: markRaw(SnowflakeCortexAnalystNode), // 循环节点 loop: markRaw(LoopNode), + form_basic_info: markRaw(FormBasicInfoNode), + form_database_design: markRaw(FormDatabaseDesignNode), + form_database_create: markRaw(FormDatabaseCreateNode), + form_ui_design: markRaw(FormUIDesignNode), + form_list_design: markRaw(FormListDesignNode), + form_create: markRaw(FormCreateNode), + form_publish: markRaw(FormPublishNode), + app_create: markRaw(AppCreateNode), + app_design: markRaw(AppDesignNode), + app_settings: markRaw(AppSettingsNode), + app_update: markRaw(AppUpdateNode), + dashboard_basic_info: markRaw(DashboardBasicInfoNode), + dashboard_design: markRaw(DashboardDesignNode), + dashboard_create: markRaw(DashboardCreateNode), + dashboard_publish: markRaw(DashboardPublishNode), + system_summary: markRaw(SystemSummaryNode), + form_data_create: markRaw(FormDataNode), + form_data_read: markRaw(FormDataNode), + form_data_update: markRaw(FormDataNode), + form_data_delete: markRaw(FormDataNode), + form_data_list: markRaw(FormDataNode), + form_schema_to_llm: markRaw(FormDataNode), // 子流程节点 subflow: markRaw(SubflowNode), // Text-to-SQL 节点 @@ -339,6 +385,94 @@ const nodeCategories = computed(() => [ }, ], }, + { + name: $t('ai-platform.workflow.editor.categories.formDesign'), + nodes: [ + { + type: 'form_basic_info', + label: getNodeLabel('form_basic_info'), + icon: 'FileText', + color: 'purple', + }, + { + type: 'form_database_design', + label: getNodeLabel('form_database_design'), + icon: 'Database', + color: 'purple', + }, + { + type: 'form_database_create', + label: getNodeLabel('form_database_create'), + icon: 'DatabaseZap', + color: 'purple', + }, + { + type: 'form_ui_design', + label: getNodeLabel('form_ui_design'), + icon: 'LayoutTemplate', + color: 'purple', + }, + { + type: 'form_list_design', + label: getNodeLabel('form_list_design'), + icon: 'TableProperties', + color: 'purple', + }, + { + type: 'form_create', + label: getNodeLabel('form_create'), + icon: 'FilePlus', + color: 'purple', + }, + { + type: 'form_publish', + label: getNodeLabel('form_publish'), + icon: 'Send', + color: 'purple', + }, + ], + }, + { + name: $t('ai-platform.workflow.editor.categories.formData'), + nodes: [ + { + type: 'form_data_create', + label: getNodeLabel('form_data_create'), + icon: 'FilePlus', + color: 'green', + }, + { + type: 'form_data_read', + label: getNodeLabel('form_data_read'), + icon: 'FileText', + color: 'blue', + }, + { + type: 'form_data_update', + label: getNodeLabel('form_data_update'), + icon: 'FileText', + color: 'orange', + }, + { + type: 'form_data_delete', + label: getNodeLabel('form_data_delete'), + icon: 'Trash2', + color: 'red', + }, + { + type: 'form_data_list', + label: getNodeLabel('form_data_list'), + icon: 'Search', + color: 'blue', + }, + { + type: 'form_schema_to_llm', + label: getNodeLabel('form_schema_to_llm'), + icon: 'Code', + color: 'purple', + }, + ], + }, { name: $t('ai-platform.workflow.editor.categories.dialog'), nodes: [ @@ -473,6 +607,72 @@ const nodeCategories = computed(() => [ }, ], }, + { + name: $t('ai-platform.workflow.editor.categories.appManage'), + nodes: [ + { + type: 'app_create', + label: getNodeLabel('app_create'), + icon: 'LayoutTemplate', + color: 'indigo', + }, + { + type: 'app_design', + label: getNodeLabel('app_design'), + icon: 'LayoutDashboard', + color: 'indigo', + }, + { + type: 'app_settings', + label: getNodeLabel('app_settings'), + icon: 'Settings', + color: 'indigo', + }, + { + type: 'app_update', + label: getNodeLabel('app_update'), + icon: 'Save', + color: 'indigo', + }, + ], + }, + { + name: $t('ai-platform.workflow.editor.categories.dashboard'), + icon: 'LayoutDashboard', + color: 'cyan', + nodes: [ + { + type: 'dashboard_basic_info', + label: getNodeLabel('dashboard_basic_info'), + icon: 'FileText', + color: 'cyan', + }, + { + type: 'dashboard_design', + label: getNodeLabel('dashboard_design'), + icon: 'LayoutDashboard', + color: 'cyan', + }, + { + type: 'dashboard_create', + label: getNodeLabel('dashboard_create'), + icon: 'FilePlus', + color: 'cyan', + }, + { + type: 'dashboard_publish', + label: getNodeLabel('dashboard_publish'), + icon: 'Upload', + color: 'cyan', + }, + { + type: 'system_summary', + label: getNodeLabel('system_summary'), + icon: 'ClipboardCheck', + color: 'cyan', + }, + ], + }, { name: $t('ai-platform.workflow.editor.categories.knowledge'), nodes: [ @@ -515,12 +715,18 @@ const iconComponents: Record = { // 循环节点图标 RefreshCw, CornerDownLeft, + FileText, + TableProperties, + LayoutDashboard, + Settings, + FilePlus, + Upload, + ClipboardCheck, // 表单节点图标 Send, // 应用节点图标 Save, // 仪表盘节点图标 - FilePlus, // 系统总结节点图标 // 子流程节点图标 Workflow, @@ -532,6 +738,18 @@ const iconComponents: Record = { const filteredNodeCategories = computed(() => { let categories = nodeCategories.value; + if (workflow.value?.workflow_type === 'general') { + const excludeNames = new Set([ + $t('ai-platform.workflow.editor.categories.appManage'), + $t('ai-platform.workflow.editor.categories.dashboard'), + $t('ai-platform.workflow.editor.categories.formDesign'), + ]); + categories = nodeCategories.value.filter( + (category: { name: string; nodes: any[] }) => + !excludeNames.has(category.name), + ); + } + // 再根据搜索关键词过滤 if (!nodeSearchQuery.value.trim()) { return categories; diff --git a/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/AppCreateNode.vue b/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/AppCreateNode.vue new file mode 100644 index 0000000..0e8e69f --- /dev/null +++ b/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/AppCreateNode.vue @@ -0,0 +1,59 @@ + + + diff --git a/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/AppDesignNode.vue b/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/AppDesignNode.vue new file mode 100644 index 0000000..40b72c2 --- /dev/null +++ b/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/AppDesignNode.vue @@ -0,0 +1,57 @@ + + + diff --git a/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/AppSettingsNode.vue b/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/AppSettingsNode.vue new file mode 100644 index 0000000..da597b7 --- /dev/null +++ b/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/AppSettingsNode.vue @@ -0,0 +1,69 @@ + + + diff --git a/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/AppUpdateNode.vue b/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/AppUpdateNode.vue new file mode 100644 index 0000000..d4650d2 --- /dev/null +++ b/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/AppUpdateNode.vue @@ -0,0 +1,57 @@ + + + diff --git a/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/DashboardBasicInfoNode.vue b/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/DashboardBasicInfoNode.vue new file mode 100644 index 0000000..db4131f --- /dev/null +++ b/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/DashboardBasicInfoNode.vue @@ -0,0 +1,58 @@ + + + diff --git a/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/DashboardCreateNode.vue b/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/DashboardCreateNode.vue new file mode 100644 index 0000000..aa90cfe --- /dev/null +++ b/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/DashboardCreateNode.vue @@ -0,0 +1,65 @@ + + + diff --git a/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/DashboardDesignNode.vue b/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/DashboardDesignNode.vue new file mode 100644 index 0000000..0a946bd --- /dev/null +++ b/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/DashboardDesignNode.vue @@ -0,0 +1,55 @@ + + + diff --git a/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/DashboardPublishNode.vue b/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/DashboardPublishNode.vue new file mode 100644 index 0000000..99b761b --- /dev/null +++ b/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/DashboardPublishNode.vue @@ -0,0 +1,57 @@ + + + diff --git a/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/FormBasicInfoNode.vue b/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/FormBasicInfoNode.vue new file mode 100644 index 0000000..f6a7045 --- /dev/null +++ b/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/FormBasicInfoNode.vue @@ -0,0 +1,75 @@ + + + diff --git a/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/FormCreateNode.vue b/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/FormCreateNode.vue new file mode 100644 index 0000000..a26f505 --- /dev/null +++ b/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/FormCreateNode.vue @@ -0,0 +1,51 @@ + + + diff --git a/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/FormDataNode.vue b/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/FormDataNode.vue new file mode 100644 index 0000000..e5c34d2 --- /dev/null +++ b/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/FormDataNode.vue @@ -0,0 +1,189 @@ + + + diff --git a/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/FormDatabaseCreateNode.vue b/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/FormDatabaseCreateNode.vue new file mode 100644 index 0000000..a3e0b3a --- /dev/null +++ b/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/FormDatabaseCreateNode.vue @@ -0,0 +1,74 @@ + + + diff --git a/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/FormDatabaseDesignNode.vue b/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/FormDatabaseDesignNode.vue new file mode 100644 index 0000000..dd92fe6 --- /dev/null +++ b/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/FormDatabaseDesignNode.vue @@ -0,0 +1,80 @@ + + + diff --git a/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/FormListDesignNode.vue b/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/FormListDesignNode.vue new file mode 100644 index 0000000..77ede57 --- /dev/null +++ b/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/FormListDesignNode.vue @@ -0,0 +1,86 @@ + + + diff --git a/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/FormPublishNode.vue b/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/FormPublishNode.vue new file mode 100644 index 0000000..f91c56c --- /dev/null +++ b/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/FormPublishNode.vue @@ -0,0 +1,56 @@ + + + diff --git a/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/FormUIDesignNode.vue b/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/FormUIDesignNode.vue new file mode 100644 index 0000000..c4c2365 --- /dev/null +++ b/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/FormUIDesignNode.vue @@ -0,0 +1,89 @@ + + + diff --git a/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/SystemSummaryNode.vue b/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/SystemSummaryNode.vue new file mode 100644 index 0000000..164bc4d --- /dev/null +++ b/web/apps/web-ele/src/views/ai-platform/workflow/editor/nodes/SystemSummaryNode.vue @@ -0,0 +1,80 @@ + + + diff --git a/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/AppCreatePanel.vue b/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/AppCreatePanel.vue new file mode 100644 index 0000000..9c9816d --- /dev/null +++ b/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/AppCreatePanel.vue @@ -0,0 +1,189 @@ + + + diff --git a/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/AppDesignPanel.vue b/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/AppDesignPanel.vue new file mode 100644 index 0000000..349fcd2 --- /dev/null +++ b/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/AppDesignPanel.vue @@ -0,0 +1,114 @@ + + + diff --git a/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/AppSettingsPanel.vue b/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/AppSettingsPanel.vue new file mode 100644 index 0000000..38f9ca6 --- /dev/null +++ b/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/AppSettingsPanel.vue @@ -0,0 +1,196 @@ + + + diff --git a/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/AppUpdatePanel.vue b/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/AppUpdatePanel.vue new file mode 100644 index 0000000..13d4d10 --- /dev/null +++ b/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/AppUpdatePanel.vue @@ -0,0 +1,103 @@ + + + diff --git a/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/DashboardBasicInfoPanel.vue b/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/DashboardBasicInfoPanel.vue new file mode 100644 index 0000000..793b221 --- /dev/null +++ b/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/DashboardBasicInfoPanel.vue @@ -0,0 +1,164 @@ + + + diff --git a/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/DashboardCreatePanel.vue b/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/DashboardCreatePanel.vue new file mode 100644 index 0000000..ba5a2f2 --- /dev/null +++ b/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/DashboardCreatePanel.vue @@ -0,0 +1,135 @@ + + + diff --git a/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/DashboardDesignPanel.vue b/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/DashboardDesignPanel.vue new file mode 100644 index 0000000..ae30fc1 --- /dev/null +++ b/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/DashboardDesignPanel.vue @@ -0,0 +1,119 @@ + + + diff --git a/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/DashboardPublishPanel.vue b/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/DashboardPublishPanel.vue new file mode 100644 index 0000000..bbaa88a --- /dev/null +++ b/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/DashboardPublishPanel.vue @@ -0,0 +1,165 @@ + + + diff --git a/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/FormBasicInfoPanel.vue b/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/FormBasicInfoPanel.vue new file mode 100644 index 0000000..e902789 --- /dev/null +++ b/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/FormBasicInfoPanel.vue @@ -0,0 +1,206 @@ + + + diff --git a/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/FormCreatePanel.vue b/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/FormCreatePanel.vue new file mode 100644 index 0000000..147b552 --- /dev/null +++ b/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/FormCreatePanel.vue @@ -0,0 +1,228 @@ + + + diff --git a/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/FormDataPanel.vue b/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/FormDataPanel.vue new file mode 100644 index 0000000..6b26013 --- /dev/null +++ b/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/FormDataPanel.vue @@ -0,0 +1,308 @@ + + + diff --git a/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/FormDatabaseCreatePanel.vue b/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/FormDatabaseCreatePanel.vue new file mode 100644 index 0000000..486fced --- /dev/null +++ b/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/FormDatabaseCreatePanel.vue @@ -0,0 +1,206 @@ + + + diff --git a/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/FormDatabaseDesignPanel.vue b/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/FormDatabaseDesignPanel.vue new file mode 100644 index 0000000..41a9173 --- /dev/null +++ b/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/FormDatabaseDesignPanel.vue @@ -0,0 +1,360 @@ + + + diff --git a/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/FormListDesignPanel.vue b/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/FormListDesignPanel.vue new file mode 100644 index 0000000..baebaa4 --- /dev/null +++ b/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/FormListDesignPanel.vue @@ -0,0 +1,286 @@ + + + diff --git a/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/FormPublishPanel.vue b/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/FormPublishPanel.vue new file mode 100644 index 0000000..226df16 --- /dev/null +++ b/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/FormPublishPanel.vue @@ -0,0 +1,132 @@ + + + diff --git a/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/FormUIDesignPanel.vue b/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/FormUIDesignPanel.vue new file mode 100644 index 0000000..f2566ca --- /dev/null +++ b/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/FormUIDesignPanel.vue @@ -0,0 +1,248 @@ + + + diff --git a/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/SystemSummaryPanel.vue b/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/SystemSummaryPanel.vue new file mode 100644 index 0000000..535d20a --- /dev/null +++ b/web/apps/web-ele/src/views/ai-platform/workflow/editor/panels/SystemSummaryPanel.vue @@ -0,0 +1,214 @@ + + +