diff --git a/.github/scripts/check_compose.py b/.github/scripts/check_compose.py index 2cc2c8352..318d93fcf 100644 --- a/.github/scripts/check_compose.py +++ b/.github/scripts/check_compose.py @@ -45,14 +45,14 @@ def validate_configuration(config: dict[str, Any], framework: str, engine: str, def main() -> None: """ - 使用两种框架解析全部生产和测试 Compose,无需启动容器。 + 使用全部框架解析生产和测试 Compose,无需启动容器。 :return: None """ parser = argparse.ArgumentParser() parser.add_argument('--docker', default='docker') args = parser.parse_args() - for framework in ('vue2', 'vue3'): + for framework in ('vue2', 'vue3', 'react'): for env_file, compose_file, engine in CONFIGURATIONS: result = subprocess.run( [args.docker, 'compose', '--env-file', env_file, '-f', compose_file, 'config', '--format', 'json'], diff --git a/.github/scripts/ci_plan.py b/.github/scripts/ci_plan.py index 28acc9356..bbc43e9ce 100644 --- a/.github/scripts/ci_plan.py +++ b/.github/scripts/ci_plan.py @@ -6,7 +6,7 @@ from typing import Any ROOT = Path(__file__).resolve().parents[2] -FRAMEWORKS = ('vue2', 'vue3') +FRAMEWORKS = ('vue2', 'vue3', 'react') PYTHON_VERSIONS = ('3.10', '3.11', '3.12', '3.13') CATEGORIES = ('quality', 'backend', 'frontend', 'integration', 'e2e', 'native') FRONTEND_FILE_PATH_PARTS = 4 diff --git a/.github/scripts/mobile_smoke.py b/.github/scripts/mobile_smoke.py index 1530bb89f..86cd63050 100644 --- a/.github/scripts/mobile_smoke.py +++ b/.github/scripts/mobile_smoke.py @@ -45,7 +45,7 @@ async def api(route: Route) -> None: :return: None """ request = route.request - path = urlparse(request.url).path + path = urlparse(request.url).path.removeprefix('/dev-api') headers = {'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': '*'} if request.method == 'OPTIONS': await route.fulfill(status=204, headers=headers) @@ -100,7 +100,7 @@ async def run_smoke(framework: str, base_url: str, output: Path) -> None: """ 使用真实 H5 构建验证登录、持久化身份和个人资料页。 - :param framework: vue2 或 vue3 + :param framework: vue2、vue3 或 react :param base_url: 仅用于本次测试的静态文件服务器 :param output: 浏览器报告目录 :return: None @@ -118,6 +118,7 @@ async def run_smoke(framework: str, base_url: str, output: Path) -> None: context = await browser.new_context(viewport={'width': 390, 'height': 844}, timezone_id='UTC') await context.tracing.start(screenshots=True, snapshots=True, sources=True) await context.route('http://localhost:9099/**', api) + await context.route(f'{base_url}/dev-api/**', api) page = await context.new_page() page.on('pageerror', lambda error: errors.append(str(error))) page.on('console', lambda message: logs.append(f'{message.type}: {message.text}')) @@ -128,7 +129,8 @@ async def run_smoke(framework: str, base_url: str, output: Path) -> None: await expect(page.locator('input')).to_have_count(2, timeout=15000) await page.locator('input[type="text"]').fill('ci-user') await page.locator('input[type="password"]').fill('ci-test-password') - await page.locator('uni-button').filter(has_text=re.compile(r'登\s*录')).click() + button = page.get_by_role('button') if framework == 'react' else page.locator('uni-button') + await button.filter(has_text=re.compile(r'登\s*录')).click() await expect(page).to_have_url(re.compile(r'/pages/index'), timeout=30000) await page.goto(f'{base_url}/#/pages/mine/info/index') await expect(page.get_by_text(USER['nickName'], exact=True)).to_be_visible(timeout=30000) @@ -162,9 +164,10 @@ def main() -> None: :return: None """ parser = argparse.ArgumentParser() - parser.add_argument('--framework', choices=('vue2', 'vue3'), required=True) + parser.add_argument('--framework', choices=('vue2', 'vue3', 'react'), required=True) args = parser.parse_args() - distribution = ROOT / 'ruoyi-fastapi-frontend' / args.framework / 'mobile/dist/build/h5' + build_path = 'dist/h5' if args.framework == 'react' else 'dist/build/h5' + distribution = ROOT / 'ruoyi-fastapi-frontend' / args.framework / 'mobile' / build_path if not (distribution / 'index.html').is_file(): raise SystemExit(f'请先构建 H5:{distribution}') output = ROOT / 'target/ci/mobile-smoke' / args.framework diff --git a/.github/tests/test_ci_contracts.py b/.github/tests/test_ci_contracts.py index 8e52c66db..31a41eeb8 100644 --- a/.github/tests/test_ci_contracts.py +++ b/.github/tests/test_ci_contracts.py @@ -1,4 +1,5 @@ import importlib.util +import json from copy import deepcopy from pathlib import Path from types import ModuleType @@ -43,9 +44,30 @@ def configuration() -> dict[str, Any]: } -def test_compose_accepts_consistent_framework(configuration: dict[str, Any]) -> None: +@pytest.mark.parametrize('framework', ['vue2', 'vue3', 'react']) +def test_compose_accepts_consistent_framework(configuration: dict[str, Any], framework: str) -> None: """框架一致的 Compose 配置可以通过校验。""" - compose.validate_configuration(configuration, 'vue2', 'my', ROOT) + frontend = ROOT / 'ruoyi-fastapi-frontend' / framework / 'web' + configuration['services']['ruoyi-frontend']['build']['context'] = str(frontend) + configuration['services']['ruoyi-frontend']['volumes'][0]['source'] = str(frontend / 'bin/nginx.dockermy.conf') + configuration['services']['ruoyi-backend-my']['environment']['RUOYI_PLUGIN_FRONTEND_FRAMEWORK'] = framework + compose.validate_configuration(configuration, framework, 'my', ROOT) + + +@pytest.mark.parametrize('framework', ['vue2', 'vue3', 'react']) +@pytest.mark.parametrize('target', ['web', 'mobile']) +def test_frontend_ci_commands_exist_in_every_project(framework: str, target: str) -> None: + """矩阵中的工程必须实际提供工作流调用的测试和构建命令。""" + directory = ROOT / 'ruoyi-fastapi-frontend' / framework / target + package = json.loads((directory / 'package.json').read_text(encoding='utf-8')) + required = {'test:unit', 'test:contract'} + required.update({'build:prod', 'build:docker'} if target == 'web' else {'build:h5', 'build:mp-weixin'}) + if framework == 'react': + assert package['packageManager'].startswith('pnpm@') + assert (directory / 'pnpm-workspace.yaml').is_file() + if target == 'mobile': + required.update({'check:platforms', 'build:rn:android', 'build:rn:ios'}) + assert required <= package['scripts'].keys() @pytest.mark.parametrize('component', ['build', 'nginx', 'backend']) diff --git a/.github/tests/test_ci_plan.py b/.github/tests/test_ci_plan.py index fe548e3b4..2913083ab 100644 --- a/.github/tests/test_ci_plan.py +++ b/.github/tests/test_ci_plan.py @@ -7,9 +7,9 @@ SPEC = importlib.util.spec_from_file_location('ci_plan', SCRIPT) ci = importlib.util.module_from_spec(SPEC) SPEC.loader.exec_module(ci) -FRONTEND_PROJECT_COUNT = 4 -STANDARD_E2E_COMBINATION_COUNT = 4 -FULL_E2E_COMBINATION_COUNT = 16 +FRONTEND_PROJECT_COUNT = 6 +STANDARD_E2E_COMBINATION_COUNT = 6 +FULL_E2E_COMBINATION_COUNT = 24 def test_readme_change_only_runs_quality() -> None: @@ -48,11 +48,12 @@ def test_native_builder_change_runs_native_verification() -> None: assert plan['required']['native'] -def test_bundle_example_change_runs_both_web_consumers() -> None: - """示例开发代理由两版 Web 测试直接引用,变更时均应执行。""" +def test_bundle_example_change_runs_all_web_consumers() -> None: + """示例开发代理由全部 Web 测试直接引用,变更时均应执行。""" plan = ci.build_plan(['ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/dev/mockRequest.js']) assert plan['frontend_matrix'] == { 'include': [ + {'framework': 'react', 'target': 'web'}, {'framework': 'vue2', 'target': 'web'}, {'framework': 'vue3', 'target': 'web'}, ] @@ -67,12 +68,12 @@ def test_bundle_example_change_runs_both_web_consumers() -> None: 'ruoyi-fastapi-backend/tests/plugins/core/runtime/test_browser_session.py', ], ) -def test_shared_auth_and_test_helpers_keep_both_network_suites(path: str) -> None: - """共享鉴权和测试夹具变更触发两版网络验收。""" +def test_shared_auth_and_test_helpers_keep_all_network_suites(path: str) -> None: + """共享鉴权和测试夹具变更触发全部框架的网络验收。""" plan = ci.build_plan([path]) assert plan['required']['backend'] assert plan['required']['integration'] - assert plan['network_frameworks'] == ['vue2', 'vue3'] + assert plan['network_frameworks'] == ['react', 'vue2', 'vue3'] @pytest.mark.parametrize( @@ -86,11 +87,13 @@ def test_ci_and_dependency_changes_select_every_category(path: str) -> None: assert len(plan['e2e_matrix']['include']) == STANDARD_E2E_COMBINATION_COUNT -def test_mobile_change_does_not_build_other_projects_or_web_e2e() -> None: +@pytest.mark.parametrize('framework', ci.FRAMEWORKS) +def test_mobile_change_does_not_build_other_projects_or_web_e2e(framework: str) -> None: """单个移动端变更只选中对应工程。""" - plan = ci.build_plan(['ruoyi-fastapi-frontend/vue2/mobile/src/utils/request.js']) - assert plan['frontend_matrix'] == {'include': [{'framework': 'vue2', 'target': 'mobile'}]} + plan = ci.build_plan([f'ruoyi-fastapi-frontend/{framework}/mobile/src/utils/request.js']) + assert plan['frontend_matrix'] == {'include': [{'framework': framework, 'target': 'mobile'}]} assert not plan['required']['e2e'] + assert not plan['required']['native'] @pytest.mark.parametrize( @@ -100,12 +103,13 @@ def test_mobile_change_does_not_build_other_projects_or_web_e2e() -> None: 'ruoyi-fastapi-test/frontend/mobile-behavior.mjs', ], ) -def test_shared_mobile_harness_runs_both_mobile_projects(path: str) -> None: - """共享移动端测试工具变更同时验证两版工程。""" +def test_shared_mobile_harness_runs_all_mobile_projects(path: str) -> None: + """共享移动端测试工具变更同时验证全部移动工程。""" plan = ci.build_plan([path]) assert plan['required']['frontend'] assert plan['frontend_matrix'] == { 'include': [ + {'framework': 'react', 'target': 'mobile'}, {'framework': 'vue2', 'target': 'mobile'}, {'framework': 'vue3', 'target': 'mobile'}, ] @@ -113,13 +117,15 @@ def test_shared_mobile_harness_runs_both_mobile_projects(path: str) -> None: assert not plan['required']['e2e'] -def test_web_change_selects_matching_framework_and_both_databases() -> None: +@pytest.mark.parametrize('framework', ci.FRAMEWORKS) +def test_web_change_selects_matching_framework_and_both_databases(framework: str) -> None: """Web 变更使用对应框架并覆盖两类数据库。""" - plan = ci.build_plan(['ruoyi-fastapi-frontend/vue2/web/src/views/login.vue']) - assert plan['network_frameworks'] == ['vue2'] + plan = ci.build_plan([f'ruoyi-fastapi-frontend/{framework}/web/src/views/login.jsx']) + assert plan['frontend_matrix'] == {'include': [{'framework': framework, 'target': 'web'}]} + assert plan['network_frameworks'] == [framework] assert {(row['framework'], row['database'], row['python']) for row in plan['e2e_matrix']['include']} == { - ('vue2', 'mysql', '3.12'), - ('vue2', 'postgresql', '3.12'), + (framework, 'mysql', '3.12'), + (framework, 'postgresql', '3.12'), } diff --git a/.github/workflows/ci-e2e.yml b/.github/workflows/ci-e2e.yml index f3ef383a3..169fade73 100644 --- a/.github/workflows/ci-e2e.yml +++ b/.github/workflows/ci-e2e.yml @@ -83,7 +83,7 @@ jobs: docker compose up --detach --no-build --wait --wait-timeout 240 echo "TEST_FRONTEND_URL=http://$(docker compose port ruoyi-frontend 80)" >> "$GITHUB_ENV" echo "TEST_BACKEND_URL=http://$(docker compose port ruoyi-backend-${{ steps.database.outputs.suffix }} 9099)" >> "$GITHUB_ENV" - - name: Run all application pages and functional E2E checks + - name: Run application pages and full functional E2E checks working-directory: ruoyi-fastapi-test run: | python -m pytest -m e2e -v --durations=20 --junitxml=target/e2e/junit.xml diff --git a/.github/workflows/ci-frontend.yml b/.github/workflows/ci-frontend.yml index 68cf449ac..058055cd2 100644 --- a/.github/workflows/ci-frontend.yml +++ b/.github/workflows/ci-frontend.yml @@ -8,7 +8,7 @@ on: required: true type: string full: - description: 'Reserved for callers requesting the full project matrix' + description: 'Also build every additional React Mobile mini-program and Harmony target' required: false default: false type: boolean @@ -20,7 +20,7 @@ jobs: frontend: name: ${{ matrix.framework }} / ${{ matrix.target }} runs-on: ubuntu-latest - timeout-minutes: 35 + timeout-minutes: ${{ matrix.framework == 'react' && matrix.target == 'mobile' && 60 || 35 }} strategy: fail-fast: false matrix: ${{ fromJSON(inputs.matrix_json) }} @@ -28,6 +28,7 @@ jobs: NODE_OPTIONS: ${{ matrix.framework == 'vue2' && '--openssl-legacy-provider' || '' }} defaults: run: + shell: bash working-directory: ruoyi-fastapi-frontend/${{ matrix.framework }}/${{ matrix.target }} steps: - uses: actions/checkout@v5 @@ -38,16 +39,18 @@ jobs: if: matrix.framework == 'vue2' && matrix.target == 'mobile' run: npm install --global yarn@1.22.22 - uses: pnpm/action-setup@v5 - if: matrix.framework == 'vue3' && matrix.target == 'mobile' + if: matrix.framework == 'react' || (matrix.framework == 'vue3' && matrix.target == 'mobile') with: - package_json_file: ruoyi-fastapi-frontend/vue3/mobile/package.json + package_json_file: ruoyi-fastapi-frontend/${{ matrix.framework }}/${{ matrix.target }}/package.json - name: Locate the package cache id: package-cache env: FRAMEWORK: ${{ matrix.framework }} TARGET: ${{ matrix.target }} run: | - if [ "$TARGET" = web ]; then + if [ "$FRAMEWORK" = react ]; then + echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + elif [ "$TARGET" = web ]; then echo "path=$(npm config get cache)" >> "$GITHUB_OUTPUT" elif [ "$FRAMEWORK" = vue2 ]; then echo "path=$(yarn cache dir --silent)" >> "$GITHUB_OUTPUT" @@ -58,17 +61,17 @@ jobs: with: path: ${{ steps.package-cache.outputs.path }} # Lockfiles are intentionally not tracked in these projects. - key: ${{ runner.os }}-node22-${{ matrix.framework }}-${{ matrix.target }}-${{ hashFiles(format('ruoyi-fastapi-frontend/{0}/{1}/package.json', matrix.framework, matrix.target)) }} + key: ${{ runner.os }}-node22-${{ matrix.framework }}-${{ matrix.target }}-${{ hashFiles(format('ruoyi-fastapi-frontend/{0}/{1}/package.json', matrix.framework, matrix.target), format('ruoyi-fastapi-frontend/{0}/{1}/pnpm-workspace.yaml', matrix.framework, matrix.target)) }} restore-keys: | ${{ runner.os }}-node22-${{ matrix.framework }}-${{ matrix.target }}- - name: Install Web dependencies - if: matrix.target == 'web' + if: matrix.target == 'web' && matrix.framework != 'react' run: npm install --no-audit --no-fund --package-lock=false - name: Install Vue 2 Mobile dependencies if: matrix.framework == 'vue2' && matrix.target == 'mobile' run: yarn install --non-interactive - - name: Install Vue 3 Mobile dependencies - if: matrix.framework == 'vue3' && matrix.target == 'mobile' + - name: Install pnpm project dependencies + if: matrix.framework == 'react' || (matrix.framework == 'vue3' && matrix.target == 'mobile') run: pnpm install --no-frozen-lockfile - name: Run unit and component behavior tests run: npm run test:unit @@ -86,8 +89,11 @@ jobs: npm run build:prod npm run build:docker - name: Build Mobile H5 - if: matrix.target == 'mobile' + if: matrix.target == 'mobile' && matrix.framework != 'react' run: npm run build:h5 + - name: Build and verify React Mobile H5 entry + if: matrix.target == 'mobile' && matrix.framework == 'react' + run: pnpm check:platforms h5 - uses: actions/setup-python@v6 if: matrix.target == 'mobile' with: @@ -102,8 +108,22 @@ jobs: working-directory: . run: python .github/scripts/mobile_smoke.py --framework ${{ matrix.framework }} - name: Build Mobile WeChat target - if: matrix.target == 'mobile' + if: matrix.target == 'mobile' && matrix.framework != 'react' run: npm run build:mp-weixin + - name: Build and verify React Mobile WeChat target + if: matrix.target == 'mobile' && matrix.framework == 'react' + run: pnpm check:platforms weapp + - name: Build the additional React Mobile platform matrix + if: inputs.full && matrix.target == 'mobile' && matrix.framework == 'react' + run: pnpm check:platforms alipay swan tt qq jd kwai lark xhs harmony-hybrid + - name: Build Android and iOS JavaScript bundles + if: matrix.target == 'mobile' && matrix.framework == 'react' + run: | + mkdir -p test-results + pnpm build:rn:android --reset-cache 2>&1 | tee test-results/rn-android.log + test -s dist/rn/android/index.bundle + pnpm build:rn:ios --reset-cache 2>&1 | tee test-results/rn-ios.log + test -s dist/rn/ios/main.jsbundle - name: Upload frontend diagnostics if: always() uses: actions/upload-artifact@v6 @@ -112,5 +132,7 @@ jobs: path: | target/ci/mobile-smoke/${{ matrix.framework }}/ ruoyi-fastapi-frontend/${{ matrix.framework }}/${{ matrix.target }}/test-results/ + ruoyi-fastapi-frontend/${{ matrix.framework }}/${{ matrix.target }}/.cache/platform-check/ + include-hidden-files: true if-no-files-found: ignore retention-days: 7 diff --git a/.github/workflows/ci-integration.yml b/.github/workflows/ci-integration.yml index 475b36c82..098b5114e 100644 --- a/.github/workflows/ci-integration.yml +++ b/.github/workflows/ci-integration.yml @@ -204,9 +204,31 @@ jobs: node-version: '22' cache: npm cache-dependency-path: ruoyi-fastapi-frontend/${{ matrix.framework }}/web/package.json + - uses: pnpm/action-setup@v5 + if: matrix.framework == 'react' + with: + package_json_file: ruoyi-fastapi-frontend/react/web/package.json + - name: Locate the React package cache + if: matrix.framework == 'react' + id: react-cache + working-directory: ruoyi-fastapi-frontend/react/web + run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + - name: Cache React dependencies + if: matrix.framework == 'react' + uses: actions/cache@v5 + with: + path: ${{ steps.react-cache.outputs.path }} + key: ${{ runner.os }}-network-react-${{ hashFiles('ruoyi-fastapi-frontend/react/web/package.json', 'ruoyi-fastapi-frontend/react/web/pnpm-workspace.yaml') }} + restore-keys: ${{ runner.os }}-network-react- - run: python -m pip install -r requirements.txt -r requirements-test.txt playwright==1.62.0 - run: python -m playwright install --with-deps chromium - - run: npm --prefix ../ruoyi-fastapi-frontend/${{ matrix.framework }}/web install --no-audit --no-fund --package-lock=false + - name: Install Vue Web dependencies + if: matrix.framework != 'react' + run: npm --prefix ../ruoyi-fastapi-frontend/${{ matrix.framework }}/web install --no-audit --no-fund --package-lock=false + - name: Install React Web dependencies + if: matrix.framework == 'react' + working-directory: ruoyi-fastapi-frontend/react/web + run: pnpm install --no-frozen-lockfile - name: Verify HTTPS proxy, iframe sessions and long connections run: python -m pytest tests/plugins/integration/test_network_smoke.py -m browser --strict-markers -v --junitxml=target/plugin-network-smoke/${{ matrix.framework }}/results.xml - run: python ../.github/scripts/check_test_report.py target/plugin-network-smoke/${{ matrix.framework }}/results.xml diff --git a/.github/workflows/ci-quality.yml b/.github/workflows/ci-quality.yml index a1c0bbf93..60b77705b 100644 --- a/.github/workflows/ci-quality.yml +++ b/.github/workflows/ci-quality.yml @@ -40,11 +40,13 @@ jobs: with: node-version: '22' cache: npm - cache-dependency-path: ruoyi-fastapi-frontend/*/web/package.json - - name: Check Web formatting with the existing project configurations + cache-dependency-path: ruoyi-fastapi-frontend/*/*/package.json + - name: Check frontend formatting run: | - for framework in vue2 vue3; do - (cd "ruoyi-fastapi-frontend/$framework/web" && npx --yes prettier@3.6.2 --check .) + for framework in vue2 vue3 react; do + for target in web mobile; do + (cd "ruoyi-fastapi-frontend/$framework/$target" && npx --yes prettier@3.6.2 --check .) + done done - uses: actions/upload-artifact@v6 if: always() diff --git a/README.md b/README.md index 38ff587bb..8d863fc81 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,8 @@ RuoYi-FastAPI是一套全部开源的快速开发平台,毫无保留给个人及企业免费使用。 -* Web 前端提供 Vue2 + Element UI 和 Vue3 + Element Plus 两套工程,分别基于[RuoYi-Vue](https://github.com/yangzongzhuan/RuoYi-Vue)和[RuoYi-Vue3](https://github.com/yangzongzhuan/RuoYi-Vue3)修改。 -* 移动端提供 uni-app 的 Vue2、Vue3 两套工程,内置 tailwindcss,基于[RuoYi-App](https://github.com/yangzongzhuan/RuoYi-App)项目修改。 +* Web 前端提供 Vue2 + Element UI、Vue3 + Element Plus 和 React + Ant Design 三套工程。Vue2、Vue3 版本分别基于[RuoYi-Vue](https://github.com/yangzongzhuan/RuoYi-Vue)和[RuoYi-Vue3](https://github.com/yangzongzhuan/RuoYi-Vue3)修改。 +* 移动端提供基于 uni-app 的 Vue2、Vue3 和 基于 Taro 的 React 三套工程,内置 Tailwind CSS,Vue2、Vue3 版本基于[RuoYi-App](https://github.com/yangzongzhuan/RuoYi-App)项目修改。 * 后端采用FastAPI、sqlalchemy、MySQL(PostgreSQL)、Redis、OAuth2 & Jwt。 * 权限认证使用OAuth2 & Jwt,支持多终端认证系统。 * 支持加载动态权限菜单,多方式轻松权限控制。 @@ -61,7 +61,7 @@ RuoYi-FastAPI是一套全部开源的快速开发平台,毫无保留给个人 15. 传输加密:支持前后端请求加密、响应解密、公钥轮换、运行策略下发与监控统计。 16. 在线构建器:拖动表单元素生成相应的HTML代码。 17. 系统接口:根据业务代码自动生成相关的api接口文档。 -18. 代码生成:配置数据库表信息一键生成前后端代码(python、sql、vue、js),支持下载。 +18. 代码生成:配置数据库表信息,一键生成前后端代码(Python、SQL、Vue、JavaScript、React JSX)并打包下载。 19. AI管理:提供AI模型管理和AI对话功能。 20. 文件管理:统一管理公开文件和受保护附件,支持访问控制、业务引用保护、操作审计、回收站、保留策略及存储对账。 21. 插件系统:支持插件发现、安装、启停、升级、卸载与清理,提供依赖与配置管理、迁移与种子、定时任务、菜单权限、批量预演、健康诊断、操作审计及前后端插件脚手架。 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_admin/service/test_user_service.py b/ruoyi-fastapi-backend/tests/module_admin/service/test_user_service.py index 9701487a6..adb6ff95b 100644 --- a/ruoyi-fastapi-backend/tests/module_admin/service/test_user_service.py +++ b/ruoyi-fastapi-backend/tests/module_admin/service/test_user_service.py @@ -5,9 +5,47 @@ from common.enums import PasswordCharacterType from exceptions.exception import ServiceException +from module_admin.dao.user_dao import UserDao +from module_admin.entity.vo.user_vo import EditUserModel from module_admin.service.user_service import UserService +@pytest.mark.asyncio +@pytest.mark.parametrize('readonly_fields', [{}, {'role': [{'roleId': 2}]}], ids=['react-form', 'vue-form']) +async def test_edit_user_accepts_form_without_readonly_roles( + monkeypatch: pytest.MonkeyPatch, readonly_fields: dict +) -> None: + """编辑表单可以省略只读角色详情,用户字段与角色/岗位关联仍全部保存。""" + user = EditUserModel(userId=2, userName='tester', sex='1', roleIds=[2], postIds=[1], **readonly_fields) + database = SimpleNamespace(commit=AsyncMock(), rollback=AsyncMock()) + monkeypatch.setattr(UserService, 'user_detail_services', AsyncMock(return_value=SimpleNamespace(data=user))) + monkeypatch.setattr(UserService, 'check_user_name_unique_services', AsyncMock(return_value=True)) + security_event = AsyncMock() + monkeypatch.setattr(UserService, '_handle_edit_identity_security_event', security_event) + operations = {} + for name in ( + 'edit_user_dao', + 'delete_user_role_dao', + 'delete_user_post_dao', + 'add_user_role_dao', + 'add_user_post_dao', + ): + operations[name] = AsyncMock() + monkeypatch.setattr(UserDao, name, operations[name]) + + result = await UserService.edit_user_services(database, user) + + assert result.is_success is True + operations['edit_user_dao'].assert_awaited_once_with(database, {'user_id': 2, 'user_name': 'tester', 'sex': '1'}) + for operation in operations.values(): + operation.assert_awaited_once() + assert operations['add_user_role_dao'].await_args.args[1].role_id == user.role_ids[0] + assert operations['add_user_post_dao'].await_args.args[1].post_id == user.post_ids[0] + security_event.assert_awaited_once_with(database, user, user) + database.commit.assert_awaited_once() + database.rollback.assert_not_awaited() + + @pytest.mark.asyncio @pytest.mark.parametrize( ('pwd_chrtype', 'password'), 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..5c9e41ce1 --- /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 '