From 59125eca21151e626e7abba33d9ea41e60163af0 Mon Sep 17 00:00:00 2001 From: insistence <3055204202@qq.com> Date: Sat, 17 Oct 2026 16:51:18 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E5=AF=B9react=E7=89=88=E6=9C=AC=E7=9A=84=E6=94=AF?= =?UTF-8?q?=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cli/groups/plugin/commands/developer.py | 3 +- .../cli/groups/plugin/options.py | 4 +- .../cli/runtime/plugin/scaffold/backend.py | 6 +- .../cli/runtime/plugin/scaffold/builder.py | 15 +- .../cli/runtime/plugin/scaffold/frontend.py | 92 ++------ .../runtime/plugin/scaffold/frontend_api.py | 73 ++++++ .../runtime/plugin/scaffold/frontend_react.py | 223 ++++++++++++++++++ .../cli/runtime/plugin/service.py | 2 +- ruoyi-fastapi-backend/docs/cli_usage.md | 6 +- .../docs/plugin_development.md | 36 ++- .../module_generator/entity/vo/gen_vo.py | 2 +- .../templates/react/controls.jinja2 | 45 ++++ .../templates/react/index-tree.jsx.jinja2 | 1 + .../templates/react/index.jsx.jinja2 | 201 ++++++++++++++++ .../templates/react/view.jsx.jinja2 | 33 +++ ruoyi-fastapi-backend/plugins/ai/README.md | 21 +- ruoyi-fastapi-backend/plugins/ai/plugin.yaml | 8 + .../plugins/core/manifest/menu_tree.py | 10 +- .../plugins/core/validation/structure.py | 31 ++- .../tests/cli/root/test_contract_app_ops.py | 8 +- .../cli/root/test_plugin_create_templates.py | 8 +- .../root/test_plugin_frontend_environment.py | 19 +- .../cli/root/test_plugin_sdk_commands.py | 10 +- .../plugin/test_runtime_react_scaffold.py | 93 ++++++++ .../runtime/plugin/test_runtime_scaffold.py | 2 +- .../module_generator/test_react_templates.py | 78 ++++++ .../plugins/core/validation/test_structure.py | 29 ++- .../plugins/sample_plugins/test_ai_plugin.py | 13 +- .../test_ai_react_integration.py | 111 +++++++++ ruoyi-fastapi-backend/utils/template_util.py | 18 +- 30 files changed, 1066 insertions(+), 135 deletions(-) create mode 100644 ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/frontend_api.py create mode 100644 ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/frontend_react.py create mode 100644 ruoyi-fastapi-backend/module_generator/templates/react/controls.jinja2 create mode 100644 ruoyi-fastapi-backend/module_generator/templates/react/index-tree.jsx.jinja2 create mode 100644 ruoyi-fastapi-backend/module_generator/templates/react/index.jsx.jinja2 create mode 100644 ruoyi-fastapi-backend/module_generator/templates/react/view.jsx.jinja2 create mode 100644 ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_react_scaffold.py create mode 100644 ruoyi-fastapi-backend/tests/module_generator/test_react_templates.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/sample_plugins/test_ai_react_integration.py diff --git a/ruoyi-fastapi-backend/cli/groups/plugin/commands/developer.py b/ruoyi-fastapi-backend/cli/groups/plugin/commands/developer.py index ec3dac477..5c4d498b0 100644 --- a/ruoyi-fastapi-backend/cli/groups/plugin/commands/developer.py +++ b/ruoyi-fastapi-backend/cli/groups/plugin/commands/developer.py @@ -66,7 +66,8 @@ def create_command( # noqa: PLR0913 frontend_framework: Annotated[ PluginFrontendFrameworkChoice, typer.Option( - '--frontend-framework', help='宿主框架,auto 自动识别;v1 源码模板仅支持 vue2、vue3,v2 模板仅接受 auto' + '--frontend-framework', + help='宿主框架,auto 自动识别;v1 源码模板支持 vue2、vue3、react,v2 模板仅接受 auto', ), ] = 'auto', backend_only: Annotated[bool, typer.Option('--backend-only', help='只创建后端插件模板')] = False, diff --git a/ruoyi-fastapi-backend/cli/groups/plugin/options.py b/ruoyi-fastapi-backend/cli/groups/plugin/options.py index 095070449..acbf71725 100644 --- a/ruoyi-fastapi-backend/cli/groups/plugin/options.py +++ b/ruoyi-fastapi-backend/cli/groups/plugin/options.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from typing import Literal -PluginFrontendFrameworkChoice = Literal['auto', 'vue2', 'vue3'] +PluginFrontendFrameworkChoice = Literal['auto', 'vue2', 'vue3', 'react'] @dataclass(frozen=True) @@ -12,7 +12,7 @@ class PluginCreateCommandOptions: :param backend_only: 是否只创建后端插件模板 :param frontend_only: 是否只创建前端插件模板 :param template: 插件模板名称 - :param frontend_framework: 前端框架标识,auto 表示自动识别;源码模板仅支持 vue2、vue3 + :param frontend_framework: 前端框架标识,auto 表示自动识别;源码模板支持 vue2、vue3、react :param no_migration: 是否不创建 migration 示例 :param no_seed: 是否不创建 seed 示例 :param no_job: 是否不创建定时任务示例 diff --git a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/backend.py b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/backend.py index 30613289b..f1f1d674d 100644 --- a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/backend.py +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/backend.py @@ -448,13 +448,15 @@ def test_{plugin_id}_service_crud_flow() -> None: """ @staticmethod - def build_readme(plugin_id: str) -> str: + def build_readme(plugin_id: str, frontend_framework: str | None = 'vue3') -> str: """ 构建后端 README 内容。 :param plugin_id: 插件ID + :param frontend_framework: 前端宿主框架,None 表示只生成后端 :return: 后端 README 内容 """ + view_filename = 'index.jsx' if frontend_framework == 'react' else 'index.vue' return f"""# {plugin_id} backend plugin Backend plugin scaffold generated by `ruoyi plugin create`. @@ -495,6 +497,6 @@ def build_readme(plugin_id: str) -> str: The menu component `plugin/{plugin_id}/index` maps to: ```text -/plugins/{plugin_id}/views/index.vue +/plugins/{plugin_id}/views/{view_filename} ``` """ diff --git a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/builder.py b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/builder.py index a6f6baab3..edb44839b 100644 --- a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/builder.py +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/builder.py @@ -63,7 +63,7 @@ def build_plan( :param job: 是否创建定时任务示例 :param config: 是否创建配置项示例 :param test: 是否创建测试样例 - :param frontend_framework: 目标前端框架标识,auto 表示自动识别;源码模板仅支持 vue2、vue3 + :param frontend_framework: 目标前端框架标识,auto 表示自动识别;源码模板支持 vue2、vue3、react :return: 插件模板写入计划 """ self._validate_plugin_id(plugin_id) @@ -105,7 +105,9 @@ def build_plan( target_dirs.append(str(backend_plugin_root)) if effective_backend_test: target_dirs.append(str(self.backend_root / 'tests' / 'plugins' / plugin_id)) - files.extend(self._build_backend_files(plugin_id, backend_plugin_root, options)) + files.extend( + self._build_backend_files(plugin_id, backend_plugin_root, options, resolved_frontend_framework) + ) if options.frontend: assert resolved_frontend_framework is not None frontend_plugin_root = frontend_root / 'plugins' / plugin_id @@ -219,12 +221,14 @@ def _build_backend_files( plugin_id: str, plugin_root: Path, options: PluginScaffoldOptions, + frontend_framework: FrontendFramework | None = None, ) -> list[tuple[Path, str]]: """ 构建后端插件模板文件。 :param plugin_id: 插件ID :param plugin_root: 后端插件根目录 + :param frontend_framework: 所选宿主框架,仅后端模板为 None :param options: 插件模板生成选项 :return: 文件路径和内容列表 """ @@ -243,7 +247,10 @@ def _build_backend_files( else PluginBackendScaffoldTemplateBuilder.build_service(plugin_id), ), (plugin_root / 'hooks.py', PluginBackendScaffoldTemplateBuilder.build_hooks(plugin_id)), - (plugin_root / 'README.md', PluginBackendScaffoldTemplateBuilder.build_readme(plugin_id)), + ( + plugin_root / 'README.md', + PluginBackendScaffoldTemplateBuilder.build_readme(plugin_id, frontend_framework), + ), ] if options.job: files.append((plugin_root / 'jobs.py', PluginBackendScaffoldTemplateBuilder.build_jobs(plugin_id))) @@ -295,7 +302,7 @@ def _build_frontend_files( else PluginFrontendScaffoldTemplateBuilder.build_api(plugin_id), ), ( - plugin_root / 'views' / 'index.vue', + plugin_root / 'views' / PluginFrontendScaffoldTemplateBuilder.view_filename(frontend_framework), PluginFrontendScaffoldTemplateBuilder.build_crud_view(plugin_id, frontend_framework) if options.crud else PluginFrontendScaffoldTemplateBuilder.build_view(plugin_id, frontend_framework), diff --git a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/frontend.py b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/frontend.py index cbf643e1c..cd90141eb 100644 --- a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/frontend.py +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/frontend.py @@ -1,82 +1,16 @@ from plugins.core.frontend import FrontendFramework, PluginFrontendFrameworkResolver # noqa: F401 +from .frontend_api import PluginFrontendApiScaffoldTemplateBuilder +from .frontend_react import PluginReactFrontendScaffoldTemplateBuilder from .frontend_vue2 import PluginVue2FrontendScaffoldTemplateBuilder from .naming import PluginScaffoldNaming -class PluginVue3FrontendScaffoldTemplateBuilder: +class PluginVue3FrontendScaffoldTemplateBuilder(PluginFrontendApiScaffoldTemplateBuilder): """ 前端插件模板内容构建器。 """ - @staticmethod - def build_api(plugin_id: str) -> str: - """ - 构建前端 API 模板内容。 - - :param plugin_id: 插件ID - :return: 前端 API 模板内容 - """ - return f"""import request from '@/utils/request' - -export function ping{PluginScaffoldNaming.to_class_name(plugin_id)}() {{ - return request({{ - url: '/{plugin_id}/ping', - method: 'get' - }}) -}} -""" - - @staticmethod - def build_crud_api(plugin_id: str) -> str: - """ - 构建前端 CRUD API 模板内容。 - - :param plugin_id: 插件ID - :return: 前端 CRUD API 模板内容 - """ - class_name = PluginScaffoldNaming.to_class_name(plugin_id) - return f"""import request from '@/utils/request' - -export function ping{class_name}() {{ - return request({{ - url: '/{plugin_id}/ping', - method: 'get' - }}) -}} - -export function list{class_name}Items(query) {{ - return request({{ - url: '/{plugin_id}/items', - method: 'get', - params: query - }}) -}} - -export function add{class_name}Item(data) {{ - return request({{ - url: '/{plugin_id}/items', - method: 'post', - data - }}) -}} - -export function update{class_name}Item(itemId, data) {{ - return request({{ - url: '/{plugin_id}/items/' + itemId, - method: 'put', - data - }}) -}} - -export function del{class_name}Item(itemId) {{ - return request({{ - url: '/{plugin_id}/items/' + itemId, - method: 'delete' - }}) -}} -""" - @staticmethod def build_view(plugin_id: str) -> str: """ @@ -312,11 +246,17 @@ class PluginFrontendScaffoldTemplateBuilder: @staticmethod def build_api(plugin_id: str) -> str: - return PluginVue3FrontendScaffoldTemplateBuilder.build_api(plugin_id) + return PluginFrontendApiScaffoldTemplateBuilder.build_api(plugin_id) @staticmethod def build_crud_api(plugin_id: str) -> str: - return PluginVue3FrontendScaffoldTemplateBuilder.build_crud_api(plugin_id) + return PluginFrontendApiScaffoldTemplateBuilder.build_crud_api(plugin_id) + + @classmethod + def view_filename(cls, frontend_framework: FrontendFramework = 'vue3') -> str: + """验证框架并返回该宿主加载的页面文件名。""" + cls._resolve_builder(frontend_framework) + return 'index.jsx' if frontend_framework == 'react' else 'index.vue' @classmethod def build_view(cls, plugin_id: str, frontend_framework: FrontendFramework = 'vue3') -> str: @@ -337,9 +277,15 @@ def build_test(cls, plugin_id: str, frontend_framework: FrontendFramework = 'vue @staticmethod def _resolve_builder( frontend_framework: FrontendFramework, - ) -> type[PluginVue2FrontendScaffoldTemplateBuilder] | type[PluginVue3FrontendScaffoldTemplateBuilder]: + ) -> ( + type[PluginVue2FrontendScaffoldTemplateBuilder] + | type[PluginVue3FrontendScaffoldTemplateBuilder] + | type[PluginReactFrontendScaffoldTemplateBuilder] + ): if frontend_framework == 'vue2': return PluginVue2FrontendScaffoldTemplateBuilder if frontend_framework == 'vue3': return PluginVue3FrontendScaffoldTemplateBuilder - raise ValueError(f'暂不支持前端框架 {frontend_framework} 的源码模板,目前仅支持 vue2、vue3') + if frontend_framework == 'react': + return PluginReactFrontendScaffoldTemplateBuilder + raise ValueError(f'暂不支持前端框架 {frontend_framework} 的源码模板,目前仅支持 vue2、vue3、react') diff --git a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/frontend_api.py b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/frontend_api.py new file mode 100644 index 000000000..d1fef277c --- /dev/null +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/frontend_api.py @@ -0,0 +1,73 @@ +from .naming import PluginScaffoldNaming + + +class PluginFrontendApiScaffoldTemplateBuilder: + """各宿主框架共用的请求封装模板。""" + + @staticmethod + def build_api(plugin_id: str) -> str: + """ + 构建前端 API 模板内容。 + + :param plugin_id: 插件ID + :return: 前端 API 模板内容 + """ + return f"""import request from '@/utils/request' + +export function ping{PluginScaffoldNaming.to_class_name(plugin_id)}() {{ + return request({{ + url: '/{plugin_id}/ping', + method: 'get' + }}) +}} +""" + + @staticmethod + def build_crud_api(plugin_id: str) -> str: + """ + 构建前端 CRUD API 模板内容。 + + :param plugin_id: 插件ID + :return: 前端 CRUD API 模板内容 + """ + class_name = PluginScaffoldNaming.to_class_name(plugin_id) + return f"""import request from '@/utils/request' + +export function ping{class_name}() {{ + return request({{ + url: '/{plugin_id}/ping', + method: 'get' + }}) +}} + +export function list{class_name}Items(query) {{ + return request({{ + url: '/{plugin_id}/items', + method: 'get', + params: query + }}) +}} + +export function add{class_name}Item(data) {{ + return request({{ + url: '/{plugin_id}/items', + method: 'post', + data + }}) +}} + +export function update{class_name}Item(itemId, data) {{ + return request({{ + url: '/{plugin_id}/items/' + itemId, + method: 'put', + data + }}) +}} + +export function del{class_name}Item(itemId) {{ + return request({{ + url: '/{plugin_id}/items/' + itemId, + method: 'delete' + }}) +}} +""" diff --git a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/frontend_react.py b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/frontend_react.py new file mode 100644 index 000000000..b90988764 --- /dev/null +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/frontend_react.py @@ -0,0 +1,223 @@ +from .naming import PluginScaffoldNaming + + +class PluginReactFrontendScaffoldTemplateBuilder: + """使用 React Hooks 与宿主 Ant Design 组件生成插件源码页面。""" + + @staticmethod + def build_view(plugin_id: str) -> str: + """生成与基础 Vue 模板相同的插件信息卡片。""" + class_name = PluginScaffoldNaming.to_class_name(plugin_id) + return f"""import {{ Card }} from 'antd'; + +export default function {class_name}Plugin() {{ + return ( +
+ +
{plugin_id} plugin
+
+
+ ); +}} +""" + + @staticmethod + def build_crud_view(plugin_id: str) -> str: + """生成筛选、列表和编辑弹窗,复用宿主请求及消息组件。""" + class_name = PluginScaffoldNaming.to_class_name(plugin_id) + return f"""import {{ useEffect, useState }} from 'react'; +import {{ Button, Form, Input, Modal, Radio, Space, Table, Tag }} from 'antd'; +import TableActions, {{ TableAction }} from '@/components/TableActions'; +import {{ DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined, SearchOutlined }} from '@ant-design/icons'; +import {{ confirm, message }} from '@/plugins/modal'; +import {{ renderDialogFooter }} from '@/components/DialogFooter'; +import {{ + add{class_name}Item, + del{class_name}Item, + list{class_name}Items, + update{class_name}Item +}} from '../api/{plugin_id}'; + +const emptyForm = {{ itemName: '', status: '0', remark: '' }}; + +export default function {class_name}Plugin() {{ + const [queryForm] = Form.useForm(); + const [form] = Form.useForm(); + const [query, setQuery] = useState({{ keyword: '' }}); + const [revision, setRevision] = useState(0); + const [loading, setLoading] = useState(false); + const [items, setItems] = useState([]); + const [editing, setEditing] = useState(null); + const [saving, setSaving] = useState(false); + + useEffect(() => {{ + let active = true; + setLoading(true); + list{class_name}Items(query) + .then((response) => {{ + if (active) {{ + const data = response.data || response; + setItems(data.rows || []); + }} + }}) + .catch(() => {{ /* 请求工具统一显示接口错误。 */ }}) + .finally(() => {{ + if (active) setLoading(false); + }}); + return () => {{ active = false; }}; + }}, [query, revision]); + + function openEditor(row = null) {{ + form.resetFields(); + form.setFieldsValue({{ ...emptyForm, ...row }}); + setEditing(row || {{}}); + }} + + function resetQuery() {{ + queryForm.resetFields(); + setQuery({{ keyword: '' }}); + }} + + async function save(values) {{ + if (saving) return; + setSaving(true); + try {{ + if (editing.itemId != null) {{ + await update{class_name}Item(editing.itemId, values); + }} else {{ + await add{class_name}Item(values); + }} + message.success('保存成功'); + setEditing(null); + setRevision((value) => value + 1); + }} catch {{ + // 请求工具统一显示接口错误,保留表单供重试。 + }} finally {{ + setSaving(false); + }} + }} + + async function remove(row) {{ + try {{ + await confirm('确认删除数据“' + row.itemName + '”吗?'); + await del{class_name}Item(row.itemId); + message.success('删除成功'); + setRevision((value) => value + 1); + }} catch {{ + // 取消确认或请求失败时保留列表。 + }} + }} + + const columns = [ + {{ title: 'ID', dataIndex: 'itemId', width: 90, align: 'center' }}, + {{ title: '名称', dataIndex: 'itemName', width: 180 }}, + {{ + title: '状态', dataIndex: 'status', width: 90, align: 'center', + render: (status) => {{status === '0' ? '正常' : '停用'}} + }}, + {{ title: '备注', dataIndex: 'remark', width: 220 }}, + {{ + title: '操作', width: 150, align: 'center', + render: (_, row) => ( + + openEditor(row)}}>修改 + remove(row)}}>删除 + + ) + }} + ]; + + return ( +
+
setQuery({{ keyword: values.keyword || '' }})}}> + + + + + + + + + + +
+ + {{ if (!saving) setEditing(null); }}}} + onOk={{() => form.submit()}} + okText="确定" + cancelText="取消" + footer={{renderDialogFooter}} + confirmLoading={{saving}} + cancelButtonProps={{{{ disabled: saving }}}} + closable={{!saving}} + mask={{{{ closable: !saving }}}} + keyboard={{!saving}} + forceRender + > +
+ + + + + + + + + + + } +
+ + + + +{% if table.tree %} + +{% endif %} +
+
+{% if not table.tree %} + setQuery({ ...query, ...page })} /> +{% endif %} + setOpen(false)} onOk={submit} confirmLoading={saving} width="{{ dialogWidth }}" footer={renderDialogFooter} forceRender> +
+{% for column in columns if column.insert or column.edit %} +{% if table.tree and column.python_field == treeParentCode %} +
+{% elif not column.pk %} + {(editing ? {{ column.edit | lower }} : {{ column.insert | lower }}) && + {{ control(column) | trim }} + } +{% elif column.insert and not column.increment %} + {!editing && {{ control(column) | trim }}} +{% endif %} +{% endfor %} + +{% if table.sub %} +

{{ subTable.function_name or subTable.table_comment }}

+ {(fields, { add, remove }) => <> + + {fields.map(field =>
+ +{% for column in subTable.columns if not column.pk and column.column_name != subTableFkName %} +
{{ control(column) | trim }} +{% endfor %} + )} + } +{% endif %} + + +{% if genView %} + setViewId(null)} /> +{% endif %} + ; +} diff --git a/ruoyi-fastapi-backend/module_generator/templates/react/view.jsx.jinja2 b/ruoyi-fastapi-backend/module_generator/templates/react/view.jsx.jinja2 new file mode 100644 index 000000000..7e66fc13d --- /dev/null +++ b/ruoyi-fastapi-backend/module_generator/templates/react/view.jsx.jinja2 @@ -0,0 +1,33 @@ +{% from 'react/controls.jinja2' import display %} +import { useEffect, useState } from 'react'; +import { Descriptions, Drawer, Spin, Table } from 'antd'; +import DOMPurify from 'dompurify'; +import { get{{ BusinessName }} } from '@/api/{{ moduleName }}/{{ businessName }}'; +import { useDict } from '@/utils/dict'; +import { formatBusinessTime } from '@/utils/time'; +import DictTag from '@/components/DictTag'; +import ImagePreview from '@/components/ImagePreview'; +import FileUpload from '@/components/FileUpload'; +export default function {{ BusinessName }}View({ id, onClose }) { + const dicts = useDict({{ dicts }}); + const [data, setData] = useState({}), [loading, setLoading] = useState(false); + useEffect(() => { let active = true; if (id == null) return; setData({}); setLoading(true); + get{{ BusinessName }}(id).then(response => { if (active) setData(response.data || {}); }).catch(() => {}).finally(() => { if (active) setLoading(false); }); + return () => { active = false; }; + }, [id]); + return + +{% if table.sub %} +

{{ subTable.function_name or subTable.table_comment }}

+
{{ display(column) | trim }} }, +{% endfor %} + ]} /> +{% endif %} + ; +} diff --git a/ruoyi-fastapi-backend/plugins/ai/README.md b/ruoyi-fastapi-backend/plugins/ai/README.md index aca576719..d33956dcd 100644 --- a/ruoyi-fastapi-backend/plugins/ai/README.md +++ b/ruoyi-fastapi-backend/plugins/ai/README.md @@ -1,6 +1,6 @@ # AI 管理后端插件 -AI 管理插件提供模型配置、供应商字典、会话对话和流式输出能力。插件保留原有接口前缀和权限标识,安装后会在系统菜单中生成「AI 管理」目录,以及「模型管理」「AI 对话」两个页面。各前端框架共用此后端插件及同一份 `plugin.yaml`,页面目录约定为 `ruoyi-fastapi-frontend//web/plugins/ai`;当前提供 Vue2、Vue3 页面,分别位于 `vue2/web/plugins/ai` 和 `vue3/web/plugins/ai`。 +AI 管理插件提供模型配置、供应商字典、会话对话和流式输出能力。插件保留原有接口前缀和权限标识,安装后会在系统菜单中生成「AI 管理」目录,以及「模型管理」「AI 对话」两个页面。各前端框架共用此后端插件及同一份 `plugin.yaml`,页面目录约定为 `ruoyi-fastapi-frontend//web/plugins/ai`;当前提供 Vue2、Vue3 和 React 页面,分别位于对应框架的 web/plugins/ai 目录。 ## 功能 @@ -100,11 +100,28 @@ ruoyi plugin disable ai --env=dev --yes - `dependencies.frontend.vue2.npm`:Vue2 所需的全部运行依赖,包括 `markstream-vue2` 以及 Markdown、代码高亮、图表等依赖。 - `dependencies.frontend.vue3.npm`:Vue3 所需的全部运行依赖,包括 `markstream-vue`、`stream-diffs` 以及 Markdown、代码高亮、图表等依赖。 - `dependencies.frontend.vue3.npmDev`:Vue3 构建使用的 `vite-plugin-monaco-editor-esm`。 +- dependencies.frontend.react.npm:React AI 页面需要的 @ant-design/x、markstream-react、stream-diffs、KaTeX 和 Mermaid,与 React 宿主 package.json 保持一致;npmDev 为空。 -多个框架都使用的包也在各自分类中列出,不设公共依赖层。`dependencies.frontend` 接受 `react` 等安全的小写框架标识,但新增分类不会生成对应页面;当前 AI 插件仅提供 Vue2、Vue3 实现。旧顶层 `dependencies.npm/npmDev` 已移除,清单校验会拒绝;未声明目标框架分类时,该宿主没有 npm 依赖。 +多个框架都使用的包也在各自分类中列出,不设公共依赖层。`dependencies.frontend` 接受 `react` 等安全的小写框架标识,但新增分类不会生成对应页面;当前 AI 插件已提供 Vue2、Vue3、React 实现。旧顶层 `dependencies.npm/npmDev` 已移除,清单校验会拒绝;未声明目标框架分类时,该宿主没有 npm 依赖。 插件系统解析选定 Web 工程的框架,只检查、安装匹配框架分类中的依赖。默认选择 `vue3/web`;检查或安装 Vue2 依赖时,先在当前终端设置 `RUOYI_PLUGIN_FRONTEND_FRAMEWORK=vue2`(PowerShell:`$env:RUOYI_PLUGIN_FRONTEND_FRAMEWORK = 'vue2'`;Bash:`export RUOYI_PLUGIN_FRONTEND_FRAMEWORK=vue2`),再执行同样的插件命令。未设置该变量时也可使用 `RUOYI_FRONTEND_FRAMEWORK`,或用 `RUOYI_PLUGIN_FRONTEND_ROOT` 指向具体 Web 工程。各框架工程分别安装依赖和构建,后端插件仅需安装、启用一次。完整规则见[插件开发手册](../../docs/plugin_development.md#9-依赖管理)。 +### React 宿主 + +在后端目录使用 PowerShell 选择 React 工程,再运行现有插件命令: + +~~~powershell +$env:RUOYI_PLUGIN_FRONTEND_FRAMEWORK = 'react' +ruoyi plugin check ai --env=dev +ruoyi plugin install-deps ai --env=dev +~~~ + +后端启动进程也需使用同一环境变量,插件详情、启动依赖门禁和依赖安装才会选择 React 分类。已安装并启用的 AI 后端无需重复安装;新环境仍按上面的安装/启用流程处理。React 前端在 ruoyi-fastapi-frontend/react/web 内执行 pnpm install --frozen-lockfile 和 pnpm build。 + +菜单组件值保持 plugin/ai/model/index、plugin/ai/chat/index,后端结构检查根据宿主框架验证 React 的 .jsx 页面或 Vue 的 .vue 页面。依赖检查、安装计划和锁文件只使用所选框架的分类,React 不会安装 markstream-vue 或 Vue 专属构建插件。 + +正文和思考过程继续通过 /ai/chat/send 返回逐行 JSON(响应媒体类型沿用 text/event-stream),事件包括 meta/session_id、run_info/run_id、content、reasoning、metrics 和 error。会话与配置接口使用 camelCase;Markdown/代码/公式原文交由前端渲染,markstream-react 不需要 Python 对应包或数据库变更。 + ## 数据库 安装插件时会执行当前数据库类型对应的脚本: diff --git a/ruoyi-fastapi-backend/plugins/ai/plugin.yaml b/ruoyi-fastapi-backend/plugins/ai/plugin.yaml index 9355bdc51..323014761 100644 --- a/ruoyi-fastapi-backend/plugins/ai/plugin.yaml +++ b/ruoyi-fastapi-backend/plugins/ai/plugin.yaml @@ -111,6 +111,14 @@ dependencies: - stream-monaco==0.0.48 npmDev: - vite-plugin-monaco-editor-esm==2.0.2 + react: + npm: + - '@ant-design/x==2.9.0' + - katex^0.19.0 + - markstream-react==2.0.17 + - mermaid^12.1.0 + - stream-diffs==0.0.2 + npmDev: [] plugins: [] compatibility: diff --git a/ruoyi-fastapi-backend/plugins/core/manifest/menu_tree.py b/ruoyi-fastapi-backend/plugins/core/manifest/menu_tree.py index 5f557e963..f3cfdb860 100644 --- a/ruoyi-fastapi-backend/plugins/core/manifest/menu_tree.py +++ b/ruoyi-fastapi-backend/plugins/core/manifest/menu_tree.py @@ -83,17 +83,21 @@ def is_plugin_component(component: str) -> bool: return component.startswith('plugin/') @staticmethod - def resolve_plugin_view_path(manifest: 'PluginManifest', component: str) -> Path | None: + def resolve_plugin_view_path( + manifest: 'PluginManifest', component: str, frontend_framework: str = 'vue3' + ) -> Path | None: """ 将插件组件路径解析为前端插件内视图路径。 :param manifest: 插件清单 :param component: 菜单组件路径 - :return: Vue 文件相对路径 + :param frontend_framework: 宿主框架,React 对应 JSX,其余保留 Vue 页面约定 + :return: 页面文件相对路径 """ parts = component.split('/') plugin_id = manifest.frontend.plugin_id or manifest.id if len(parts) < MIN_PLUGIN_COMPONENT_PARTS or parts[0] != 'plugin' or parts[1] != plugin_id: return None - return Path(manifest.frontend.views_path, *parts[2:]).with_suffix('.vue') + suffix = '.jsx' if frontend_framework == 'react' else '.vue' + return Path(manifest.frontend.views_path, *parts[2:]).with_suffix(suffix) diff --git a/ruoyi-fastapi-backend/plugins/core/validation/structure.py b/ruoyi-fastapi-backend/plugins/core/validation/structure.py index afaea4278..e1b604790 100644 --- a/ruoyi-fastapi-backend/plugins/core/validation/structure.py +++ b/ruoyi-fastapi-backend/plugins/core/validation/structure.py @@ -111,10 +111,12 @@ def __init__(self, backend_root: Path | str, frontend_root: Path | str | None = :param frontend_root: 前端插件根目录,默认使用运行时环境解析出的 plugins 目录 """ self.backend_root = Path(backend_root) + self.runtime_environment = PluginRuntimeEnvironmentService( + backend_root=self.backend_root, + frontend_root=Path(frontend_root).parent if frontend_root else None, + ) self.frontend_root = ( - Path(frontend_root) - if frontend_root - else Path(PluginRuntimeEnvironmentService(backend_root=self.backend_root).get_frontend_plugins_dir()) + Path(frontend_root) if frontend_root else Path(self.runtime_environment.get_frontend_plugins_dir()) ) def check( @@ -693,7 +695,22 @@ def _check_frontend(self, discovered_plugin: DiscoveredPlugin) -> list[PluginStr items = [self._check_dir('frontend_root', frontend_plugin_root)] items.append(self._check_dir('frontend_views_path', frontend_plugin_root / manifest.frontend.views_path)) items.append(self._check_dir('frontend_api_path', frontend_plugin_root / manifest.frontend.api_path)) - items.extend(self._check_plugin_view(frontend_plugin_root, manifest, menu) for menu in plugin_view_menus) + try: + frontend_framework = self.runtime_environment.get_frontend_framework() + except ValueError as exc: + # 旧版独立源码目录可能只有 plugins,没有 package.json;保留原有 Vue 检查。 + if (self.frontend_root.parent / 'package.json').is_file(): + items.append( + PluginStructureCheckItem( + kind='frontend_framework', path=str(self.frontend_root.parent), ok=False, message=str(exc) + ) + ) + return items + frontend_framework = 'vue3' + items.extend( + self._check_plugin_view(frontend_plugin_root, manifest, menu, frontend_framework) + for menu in plugin_view_menus + ) return items @@ -702,16 +719,18 @@ def _check_plugin_view( frontend_plugin_root: Path, manifest: PluginManifest, menu: PluginMenuManifest, + frontend_framework: str = 'vue3', ) -> PluginStructureCheckItem: """ - 检查插件菜单组件对应的 Vue 页面。 + 检查插件菜单组件对应的宿主页面。 :param frontend_plugin_root: 前端插件根目录 :param manifest: 插件清单 :param menu: 菜单声明 + :param frontend_framework: 宿主框架标识 :return: 页面检查项 """ - view_path = PluginMenuTree.resolve_plugin_view_path(manifest, menu.component) + view_path = PluginMenuTree.resolve_plugin_view_path(manifest, menu.component, frontend_framework) if view_path is None: return PluginStructureCheckItem( kind='frontend_view', diff --git a/ruoyi-fastapi-backend/tests/cli/root/test_contract_app_ops.py b/ruoyi-fastapi-backend/tests/cli/root/test_contract_app_ops.py index ce6875bdb..a2af1271b 100644 --- a/ruoyi-fastapi-backend/tests/cli/root/test_contract_app_ops.py +++ b/ruoyi-fastapi-backend/tests/cli/root/test_contract_app_ops.py @@ -538,20 +538,20 @@ def test_plugin_create_frontend_framework_override_json_output_has_stable_contra def test_plugin_create_rejects_framework_outside_fixed_choices( run_cli_command: Callable[..., subprocess.CompletedProcess[str]], ) -> None: - """校验根命令在参数解析阶段拒绝不属于固定选项的 React 框架。""" + """校验根命令在参数解析阶段拒绝不属于固定选项的 unknown 框架。""" completed = run_cli_command( 'plugin', 'create', - 'contract_react', + 'contract_unknown', '--dry-run', '--frontend-only', - '--frontend-framework=react', + '--frontend-framework=unknown', '--env=dev', '--output=json', ) assert completed.returncode == ARGUMENT_ERROR assert 'Invalid value' in completed.stderr - assert 'react' in completed.stderr + assert 'unknown' in completed.stderr def test_plugin_create_rejects_removed_frontend_version_option( diff --git a/ruoyi-fastapi-backend/tests/cli/root/test_plugin_create_templates.py b/ruoyi-fastapi-backend/tests/cli/root/test_plugin_create_templates.py index d2ea5210e..07af5a9a6 100644 --- a/ruoyi-fastapi-backend/tests/cli/root/test_plugin_create_templates.py +++ b/ruoyi-fastapi-backend/tests/cli/root/test_plugin_create_templates.py @@ -52,7 +52,7 @@ def create_plugin(plugin_id: str, env: str, output: str, options: PluginCreateCo assert recorded == [PluginCreateCommandOptions()] -@pytest.mark.parametrize('framework', ['auto', 'vue2', 'vue3']) +@pytest.mark.parametrize('framework', ['auto', 'vue2', 'vue3', 'react']) def test_create_cli_accepts_fixed_frontend_framework_choices(framework: str) -> None: """校验创建命令接受固定框架选项并将原始字符串传递给控制器。""" recorded: list[PluginCreateCommandOptions] = [] @@ -69,7 +69,7 @@ def create_plugin(plugin_id: str, env: str, output: str, options: PluginCreateCo assert recorded[0].frontend_framework == framework -@pytest.mark.parametrize('framework', ['react', 'custom_ui-1', '../react', 'Vue3']) +@pytest.mark.parametrize('framework', ['custom_ui-1', '../react', 'Vue3']) def test_create_cli_rejects_framework_before_resolving_controller(framework: str) -> None: """校验非法框架选项在加载控制器或运行时之前被拒绝。""" @@ -87,9 +87,9 @@ def unavailable_controller() -> None: def test_create_cli_help_displays_fixed_frontend_framework_choices() -> None: - """校验创建命令帮助展示固定的三种框架选项。""" + """校验创建命令帮助展示固定的四种框架选项。""" app = typer.Typer() register_developer_commands(app, lambda: None) result = CliRunner().invoke(app, ['create', '--help'], terminal_width=160) assert result.exit_code == SUCCESS - assert 'auto|vue2|vue3' in result.output + assert 'auto|vue2|vue3|react' in result.output diff --git a/ruoyi-fastapi-backend/tests/cli/root/test_plugin_frontend_environment.py b/ruoyi-fastapi-backend/tests/cli/root/test_plugin_frontend_environment.py index 6c52f877b..f3ce6adfa 100644 --- a/ruoyi-fastapi-backend/tests/cli/root/test_plugin_frontend_environment.py +++ b/ruoyi-fastapi-backend/tests/cli/root/test_plugin_frontend_environment.py @@ -13,7 +13,7 @@ SDK_FILES = ('pluginBridge.js', 'pluginBridge.d.ts', 'pluginBridge.sdk.json') -@pytest.mark.parametrize('framework', ['vue2', 'vue3']) +@pytest.mark.parametrize('framework', ['vue2', 'vue3', 'react']) def test_cold_cli_preserves_frontend_framework_across_scaffold_sdk_and_dependency_lock( tmp_path: Path, backend_dir: Path, @@ -28,12 +28,19 @@ def test_cold_cli_preserves_frontend_framework_across_scaffold_sdk_and_dependenc """ backend = tmp_path / 'backend' frontend_container = tmp_path / 'ruoyi-fastapi-frontend' - for host_framework in ('vue2', 'vue3'): + for host_framework in ('vue2', 'vue3', 'react'): frontend = frontend_container / host_framework / 'web' source = frontend / 'src' / 'utils' source.mkdir(parents=True) (frontend / 'package.json').write_text( - json.dumps({'dependencies': {'vue': f'^{host_framework[-1]}.0.0'}}), encoding='utf-8' + json.dumps( + { + 'dependencies': {'react': '^19.2.0'} + if host_framework == 'react' + else {'vue': f'^{host_framework[-1]}.0.0'} + } + ), + encoding='utf-8', ) original_source = backend_dir.parent / 'ruoyi-fastapi-frontend' / host_framework / 'web' / 'src' / 'utils' for name in SDK_FILES: @@ -51,6 +58,7 @@ def test_cold_cli_preserves_frontend_framework_across_scaffold_sdk_and_dependenc 'frontend': { 'vue2': {'npm': ['vue2-renderer==2.0.0']}, 'vue3': {'npm': ['vue3-renderer==3.0.0']}, + 'react': {'npm': ['react-renderer==19.0.0']}, } }, } @@ -85,6 +93,8 @@ def test_cold_cli_preserves_frontend_framework_across_scaffold_sdk_and_dependenc scaffold = json.loads(created.stdout) selected_frontend = frontend_container / framework / 'web' assert scaffold['frontendFramework'] == framework + suffix = '.jsx' if framework == 'react' else '.vue' + assert any(Path(item['path']).name == f'index{suffix}' for item in scaffold['files']) assert str(backend / 'plugins' / 'environment_scaffold') in scaffold['targetDirs'] assert str(selected_frontend / 'plugins' / 'environment_scaffold') in scaffold['targetDirs'] assert all(Path(item['path']).is_relative_to(tmp_path) for item in scaffold['files']) @@ -97,7 +107,8 @@ def test_cold_cli_preserves_frontend_framework_across_scaffold_sdk_and_dependenc lockfile = yaml.safe_load(lock_payload['lockfile']) assert Path(lock_payload['outputFile']) == plugin / 'plugin.lock.yaml' assert lockfile['frontendFramework'] == framework - assert [item['requirement'] for item in lockfile['npm']] == [f'{framework}-renderer=={framework[-1]}.0.0'] + renderer_version = '19' if framework == 'react' else framework[-1] + assert [item['requirement'] for item in lockfile['npm']] == [f'{framework}-renderer=={renderer_version}.0.0'] assert lock_payload['written'] is False assert original_files == {path: path.read_bytes() for path in tmp_path.rglob('*') if path.is_file()} diff --git a/ruoyi-fastapi-backend/tests/cli/root/test_plugin_sdk_commands.py b/ruoyi-fastapi-backend/tests/cli/root/test_plugin_sdk_commands.py index ecf760f3f..1bbdda39c 100644 --- a/ruoyi-fastapi-backend/tests/cli/root/test_plugin_sdk_commands.py +++ b/ruoyi-fastapi-backend/tests/cli/root/test_plugin_sdk_commands.py @@ -93,11 +93,13 @@ def test_sdk_cli_bad_source_is_json_and_nonzero(project: Path, tmp_path: Path) - assert json.loads(result.stdout)['ok'] is False -@pytest.mark.parametrize(('framework', 'selected_framework'), [('auto', 'vue3'), ('vue2', 'vue2'), ('vue3', 'vue3')]) +@pytest.mark.parametrize( + ('framework', 'selected_framework'), [('auto', 'vue3'), ('vue2', 'vue2'), ('vue3', 'vue3'), ('react', 'react')] +) def test_sdk_cli_selects_supported_framework_from_frontend_container( project: Path, framework: str, selected_framework: str ) -> None: - """校验 SDK 更新和检查命令均接受三个固定框架选项。""" + """校验 SDK 更新和检查命令均接受四个固定框架选项。""" result = CliRunner().invoke( sdk_app(), [ @@ -135,7 +137,7 @@ def test_sdk_cli_selects_supported_framework_from_frontend_container( @pytest.mark.parametrize('operation', ['check', 'update']) -@pytest.mark.parametrize('framework', ['react', 'custom_ui-1', '../react', 'Vue3']) +@pytest.mark.parametrize('framework', ['custom_ui-1', '../react', 'Vue3']) def test_sdk_cli_rejects_unsupported_framework_before_file_operations( project: Path, operation: str, framework: str ) -> None: @@ -165,7 +167,7 @@ def test_sdk_cli_help_displays_fixed_frontend_framework_choices(operation: str) """校验两个 SDK 命令的帮助均展示固定框架选项。""" result = CliRunner().invoke(sdk_app(), ['sdk', operation, '--help'], terminal_width=160) assert result.exit_code == SUCCESS - assert 'auto|vue2|vue3' in result.output + assert 'auto|vue2|vue3|react' in result.output def test_sdk_cli_rejects_removed_frontend_version_option(project: Path) -> None: diff --git a/ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_react_scaffold.py b/ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_react_scaffold.py new file mode 100644 index 000000000..9f9e397e9 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_react_scaffold.py @@ -0,0 +1,93 @@ +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +from cli.runtime.plugin.service import CliPluginRuntimeService +from plugins.core.environment import PluginRuntimeEnvironmentService + +BACKEND_ROOT = Path(__file__).resolve().parents[4] +REACT_ROOT = BACKEND_ROOT.parent / 'ruoyi-fastapi-frontend' / 'react' / 'web' + + +@pytest.mark.parametrize('template', ['full-stack', 'crud-page']) +@pytest.mark.parametrize('selection', ['explicit', 'package', 'environment']) +def test_react_scaffold_generates_checkable_files_and_route_test( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, template: str, selection: str +) -> None: + """生成实际插件并通过后端检查,Node 执行生成的路由测试。""" + backend = tmp_path / 'backend' + container = tmp_path / 'ruoyi-fastapi-frontend' + frontend = tmp_path / 'host' if selection == 'package' else container / 'react' / 'web' + frontend.mkdir(parents=True) + (frontend / 'package.json').write_text( + json.dumps({'type': 'module', 'dependencies': {'react': '19.2.0', 'antd': '6.6.5'}}), encoding='utf-8' + ) + resolver = frontend / 'src' / 'utils' / 'pluginViewResolver.js' + resolver.parent.mkdir(parents=True) + resolver.write_bytes((REACT_ROOT / 'src' / 'utils' / 'pluginViewResolver.js').read_bytes()) + for name in ('RUOYI_PLUGIN_FRONTEND_FRAMEWORK', 'RUOYI_FRONTEND_FRAMEWORK'): + monkeypatch.delenv(name, raising=False) + if selection == 'environment': + monkeypatch.setenv('RUOYI_PLUGIN_FRONTEND_FRAMEWORK', 'react') + environment = PluginRuntimeEnvironmentService( + backend_root=backend, frontend_root=frontend if selection == 'package' else container + ) + runtime = CliPluginRuntimeService(runtime_environment=environment) + kwargs = {'frontend_framework': 'react'} if selection == 'explicit' else {} + preview = runtime.create_plugin('react_demo', template=template, dry_run=True, **kwargs) + assert preview['ok'] is True + assert preview['frontendFramework'] == 'react' + assert not (backend / 'plugins' / 'react_demo').exists() + assert not (frontend / 'plugins' / 'react_demo').exists() + + created = runtime.create_plugin('react_demo', template=template, **kwargs) + assert created['ok'] is True + view = frontend / 'plugins' / 'react_demo' / 'views' / 'index.jsx' + api = frontend / 'plugins' / 'react_demo' / 'api' / 'react_demo.js' + assert view.is_file() and api.is_file() + assert not view.with_suffix('.vue').exists() + assert not (container / 'vue3' / 'web' / 'plugins' / 'react_demo').exists() + assert 'index.jsx' in (backend / 'plugins' / 'react_demo' / 'README.md').read_text(encoding='utf-8') + assert 'React' in (frontend / 'plugins' / 'react_demo' / 'README.md').read_text(encoding='utf-8') + + # 检查命令使用选中的实际 React 工程。 + react_runtime = CliPluginRuntimeService( + runtime_environment=PluginRuntimeEnvironmentService(backend_root=backend, frontend_root=frontend) + ) + checked = react_runtime.core_runtime.check_plugin('react_demo') + assert checked['ok'] is True, checked + assert checked['checks'][0]['structureErrors'] == [] + before = {path: path.read_bytes() for path in frontend.rglob('*') if path.is_file()} + conflict = runtime.create_plugin('react_demo', template=template, **kwargs) + assert conflict['ok'] is False + assert before == {path: path.read_bytes() for path in frontend.rglob('*') if path.is_file()} + + node = shutil.which('node') + if node: + result = subprocess.run( + [node, '--test', str(frontend / 'tests/plugins/react_demo/pluginView.test.js')], + cwd=frontend, + capture_output=True, + text=True, + encoding='utf-8', + check=False, + timeout=30, + ) + assert result.returncode == 0, result.stdout + result.stderr + + +def test_react_frontend_only_respects_no_test_option(tmp_path: Path) -> None: + """只生成前端时不创建后端插件,关闭测试开关不留下测试目录。""" + backend = tmp_path / 'backend' + frontend = tmp_path / 'ruoyi-fastapi-frontend' / 'react' / 'web' + runtime = CliPluginRuntimeService( + runtime_environment=PluginRuntimeEnvironmentService(backend_root=backend, frontend_root=frontend) + ) + result = runtime.create_plugin('frontend_demo', template='crud-page', backend=False, test=False) + assert result['ok'] is True + assert (frontend / 'plugins/frontend_demo/views/index.jsx').is_file() + assert not (backend / 'plugins').exists() + assert not (frontend / 'tests').exists() diff --git a/ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_scaffold.py b/ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_scaffold.py index 73f00ec90..c44921f20 100644 --- a/ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_scaffold.py +++ b/ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_scaffold.py @@ -206,7 +206,7 @@ def test_backend_only_scaffold_does_not_require_frontend_framework(tmp_path: Pat assert payload['frontendFramework'] is None -@pytest.mark.parametrize('framework', ['vue4', 'react', 'custom_ui-1']) +@pytest.mark.parametrize('framework', ['vue4', 'custom_ui-1']) def test_plugin_runtime_create_plugin_rejects_unsupported_frontend_template(tmp_path: Path, framework: str) -> None: """校验合法但没有源码模板的框架会返回清晰错误且不写文件。""" backend_root = tmp_path / 'project' / 'api-server' diff --git a/ruoyi-fastapi-backend/tests/module_generator/test_react_templates.py b/ruoyi-fastapi-backend/tests/module_generator/test_react_templates.py new file mode 100644 index 000000000..e70d5d1b3 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/module_generator/test_react_templates.py @@ -0,0 +1,78 @@ +"""React templates preserve the generator's CRUD, tree, child-table and time contracts.""" + +import json +import re + +import pytest +from jinja2 import StrictUndefined + +from test_generated_time_contract import make_table, render_case +from utils.template_util import TemplateInitializer, TemplateUtils + + +@pytest.mark.parametrize('db_type', ['mysql', 'postgresql']) +@pytest.mark.parametrize('category', ['crud', 'tree', 'sub']) +def test_react_templates_render_all_business_files(db_type: str, category: str) -> None: + table = make_table(db_type, category, 'react-antd') + rendered = render_case(db_type, category, 'react-antd') + files = {TemplateUtils.get_file_name(name, table): text for name, text in rendered.items()} + assert '' not in files + assert len(files) == len(rendered) + assert {name for name in files if name.startswith('frontend/')} == { + 'frontend/api/test/item.js', + 'frontend/views/test/item/index.jsx', + 'frontend/views/test/item/view.jsx', + } + page = files['frontend/views/test/item/index.jsx'] + assert 'serializeTimeFieldsForSubmit' in page + assert 'prepareTimeFields' in page + assert 'BusinessDateTimePicker' in page + assert 'beginBirthday' in page + assert 'beginOpeningTime' in page + assert 'test:item:edit' in page + assert 'ViewDrawer' in page + assert '