diff --git a/zhenxun/builtin_plugins/test.py b/zhenxun/builtin_plugins/test.py new file mode 100644 index 00000000..6a33dbd3 --- /dev/null +++ b/zhenxun/builtin_plugins/test.py @@ -0,0 +1,128 @@ +from typing import Any + +from nonebot.plugin import PluginMetadata +from nonebot.rule import to_me +from nonebot_plugin_alconna import Alconna, Arparma, on_alconna +from nonebot_plugin_uninfo import Uninfo +from pydantic import BaseModel, Field + +from zhenxun.configs.utils import PluginExtraData +from zhenxun.services.log import logger +from zhenxun.services.page_template import PageTemplateConfig, template_manager +from zhenxun.services.page_template.components import ( + Button, + ButtonProps, + Col, + ColProps, + Form, + FormItem, + FormItemProps, + FormProps, + Row, + RowProps, +) + +__plugin_meta__ = PluginMetadata( + name="web测试", + description="想要更加了解真寻吗", + usage=""" + 指令: + 关于 + """.strip(), + extra=PluginExtraData(author="HibiKier", version="0.1", menu_type="其他").to_dict(), +) + + +_matcher = on_alconna(Alconna("test"), priority=5, block=True, rule=to_me()) + + +@_matcher.handle() +async def _(session: Uninfo, arparma: Arparma): + logger.info("1") + + +def temp(a: dict[str, Any]): + pass + + +class UserFormData(BaseModel): + username: str = Field(..., min_length=3, max_length=20) + email: str + age: int | None = None + + +def register_user_form_template(): + # 使用 list[Any] 避免 list 协变导致的类型告警 + layout: list[Any] = [ + Row( + props=RowProps(gutter=16), + children=[ + Col( + props=ColProps(span=12), + children=[ + Form( + props=FormProps(label_width="100px", inline=True), + children=[ + FormItem( + props=FormItemProps( + label="用户名", prop="username" + ), + children=None, + bind_field="username", + ), + FormItem( + props=FormItemProps(label="邮箱", prop="email"), + children=None, + bind_field="email", + ), + FormItem( + props=FormItemProps(label="年龄", prop="age"), + children=None, + bind_field="age", + ), + FormItem( + props=FormItemProps(label=""), + children=[ + Button( + props=ButtonProps( + text="提交", + type="primary", + action="submit", + confirm=True, + confirm_text="确认提交吗?", + ), + ), + Button( + props=ButtonProps( + text="重置", + type="default", + action="reset", # 前端重置表单 + ), + ), + Button( + props=ButtonProps( + text="取消", + type="danger", + action="cancel", # 前端自行关闭/返回 + ), + ), + ], + bind_field=None, + ), + ], + ) + ], + ) + ], + ) + ] + + config = PageTemplateConfig( + template_id="user_form", + title="用户表单示例", + description="包含提交/重置/取消按钮的示例表单", + layout=layout, + callback_handler=temp, + ) + + template_manager.register(config, data_model=UserFormData) diff --git a/zhenxun/services/__init__.py b/zhenxun/services/__init__.py index ec0ff65d..5ff86512 100644 --- a/zhenxun/services/__init__.py +++ b/zhenxun/services/__init__.py @@ -7,6 +7,7 @@ Zhenxun Bot - 核心服务模块 - LLM服务 (llm): 提供与大语言模型交互的统一API。 - 插件生命周期管理 (plugin_init): 支持插件安装和卸载时的钩子函数。 - 定时任务调度器 (scheduler): 提供持久化的、可管理的定时任务服务。 +- 页面模板服务 (page_template_service): 用于构建前端页面(表格、表单等)并处理数据提交。 """ from nonebot import require @@ -44,6 +45,15 @@ from .llm import ( set_global_default_model_name, ) from .log import logger +from .page_template import ( + ColumnAlign, + FieldConfig, + FieldType, + PageTemplateConfig, + PageTemplateManager, + PageTemplateService, + template_manager, +) from .plugin_init import PluginInit, PluginInitManager from .renderer import renderer_service from .scheduler import ( @@ -56,13 +66,19 @@ from .scheduler import ( __all__ = [ "AI", "AIConfig", + "ColumnAlign", "CommonOverrides", "ExecutionPolicy", + "FieldConfig", + "FieldType", "LLMContentPart", "LLMException", "LLMGenerationConfig", "LLMMessage", "Model", + "PageTemplateConfig", + "PageTemplateManager", + "PageTemplateService", "PluginInit", "PluginInitManager", "ScheduleContext", @@ -86,5 +102,6 @@ __all__ = [ "scheduler_manager", "search", "set_global_default_model_name", + "template_manager", "with_db_timeout", ] diff --git a/zhenxun/services/page_template/__init__.py b/zhenxun/services/page_template/__init__.py new file mode 100644 index 00000000..0774cb1e --- /dev/null +++ b/zhenxun/services/page_template/__init__.py @@ -0,0 +1,59 @@ +""" +页面模板服务模块 + +提供页面模板配置、字段定义和数据验证功能。 +""" + +from .components import ( + Button, + ButtonProps, + Card, + CardProps, + Col, + ColProps, + Component, + ComponentType, + Divider, + Form, + FormItem, + FormItemProps, + FormProps, + Row, + RowProps, + Space, + Table, + TableProps, + Text, + TextProps, +) +from .service import PageTemplateConfig, PageTemplateManager, PageTemplateService + +# 创建全局模板管理器实例 +template_manager = PageTemplateManager() + +__all__ = [ + "Button", + "ButtonProps", + "Card", + "CardProps", + "Col", + "ColProps", + "Component", + "ComponentType", + "Divider", + "Form", + "FormItem", + "FormItemProps", + "FormProps", + "PageTemplateConfig", + "PageTemplateManager", + "PageTemplateService", + "Row", + "RowProps", + "Space", + "Table", + "TableProps", + "Text", + "TextProps", + "template_manager", +] diff --git a/zhenxun/services/page_template/components/__init__.py b/zhenxun/services/page_template/components/__init__.py new file mode 100644 index 00000000..07d305a6 --- /dev/null +++ b/zhenxun/services/page_template/components/__init__.py @@ -0,0 +1,55 @@ +""" +前端布局组件模型集合 + +用于以数据形式描述页面布局,标签与前端 Element 组件保持一致: +- el-row, el-col +- el-text +- el-button +- el-card, el-divider, el-space, el-form, el-form-item, el-table +""" + +from .layout import ( + Button, + ButtonProps, + Card, + CardProps, + Col, + ColProps, + Component, + ComponentType, + Divider, + Form, + FormItem, + FormItemProps, + FormProps, + Row, + RowProps, + Space, + Table, + TableProps, + Text, + TextProps, +) + +__all__ = [ + "Button", + "ButtonProps", + "Card", + "CardProps", + "Col", + "ColProps", + "Component", + "ComponentType", + "Divider", + "Form", + "FormItem", + "FormItemProps", + "FormProps", + "Row", + "RowProps", + "Space", + "Table", + "TableProps", + "Text", + "TextProps", +] diff --git a/zhenxun/services/page_template/components/layout.py b/zhenxun/services/page_template/components/layout.py new file mode 100644 index 00000000..8cd7361c --- /dev/null +++ b/zhenxun/services/page_template/components/layout.py @@ -0,0 +1,207 @@ +from enum import Enum +from typing import Any + +from pydantic import BaseModel + + +class ComponentType(str, Enum): + """组件类型,名称与前端 tag 保持一致""" + + ROW = "row" + COL = "col" + TEXT = "text" + BUTTON = "button" + CARD = "card" + DIVIDER = "divider" + SPACE = "space" + FORM = "form" + FORM_ITEM = "form_item" + TABLE = "table" + + +class RowProps(BaseModel): + """行组件属性""" + + gutter: int | None = None # 行间距 + justify: str | None = None # 主轴对齐方式 + align: str | None = None # 交叉轴对齐方式 + + +class ColProps(BaseModel): + """列组件属性""" + + span: int | None = None # 栅格占比 + offset: int | None = None # 左侧偏移 + push: int | None = None # 向右移动 + pull: int | None = None # 向左移动 + + +class TextProps(BaseModel): + """文本组件属性""" + + content: str = "" # 文本内容 + tag: str | None = None # HTML 标签,如 h1/h2/p/span + type: str | None = None # 文本类型,对应 el-text 的 type + size: str | None = None # 文本大小 + truncated: bool = False # 是否截断 + line_clamp: int | None = None # 最多显示行数 + + +class ButtonProps(BaseModel): + """按钮组件属性""" + + text: str # 按钮文本(必填) + type: str | None = None # 按钮类型 primary/success/warning/danger/info/default + size: str | None = None # 按钮尺寸 large/default/small + plain: bool = False # 朴素按钮 + round: bool = False # 圆角按钮 + circle: bool = False # 圆形按钮 + link: bool = False # 文字按钮 + icon: str | None = None # 图标名称 + action: str | None = None # 按钮行为:submit/reset/cancel/custom + confirm: bool = False # 是否需要二次确认 + confirm_text: str | None = None # 确认提示文案 + api: str | None = None # 自定义调用的后端 API(action=custom 时使用) + api_method: str = "POST" # 自定义 API 的 HTTP 方法 + + +class CardProps(BaseModel): + """卡片组件属性""" + + header: str | None = None # 卡片标题 + shadow: str | None = None # 阴影类型 + body_style: dict[str, Any] | None = None # 卡片主体样式 + + +class FormProps(BaseModel): + """表单组件属性""" + + label_width: str | None = None # 标签宽度 + inline: bool = False # 行内表单 + size: str | None = None # 表单尺寸 + + +class FormItemProps(BaseModel): + """表单项组件属性""" + + label: str | None = None # 标签文本 + prop: str | None = None # 绑定字段名 + required: bool = False # 是否必填 + + +class TableProps(BaseModel): + """表格组件属性""" + + columns: list[dict[str, Any]] | None = None # 列配置 + data: list[dict[str, Any]] | None = None # 数据源 + + +class BaseComponent(BaseModel): + # 这些字段在 BaseModel 中有默认值,调用时都不是必传 + # 用 Any 以便可以直接传 TextProps / ButtonProps / FormProps 等模型实例 + props: Any | None + # 用 Any 避免 list 协变问题 + children: list[Any] | None = None + + class Config: + arbitrary_types_allowed = True + + def __init__(self, **data: Any): + super().__init__(**data) + _validate_children_impl(self.__dict__) + + +def _validate_children_impl(values: dict[str, Any]) -> dict[str, Any]: + """ + 限定哪些组件可以拥有 children: + - 允许 children: row, col, card, space, form, form_item, table + - 不允许 children: text, button, divider + """ + t = values.get("type") + children = values.get("children") or [] + no_children = { + ComponentType.TEXT.value, + ComponentType.BUTTON.value, + ComponentType.DIVIDER.value, + } + if t in no_children and children: + raise ValueError(f"组件 '{t}' 不允许包含 children") + return values + + +class Row(BaseComponent): + """行组件(对应 el-row)""" + + type = ComponentType.ROW.value + props: RowProps # 必填 + + +class Col(BaseComponent): + """列组件(对应 el-col)""" + + type = ComponentType.COL.value + props: ColProps # 必填 + + +class Text(BaseComponent): + """文本组件(对应 el-text 或基础标签)""" + + type = ComponentType.TEXT.value + props: TextProps # 必填 + + +class Button(BaseComponent): + """按钮组件(对应 el-button)""" + + type = ComponentType.BUTTON.value + props: ButtonProps # 必填 + + +class Card(BaseComponent): + """卡片组件(对应 el-card)""" + + type = ComponentType.CARD.value + props: CardProps # 必填 + + +class Divider(BaseComponent): + """分割线组件(对应 el-divider)""" + + type = ComponentType.DIVIDER.value + + +class Space(BaseComponent): + """间距组件(对应 el-space)""" + + type = ComponentType.SPACE.value + + +class Form(BaseComponent): + """表单组件(对应 el-form)""" + + type = ComponentType.FORM.value + props: FormProps # 必填 + + +class FormItem(BaseComponent): + """表单项组件(对应 el-form-item)""" + + type = ComponentType.FORM_ITEM.value + props: FormItemProps # 必填 + children: list[Any] | None = None + bind_field: str | None = None + + +class Table(BaseComponent): + """表格组件(对应 el-table)""" + + type = ComponentType.TABLE.value + + +Component = Row | Col | Text | Button | Card | Divider | Space | Form | FormItem | Table + + +try: + BaseComponent.model_rebuild() +except AttributeError: + BaseComponent.update_forward_refs() diff --git a/zhenxun/services/page_template/router.py b/zhenxun/services/page_template/router.py new file mode 100644 index 00000000..f50f7d85 --- /dev/null +++ b/zhenxun/services/page_template/router.py @@ -0,0 +1,94 @@ +""" +页面模板服务 FastAPI 路由 + +提供统一的API接口,通过template_id来获取配置和处理数据提交。 +""" + +from fastapi import APIRouter, Body, Depends, Query +from fastapi.responses import JSONResponse + +from zhenxun.builtin_plugins.web_ui.base_model import Result +from zhenxun.builtin_plugins.web_ui.utils import authentication +from zhenxun.services.log import logger +from zhenxun.services.page_template import template_manager + +router = APIRouter(prefix="/page_template") + + +@router.get( + "/template_config", + dependencies=[Depends(authentication())], + response_model=Result[dict], + response_class=JSONResponse, + description="获取页面模板配置(表格配置)", +) +async def get_template_config(template_id: str = Query(..., description="模板ID")): + """获取页面模板配置""" + try: + config = template_manager.get_template_config(template_id) + if config is None: + return Result.fail(f"模板ID '{template_id}' 不存在") + return Result.ok(config, "获取配置成功") + except Exception as e: + logger.error(f"{router.prefix}/template_config 调用错误", "PageTemplate", e=e) + return Result.fail(f"获取配置失败: {type(e)}: {e}") + + +@router.get( + "/form_config", + dependencies=[Depends(authentication())], + response_model=Result[dict], + response_class=JSONResponse, + description="获取表单配置", +) +async def get_form_config(template_id: str = Query(..., description="模板ID")): + """获取表单配置""" + try: + config = template_manager.get_form_config(template_id) + if config is None: + return Result.fail(f"模板ID '{template_id}' 不存在") + return Result.ok(config, "获取表单配置成功") + except Exception as e: + logger.error(f"{router.prefix}/form_config 调用错误", "PageTemplate", e=e) + return Result.fail(f"获取表单配置失败: {type(e)}: {e}") + + +@router.post( + "/submit", + dependencies=[Depends(authentication())], + response_model=Result, + response_class=JSONResponse, + description="提交数据", +) +async def submit_data( + template_id: str = Query(..., description="模板ID"), + data: dict = Body(..., description="提交的数据"), +): + """处理数据提交""" + try: + success, message, result = await template_manager.process_submit( + template_id, data + ) + if not success: + return Result.fail(message) + return Result.ok(info=message, data=result) + except Exception as e: + logger.error(f"{router.prefix}/submit 调用错误", "PageTemplate", e=e) + return Result.fail(f"提交数据失败: {type(e)}: {e}") + + +@router.get( + "/list", + dependencies=[Depends(authentication())], + response_model=Result[list[str]], + response_class=JSONResponse, + description="获取所有已注册的模板ID列表", +) +async def list_templates(): + """获取所有已注册的模板ID列表""" + try: + template_ids = template_manager.list_templates() + return Result.ok(template_ids, "获取模板列表成功") + except Exception as e: + logger.error(f"{router.prefix}/list 调用错误", "PageTemplate", e=e) + return Result.fail(f"获取模板列表失败: {type(e)}: {e}") diff --git a/zhenxun/services/page_template/service.py b/zhenxun/services/page_template/service.py new file mode 100644 index 00000000..2bb3c87a --- /dev/null +++ b/zhenxun/services/page_template/service.py @@ -0,0 +1,314 @@ +""" +页面模板服务 + +用于构建前端页面(如表格、表单等),支持字段绑定和数据提交处理。 +""" + +from collections.abc import Callable +from typing import Any, Generic, TypeVar + +from pydantic import BaseModel, Field, ValidationError + +from zhenxun.services.log import logger +from zhenxun.services.page_template.components import Component + +T = TypeVar("T", bound=BaseModel) + + +class PageTemplateConfig(BaseModel): + """页面模板配置""" + + template_id: str = Field(..., description="模板ID") + """模板ID""" + title: str = Field(..., description="页面标题") + """页面标题""" + description: str | None = Field(None, description="页面描述") + """页面描述""" + callback_handler: Callable[[dict[str, Any]], Any] | None = Field( + None, + description="数据提交后的回调处理方法(异步或同步函数,接收验证后的数据字典)", + ) + """数据提交后的回调处理方法(异步或同步函数,接收验证后的数据字典)""" + layout: list[Component] = Field( + default_factory=list, + description="页面布局组件树,使用 row/col/text/button 等组件描述前端结构", + ) + """页面布局组件树""" + + class Config: + arbitrary_types_allowed = True + + +class PageTemplateService(Generic[T]): + """页面模板服务""" + + def __init__(self, config: PageTemplateConfig, data_model: type[T] | None = None): + """ + 初始化页面模板服务 + + 参数: + config: 页面模板配置 + data_model: 数据模型类(可选,用于数据验证) + """ + self.config = config + self.data_model = data_model + + def get_table_config(self) -> dict[str, Any]: + """ + 获取表格配置(用于前端渲染表格) + + 返回: + 包含表格配置的字典 + """ + return { + "template_id": self.config.template_id, + "title": self.config.title, + "description": self.config.description, + "layout": self.get_layout_config(), + } + + def get_form_config(self) -> dict[str, Any]: + """ + 获取表单配置(用于前端渲染表单) + + 返回: + 包含表单配置的字典 + """ + return { + "template_id": self.config.template_id, + "title": self.config.title, + "description": self.config.description, + "layout": self.get_layout_config(), + } + + def get_layout_config(self) -> list[dict[str, Any]]: + """ + 获取页面布局配置(组件树) + + 返回: + 布局组件的列表(字典形式,适合前端直接渲染) + """ + from zhenxun.utils.pydantic_compat import model_dump + + return [model_dump(node, exclude_none=True) for node in self.config.layout] + + def validate_data(self, data: dict[str, Any]) -> tuple[bool, str | None, T | None]: + """ + 验证提交的数据 + + 参数: + data: 待验证的数据字典 + + 返回: + 元组 (是否有效, 错误信息, 验证后的数据模型实例) + """ + # 如果提供了数据模型,使用Pydantic验证 + if self.data_model: + try: + validated_data = self.data_model(**data) + return True, None, validated_data + except ValidationError as e: + error_messages = [] + for error in e.errors(): + field_name = ".".join(str(loc) for loc in error["loc"]) + error_messages.append(f"{field_name}: {error['msg']}") + return False, "; ".join(error_messages), None + + return True, None, None + + def process_submit_data( + self, data: dict[str, Any] + ) -> tuple[bool, str, dict[str, Any]]: + """ + 处理提交的数据(验证并返回处理后的数据) + + 参数: + data: 提交的数据字典 + + 返回: + 元组 (是否成功, 消息, 处理后的数据) + """ + is_valid, error_msg, validated_model = self.validate_data(data) + + if not is_valid: + return False, error_msg or "数据验证失败", {} + + # 如果验证成功且有模型,返回模型数据 + if validated_model: + from zhenxun.utils.pydantic_compat import model_dump + + return True, "数据验证成功", model_dump(validated_model) + + # 否则返回原始数据(已通过验证) + return True, "数据验证成功", data + + +class PageTemplateManager: + """页面模板管理器(全局单例)""" + + _instance: "PageTemplateManager | None" = None + _templates: dict[str, PageTemplateService[Any]] + + def __new__(cls) -> "PageTemplateManager": + """单例模式""" + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._templates = {} + return cls._instance + + def register( + self, + config: PageTemplateConfig, + data_model: type[T] | None = None, + ) -> PageTemplateService[T]: + """ + 注册页面模板 + + 参数: + config: 页面模板配置 + data_model: 数据模型类(可选,用于数据验证) + + 返回: + PageTemplateService实例 + + 异常: + ValueError: 如果template_id已存在 + """ + if config.template_id in self._templates: + raise ValueError( + f"模板ID '{config.template_id}' 已存在,请使用不同的template_id" + ) + + service = PageTemplateService(config, data_model) + self._templates[config.template_id] = service + logger.info(f"已注册页面模板: {config.template_id} - {config.title}") + return service + + def get(self, template_id: str) -> PageTemplateService[Any] | None: + """ + 获取页面模板服务 + + 参数: + template_id: 模板ID + + 返回: + PageTemplateService实例,如果不存在则返回None + """ + return self._templates.get(template_id) + + def unregister(self, template_id: str) -> bool: + """ + 注销页面模板 + + 参数: + template_id: 模板ID + + 返回: + 是否成功注销 + """ + if template_id in self._templates: + del self._templates[template_id] + logger.info(f"已注销页面模板: {template_id}") + return True + return False + + def list_templates(self) -> list[str]: + """ + 列出所有已注册的模板ID + + 返回: + 模板ID列表 + """ + return list(self._templates.keys()) + + def get_template_config(self, template_id: str) -> dict[str, Any] | None: + """ + 获取模板配置(表格配置) + + 参数: + template_id: 模板ID + + 返回: + 表格配置字典,如果模板不存在则返回None + """ + service = self.get(template_id) + return service.get_table_config() if service else None + + def get_form_config(self, template_id: str) -> dict[str, Any] | None: + """ + 获取表单配置 + + 参数: + template_id: 模板ID + + 返回: + 表单配置字典,如果模板不存在则返回None + """ + service = self.get(template_id) + return service.get_form_config() if service else None + + async def process_submit( + self, template_id: str, data: dict[str, Any] + ) -> tuple[bool, str, dict[str, Any] | None]: + """ + 处理数据提交 + + 参数: + template_id: 模板ID + data: 提交的数据字典 + + 返回: + 元组 (是否成功, 消息, 处理后的数据或回调结果) + """ + service = self.get(template_id) + if not service: + return False, f"模板ID '{template_id}' 不存在", None + + # 验证数据 + success, message, processed_data = service.process_submit_data(data) + if not success: + return False, message, None + + # 如果有回调处理器,执行回调 + if service.config.callback_handler: + try: + result = await self._call_handler( + service.config.callback_handler, processed_data + ) + return True, message, result + except Exception as e: + handler_name = getattr( + service.config.callback_handler, "__name__", "unknown" + ) + logger.error( + f"执行回调处理器失败: {handler_name}", + "PageTemplate", + e=e, + ) + return False, f"执行回调处理器失败: {e!s}", None + + return True, message, processed_data + + async def _call_handler( + self, handler: Callable[[dict[str, Any]], Any], data: dict[str, Any] + ) -> Any: + """ + 调用回调处理器 + + 参数: + handler: 回调处理函数 + data: 要传递的数据 + + 返回: + 处理器的返回值 + """ + if not callable(handler): + raise ValueError("回调处理器必须是一个可调用对象") + + import inspect + + # 检查是否是异步函数 + if inspect.iscoroutinefunction(handler): + return await handler(data) + else: + return handler(data)