From 025b0822df11e95b19960a015494fed9a75c8de7 Mon Sep 17 00:00:00 2001 From: insistence <92962165+insistence@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:15:22 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E7=BB=9F=E4=B8=80=E5=A4=9A?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E5=B7=A5=E7=A8=8B=E6=9E=B6=E6=9E=84=E3=80=81?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E9=85=8D=E7=BD=AE=E4=B8=8E=20CI=20(#131)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 重构项目架构 * refactor: 重构ci和测试 * fix: 修复测试 * feat: 新增更多E2E测试 * fix: 修复E2E测试 --- .github/requirements.txt | 3 + .github/ruff.toml | 2 + .github/scripts/check_compose.py | 72 + .github/scripts/check_test_report.py | 39 + .github/scripts/ci_plan.py | 245 ++ .../scripts/install_ai_test_dependencies.py | 23 + .github/scripts/mobile_smoke.py | 184 ++ .github/tests/test_ci_contracts.py | 91 + .github/tests/test_ci_plan.py | 169 + .github/workflows/backend.yml | 64 - .github/workflows/ci-backend.yml | 110 + .github/workflows/ci-e2e.yml | 111 + .github/workflows/ci-frontend.yml | 116 + .github/workflows/ci-integration.yml | 218 ++ .github/workflows/ci-native.yml | 80 + .github/workflows/ci-quality.yml | 54 + .github/workflows/ci.yml | 106 + .github/workflows/native-plugin.yml | 97 - .github/workflows/playwright.yml | 174 - .github/workflows/plugin-frontend.yml | 69 - .github/workflows/plugin-network-smoke.yml | 63 - .../workflows/plugin-release-integration.yml | 94 - .github/workflows/ruff.yml | 33 - .github/workflows/timezone.yml | 106 - CHANGELOG.md | 244 +- README.md | 98 +- docker-compose.my.yml | 5 +- docker-compose.pg.yml | 5 +- docker.env | 1 + ruoyi-fastapi-app/tests/time.test.js | 3 - ruoyi-fastapi-backend/.dockerignore | 12 + ruoyi-fastapi-backend/Dockerfile.my | 6 +- ruoyi-fastapi-backend/Dockerfile.pg | 6 +- .../cli/groups/plugin/commands/developer.py | 16 +- .../cli/groups/plugin/commands/sdk.py | 43 +- .../cli/groups/plugin/controller.py | 2 +- .../cli/groups/plugin/options.py | 6 +- .../cli/groups/plugin/presenter.py | 2 +- .../cli/runtime/plugin/scaffold/__init__.py | 6 +- .../cli/runtime/plugin/scaffold/backend.py | 3 +- .../cli/runtime/plugin/scaffold/builder.py | 69 +- .../cli/runtime/plugin/scaffold/frontend.py | 114 +- .../cli/runtime/plugin/scaffold/payload.py | 4 +- .../cli/runtime/plugin/scaffold/v2.py | 29 +- .../cli/runtime/plugin/sdk.py | 50 +- .../cli/runtime/plugin/service.py | 13 +- .../cli/runtime/plugin/support.py | 31 +- ruoyi-fastapi-backend/config/env.py | 2 +- ruoyi-fastapi-backend/docs/cli_usage.md | 31 +- .../docs/plugin_development.md | 139 +- .../unified_authentication_developer_guide.md | 2 +- .../module_admin/service/server_service.py | 75 +- .../module_identity/service/audit_service.py | 5 +- ruoyi-fastapi-backend/plugins/ai/README.md | 14 +- ruoyi-fastapi-backend/plugins/ai/plugin.yaml | 37 +- .../plugins/core/capability.py | 3 +- .../plugins/core/environment.py | 85 +- .../plugins/core/frontend.py | 214 ++ .../core/management/service/service.py | 27 +- .../plugins/core/manifest/schema.py | 57 +- .../plugins/core/manifest/v2.py | 2 +- .../core/runtime/service/dependencies.py | 8 +- .../plugins/core/runtime/service/facade.py | 2 + .../runtime/support/payload/documentation.py | 18 +- .../plugins/core/validation/dependencies.py | 29 +- .../core/validation/dependency_policy.py | 46 +- .../plugins/core/validation/manifest.py | 9 +- .../examples/python/bundle_demo/README.md | 6 +- .../python/bundle_demo/web/vite.config.js | 2 +- .../python/task_demo/web/vite.config.js | 2 +- ruoyi-fastapi-backend/pyproject.toml | 8 +- ruoyi-fastapi-backend/requirements-test.txt | 4 + .../tests/cli/root/conftest.py | 38 +- .../tests/cli/root/test_bootstrap.py | 7 + .../tests/cli/root/test_contract_app_ops.py | 45 +- .../tests/cli/root/test_contract_cli.py | 4 + .../tests/cli/root/test_contract_config.py | 4 + .../cli/root/test_contract_db_cache_dev.py | 3 + .../tests/cli/root/test_contract_job_gen.py | 3 + .../cli/root/test_plugin_artifact_commands.py | 3 +- .../cli/root/test_plugin_create_templates.py | 46 + .../root/test_plugin_frontend_environment.py | 130 + .../tests/cli/root/test_plugin_lazy_import.py | 5 + .../cli/root/test_plugin_sdk_commands.py | 102 +- .../tests/cli/runtime/plugin/conftest.py | 5 + .../plugin/test_runtime_dependency_lock.py | 5 +- .../runtime/plugin/test_runtime_scaffold.py | 123 +- .../plugin/test_runtime_scaffold_v2.py | 48 +- .../cli/runtime/plugin/test_runtime_sdk.py | 51 + .../runtime/plugin/test_runtime_testing.py | 6 +- .../tests/{ => config}/conftest.py | 0 .../test_scheduler_execution_coordination.py | 47 +- .../tests/module_admin/service/conftest.py | 1 + .../service/test_server_service.py | 169 + .../services/test_audit_service.py | 44 +- .../plugins/core/deployment/test_worker.py | 1 + .../plugins/core/runtime/fakes/environment.py | 6 + .../service/test_batch_dependencies.py | 22 +- .../core/runtime/service/test_catalog.py | 6 +- .../core/runtime/service/test_check_docs.py | 6 +- .../service/test_install_config_health.py | 18 +- .../runtime/service/test_structure_checks.py | 2 +- .../service/test_upgrade_enable_purge.py | 28 +- .../core/runtime/test_plugin_bootstrap.py | 4 +- .../plugins/core/test_frontend_layout.py | 151 + .../core/validation/test_dependencies.py | 19 +- .../validation/test_frontend_dependencies.py | 351 ++ .../plugins/core/validation/test_manifest.py | 9 +- .../core/validation/test_manifest_v2.py | 2 +- .../plugins/integration/test_network_smoke.py | 41 +- .../tests/plugins/native/test_rust_demo.py | 5 +- .../plugins/sample_plugins/test_ai_plugin.py | 27 +- .../tests/time/time.test.js | 3 - ruoyi-fastapi-frontend/vue2/mobile/.env | 1 + ruoyi-fastapi-frontend/vue2/mobile/.gitignore | 31 + ruoyi-fastapi-frontend/vue2/mobile/.npmrc | 1 + ruoyi-fastapi-frontend/vue2/mobile/README.md | 35 + .../vue2/mobile/babel.config.js | 86 + .../vue2/mobile/package.json | 150 + .../vue2/mobile/platform.js | 12 + .../vue2/mobile/postcss.config.js | 32 + .../vue2/mobile/public/index.html | 20 + .../vue2/mobile/scripts/patch-uni-webpack.cjs | 19 + .../vue2/mobile/src/App.vue | 70 + .../vue2/mobile}/src/api/login.js | 124 +- .../vue2/mobile}/src/api/system/dict/data.js | 0 .../vue2/mobile}/src/api/system/dict/type.js | 0 .../vue2/mobile}/src/api/system/user.js | 0 .../vue2/mobile/src/config.js | 26 + .../vue2/mobile/src/main.ts | 17 + .../vue2/mobile/src/manifest.json | 68 + .../vue2/mobile}/src/pages.json | 276 +- .../src/pages/common/agreement/index.vue | 69 + .../mobile/src/pages/common/privacy/index.vue | 93 + .../src/pages/common/textview/index.vue | 39 + .../mobile/src/pages/common/webview/index.vue | 34 + .../vue2/mobile/src/pages/index.vue | 214 ++ .../vue2/mobile/src/pages/login.vue | 218 ++ .../mobile/src/pages/mine/about/index.vue | 73 + .../mobile/src/pages/mine/avatar/index.vue | 757 +++++ .../vue2/mobile/src/pages/mine/help/index.vue | 94 + .../vue2/mobile/src/pages/mine/index.vue | 212 ++ .../vue2/mobile/src/pages/mine/info/edit.vue | 147 + .../vue2/mobile/src/pages/mine/info/index.vue | 133 + .../vue2/mobile/src/pages/mine/pwd/index.vue | 100 + .../mobile/src/pages/mine/setting/index.vue | 98 + .../src/pages/mine/setting/timezone.vue | 198 ++ .../vue2/mobile/src/pages/register.vue | 207 ++ .../vue2/mobile/src/pages/work/index.vue | 235 ++ .../vue2/mobile}/src/permission.js | 87 +- .../vue2/mobile/src/plugins/auth.js | 60 + .../vue2/mobile/src/plugins/index.js | 14 + .../vue2/mobile}/src/plugins/modal.js | 156 +- .../vue2/mobile}/src/plugins/tab.js | 60 +- .../vue2/mobile/src/sfc.d.ts | 4 + .../vue2/mobile}/src/static/favicon.ico | Bin .../src/static/images/banner/banner01.jpg | Bin .../src/static/images/banner/banner02.jpg | Bin .../src/static/images/banner/banner03.jpg | Bin .../mobile}/src/static/images/profile.jpg | Bin .../mobile}/src/static/images/tabbar/home.png | Bin .../src/static/images/tabbar/home_.png | Bin .../mobile}/src/static/images/tabbar/mine.png | Bin .../src/static/images/tabbar/mine_.png | Bin .../mobile}/src/static/images/tabbar/work.png | Bin .../src/static/images/tabbar/work_.png | Bin .../vue2/mobile}/src/static/logo.png | Bin .../vue2/mobile}/src/static/logo200.png | Bin .../vue2/mobile/src/store/getters.js | 9 + .../vue2/mobile/src/store/index.js | 15 + .../vue2/mobile/src/store/modules/user.js | 147 + .../vue2/mobile}/src/uni.scss | 0 .../vue2/mobile}/src/utils/auth.js | 26 +- .../vue2/mobile}/src/utils/common.js | 0 .../vue2/mobile}/src/utils/constant.js | 0 .../vue2/mobile}/src/utils/errorCode.js | 12 +- .../vue2/mobile}/src/utils/permission.js | 110 +- .../vue2/mobile/src/utils/request.js | 140 + .../vue2/mobile}/src/utils/storage.js | 0 .../vue2/mobile/src/utils/time.js | 535 ++++ .../vue2/mobile}/src/utils/transportCrypto.js | 0 .../src/utils/transportCryptoPolicy.js | 0 .../vue2/mobile}/src/utils/transportForge.js | 0 .../vue2/mobile/src/utils/upload.js | 72 + .../vue2/mobile}/src/utils/validate.js | 0 .../mobile/src/vendor/node-forge/lib/prng.js | 421 +++ .../src/vendor/node-forge/lib/random.js | 191 ++ .../mobile/src/vendor/node-forge/lib/rsa.js | 1954 ++++++++++++ .../mobile/src/vendor/node-forge/lib/util.js | 2652 ++++++++++++++++ .../vue2/mobile/tailwind.config.js | 43 + .../vue2/mobile/tests/behavior.test.mjs | 3 + .../vue2/mobile/tests/time-bundle.test.cjs | 68 + .../vue2/mobile/tests/time-polyfill.test.mjs | 5 + .../vue2/mobile/tests/time.test.mjs | 3 + .../vue2/mobile/tsconfig.json | 27 + .../vue2/mobile/vue.config.js | 71 + ruoyi-fastapi-frontend/vue2/web/.dockerignore | 6 + .../{ => vue2/web}/.editorconfig | 0 .../vue2/web/.env.development | 14 + ruoyi-fastapi-frontend/vue2/web/.env.docker | 15 + .../vue2/web/.env.production | 11 + ruoyi-fastapi-frontend/vue2/web/.env.staging | 15 + .../{ => vue2/web}/.gitattributes | 0 .../{ => vue2/web}/.gitignore | 0 .../vue2/web/.prettierignore | 30 + .../{ => vue2/web}/.prettierrc.json | 0 ruoyi-fastapi-frontend/vue2/web/Dockerfile | 31 + ruoyi-fastapi-frontend/vue2/web/FORMATTING.md | 122 + .../vue2/web/babel.config.js | 16 + ruoyi-fastapi-frontend/vue2/web/bin/build.bat | 12 + .../vue2/web/bin/nginx.dockermy.conf | 72 + .../vue2/web/bin/nginx.dockerpg.conf | 72 + .../vue2/web/bin/package.bat | 12 + .../vue2/web/bin/run-web.bat | 12 + .../vue2/web/build/index.js | 34 + .../vue2/web/frontend.code-workspace | 21 + ruoyi-fastapi-frontend/vue2/web/package.json | 101 + .../vue2/web/plugins/ai/README.md | 84 + .../{ => vue2/web}/plugins/ai/api/chat.js | 0 .../{ => vue2/web}/plugins/ai/api/model.js | 0 .../ai/views/chat/components/AiMessage.vue | 194 ++ .../vue2/web/plugins/ai/views/chat/index.vue | 1317 ++++++++ .../vue2/web/plugins/ai/views/model/index.vue | 585 ++++ .../{ => vue2/web}/public/favicon.ico | Bin .../{ => vue2/web/public}/html/ie.html | 0 .../vue2/web/public/index.html | 211 ++ .../vue2/web/public/robots.txt | 2 + .../web/public/styles/theme-chalk/index.css | 1 + ruoyi-fastapi-frontend/vue2/web/src/App.vue | 34 + .../{ => vue2/web}/src/api/authCenter.js | 0 .../{ => vue2/web}/src/api/login.js | 0 .../{ => vue2/web}/src/api/menu.js | 0 .../{ => vue2/web}/src/api/monitor/cache.js | 0 .../{ => vue2/web}/src/api/monitor/job.js | 0 .../{ => vue2/web}/src/api/monitor/jobLog.js | 0 .../web}/src/api/monitor/logininfor.js | 0 .../web}/src/api/monitor/oauthAudit.js | 0 .../{ => vue2/web}/src/api/monitor/online.js | 0 .../{ => vue2/web}/src/api/monitor/operlog.js | 0 .../{ => vue2/web}/src/api/monitor/server.js | 0 .../web}/src/api/monitor/transportCrypto.js | 0 .../{ => vue2/web}/src/api/system/config.js | 0 .../{ => vue2/web}/src/api/system/dept.js | 0 .../web}/src/api/system/dict/data.js | 0 .../web}/src/api/system/dict/type.js | 0 .../{ => vue2/web}/src/api/system/file.js | 0 .../{ => vue2/web}/src/api/system/menu.js | 0 .../{ => vue2/web}/src/api/system/notice.js | 0 .../web}/src/api/system/oauthClient.js | 0 .../web}/src/api/system/oauthResource.js | 0 .../web}/src/api/system/oauthSession.js | 0 .../{ => vue2/web}/src/api/system/plugin.js | 0 .../{ => vue2/web}/src/api/system/post.js | 0 .../{ => vue2/web}/src/api/system/role.js | 0 .../{ => vue2/web}/src/api/system/user.js | 0 .../{ => vue2/web}/src/api/tool/gen.js | 0 .../web}/src/assets/401_images/401.gif | Bin .../web}/src/assets/404_images/404.png | Bin .../web}/src/assets/404_images/404_cloud.png | Bin .../vue2/web/src/assets/icons/index.js | 9 + .../web}/src/assets/icons/svg/404.svg | 0 .../web}/src/assets/icons/svg/ai-chat.svg | 0 .../web}/src/assets/icons/svg/ai-manage.svg | 0 .../web}/src/assets/icons/svg/ai-model.svg | 0 .../web}/src/assets/icons/svg/bell.svg | 0 .../web}/src/assets/icons/svg/bug.svg | 0 .../web}/src/assets/icons/svg/build.svg | 0 .../web}/src/assets/icons/svg/button.svg | 0 .../web}/src/assets/icons/svg/cascader.svg | 0 .../web}/src/assets/icons/svg/chart.svg | 0 .../web}/src/assets/icons/svg/checkbox.svg | 0 .../web}/src/assets/icons/svg/client.svg | 0 .../web}/src/assets/icons/svg/clipboard.svg | 0 .../web}/src/assets/icons/svg/code.svg | 0 .../web}/src/assets/icons/svg/color.svg | 0 .../web}/src/assets/icons/svg/component.svg | 0 .../web}/src/assets/icons/svg/dashboard.svg | 0 .../web}/src/assets/icons/svg/date-range.svg | 0 .../web}/src/assets/icons/svg/date.svg | 0 .../web}/src/assets/icons/svg/deepthink.svg | 0 .../web}/src/assets/icons/svg/dict.svg | 0 .../src/assets/icons/svg/documentation.svg | 0 .../web}/src/assets/icons/svg/download.svg | 0 .../web}/src/assets/icons/svg/drag.svg | 0 .../web}/src/assets/icons/svg/druid.svg | 0 .../web}/src/assets/icons/svg/edit.svg | 0 .../web}/src/assets/icons/svg/education.svg | 0 .../web}/src/assets/icons/svg/email.svg | 0 .../web}/src/assets/icons/svg/enter.svg | 0 .../web}/src/assets/icons/svg/example.svg | 0 .../web}/src/assets/icons/svg/excel.svg | 0 .../src/assets/icons/svg/exit-fullscreen.svg | 0 .../web}/src/assets/icons/svg/eye-open.svg | 0 .../web}/src/assets/icons/svg/eye.svg | 0 .../web}/src/assets/icons/svg/form.svg | 0 .../web}/src/assets/icons/svg/fullscreen.svg | 0 .../web}/src/assets/icons/svg/github.svg | 0 .../web}/src/assets/icons/svg/grant.svg | 0 .../web}/src/assets/icons/svg/guide.svg | 0 .../web}/src/assets/icons/svg/icon.svg | 0 .../web}/src/assets/icons/svg/input.svg | 0 .../src/assets/icons/svg/international.svg | 0 .../web}/src/assets/icons/svg/job.svg | 0 .../web}/src/assets/icons/svg/key.svg | 0 .../web}/src/assets/icons/svg/language.svg | 0 .../web}/src/assets/icons/svg/link.svg | 0 .../web}/src/assets/icons/svg/list.svg | 0 .../web}/src/assets/icons/svg/lock.svg | 0 .../web}/src/assets/icons/svg/log.svg | 0 .../web}/src/assets/icons/svg/logininfor.svg | 0 .../web}/src/assets/icons/svg/message.svg | 0 .../web}/src/assets/icons/svg/money.svg | 0 .../web}/src/assets/icons/svg/monitor.svg | 0 .../web}/src/assets/icons/svg/more-up.svg | 0 .../web}/src/assets/icons/svg/nested.svg | 0 .../web}/src/assets/icons/svg/number.svg | 0 .../web}/src/assets/icons/svg/oauth.svg | 0 .../web}/src/assets/icons/svg/online.svg | 0 .../web}/src/assets/icons/svg/password.svg | 0 .../web}/src/assets/icons/svg/pdf.svg | 0 .../web}/src/assets/icons/svg/people.svg | 0 .../web}/src/assets/icons/svg/peoples.svg | 0 .../web}/src/assets/icons/svg/phone.svg | 0 .../web}/src/assets/icons/svg/post.svg | 0 .../web}/src/assets/icons/svg/qq.svg | 0 .../web}/src/assets/icons/svg/question.svg | 0 .../web}/src/assets/icons/svg/radio.svg | 0 .../web}/src/assets/icons/svg/rate.svg | 0 .../web/src/assets/icons/svg/redis-list.svg | 1 + .../web}/src/assets/icons/svg/redis.svg | 0 .../web}/src/assets/icons/svg/resource.svg | 0 .../web}/src/assets/icons/svg/row.svg | 0 .../web}/src/assets/icons/svg/scope.svg | 0 .../web}/src/assets/icons/svg/search.svg | 0 .../web}/src/assets/icons/svg/select.svg | 0 .../web}/src/assets/icons/svg/server.svg | 0 .../web}/src/assets/icons/svg/session.svg | 0 .../web}/src/assets/icons/svg/shopping.svg | 0 .../vue2/web/src/assets/icons/svg/size.svg | 1 + .../web}/src/assets/icons/svg/skill.svg | 0 .../web}/src/assets/icons/svg/slider.svg | 0 .../web}/src/assets/icons/svg/star.svg | 0 .../web}/src/assets/icons/svg/swagger.svg | 0 .../web}/src/assets/icons/svg/switch.svg | 0 .../web}/src/assets/icons/svg/system.svg | 0 .../web}/src/assets/icons/svg/tab.svg | 0 .../web}/src/assets/icons/svg/table.svg | 0 .../web}/src/assets/icons/svg/textarea.svg | 0 .../web}/src/assets/icons/svg/theme.svg | 0 .../web}/src/assets/icons/svg/time-range.svg | 0 .../web}/src/assets/icons/svg/time.svg | 0 .../web}/src/assets/icons/svg/tool.svg | 0 .../web}/src/assets/icons/svg/tree-table.svg | 0 .../web}/src/assets/icons/svg/tree.svg | 0 .../web}/src/assets/icons/svg/upload.svg | 0 .../web}/src/assets/icons/svg/user.svg | 0 .../web}/src/assets/icons/svg/validCode.svg | 0 .../web}/src/assets/icons/svg/wechat.svg | 0 .../web}/src/assets/icons/svg/zip.svg | 0 .../vue2/web/src/assets/icons/svgo.yml | 21 + .../{ => vue2/web}/src/assets/images/dark.svg | 0 .../web}/src/assets/images/light.svg | 0 .../src/assets/images/login-background.jpg | Bin .../web}/src/assets/images/profile.jpg | Bin .../{ => vue2/web}/src/assets/logo/logo.png | Bin .../vue2/web/src/assets/styles/btn.scss | 99 + .../web/src/assets/styles/element-ui.scss | 88 + .../src/assets/styles/element-variables.scss | 31 + .../vue2/web/src/assets/styles/index.scss | 193 ++ .../web}/src/assets/styles/mixin.scss | 0 .../vue2/web/src/assets/styles/oauth.scss | 182 ++ .../vue2/web/src/assets/styles/ruoyi.scss | 541 ++++ .../vue2/web/src/assets/styles/sidebar.scss | 322 ++ .../web/src/assets/styles/transition.scss | 49 + .../vue2/web/src/assets/styles/variables.scss | 39 + .../web/src/components/Breadcrumb/index.vue | 120 + .../BusinessDateTimePicker/index.vue | 129 + .../components/BusinessFileUpload/index.vue | 578 ++++ .../vue2/web/src/components/Crontab/day.vue | 218 ++ .../vue2/web/src/components/Crontab/hour.vue | 163 + .../vue2/web/src/components/Crontab/index.vue | 478 +++ .../vue2/web/src/components/Crontab/min.vue | 157 + .../vue2/web/src/components/Crontab/month.vue | 157 + .../web/src/components/Crontab/result.vue | 112 + .../web/src/components/Crontab/second.vue | 160 + .../vue2/web/src/components/Crontab/week.vue | 262 ++ .../vue2/web/src/components/Crontab/year.vue | 174 + .../vue2/web/src/components/DictData/index.js | 53 + .../vue2/web/src/components/DictTag/index.vue | 104 + .../vue2/web/src/components/Editor/index.vue | 309 ++ .../components/ExcelImportDialog/index.vue | 178 ++ .../web/src/components/FileUpload/index.vue | 308 ++ .../web/src/components/Hamburger/index.vue | 49 + .../web/src/components/HeaderSearch/index.vue | 421 +++ .../web/src/components/IconSelect/index.vue | 123 + .../src/components/IconSelect/requireIcons.js | 10 + .../web/src/components/ImagePreview/index.vue | 93 + .../web/src/components/ImageUpload/index.vue | 280 ++ .../web/src/components/JsonEditor/index.vue | 353 +++ .../components/OAuthWorkspace/AccessMap.vue | 127 + .../components/OAuthWorkspace/FieldLabel.vue | 50 + .../components/OAuthWorkspace/PageFrame.vue | 259 ++ .../OAuthWorkspace/StringListInput.vue | 99 + .../web/src/components/Pagination/index.vue | 115 + .../web/src/components/PanThumb/index.vue | 152 + .../web}/src/components/ParentView/index.vue | 0 .../web/src/components/PluginFrame/index.vue | 305 ++ .../web/src/components/RightToolbar/index.vue | 293 ++ .../web/src/components/RuoYi/Doc/index.vue | 24 + .../web/src/components/RuoYi/Git/index.vue | 24 + .../web/src/components/Screenfull/index.vue | 60 + .../web/src/components/SizeSelect/index.vue | 66 + .../vue2/web/src/components/SvgIcon/index.vue | 71 + .../web/src/components/ThemePicker/index.vue | 179 ++ .../web/src/components/TreePanel/index.vue | 780 +++++ .../vue2/web/src/components/iFrame/index.vue | 39 + .../vue2/web/src/directive/dialog/drag.js | 63 + .../web/src/directive/dialog/dragHeight.js | 38 + .../web/src/directive/dialog/dragWidth.js | 34 + .../vue2/web/src/directive/index.js | 23 + .../web/src/directive/module/clipboard.js | 56 + .../web/src/directive/permission/hasPermi.js | 28 + .../web/src/directive/permission/hasRole.js | 28 + .../web/src/layout/components/AppMain.vue | 146 + .../src/layout/components/Copyright/index.vue | 38 + .../components/HeaderNotice/DetailView.vue | 400 +++ .../layout/components/HeaderNotice/index.vue | 246 ++ .../layout/components/IframeToggle/index.vue | 38 + .../src/layout/components/InnerLink/index.vue | 51 + .../vue2/web/src/layout/components/Navbar.vue | 320 ++ .../src/layout/components/Settings/index.vue | 553 ++++ .../layout/components/Sidebar/FixiOSBug.js | 25 + .../src/layout/components/Sidebar/Item.vue | 40 + .../src/layout/components/Sidebar/Link.vue | 46 + .../src/layout/components/Sidebar/Logo.vue | 148 + .../layout/components/Sidebar/SidebarItem.vue | 123 + .../src/layout/components/Sidebar/index.vue | 77 + .../layout/components/TagsView/ScrollPane.vue | 156 + .../src/layout/components/TagsView/index.vue | 893 ++++++ .../src/layout/components/TopBar/index.vue | 123 + .../src/layout/components/TopNav/index.vue | 206 ++ .../vue2/web/src/layout/components/index.js | 5 + .../vue2/web/src/layout/index.vue | 133 + .../web/src/layout/mixin/ResizeHandler.js | 45 + ruoyi-fastapi-frontend/vue2/web/src/main.js | 109 + .../vue2/web/src/permission.js | 87 + .../vue2/web/src/plugins/auth.js | 60 + .../{ => vue2/web}/src/plugins/cache.js | 0 .../vue2/web/src/plugins/download.js | 199 ++ .../vue2/web/src/plugins/index.js | 20 + .../vue2/web/src/plugins/modal.js | 83 + .../vue2/web/src/plugins/tab.js | 75 + .../vue2/web/src/router/index.js | 213 ++ .../vue2/web/src/settings.js | 66 + .../vue2/web/src/store/getters.js | 23 + .../vue2/web/src/store/index.js | 27 + .../vue2/web/src/store/modules/app.js | 66 + .../vue2/web/src/store/modules/dict.js | 48 + .../vue2/web/src/store/modules/lock.js | 34 + .../vue2/web/src/store/modules/permission.js | 139 + .../vue2/web/src/store/modules/settings.js | 68 + .../vue2/web/src/store/modules/tagsView.js | 284 ++ .../vue2/web/src/store/modules/user.js | 166 + .../{ => vue2/web}/src/utils/auth.js | 0 .../web}/src/utils/authCenterAccess.js | 0 .../vue2/web/src/utils/dict/Dict.js | 81 + .../vue2/web/src/utils/dict/DictConverter.js | 17 + .../vue2/web/src/utils/dict/DictData.js | 13 + .../vue2/web/src/utils/dict/DictMeta.js | 37 + .../vue2/web/src/utils/dict/DictOptions.js | 51 + .../vue2/web/src/utils/dict/index.js | 37 + .../vue2/web/src/utils/dynamicTitle.js | 13 + .../{ => vue2/web}/src/utils/errorCode.js | 0 .../vue2/web/src/utils/generator/config.js | 451 +++ .../{ => vue2/web}/src/utils/generator/css.js | 0 .../web/src/utils/generator/drawingDefault.js | 39 + .../vue2/web/src/utils/generator/html.js | 371 +++ .../web}/src/utils/generator/icon.json | 0 .../vue2/web/src/utils/generator/js.js | 254 ++ .../vue2/web/src/utils/generator/render.js | 164 + .../vue2/web/src/utils/index.js | 361 +++ .../{ => vue2/web}/src/utils/job.js | 0 .../{ => vue2/web}/src/utils/jsencrypt.js | 0 .../vue2/web/src/utils/monaco.js | 29 + .../{ => vue2/web}/src/utils/oauthUri.js | 0 .../vue2/web/src/utils/passwordRule.js | 89 + .../vue2/web/src/utils/permission.js | 45 + .../web}/src/utils/pluginBridge.d.ts | 0 .../{ => vue2/web}/src/utils/pluginBridge.js | 0 .../web}/src/utils/pluginBridge.sdk.json | 0 .../web}/src/utils/pluginPlanFormatter.js | 0 .../web}/src/utils/pluginReleaseFormatter.js | 0 .../{ => vue2/web}/src/utils/pluginSse.js | 0 .../vue2/web/src/utils/pluginViewResolver.js | 33 + .../vue2/web/src/utils/request.js | 264 ++ .../vue2/web/src/utils/ruoyi.js | 204 ++ .../vue2/web/src/utils/scroll-to.js | 69 + .../vue2/web/src/utils/time.js | 597 ++++ .../vue2/web/src/utils/transportCrypto.js | 753 +++++ .../web/src/utils/transportCryptoPolicy.js | 356 +++ .../{ => vue2/web}/src/utils/transportPath.js | 0 .../{ => vue2/web}/src/utils/validate.js | 0 .../src/views/auth-center/AuthCenterShell.vue | 454 +++ .../src/views/auth-center/changePassword.vue | 402 +++ .../web/src/views/auth-center/consent.vue | 477 +++ .../vue2/web/src/views/auth-center/error.vue | 169 + .../views/auth-center/interactionSecurity.js | 0 .../vue2/web/src/views/auth-center/login.vue | 344 ++ .../src/views/auth-center/useInteraction.js | 112 + .../vue2/web/src/views/dashboard/BarChart.vue | 114 + .../web/src/views/dashboard/LineChart.vue | 141 + .../web/src/views/dashboard/PanelGroup.vue | 240 ++ .../vue2/web/src/views/dashboard/PieChart.vue | 82 + .../vue2/web/src/views/dashboard/Radar.vue | 104 + .../web/src/views/dashboard/RaddarChart.vue | 122 + .../web/src/views/dashboard/Workplace.less | 112 + .../vue2/web/src/views/dashboard/index.vue | 622 ++++ .../web/src/views/dashboard/mixins/resize.js | 58 + .../vue2/web/src/views/error/401.vue | 93 + .../vue2/web/src/views/error/404.vue | 249 ++ .../vue2/web/src/views/index.vue | 1097 +++++++ .../vue2/web/src/views/index_v1.vue | 107 + .../vue2/web/src/views/lock.vue | 429 +++ .../vue2/web/src/views/login.vue | 258 ++ .../web/src/views/monitor/cache/index.vue | 258 ++ .../vue2/web/src/views/monitor/cache/list.vue | 275 ++ .../web/src/views/monitor/druid/index.vue | 18 + .../vue2/web/src/views/monitor/job/detail.vue | 493 +++ .../vue2/web/src/views/monitor/job/index.vue | 1048 ++++++ .../vue2/web/src/views/monitor/job/log.vue | 414 +++ .../web/src/views/monitor/job/runtime.vue | 472 +++ .../src/views/monitor/logininfor/index.vue | 364 +++ .../src/views/monitor/oauthAudit/index.vue | 721 +++++ .../web/src/views/monitor/online/index.vue | 198 ++ .../web/src/views/monitor/operlog/detail.vue | 215 ++ .../web/src/views/monitor/operlog/index.vue | 413 +++ .../web/src/views/monitor/server/index.vue | 433 +++ .../views/monitor/transportCrypto/index.vue | 1258 ++++++++ .../vue2/web/src/views/redirect.vue | 12 + .../vue2/web/src/views/register.vue | 263 ++ .../web/src/views/system/config/index.vue | 497 +++ .../vue2/web/src/views/system/dept/index.vue | 537 ++++ .../vue2/web/src/views/system/dict/data.vue | 563 ++++ .../vue2/web/src/views/system/dict/detail.vue | 240 ++ .../vue2/web/src/views/system/dict/index.vue | 524 +++ .../system/file/components/FileAclDrawer.vue | 491 +++ .../file/components/FileAuditDrawer.vue | 408 +++ .../file/components/FileDetailDialog.vue | 170 + .../file/components/FileReconcileDrawer.vue | 902 ++++++ .../file/components/FileReferenceDrawer.vue | 134 + .../components/FileRetentionPolicyDrawer.vue | 307 ++ .../FileRetentionReminderDrawer.vue | 485 +++ .../system/file/components/FileSearchForm.vue | 202 ++ .../system/file/components/FileStatistics.vue | 282 ++ .../system/file/components/FileTable.vue | 353 +++ .../file/components/FileTransferDialog.vue | 201 ++ .../system/file/components/fileFormatters.js | 163 + .../vue2/web/src/views/system/file/index.vue | 382 +++ .../vue2/web/src/views/system/menu/index.vue | 765 +++++ .../web/src/views/system/notice/ReadUsers.vue | 178 ++ .../web/src/views/system/notice/index.vue | 460 +++ .../src/views/system/oauth/client/index.vue | 1280 ++++++++ .../src/views/system/oauth/client/secret.vue | 116 + .../system/oauth/grant/AccessPolicyDrawer.vue | 386 +++ .../src/views/system/oauth/grant/index.vue | 759 +++++ .../web/src/views/system/oauth/key/index.vue | 914 ++++++ .../src/views/system/oauth/resource/index.vue | 713 +++++ .../src/views/system/oauth/scope/index.vue | 753 +++++ .../src/views/system/oauth/session/index.vue | 631 ++++ .../plugin/components/PluginConfigDialog.vue | 480 +++ .../plugin/components/PluginConfigStatus.vue | 183 ++ .../components/PluginDependencyDialog.vue | 501 +++ .../plugin/components/PluginDetailDialog.vue | 861 +++++ .../components/PluginDiagnosticDialog.vue | 297 ++ .../plugin/components/PluginPlanDialog.vue | 383 +++ .../plugin/components/PluginReleaseStatus.vue | 34 + .../components/PluginRuntimeMetrics.vue | 222 ++ .../web/src/views/system/plugin/index.vue | 2821 +++++++++++++++++ .../vue2/web/src/views/system/post/index.vue | 441 +++ .../web/src/views/system/role/authUser.vue | 283 ++ .../vue2/web/src/views/system/role/index.vue | 815 +++++ .../web/src/views/system/role/selectUser.vue | 208 ++ .../web/src/views/system/user/authRole.vue | 187 ++ .../vue2/web/src/views/system/user/index.vue | 830 +++++ .../src/views/system/user/profile/index.vue | 129 + .../views/system/user/profile/resetPwd.vue | 109 + .../system/user/profile/timezoneSettings.vue | 192 ++ .../views/system/user/profile/userAvatar.vue | 260 ++ .../views/system/user/profile/userInfo.vue | 124 + .../vue2/web/src/views/system/user/view.vue | 231 ++ .../src/views/tool/build/CodeTypeDialog.vue | 123 + .../src/views/tool/build/DraggableItem.vue | 136 + .../web/src/views/tool/build/IconsDialog.vue | 123 + .../web/src/views/tool/build/RightPanel.vue | 1348 ++++++++ .../src/views/tool/build/TreeNodeDialog.vue | 146 + .../vue2/web/src/views/tool/build/index.vue | 822 +++++ .../web/src/views/tool/gen/basicInfoForm.vue | 88 + .../web/src/views/tool/gen/createTable.vue | 98 + .../vue2/web/src/views/tool/gen/editTable.vue | 437 +++ .../web/src/views/tool/gen/genInfoForm.vue | 425 +++ .../web/src/views/tool/gen/importTable.vue | 241 ++ .../vue2/web/src/views/tool/gen/index.vue | 516 +++ .../vue2/web/src/views/tool/swagger/index.vue | 15 + .../tests/auth-center/availability.test.cjs | 52 + .../auth-center/interactionSecurity.test.cjs | 27 + .../web/tests/auth-center/oauthUri.test.cjs | 45 + .../auth-center/vue2-components.test.cjs | 246 ++ .../web/tests/job/job-form.test.mjs} | 0 .../web/tests/job/vue2-components.test.cjs | 426 +++ .../fixtures/bridge-b0d16a3e/.gitattributes | 0 .../fixtures/bridge-b0d16a3e/pluginBridge.js | 0 .../fixtures/bridge-b0d16a3e/provenance.json | 0 .../tests/plugins/fixtures/json-only-host.js | 0 .../tests/plugins/network-smoke/server.mjs | 134 + .../web/tests/plugins/pluginBridge.test.mjs} | 0 .../pluginBridgeCompatibility.test.mjs} | 0 .../tests/plugins/pluginConfigStatus.test.mjs | 77 + .../web/tests/plugins/pluginDevMock.test.mjs | 77 + .../web/tests/plugins/pluginFrame.test.mjs | 71 + .../plugins/pluginHostIntegration.test.cjs | 297 ++ .../plugins/pluginReleaseFormatter.test.mjs | 116 + .../plugins/pluginRuntimeMetrics.test.mjs | 79 + .../web/tests/plugins/pluginSse.test.mjs} | 0 .../tests/plugins/pluginViewResolver.test.js | 14 + .../plugins/pluginVue2Components.test.cjs | 51 + .../web/tests/plugins/run-plugin-tests.js | 40 + .../vue2/web/tests/support/load-module.cjs | 55 + .../vue2/web/tests/time/time.test.mjs | 3 + ruoyi-fastapi-frontend/vue2/web/vue.config.js | 445 +++ .../vue3/mobile}/.editorconfig | 0 .../vue3/mobile}/.gitignore | 0 .../vue3/mobile}/.npmrc | 0 .../vue3/mobile}/README.md | 14 +- .../vue3/mobile}/index.html | 0 .../vue3/mobile}/netlify.toml | 0 .../vue3/mobile}/package.json | 5 +- .../vue3/mobile}/platform.ts | 0 .../vue3/mobile}/postcss.config.ts | 0 .../vue3/mobile}/src/App.vue | 0 .../vue3/mobile/src/api/login.js | 62 + .../vue3/mobile/src/api/system/dict/data.js | 52 + .../vue3/mobile/src/api/system/dict/type.js | 60 + .../vue3/mobile/src/api/system/user.js | 55 + .../vue3/mobile}/src/auto-imports.d.ts | 0 .../vue3/mobile}/src/config.js | 0 .../vue3/mobile}/src/env.d.ts | 0 .../vue3/mobile}/src/main.ts | 0 .../vue3/mobile}/src/manifest.json | 0 .../vue3/mobile/src/pages.json | 138 + .../src/pages/common/agreement/index.vue | 0 .../src/pages/common/privacy/index.vue | 0 .../src/pages/common/textview/index.vue | 0 .../src/pages/common/webview/index.vue | 0 .../vue3/mobile}/src/pages/index.vue | 0 .../vue3/mobile}/src/pages/login.vue | 0 .../mobile}/src/pages/mine/about/index.vue | 0 .../mobile}/src/pages/mine/avatar/index.vue | 1356 ++++---- .../mobile}/src/pages/mine/help/index.vue | 182 +- .../vue3/mobile}/src/pages/mine/index.vue | 418 +-- .../vue3/mobile}/src/pages/mine/info/edit.vue | 0 .../mobile}/src/pages/mine/info/index.vue | 0 .../vue3/mobile}/src/pages/mine/pwd/index.vue | 0 .../mobile}/src/pages/mine/setting/index.vue | 0 .../src/pages/mine/setting/timezone.vue | 0 .../vue3/mobile}/src/pages/register.vue | 0 .../vue3/mobile}/src/pages/work/index.vue | 0 .../vue3/mobile/src/permission.js | 44 + .../vue3/mobile}/src/plugins/auth.js | 120 +- .../vue3/mobile}/src/plugins/index.js | 24 +- .../vue3/mobile/src/plugins/modal.js | 78 + .../vue3/mobile/src/plugins/tab.js | 30 + .../vue3/mobile/src/static/favicon.ico | Bin 0 -> 10729 bytes .../src/static/images/banner/banner01.jpg | Bin 0 -> 39640 bytes .../src/static/images/banner/banner02.jpg | Bin 0 -> 37061 bytes .../src/static/images/banner/banner03.jpg | Bin 0 -> 38123 bytes .../vue3/mobile/src/static/images/profile.jpg | Bin 0 -> 8997 bytes .../mobile/src/static/images/tabbar/home.png | Bin 0 -> 3265 bytes .../mobile/src/static/images/tabbar/home_.png | Bin 0 -> 3229 bytes .../mobile/src/static/images/tabbar/mine.png | Bin 0 -> 4235 bytes .../mobile/src/static/images/tabbar/mine_.png | Bin 0 -> 4219 bytes .../mobile/src/static/images/tabbar/work.png | Bin 0 -> 4108 bytes .../mobile/src/static/images/tabbar/work_.png | Bin 0 -> 5008 bytes .../vue3/mobile/src/static/logo.png | Bin 0 -> 3501 bytes .../vue3/mobile/src/static/logo200.png | Bin 0 -> 11619 bytes .../vue3/mobile}/src/store/index.js | 0 .../vue3/mobile}/src/store/modules/config.js | 0 .../vue3/mobile}/src/store/modules/dict.js | 0 .../vue3/mobile}/src/store/modules/user.js | 0 .../vue3/mobile}/src/theme.json | 0 .../vue3/mobile/src/uni.scss | 0 .../vue3/mobile/src/utils/auth.js | 13 + .../vue3/mobile/src/utils/common.js | 58 + .../vue3/mobile/src/utils/constant.js | 10 + .../vue3/mobile}/src/utils/dict.js | 0 .../vue3/mobile/src/utils/errorCode.js | 6 + .../vue3/mobile/src/utils/permission.js | 55 + .../vue3/mobile}/src/utils/request.js | 0 .../vue3/mobile/src/utils/storage.js | 39 + .../vue3/mobile}/src/utils/time.js | 0 .../vue3/mobile/src/utils/transportCrypto.js | 1236 ++++++++ .../mobile/src/utils/transportCryptoPolicy.js | 438 +++ .../vue3/mobile/src/utils/transportForge.js | 214 ++ .../vue3/mobile}/src/utils/upload.js | 144 +- .../vue3/mobile/src/utils/validate.js | 124 + .../vue3/mobile}/stylelint.config.mjs | 0 .../vue3/mobile}/tailwind.config.ts | 0 .../vue3/mobile/tests/behavior.test.mjs | 3 + .../vue3/mobile}/tests/time-polyfill.test.js | 2 +- .../vue3/mobile/tests/time.test.js | 3 + .../vue3/mobile}/tsconfig.json | 0 .../vue3/mobile}/vite.config.ts | 0 ruoyi-fastapi-frontend/vue3/web/.dockerignore | 6 + ruoyi-fastapi-frontend/vue3/web/.editorconfig | 15 + .../{ => vue3/web}/.env.development | 0 .../{ => vue3/web}/.env.docker | 0 .../{ => vue3/web}/.env.production | 0 .../{ => vue3/web}/.env.staging | 0 .../vue3/web/.gitattributes | 6 + ruoyi-fastapi-frontend/vue3/web/.gitignore | 23 + .../{ => vue3/web}/.prettierignore | 0 .../vue3/web/.prettierrc.json | 17 + .../{ => vue3/web}/Dockerfile | 0 .../{ => vue3/web}/FORMATTING.md | 8 +- ruoyi-fastapi-frontend/{ => vue3/web}/LICENSE | 0 .../{ => vue3/web}/bin/build.bat | 0 .../{ => vue3/web}/bin/nginx.dockermy.conf | 0 .../{ => vue3/web}/bin/nginx.dockerpg.conf | 0 .../{ => vue3/web}/bin/package.bat | 0 .../{ => vue3/web}/bin/run-web.bat | 0 .../{ => vue3/web}/frontend.code-workspace | 2 +- ruoyi-fastapi-frontend/vue3/web/html/ie.html | 46 + .../{ => vue3/web}/index.html | 0 .../{ => vue3/web}/package.json | 7 +- .../{ => vue3/web}/plugins/ai/README.md | 9 +- .../vue3/web/plugins/ai/api/chat.js | 53 + .../vue3/web/plugins/ai/api/model.js | 52 + .../ai/views/chat/components/AiMessage.vue | 0 .../web}/plugins/ai/views/chat/index.vue | 0 .../web}/plugins/ai/views/model/index.vue | 0 .../vue3/web/public/favicon.ico | Bin 0 -> 10729 bytes .../web}/public/monaco-environment.js | 0 .../{ => vue3/web}/src/App.vue | 0 .../vue3/web/src/api/authCenter.js | 91 + .../vue3/web/src/api/login.js | 70 + .../vue3/web/src/api/menu.js | 9 + .../vue3/web/src/api/monitor/cache.js | 57 + .../vue3/web/src/api/monitor/job.js | 119 + .../vue3/web/src/api/monitor/jobLog.js | 26 + .../vue3/web/src/api/monitor/logininfor.js | 34 + .../vue3/web/src/api/monitor/oauthAudit.js | 10 + .../vue3/web/src/api/monitor/online.js | 18 + .../vue3/web/src/api/monitor/operlog.js | 26 + .../vue3/web/src/api/monitor/server.js | 9 + .../web/src/api/monitor/transportCrypto.js | 9 + .../vue3/web/src/api/system/config.js | 60 + .../vue3/web/src/api/system/dept.js | 61 + .../vue3/web/src/api/system/dict/data.js | 52 + .../vue3/web/src/api/system/dict/type.js | 60 + .../vue3/web/src/api/system/file.js | 241 ++ .../vue3/web/src/api/system/menu.js | 69 + .../vue3/web/src/api/system/notice.js | 79 + .../vue3/web/src/api/system/oauthClient.js | 92 + .../vue3/web/src/api/system/oauthResource.js | 110 + .../vue3/web/src/api/system/oauthSession.js | 127 + .../vue3/web/src/api/system/plugin.js | 228 ++ .../vue3/web/src/api/system/post.js | 44 + .../vue3/web/src/api/system/role.js | 119 + .../vue3/web/src/api/system/user.js | 146 + .../vue3/web/src/api/tool/gen.js | 95 + .../vue3/web/src/assets/401_images/401.gif | Bin 0 -> 164227 bytes .../vue3/web/src/assets/404_images/404.png | Bin 0 -> 98071 bytes .../web/src/assets/404_images/404_cloud.png | Bin 0 -> 4766 bytes .../vue3/web/src/assets/icons/svg/404.svg | 1 + .../vue3/web/src/assets/icons/svg/ai-chat.svg | 1 + .../web/src/assets/icons/svg/ai-manage.svg | 1 + .../web/src/assets/icons/svg/ai-model.svg | 1 + .../vue3/web/src/assets/icons/svg/bell.svg | 1 + .../vue3/web/src/assets/icons/svg/bug.svg | 1 + .../vue3/web/src/assets/icons/svg/build.svg | 1 + .../vue3/web/src/assets/icons/svg/button.svg | 1 + .../web/src/assets/icons/svg/cascader.svg | 1 + .../vue3/web/src/assets/icons/svg/chart.svg | 1 + .../web/src/assets/icons/svg/checkbox.svg | 1 + .../vue3/web/src/assets/icons/svg/client.svg | 1 + .../web/src/assets/icons/svg/clipboard.svg | 1 + .../vue3/web/src/assets/icons/svg/code.svg | 1 + .../vue3/web/src/assets/icons/svg/color.svg | 1 + .../web/src/assets/icons/svg/component.svg | 1 + .../web/src/assets/icons/svg/dashboard.svg | 1 + .../web/src/assets/icons/svg/date-range.svg | 1 + .../vue3/web/src/assets/icons/svg/date.svg | 1 + .../web/src/assets/icons/svg/deepthink.svg | 1 + .../vue3/web/src/assets/icons/svg/dict.svg | 1 + .../src/assets/icons/svg/documentation.svg | 1 + .../web/src/assets/icons/svg/download.svg | 1 + .../vue3/web/src/assets/icons/svg/drag.svg | 1 + .../vue3/web/src/assets/icons/svg/druid.svg | 1 + .../vue3/web/src/assets/icons/svg/edit.svg | 1 + .../web/src/assets/icons/svg/education.svg | 1 + .../vue3/web/src/assets/icons/svg/email.svg | 1 + .../vue3/web/src/assets/icons/svg/enter.svg | 1 + .../vue3/web/src/assets/icons/svg/example.svg | 1 + .../vue3/web/src/assets/icons/svg/excel.svg | 1 + .../src/assets/icons/svg/exit-fullscreen.svg | 1 + .../web/src/assets/icons/svg/eye-open.svg | 1 + .../vue3/web/src/assets/icons/svg/eye.svg | 1 + .../vue3/web/src/assets/icons/svg/form.svg | 1 + .../web/src/assets/icons/svg/fullscreen.svg | 1 + .../vue3/web/src/assets/icons/svg/github.svg | 1 + .../vue3/web/src/assets/icons/svg/grant.svg | 1 + .../vue3/web/src/assets/icons/svg/guide.svg | 1 + .../vue3/web/src/assets/icons/svg/icon.svg | 1 + .../vue3/web/src/assets/icons/svg/input.svg | 1 + .../src/assets/icons/svg/international.svg | 1 + .../vue3/web/src/assets/icons/svg/job.svg | 1 + .../vue3/web/src/assets/icons/svg/key.svg | 1 + .../web/src/assets/icons/svg/language.svg | 1 + .../vue3/web/src/assets/icons/svg/link.svg | 1 + .../vue3/web/src/assets/icons/svg/list.svg | 1 + .../vue3/web/src/assets/icons/svg/lock.svg | 1 + .../vue3/web/src/assets/icons/svg/log.svg | 1 + .../web/src/assets/icons/svg/logininfor.svg | 1 + .../vue3/web/src/assets/icons/svg/message.svg | 1 + .../vue3/web/src/assets/icons/svg/money.svg | 1 + .../vue3/web/src/assets/icons/svg/monitor.svg | 2 + .../web}/src/assets/icons/svg/moon.svg | 0 .../vue3/web/src/assets/icons/svg/more-up.svg | 1 + .../vue3/web/src/assets/icons/svg/nested.svg | 1 + .../vue3/web/src/assets/icons/svg/number.svg | 1 + .../vue3/web/src/assets/icons/svg/oauth.svg | 1 + .../vue3/web/src/assets/icons/svg/online.svg | 1 + .../web/src/assets/icons/svg/password.svg | 1 + .../vue3/web/src/assets/icons/svg/pdf.svg | 1 + .../vue3/web/src/assets/icons/svg/people.svg | 1 + .../vue3/web/src/assets/icons/svg/peoples.svg | 1 + .../vue3/web/src/assets/icons/svg/phone.svg | 1 + .../vue3/web/src/assets/icons/svg/post.svg | 1 + .../vue3/web/src/assets/icons/svg/qq.svg | 1 + .../web/src/assets/icons/svg/question.svg | 1 + .../vue3/web/src/assets/icons/svg/radio.svg | 1 + .../vue3/web/src/assets/icons/svg/rate.svg | 1 + .../web}/src/assets/icons/svg/redis-list.svg | 0 .../vue3/web/src/assets/icons/svg/redis.svg | 1 + .../web/src/assets/icons/svg/resource.svg | 1 + .../vue3/web/src/assets/icons/svg/row.svg | 1 + .../vue3/web/src/assets/icons/svg/scope.svg | 1 + .../vue3/web/src/assets/icons/svg/search.svg | 1 + .../vue3/web/src/assets/icons/svg/select.svg | 1 + .../vue3/web/src/assets/icons/svg/server.svg | 1 + .../vue3/web/src/assets/icons/svg/session.svg | 1 + .../web/src/assets/icons/svg/shopping.svg | 1 + .../web}/src/assets/icons/svg/size.svg | 0 .../vue3/web/src/assets/icons/svg/skill.svg | 1 + .../vue3/web/src/assets/icons/svg/slider.svg | 1 + .../vue3/web/src/assets/icons/svg/star.svg | 1 + .../web}/src/assets/icons/svg/sunny.svg | 0 .../vue3/web/src/assets/icons/svg/swagger.svg | 1 + .../vue3/web/src/assets/icons/svg/switch.svg | 1 + .../vue3/web/src/assets/icons/svg/system.svg | 2 + .../vue3/web/src/assets/icons/svg/tab.svg | 1 + .../vue3/web/src/assets/icons/svg/table.svg | 1 + .../web/src/assets/icons/svg/textarea.svg | 1 + .../vue3/web/src/assets/icons/svg/theme.svg | 1 + .../web/src/assets/icons/svg/time-range.svg | 1 + .../vue3/web/src/assets/icons/svg/time.svg | 1 + .../vue3/web/src/assets/icons/svg/tool.svg | 1 + .../web/src/assets/icons/svg/tree-table.svg | 1 + .../vue3/web/src/assets/icons/svg/tree.svg | 1 + .../vue3/web/src/assets/icons/svg/upload.svg | 1 + .../vue3/web/src/assets/icons/svg/user.svg | 1 + .../web/src/assets/icons/svg/validCode.svg | 1 + .../vue3/web/src/assets/icons/svg/wechat.svg | 1 + .../vue3/web/src/assets/icons/svg/zip.svg | 1 + .../vue3/web/src/assets/images/dark.svg | 39 + .../vue3/web/src/assets/images/light.svg | 39 + .../src/assets/images/login-background.jpg | Bin 0 -> 521275 bytes .../vue3/web/src/assets/images/profile.jpg | Bin 0 -> 8997 bytes .../vue3/web/src/assets/logo/logo.png | Bin 0 -> 3501 bytes .../{ => vue3/web}/src/assets/styles/btn.scss | 0 .../web}/src/assets/styles/element-ui.scss | 0 .../web}/src/assets/styles/index.scss | 0 .../vue3/web/src/assets/styles/mixin.scss | 60 + .../web}/src/assets/styles/ruoyi.scss | 0 .../web}/src/assets/styles/sidebar.scss | 0 .../web}/src/assets/styles/transition.scss | 0 .../src/assets/styles/variables.module.scss | 0 .../web}/src/components/Breadcrumb/index.vue | 0 .../BusinessDateTimePicker/index.vue | 0 .../components/BusinessFileUpload/index.vue | 0 .../web}/src/components/Crontab/day.vue | 0 .../web}/src/components/Crontab/hour.vue | 0 .../web}/src/components/Crontab/index.vue | 0 .../web}/src/components/Crontab/min.vue | 0 .../web}/src/components/Crontab/month.vue | 0 .../web}/src/components/Crontab/result.vue | 0 .../web}/src/components/Crontab/second.vue | 0 .../web}/src/components/Crontab/week.vue | 0 .../web}/src/components/Crontab/year.vue | 0 .../web}/src/components/DictTag/index.vue | 0 .../web}/src/components/Editor/index.vue | 0 .../components/ExcelImportDialog/index.vue | 0 .../web}/src/components/FileUpload/index.vue | 0 .../web}/src/components/Hamburger/index.vue | 0 .../src/components/HeaderSearch/index.vue | 0 .../web}/src/components/IconSelect/index.vue | 0 .../src/components/IconSelect/requireIcons.js | 0 .../src/components/ImagePreview/index.vue | 0 .../web}/src/components/ImageUpload/index.vue | 0 .../web}/src/components/JsonEditor/index.vue | 0 .../components/OAuthWorkspace/AccessMap.vue | 0 .../components/OAuthWorkspace/FieldLabel.vue | 0 .../components/OAuthWorkspace/PageFrame.vue | 0 .../OAuthWorkspace/StringListInput.vue | 0 .../web}/src/components/Pagination/index.vue | 0 .../web/src/components/ParentView/index.vue | 3 + .../web}/src/components/PluginFrame/index.vue | 0 .../src/components/RightToolbar/index.vue | 0 .../web}/src/components/RuoYi/Doc/index.vue | 0 .../web}/src/components/RuoYi/Git/index.vue | 2 +- .../web}/src/components/Screenfull/index.vue | 0 .../web}/src/components/SizeSelect/index.vue | 0 .../web}/src/components/SvgIcon/index.vue | 0 .../web}/src/components/SvgIcon/svgicon.js | 0 .../web}/src/components/TreePanel/index.vue | 0 .../web}/src/components/iFrame/index.vue | 0 .../web}/src/directive/common/copyText.js | 0 .../{ => vue3/web}/src/directive/index.js | 0 .../web}/src/directive/permission/hasPermi.js | 0 .../web}/src/directive/permission/hasRole.js | 0 .../web}/src/layout/components/AppMain.vue | 0 .../src/layout/components/Copyright/index.vue | 0 .../components/HeaderNotice/DetailView.vue | 0 .../layout/components/HeaderNotice/index.vue | 0 .../layout/components/IframeToggle/index.vue | 0 .../src/layout/components/InnerLink/index.vue | 0 .../web}/src/layout/components/Navbar.vue | 0 .../src/layout/components/Settings/index.vue | 0 .../src/layout/components/Sidebar/Link.vue | 0 .../src/layout/components/Sidebar/Logo.vue | 0 .../layout/components/Sidebar/SidebarItem.vue | 0 .../src/layout/components/Sidebar/index.vue | 0 .../layout/components/TagsView/ScrollPane.vue | 0 .../src/layout/components/TagsView/index.vue | 0 .../src/layout/components/TopBar/index.vue | 0 .../src/layout/components/TopNav/index.vue | 0 .../web}/src/layout/components/index.js | 0 .../{ => vue3/web}/src/layout/index.vue | 0 .../{ => vue3/web}/src/main.js | 0 .../{ => vue3/web}/src/permission.js | 0 .../{ => vue3/web}/src/plugins/auth.js | 0 .../vue3/web/src/plugins/cache.js | 79 + .../{ => vue3/web}/src/plugins/download.js | 0 .../{ => vue3/web}/src/plugins/index.js | 0 .../{ => vue3/web}/src/plugins/modal.js | 0 .../{ => vue3/web}/src/plugins/tab.js | 0 .../{ => vue3/web}/src/router/index.js | 0 .../{ => vue3/web}/src/settings.js | 0 .../{ => vue3/web}/src/store/index.js | 0 .../{ => vue3/web}/src/store/modules/app.js | 0 .../{ => vue3/web}/src/store/modules/dict.js | 0 .../{ => vue3/web}/src/store/modules/lock.js | 0 .../web}/src/store/modules/permission.js | 0 .../web}/src/store/modules/settings.js | 0 .../web}/src/store/modules/tagsView.js | 0 .../{ => vue3/web}/src/store/modules/user.js | 0 .../vue3/web/src/utils/auth.js | 15 + .../vue3/web/src/utils/authCenterAccess.js | 25 + .../{ => vue3/web}/src/utils/dict.js | 0 .../{ => vue3/web}/src/utils/dynamicTitle.js | 0 .../vue3/web/src/utils/errorCode.js | 6 + .../web}/src/utils/generator/config.js | 0 .../vue3/web/src/utils/generator/css.js | 18 + .../src/utils/generator/drawingDefault.js | 0 .../web}/src/utils/generator/html.js | 0 .../vue3/web/src/utils/generator/icon.json | 282 ++ .../{ => vue3/web}/src/utils/generator/js.js | 0 .../web}/src/utils/generator/render.js | 0 .../{ => vue3/web}/src/utils/index.js | 0 .../vue3/web/src/utils/job.js | 67 + .../vue3/web/src/utils/jsencrypt.js | 31 + .../vue3/web/src/utils/oauthUri.js | 82 + .../{ => vue3/web}/src/utils/passwordRule.js | 0 .../{ => vue3/web}/src/utils/permission.js | 0 .../vue3/web/src/utils/pluginBridge.d.ts | 156 + .../vue3/web/src/utils/pluginBridge.js | 884 ++++++ .../vue3/web/src/utils/pluginBridge.sdk.json | 9 + .../vue3/web/src/utils/pluginPlanFormatter.js | 196 ++ .../web/src/utils/pluginReleaseFormatter.js | 51 + .../vue3/web/src/utils/pluginSse.js | 134 + .../web}/src/utils/pluginViewResolver.js | 0 .../{ => vue3/web}/src/utils/request.js | 0 .../{ => vue3/web}/src/utils/ruoyi.js | 0 .../{ => vue3/web}/src/utils/scroll-to.js | 0 .../{ => vue3/web}/src/utils/theme.js | 0 .../{ => vue3/web}/src/utils/time.js | 0 .../web}/src/utils/transportCrypto.js | 0 .../web}/src/utils/transportCryptoPolicy.js | 0 .../vue3/web/src/utils/transportPath.js | 29 + .../vue3/web/src/utils/validate.js | 121 + .../src/views/auth-center/AuthCenterShell.vue | 0 .../src/views/auth-center/changePassword.vue | 0 .../web}/src/views/auth-center/consent.vue | 0 .../web}/src/views/auth-center/error.vue | 0 .../views/auth-center/interactionSecurity.js | 45 + .../web}/src/views/auth-center/login.vue | 0 .../src/views/auth-center/useInteraction.js | 0 .../views/dashboard/editable-link-group.vue | 0 .../web}/src/views/dashboard/index.vue | 0 .../{ => vue3/web}/src/views/error/401.vue | 0 .../{ => vue3/web}/src/views/error/404.vue | 0 .../{ => vue3/web}/src/views/lock.vue | 0 .../{ => vue3/web}/src/views/login.vue | 0 .../web}/src/views/monitor/cache/index.vue | 0 .../web}/src/views/monitor/cache/list.vue | 0 .../web}/src/views/monitor/druid/index.vue | 0 .../web}/src/views/monitor/job/detail.vue | 0 .../web}/src/views/monitor/job/index.vue | 0 .../web}/src/views/monitor/job/log.vue | 0 .../web}/src/views/monitor/job/runtime.vue | 0 .../src/views/monitor/logininfor/index.vue | 0 .../src/views/monitor/oauthAudit/index.vue | 0 .../web}/src/views/monitor/online/index.vue | 0 .../web}/src/views/monitor/operlog/detail.vue | 0 .../web}/src/views/monitor/operlog/index.vue | 0 .../web}/src/views/monitor/server/index.vue | 0 .../views/monitor/transportCrypto/index.vue | 0 .../web}/src/views/redirect/index.vue | 0 .../{ => vue3/web}/src/views/register.vue | 0 .../web}/src/views/system/config/index.vue | 0 .../web}/src/views/system/dept/index.vue | 0 .../web}/src/views/system/dict/data.vue | 0 .../web}/src/views/system/dict/detail.vue | 0 .../web}/src/views/system/dict/index.vue | 0 .../system/file/components/FileAclDrawer.vue | 0 .../file/components/FileAuditDrawer.vue | 0 .../file/components/FileDetailDialog.vue | 0 .../file/components/FileReconcileDrawer.vue | 0 .../file/components/FileReferenceDrawer.vue | 0 .../components/FileRetentionPolicyDrawer.vue | 0 .../FileRetentionReminderDrawer.vue | 0 .../system/file/components/FileSearchForm.vue | 0 .../system/file/components/FileStatistics.vue | 0 .../system/file/components/FileTable.vue | 0 .../file/components/FileTransferDialog.vue | 0 .../system/file/components/fileFormatters.js | 0 .../web}/src/views/system/file/index.vue | 0 .../web}/src/views/system/menu/index.vue | 0 .../src/views/system/notice/ReadUsers.vue | 0 .../web}/src/views/system/notice/index.vue | 0 .../src/views/system/oauth/client/index.vue | 0 .../src/views/system/oauth/client/secret.vue | 0 .../system/oauth/grant/AccessPolicyDrawer.vue | 0 .../src/views/system/oauth/grant/index.vue | 0 .../web}/src/views/system/oauth/key/index.vue | 0 .../src/views/system/oauth/resource/index.vue | 0 .../src/views/system/oauth/scope/index.vue | 0 .../src/views/system/oauth/session/index.vue | 0 .../plugin/components/PluginConfigDialog.vue | 0 .../plugin/components/PluginConfigStatus.vue | 0 .../components/PluginDependencyDialog.vue | 0 .../plugin/components/PluginDetailDialog.vue | 0 .../components/PluginDiagnosticDialog.vue | 0 .../plugin/components/PluginPlanDialog.vue | 0 .../plugin/components/PluginReleaseStatus.vue | 0 .../components/PluginRuntimeMetrics.vue | 0 .../web}/src/views/system/plugin/index.vue | 0 .../web}/src/views/system/post/index.vue | 0 .../web}/src/views/system/role/authUser.vue | 0 .../web}/src/views/system/role/index.vue | 13 + .../web}/src/views/system/role/selectUser.vue | 0 .../web}/src/views/system/user/authRole.vue | 0 .../web}/src/views/system/user/index.vue | 0 .../src/views/system/user/profile/index.vue | 0 .../views/system/user/profile/resetPwd.vue | 0 .../system/user/profile/timezoneSettings.vue | 0 .../views/system/user/profile/userAvatar.vue | 0 .../views/system/user/profile/userInfo.vue | 0 .../web}/src/views/system/user/view.vue | 0 .../src/views/tool/build/CodeTypeDialog.vue | 0 .../src/views/tool/build/DraggableItem.vue | 0 .../web}/src/views/tool/build/IconsDialog.vue | 0 .../web}/src/views/tool/build/RightPanel.vue | 0 .../src/views/tool/build/TreeNodeDialog.vue | 0 .../web}/src/views/tool/build/index.vue | 0 .../web}/src/views/tool/gen/basicInfoForm.vue | 0 .../web}/src/views/tool/gen/createTable.vue | 0 .../web}/src/views/tool/gen/editTable.vue | 0 .../web}/src/views/tool/gen/genInfoForm.vue | 0 .../web}/src/views/tool/gen/importTable.vue | 0 .../web}/src/views/tool/gen/index.vue | 0 .../web}/src/views/tool/swagger/index.vue | 0 .../tests/auth-center/availability.test.js | 0 .../web}/tests/auth-center/clientForm.test.js | 0 .../auth-center/interactionSecurity.test.js | 0 .../web}/tests/auth-center/oauthUri.test.js | 0 .../tests/auth-center/useInteraction.test.js | 0 .../vue3/web/tests/job/job-form.test.js | 39 + .../fixtures/bridge-b0d16a3e/.gitattributes | 1 + .../fixtures/bridge-b0d16a3e/pluginBridge.js | 882 ++++++ .../fixtures/bridge-b0d16a3e/provenance.json | 8 + .../tests/plugins/fixtures/json-only-host.js | 61 + .../tests/plugins/monacoEnvironment.test.js | 0 .../tests/plugins/network-smoke/server.mjs | 0 .../web/tests/plugins/pluginBridge.test.js | 950 ++++++ .../plugins/pluginBridgeCompatibility.test.js | 226 ++ .../tests/plugins/pluginConfigStatus.test.js | 0 .../web}/tests/plugins/pluginDevMock.test.js | 2 +- .../web}/tests/plugins/pluginFrame.test.js | 0 .../web}/tests/plugins/pluginProxy.test.js | 2 +- .../plugins/pluginReleaseFormatter.test.js | 0 .../plugins/pluginRuntimeMetrics.test.js | 0 .../vue3/web/tests/plugins/pluginSse.test.js | 169 + .../tests/plugins/pluginViewResolver.test.js | 0 .../web}/tests/plugins/run-plugin-tests.js | 0 .../{ => vue3/web}/tests/run-tests.js | 0 .../vue3/web/tests/time/time.test.js | 3 + .../{ => vue3/web}/vite.config.js | 0 .../web}/vite/plugins/auto-import.js | 0 .../web}/vite/plugins/compression.js | 0 .../{ => vue3/web}/vite/plugins/index.js | 0 .../web}/vite/plugins/monaco-environment.js | 0 .../web}/vite/plugins/setup-extend.js | 0 .../{ => vue3/web}/vite/plugins/svg-icon.js | 0 ruoyi-fastapi-test/README.md | 180 +- ruoyi-fastapi-test/common/base_page_test.py | 52 +- ruoyi-fastapi-test/common/browser_harness.py | 188 ++ ruoyi-fastapi-test/common/config.py | 1 + ruoyi-fastapi-test/common/page_catalog.py | 45 + ruoyi-fastapi-test/conftest.py | 59 + ruoyi-fastapi-test/docker-compose.test.my.yml | 5 +- ruoyi-fastapi-test/docker-compose.test.pg.yml | 5 +- ruoyi-fastapi-test/docker.env | 1 + .../frontend/mobile-behavior.mjs | 141 + .../frontend/mobile-harness.mjs | 170 + ruoyi-fastapi-test/monitor/test_cache_list.py | 133 +- .../monitor/test_cache_monitor.py | 82 +- .../monitor/test_job_management.py | 110 +- .../monitor/test_oauth_audit.py | 88 + .../monitor/test_online_user.py | 125 +- .../monitor/test_server_monitor.py | 91 +- .../monitor/test_transport_crypto.py | 50 + ruoyi-fastapi-test/prepare_e2e_compose.py | 115 + ruoyi-fastapi-test/pytest.ini | 11 + ruoyi-fastapi-test/requirements.txt | 12 +- ruoyi-fastapi-test/system/page.py | 251 ++ .../system/test_config_management.py | 198 +- .../system/test_dept_management.py | 165 +- .../system/test_dict_management.py | 253 +- .../system/test_file_management.py | 139 + .../system/test_log_management.py | 195 +- .../system/test_menu_management.py | 201 +- .../system/test_notice_management.py | 200 +- .../system/test_oauth_management.py | 297 ++ .../system/test_plugin_management.py | 406 +++ .../system/test_post_management.py | 163 +- .../system/test_role_management.py | 247 +- .../system/test_user_management.py | 249 +- ruoyi-fastapi-test/test_account_pages.py | 159 + ruoyi-fastapi-test/test_browser_harness.py | 266 ++ ruoyi-fastapi-test/test_login.py | 233 +- ruoyi-fastapi-test/test_pages.py | 196 +- .../test_plugin_response_contract.py | 216 ++ ruoyi-fastapi-test/test_timezone.py | 134 +- .../time-contract/run-host-timezones.mjs | 19 +- ruoyi-fastapi-test/tool/test_build.py | 46 + ruoyi-fastapi-test/tool/test_code_gen.py | 350 +- ruoyi-fastapi-test/tool/test_swagger.py | 60 +- 1167 files changed, 94684 insertions(+), 5231 deletions(-) create mode 100644 .github/requirements.txt create mode 100644 .github/ruff.toml create mode 100644 .github/scripts/check_compose.py create mode 100644 .github/scripts/check_test_report.py create mode 100644 .github/scripts/ci_plan.py create mode 100644 .github/scripts/install_ai_test_dependencies.py create mode 100644 .github/scripts/mobile_smoke.py create mode 100644 .github/tests/test_ci_contracts.py create mode 100644 .github/tests/test_ci_plan.py delete mode 100644 .github/workflows/backend.yml create mode 100644 .github/workflows/ci-backend.yml create mode 100644 .github/workflows/ci-e2e.yml create mode 100644 .github/workflows/ci-frontend.yml create mode 100644 .github/workflows/ci-integration.yml create mode 100644 .github/workflows/ci-native.yml create mode 100644 .github/workflows/ci-quality.yml create mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/native-plugin.yml delete mode 100644 .github/workflows/playwright.yml delete mode 100644 .github/workflows/plugin-frontend.yml delete mode 100644 .github/workflows/plugin-network-smoke.yml delete mode 100644 .github/workflows/plugin-release-integration.yml delete mode 100644 .github/workflows/ruff.yml delete mode 100644 .github/workflows/timezone.yml create mode 100644 docker.env delete mode 100644 ruoyi-fastapi-app/tests/time.test.js create mode 100644 ruoyi-fastapi-backend/.dockerignore create mode 100644 ruoyi-fastapi-backend/plugins/core/frontend.py create mode 100644 ruoyi-fastapi-backend/requirements-test.txt create mode 100644 ruoyi-fastapi-backend/tests/cli/root/test_plugin_frontend_environment.py rename ruoyi-fastapi-backend/tests/{ => config}/conftest.py (100%) create mode 100644 ruoyi-fastapi-backend/tests/module_admin/service/conftest.py create mode 100644 ruoyi-fastapi-backend/tests/module_admin/service/test_server_service.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/test_frontend_layout.py create mode 100644 ruoyi-fastapi-backend/tests/plugins/core/validation/test_frontend_dependencies.py delete mode 100644 ruoyi-fastapi-frontend/tests/time/time.test.js create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/.env create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/.gitignore create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/.npmrc create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/README.md create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/babel.config.js create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/package.json create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/platform.js create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/postcss.config.js create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/public/index.html create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/scripts/patch-uni-webpack.cjs create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/src/App.vue rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue2/mobile}/src/api/login.js (94%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue2/mobile}/src/api/system/dict/data.js (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue2/mobile}/src/api/system/dict/type.js (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue2/mobile}/src/api/system/user.js (100%) create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/src/config.js create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/src/main.ts create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/src/manifest.json rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue2/mobile}/src/pages.json (95%) create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/src/pages/common/agreement/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/src/pages/common/privacy/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/src/pages/common/textview/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/src/pages/common/webview/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/src/pages/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/src/pages/login.vue create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/src/pages/mine/about/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/src/pages/mine/avatar/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/src/pages/mine/help/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/src/pages/mine/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/src/pages/mine/info/edit.vue create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/src/pages/mine/info/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/src/pages/mine/pwd/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/src/pages/mine/setting/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/src/pages/mine/setting/timezone.vue create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/src/pages/register.vue create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/src/pages/work/index.vue rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue2/mobile}/src/permission.js (91%) create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/src/plugins/auth.js create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/src/plugins/index.js rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue2/mobile}/src/plugins/modal.js (94%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue2/mobile}/src/plugins/tab.js (95%) create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/src/sfc.d.ts rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue2/mobile}/src/static/favicon.ico (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue2/mobile}/src/static/images/banner/banner01.jpg (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue2/mobile}/src/static/images/banner/banner02.jpg (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue2/mobile}/src/static/images/banner/banner03.jpg (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue2/mobile}/src/static/images/profile.jpg (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue2/mobile}/src/static/images/tabbar/home.png (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue2/mobile}/src/static/images/tabbar/home_.png (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue2/mobile}/src/static/images/tabbar/mine.png (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue2/mobile}/src/static/images/tabbar/mine_.png (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue2/mobile}/src/static/images/tabbar/work.png (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue2/mobile}/src/static/images/tabbar/work_.png (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue2/mobile}/src/static/logo.png (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue2/mobile}/src/static/logo200.png (100%) create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/src/store/getters.js create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/src/store/index.js create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/src/store/modules/user.js rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue2/mobile}/src/uni.scss (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue2/mobile}/src/utils/auth.js (95%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue2/mobile}/src/utils/common.js (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue2/mobile}/src/utils/constant.js (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue2/mobile}/src/utils/errorCode.js (97%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue2/mobile}/src/utils/permission.js (96%) create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/src/utils/request.js rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue2/mobile}/src/utils/storage.js (100%) create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/src/utils/time.js rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue2/mobile}/src/utils/transportCrypto.js (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue2/mobile}/src/utils/transportCryptoPolicy.js (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue2/mobile}/src/utils/transportForge.js (100%) create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/src/utils/upload.js rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue2/mobile}/src/utils/validate.js (100%) create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/src/vendor/node-forge/lib/prng.js create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/src/vendor/node-forge/lib/random.js create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/src/vendor/node-forge/lib/rsa.js create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/src/vendor/node-forge/lib/util.js create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/tailwind.config.js create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/tests/behavior.test.mjs create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/tests/time-bundle.test.cjs create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/tests/time-polyfill.test.mjs create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/tests/time.test.mjs create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/tsconfig.json create mode 100644 ruoyi-fastapi-frontend/vue2/mobile/vue.config.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/.dockerignore rename ruoyi-fastapi-frontend/{ => vue2/web}/.editorconfig (100%) create mode 100644 ruoyi-fastapi-frontend/vue2/web/.env.development create mode 100644 ruoyi-fastapi-frontend/vue2/web/.env.docker create mode 100644 ruoyi-fastapi-frontend/vue2/web/.env.production create mode 100644 ruoyi-fastapi-frontend/vue2/web/.env.staging rename ruoyi-fastapi-frontend/{ => vue2/web}/.gitattributes (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/.gitignore (100%) create mode 100644 ruoyi-fastapi-frontend/vue2/web/.prettierignore rename ruoyi-fastapi-frontend/{ => vue2/web}/.prettierrc.json (100%) create mode 100644 ruoyi-fastapi-frontend/vue2/web/Dockerfile create mode 100644 ruoyi-fastapi-frontend/vue2/web/FORMATTING.md create mode 100644 ruoyi-fastapi-frontend/vue2/web/babel.config.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/bin/build.bat create mode 100644 ruoyi-fastapi-frontend/vue2/web/bin/nginx.dockermy.conf create mode 100644 ruoyi-fastapi-frontend/vue2/web/bin/nginx.dockerpg.conf create mode 100644 ruoyi-fastapi-frontend/vue2/web/bin/package.bat create mode 100644 ruoyi-fastapi-frontend/vue2/web/bin/run-web.bat create mode 100644 ruoyi-fastapi-frontend/vue2/web/build/index.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/frontend.code-workspace create mode 100644 ruoyi-fastapi-frontend/vue2/web/package.json create mode 100644 ruoyi-fastapi-frontend/vue2/web/plugins/ai/README.md rename ruoyi-fastapi-frontend/{ => vue2/web}/plugins/ai/api/chat.js (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/plugins/ai/api/model.js (100%) create mode 100644 ruoyi-fastapi-frontend/vue2/web/plugins/ai/views/chat/components/AiMessage.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/plugins/ai/views/chat/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/plugins/ai/views/model/index.vue rename ruoyi-fastapi-frontend/{ => vue2/web}/public/favicon.ico (100%) rename ruoyi-fastapi-frontend/{ => vue2/web/public}/html/ie.html (100%) create mode 100644 ruoyi-fastapi-frontend/vue2/web/public/index.html create mode 100644 ruoyi-fastapi-frontend/vue2/web/public/robots.txt create mode 100644 ruoyi-fastapi-frontend/vue2/web/public/styles/theme-chalk/index.css create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/App.vue rename ruoyi-fastapi-frontend/{ => vue2/web}/src/api/authCenter.js (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/api/login.js (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/api/menu.js (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/api/monitor/cache.js (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/api/monitor/job.js (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/api/monitor/jobLog.js (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/api/monitor/logininfor.js (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/api/monitor/oauthAudit.js (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/api/monitor/online.js (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/api/monitor/operlog.js (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/api/monitor/server.js (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/api/monitor/transportCrypto.js (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/api/system/config.js (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/api/system/dept.js (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/api/system/dict/data.js (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/api/system/dict/type.js (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/api/system/file.js (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/api/system/menu.js (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/api/system/notice.js (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/api/system/oauthClient.js (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/api/system/oauthResource.js (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/api/system/oauthSession.js (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/api/system/plugin.js (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/api/system/post.js (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/api/system/role.js (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/api/system/user.js (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/api/tool/gen.js (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/401_images/401.gif (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/404_images/404.png (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/404_images/404_cloud.png (100%) create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/assets/icons/index.js rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/404.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/ai-chat.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/ai-manage.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/ai-model.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/bell.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/bug.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/build.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/button.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/cascader.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/chart.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/checkbox.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/client.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/clipboard.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/code.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/color.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/component.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/dashboard.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/date-range.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/date.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/deepthink.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/dict.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/documentation.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/download.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/drag.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/druid.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/edit.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/education.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/email.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/enter.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/example.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/excel.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/exit-fullscreen.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/eye-open.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/eye.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/form.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/fullscreen.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/github.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/grant.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/guide.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/icon.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/input.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/international.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/job.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/key.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/language.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/link.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/list.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/lock.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/log.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/logininfor.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/message.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/money.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/monitor.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/more-up.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/nested.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/number.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/oauth.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/online.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/password.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/pdf.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/people.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/peoples.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/phone.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/post.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/qq.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/question.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/radio.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/rate.svg (100%) create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/assets/icons/svg/redis-list.svg rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/redis.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/resource.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/row.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/scope.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/search.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/select.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/server.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/session.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/shopping.svg (100%) create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/assets/icons/svg/size.svg rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/skill.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/slider.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/star.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/swagger.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/switch.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/system.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/tab.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/table.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/textarea.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/theme.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/time-range.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/time.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/tool.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/tree-table.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/tree.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/upload.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/user.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/validCode.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/wechat.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/icons/svg/zip.svg (100%) create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/assets/icons/svgo.yml rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/images/dark.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/images/light.svg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/images/login-background.jpg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/images/profile.jpg (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/logo/logo.png (100%) create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/assets/styles/btn.scss create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/assets/styles/element-ui.scss create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/assets/styles/element-variables.scss create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/assets/styles/index.scss rename ruoyi-fastapi-frontend/{ => vue2/web}/src/assets/styles/mixin.scss (100%) create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/assets/styles/oauth.scss create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/assets/styles/ruoyi.scss create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/assets/styles/sidebar.scss create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/assets/styles/transition.scss create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/assets/styles/variables.scss create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/components/Breadcrumb/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/components/BusinessDateTimePicker/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/components/BusinessFileUpload/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/components/Crontab/day.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/components/Crontab/hour.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/components/Crontab/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/components/Crontab/min.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/components/Crontab/month.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/components/Crontab/result.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/components/Crontab/second.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/components/Crontab/week.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/components/Crontab/year.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/components/DictData/index.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/components/DictTag/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/components/Editor/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/components/ExcelImportDialog/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/components/FileUpload/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/components/Hamburger/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/components/HeaderSearch/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/components/IconSelect/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/components/IconSelect/requireIcons.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/components/ImagePreview/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/components/ImageUpload/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/components/JsonEditor/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/components/OAuthWorkspace/AccessMap.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/components/OAuthWorkspace/FieldLabel.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/components/OAuthWorkspace/PageFrame.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/components/OAuthWorkspace/StringListInput.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/components/Pagination/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/components/PanThumb/index.vue rename ruoyi-fastapi-frontend/{ => vue2/web}/src/components/ParentView/index.vue (100%) create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/components/PluginFrame/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/components/RightToolbar/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/components/RuoYi/Doc/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/components/RuoYi/Git/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/components/Screenfull/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/components/SizeSelect/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/components/SvgIcon/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/components/ThemePicker/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/components/TreePanel/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/components/iFrame/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/directive/dialog/drag.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/directive/dialog/dragHeight.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/directive/dialog/dragWidth.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/directive/index.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/directive/module/clipboard.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/directive/permission/hasPermi.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/directive/permission/hasRole.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/layout/components/AppMain.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/layout/components/Copyright/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/layout/components/HeaderNotice/DetailView.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/layout/components/HeaderNotice/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/layout/components/IframeToggle/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/layout/components/InnerLink/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/layout/components/Navbar.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/layout/components/Settings/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/layout/components/Sidebar/FixiOSBug.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/layout/components/Sidebar/Item.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/layout/components/Sidebar/Link.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/layout/components/Sidebar/Logo.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/layout/components/Sidebar/SidebarItem.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/layout/components/Sidebar/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/layout/components/TagsView/ScrollPane.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/layout/components/TagsView/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/layout/components/TopBar/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/layout/components/TopNav/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/layout/components/index.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/layout/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/layout/mixin/ResizeHandler.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/main.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/permission.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/plugins/auth.js rename ruoyi-fastapi-frontend/{ => vue2/web}/src/plugins/cache.js (100%) create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/plugins/download.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/plugins/index.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/plugins/modal.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/plugins/tab.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/router/index.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/settings.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/store/getters.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/store/index.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/store/modules/app.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/store/modules/dict.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/store/modules/lock.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/store/modules/permission.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/store/modules/settings.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/store/modules/tagsView.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/store/modules/user.js rename ruoyi-fastapi-frontend/{ => vue2/web}/src/utils/auth.js (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/utils/authCenterAccess.js (100%) create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/utils/dict/Dict.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/utils/dict/DictConverter.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/utils/dict/DictData.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/utils/dict/DictMeta.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/utils/dict/DictOptions.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/utils/dict/index.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/utils/dynamicTitle.js rename ruoyi-fastapi-frontend/{ => vue2/web}/src/utils/errorCode.js (100%) create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/utils/generator/config.js rename ruoyi-fastapi-frontend/{ => vue2/web}/src/utils/generator/css.js (100%) mode change 100755 => 100644 create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/utils/generator/drawingDefault.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/utils/generator/html.js rename ruoyi-fastapi-frontend/{ => vue2/web}/src/utils/generator/icon.json (100%) mode change 100755 => 100644 create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/utils/generator/js.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/utils/generator/render.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/utils/index.js rename ruoyi-fastapi-frontend/{ => vue2/web}/src/utils/job.js (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/utils/jsencrypt.js (100%) create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/utils/monaco.js rename ruoyi-fastapi-frontend/{ => vue2/web}/src/utils/oauthUri.js (100%) create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/utils/passwordRule.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/utils/permission.js rename ruoyi-fastapi-frontend/{ => vue2/web}/src/utils/pluginBridge.d.ts (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/utils/pluginBridge.js (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/utils/pluginBridge.sdk.json (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/utils/pluginPlanFormatter.js (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/utils/pluginReleaseFormatter.js (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/utils/pluginSse.js (100%) create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/utils/pluginViewResolver.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/utils/request.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/utils/ruoyi.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/utils/scroll-to.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/utils/time.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/utils/transportCrypto.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/utils/transportCryptoPolicy.js rename ruoyi-fastapi-frontend/{ => vue2/web}/src/utils/transportPath.js (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/src/utils/validate.js (100%) create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/auth-center/AuthCenterShell.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/auth-center/changePassword.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/auth-center/consent.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/auth-center/error.vue rename ruoyi-fastapi-frontend/{ => vue2/web}/src/views/auth-center/interactionSecurity.js (100%) create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/auth-center/login.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/auth-center/useInteraction.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/dashboard/BarChart.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/dashboard/LineChart.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/dashboard/PanelGroup.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/dashboard/PieChart.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/dashboard/Radar.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/dashboard/RaddarChart.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/dashboard/Workplace.less create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/dashboard/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/dashboard/mixins/resize.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/error/401.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/error/404.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/index_v1.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/lock.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/login.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/monitor/cache/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/monitor/cache/list.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/monitor/druid/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/monitor/job/detail.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/monitor/job/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/monitor/job/log.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/monitor/job/runtime.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/monitor/logininfor/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/monitor/oauthAudit/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/monitor/online/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/monitor/operlog/detail.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/monitor/operlog/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/monitor/server/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/monitor/transportCrypto/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/redirect.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/register.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/config/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/dept/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/dict/data.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/dict/detail.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/dict/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/file/components/FileAclDrawer.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/file/components/FileAuditDrawer.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/file/components/FileDetailDialog.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/file/components/FileReconcileDrawer.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/file/components/FileReferenceDrawer.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/file/components/FileRetentionPolicyDrawer.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/file/components/FileRetentionReminderDrawer.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/file/components/FileSearchForm.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/file/components/FileStatistics.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/file/components/FileTable.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/file/components/FileTransferDialog.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/file/components/fileFormatters.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/file/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/menu/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/notice/ReadUsers.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/notice/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/oauth/client/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/oauth/client/secret.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/oauth/grant/AccessPolicyDrawer.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/oauth/grant/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/oauth/key/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/oauth/resource/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/oauth/scope/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/oauth/session/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/plugin/components/PluginConfigDialog.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/plugin/components/PluginConfigStatus.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/plugin/components/PluginDependencyDialog.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/plugin/components/PluginDetailDialog.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/plugin/components/PluginDiagnosticDialog.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/plugin/components/PluginPlanDialog.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/plugin/components/PluginReleaseStatus.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/plugin/components/PluginRuntimeMetrics.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/plugin/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/post/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/role/authUser.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/role/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/role/selectUser.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/user/authRole.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/user/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/user/profile/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/user/profile/resetPwd.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/user/profile/timezoneSettings.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/user/profile/userAvatar.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/user/profile/userInfo.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/system/user/view.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/tool/build/CodeTypeDialog.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/tool/build/DraggableItem.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/tool/build/IconsDialog.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/tool/build/RightPanel.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/tool/build/TreeNodeDialog.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/tool/build/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/tool/gen/basicInfoForm.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/tool/gen/createTable.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/tool/gen/editTable.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/tool/gen/genInfoForm.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/tool/gen/importTable.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/tool/gen/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/src/views/tool/swagger/index.vue create mode 100644 ruoyi-fastapi-frontend/vue2/web/tests/auth-center/availability.test.cjs create mode 100644 ruoyi-fastapi-frontend/vue2/web/tests/auth-center/interactionSecurity.test.cjs create mode 100644 ruoyi-fastapi-frontend/vue2/web/tests/auth-center/oauthUri.test.cjs create mode 100644 ruoyi-fastapi-frontend/vue2/web/tests/auth-center/vue2-components.test.cjs rename ruoyi-fastapi-frontend/{tests/job/job-form.test.js => vue2/web/tests/job/job-form.test.mjs} (100%) create mode 100644 ruoyi-fastapi-frontend/vue2/web/tests/job/vue2-components.test.cjs rename ruoyi-fastapi-frontend/{ => vue2/web}/tests/plugins/fixtures/bridge-b0d16a3e/.gitattributes (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/tests/plugins/fixtures/bridge-b0d16a3e/pluginBridge.js (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/tests/plugins/fixtures/bridge-b0d16a3e/provenance.json (100%) rename ruoyi-fastapi-frontend/{ => vue2/web}/tests/plugins/fixtures/json-only-host.js (100%) create mode 100644 ruoyi-fastapi-frontend/vue2/web/tests/plugins/network-smoke/server.mjs rename ruoyi-fastapi-frontend/{tests/plugins/pluginBridge.test.js => vue2/web/tests/plugins/pluginBridge.test.mjs} (100%) rename ruoyi-fastapi-frontend/{tests/plugins/pluginBridgeCompatibility.test.js => vue2/web/tests/plugins/pluginBridgeCompatibility.test.mjs} (100%) create mode 100644 ruoyi-fastapi-frontend/vue2/web/tests/plugins/pluginConfigStatus.test.mjs create mode 100644 ruoyi-fastapi-frontend/vue2/web/tests/plugins/pluginDevMock.test.mjs create mode 100644 ruoyi-fastapi-frontend/vue2/web/tests/plugins/pluginFrame.test.mjs create mode 100644 ruoyi-fastapi-frontend/vue2/web/tests/plugins/pluginHostIntegration.test.cjs create mode 100644 ruoyi-fastapi-frontend/vue2/web/tests/plugins/pluginReleaseFormatter.test.mjs create mode 100644 ruoyi-fastapi-frontend/vue2/web/tests/plugins/pluginRuntimeMetrics.test.mjs rename ruoyi-fastapi-frontend/{tests/plugins/pluginSse.test.js => vue2/web/tests/plugins/pluginSse.test.mjs} (100%) create mode 100644 ruoyi-fastapi-frontend/vue2/web/tests/plugins/pluginViewResolver.test.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/tests/plugins/pluginVue2Components.test.cjs create mode 100644 ruoyi-fastapi-frontend/vue2/web/tests/plugins/run-plugin-tests.js create mode 100644 ruoyi-fastapi-frontend/vue2/web/tests/support/load-module.cjs create mode 100644 ruoyi-fastapi-frontend/vue2/web/tests/time/time.test.mjs create mode 100644 ruoyi-fastapi-frontend/vue2/web/vue.config.js rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue3/mobile}/.editorconfig (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue3/mobile}/.gitignore (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue3/mobile}/.npmrc (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue3/mobile}/README.md (89%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue3/mobile}/index.html (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue3/mobile}/netlify.toml (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue3/mobile}/package.json (94%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue3/mobile}/platform.ts (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue3/mobile}/postcss.config.ts (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue3/mobile}/src/App.vue (100%) create mode 100644 ruoyi-fastapi-frontend/vue3/mobile/src/api/login.js create mode 100644 ruoyi-fastapi-frontend/vue3/mobile/src/api/system/dict/data.js create mode 100644 ruoyi-fastapi-frontend/vue3/mobile/src/api/system/dict/type.js create mode 100644 ruoyi-fastapi-frontend/vue3/mobile/src/api/system/user.js rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue3/mobile}/src/auto-imports.d.ts (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue3/mobile}/src/config.js (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue3/mobile}/src/env.d.ts (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue3/mobile}/src/main.ts (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue3/mobile}/src/manifest.json (100%) create mode 100644 ruoyi-fastapi-frontend/vue3/mobile/src/pages.json rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue3/mobile}/src/pages/common/agreement/index.vue (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue3/mobile}/src/pages/common/privacy/index.vue (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue3/mobile}/src/pages/common/textview/index.vue (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue3/mobile}/src/pages/common/webview/index.vue (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue3/mobile}/src/pages/index.vue (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue3/mobile}/src/pages/login.vue (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue3/mobile}/src/pages/mine/about/index.vue (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue3/mobile}/src/pages/mine/avatar/index.vue (96%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue3/mobile}/src/pages/mine/help/index.vue (96%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue3/mobile}/src/pages/mine/index.vue (97%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue3/mobile}/src/pages/mine/info/edit.vue (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue3/mobile}/src/pages/mine/info/index.vue (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue3/mobile}/src/pages/mine/pwd/index.vue (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue3/mobile}/src/pages/mine/setting/index.vue (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue3/mobile}/src/pages/mine/setting/timezone.vue (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue3/mobile}/src/pages/register.vue (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue3/mobile}/src/pages/work/index.vue (100%) create mode 100644 ruoyi-fastapi-frontend/vue3/mobile/src/permission.js rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue3/mobile}/src/plugins/auth.js (96%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue3/mobile}/src/plugins/index.js (96%) create mode 100644 ruoyi-fastapi-frontend/vue3/mobile/src/plugins/modal.js create mode 100644 ruoyi-fastapi-frontend/vue3/mobile/src/plugins/tab.js create mode 100644 ruoyi-fastapi-frontend/vue3/mobile/src/static/favicon.ico create mode 100644 ruoyi-fastapi-frontend/vue3/mobile/src/static/images/banner/banner01.jpg create mode 100644 ruoyi-fastapi-frontend/vue3/mobile/src/static/images/banner/banner02.jpg create mode 100644 ruoyi-fastapi-frontend/vue3/mobile/src/static/images/banner/banner03.jpg create mode 100644 ruoyi-fastapi-frontend/vue3/mobile/src/static/images/profile.jpg create mode 100644 ruoyi-fastapi-frontend/vue3/mobile/src/static/images/tabbar/home.png create mode 100644 ruoyi-fastapi-frontend/vue3/mobile/src/static/images/tabbar/home_.png create mode 100644 ruoyi-fastapi-frontend/vue3/mobile/src/static/images/tabbar/mine.png create mode 100644 ruoyi-fastapi-frontend/vue3/mobile/src/static/images/tabbar/mine_.png create mode 100644 ruoyi-fastapi-frontend/vue3/mobile/src/static/images/tabbar/work.png create mode 100644 ruoyi-fastapi-frontend/vue3/mobile/src/static/images/tabbar/work_.png create mode 100644 ruoyi-fastapi-frontend/vue3/mobile/src/static/logo.png create mode 100644 ruoyi-fastapi-frontend/vue3/mobile/src/static/logo200.png rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue3/mobile}/src/store/index.js (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue3/mobile}/src/store/modules/config.js (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue3/mobile}/src/store/modules/dict.js (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue3/mobile}/src/store/modules/user.js (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue3/mobile}/src/theme.json (100%) create mode 100644 ruoyi-fastapi-frontend/vue3/mobile/src/uni.scss create mode 100644 ruoyi-fastapi-frontend/vue3/mobile/src/utils/auth.js create mode 100644 ruoyi-fastapi-frontend/vue3/mobile/src/utils/common.js create mode 100644 ruoyi-fastapi-frontend/vue3/mobile/src/utils/constant.js rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue3/mobile}/src/utils/dict.js (100%) create mode 100644 ruoyi-fastapi-frontend/vue3/mobile/src/utils/errorCode.js create mode 100644 ruoyi-fastapi-frontend/vue3/mobile/src/utils/permission.js rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue3/mobile}/src/utils/request.js (100%) create mode 100644 ruoyi-fastapi-frontend/vue3/mobile/src/utils/storage.js rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue3/mobile}/src/utils/time.js (100%) create mode 100644 ruoyi-fastapi-frontend/vue3/mobile/src/utils/transportCrypto.js create mode 100644 ruoyi-fastapi-frontend/vue3/mobile/src/utils/transportCryptoPolicy.js create mode 100644 ruoyi-fastapi-frontend/vue3/mobile/src/utils/transportForge.js rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue3/mobile}/src/utils/upload.js (97%) create mode 100644 ruoyi-fastapi-frontend/vue3/mobile/src/utils/validate.js rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue3/mobile}/stylelint.config.mjs (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue3/mobile}/tailwind.config.ts (100%) create mode 100644 ruoyi-fastapi-frontend/vue3/mobile/tests/behavior.test.mjs rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue3/mobile}/tests/time-polyfill.test.js (67%) create mode 100644 ruoyi-fastapi-frontend/vue3/mobile/tests/time.test.js rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue3/mobile}/tsconfig.json (100%) rename {ruoyi-fastapi-app => ruoyi-fastapi-frontend/vue3/mobile}/vite.config.ts (100%) create mode 100644 ruoyi-fastapi-frontend/vue3/web/.dockerignore create mode 100644 ruoyi-fastapi-frontend/vue3/web/.editorconfig rename ruoyi-fastapi-frontend/{ => vue3/web}/.env.development (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/.env.docker (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/.env.production (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/.env.staging (100%) create mode 100644 ruoyi-fastapi-frontend/vue3/web/.gitattributes create mode 100644 ruoyi-fastapi-frontend/vue3/web/.gitignore rename ruoyi-fastapi-frontend/{ => vue3/web}/.prettierignore (100%) create mode 100644 ruoyi-fastapi-frontend/vue3/web/.prettierrc.json rename ruoyi-fastapi-frontend/{ => vue3/web}/Dockerfile (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/FORMATTING.md (93%) rename ruoyi-fastapi-frontend/{ => vue3/web}/LICENSE (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/bin/build.bat (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/bin/nginx.dockermy.conf (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/bin/nginx.dockerpg.conf (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/bin/package.bat (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/bin/run-web.bat (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/frontend.code-workspace (93%) create mode 100644 ruoyi-fastapi-frontend/vue3/web/html/ie.html rename ruoyi-fastapi-frontend/{ => vue3/web}/index.html (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/package.json (87%) rename ruoyi-fastapi-frontend/{ => vue3/web}/plugins/ai/README.md (67%) create mode 100644 ruoyi-fastapi-frontend/vue3/web/plugins/ai/api/chat.js create mode 100644 ruoyi-fastapi-frontend/vue3/web/plugins/ai/api/model.js rename ruoyi-fastapi-frontend/{ => vue3/web}/plugins/ai/views/chat/components/AiMessage.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/plugins/ai/views/chat/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/plugins/ai/views/model/index.vue (100%) create mode 100644 ruoyi-fastapi-frontend/vue3/web/public/favicon.ico rename ruoyi-fastapi-frontend/{ => vue3/web}/public/monaco-environment.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/App.vue (100%) create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/api/authCenter.js create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/api/login.js create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/api/menu.js create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/api/monitor/cache.js create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/api/monitor/job.js create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/api/monitor/jobLog.js create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/api/monitor/logininfor.js create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/api/monitor/oauthAudit.js create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/api/monitor/online.js create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/api/monitor/operlog.js create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/api/monitor/server.js create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/api/monitor/transportCrypto.js create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/api/system/config.js create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/api/system/dept.js create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/api/system/dict/data.js create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/api/system/dict/type.js create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/api/system/file.js create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/api/system/menu.js create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/api/system/notice.js create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/api/system/oauthClient.js create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/api/system/oauthResource.js create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/api/system/oauthSession.js create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/api/system/plugin.js create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/api/system/post.js create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/api/system/role.js create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/api/system/user.js create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/api/tool/gen.js create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/401_images/401.gif create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/404_images/404.png create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/404_images/404_cloud.png create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/404.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/ai-chat.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/ai-manage.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/ai-model.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/bell.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/bug.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/build.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/button.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/cascader.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/chart.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/checkbox.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/client.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/clipboard.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/code.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/color.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/component.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/dashboard.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/date-range.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/date.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/deepthink.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/dict.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/documentation.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/download.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/drag.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/druid.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/edit.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/education.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/email.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/enter.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/example.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/excel.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/exit-fullscreen.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/eye-open.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/eye.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/form.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/fullscreen.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/github.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/grant.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/guide.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/icon.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/input.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/international.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/job.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/key.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/language.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/link.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/list.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/lock.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/log.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/logininfor.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/message.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/money.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/monitor.svg rename ruoyi-fastapi-frontend/{ => vue3/web}/src/assets/icons/svg/moon.svg (100%) mode change 100755 => 100644 create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/more-up.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/nested.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/number.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/oauth.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/online.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/password.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/pdf.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/people.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/peoples.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/phone.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/post.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/qq.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/question.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/radio.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/rate.svg rename ruoyi-fastapi-frontend/{ => vue3/web}/src/assets/icons/svg/redis-list.svg (100%) create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/redis.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/resource.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/row.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/scope.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/search.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/select.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/server.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/session.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/shopping.svg rename ruoyi-fastapi-frontend/{ => vue3/web}/src/assets/icons/svg/size.svg (100%) create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/skill.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/slider.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/star.svg rename ruoyi-fastapi-frontend/{ => vue3/web}/src/assets/icons/svg/sunny.svg (100%) mode change 100755 => 100644 create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/swagger.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/switch.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/system.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/tab.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/table.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/textarea.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/theme.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/time-range.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/time.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/tool.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/tree-table.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/tree.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/upload.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/user.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/validCode.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/wechat.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/icons/svg/zip.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/images/dark.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/images/light.svg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/images/login-background.jpg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/images/profile.jpg create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/logo/logo.png rename ruoyi-fastapi-frontend/{ => vue3/web}/src/assets/styles/btn.scss (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/assets/styles/element-ui.scss (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/assets/styles/index.scss (100%) create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/assets/styles/mixin.scss rename ruoyi-fastapi-frontend/{ => vue3/web}/src/assets/styles/ruoyi.scss (100%) mode change 100755 => 100644 rename ruoyi-fastapi-frontend/{ => vue3/web}/src/assets/styles/sidebar.scss (100%) mode change 100755 => 100644 rename ruoyi-fastapi-frontend/{ => vue3/web}/src/assets/styles/transition.scss (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/assets/styles/variables.module.scss (100%) mode change 100755 => 100644 rename ruoyi-fastapi-frontend/{ => vue3/web}/src/components/Breadcrumb/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/components/BusinessDateTimePicker/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/components/BusinessFileUpload/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/components/Crontab/day.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/components/Crontab/hour.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/components/Crontab/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/components/Crontab/min.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/components/Crontab/month.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/components/Crontab/result.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/components/Crontab/second.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/components/Crontab/week.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/components/Crontab/year.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/components/DictTag/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/components/Editor/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/components/ExcelImportDialog/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/components/FileUpload/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/components/Hamburger/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/components/HeaderSearch/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/components/IconSelect/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/components/IconSelect/requireIcons.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/components/ImagePreview/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/components/ImageUpload/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/components/JsonEditor/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/components/OAuthWorkspace/AccessMap.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/components/OAuthWorkspace/FieldLabel.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/components/OAuthWorkspace/PageFrame.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/components/OAuthWorkspace/StringListInput.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/components/Pagination/index.vue (100%) create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/components/ParentView/index.vue rename ruoyi-fastapi-frontend/{ => vue3/web}/src/components/PluginFrame/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/components/RightToolbar/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/components/RuoYi/Doc/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/components/RuoYi/Git/index.vue (71%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/components/Screenfull/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/components/SizeSelect/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/components/SvgIcon/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/components/SvgIcon/svgicon.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/components/TreePanel/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/components/iFrame/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/directive/common/copyText.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/directive/index.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/directive/permission/hasPermi.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/directive/permission/hasRole.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/layout/components/AppMain.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/layout/components/Copyright/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/layout/components/HeaderNotice/DetailView.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/layout/components/HeaderNotice/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/layout/components/IframeToggle/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/layout/components/InnerLink/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/layout/components/Navbar.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/layout/components/Settings/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/layout/components/Sidebar/Link.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/layout/components/Sidebar/Logo.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/layout/components/Sidebar/SidebarItem.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/layout/components/Sidebar/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/layout/components/TagsView/ScrollPane.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/layout/components/TagsView/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/layout/components/TopBar/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/layout/components/TopNav/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/layout/components/index.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/layout/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/main.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/permission.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/plugins/auth.js (100%) create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/plugins/cache.js rename ruoyi-fastapi-frontend/{ => vue3/web}/src/plugins/download.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/plugins/index.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/plugins/modal.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/plugins/tab.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/router/index.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/settings.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/store/index.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/store/modules/app.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/store/modules/dict.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/store/modules/lock.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/store/modules/permission.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/store/modules/settings.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/store/modules/tagsView.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/store/modules/user.js (100%) create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/utils/auth.js create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/utils/authCenterAccess.js rename ruoyi-fastapi-frontend/{ => vue3/web}/src/utils/dict.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/utils/dynamicTitle.js (100%) create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/utils/errorCode.js rename ruoyi-fastapi-frontend/{ => vue3/web}/src/utils/generator/config.js (100%) mode change 100755 => 100644 create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/utils/generator/css.js rename ruoyi-fastapi-frontend/{ => vue3/web}/src/utils/generator/drawingDefault.js (100%) mode change 100755 => 100644 rename ruoyi-fastapi-frontend/{ => vue3/web}/src/utils/generator/html.js (100%) mode change 100755 => 100644 create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/utils/generator/icon.json rename ruoyi-fastapi-frontend/{ => vue3/web}/src/utils/generator/js.js (100%) mode change 100755 => 100644 rename ruoyi-fastapi-frontend/{ => vue3/web}/src/utils/generator/render.js (100%) mode change 100755 => 100644 rename ruoyi-fastapi-frontend/{ => vue3/web}/src/utils/index.js (100%) create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/utils/job.js create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/utils/jsencrypt.js create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/utils/oauthUri.js rename ruoyi-fastapi-frontend/{ => vue3/web}/src/utils/passwordRule.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/utils/permission.js (100%) create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/utils/pluginBridge.d.ts create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/utils/pluginBridge.js create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/utils/pluginBridge.sdk.json create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/utils/pluginPlanFormatter.js create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/utils/pluginReleaseFormatter.js create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/utils/pluginSse.js rename ruoyi-fastapi-frontend/{ => vue3/web}/src/utils/pluginViewResolver.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/utils/request.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/utils/ruoyi.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/utils/scroll-to.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/utils/theme.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/utils/time.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/utils/transportCrypto.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/utils/transportCryptoPolicy.js (100%) create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/utils/transportPath.js create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/utils/validate.js rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/auth-center/AuthCenterShell.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/auth-center/changePassword.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/auth-center/consent.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/auth-center/error.vue (100%) create mode 100644 ruoyi-fastapi-frontend/vue3/web/src/views/auth-center/interactionSecurity.js rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/auth-center/login.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/auth-center/useInteraction.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/dashboard/editable-link-group.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/dashboard/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/error/401.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/error/404.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/lock.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/login.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/monitor/cache/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/monitor/cache/list.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/monitor/druid/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/monitor/job/detail.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/monitor/job/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/monitor/job/log.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/monitor/job/runtime.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/monitor/logininfor/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/monitor/oauthAudit/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/monitor/online/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/monitor/operlog/detail.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/monitor/operlog/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/monitor/server/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/monitor/transportCrypto/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/redirect/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/register.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/config/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/dept/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/dict/data.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/dict/detail.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/dict/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/file/components/FileAclDrawer.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/file/components/FileAuditDrawer.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/file/components/FileDetailDialog.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/file/components/FileReconcileDrawer.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/file/components/FileReferenceDrawer.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/file/components/FileRetentionPolicyDrawer.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/file/components/FileRetentionReminderDrawer.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/file/components/FileSearchForm.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/file/components/FileStatistics.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/file/components/FileTable.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/file/components/FileTransferDialog.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/file/components/fileFormatters.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/file/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/menu/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/notice/ReadUsers.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/notice/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/oauth/client/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/oauth/client/secret.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/oauth/grant/AccessPolicyDrawer.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/oauth/grant/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/oauth/key/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/oauth/resource/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/oauth/scope/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/oauth/session/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/plugin/components/PluginConfigDialog.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/plugin/components/PluginConfigStatus.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/plugin/components/PluginDependencyDialog.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/plugin/components/PluginDetailDialog.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/plugin/components/PluginDiagnosticDialog.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/plugin/components/PluginPlanDialog.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/plugin/components/PluginReleaseStatus.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/plugin/components/PluginRuntimeMetrics.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/plugin/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/post/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/role/authUser.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/role/index.vue (97%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/role/selectUser.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/user/authRole.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/user/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/user/profile/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/user/profile/resetPwd.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/user/profile/timezoneSettings.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/user/profile/userAvatar.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/user/profile/userInfo.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/system/user/view.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/tool/build/CodeTypeDialog.vue (100%) mode change 100755 => 100644 rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/tool/build/DraggableItem.vue (100%) mode change 100755 => 100644 rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/tool/build/IconsDialog.vue (100%) mode change 100755 => 100644 rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/tool/build/RightPanel.vue (100%) mode change 100755 => 100644 rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/tool/build/TreeNodeDialog.vue (100%) mode change 100755 => 100644 rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/tool/build/index.vue (100%) mode change 100755 => 100644 rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/tool/gen/basicInfoForm.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/tool/gen/createTable.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/tool/gen/editTable.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/tool/gen/genInfoForm.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/tool/gen/importTable.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/tool/gen/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/src/views/tool/swagger/index.vue (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/tests/auth-center/availability.test.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/tests/auth-center/clientForm.test.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/tests/auth-center/interactionSecurity.test.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/tests/auth-center/oauthUri.test.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/tests/auth-center/useInteraction.test.js (100%) create mode 100644 ruoyi-fastapi-frontend/vue3/web/tests/job/job-form.test.js create mode 100644 ruoyi-fastapi-frontend/vue3/web/tests/plugins/fixtures/bridge-b0d16a3e/.gitattributes create mode 100644 ruoyi-fastapi-frontend/vue3/web/tests/plugins/fixtures/bridge-b0d16a3e/pluginBridge.js create mode 100644 ruoyi-fastapi-frontend/vue3/web/tests/plugins/fixtures/bridge-b0d16a3e/provenance.json create mode 100644 ruoyi-fastapi-frontend/vue3/web/tests/plugins/fixtures/json-only-host.js rename ruoyi-fastapi-frontend/{ => vue3/web}/tests/plugins/monacoEnvironment.test.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/tests/plugins/network-smoke/server.mjs (100%) create mode 100644 ruoyi-fastapi-frontend/vue3/web/tests/plugins/pluginBridge.test.js create mode 100644 ruoyi-fastapi-frontend/vue3/web/tests/plugins/pluginBridgeCompatibility.test.js rename ruoyi-fastapi-frontend/{ => vue3/web}/tests/plugins/pluginConfigStatus.test.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/tests/plugins/pluginDevMock.test.js (96%) rename ruoyi-fastapi-frontend/{ => vue3/web}/tests/plugins/pluginFrame.test.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/tests/plugins/pluginProxy.test.js (99%) rename ruoyi-fastapi-frontend/{ => vue3/web}/tests/plugins/pluginReleaseFormatter.test.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/tests/plugins/pluginRuntimeMetrics.test.js (100%) create mode 100644 ruoyi-fastapi-frontend/vue3/web/tests/plugins/pluginSse.test.js rename ruoyi-fastapi-frontend/{ => vue3/web}/tests/plugins/pluginViewResolver.test.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/tests/plugins/run-plugin-tests.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/tests/run-tests.js (100%) create mode 100644 ruoyi-fastapi-frontend/vue3/web/tests/time/time.test.js rename ruoyi-fastapi-frontend/{ => vue3/web}/vite.config.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/vite/plugins/auto-import.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/vite/plugins/compression.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/vite/plugins/index.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/vite/plugins/monaco-environment.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/vite/plugins/setup-extend.js (100%) rename ruoyi-fastapi-frontend/{ => vue3/web}/vite/plugins/svg-icon.js (100%) create mode 100644 ruoyi-fastapi-test/common/browser_harness.py create mode 100644 ruoyi-fastapi-test/common/page_catalog.py create mode 100644 ruoyi-fastapi-test/conftest.py create mode 100644 ruoyi-fastapi-test/docker.env create mode 100644 ruoyi-fastapi-test/frontend/mobile-behavior.mjs create mode 100644 ruoyi-fastapi-test/frontend/mobile-harness.mjs create mode 100644 ruoyi-fastapi-test/monitor/test_oauth_audit.py create mode 100644 ruoyi-fastapi-test/monitor/test_transport_crypto.py create mode 100644 ruoyi-fastapi-test/prepare_e2e_compose.py create mode 100644 ruoyi-fastapi-test/pytest.ini create mode 100644 ruoyi-fastapi-test/system/page.py create mode 100644 ruoyi-fastapi-test/system/test_file_management.py create mode 100644 ruoyi-fastapi-test/system/test_oauth_management.py create mode 100644 ruoyi-fastapi-test/system/test_plugin_management.py create mode 100644 ruoyi-fastapi-test/test_account_pages.py create mode 100644 ruoyi-fastapi-test/test_browser_harness.py create mode 100644 ruoyi-fastapi-test/test_plugin_response_contract.py create mode 100644 ruoyi-fastapi-test/tool/test_build.py diff --git a/.github/requirements.txt b/.github/requirements.txt new file mode 100644 index 0000000..26cde44 --- /dev/null +++ b/.github/requirements.txt @@ -0,0 +1,3 @@ +pytest==9.1.1 +PyYAML==6.0.3 +ruff==0.16.1 diff --git a/.github/ruff.toml b/.github/ruff.toml new file mode 100644 index 0000000..3c59e39 --- /dev/null +++ b/.github/ruff.toml @@ -0,0 +1,2 @@ +# CI 脚本和测试与后端使用相同的 Python 风格。 +extend = "../ruoyi-fastapi-backend/ruff.toml" diff --git a/.github/scripts/check_compose.py b/.github/scripts/check_compose.py new file mode 100644 index 0000000..2cc2c83 --- /dev/null +++ b/.github/scripts/check_compose.py @@ -0,0 +1,72 @@ +import argparse +import json +import os +import subprocess +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[2] +CONFIGURATIONS = ( + ('docker.env', 'docker-compose.my.yml', 'my'), + ('docker.env', 'docker-compose.pg.yml', 'pg'), + ('ruoyi-fastapi-test/docker.env', 'ruoyi-fastapi-test/docker-compose.test.my.yml', 'my'), + ('ruoyi-fastapi-test/docker.env', 'ruoyi-fastapi-test/docker-compose.test.pg.yml', 'pg'), +) + + +def validate_configuration(config: dict[str, Any], framework: str, engine: str, root: Path) -> None: + """ + 核对 Compose 实际解析后的前后端框架及 nginx 来源。 + + :param config: docker compose config 的 JSON 结果 + :param framework: 本次选择的框架 + :param engine: my 或 pg + :param root: 仓库根目录 + :return: None + """ + expected = (root / 'ruoyi-fastapi-frontend' / framework / 'web').resolve() + services = config['services'] + frontend = services['ruoyi-frontend'] + build = frontend['build'] + if Path(build['context']).resolve() != expected: + raise ValueError(f'{framework}: 前端构建目录不一致') + if not (expected / build.get('dockerfile', 'Dockerfile')).is_file(): + raise ValueError(f'{framework}: 前端 Dockerfile 不存在') + volumes = [ + volume for volume in frontend.get('volumes', []) if volume.get('target') == '/etc/nginx/conf.d/default.conf' + ] + nginx = expected / 'bin' / f'nginx.docker{engine}.conf' + if len(volumes) != 1 or Path(volumes[0]['source']).resolve() != nginx or not nginx.is_file(): + raise ValueError(f'{framework}: nginx 配置未选择同一框架') + backend = services[f'ruoyi-backend-{engine}'] + if backend['environment'].get('RUOYI_PLUGIN_FRONTEND_FRAMEWORK') != framework: + raise ValueError(f'{framework}: 后端插件框架与前端不一致') + + +def main() -> None: + """ + 使用两种框架解析全部生产和测试 Compose,无需启动容器。 + + :return: None + """ + parser = argparse.ArgumentParser() + parser.add_argument('--docker', default='docker') + args = parser.parse_args() + for framework in ('vue2', 'vue3'): + for env_file, compose_file, engine in CONFIGURATIONS: + result = subprocess.run( + [args.docker, 'compose', '--env-file', env_file, '-f', compose_file, 'config', '--format', 'json'], + cwd=ROOT, + env={**os.environ, 'FRONTEND_FRAMEWORK': framework}, + check=True, + capture_output=True, + text=True, + encoding='utf-8', + timeout=60, + ) + validate_configuration(json.loads(result.stdout), framework, engine, ROOT) + print(f'{framework}: {compose_file} PASS') + + +if __name__ == '__main__': + main() diff --git a/.github/scripts/check_test_report.py b/.github/scripts/check_test_report.py new file mode 100644 index 0000000..d9a057e --- /dev/null +++ b/.github/scripts/check_test_report.py @@ -0,0 +1,39 @@ +import argparse +import xml.etree.ElementTree as ET +from pathlib import Path + + +def check_report(path: Path, *, allow_skips: bool = False) -> tuple[int, int]: + """ + 验证测试确实执行,专用验收任务不允许缺少依赖而全部跳过。 + + :param path: pytest JUnit 报告路径 + :param allow_skips: 是否允许普通跨平台回归中的预期跳过 + :return: 实际执行及跳过的测试数量 + """ + root = ET.parse(path).getroot() + cases = root.findall('.//testcase') + skipped = sum(case.find('skipped') is not None for case in cases) + executed = len(cases) - skipped + failures = sum(case.find('failure') is not None or case.find('error') is not None for case in cases) + if executed == 0 or failures or (skipped and not allow_skips): + raise ValueError(f'{path}: 执行 {executed},跳过 {skipped},失败 {failures}') + return executed, skipped + + +def main() -> None: + """ + 检查报告并将执行数量写入 CI 摘要。 + + :return: None + """ + parser = argparse.ArgumentParser() + parser.add_argument('report', type=Path) + parser.add_argument('--allow-skips', action='store_true') + args = parser.parse_args() + executed, skipped = check_report(args.report, allow_skips=args.allow_skips) + print(f'{args.report}: executed={executed}, skipped={skipped}') + + +if __name__ == '__main__': + main() diff --git a/.github/scripts/ci_plan.py b/.github/scripts/ci_plan.py new file mode 100644 index 0000000..28acc93 --- /dev/null +++ b/.github/scripts/ci_plan.py @@ -0,0 +1,245 @@ +import argparse +import json +import os +import subprocess +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[2] +FRAMEWORKS = ('vue2', 'vue3') +PYTHON_VERSIONS = ('3.10', '3.11', '3.12', '3.13') +CATEGORIES = ('quality', 'backend', 'frontend', 'integration', 'e2e', 'native') +FRONTEND_FILE_PATH_PARTS = 4 + + +# 将影响范围规则集中列出,便于逐条审核;拆散分支会掩盖任务之间的联动。 +def build_plan(paths: list[str], *, full: bool = False) -> dict[str, Any]: # noqa: PLR0912, PLR0915 + """ + 根据源码、共享依赖及测试的影响范围生成 CI 执行计划。 + + :param paths: 变更文件,重命名需要同时传入旧路径和新路径 + :param full: 是否执行完整兼容矩阵 + :return: 分类开关、矩阵和必跑任务 + """ + projects: set[tuple[str, str]] = set() + browsers: set[str] = set() + networks: set[str] = set() + backend = native = database = release = False + normalized = [path.replace('\\', '/').removeprefix('./') for path in paths] + shared = full or any( + path.startswith(('.github/', 'ruoyi-fastapi-backend/requirements')) + or path + in ( + 'ruoyi-fastapi-backend/pyproject.toml', + 'ruoyi-fastapi-backend/tests/conftest.py', + 'ruoyi-fastapi-test/requirements.txt', + '.gitignore', + ) + for path in normalized + ) + if shared: + projects.update((framework, target) for framework in FRAMEWORKS for target in ('web', 'mobile')) + browsers.update(FRAMEWORKS) + networks.update(FRAMEWORKS) + backend = native = database = release = True + for path in normalized: + if path.lower().endswith('.md'): + continue + parts = path.split('/') + if path.startswith('ruoyi-fastapi-backend/'): + backend = True + relative = path.removeprefix('ruoyi-fastapi-backend/') + if not relative.startswith(('tests/', 'ruff.toml')): + browsers.update(FRAMEWORKS) + if relative.startswith(('common/', 'config/', 'utils/', 'sql/', 'scripts/verify_timezone_database')): + database = True + if relative.startswith( + ( + 'plugins/', + 'module_plugin/', + 'cli/', + 'tests/plugins/', + 'tests/cli/', + 'tests/module_plugin/', + 'tests/sql/', + ) + ): + native = release = True + if relative == 'scripts/build_native_plugin.py': + native = True + if relative.startswith( + ('plugins/', 'module_plugin/', 'common/', 'config/', 'middlewares/', 'utils/') + ) or relative in ('server.py', 'app.py', 'module_admin/service/login_service.py'): + networks.update(FRAMEWORKS) + if relative.startswith(('config/', 'sql/', 'scripts/plugin_', 'tests/server/')) or relative in ( + 'server.py', + 'app.py', + ): + release = True + if relative.startswith('tests/plugins/integration/') or relative in ( + 'tests/scheduler_helpers.py', + 'tests/plugins/core/runtime/test_browser_session.py', + ): + networks.update(FRAMEWORKS) + if relative in ( + 'plugins/core/frontend.py', + 'plugins/core/environment.py', + 'plugins/core/manifest/schema.py', + ): + projects.update((framework, target) for framework in FRAMEWORKS for target in ('web', 'mobile')) + if relative.startswith('plugins/examples/python/bundle_demo/web/'): + projects.update((framework, 'web') for framework in FRAMEWORKS) + elif ( + len(parts) >= FRONTEND_FILE_PATH_PARTS + and parts[0] == 'ruoyi-fastapi-frontend' + and parts[1] in FRAMEWORKS + and parts[2] in ('web', 'mobile') + ): + framework, target = parts[1:3] + projects.add((framework, target)) + if target == 'web': + browsers.add(framework) + networks.add(framework) + if target == 'web' and (parts[-1].startswith('pluginBridge') or parts[-1] == 'package.json'): + backend = native = release = True + elif path.startswith('ruoyi-fastapi-frontend/'): + projects.update((framework, target) for framework in FRAMEWORKS for target in ('web', 'mobile')) + browsers.update(FRAMEWORKS) + networks.update(FRAMEWORKS) + elif path.startswith('ruoyi-fastapi-test/frontend/'): + projects.update((framework, 'mobile') for framework in FRAMEWORKS) + elif path.startswith('ruoyi-fastapi-test/time-contract/'): + projects.update((framework, target) for framework in FRAMEWORKS for target in ('web', 'mobile')) + database = True + browsers.update(FRAMEWORKS) + elif path.startswith('ruoyi-fastapi-test/') or path in ( + 'docker.env', + 'docker-compose.my.yml', + 'docker-compose.pg.yml', + ): + browsers.update(FRAMEWORKS) + if 'docker' in path or path.endswith('.sql'): + database = True + + required = { + 'quality': True, + 'backend': backend, + 'frontend': bool(projects), + 'integration': database or release or bool(networks), + 'e2e': bool(browsers), + 'native': native, + } + return { + 'full': full, + 'required': required, + 'frontend_matrix': { + 'include': [{'framework': framework, 'target': target} for framework, target in sorted(projects)] + }, + 'e2e_matrix': { + 'include': [ + {'framework': framework, 'database': engine, 'python': version} + for framework in sorted(browsers) + for engine in ('mysql', 'postgresql') + for version in (PYTHON_VERSIONS if full else ('3.12',)) + ] + }, + 'network_frameworks': sorted(networks), + 'database': database, + 'release': release, + 'paths': normalized, + } + + +def check_results(plan: dict[str, Any], needs: dict[str, Any]) -> list[str]: + """ + 核验必跑任务,拒绝失败、取消、缺失和意外跳过。 + + :param plan: 选路任务生成的计划 + :param needs: GitHub needs 上下文 + :return: 不满足门禁的原因 + """ + errors = [] + if needs.get('plan', {}).get('result') != 'success': + errors.append('CI 计划未成功生成') + required = plan.get('required', {}) + if set(required) != set(CATEGORIES) or not all(isinstance(value, bool) for value in required.values()): + return [*errors, 'CI 计划缺少完整的任务分类'] + for category in CATEGORIES: + result = needs.get(category, {}).get('result') + allowed = ('success',) if required[category] else ('success', 'skipped') + if result not in allowed: + errors.append(f'{category}: {result or "missing"},预期 {"/".join(allowed)}') + return errors + + +def changed_paths(base: str) -> list[str]: + """ + 从 Git 获取变更文件,保留删除及重命名前后的路径。 + + :param base: 已检出的比较基准提交 + :return: 相对仓库根目录的路径 + """ + result = subprocess.run( + ['git', 'diff', '--no-renames', '--name-only', '-z', base, 'HEAD'], + cwd=ROOT, + check=True, + capture_output=True, + timeout=60, + ) + return [path for path in result.stdout.decode('utf-8').split('\0') if path] + + +def write_outputs(plan: dict[str, Any]) -> None: + """ + 输出 GitHub 任务参数及可审阅的执行摘要。 + + :param plan: 本次运行计划 + :return: None + """ + payload = { + **plan['required'], + **{key: value for key, value in plan.items() if key not in ('required', 'paths')}, + 'plan_json': plan, + } + if output := os.environ.get('GITHUB_OUTPUT'): + with Path(output).open('a', encoding='utf-8') as stream: + for key, value in payload.items(): + stream.write(f'{key}={json.dumps(value, ensure_ascii=False, separators=(",", ":"))}\n') + if summary := os.environ.get('GITHUB_STEP_SUMMARY'): + with Path(summary).open('a', encoding='utf-8') as stream: + stream.write('## CI 执行计划\n\n| 分类 | 执行 |\n|---|---|\n') + for category, required in plan['required'].items(): + stream.write(f'| {category} | {"运行" if required else "无相关改动"} |\n') + stream.write(f'\n完整矩阵:{plan["full"]}\n') + print(json.dumps(plan, ensure_ascii=False, indent=2)) + + +def main() -> None: + """ + 执行变更选路或最终结果校验。 + + :return: None + """ + parser = argparse.ArgumentParser() + parser.add_argument('command', choices=('plan', 'gate')) + args = parser.parse_args() + if args.command == 'gate': + plan = json.loads(os.environ.get('PLAN_JSON') or '{}') + needs = json.loads(os.environ.get('NEEDS_JSON') or '{}') + errors = check_results(plan, needs) + if errors: + raise SystemExit('\n'.join(errors)) + print('所有应执行的 CI 分类均已通过。') + return + event = json.loads(Path(os.environ['GITHUB_EVENT_PATH']).read_text(encoding='utf-8')) + event_name = os.environ['GITHUB_EVENT_NAME'] + full = event_name in ('push', 'schedule') or ( + str(event.get('inputs', {}).get('full', 'true')).lower() == 'true' and event_name == 'workflow_dispatch' + ) + base = event.get('pull_request', {}).get('base', {}).get('sha', '') + paths = changed_paths(base) if base else ['.github/workflows/ci.yml'] + write_outputs(build_plan(paths, full=full)) + + +if __name__ == '__main__': + main() diff --git a/.github/scripts/install_ai_test_dependencies.py b/.github/scripts/install_ai_test_dependencies.py new file mode 100644 index 0000000..6ab49c7 --- /dev/null +++ b/.github/scripts/install_ai_test_dependencies.py @@ -0,0 +1,23 @@ +import subprocess +import sys +from pathlib import Path + +import yaml + + +def main() -> None: + """ + 从 AI 插件清单安装测试依赖,避免 CI 维护第二份版本列表。 + + :return: None + """ + backend = Path(__file__).resolve().parents[2] / 'ruoyi-fastapi-backend' + manifest = yaml.safe_load((backend / 'plugins/ai/plugin.yaml').read_text(encoding='utf-8')) + requirements = manifest['dependencies']['python'] + if not requirements or not all(isinstance(item, str) and not item.startswith('-') for item in requirements): + raise ValueError('AI 插件 Python 依赖清单无效') + subprocess.run([sys.executable, '-m', 'pip', 'install', *requirements], check=True, timeout=900) + + +if __name__ == '__main__': + main() diff --git a/.github/scripts/mobile_smoke.py b/.github/scripts/mobile_smoke.py new file mode 100644 index 0000000..1530bb8 --- /dev/null +++ b/.github/scripts/mobile_smoke.py @@ -0,0 +1,184 @@ +import argparse +import asyncio +import json +import os +import re +import threading +import time +from collections.abc import Awaitable, Callable +from functools import partial +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +from playwright.async_api import Route, async_playwright, expect + +ROOT = Path(__file__).resolve().parents[2] +TOKEN = 'isolated-mobile-smoke-token' +USER = { + 'userId': 7, + 'userName': 'ci-user', + 'nickName': '移动端 CI', + 'avatar': '', + 'phonenumber': '13800000000', + 'email': 'mobile-ci@example.test', + 'createTime': '2026-01-01T00:00:00Z', + 'timeZone': 'America/New_York', +} + + +def create_api_handler(errors: list[str], observed: list[dict[str, Any]]) -> Callable[[Route], Awaitable[None]]: + """ + 创建隔离 API 处理函数,向共享记录追加请求及异常。 + + :param errors: 本次验收收集的异常说明 + :param observed: 实际发出的 API 请求记录 + :return: 供浏览器路由使用的异步处理函数 + """ + + async def api(route: Route) -> None: + """ + 隔离 API 边界,记录真实构建发出的认证和时区请求。 + + :param route: 当前浏览器 API 请求 + :return: None + """ + request = route.request + path = urlparse(request.url).path + headers = {'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': '*'} + if request.method == 'OPTIONS': + await route.fulfill(status=204, headers=headers) + return + observed.append( + { + 'path': path, + 'method': request.method, + 'authorization': request.headers.get('authorization'), + 'timezone': request.headers.get('x-timezone'), + } + ) + payload: dict[str, Any] + if path == '/transport/crypto/frontend-config': + payload = { + 'code': 200, + 'data': { + 'transportCryptoEnabled': False, + 'transportCryptoMode': 'off', + 'transportCryptoActive': False, + 'configExpireAt': int(time.time()) + 600, + }, + } + elif path == '/captchaImage': + payload = {'code': 200, 'captchaEnabled': False} + elif path == '/login': + if 'ci-user' not in (request.post_data or ''): + errors.append('登录请求未包含输入的用户名') + payload = {'code': 200, 'token': TOKEN} + elif path == '/getInfo': + payload = { + 'code': 200, + 'user': USER, + 'roles': ['admin'], + 'permissions': ['*:*:*'], + 'appTimezone': 'Asia/Shanghai', + } + elif path == '/system/user/profile': + payload = {'code': 200, 'data': USER, 'postGroup': 'CI', 'roleGroup': '管理员'} + elif path == '/logout': + payload = {'code': 200} + else: + errors.append(f'未预期的 API 请求:{path}') + await route.fulfill(status=500, json={'code': 500, 'msg': 'unexpected smoke request'}, headers=headers) + return + await route.fulfill(json=payload, headers=headers) + + return api + + +async def run_smoke(framework: str, base_url: str, output: Path) -> None: + """ + 使用真实 H5 构建验证登录、持久化身份和个人资料页。 + + :param framework: vue2 或 vue3 + :param base_url: 仅用于本次测试的静态文件服务器 + :param output: 浏览器报告目录 + :return: None + """ + logs: list[str] = [] + errors: list[str] = [] + observed: list[dict[str, Any]] = [] + api = create_api_handler(errors, observed) + + async with async_playwright() as playwright: + executable = os.environ.get('RUOYI_SMOKE_BROWSER_EXECUTABLE') + browser = await playwright.chromium.launch( + headless=True, **({'executable_path': executable} if executable else {}) + ) + 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) + 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}')) + failed = False + try: + await page.goto(f'{base_url}/#/pages/login') + await expect(page.locator('input[type="text"]')).to_be_visible(timeout=30000) + 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() + 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) + await expect(page.get_by_text('2025-12-31 19:00:00', exact=True)).to_be_visible(timeout=15000) + profile_requests = [request for request in observed if request['path'] == '/system/user/profile'] + assert profile_requests, '个人资料页未发送真实 API 请求' + assert all(request['authorization'] == f'Bearer {TOKEN}' for request in profile_requests) + assert all(request['timezone'] == 'America/New_York' for request in profile_requests) + assert not errors, errors + (output / 'result.json').write_text( + json.dumps({'framework': framework, 'ok': True, 'requests': observed}, ensure_ascii=False, indent=2), + encoding='utf-8', + ) + print(f'{framework}: H5 login, persisted session and timezone profile PASS') + except BaseException: + failed = True + await page.screenshot(path=str(output / 'failure.png'), full_page=True) + (output / 'page.html').write_text(await page.content(), encoding='utf-8') + raise + finally: + (output / 'browser.log').write_text('\n'.join([*logs, *errors]), encoding='utf-8') + await context.tracing.stop(**({'path': str(output / 'trace.zip')} if failed else {})) + await context.close() + await browser.close() + + +def main() -> None: + """ + 在独立临时端口启动 H5 静态服务,结束后关闭自身创建的资源。 + + :return: None + """ + parser = argparse.ArgumentParser() + parser.add_argument('--framework', choices=('vue2', 'vue3'), required=True) + args = parser.parse_args() + distribution = ROOT / 'ruoyi-fastapi-frontend' / args.framework / 'mobile/dist/build/h5' + if not (distribution / 'index.html').is_file(): + raise SystemExit(f'请先构建 H5:{distribution}') + output = ROOT / 'target/ci/mobile-smoke' / args.framework + output.mkdir(parents=True, exist_ok=True) + server = ThreadingHTTPServer(('127.0.0.1', 0), partial(SimpleHTTPRequestHandler, directory=str(distribution))) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + asyncio.run(run_smoke(args.framework, f'http://127.0.0.1:{server.server_port}', output)) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +if __name__ == '__main__': + main() diff --git a/.github/tests/test_ci_contracts.py b/.github/tests/test_ci_contracts.py new file mode 100644 index 0000000..8e52c66 --- /dev/null +++ b/.github/tests/test_ci_contracts.py @@ -0,0 +1,91 @@ +import importlib.util +from copy import deepcopy +from pathlib import Path +from types import ModuleType +from typing import Any + +import pytest + +ROOT = Path(__file__).resolve().parents[2] + + +def load_script(name: str) -> ModuleType: + """ + 按文件路径加载 CI 脚本,避免依赖仓库外的模块搜索路径。 + + :param name: 不含扩展名的脚本名称 + :return: 已执行模块顶层定义的脚本模块 + """ + spec = importlib.util.spec_from_file_location(name, ROOT / '.github/scripts' / f'{name}.py') + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +compose = load_script('check_compose') +reports = load_script('check_test_report') + + +@pytest.fixture +def configuration() -> dict[str, Any]: + """提供前端、nginx 和后端框架一致的 Compose 配置。""" + frontend = ROOT / 'ruoyi-fastapi-frontend/vue2/web' + return { + 'services': { + 'ruoyi-frontend': { + 'build': {'context': str(frontend), 'dockerfile': 'Dockerfile'}, + 'volumes': [ + {'source': str(frontend / 'bin/nginx.dockermy.conf'), 'target': '/etc/nginx/conf.d/default.conf'} + ], + }, + 'ruoyi-backend-my': {'environment': {'RUOYI_PLUGIN_FRONTEND_FRAMEWORK': 'vue2'}}, + } + } + + +def test_compose_accepts_consistent_framework(configuration: dict[str, Any]) -> None: + """框架一致的 Compose 配置可以通过校验。""" + compose.validate_configuration(configuration, 'vue2', 'my', ROOT) + + +@pytest.mark.parametrize('component', ['build', 'nginx', 'backend']) +def test_compose_rejects_mixed_framework_deployment(configuration: dict[str, Any], component: str) -> None: + """前端、nginx 或后端混用框架时拒绝部署配置。""" + invalid = deepcopy(configuration) + if component == 'build': + invalid['services']['ruoyi-frontend']['build']['context'] = str(ROOT / 'ruoyi-fastapi-frontend/vue3/web') + elif component == 'nginx': + invalid['services']['ruoyi-frontend']['volumes'][0]['source'] = str( + ROOT / 'ruoyi-fastapi-frontend/vue3/web/bin/nginx.dockermy.conf' + ) + else: + invalid['services']['ruoyi-backend-my']['environment']['RUOYI_PLUGIN_FRONTEND_FRAMEWORK'] = 'vue3' + with pytest.raises(ValueError): + compose.validate_configuration(invalid, 'vue2', 'my', ROOT) + + +@pytest.mark.parametrize( + 'cases', + ['', '', '', ''], +) +def test_report_rejects_empty_skipped_or_failed_verification(tmp_path: Path, cases: str) -> None: + """空报告、全部跳过和失败用例都不能视为验收成功。""" + report = tmp_path / 'results.xml' + report.write_text(f'{cases}', encoding='utf-8') + with pytest.raises(ValueError): + reports.check_report(report) + + +def test_expected_platform_skip_still_requires_executed_tests(tmp_path: Path) -> None: + """允许预期平台跳过时仍要求实际执行测试。""" + report = tmp_path / 'results.xml' + report.write_text( + '', encoding='utf-8' + ) + assert reports.check_report(report, allow_skips=True) == (1, 1) + + +def test_missing_report_is_not_success(tmp_path: Path) -> None: + """报告缺失时不能视为验收成功。""" + with pytest.raises(FileNotFoundError): + reports.check_report(tmp_path / 'missing.xml') diff --git a/.github/tests/test_ci_plan.py b/.github/tests/test_ci_plan.py new file mode 100644 index 0000000..fe548e3 --- /dev/null +++ b/.github/tests/test_ci_plan.py @@ -0,0 +1,169 @@ +import importlib.util +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).resolve().parents[1] / 'scripts' / 'ci_plan.py' +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 + + +def test_readme_change_only_runs_quality() -> None: + """仅文档变更时只执行质量检查。""" + assert ci.build_plan(['README.md'])['required'] == { + 'quality': True, + 'backend': False, + 'frontend': False, + 'integration': False, + 'e2e': False, + 'native': False, + } + + +@pytest.mark.parametrize( + 'path', + [ + 'ruoyi-fastapi-backend/cli/groups/plugin/options.py', + 'ruoyi-fastapi-backend/cli/groups/plugin/controller.py', + 'ruoyi-fastapi-backend/cli/runtime/plugin/service.py', + 'ruoyi-fastapi-backend/tests/module_plugin/controller/test_plugin_release_controller.py', + 'ruoyi-fastapi-backend/tests/sql/test_plugin_release_schema.py', + ], +) +def test_shared_plugin_changes_retain_windows_and_release_coverage(path: str) -> None: + """共享插件变更保留原生模块及发布验收覆盖。""" + plan = ci.build_plan([path]) + assert plan['required']['backend'] + assert plan['required']['native'] + assert plan['release'] + + +def test_native_builder_change_runs_native_verification() -> None: + """原生构建器变更必须触发原生验收。""" + plan = ci.build_plan(['ruoyi-fastapi-backend/scripts/build_native_plugin.py']) + assert plan['required']['native'] + + +def test_bundle_example_change_runs_both_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': 'vue2', 'target': 'web'}, + {'framework': 'vue3', 'target': 'web'}, + ] + } + + +@pytest.mark.parametrize( + 'path', + [ + 'ruoyi-fastapi-backend/module_admin/service/login_service.py', + 'ruoyi-fastapi-backend/tests/scheduler_helpers.py', + '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: + """共享鉴权和测试夹具变更触发两版网络验收。""" + plan = ci.build_plan([path]) + assert plan['required']['backend'] + assert plan['required']['integration'] + assert plan['network_frameworks'] == ['vue2', 'vue3'] + + +@pytest.mark.parametrize( + 'path', ['.github/workflows/ci.yml', '.github/scripts/ci_plan.py', 'ruoyi-fastapi-backend/requirements-test.txt'] +) +def test_ci_and_dependency_changes_select_every_category(path: str) -> None: + """CI 及共享依赖变更覆盖全部任务分类。""" + plan = ci.build_plan([path]) + assert all(plan['required'].values()) + assert len(plan['frontend_matrix']['include']) == FRONTEND_PROJECT_COUNT + assert len(plan['e2e_matrix']['include']) == STANDARD_E2E_COMBINATION_COUNT + + +def test_mobile_change_does_not_build_other_projects_or_web_e2e() -> None: + """单个移动端变更只选中对应工程。""" + plan = ci.build_plan(['ruoyi-fastapi-frontend/vue2/mobile/src/utils/request.js']) + assert plan['frontend_matrix'] == {'include': [{'framework': 'vue2', 'target': 'mobile'}]} + assert not plan['required']['e2e'] + + +@pytest.mark.parametrize( + 'path', + [ + 'ruoyi-fastapi-test/frontend/mobile-harness.mjs', + 'ruoyi-fastapi-test/frontend/mobile-behavior.mjs', + ], +) +def test_shared_mobile_harness_runs_both_mobile_projects(path: str) -> None: + """共享移动端测试工具变更同时验证两版工程。""" + plan = ci.build_plan([path]) + assert plan['required']['frontend'] + assert plan['frontend_matrix'] == { + 'include': [ + {'framework': 'vue2', 'target': 'mobile'}, + {'framework': 'vue3', 'target': 'mobile'}, + ] + } + assert not plan['required']['e2e'] + + +def test_web_change_selects_matching_framework_and_both_databases() -> None: + """Web 变更使用对应框架并覆盖两类数据库。""" + plan = ci.build_plan(['ruoyi-fastapi-frontend/vue2/web/src/views/login.vue']) + assert plan['network_frameworks'] == ['vue2'] + assert {(row['framework'], row['database'], row['python']) for row in plan['e2e_matrix']['include']} == { + ('vue2', 'mysql', '3.12'), + ('vue2', 'postgresql', '3.12'), + } + + +def test_shared_time_contract_runs_all_frontends_and_database_checks() -> None: + """共享时间契约触发全部前端及数据库检查。""" + plan = ci.build_plan(['ruoyi-fastapi-test/time-contract/fixtures.json']) + assert len(plan['frontend_matrix']['include']) == FRONTEND_PROJECT_COUNT + assert plan['database'] + + +def test_framework_move_keeps_both_sides_in_plan() -> None: + """跨框架移动文件保留来源和目标两侧验证。""" + plan = ci.build_plan( + ['ruoyi-fastapi-frontend/vue2/web/src/utils/demo.js', 'ruoyi-fastapi-frontend/vue3/web/src/utils/demo.js'] + ) + assert plan['network_frameworks'] == ['vue2', 'vue3'] + + +def test_full_run_preserves_python_database_and_framework_combinations() -> None: + """完整运行保留 Python、数据库和框架的全部组合。""" + plan = ci.build_plan([], full=True) + assert all(plan['required'].values()) + assert len(plan['e2e_matrix']['include']) == FULL_E2E_COMBINATION_COUNT + assert {row['python'] for row in plan['e2e_matrix']['include']} == {'3.10', '3.11', '3.12', '3.13'} + + +@pytest.mark.parametrize('result', ['failure', 'cancelled', 'skipped', None]) +def test_gate_rejects_unsuccessful_required_job(result: str | None) -> None: + """必跑任务失败、取消、跳过或缺失均不能通过门禁。""" + plan = ci.build_plan(['README.md']) + needs = {'plan': {'result': 'success'}, **{name: {'result': 'skipped'} for name in ci.CATEGORIES}} + needs['quality']['result'] = result + assert ci.check_results(plan, needs) + + +def test_gate_allows_only_unselected_jobs_to_skip() -> None: + """只有未选中的任务允许跳过。""" + plan = ci.build_plan(['README.md']) + needs = {'plan': {'result': 'success'}, **{name: {'result': 'skipped'} for name in ci.CATEGORIES}} + needs['quality']['result'] = 'success' + assert ci.check_results(plan, needs) == [] + + +def test_gate_fails_closed_when_plan_or_job_is_missing() -> None: + """缺少执行计划或任务结果时门禁必须拒绝放行。""" + assert ci.check_results({}, {}) + assert ci.check_results(ci.build_plan(['README.md']), {'plan': {'result': 'success'}}) diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml deleted file mode 100644 index 7c332c9..0000000 --- a/.github/workflows/backend.yml +++ /dev/null @@ -1,64 +0,0 @@ -name: Backend Tests - -on: - push: - branches: [master] - pull_request: - branches: [master] - -jobs: - pytest: - runs-on: ubuntu-latest - env: - # Typer otherwise forces ANSI help output when GITHUB_ACTIONS is set. - _TYPER_FORCE_DISABLE_TERMINAL: "1" - strategy: - fail-fast: false - matrix: - python-version: ["3.10", "3.11", "3.12", "3.13"] - - defaults: - run: - working-directory: ruoyi-fastapi-backend - - steps: - - uses: actions/checkout@v5 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 - with: - python-version: ${{ matrix.python-version }} - cache: pip - cache-dependency-path: | - ruoyi-fastapi-backend/requirements.txt - ruoyi-fastapi-backend/plugins/ai/plugin.yaml - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt pytest pytest-asyncio aiosqlite - - - name: Install AI plugin dependencies - run: | - pip install \ - agno==2.8.6 \ - anthropic==0.120.2 \ - cerebras-cloud-sdk==1.91.0 \ - cohere==7.0.8 \ - google-genai==2.16.0 \ - groq==1.6.0 \ - litellm==1.94.0 \ - llama-api-client==0.6.0 \ - mistralai==2.8.0 \ - ollama==0.6.2 \ - openai==2.52.0 \ - portkey-ai==2.3.4 - - - name: Run backend tests - run: pytest -q - - - name: Check timezone behavior under independent host timezones - run: | - for zone in UTC Asia/Shanghai America/New_York; do - TZ="$zone" python -m pytest -q tests/common/test_types.py tests/common/test_query_time.py tests/utils/test_time_util.py tests/config/test_scheduler_database.py tests/utils/test_cron_util.py tests/module_admin/controller/test_job_controller.py tests/config/test_scheduler_job_persistence.py - done diff --git a/.github/workflows/ci-backend.yml b/.github/workflows/ci-backend.yml new file mode 100644 index 0000000..d447384 --- /dev/null +++ b/.github/workflows/ci-backend.yml @@ -0,0 +1,110 @@ +name: CI / Backend + +on: + workflow_call: + +permissions: + contents: read + +env: + _TYPER_FORCE_DISABLE_TERMINAL: '1' + PYTHONUTF8: '1' + +jobs: + unit-contract: + name: Python ${{ matrix.python }} + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + python: ['3.10', '3.11', '3.12', '3.13'] + defaults: + run: + working-directory: ruoyi-fastapi-backend + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python }} + cache: pip + cache-dependency-path: | + ruoyi-fastapi-backend/requirements*.txt + ruoyi-fastapi-backend/plugins/ai/plugin.yaml + - name: Install test dependencies + run: python -m pip install -r requirements.txt -r requirements-test.txt + - name: Install AI plugin dependencies from its manifest + run: python ../.github/scripts/install_ai_test_dependencies.py + - name: Run backend unit and contract tests + run: python -m pytest tests -q -m 'not browser and not native and not integration' --strict-markers --durations=20 --junitxml=target/ci/backend.xml + - name: Verify tests were executed + run: python ../.github/scripts/check_test_report.py target/ci/backend.xml --allow-skips + - uses: actions/upload-artifact@v6 + if: always() + with: + name: backend-python-${{ matrix.python }} + path: ruoyi-fastapi-backend/target/ci/ + retention-days: 7 + + windows-contract: + name: Windows CLI and paths + runs-on: windows-latest + timeout-minutes: 20 + defaults: + run: + working-directory: ruoyi-fastapi-backend + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + cache: pip + cache-dependency-path: ruoyi-fastapi-backend/requirements*.txt + - run: python -m pip install -r requirements.txt -r requirements-test.txt + - name: Verify CLI, paths, artifacts and portable runtime contracts + run: >- + python -m pytest tests/cli + tests/plugins/core/test_frontend_layout.py tests/plugins/core/validation/test_frontend_dependencies.py + tests/plugins/core/native tests/plugins/core/runtime/test_host_services.py tests/plugins/core/runtime/test_job_dispatcher.py + tests/plugins/core/artifacts tests/plugins/core/deployment + tests/plugins/core/management/test_release_dao.py tests/plugins/core/management/test_artifact_views.py + tests/module_plugin/controller/test_plugin_release_controller.py tests/sql/test_plugin_release_schema.py + tests/plugins/examples/test_task_demo.py + -m 'not native and not browser and not integration' -q --strict-markers --durations=15 --junitxml=target/ci/windows-contract.xml + - run: python ../.github/scripts/check_test_report.py target/ci/windows-contract.xml --allow-skips + - uses: actions/upload-artifact@v6 + if: always() + with: + name: backend-windows-contract + path: ruoyi-fastapi-backend/target/ci/ + retention-days: 7 + + timezone: + name: Host timezone contracts + runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + working-directory: ruoyi-fastapi-backend + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + cache: pip + cache-dependency-path: ruoyi-fastapi-backend/requirements*.txt + - run: python -m pip install -r requirements.txt -r requirements-test.txt + - name: Verify UTC, Shanghai and New York in separate processes + shell: bash + run: | + for zone in UTC Asia/Shanghai America/New_York; do + report="target/ci/timezone-${zone//\//_}.xml" + TZ="$zone" python -m pytest -q tests/common/test_types.py tests/common/test_query_time.py tests/utils/test_time_util.py tests/config/test_scheduler_database.py tests/utils/test_cron_util.py tests/module_admin/controller/test_job_controller.py tests/config/test_scheduler_job_persistence.py --junitxml="$report" + python ../.github/scripts/check_test_report.py "$report" + done + - uses: actions/upload-artifact@v6 + if: always() + with: + name: backend-timezones + path: ruoyi-fastapi-backend/target/ci/ + retention-days: 7 diff --git a/.github/workflows/ci-e2e.yml b/.github/workflows/ci-e2e.yml new file mode 100644 index 0000000..f3ef383 --- /dev/null +++ b/.github/workflows/ci-e2e.yml @@ -0,0 +1,111 @@ +name: CI / E2E + +on: + workflow_call: + inputs: + matrix_json: + type: string + required: true + +permissions: + contents: read + +jobs: + browser: + name: ${{ matrix.framework }} / ${{ matrix.database }} / Python ${{ matrix.python }} + runs-on: ubuntu-latest + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: ${{ fromJSON(inputs.matrix_json) }} + env: + COMPOSE_PROJECT_NAME: ruoyi-ci-${{ github.run_id }}-${{ github.run_attempt }}-${{ strategy.job-index }} + COMPOSE_FILE: ${{ github.workspace }}/ruoyi-fastapi-test/target/e2e/compose.json + FRONTEND_FRAMEWORK: ${{ matrix.framework }} + E2E_OUTPUT_DIR: ${{ github.workspace }}/ruoyi-fastapi-test/target/e2e/browser + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python }} + cache: pip + cache-dependency-path: ruoyi-fastapi-test/requirements.txt + - name: Install test runner and Chromium + run: | + python -m pip install -r ruoyi-fastapi-test/requirements.txt + python -m playwright install --with-deps chromium + - name: Verify reusable browser and isolated contexts + working-directory: ruoyi-fastapi-test + run: | + python -m pytest -m harness -q --junitxml=target/e2e/harness.xml + python ../.github/scripts/check_test_report.py target/e2e/harness.xml + - name: Generate isolated Compose project + run: | + python ruoyi-fastapi-test/prepare_e2e_compose.py \ + --database '${{ matrix.database }}' --framework '${{ matrix.framework }}' \ + --python-version '${{ matrix.python }}' --project "$COMPOSE_PROJECT_NAME" \ + --output "$COMPOSE_FILE" + docker compose config --quiet + - uses: docker/setup-buildx-action@v3 + - name: Build frontend with reusable layers + uses: docker/build-push-action@v6 + with: + context: ruoyi-fastapi-frontend/${{ matrix.framework }}/web + file: ruoyi-fastapi-frontend/${{ matrix.framework }}/web/Dockerfile + load: true + tags: ${{ env.COMPOSE_PROJECT_NAME }}-ruoyi-frontend:ci + cache-from: type=gha,scope=e2e-frontend-${{ matrix.framework }} + cache-to: type=gha,scope=e2e-frontend-${{ matrix.framework }},mode=max + - name: Select database Dockerfile + id: database + env: + DATABASE: ${{ matrix.database }} + run: | + if [ "$DATABASE" = mysql ]; then + echo 'suffix=my' >> "$GITHUB_OUTPUT" + else + echo 'suffix=pg' >> "$GITHUB_OUTPUT" + fi + - name: Build backend with reusable layers + uses: docker/build-push-action@v6 + with: + context: ruoyi-fastapi-backend + file: ruoyi-fastapi-backend/Dockerfile.${{ steps.database.outputs.suffix }} + load: true + tags: ${{ env.COMPOSE_PROJECT_NAME }}-ruoyi-backend-${{ steps.database.outputs.suffix }}:ci + build-args: | + PYTHON_VERSION=${{ matrix.python }} + PIP_INDEX_URL=https://pypi.org/simple + cache-from: type=gha,scope=e2e-backend-${{ matrix.database }}-py${{ matrix.python }} + cache-to: type=gha,scope=e2e-backend-${{ matrix.database }}-py${{ matrix.python }},mode=max + - name: Wait for healthy database, API and frontend proxy + run: | + 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 + working-directory: ruoyi-fastapi-test + run: | + python -m pytest -m e2e -v --durations=20 --junitxml=target/e2e/junit.xml + python ../.github/scripts/check_test_report.py target/e2e/junit.xml + - name: Collect service diagnostics + if: always() + run: | + mkdir -p ruoyi-fastapi-test/target/e2e + if [ -f "$COMPOSE_FILE" ]; then + docker compose ps --all > ruoyi-fastapi-test/target/e2e/services.txt 2>&1 || true + docker compose logs --no-color --timestamps > ruoyi-fastapi-test/target/e2e/services.log 2>&1 || true + fi + - name: Remove only this test project's containers and data + if: always() + run: | + if [ -f "$COMPOSE_FILE" ]; then + docker compose down --volumes --remove-orphans --timeout 20 + fi + - uses: actions/upload-artifact@v6 + if: always() + with: + name: e2e-${{ matrix.framework }}-${{ matrix.database }}-py${{ matrix.python }} + path: ruoyi-fastapi-test/target/e2e + retention-days: 7 + if-no-files-found: warn diff --git a/.github/workflows/ci-frontend.yml b/.github/workflows/ci-frontend.yml new file mode 100644 index 0000000..68cf449 --- /dev/null +++ b/.github/workflows/ci-frontend.yml @@ -0,0 +1,116 @@ +name: CI / Frontend + +on: + workflow_call: + inputs: + matrix_json: + description: 'JSON matrix with framework and target entries under include' + required: true + type: string + full: + description: 'Reserved for callers requesting the full project matrix' + required: false + default: false + type: boolean + +permissions: + contents: read + +jobs: + frontend: + name: ${{ matrix.framework }} / ${{ matrix.target }} + runs-on: ubuntu-latest + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: ${{ fromJSON(inputs.matrix_json) }} + env: + NODE_OPTIONS: ${{ matrix.framework == 'vue2' && '--openssl-legacy-provider' || '' }} + defaults: + run: + working-directory: ruoyi-fastapi-frontend/${{ matrix.framework }}/${{ matrix.target }} + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v6 + with: + node-version: '22' + - name: Install the Vue 2 Mobile package manager + 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' + with: + package_json_file: ruoyi-fastapi-frontend/vue3/mobile/package.json + - name: Locate the package cache + id: package-cache + env: + FRAMEWORK: ${{ matrix.framework }} + TARGET: ${{ matrix.target }} + run: | + if [ "$TARGET" = web ]; then + echo "path=$(npm config get cache)" >> "$GITHUB_OUTPUT" + elif [ "$FRAMEWORK" = vue2 ]; then + echo "path=$(yarn cache dir --silent)" >> "$GITHUB_OUTPUT" + else + echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + fi + - uses: actions/cache@v5 + 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)) }} + restore-keys: | + ${{ runner.os }}-node22-${{ matrix.framework }}-${{ matrix.target }}- + - name: Install Web dependencies + if: matrix.target == 'web' + 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' + run: pnpm install --no-frozen-lockfile + - name: Run unit and component behavior tests + run: npm run test:unit + - name: Verify time contracts in three host timezones + run: | + for zone in UTC Asia/Shanghai America/New_York; do + TZ="$zone" npm run test:contract + done + - name: Verify the Vue 2 Mobile browser bundle without native Intl + if: matrix.framework == 'vue2' && matrix.target == 'mobile' + run: npm run test:bundle + - name: Build Web production and Docker targets + if: matrix.target == 'web' + run: | + npm run build:prod + npm run build:docker + - name: Build Mobile H5 + if: matrix.target == 'mobile' + run: npm run build:h5 + - uses: actions/setup-python@v6 + if: matrix.target == 'mobile' + with: + python-version: '3.12' + - name: Install the Mobile smoke browser + if: matrix.target == 'mobile' + run: | + python -m pip install playwright==1.62.0 + python -m playwright install --with-deps chromium + - name: Verify Mobile H5 login and profile + if: matrix.target == 'mobile' + working-directory: . + run: python .github/scripts/mobile_smoke.py --framework ${{ matrix.framework }} + - name: Build Mobile WeChat target + if: matrix.target == 'mobile' + run: npm run build:mp-weixin + - name: Upload frontend diagnostics + if: always() + uses: actions/upload-artifact@v6 + with: + name: frontend-${{ matrix.framework }}-${{ matrix.target }} + path: | + target/ci/mobile-smoke/${{ matrix.framework }}/ + ruoyi-fastapi-frontend/${{ matrix.framework }}/${{ matrix.target }}/test-results/ + if-no-files-found: ignore + retention-days: 7 diff --git a/.github/workflows/ci-integration.yml b/.github/workflows/ci-integration.yml new file mode 100644 index 0000000..475b36c --- /dev/null +++ b/.github/workflows/ci-integration.yml @@ -0,0 +1,218 @@ +name: CI / Integration + +on: + workflow_call: + inputs: + database: + type: boolean + default: false + release: + type: boolean + default: false + network_frameworks: + type: string + default: '[]' + full: + type: boolean + default: false + +permissions: + contents: read + +jobs: + database-timezone: + name: MySQL and PostgreSQL time contracts + if: inputs.database + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + TZ: America/New_York + PYTHONUTF8: '1' + defaults: + run: + working-directory: ruoyi-fastapi-backend + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: timezone_test + TZ: Asia/Shanghai + ports: ['3306:3306'] + options: >- + --health-cmd="mysqladmin ping -h localhost -ptimezone_test" + --health-interval=10s --health-timeout=5s --health-retries=12 + postgres: + image: postgres:14 + env: + POSTGRES_PASSWORD: timezone_test + TZ: America/New_York + PGTZ: America/New_York + ports: ['5432:5432'] + options: >- + --health-cmd="pg_isready -U postgres" + --health-interval=10s --health-timeout=5s --health-retries=12 + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + cache: pip + cache-dependency-path: ruoyi-fastapi-backend/requirements*.txt + - run: python -m pip install -r requirements.txt -r requirements-pg.txt -r requirements-test.txt + - name: Configure isolated database verification + run: | + mkdir -p target/ci/timezone + python - <<'PY' + import json + from pathlib import Path + for dialect, port, username, database in [('mysql', 3306, 'root', 'mysql'), ('postgresql', 5432, 'postgres', 'postgres')]: + source = dict(db_type=dialect, db_host='127.0.0.1', db_port=port, db_username=username, + db_password='timezone_test', db_database=database) + Path(f'target/ci/timezone/{dialect}.json').write_text(json.dumps(source)) + PY + - name: Verify drivers, CRUD and scheduler persistence + run: | + python -m scripts.verify_timezone_database --config target/ci/timezone/mysql.json --report target/ci/timezone/mysql-report.json + python -m scripts.verify_timezone_database --config target/ci/timezone/postgresql.json --report target/ci/timezone/postgresql-report.json + - uses: actions/upload-artifact@v6 + if: always() + with: + name: integration-database-timezones + path: ruoyi-fastapi-backend/target/ci/timezone/*-report.json + retention-days: 7 + + task-bundle: + name: Task plugin bundle + if: inputs.release + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: ruoyi-fastapi-backend/plugins/examples/python/task_demo/web + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v6 + with: + node-version: '22' + cache: npm + cache-dependency-path: ruoyi-fastapi-backend/plugins/examples/python/task_demo/web/package.json + - run: npm install --no-audit --no-fund --package-lock=false + - run: npm test + - run: npm run build + - uses: actions/upload-artifact@v6 + with: + name: integration-task-bundle + path: ruoyi-fastapi-backend/plugins/examples/python/task_demo/web/dist/ + if-no-files-found: error + retention-days: 1 + + release: + name: Plugin maintenance / Python ${{ matrix.python }} + needs: task-bundle + if: inputs.release + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + python: ${{ fromJSON(inputs.full && '["3.10","3.11","3.12","3.13"]' || '["3.12"]') }} + defaults: + run: + working-directory: ruoyi-fastapi-backend + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: ci-only-mysql + ports: ['3306:3306'] + options: >- + --health-cmd="mysqladmin ping -h localhost -pci-only-mysql" + --health-interval=10s --health-timeout=5s --health-retries=12 + postgres: + image: postgres:14 + env: + POSTGRES_PASSWORD: ci-only-postgres + ports: ['5432:5432'] + options: >- + --health-cmd="pg_isready -U postgres" + --health-interval=10s --health-timeout=5s --health-retries=12 + redis: + image: redis:6.2 + ports: ['6379:6379'] + options: >- + --health-cmd="redis-cli ping" + --health-interval=10s --health-timeout=5s --health-retries=12 + env: + RUOYI_PLUGIN_IT_ALLOW: '1' + RUOYI_IT_MYSQL_PASSWORD: ci-only-mysql + RUOYI_IT_PG_PASSWORD: ci-only-postgres + RUOYI_IT_REDIS_PASSWORD: '' + PYTHONUTF8: '1' + _TYPER_FORCE_DISABLE_TERMINAL: '1' + steps: + - uses: actions/checkout@v5 + - uses: actions/download-artifact@v6 + with: + name: integration-task-bundle + path: ruoyi-fastapi-backend/plugins/examples/python/task_demo/web/dist + - uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python }} + cache: pip + cache-dependency-path: ruoyi-fastapi-backend/requirements*.txt + - run: python -m pip install -r requirements.txt -r requirements-pg.txt + - name: Verify MySQL and PostgreSQL maintenance releases + shell: bash + run: | + mkdir -p target/ci/release + python scripts/plugin_release_integration.py --engine mysql 2>&1 | tee target/ci/release/mysql.log + python scripts/plugin_release_integration.py --engine postgresql 2>&1 | tee target/ci/release/postgresql.log + - uses: actions/upload-artifact@v6 + if: always() + with: + name: integration-release-${{ matrix.python }} + path: ruoyi-fastapi-backend/target/ci/release/ + retention-days: 7 + + network: + name: ${{ matrix.framework }} proxy and plugin sessions + if: inputs.network_frameworks != '[]' + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + framework: ${{ fromJSON(inputs.network_frameworks) }} + defaults: + run: + working-directory: ruoyi-fastapi-backend + env: + RUOYI_PLUGIN_NETWORK_SMOKE: '1' + RUOYI_PLUGIN_FRONTEND_FRAMEWORK: ${{ matrix.framework }} + NODE_OPTIONS: ${{ matrix.framework == 'vue2' && '--openssl-legacy-provider' || '' }} + DB_SOURCES: '{"primary":{"db_type":"mysql","db_host":"127.0.0.1","db_port":3306,"db_username":"network-smoke","db_password":"network-smoke","db_database":"network-smoke"}}' + JWT_SECRET_KEY: isolated-plugin-network-smoke-ci-only + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + cache: pip + cache-dependency-path: ruoyi-fastapi-backend/requirements*.txt + - uses: actions/setup-node@v6 + with: + node-version: '22' + cache: npm + cache-dependency-path: ruoyi-fastapi-frontend/${{ matrix.framework }}/web/package.json + - 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: 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 + - uses: actions/upload-artifact@v6 + if: always() + with: + name: integration-network-${{ matrix.framework }} + path: ruoyi-fastapi-backend/target/plugin-network-smoke/${{ matrix.framework }}/ + retention-days: 7 diff --git a/.github/workflows/ci-native.yml b/.github/workflows/ci-native.yml new file mode 100644 index 0000000..373a13d --- /dev/null +++ b/.github/workflows/ci-native.yml @@ -0,0 +1,80 @@ +name: CI / Native + +on: + workflow_call: + +permissions: + contents: read + +env: + PYTHONUTF8: '1' + _TYPER_FORCE_DISABLE_TERMINAL: '1' + +jobs: + rust-quality: + name: Rust format and clippy + runs-on: ubuntu-latest + timeout-minutes: 20 + defaults: + run: + working-directory: ruoyi-fastapi-backend + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - run: rustup toolchain install 1.99.0 --profile minimal --component rustfmt --component clippy + - run: rustup default 1.99.0 + - uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + ruoyi-fastapi-backend/plugins/examples/rust/rust_demo/target + key: rust-quality-${{ runner.os }}-1.99.0-${{ hashFiles('**/Cargo.lock') }} + - run: cargo fmt --manifest-path plugins/examples/rust/rust_demo/Cargo.toml --check + - run: cargo clippy --manifest-path plugins/examples/rust/rust_demo/Cargo.toml --locked -- -D warnings + + runtime: + name: ${{ matrix.os }} / Python ${{ matrix.python }} + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + python: ['3.10', '3.11', '3.12', '3.13'] + defaults: + run: + working-directory: ruoyi-fastapi-backend + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python }} + cache: pip + cache-dependency-path: ruoyi-fastapi-backend/requirements*.txt + - run: python -m pip install -r requirements.txt -r requirements-test.txt 'maturin>=1.15,<2' + - run: rustup toolchain install 1.99.0 --profile minimal + - run: rustup default 1.99.0 + - uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + ruoyi-fastapi-backend/plugins/examples/rust/rust_demo/target + key: rust-native-${{ runner.os }}-${{ matrix.python }}-1.99.0-${{ hashFiles('**/Cargo.lock') }} + - name: Build and assemble source-free plugin + run: python scripts/build_native_plugin.py --output target/native-package + - name: Exercise real native runtime and signed artifact loading + env: + RUOYI_NATIVE_PLUGIN_DIR: ${{ github.workspace }}/ruoyi-fastapi-backend/target/native-package/rust_demo + run: python -m pytest tests/plugins/native tests/plugins/core/deployment -m native --strict-markers -q --durations=15 --junitxml=target/ci/native.xml + - name: Require native tests to run without missing-artifact skips + run: python ../.github/scripts/check_test_report.py target/ci/native.xml + - uses: actions/upload-artifact@v6 + if: always() + with: + name: native-${{ runner.os }}-${{ matrix.python }} + path: ruoyi-fastapi-backend/target/ci/ + retention-days: 7 diff --git a/.github/workflows/ci-quality.yml b/.github/workflows/ci-quality.yml new file mode 100644 index 0000000..a1c0bbf --- /dev/null +++ b/.github/workflows/ci-quality.yml @@ -0,0 +1,54 @@ +name: CI / Quality + +on: + workflow_call: + +permissions: + contents: read + +jobs: + checks: + name: Code and CI contracts + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + cache: pip + cache-dependency-path: .github/requirements.txt + - run: python -m pip install -r .github/requirements.txt + - name: Python lint and format + run: | + ruff check ruoyi-fastapi-backend ruoyi-fastapi-test .github/scripts .github/tests + ruff format --check ruoyi-fastapi-backend ruoyi-fastapi-test .github/scripts .github/tests + - name: Test CI routing, failure gates and deployment contracts + run: python -m pytest .github/tests -q --junitxml=target/ci/quality.xml + - name: Validate every Compose framework selection + run: python .github/scripts/check_compose.py + - name: Install actionlint + env: + ACTIONLINT_VERSION: '1.7.12' + run: | + archive="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" + curl --fail --location --retry 3 "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/${archive}" --output "${RUNNER_TEMP}/${archive}" + echo "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 ${RUNNER_TEMP}/${archive}" | sha256sum --check + tar -xzf "${RUNNER_TEMP}/${archive}" -C "$RUNNER_TEMP" actionlint + "$RUNNER_TEMP/actionlint" -color + - uses: actions/setup-node@v6 + with: + node-version: '22' + cache: npm + cache-dependency-path: ruoyi-fastapi-frontend/*/web/package.json + - name: Check Web formatting with the existing project configurations + run: | + for framework in vue2 vue3; do + (cd "ruoyi-fastapi-frontend/$framework/web" && npx --yes prettier@3.6.2 --check .) + done + - uses: actions/upload-artifact@v6 + if: always() + with: + name: quality-reports + path: target/ci/ + retention-days: 7 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fe59a04 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,106 @@ +name: CI + +on: + pull_request: + branches: [master] + push: + branches: [master] + schedule: + - cron: '21 18 * * *' + workflow_dispatch: + inputs: + full: + description: Run the full compatibility and end-to-end matrix + type: boolean + default: true + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + plan: + name: Plan + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + backend: ${{ steps.plan.outputs.backend }} + frontend: ${{ steps.plan.outputs.frontend }} + integration: ${{ steps.plan.outputs.integration }} + e2e: ${{ steps.plan.outputs.e2e }} + native: ${{ steps.plan.outputs.native }} + full: ${{ steps.plan.outputs.full }} + frontend_matrix: ${{ steps.plan.outputs.frontend_matrix }} + e2e_matrix: ${{ steps.plan.outputs.e2e_matrix }} + network_frameworks: ${{ steps.plan.outputs.network_frameworks }} + database: ${{ steps.plan.outputs.database }} + release: ${{ steps.plan.outputs.release }} + plan_json: ${{ steps.plan.outputs.plan_json }} + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - id: plan + run: python .github/scripts/ci_plan.py plan + + quality: + name: Quality + needs: plan + uses: ./.github/workflows/ci-quality.yml + + backend: + name: Backend + needs: plan + if: needs.plan.outputs.backend == 'true' + uses: ./.github/workflows/ci-backend.yml + + frontend: + name: Frontend + needs: plan + if: needs.plan.outputs.frontend == 'true' + uses: ./.github/workflows/ci-frontend.yml + with: + matrix_json: ${{ needs.plan.outputs.frontend_matrix }} + full: ${{ needs.plan.outputs.full == 'true' }} + + integration: + name: Integration + needs: plan + if: needs.plan.outputs.integration == 'true' + uses: ./.github/workflows/ci-integration.yml + with: + database: ${{ needs.plan.outputs.database == 'true' }} + release: ${{ needs.plan.outputs.release == 'true' }} + network_frameworks: ${{ needs.plan.outputs.network_frameworks }} + full: ${{ needs.plan.outputs.full == 'true' }} + + e2e: + name: E2E + needs: plan + if: needs.plan.outputs.e2e == 'true' + uses: ./.github/workflows/ci-e2e.yml + with: + matrix_json: ${{ needs.plan.outputs.e2e_matrix }} + + native: + name: Native + needs: plan + if: needs.plan.outputs.native == 'true' + uses: ./.github/workflows/ci-native.yml + + result: + name: Result + if: always() + needs: [plan, quality, backend, frontend, integration, e2e, native] + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v5 + - name: Require every selected category to succeed + env: + PLAN_JSON: ${{ needs.plan.outputs.plan_json }} + NEEDS_JSON: ${{ toJSON(needs) }} + run: python .github/scripts/ci_plan.py gate diff --git a/.github/workflows/native-plugin.yml b/.github/workflows/native-plugin.yml deleted file mode 100644 index 49555c5..0000000 --- a/.github/workflows/native-plugin.yml +++ /dev/null @@ -1,97 +0,0 @@ -name: Native Plugin - -on: - push: - branches: [master] - paths: - - 'ruoyi-fastapi-backend/plugins/core/**' - - 'ruoyi-fastapi-backend/plugins/examples/rust/**' - - 'ruoyi-fastapi-backend/plugins/examples/python/task_demo/**' - - 'ruoyi-fastapi-backend/scripts/plugin_task_integration.py' - - 'ruoyi-fastapi-backend/scripts/build_native_plugin.py' - - 'ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/**' - - 'ruoyi-fastapi-backend/cli/runtime/plugin/sdk.py' - - 'ruoyi-fastapi-backend/cli/groups/plugin/commands/developer.py' - - 'ruoyi-fastapi-backend/cli/groups/plugin/commands/sdk.py' - - 'ruoyi-fastapi-backend/cli/groups/plugin/commands/artifact*.py' - - 'ruoyi-fastapi-backend/cli/groups/plugin/artifact_controller.py' - - 'ruoyi-fastapi-backend/cli/groups/plugin/command.py' - - 'ruoyi-fastapi-frontend/src/utils/pluginBridge*' - - 'ruoyi-fastapi-backend/pyproject.toml' - - 'ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_scaffold*.py' - - 'ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_sdk.py' - - 'ruoyi-fastapi-backend/tests/cli/root/test_plugin_sdk_commands.py' - - 'ruoyi-fastapi-backend/tests/cli/root/test_plugin_artifact_commands.py' - - 'ruoyi-fastapi-backend/tests/cli/root/test_plugin_release_wait.py' - - 'ruoyi-fastapi-backend/tests/plugins/**' - - '.github/workflows/native-plugin.yml' - pull_request: - branches: [master] - paths: - - 'ruoyi-fastapi-backend/plugins/core/**' - - 'ruoyi-fastapi-backend/plugins/examples/rust/**' - - 'ruoyi-fastapi-backend/plugins/examples/python/task_demo/**' - - 'ruoyi-fastapi-backend/scripts/plugin_task_integration.py' - - 'ruoyi-fastapi-backend/scripts/build_native_plugin.py' - - 'ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/**' - - 'ruoyi-fastapi-backend/cli/runtime/plugin/sdk.py' - - 'ruoyi-fastapi-backend/cli/groups/plugin/commands/developer.py' - - 'ruoyi-fastapi-backend/cli/groups/plugin/commands/sdk.py' - - 'ruoyi-fastapi-backend/cli/groups/plugin/commands/artifact*.py' - - 'ruoyi-fastapi-backend/cli/groups/plugin/artifact_controller.py' - - 'ruoyi-fastapi-backend/cli/groups/plugin/command.py' - - 'ruoyi-fastapi-frontend/src/utils/pluginBridge*' - - 'ruoyi-fastapi-backend/pyproject.toml' - - 'ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_scaffold*.py' - - 'ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_sdk.py' - - 'ruoyi-fastapi-backend/tests/cli/root/test_plugin_sdk_commands.py' - - 'ruoyi-fastapi-backend/tests/cli/root/test_plugin_artifact_commands.py' - - 'ruoyi-fastapi-backend/tests/cli/root/test_plugin_release_wait.py' - - 'ruoyi-fastapi-backend/tests/plugins/**' - - '.github/workflows/native-plugin.yml' - workflow_dispatch: - -jobs: - native: - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest] - python: ['3.10', '3.11', '3.12', '3.13'] - runs-on: ${{ matrix.os }} - defaults: - run: - working-directory: ruoyi-fastapi-backend - steps: - - uses: actions/checkout@v5 - - uses: actions/setup-python@v6 - with: - python-version: ${{ matrix.python }} - cache: pip - cache-dependency-path: ruoyi-fastapi-backend/requirements.txt - - name: Install host and build dependencies - run: python -m pip install -r requirements.txt 'maturin>=1.15,<2' pytest pytest-asyncio aiosqlite - - name: Install Rust - run: rustup toolchain install 1.99.0 --profile minimal --component rustfmt --component clippy - - name: Select Rust - run: rustup default 1.99.0 - - name: Check Rust formatting - run: cargo fmt --manifest-path plugins/examples/rust/rust_demo/Cargo.toml --check - - name: Verify v1 and v2 plugin scaffolds - run: python -m pytest tests/cli/runtime/plugin/test_runtime_scaffold.py tests/cli/runtime/plugin/test_runtime_scaffold_v2.py -q - - name: Verify SDK provenance and offline update commands - run: python -m pytest tests/cli/runtime/plugin/test_runtime_sdk.py tests/cli/root/test_plugin_sdk_commands.py -q - - name: Verify persistent task plugin - run: python -m pytest tests/plugins/examples/test_task_demo.py -q - - name: Build and assemble source-free plugin - run: python scripts/build_native_plugin.py --output target/native-package - - name: Check Rust code - run: cargo clippy --manifest-path plugins/examples/rust/rust_demo/Cargo.toml --locked -- -D warnings - - name: Exercise native library through the host runtime - env: - RUOYI_NATIVE_PLUGIN_DIR: ${{ github.workspace }}/ruoyi-fastapi-backend/target/native-package/rust_demo - run: python -m pytest tests/plugins/native tests/plugins/core/native tests/plugins/core/runtime/test_host_services.py tests/plugins/core/runtime/test_job_dispatcher.py -q - - name: Verify signed artifacts and maintenance releases - env: - RUOYI_NATIVE_PLUGIN_DIR: ${{ github.workspace }}/ruoyi-fastapi-backend/target/native-package/rust_demo - run: python -m pytest tests/plugins/core/artifacts tests/plugins/core/deployment tests/plugins/core/management/test_release_dao.py tests/plugins/core/management/test_artifact_views.py tests/module_plugin/controller/test_plugin_release_controller.py tests/cli/root/test_plugin_artifact_commands.py tests/cli/root/test_plugin_release_wait.py tests/sql/test_plugin_release_schema.py -q diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml deleted file mode 100644 index 8c802ba..0000000 --- a/.github/workflows/playwright.yml +++ /dev/null @@ -1,174 +0,0 @@ -name: Playwright Tests - -on: - push: - branches: [master] - pull_request: - branches: [master] - -jobs: - mysql-test: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python-version: ["3.10", "3.11", "3.12", "3.13"] - - steps: - - uses: actions/checkout@v5 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Cache Docker layers - uses: actions/cache@v5 - with: - path: /tmp/.docker_cache - key: ${{ runner.os }}-docker-${{ matrix.python-version }}-${{ hashFiles('**/Dockerfile*', '**/requirements.txt') }} - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Create temporary Dockerfile with Python ${{ matrix.python-version }} - run: | - # Save original Dockerfile.my - cp ruoyi-fastapi-backend/Dockerfile.my ruoyi-fastapi-backend/Dockerfile.my.bak - # Create temporary Dockerfile with target Python version and default PyPI index - sed -e "s/FROM python:3.10/FROM python:${{ matrix.python-version }}/g" \ - -e 's| -i https://pypi.tuna.tsinghua.edu.cn/simple||g' \ - ruoyi-fastapi-backend/Dockerfile.my.bak > ruoyi-fastapi-backend/Dockerfile.my - - - name: Start services with docker-compose - run: | - cd ruoyi-fastapi-test - docker compose -f docker-compose.test.my.yml up -d --build - - - name: Wait for services to be ready - run: | - cd ruoyi-fastapi-test - # Wait for backend to be running - timeout 180 bash -c 'until docker compose -f docker-compose.test.my.yml ps ruoyi-backend-my | grep -q "Up"; do sleep 5; done' - # Wait for frontend to be running - timeout 120 bash -c 'until docker compose -f docker-compose.test.my.yml ps ruoyi-frontend | grep -q "Up"; do sleep 5; done' - # Additional wait for services to be fully ready and listening on ports - sleep 30 - # Check that backend is actually responding on the API endpoint - echo "Checking if backend service is ready..." - for i in {1..30}; do - if curl -f http://localhost:9099/captchaImage > /dev/null 2>&1; then - echo "Backend service is ready!" - break - fi - echo "Waiting for backend service to be ready... ($i/30)" - sleep 5 - done - # Final check - if ! curl -f http://localhost:9099/captchaImage > /dev/null 2>&1; then - echo "Backend service failed to start properly. Checking logs..." - docker logs ruoyi-backend-my-test - exit 1 - fi - - - name: Run tests - run: | - cd ruoyi-fastapi-test - pip install -r requirements.txt - playwright install - pytest -v - - - name: Stop services - if: always() - run: | - cd ruoyi-fastapi-test - docker compose -f docker-compose.test.my.yml down - - - name: Restore original Dockerfile.my - if: always() - run: | - # Restore original Dockerfile.my after test - mv ruoyi-fastapi-backend/Dockerfile.my.bak ruoyi-fastapi-backend/Dockerfile.my - - pg-test: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python-version: ["3.10", "3.11", "3.12", "3.13"] - - steps: - - uses: actions/checkout@v5 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Cache Docker layers - uses: actions/cache@v5 - with: - path: /tmp/.docker_cache - key: ${{ runner.os }}-docker-${{ matrix.python-version }}-${{ hashFiles('**/Dockerfile*', '**/requirements.txt') }} - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Create temporary Dockerfile with Python ${{ matrix.python-version }} - run: | - # Save original Dockerfile.pg - cp ruoyi-fastapi-backend/Dockerfile.pg ruoyi-fastapi-backend/Dockerfile.pg.bak - # Create temporary Dockerfile with target Python version and default PyPI index - sed -e "s/FROM python:3.10/FROM python:${{ matrix.python-version }}/g" \ - -e 's| -i https://pypi.tuna.tsinghua.edu.cn/simple||g' \ - ruoyi-fastapi-backend/Dockerfile.pg.bak > ruoyi-fastapi-backend/Dockerfile.pg - - - name: Start services with docker-compose - run: | - cd ruoyi-fastapi-test - docker compose -f docker-compose.test.pg.yml up -d --build - - - name: Wait for services to be ready - run: | - cd ruoyi-fastapi-test - # Wait for backend to be running - timeout 180 bash -c 'until docker compose -f docker-compose.test.pg.yml ps ruoyi-backend-pg | grep -q "Up"; do sleep 5; done' - # Wait for frontend to be running - timeout 120 bash -c 'until docker compose -f docker-compose.test.pg.yml ps ruoyi-frontend | grep -q "Up"; do sleep 5; done' - # Additional wait for services to be fully ready and listening on ports - sleep 30 - # Check that backend is actually responding on the API endpoint - echo "Checking if backend service is ready..." - for i in {1..30}; do - if curl -f http://localhost:9099/captchaImage > /dev/null 2>&1; then - echo "Backend service is ready!" - break - fi - echo "Waiting for backend service to be ready... ($i/30)" - sleep 5 - done - # Final check - if ! curl -f http://localhost:9099/captchaImage > /dev/null 2>&1; then - echo "Backend service failed to start properly. Checking logs..." - docker logs ruoyi-backend-pg-test - exit 1 - fi - - - name: Run tests - run: | - cd ruoyi-fastapi-test - pip install -r requirements.txt - playwright install - pytest -v - - - name: Stop services - if: always() - run: | - cd ruoyi-fastapi-test - docker compose -f docker-compose.test.pg.yml down - - - name: Restore original Dockerfile.pg - if: always() - run: | - # Restore original Dockerfile.pg after test - mv ruoyi-fastapi-backend/Dockerfile.pg.bak ruoyi-fastapi-backend/Dockerfile.pg diff --git a/.github/workflows/plugin-frontend.yml b/.github/workflows/plugin-frontend.yml deleted file mode 100644 index ad3769c..0000000 --- a/.github/workflows/plugin-frontend.yml +++ /dev/null @@ -1,69 +0,0 @@ -name: Plugin Frontend Tests - -on: - push: - branches: [master] - paths: - - 'ruoyi-fastapi-frontend/src/api/system/plugin.js' - - 'ruoyi-fastapi-frontend/src/components/PluginFrame/**' - - 'ruoyi-fastapi-frontend/src/views/system/plugin/**' - - 'ruoyi-fastapi-frontend/src/utils/**' - - 'ruoyi-fastapi-frontend/src/plugins/**' - - 'ruoyi-fastapi-frontend/src/router/**' - - 'ruoyi-fastapi-frontend/src/store/modules/permission.js' - - 'ruoyi-fastapi-frontend/src/store/modules/user.js' - - 'ruoyi-fastapi-frontend/src/permission.js' - - 'ruoyi-fastapi-frontend/plugins/**' - - 'ruoyi-fastapi-frontend/tests/plugins/**' - - 'ruoyi-fastapi-frontend/tests/run-tests.js' - - 'ruoyi-fastapi-frontend/package.json' - - 'ruoyi-fastapi-frontend/package-lock.json' - - 'ruoyi-fastapi-frontend/vite.config.js' - - 'ruoyi-fastapi-frontend/vite/**' - - 'ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/**' - - '.github/workflows/plugin-frontend.yml' - pull_request: - branches: [master] - paths: - - 'ruoyi-fastapi-frontend/src/api/system/plugin.js' - - 'ruoyi-fastapi-frontend/src/components/PluginFrame/**' - - 'ruoyi-fastapi-frontend/src/views/system/plugin/**' - - 'ruoyi-fastapi-frontend/src/utils/**' - - 'ruoyi-fastapi-frontend/src/plugins/**' - - 'ruoyi-fastapi-frontend/src/router/**' - - 'ruoyi-fastapi-frontend/src/store/modules/permission.js' - - 'ruoyi-fastapi-frontend/src/store/modules/user.js' - - 'ruoyi-fastapi-frontend/src/permission.js' - - 'ruoyi-fastapi-frontend/plugins/**' - - 'ruoyi-fastapi-frontend/tests/plugins/**' - - 'ruoyi-fastapi-frontend/tests/run-tests.js' - - 'ruoyi-fastapi-frontend/package.json' - - 'ruoyi-fastapi-frontend/package-lock.json' - - 'ruoyi-fastapi-frontend/vite.config.js' - - 'ruoyi-fastapi-frontend/vite/**' - - 'ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/**' - - '.github/workflows/plugin-frontend.yml' - workflow_dispatch: - -permissions: - contents: read - -jobs: - plugins: - runs-on: ubuntu-latest - timeout-minutes: 15 - defaults: - run: - working-directory: ruoyi-fastapi-frontend - steps: - - uses: actions/checkout@v5 - - uses: actions/setup-node@v6 - with: - node-version: '22' - cache: npm - cache-dependency-path: ruoyi-fastapi-frontend/package.json - - name: Install frontend dependencies - # This repository does not commit npm lockfiles, so npm ci is not available. - run: npm install --no-audit --no-fund --package-lock=false - - name: Verify plugin bridge, sessions and management views - run: npm run test:plugin diff --git a/.github/workflows/plugin-network-smoke.yml b/.github/workflows/plugin-network-smoke.yml deleted file mode 100644 index 6d26c4b..0000000 --- a/.github/workflows/plugin-network-smoke.yml +++ /dev/null @@ -1,63 +0,0 @@ -name: Plugin Network Smoke - -on: - pull_request: - branches: [master] - paths: - - 'ruoyi-fastapi-backend/plugins/**' - - 'ruoyi-fastapi-backend/module_plugin/**' - - 'ruoyi-fastapi-backend/module_admin/service/login_service.py' - - 'ruoyi-fastapi-backend/common/**' - - 'ruoyi-fastapi-backend/config/**' - - 'ruoyi-fastapi-backend/middlewares/**' - - 'ruoyi-fastapi-backend/utils/**' - - 'ruoyi-fastapi-backend/tests/**' - - 'ruoyi-fastapi-backend/requirements.txt' - - 'ruoyi-fastapi-backend/pyproject.toml' - - 'ruoyi-fastapi-frontend/**' - - '.github/workflows/plugin-network-smoke.yml' - workflow_dispatch: - -permissions: - contents: read - -jobs: - browser: - runs-on: ubuntu-latest - timeout-minutes: 15 - defaults: - run: - working-directory: ruoyi-fastapi-backend - env: - RUOYI_PLUGIN_NETWORK_SMOKE: '1' - # The fixture replaces account, DB and Redis access; no external service is used. - DB_SOURCES: '{"primary":{"db_type":"mysql","db_host":"127.0.0.1","db_port":3306,"db_username":"network-smoke","db_password":"network-smoke","db_database":"network-smoke"}}' - JWT_SECRET_KEY: 'isolated-plugin-network-smoke-ci-only' - steps: - - uses: actions/checkout@v5 - - uses: actions/setup-python@v6 - with: - python-version: '3.12' - cache: pip - cache-dependency-path: ruoyi-fastapi-backend/requirements.txt - - uses: actions/setup-node@v6 - with: - node-version: '22' - cache: npm - cache-dependency-path: ruoyi-fastapi-frontend/package.json - - name: Install host and browser test dependencies - run: python -m pip install -r requirements.txt pytest pytest-asyncio aiosqlite playwright - - name: Install Chromium - run: python -m playwright install --with-deps chromium - - name: Install frontend dependencies - # This repository does not commit npm lockfiles, so npm ci is not available. - run: npm --prefix ../ruoyi-fastapi-frontend install --no-audit --no-fund --package-lock=false - - name: Verify HTTPS proxy, iframe sessions and long connections - run: python -m pytest tests/plugins/integration/test_network_smoke.py -v --junitxml=target/plugin-network-smoke/results.xml - - name: Upload browser trace, screenshots and proxy log - if: always() - uses: actions/upload-artifact@v4 - with: - name: plugin-network-smoke - path: ruoyi-fastapi-backend/target/plugin-network-smoke/ - retention-days: 7 diff --git a/.github/workflows/plugin-release-integration.yml b/.github/workflows/plugin-release-integration.yml deleted file mode 100644 index 793ad00..0000000 --- a/.github/workflows/plugin-release-integration.yml +++ /dev/null @@ -1,94 +0,0 @@ -name: Plugin Release Integration - -on: - pull_request: - branches: [master] - paths: - - 'ruoyi-fastapi-backend/plugins/core/**' - - 'ruoyi-fastapi-backend/plugins/examples/python/task_demo/**' - - 'ruoyi-fastapi-frontend/src/utils/pluginBridge.js' - - 'ruoyi-fastapi-backend/config/**' - - 'ruoyi-fastapi-backend/cli/groups/plugin/**' - - 'ruoyi-fastapi-backend/cli/runtime/plugin/**' - - 'ruoyi-fastapi-backend/module_plugin/**' - - 'ruoyi-fastapi-backend/server.py' - - 'ruoyi-fastapi-backend/app.py' - - 'ruoyi-fastapi-backend/sql/ruoyi-fastapi*.sql' - - 'ruoyi-fastapi-backend/scripts/plugin_release_integration.py' - - 'ruoyi-fastapi-backend/scripts/plugin_task_integration.py' - - 'ruoyi-fastapi-backend/tests/plugins/**' - - 'ruoyi-fastapi-backend/tests/cli/root/test_plugin_artifact_commands.py' - - 'ruoyi-fastapi-backend/tests/cli/root/test_plugin_release_wait.py' - - 'ruoyi-fastapi-backend/tests/cli/root/test_plugin_diagnostics.py' - - 'ruoyi-fastapi-backend/tests/cli/runtime/plugin/**' - - 'ruoyi-fastapi-backend/tests/module_plugin/**' - - 'ruoyi-fastapi-backend/tests/server/test_plugin_runtime.py' - - 'ruoyi-fastapi-backend/tests/sql/test_plugin_release_schema.py' - - 'ruoyi-fastapi-backend/pyproject.toml' - - 'ruoyi-fastapi-backend/requirements.txt' - - 'ruoyi-fastapi-backend/requirements-pg.txt' - - '.github/workflows/plugin-release-integration.yml' - workflow_dispatch: - -permissions: - contents: read - -jobs: - release: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python: ['3.10', '3.11', '3.12', '3.13'] - defaults: - run: - working-directory: ruoyi-fastapi-backend - services: - mysql: - image: mysql:8.0 - env: - MYSQL_ROOT_PASSWORD: ci-only-mysql - ports: ['3306:3306'] - options: >- - --health-cmd="mysqladmin ping -h localhost" - --health-interval=10s --health-timeout=5s --health-retries=12 - postgres: - image: postgres:14 - env: - POSTGRES_PASSWORD: ci-only-postgres - ports: ['5432:5432'] - options: >- - --health-cmd="pg_isready -U postgres" - --health-interval=10s --health-timeout=5s --health-retries=12 - redis: - image: redis:6.2 - ports: ['6379:6379'] - options: >- - --health-cmd="redis-cli ping" - --health-interval=10s --health-timeout=5s --health-retries=12 - env: - RUOYI_PLUGIN_IT_ALLOW: '1' - RUOYI_IT_MYSQL_PASSWORD: ci-only-mysql - RUOYI_IT_PG_PASSWORD: ci-only-postgres - RUOYI_IT_REDIS_PASSWORD: '' - steps: - - uses: actions/checkout@v5 - - uses: actions/setup-python@v6 - with: - python-version: ${{ matrix.python }} - cache: pip - cache-dependency-path: ruoyi-fastapi-backend/requirements*.txt - - name: Install host dependencies - run: python -m pip install -r requirements.txt -r requirements-pg.txt - - uses: actions/setup-node@v5 - with: - node-version: '22' - - name: Test and build task plugin bundle - run: | - npm --prefix plugins/examples/python/task_demo/web install --no-audit --no-fund --package-lock=false - npm --prefix plugins/examples/python/task_demo/web test - npm --prefix plugins/examples/python/task_demo/web run build - - name: Verify MySQL and Redis maintenance releases - run: python scripts/plugin_release_integration.py --engine mysql - - name: Verify PostgreSQL and Redis maintenance releases - run: python scripts/plugin_release_integration.py --engine postgresql diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml deleted file mode 100644 index 0d76548..0000000 --- a/.github/workflows/ruff.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Ruff Check - -on: - push: - branches: [master] - pull_request: - branches: [master] - -jobs: - lint-format: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.10" - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install ruff - - - name: Run linting - run: | - ruff check ruoyi-fastapi-backend - ruff check ruoyi-fastapi-test - - - name: Run format check - run: | - ruff format ruoyi-fastapi-backend --check - ruff format ruoyi-fastapi-test --check diff --git a/.github/workflows/timezone.yml b/.github/workflows/timezone.yml deleted file mode 100644 index 99d14da..0000000 --- a/.github/workflows/timezone.yml +++ /dev/null @@ -1,106 +0,0 @@ -name: Timezone Integration - -on: - push: - branches: [master] - pull_request: - branches: [master] - workflow_dispatch: - -jobs: - databases: - runs-on: ubuntu-latest - env: - TZ: America/New_York - PYTHONUTF8: '1' - services: - mysql: - image: mysql:8.0 - env: - MYSQL_ROOT_PASSWORD: timezone_test - TZ: Asia/Shanghai - ports: ['3306:3306'] - options: >- - --health-cmd="mysqladmin ping -h localhost -ptimezone_test" - --health-interval=10s --health-timeout=5s --health-retries=12 - postgres: - image: postgres:14 - env: - POSTGRES_PASSWORD: timezone_test - TZ: America/New_York - PGTZ: America/New_York - ports: ['5432:5432'] - options: >- - --health-cmd="pg_isready -U postgres" - --health-interval=10s --health-timeout=5s --health-retries=12 - steps: - - uses: actions/checkout@v5 - - uses: actions/setup-python@v6 - with: - python-version: '3.10' - cache: pip - cache-dependency-path: ruoyi-fastapi-backend/requirements*.txt - - name: Install verification dependencies - working-directory: ruoyi-fastapi-backend - run: python -m pip install -r requirements.txt asyncpg psycopg2-binary pytest pytest-asyncio aiosqlite - - name: Create isolated source configurations - working-directory: ruoyi-fastapi-backend - run: | - mkdir -p .cache/timezone-ci - python - <<'PY' - import json - from pathlib import Path - for dialect, port, username, database in [('mysql', 3306, 'root', 'mysql'), ('postgresql', 5432, 'postgres', 'postgres')]: - source = dict(db_type=dialect, db_host='127.0.0.1', db_port=port, db_username=username, - db_password='timezone_test', db_database=database) - Path(f'.cache/timezone-ci/{dialect}.json').write_text(json.dumps(source)) - PY - - name: Verify empty databases, drivers, generated CRUD and persistent schedulers - working-directory: ruoyi-fastapi-backend - run: | - python -m scripts.verify_timezone_database --config .cache/timezone-ci/mysql.json --report .cache/timezone-ci/mysql-report.json - python -m scripts.verify_timezone_database --config .cache/timezone-ci/postgresql.json --report .cache/timezone-ci/postgresql-report.json - - uses: actions/upload-artifact@v6 - if: always() - with: - name: timezone-database-reports - path: ruoyi-fastapi-backend/.cache/timezone-ci/*-report.json - include-hidden-files: true - - web-app: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - uses: actions/setup-node@v6 - with: - node-version: '22' - cache: npm - # Lockfiles are ignored by this repository; cache by the committed manifest. - cache-dependency-path: ruoyi-fastapi-frontend/package.json - - uses: pnpm/action-setup@v5 - with: - package_json_file: ruoyi-fastapi-app/package.json - - name: Install Web dependencies - working-directory: ruoyi-fastapi-frontend - run: npm install --no-package-lock - - name: Install App dependencies - working-directory: ruoyi-fastapi-app - run: pnpm install --no-frozen-lockfile - - name: Run time contracts in three host timezones - run: node ruoyi-fastapi-test/time-contract/run-host-timezones.mjs - - name: Build Web targets - working-directory: ruoyi-fastapi-frontend - run: | - npm run build:prod - npm run build:docker - - name: Build App targets - working-directory: ruoyi-fastapi-app - run: | - pnpm build:h5 - pnpm build:mp-weixin - - name: Validate all Compose configurations - run: | - docker compose -f docker-compose.my.yml config --quiet - docker compose -f docker-compose.pg.yml config --quiet - docker compose -f ruoyi-fastapi-test/docker-compose.test.my.yml config --quiet - docker compose -f ruoyi-fastapi-test/docker-compose.test.pg.yml config --quiet diff --git a/CHANGELOG.md b/CHANGELOG.md index fe7a308..bbc904f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # 更新日志 -## RuoYi-Vue3-FastAPI v1.10.0 +## RuoYi-FastAPI v1.10.0 ### 项目依赖 @@ -8,68 +8,68 @@ ### 新增功能 -1. 新增ApiCache接口缓存装饰器和ApiCacheEvict接口缓存失效装饰器 ([#97](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/97))。 -2. 新增接口限流装饰器 ([#99](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/99))。 -3. 新增传输加密功能 ([#100](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/100))。 -4. 新增日志脱敏功能 ([#101](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/101))。 -5. 新增cli系统 ([#103](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/103))。 -6. 新增文件管理功能 ([#111](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/111))。 -7. 新增插件系统 ([#112](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/112))。 -8. 新增自动生成jwt密钥 ([#116](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/116))。 -9. 新增锁定屏幕功能 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。 -10. 字典类型列表新增抽屉效果详细信息 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。 -11. 添加持久化标签页开关功能 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。 -12. 支持表格列显隐状态记忆 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。 -13. 代码生成支持表单布局选项 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。 -14. 新增树分割组件TreePanel ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。 -15. 新增Excel导入组件ExcelImportDialog ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。 -16. 用户列表新增抽屉效果详细信息 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。 -17. 新增标签页样式chrome风格 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。 -18. 新增菜单路由地址和名称的校验规则 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。 -19. 首页新增通知公告消息提醒 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。 -20. 定时任务详情补充执行时间字段 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。 -21. 部门管理支持批量保存排序 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。 -22. 菜单管理支持批量保存排序 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。 -23. 菜单管理列表新增类型显示 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。 -24. 新增代码生成详情页功能 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。 -25. 通知公告新增详细显示 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。 -26. 通知公告新增阅读用户列表 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。 -27. 用户密码支持自定义配置规则 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。 +1. 新增ApiCache接口缓存装饰器和ApiCacheEvict接口缓存失效装饰器 ([#97](https://github.com/insistence/RuoYi-FastAPI/pull/97))。 +2. 新增接口限流装饰器 ([#99](https://github.com/insistence/RuoYi-FastAPI/pull/99))。 +3. 新增传输加密功能 ([#100](https://github.com/insistence/RuoYi-FastAPI/pull/100))。 +4. 新增日志脱敏功能 ([#101](https://github.com/insistence/RuoYi-FastAPI/pull/101))。 +5. 新增cli系统 ([#103](https://github.com/insistence/RuoYi-FastAPI/pull/103))。 +6. 新增文件管理功能 ([#111](https://github.com/insistence/RuoYi-FastAPI/pull/111))。 +7. 新增插件系统 ([#112](https://github.com/insistence/RuoYi-FastAPI/pull/112))。 +8. 新增自动生成jwt密钥 ([#116](https://github.com/insistence/RuoYi-FastAPI/pull/116))。 +9. 新增锁定屏幕功能 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。 +10. 字典类型列表新增抽屉效果详细信息 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。 +11. 添加持久化标签页开关功能 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。 +12. 支持表格列显隐状态记忆 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。 +13. 代码生成支持表单布局选项 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。 +14. 新增树分割组件TreePanel ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。 +15. 新增Excel导入组件ExcelImportDialog ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。 +16. 用户列表新增抽屉效果详细信息 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。 +17. 新增标签页样式chrome风格 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。 +18. 新增菜单路由地址和名称的校验规则 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。 +19. 首页新增通知公告消息提醒 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。 +20. 定时任务详情补充执行时间字段 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。 +21. 部门管理支持批量保存排序 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。 +22. 菜单管理支持批量保存排序 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。 +23. 菜单管理列表新增类型显示 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。 +24. 新增代码生成详情页功能 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。 +25. 通知公告新增详细显示 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。 +26. 通知公告新增阅读用户列表 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。 +27. 用户密码支持自定义配置规则 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。 ### BUG修复 -1. 修复日志输出格式问题 ([#104](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/104))。 -2. 修复文件服务安全性问题 ([#111](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/111))。 -3. 修复windows下应用启动日志异常的问题 ([#113](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/113))。 -4. 修复tui卡顿的问题 ([#114](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/114))。 -5. 修复E2E测试失败的问题 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。 +1. 修复日志输出格式问题 ([#104](https://github.com/insistence/RuoYi-FastAPI/pull/104))。 +2. 修复文件服务安全性问题 ([#111](https://github.com/insistence/RuoYi-FastAPI/pull/111))。 +3. 修复windows下应用启动日志异常的问题 ([#113](https://github.com/insistence/RuoYi-FastAPI/pull/113))。 +4. 修复tui卡顿的问题 ([#114](https://github.com/insistence/RuoYi-FastAPI/pull/114))。 +5. 修复E2E测试失败的问题 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。 ### 代码优化 -1. 补充演示模式限制路由 ([#115](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/115))。 -2. 优化字典类型属性提醒说明 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。 -3. 优化顶部导航更多菜单路由截取 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。 -4. 优化RightToolbar搜索栏切换动画 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。 -5. 优化操作日志详细页面 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。 -7. 优化页签功能&支持全屏按钮操作 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。 -8. 优化tag全屏为页签内区域 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。 -9. 菜单搜索支持文本高亮&数量提示 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。 -10. 优化页签显示 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。 -11. 优化菜单主题风格 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。 -12. 黑暗模式下全屏不需要设置背景 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。 -13. 暗黑浅色主题优化 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。 -14. 优化快速点击页签刷新出现404问题 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。 -15. 代码生成修改拖拽时显示手指样式 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。 -16. 优化登录页暗黑模式下样式 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。 -17. 优化白名单支持对通配符路径匹配 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。 -18. 优化定时任务详情页展示 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。 -19. 优化点击任务名称查看详细 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。 -20. 更换router4过期方法next ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。 -21. 优化暗黑模式下主题样式 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。 -22. 优化暗黑模式详情样式 ([#117](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/117))。 -23. 优化jwt解析异常处理机制 ([#118](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/118))。 +1. 补充演示模式限制路由 ([#115](https://github.com/insistence/RuoYi-FastAPI/pull/115))。 +2. 优化字典类型属性提醒说明 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。 +3. 优化顶部导航更多菜单路由截取 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。 +4. 优化RightToolbar搜索栏切换动画 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。 +5. 优化操作日志详细页面 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。 +7. 优化页签功能&支持全屏按钮操作 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。 +8. 优化tag全屏为页签内区域 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。 +9. 菜单搜索支持文本高亮&数量提示 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。 +10. 优化页签显示 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。 +11. 优化菜单主题风格 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。 +12. 黑暗模式下全屏不需要设置背景 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。 +13. 暗黑浅色主题优化 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。 +14. 优化快速点击页签刷新出现404问题 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。 +15. 代码生成修改拖拽时显示手指样式 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。 +16. 优化登录页暗黑模式下样式 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。 +17. 优化白名单支持对通配符路径匹配 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。 +18. 优化定时任务详情页展示 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。 +19. 优化点击任务名称查看详细 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。 +20. 更换router4过期方法next ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。 +21. 优化暗黑模式下主题样式 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。 +22. 优化暗黑模式详情样式 ([#117](https://github.com/insistence/RuoYi-FastAPI/pull/117))。 +23. 优化jwt解析异常处理机制 ([#118](https://github.com/insistence/RuoYi-FastAPI/pull/118))。 -## RuoYi-Vue3-FastAPI v1.9.0 +## RuoYi-FastAPI v1.9.0 ### 项目依赖 @@ -77,52 +77,52 @@ ### 新增功能 -1. 新增AI管理模块 ([#69](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/69))。 -2. 新增移动端模块 ([#73](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/73))。 -3. 新增多worker运行支持 ([#76](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/76))。 -4. 应用新增演示模式 ([#78](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/78))。 +1. 新增AI管理模块 ([#69](https://github.com/insistence/RuoYi-FastAPI/pull/69))。 +2. 新增移动端模块 ([#73](https://github.com/insistence/RuoYi-FastAPI/pull/73))。 +3. 新增多worker运行支持 ([#76](https://github.com/insistence/RuoYi-FastAPI/pull/76))。 +4. 应用新增演示模式 ([#78](https://github.com/insistence/RuoYi-FastAPI/pull/78))。 ### BUG修复 -1. 修复代码生成controller模板删除接口query_db参数异常的问题 ([#63](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/63))。 -2. 修复登录接口response_model声明错误 ([#71](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/71))。 -3. 修复无法直接通过后端地址访问API文档的问题 ([#74](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/74))。 -4. 修复create_app重复执行的问题 ([#84](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/84))。 +1. 修复代码生成controller模板删除接口query_db参数异常的问题 ([#63](https://github.com/insistence/RuoYi-FastAPI/pull/63))。 +2. 修复登录接口response_model声明错误 ([#71](https://github.com/insistence/RuoYi-FastAPI/pull/71))。 +3. 修复无法直接通过后端地址访问API文档的问题 ([#74](https://github.com/insistence/RuoYi-FastAPI/pull/74))。 +4. 修复create_app重复执行的问题 ([#84](https://github.com/insistence/RuoYi-FastAPI/pull/84))。 ### 代码重构 -1. 移除对python3.9的支持 ([#67](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/67))。 +1. 移除对python3.9的支持 ([#67](https://github.com/insistence/RuoYi-FastAPI/pull/67))。 ### 代码优化 -1. 优化alembic处理表模型逻辑,避免无关表影响 ([#68](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/68))。 -2. 优化代码生成后端模板 ([#72](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/72))。 -3. 自动注册路由出错时抛出异常以便于调试 ([#79](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/79))。 +1. 优化alembic处理表模型逻辑,避免无关表影响 ([#68](https://github.com/insistence/RuoYi-FastAPI/pull/68))。 +2. 优化代码生成后端模板 ([#72](https://github.com/insistence/RuoYi-FastAPI/pull/72))。 +3. 自动注册路由出错时抛出异常以便于调试 ([#79](https://github.com/insistence/RuoYi-FastAPI/pull/79))。 4. 优化部分页面字段tooltip说明 (#80)。 -5. 优化项目启动速度 ([#82](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/82))。 -6. 优化暗黑模式切换效果 ([#83](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/83))。 -7. 优化热重载模式或单worker下scheduler的任务状态同步机制 ([#85](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/85))。 -8. 优化防重提交间隔时间可自定义 ([#87](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/87))。 -9. 优化验证码计算结果为非负数 ([#88](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/88))。 -10. 优化ci测试稳定性 ([#90](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/90))。 +5. 优化项目启动速度 ([#82](https://github.com/insistence/RuoYi-FastAPI/pull/82))。 +6. 优化暗黑模式切换效果 ([#83](https://github.com/insistence/RuoYi-FastAPI/pull/83))。 +7. 优化热重载模式或单worker下scheduler的任务状态同步机制 ([#85](https://github.com/insistence/RuoYi-FastAPI/pull/85))。 +8. 优化防重提交间隔时间可自定义 ([#87](https://github.com/insistence/RuoYi-FastAPI/pull/87))。 +9. 优化验证码计算结果为非负数 ([#88](https://github.com/insistence/RuoYi-FastAPI/pull/88))。 +10. 优化ci测试稳定性 ([#90](https://github.com/insistence/RuoYi-FastAPI/pull/90))。 -## RuoYi-Vue3-FastAPI v1.8.1 +## RuoYi-FastAPI v1.8.1 ### 新增功能 -1. 新增E2E测试 ([#57](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/57))。 +1. 新增E2E测试 ([#57](https://github.com/insistence/RuoYi-FastAPI/pull/57))。 ### BUG修复 -1. 修复DictTag组件渲染异常的问题 ([#59](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/59))。 +1. 修复DictTag组件渲染异常的问题 ([#59](https://github.com/insistence/RuoYi-FastAPI/pull/59))。 ### 代码优化 -1. 优化数据权限依赖 ([#55](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/55))。 -2. 动态导入定时任务函数,移除eval ([#56](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/56))。 -3. 优化pg版本的docker compose配置文件 ([#61](https://github.com/insistence/RuoYi-Vue3-FastAPI/pull/61))。 +1. 优化数据权限依赖 ([#55](https://github.com/insistence/RuoYi-FastAPI/pull/55))。 +2. 动态导入定时任务函数,移除eval ([#56](https://github.com/insistence/RuoYi-FastAPI/pull/56))。 +3. 优化pg版本的docker compose配置文件 ([#61](https://github.com/insistence/RuoYi-FastAPI/pull/61))。 -## RuoYi-Vue3-FastAPI v1.8.0 +## RuoYi-FastAPI v1.8.0 ### 项目依赖 @@ -143,8 +143,8 @@ ### BUG修复 -1. 修复单账号登录模式下强退功能失效的问题 [#52](https://github.com/insistence/RuoYi-Vue3-FastAPI/issues/52)。 -2. 确保ApschedulerJobs字段类型与apscheduler默认创建的表字段类型一致 [#53](https://github.com/insistence/RuoYi-Vue3-FastAPI/issues/53)。 +1. 修复单账号登录模式下强退功能失效的问题 [#52](https://github.com/insistence/RuoYi-FastAPI/issues/52)。 +2. 确保ApschedulerJobs字段类型与apscheduler默认创建的表字段类型一致 [#53](https://github.com/insistence/RuoYi-FastAPI/issues/53)。 3. 修复磁盘存在异常时服务监控无法正常运行的问题。 4. 移除代码生成表业务表外键,修复无法删除的问题。 5. 修复固定头部时出现的导航栏偏移问题。 @@ -175,7 +175,7 @@ 14. 优化字典组件值宽松匹配。 15. 默认固定头部。 -## RuoYi-Vue3-FastAPI v1.7.1 +## RuoYi-FastAPI v1.7.1 ### 项目依赖 @@ -184,13 +184,13 @@ ### BUG修复 1. 修复代码生成controller模板编辑接口异常生成字段的问题。 -2. 移除passlib直接使用bcrypt修复密码校验异常的问题 [#48](https://github.com/insistence/RuoYi-Vue3-FastAPI/issues/48) [#49](https://github.com/insistence/RuoYi-Vue3-FastAPI/issues/49)。 +2. 移除passlib直接使用bcrypt修复密码校验异常的问题 [#48](https://github.com/insistence/RuoYi-FastAPI/issues/48) [#49](https://github.com/insistence/RuoYi-FastAPI/issues/49)。 ### 代码优化 1. 代码生成do模板补充表描述。 -## RuoYi-Vue3-FastAPI v1.7.0 +## RuoYi-FastAPI v1.7.0 ### 项目依赖 @@ -221,7 +221,7 @@ ### BUG修复 -1. 修复logout接口未按照app_same_time_login配置项动态判断的问题 [#IBZZ1S](https://gitee.com/insistence2022/RuoYi-Vue3-FastAPI/issues/IBZZ1S)。 +1. 修复logout接口未按照app_same_time_login配置项动态判断的问题 [#IBZZ1S](https://gitee.com/insistence2022/RuoYi-FastAPI/issues/IBZZ1S)。 2. 修复上传组件被多次引用拖动仅对第一个有效的问题。 ### 代码优化 @@ -241,7 +241,7 @@ 2. 调整do与sql使其相互适配以支持alembic。 3. 富文本复制粘贴图片上传至url。 -## RuoYi-Vue3-FastAPI v1.6.2 +## RuoYi-FastAPI v1.6.2 ### 新增功能 @@ -250,20 +250,20 @@ ### BUG修复 -1. 修复日志管理时间查询报错 [#27](https://github.com/insistence/RuoYi-Vue3-FastAPI/issues/27)。 -2. 修复定时任务状态暂停时执行单次任务会触发cron表达式的问题 [#31](https://github.com/insistence/RuoYi-Vue3-FastAPI/issues/31)。 +1. 修复日志管理时间查询报错 [#27](https://github.com/insistence/RuoYi-FastAPI/issues/27)。 +2. 修复定时任务状态暂停时执行单次任务会触发cron表达式的问题 [#31](https://github.com/insistence/RuoYi-FastAPI/issues/31)。 3. 修复修改字典类型时获取dict_code异常的问题。 4. 修复修改字典类型时字典数据更新时间异常的问题。 -5. 修复代码生成模板时间查询问题 [#28](https://github.com/insistence/RuoYi-Vue3-FastAPI/issues/28)。 +5. 修复代码生成模板时间查询问题 [#28](https://github.com/insistence/RuoYi-FastAPI/issues/28)。 6. 修复用户导出缺失部门名称的问题。 ### 代码优化 1. 优化代码生成新增和编辑字段显示和渲染。 2. pagination更换成flex布局。 -3. 优化代码生成vue模板 [#23](https://github.com/insistence/RuoYi-Vue3-FastAPI/issues/23)。 +3. 优化代码生成vue模板 [#23](https://github.com/insistence/RuoYi-FastAPI/issues/23)。 -## RuoYi-Vue3-FastAPI v1.6.1 +## RuoYi-FastAPI v1.6.1 ### 项目依赖 @@ -282,7 +282,7 @@ pip install sqlglot[rs]==26.6.0 -i https://pypi.tuna.tsinghua.edu.cn/simple 3. 引入泛型修复as_query和as_form装饰模型文档丢失的问题。 4. 修复代码生成主子表vo模板可能缺失NotBlank的问题。 -## RuoYi-Vue3-FastAPI v1.6.0 +## RuoYi-FastAPI v1.6.0 ### 项目依赖 @@ -304,7 +304,7 @@ pip install sqlglot[rs]==26.6.0 -i https://pypi.tuna.tsinghua.edu.cn/simple 1. 修复默认关闭Tags-Views时,内链页面打不开。 2. 修复删除当前登录用户拦截失效的问题。 3. 修复定时任务目标字符串规则校验不全的问题。 -4. 修复执行单次任务时会覆盖已启用任务的问题 [#IBEKD2](https://gitee.com/insistence2022/RuoYi-Vue3-FastAPI/issues/IBEKD2)。 +4. 修复执行单次任务时会覆盖已启用任务的问题 [#IBEKD2](https://gitee.com/insistence2022/RuoYi-FastAPI/issues/IBEKD2)。 5. 修复个人中心特殊字符密码修改失败问题。 ### 代码优化 @@ -317,7 +317,7 @@ pip install sqlglot[rs]==26.6.0 -i https://pypi.tuna.tsinghua.edu.cn/simple 6. 优化TopNav内链菜单点击没有高亮。 7. ResponseUtil补充完整参数。 -## RuoYi-Vue3-FastAPI v1.5.1 +## RuoYi-FastAPI v1.5.1 ### 新增功能 @@ -329,7 +329,7 @@ pip install sqlglot[rs]==26.6.0 -i https://pypi.tuna.tsinghua.edu.cn/simple 2. 校检文件名是否包含特殊字符。 3. 移除已弃用的log_decorator装饰器。 -## RuoYi-Vue3-FastAPI v1.5.0 +## RuoYi-FastAPI v1.5.0 ### 新增功能 @@ -337,7 +337,7 @@ pip install sqlglot[rs]==26.6.0 -i https://pypi.tuna.tsinghua.edu.cn/simple ### BUG修复 -1. 修复DictTag组件控制台抛异常的问题 [#IAYSVZ](https://gitee.com/insistence2022/RuoYi-Vue3-FastAPI/issues/IAYSVZ)。 +1. 修复DictTag组件控制台抛异常的问题 [#IAYSVZ](https://gitee.com/insistence2022/RuoYi-FastAPI/issues/IAYSVZ)。 2. 修复登录日志导出文件名称错误的问题。 ### 代码回滚 @@ -351,7 +351,7 @@ pip install sqlglot[rs]==26.6.0 -i https://pypi.tuna.tsinghua.edu.cn/simple 3. 优化参数设置页面。 4. 优化上传图片带域名时不增加前缀。 -## RuoYi-Vue3-FastAPI v1.4.0 +## RuoYi-FastAPI v1.4.0 ### 项目依赖 @@ -375,7 +375,7 @@ pip install fastapi[all]==0.115.0 -i https://pypi.tuna.tsinghua.edu.cn/simple 1. 优化前端登录请求方法。 -## RuoYi-Vue3-FastAPI v1.3.3 +## RuoYi-FastAPI v1.3.3 ### 项目依赖 @@ -392,7 +392,7 @@ pip install fastapi[all]==0.115.0 -i https://pypi.tuna.tsinghua.edu.cn/simple 1. 优化在线用户模块前后端字段描述一致。 2. 日志装饰器异常处理增加logger打印日志。 -## RuoYi-Vue3-FastAPI v1.3.2 +## RuoYi-FastAPI v1.3.2 ### 新增功能 @@ -400,14 +400,14 @@ pip install fastapi[all]==0.115.0 -i https://pypi.tuna.tsinghua.edu.cn/simple ### BUG修复 -1. 修复分页函数计算has_next错误的问题 [#10](https://github.com/insistence/RuoYi-Vue3-FastAPI/issues/10)。 +1. 修复分页函数计算has_next错误的问题 [#10](https://github.com/insistence/RuoYi-FastAPI/issues/10)。 2. 修复定时任务监听函数中事件没有job_id报错的问题。 ### 代码优化 1. 优化添加中间件函数注释。 -## RuoYi-Vue3-FastAPI v1.3.1 +## RuoYi-FastAPI v1.3.1 ### BUG修复 @@ -417,7 +417,7 @@ pip install fastapi[all]==0.115.0 -i https://pypi.tuna.tsinghua.edu.cn/simple 1. 补充定时任务违规字符串。 -## RuoYi-Vue3-FastAPI v1.3.0 +## RuoYi-FastAPI v1.3.0 ### 项目依赖 @@ -459,14 +459,14 @@ ALTER TABLE sys_menu ADD COLUMN route_name varchar(50) DEFAULT ''; 3. 各模块`vo`层使用`Field`声明字段。 4. 优化API文档字段描述显示。 -## RuoYi-Vue3-FastAPI v1.2.2 +## RuoYi-FastAPI v1.2.2 ### BUG修复 1. 修复删除定时任务时未移除调度中任务的问题。 2. 修复菜单生成路由时组件条件判断错误的问题。 -## RuoYi-Vue3-FastAPI v1.2.1 +## RuoYi-FastAPI v1.2.1 ### BUG修复 @@ -477,7 +477,7 @@ ALTER TABLE sys_menu ADD COLUMN route_name varchar(50) DEFAULT ''; 1. 修改代理localhost为127.0.0.1以适配部分设备解析localhost异常的问题。 -## RuoYi-Vue3-FastAPI v1.2.0 +## RuoYi-FastAPI v1.2.0 ### 重要说明 @@ -505,7 +505,7 @@ pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple。 1. 修复用户管理模块重置用户密码时会异常重置用户岗位和角色的问题。 2. 修复清空定时任务日志异常的问题。 -## RuoYi-Vue3-FastAPI v1.1.3 +## RuoYi-FastAPI v1.1.3 ### 新增功能 @@ -516,7 +516,7 @@ pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple。 1. 修复通知公告列表查询前后端字段不一致的问题。 2. 修复个人中心修改基本资料后端异常的问题。 -## RuoYi-Vue3-FastAPI v1.1.2 +## RuoYi-FastAPI v1.1.2 ### 新增功能 @@ -530,17 +530,17 @@ pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple。 1. 使用@lru_cache缓存ip归属区域查询结果,避免重复调用ip归属区域查询接口以优化性能。 -## RuoYi-Vue3-FastAPI v1.1.1 +## RuoYi-FastAPI v1.1.1 ### BUG修复 -1. 修复编辑定时任务时更新的信息未同步至scheduler的问题 [#I9EK56](https://gitee.com/insistence2022/RuoYi-Vue3-FastAPI/issues/I9EK56)。 -2. 修复编辑角色数据权限时后端异常的问题 [#I9ENQN](https://gitee.com/insistence2022/RuoYi-Vue3-FastAPI/issues/I9ENQN)。 +1. 修复编辑定时任务时更新的信息未同步至scheduler的问题 [#I9EK56](https://gitee.com/insistence2022/RuoYi-FastAPI/issues/I9EK56)。 +2. 修复编辑角色数据权限时后端异常的问题 [#I9ENQN](https://gitee.com/insistence2022/RuoYi-FastAPI/issues/I9ENQN)。 3. 修复菜单配置路由参数不生效的问题。 4. 修复获取路由信息时菜单排序不生效的问题。 5. 修复添加菜单时是否外链和是否缓存回显异常的问题。 -## RuoYi-Vue3-FastAPI v1.1.0 +## RuoYi-FastAPI v1.1.0 ### 新增功能 @@ -550,10 +550,10 @@ pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple。 ### BUG修复 -1. 修复token本身过期时退出登录接口异常的问题 [#I9CBWT](https://gitee.com/insistence2022/RuoYi-Vue3-FastAPI/issues/I9CBWT)。 -2. 修复系统版本号或浏览器版本号无法获取时登录异常的问题 [#I9CYNM](https://gitee.com/insistence2022/RuoYi-Vue3-FastAPI/issues/I9CYNM)。 +1. 修复token本身过期时退出登录接口异常的问题 [#I9CBWT](https://gitee.com/insistence2022/RuoYi-FastAPI/issues/I9CBWT)。 +2. 修复系统版本号或浏览器版本号无法获取时登录异常的问题 [#I9CYNM](https://gitee.com/insistence2022/RuoYi-FastAPI/issues/I9CYNM)。 -## RuoYi-Vue3-FastAPI v1.0.3 +## RuoYi-FastAPI v1.0.3 ### 新增功能 @@ -561,10 +561,10 @@ pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple。 ### BUG修复 -1. 修复外链菜单无法打开的问题 [#I95KBK](https://gitee.com/insistence2022/RuoYi-Vue3-FastAPI/issues/I95KBK)。 -2. 修复添加和编辑菜单页面中是否缓存和是否外链字段回显异常的问题 [#I95KBK](https://gitee.com/insistence2022/RuoYi-Vue3-FastAPI/issues/I95KBK)。 +1. 修复外链菜单无法打开的问题 [#I95KBK](https://gitee.com/insistence2022/RuoYi-FastAPI/issues/I95KBK)。 +2. 修复添加和编辑菜单页面中是否缓存和是否外链字段回显异常的问题 [#I95KBK](https://gitee.com/insistence2022/RuoYi-FastAPI/issues/I95KBK)。 -## RuoYi-Vue3-FastAPI v1.0.2 +## RuoYi-FastAPI v1.0.2 ### 新增功能 @@ -579,7 +579,7 @@ pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple。 1. 调整参数设置、部门管理、字典管理、定时任务、日志管理、角色管理、菜单管理模块部分接口权限标识。 -## RuoYi-Vue3-FastAPI v1.0.1 +## RuoYi-FastAPI v1.0.1 ### 项目依赖 @@ -595,9 +595,9 @@ pip install fastapi[all]==0.109.1 -i https://mirrors.aliyun.com/pypi/simple/ 1. 日志管理模块新增字段排序查询。 -## RuoYi-Vue3-FastAPI v1.0.0 +## RuoYi-FastAPI v1.0.0 -RuoYi-Vue3-FastAPI第一个版本发布啦! +RuoYi-FastAPI第一个版本发布啦! 此版本功能如下: 1. 用户管理:用户是系统操作者,该功能主要完成系统用户配置。 2. 角色管理:角色菜单权限分配。 diff --git a/README.md b/README.md index ab9bc27..38ff587 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,25 @@

- logo + logo

-

RuoYi-Vue3-FastAPI

-

基于RuoYi-Vue3+FastAPI前后端分离的快速开发框架

+

RuoYi-FastAPI

+

基于 FastAPI 的前后端分离快速开发框架

- - Gitee + + Gitee - - Github + + Github - - Playwright Tests + + CI - - Ruff Check - - + project version Ruff - + LICENSE node version @@ -33,16 +30,13 @@ ## 平台简介 -RuoYi-Vue3-FastAPI是一套全部开源的快速开发平台,毫无保留给个人及企业免费使用。 +RuoYi-FastAPI是一套全部开源的快速开发平台,毫无保留给个人及企业免费使用。 -* 前端采用Vue3、Element Plus,基于[RuoYi-Vue3](https://github.com/yangzongzhuan/RuoYi-Vue3)前端项目修改。 -* 移动端采用uni-app、Vue3、Vite,内置tailwindcss,基于[RuoYi-App](https://github.com/yangzongzhuan/RuoYi-App)项目修改。 +* 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)项目修改。 * 后端采用FastAPI、sqlalchemy、MySQL(PostgreSQL)、Redis、OAuth2 & Jwt。 * 权限认证使用OAuth2 & Jwt,支持多终端认证系统。 * 支持加载动态权限菜单,多方式轻松权限控制。 -* Vue2版本: - * Gitte仓库地址: - * GitHub仓库地址: * 纯Python版本: * Gitte仓库地址: * GitHub仓库地址: @@ -200,6 +194,23 @@ RuoYi-Vue3-FastAPI是一套全部开源的快速开发平台,毫无保留给 ## 项目开发及发布相关 +### 工程目录 + +前端目录约定为 `ruoyi-fastapi-frontend//{web,mobile}`,其中 `` 是前端框架标识,当前为 `vue2`、`vue3`。 + +```text +RuoYi-FastAPI/ +├── ruoyi-fastapi-backend/ # 共用 FastAPI 后端 +├── ruoyi-fastapi-frontend/ +│ ├── vue2/ +│ │ ├── web/ # Vue2 + Element UI +│ │ └── mobile/ # uni-app + Vue2 +│ └── vue3/ +│ ├── web/ # Vue3 + Element Plus + Vite +│ └── mobile/ # uni-app + Vue3 + Vite +└── ruoyi-fastapi-test/ # 端到端与跨工程契约测试 +``` + ### 传输层加解密配置说明 后端密钥配置与轮换说明:[ruoyi-fastapi-backend/docs/transport_crypto_config.md](./ruoyi-fastapi-backend/docs/transport_crypto_config.md) @@ -208,17 +219,18 @@ RuoYi-Vue3-FastAPI是一套全部开源的快速开发平台,毫无保留给 ```bash # 克隆项目 -git clone https://gitee.com/insistence2022/RuoYi-Vue3-FastAPI.git +git clone https://gitee.com/insistence2022/RuoYi-FastAPI.git # 进入项目根目录 -cd RuoYi-Vue3-FastAPI +cd RuoYi-FastAPI ``` -#### 前端 +#### Web 前端 ```bash -# 进入前端目录 -cd ruoyi-fastapi-frontend +# 从仓库根目录进入 Vue3 Web 工程 +cd ruoyi-fastapi-frontend/vue3/web +# 使用 Vue2 时改为:cd ruoyi-fastapi-frontend/vue2/web # 安装依赖 npm install 或 yarn --registry=https://registry.npmmirror.com @@ -230,11 +242,15 @@ npm install --registry=https://registry.npmmirror.com npm run dev 或 yarn dev ``` -#### 移动端 +两个 Web 工程默认都使用 `80` 端口,开发代理均指向同一个 `9099` 后端。同时运行时,请分别配置不同的前端端口。 + +Vue2 Web 使用 Vue CLI 4 / webpack 4,在 Node.js 18 及以上启动或构建前,需要设置 `NODE_OPTIONS=--openssl-legacy-provider`:PowerShell 执行 `$env:NODE_OPTIONS = '--openssl-legacy-provider'`,Bash 执行 `export NODE_OPTIONS=--openssl-legacy-provider`。Docker 和构建 CI 已配置此选项。 + +#### Vue3 移动端 ```bash -# 进入移动端目录 -cd ruoyi-fastapi-app +# 从仓库根目录进入移动端工程 +cd ruoyi-fastapi-frontend/vue3/mobile # 安装依赖 npm install -g pnpm @@ -247,7 +263,21 @@ pnpm dev:h5 pnpm dev:mp-weixin ``` -移动端详细文档请参考:[ruoyi-fastapi-app/README.md](./ruoyi-fastapi-app/README.md) +#### Vue2 移动端 + +```bash +# 从仓库根目录进入移动端工程 +cd ruoyi-fastapi-frontend/vue2/mobile +yarn install + +# 启动 H5 +yarn dev:h5 + +# 启动微信小程序 +yarn dev:mp-weixin +``` + +移动端详细文档请参考:[Vue3 移动端](./ruoyi-fastapi-frontend/vue3/mobile/README.md)、[Vue2 移动端](./ruoyi-fastapi-frontend/vue2/mobile/README.md)。 #### 后端 @@ -289,7 +319,9 @@ ruoyi app run --env=dev ### 发布 -#### 前端 +#### Web 前端 + +先进入要发布的 `ruoyi-fastapi-frontend/vue2/web` 或 `ruoyi-fastapi-frontend/vue3/web` 工程,再运行: ```bash # 构建测试环境 @@ -313,16 +345,18 @@ ruoyi app run --env=prod > ⚠️ **警告:** 默认未做数据持久化配置,请注意数据备份或自行配置持久化 +Compose 通过前端框架标识选择 Web 工程。仓库根目录的 `docker.env` 默认设置 `FRONTEND_FRAMEWORK=vue3`,当前可选值为 `vue2`、`vue3`;改为 `vue2` 可切换到 Vue2 Web。执行下方命令时,通过 `--env-file docker.env` 显式加载配置。该变量同时选择 `ruoyi-fastapi-frontend//web` 构建目录与对应的 nginx 配置,并通过 `RUOYI_PLUGIN_FRONTEND_FRAMEWORK` 传入后端,使插件依赖检查和安装使用相同框架分类。各框架共用后端服务与数据库配置;本次部署只构建所选 Web 工程,不会构建移动端。 + #### MySQL版本 ```bash -docker compose -f docker-compose.my.yml up -d --build +docker compose --env-file docker.env -f docker-compose.my.yml up -d --build ``` #### PostgreSQL版本 ```bash -docker compose -f docker-compose.pg.yml up -d --build +docker compose --env-file docker.env -f docker-compose.pg.yml up -d --build ``` ## 交流与赞助 diff --git a/docker-compose.my.yml b/docker-compose.my.yml index e6a8dd8..eb1458a 100644 --- a/docker-compose.my.yml +++ b/docker-compose.my.yml @@ -2,7 +2,7 @@ services: # 前端服务 ruoyi-frontend: build: - context: ./ruoyi-fastapi-frontend + context: ./ruoyi-fastapi-frontend/${FRONTEND_FRAMEWORK:-vue3}/web dockerfile: Dockerfile image: ruoyi-frontend:latest container_name: ruoyi-frontend @@ -11,7 +11,7 @@ services: ports: - "12580:80" volumes: - - ./ruoyi-fastapi-frontend/bin/nginx.dockermy.conf:/etc/nginx/conf.d/default.conf + - ./ruoyi-fastapi-frontend/${FRONTEND_FRAMEWORK:-vue3}/web/bin/nginx.dockermy.conf:/etc/nginx/conf.d/default.conf depends_on: - ruoyi-backend-my networks: @@ -26,6 +26,7 @@ services: container_name: ruoyi-backend-my environment: TZ: UTC + RUOYI_PLUGIN_FRONTEND_FRAMEWORK: ${FRONTEND_FRAMEWORK:-vue3} ports: - "19099:9099" depends_on: diff --git a/docker-compose.pg.yml b/docker-compose.pg.yml index 496008d..70fc764 100644 --- a/docker-compose.pg.yml +++ b/docker-compose.pg.yml @@ -2,7 +2,7 @@ services: # 前端服务 ruoyi-frontend: build: - context: ./ruoyi-fastapi-frontend + context: ./ruoyi-fastapi-frontend/${FRONTEND_FRAMEWORK:-vue3}/web dockerfile: Dockerfile image: ruoyi-frontend:latest container_name: ruoyi-frontend @@ -11,7 +11,7 @@ services: ports: - "12580:80" volumes: - - ./ruoyi-fastapi-frontend/bin/nginx.dockerpg.conf:/etc/nginx/conf.d/default.conf + - ./ruoyi-fastapi-frontend/${FRONTEND_FRAMEWORK:-vue3}/web/bin/nginx.dockerpg.conf:/etc/nginx/conf.d/default.conf depends_on: - ruoyi-backend-pg networks: @@ -26,6 +26,7 @@ services: container_name: ruoyi-backend-pg environment: TZ: UTC + RUOYI_PLUGIN_FRONTEND_FRAMEWORK: ${FRONTEND_FRAMEWORK:-vue3} ports: - "19099:9099" depends_on: diff --git a/docker.env b/docker.env new file mode 100644 index 0000000..8393b26 --- /dev/null +++ b/docker.env @@ -0,0 +1 @@ +FRONTEND_FRAMEWORK=vue3 diff --git a/ruoyi-fastapi-app/tests/time.test.js b/ruoyi-fastapi-app/tests/time.test.js deleted file mode 100644 index 3fdff14..0000000 --- a/ruoyi-fastapi-app/tests/time.test.js +++ /dev/null @@ -1,3 +0,0 @@ -import * as time from '../src/utils/time.js' -import { testTimeContract } from '../../ruoyi-fastapi-test/time-contract/contract.mjs' -testTimeContract(time) diff --git a/ruoyi-fastapi-backend/.dockerignore b/ruoyi-fastapi-backend/.dockerignore new file mode 100644 index 0000000..9ed1862 --- /dev/null +++ b/ruoyi-fastapi-backend/.dockerignore @@ -0,0 +1,12 @@ +.git +.venv +venv +__pycache__ +*.pyc +.pytest_cache +.ruff_cache +.cache +target +logs +*.log +*.egg-info diff --git a/ruoyi-fastapi-backend/Dockerfile.my b/ruoyi-fastapi-backend/Dockerfile.my index 240396f..f872739 100644 --- a/ruoyi-fastapi-backend/Dockerfile.my +++ b/ruoyi-fastapi-backend/Dockerfile.my @@ -1,12 +1,14 @@ -FROM python:3.10 +ARG PYTHON_VERSION=3.10 +FROM python:${PYTHON_VERSION} ENV TZ=UTC WORKDIR /app +ARG PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple # 复制源代码 COPY . . # 安装依赖 -RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple +RUN pip install --no-cache-dir -r requirements.txt -i ${PIP_INDEX_URL} # 暴露端口 EXPOSE 9099 diff --git a/ruoyi-fastapi-backend/Dockerfile.pg b/ruoyi-fastapi-backend/Dockerfile.pg index cd22c60..28ddb35 100644 --- a/ruoyi-fastapi-backend/Dockerfile.pg +++ b/ruoyi-fastapi-backend/Dockerfile.pg @@ -1,12 +1,14 @@ -FROM python:3.10 +ARG PYTHON_VERSION=3.10 +FROM python:${PYTHON_VERSION} ENV TZ=UTC WORKDIR /app +ARG PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple # 复制源代码 COPY . . # 安装依赖 -RUN pip install --no-cache-dir -r requirements-pg.txt -i https://pypi.tuna.tsinghua.edu.cn/simple +RUN pip install --no-cache-dir -r requirements-pg.txt -i ${PIP_INDEX_URL} # 暴露端口 EXPOSE 9099 diff --git a/ruoyi-fastapi-backend/cli/groups/plugin/commands/developer.py b/ruoyi-fastapi-backend/cli/groups/plugin/commands/developer.py index 79cd577..ec3dac4 100644 --- a/ruoyi-fastapi-backend/cli/groups/plugin/commands/developer.py +++ b/ruoyi-fastapi-backend/cli/groups/plugin/commands/developer.py @@ -1,11 +1,11 @@ from collections.abc import Callable -from typing import Annotated, Any, Literal +from typing import Annotated, Any import typer from cli.context import DryRunOption, EnvOption, OutputOption -from ..options import PluginCreateCommandOptions +from ..options import PluginCreateCommandOptions, PluginFrontendFrameworkChoice def register_developer_commands(app: typer.Typer, get_controller: Callable[[], Any]) -> None: @@ -63,9 +63,11 @@ def register_developer_commands(app: typer.Typer, get_controller: Callable[[], A 'v2:python-asgi、python-bundle、rust-asgi、rust-bundle(生成独立源码工程)', ), ] = 'full-stack', - frontend_version: Annotated[ - Literal['auto', 'vue2', 'vue3'], - typer.Option('--frontend-version', help='v1 前端 Vue 版本:auto、vue2、vue3;v2 bundle 不使用宿主 Vue'), + frontend_framework: Annotated[ + PluginFrontendFrameworkChoice, + typer.Option( + '--frontend-framework', help='宿主框架,auto 自动识别;v1 源码模板仅支持 vue2、vue3,v2 模板仅接受 auto' + ), ] = 'auto', backend_only: Annotated[bool, typer.Option('--backend-only', help='只创建后端插件模板')] = False, frontend_only: Annotated[bool, typer.Option('--frontend-only', help='只创建前端插件模板')] = False, @@ -83,7 +85,7 @@ def register_developer_commands(app: typer.Typer, get_controller: Callable[[], A :param env: 当前命令运行环境 :param output: 输出格式 :param template: 插件模板名称 - :param frontend_version: 前端 Vue 版本 + :param frontend_framework: 前端框架标识,auto 表示自动识别 :param backend_only: 是否只创建后端插件模板 :param frontend_only: 是否只创建前端插件模板 :param no_migration: 是否不创建 migration 示例 @@ -102,7 +104,7 @@ def register_developer_commands(app: typer.Typer, get_controller: Callable[[], A backend_only=backend_only, frontend_only=frontend_only, template=template, - frontend_version=frontend_version, + frontend_framework=frontend_framework, no_migration=no_migration, no_seed=no_seed, no_job=no_job, diff --git a/ruoyi-fastapi-backend/cli/groups/plugin/commands/sdk.py b/ruoyi-fastapi-backend/cli/groups/plugin/commands/sdk.py index e6aefc2..62f4db8 100644 --- a/ruoyi-fastapi-backend/cli/groups/plugin/commands/sdk.py +++ b/ruoyi-fastapi-backend/cli/groups/plugin/commands/sdk.py @@ -1,6 +1,5 @@ import importlib import json -import os from pathlib import Path from typing import Annotated, Any @@ -10,9 +9,15 @@ from cli.context import CliContext, DryRunOption, OutputOption from cli.core import DEFAULT_CORE_SERVICES from cli.exit_codes import ARGUMENT_ERROR, DEPENDENCY_ERROR, RUNTIME_ERROR, SUCCESS +from ..options import PluginFrontendFrameworkChoice + FrontendRootOption = Annotated[ Path | None, - typer.Option('--frontend-root', help='作为 SDK 来源的宿主前端目录,默认相邻前端或 RUOYI_PLUGIN_FRONTEND_ROOT'), + typer.Option('--frontend-root', help='SDK 宿主工程或前端容器目录,默认使用所选框架的 web 工程'), +] +FrontendFrameworkOption = Annotated[ + PluginFrontendFrameworkChoice, + typer.Option('--frontend-framework', help='宿主工程框架;auto 使用环境变量或默认 vue3'), ] @@ -50,24 +55,28 @@ def _execute_sdk( *, dry_run: bool = False, force: bool = False, + frontend_framework: PluginFrontendFrameworkChoice = 'auto', ) -> None: """ 仅加载离线 SDK 文件服务,使用统一 CLI 输出和非零失败退出码。 :param operation: check 或 update :param project: 独立 bundle 源码工程目录 - :param frontend_root: 可选宿主前端源码目录 + :param frontend_root: 可选宿主前端工程根目录或包含框架分类的聚合目录 :param output: text 或 json :param dry_run: 是否只预演更新 :param force: 是否备份后替换改动过或未跟踪的 SDK + :param frontend_framework: SDK 来源宿主的前端框架标识 :return: None """ try: - root = frontend_root or Path( - os.environ.get('RUOYI_PLUGIN_FRONTEND_ROOT') - or Path(__file__).resolve().parents[5] / 'ruoyi-fastapi-frontend' + frontend_root_resolver = importlib.import_module('plugins.core.frontend').resolve_frontend_root + resolved_frontend_root = frontend_root_resolver( + Path(__file__).resolve().parents[4], frontend_root, frontend_framework + ) + service = importlib.import_module('cli.runtime.plugin.sdk').PluginBridgeSdk( + resolved_frontend_root, frontend_framework=frontend_framework ) - service = importlib.import_module('cli.runtime.plugin.sdk').PluginBridgeSdk(root) payload = ( service.check(project) if operation == 'check' else service.update(project, dry_run=dry_run, force=force) ) @@ -101,16 +110,18 @@ def register_sdk_commands(app: typer.Typer) -> None: project: Annotated[Path, typer.Argument(help='含 plugin.yaml 的独立 bundle 源码工程目录')], frontend_root: FrontendRootOption = None, output: OutputOption = 'text', + frontend_framework: FrontendFrameworkOption = 'auto', ) -> None: """ 检查源码工程的 SDK 来源与本地修改,不写文件或连接外部服务。 :param project: 独立 bundle 源码工程目录 - :param frontend_root: 可选宿主前端源码目录 + :param frontend_root: 可选宿主前端工程根目录或包含框架分类的聚合目录 :param output: 输出格式 + :param frontend_framework: SDK 来源宿主的前端框架标识 :return: None """ - _execute_sdk('check', project, frontend_root, output) + _execute_sdk('check', project, frontend_root, output, frontend_framework=frontend_framework) @sdk_app.command('update', help='备份后更新 SDK 的三个文件;不会构建、安装或重启插件') def update_sdk( @@ -119,15 +130,25 @@ def register_sdk_commands(app: typer.Typer) -> None: output: OutputOption = 'text', dry_run: DryRunOption = False, force: Annotated[bool, typer.Option('--force', help='备份后纳管旧副本或替换本地 SDK 修改')] = False, + frontend_framework: FrontendFrameworkOption = 'auto', ) -> None: """ 显式更新源码工程的 SDK;默认拒绝覆盖用户改动或来源不明的副本。 :param project: 独立 bundle 源码工程目录 - :param frontend_root: 可选宿主前端源码目录 + :param frontend_root: 可选宿主前端工程根目录或包含框架分类的聚合目录 :param output: 输出格式 :param dry_run: 是否仅预演而不写入文件 :param force: 是否备份后替换改动或未跟踪副本 + :param frontend_framework: SDK 来源宿主的前端框架标识 :return: None """ - _execute_sdk('update', project, frontend_root, output, dry_run=dry_run, force=force) + _execute_sdk( + 'update', + project, + frontend_root, + output, + dry_run=dry_run, + force=force, + frontend_framework=frontend_framework, + ) diff --git a/ruoyi-fastapi-backend/cli/groups/plugin/controller.py b/ruoyi-fastapi-backend/cli/groups/plugin/controller.py index 89b0d78..149933a 100644 --- a/ruoyi-fastapi-backend/cli/groups/plugin/controller.py +++ b/ruoyi-fastapi-backend/cli/groups/plugin/controller.py @@ -661,7 +661,7 @@ class PluginCommandController: job=not options.no_job, config=not options.no_config, test=not options.no_test, - frontend_version=options.frontend_version, + frontend_framework=options.frontend_framework, dry_run=options.dry_run, ) self.execution_service.complete_payload_with_text( diff --git a/ruoyi-fastapi-backend/cli/groups/plugin/options.py b/ruoyi-fastapi-backend/cli/groups/plugin/options.py index 556fa52..0950704 100644 --- a/ruoyi-fastapi-backend/cli/groups/plugin/options.py +++ b/ruoyi-fastapi-backend/cli/groups/plugin/options.py @@ -1,6 +1,8 @@ from dataclasses import dataclass from typing import Literal +PluginFrontendFrameworkChoice = Literal['auto', 'vue2', 'vue3'] + @dataclass(frozen=True) class PluginCreateCommandOptions: @@ -10,7 +12,7 @@ class PluginCreateCommandOptions: :param backend_only: 是否只创建后端插件模板 :param frontend_only: 是否只创建前端插件模板 :param template: 插件模板名称 - :param frontend_version: 前端 Vue 版本,支持 auto、vue2、vue3 + :param frontend_framework: 前端框架标识,auto 表示自动识别;源码模板仅支持 vue2、vue3 :param no_migration: 是否不创建 migration 示例 :param no_seed: 是否不创建 seed 示例 :param no_job: 是否不创建定时任务示例 @@ -22,7 +24,7 @@ class PluginCreateCommandOptions: backend_only: bool = False frontend_only: bool = False template: str = 'full-stack' - frontend_version: Literal['auto', 'vue2', 'vue3'] = 'auto' + frontend_framework: PluginFrontendFrameworkChoice = 'auto' no_migration: bool = False no_seed: bool = False no_job: bool = False diff --git a/ruoyi-fastapi-backend/cli/groups/plugin/presenter.py b/ruoyi-fastapi-backend/cli/groups/plugin/presenter.py index c18eeb2..414bf68 100644 --- a/ruoyi-fastapi-backend/cli/groups/plugin/presenter.py +++ b/ruoyi-fastapi-backend/cli/groups/plugin/presenter.py @@ -556,7 +556,7 @@ class PluginCommandPresenter: f'template: {payload.get("template", "-")}', f'backend: {str(payload.get("backend", False)).lower()}', f'frontend: {str(payload.get("frontend", False)).lower()}', - f'frontend_version: {payload.get("frontendVersion") or "-"}', + f'frontend_framework: {payload.get("frontendFramework") or "-"}', f'migration: {str(payload.get("migration", False)).lower()}', f'seed: {str(payload.get("seed", False)).lower()}', f'job: {str(payload.get("job", False)).lower()}', diff --git a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/__init__.py b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/__init__.py index 9c359a4..d3b3b89 100644 --- a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/__init__.py +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/__init__.py @@ -1,6 +1,6 @@ from .backend import PluginBackendScaffoldTemplateBuilder from .builder import PluginScaffoldBuilder -from .frontend import FrontendVersion, PluginFrontendScaffoldTemplateBuilder, PluginFrontendVersionResolver +from .frontend import FrontendFramework, PluginFrontendFrameworkResolver, PluginFrontendScaffoldTemplateBuilder from .naming import PluginScaffoldNaming from .options import PluginScaffoldOptions, PluginScaffoldTemplateResolver from .payload import ( @@ -11,10 +11,10 @@ from .payload import ( ) __all__ = [ - 'FrontendVersion', + 'FrontendFramework', 'PluginBackendScaffoldTemplateBuilder', + 'PluginFrontendFrameworkResolver', 'PluginFrontendScaffoldTemplateBuilder', - 'PluginFrontendVersionResolver', 'PluginScaffoldBuilder', 'PluginScaffoldConflictPayload', 'PluginScaffoldNaming', diff --git a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/backend.py b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/backend.py index 9984e44..3061328 100644 --- a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/backend.py +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/backend.py @@ -95,8 +95,7 @@ permissions: dependencies: python: [] - npm: [] - npmDev: [] + frontend: {{}} plugins: [] {config} """ diff --git a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/builder.py b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/builder.py index a48542d..a6f6baa 100644 --- a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/builder.py +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/builder.py @@ -1,10 +1,12 @@ +from collections.abc import Callable from pathlib import Path from typing import Any +from plugins.core.frontend import select_frontend_root from plugins.core.utils import validate_plugin_id_value from .backend import PluginBackendScaffoldTemplateBuilder -from .frontend import FrontendVersion, PluginFrontendScaffoldTemplateBuilder, PluginFrontendVersionResolver +from .frontend import FrontendFramework, PluginFrontendFrameworkResolver, PluginFrontendScaffoldTemplateBuilder from .options import PluginScaffoldOptions, PluginScaffoldTemplateResolver from .payload import PluginScaffoldPayloadBuilder, PluginScaffoldPlanPayload from .v2 import PluginV2ScaffoldBuilder @@ -17,15 +19,23 @@ class PluginScaffoldBuilder: 使用 Builder 模式生成后端与前端插件模板文件计划,并在确认无冲突后落地。 """ - def __init__(self, backend_root: Path, frontend_root: Path) -> None: + def __init__( + self, + backend_root: Path, + frontend_root: Path, + frontend_framework_resolver: Callable[[Path], str] | None = None, + ) -> None: """ 初始化插件模板构建器。 :param backend_root: 后端项目根目录 - :param frontend_root: 前端项目根目录 + :param frontend_root: 前端工程根目录或包含框架分类的聚合目录 + :param frontend_framework_resolver: 宿主框架解析函数,保留运行环境中选择的框架 + :return: None """ self.backend_root = backend_root - self.frontend_root = frontend_root + self.frontend_root = select_frontend_root(frontend_root) + self.frontend_framework_resolver = frontend_framework_resolver def build_plan( self, @@ -39,7 +49,7 @@ class PluginScaffoldBuilder: job: bool = True, config: bool = True, test: bool = True, - frontend_version: str = PluginFrontendVersionResolver.AUTO, + frontend_framework: str = PluginFrontendFrameworkResolver.AUTO, ) -> dict[str, Any]: """ 构建插件模板写入计划。 @@ -53,19 +63,22 @@ class PluginScaffoldBuilder: :param job: 是否创建定时任务示例 :param config: 是否创建配置项示例 :param test: 是否创建测试样例 - :param frontend_version: 前端 Vue 版本,支持 auto、vue2、vue3 + :param frontend_framework: 目标前端框架标识,auto 表示自动识别;源码模板仅支持 vue2、vue3 :return: 插件模板写入计划 """ self._validate_plugin_id(plugin_id) + frontend_root = select_frontend_root(self.frontend_root, frontend_framework) normalized_template = (template or PluginScaffoldTemplateResolver.DEFAULT_TEMPLATE).strip() if normalized_template in PluginScaffoldTemplateResolver.V2_TEMPLATES: - return PluginV2ScaffoldBuilder(self.backend_root, self.frontend_root).build_plan( + return PluginV2ScaffoldBuilder( + self.backend_root, frontend_root, frontend_framework_resolver=self.frontend_framework_resolver + ).build_plan( plugin_id, template=normalized_template, backend=backend, frontend=frontend, test=test, - frontend_version=frontend_version, + frontend_framework=frontend_framework, ) options = self._merge_options( PluginScaffoldTemplateResolver.resolve(template), @@ -84,8 +97,8 @@ class PluginScaffoldBuilder: target_dirs = [] effective_backend_test = options.test and options.backend effective_frontend_test = options.test and options.frontend - resolved_frontend_version = ( - PluginFrontendVersionResolver.resolve(self.frontend_root, frontend_version) if options.frontend else None + resolved_frontend_framework = ( + self._resolve_frontend_framework(frontend_root, frontend_framework) if options.frontend else None ) if options.backend: backend_plugin_root = self.backend_root / 'plugins' / plugin_id @@ -94,17 +107,17 @@ class PluginScaffoldBuilder: target_dirs.append(str(self.backend_root / 'tests' / 'plugins' / plugin_id)) files.extend(self._build_backend_files(plugin_id, backend_plugin_root, options)) if options.frontend: - assert resolved_frontend_version is not None - frontend_plugin_root = self.frontend_root / 'plugins' / plugin_id + assert resolved_frontend_framework is not None + frontend_plugin_root = frontend_root / 'plugins' / plugin_id target_dirs.append(str(frontend_plugin_root)) if effective_frontend_test: - target_dirs.append(str(self.frontend_root / 'tests' / 'plugins' / plugin_id)) + target_dirs.append(str(frontend_root / 'tests' / 'plugins' / plugin_id)) files.extend( self._build_frontend_files( plugin_id, frontend_plugin_root, options, - frontend_version=resolved_frontend_version, + frontend_framework=resolved_frontend_framework, ) ) @@ -122,7 +135,7 @@ class PluginScaffoldBuilder: test=effective_backend_test or effective_frontend_test, backend_test=effective_backend_test, frontend_test=effective_frontend_test, - frontend_version=resolved_frontend_version, + frontend_framework=resolved_frontend_framework, target_dirs=target_dirs, files=files, conflicts=conflicts, @@ -131,6 +144,18 @@ class PluginScaffoldBuilder: build_conflict_payload = staticmethod(PluginScaffoldPayloadBuilder.build_conflict_payload) build_success_payload = staticmethod(PluginScaffoldPayloadBuilder.build_success_payload) + def _resolve_frontend_framework(self, frontend_root: Path, requested_framework: str) -> str: + """ + 优先使用命令显式框架,其次沿用宿主运行环境的框架选择。 + + :param frontend_root: 已选中的前端工程目录 + :param requested_framework: 命令指定的框架标识 + :return: 已解析的前端框架标识 + """ + if (requested_framework or 'auto').strip().lower() == 'auto' and self.frontend_framework_resolver is not None: + return self.frontend_framework_resolver(frontend_root) + return PluginFrontendFrameworkResolver.resolve(frontend_root, requested_framework) + @classmethod def _validate_plugin_id(cls, plugin_id: str) -> None: """ @@ -251,7 +276,7 @@ class PluginScaffoldBuilder: plugin_root: Path, options: PluginScaffoldOptions, *, - frontend_version: FrontendVersion, + frontend_framework: FrontendFramework, ) -> list[tuple[Path, str]]: """ 构建前端插件模板文件。 @@ -259,7 +284,7 @@ class PluginScaffoldBuilder: :param plugin_id: 插件ID :param plugin_root: 前端插件根目录 :param options: 插件模板生成选项 - :param frontend_version: 已解析的前端 Vue 版本 + :param frontend_framework: 已解析的前端框架标识 :return: 文件路径和内容列表 """ files = [ @@ -271,20 +296,20 @@ class PluginScaffoldBuilder: ), ( plugin_root / 'views' / 'index.vue', - PluginFrontendScaffoldTemplateBuilder.build_crud_view(plugin_id, frontend_version) + PluginFrontendScaffoldTemplateBuilder.build_crud_view(plugin_id, frontend_framework) if options.crud - else PluginFrontendScaffoldTemplateBuilder.build_view(plugin_id, frontend_version), + else PluginFrontendScaffoldTemplateBuilder.build_view(plugin_id, frontend_framework), ), ( plugin_root / 'README.md', - PluginFrontendScaffoldTemplateBuilder.build_readme(plugin_id, frontend_version), + PluginFrontendScaffoldTemplateBuilder.build_readme(plugin_id, frontend_framework), ), ] if options.test: files.append( ( - self.frontend_root / 'tests' / 'plugins' / plugin_id / 'pluginView.test.js', - PluginFrontendScaffoldTemplateBuilder.build_test(plugin_id, frontend_version), + plugin_root.parents[1] / 'tests' / 'plugins' / plugin_id / 'pluginView.test.js', + PluginFrontendScaffoldTemplateBuilder.build_test(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 7b418fc..cbf643e 100644 --- a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/frontend.py +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/frontend.py @@ -1,94 +1,8 @@ -import json -import re -from pathlib import Path -from typing import Literal, cast +from plugins.core.frontend import FrontendFramework, PluginFrontendFrameworkResolver # noqa: F401 from .frontend_vue2 import PluginVue2FrontendScaffoldTemplateBuilder from .naming import PluginScaffoldNaming -FrontendVersion = Literal['vue2', 'vue3'] - - -class PluginFrontendVersionResolver: - """ - 前端 Vue 版本解析器。 - """ - - AUTO = 'auto' - DEFAULT_VERSION: FrontendVersion = 'vue3' - SUPPORTED_VALUES = (AUTO, 'vue2', 'vue3') - - @classmethod - def resolve(cls, frontend_root: Path, requested_version: str = AUTO) -> FrontendVersion: - """ - 解析脚手架应使用的 Vue 版本。 - - 显式版本优先;auto 模式读取目标前端 package.json。临时目录等没有 - package.json 的场景保持历史行为,默认生成 Vue 3 模板。 - - :param frontend_root: 前端项目根目录 - :param requested_version: auto、vue2 或 vue3 - :return: 解析后的 Vue 版本 - """ - normalized_version = (requested_version or cls.AUTO).strip().lower() - if normalized_version not in cls.SUPPORTED_VALUES: - supported = '、'.join(cls.SUPPORTED_VALUES) - raise ValueError(f'frontend_version 仅支持 {supported},当前值:{requested_version}') - if normalized_version != cls.AUTO: - return cast('FrontendVersion', normalized_version) - - package_json_path = frontend_root / 'package.json' - if not package_json_path.is_file(): - return cls.DEFAULT_VERSION - - try: - package_payload = json.loads(package_json_path.read_text(encoding='utf-8')) - except (OSError, json.JSONDecodeError) as exc: - raise ValueError(f'读取前端 package.json 失败:{package_json_path}({exc})') from exc - if not isinstance(package_payload, dict): - raise ValueError(f'前端 package.json 顶层必须是对象:{package_json_path}') - - dependencies = cls._collect_dependencies(package_payload) - vue_version = cls._resolve_vue_dependency(dependencies.get('vue')) - if vue_version is not None: - return vue_version - if 'element-ui' in dependencies: - return 'vue2' - if 'element-plus' in dependencies: - return 'vue3' - - raise ValueError(f'无法从 {package_json_path} 识别 Vue 版本,请使用 --frontend-version vue2 或 vue3 显式指定') - - @staticmethod - def _collect_dependencies(package_payload: dict[str, object]) -> dict[str, object]: - """ - 合并 dependencies 和 devDependencies。 - - :param package_payload: package.json 负载 - :return: 依赖映射 - """ - dependencies: dict[str, object] = {} - for key in ('devDependencies', 'dependencies'): - section = package_payload.get(key) - if isinstance(section, dict): - dependencies.update(section) - return dependencies - - @staticmethod - def _resolve_vue_dependency(version_spec: object) -> FrontendVersion | None: - """ - 从 npm Vue 版本约束中提取主版本。 - - :param version_spec: Vue npm 版本约束 - :return: Vue 版本,无法识别时返回 None - """ - if not isinstance(version_spec, str): - return None - match = re.search(r'(? str: - return cls._resolve_builder(frontend_version).build_view(plugin_id) + def build_view(cls, plugin_id: str, frontend_framework: FrontendFramework = 'vue3') -> str: + return cls._resolve_builder(frontend_framework).build_view(plugin_id) @classmethod - def build_crud_view(cls, plugin_id: str, frontend_version: FrontendVersion = 'vue3') -> str: - return cls._resolve_builder(frontend_version).build_crud_view(plugin_id) + def build_crud_view(cls, plugin_id: str, frontend_framework: FrontendFramework = 'vue3') -> str: + return cls._resolve_builder(frontend_framework).build_crud_view(plugin_id) @classmethod - def build_readme(cls, plugin_id: str, frontend_version: FrontendVersion = 'vue3') -> str: - return cls._resolve_builder(frontend_version).build_readme(plugin_id) + def build_readme(cls, plugin_id: str, frontend_framework: FrontendFramework = 'vue3') -> str: + return cls._resolve_builder(frontend_framework).build_readme(plugin_id) @classmethod - def build_test(cls, plugin_id: str, frontend_version: FrontendVersion = 'vue3') -> str: - return cls._resolve_builder(frontend_version).build_test(plugin_id) + def build_test(cls, plugin_id: str, frontend_framework: FrontendFramework = 'vue3') -> str: + return cls._resolve_builder(frontend_framework).build_test(plugin_id) @staticmethod def _resolve_builder( - frontend_version: FrontendVersion, + frontend_framework: FrontendFramework, ) -> type[PluginVue2FrontendScaffoldTemplateBuilder] | type[PluginVue3FrontendScaffoldTemplateBuilder]: - if frontend_version == 'vue2': + if frontend_framework == 'vue2': return PluginVue2FrontendScaffoldTemplateBuilder - if frontend_version == 'vue3': + if frontend_framework == 'vue3': return PluginVue3FrontendScaffoldTemplateBuilder - raise ValueError(f'不支持的 Vue 版本:{frontend_version}') + raise ValueError(f'暂不支持前端框架 {frontend_framework} 的源码模板,目前仅支持 vue2、vue3') diff --git a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/payload.py b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/payload.py index 2f8ac25..bfefb30 100644 --- a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/payload.py +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/payload.py @@ -23,7 +23,7 @@ class PluginScaffoldPlanPayload: test: bool backend_test: bool frontend_test: bool - frontend_version: str | None + frontend_framework: str | None target_dirs: list[str] files: list[tuple[Path, str]] conflicts: list[str] @@ -46,7 +46,7 @@ class PluginScaffoldPlanPayload: 'test': self.test, 'backendTest': self.backend_test, 'frontendTest': self.frontend_test, - 'frontendVersion': self.frontend_version, + 'frontendFramework': self.frontend_framework, 'targetDirs': [format_cli_path(path) for path in self.target_dirs], 'files': [{'path': path.as_posix(), 'content': content} for path, content in self.files], 'conflicts': self.conflicts, diff --git a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/v2.py b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/v2.py index 8b99fb7..c4587cd 100644 --- a/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/v2.py +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/scaffold/v2.py @@ -1,4 +1,5 @@ import json +from collections.abc import Callable from importlib.resources import files from pathlib import Path from typing import Any @@ -15,19 +16,26 @@ class PluginV2ScaffoldBuilder: 独立 v2 插件源码工程模板构建器。 """ - def __init__(self, backend_root: Path, frontend_root: Path) -> None: + def __init__( + self, + backend_root: Path, + frontend_root: Path, + frontend_framework_resolver: Callable[[Path], str] | None = None, + ) -> None: """ 初始化独立 v2 工程模板构建器。 :param backend_root: 宿主后端项目根目录 :param frontend_root: 宿主前端项目根目录,用于读取桥接 SDK + :param frontend_framework_resolver: 宿主框架解析函数,仅 bundle 复制 SDK 时调用 :return: None """ self.backend_root = backend_root self.frontend_root = frontend_root + self.frontend_framework_resolver = frontend_framework_resolver def build_plan( - self, plugin_id: str, *, template: str, backend: bool, frontend: bool, test: bool, frontend_version: str + self, plugin_id: str, *, template: str, backend: bool, frontend: bool, test: bool, frontend_framework: str ) -> dict[str, Any]: """ 生成独立源码工程的文件计划,不安装、启用或导入插件。 @@ -37,7 +45,7 @@ class PluginV2ScaffoldBuilder: :param backend: 是否创建后端工程 :param frontend: 是否创建前端工程 :param test: 是否生成本地合约测试 - :param frontend_version: 旧版前端选项,v2 模板仅接受 auto + :param frontend_framework: 宿主前端框架选项,v2 模板仅接受 auto :return: 包含生成文件、目标目录与冲突信息的计划 """ native = template.startswith('rust-') @@ -46,8 +54,8 @@ class PluginV2ScaffoldBuilder: raise ValueError('v2 ASGI 模板需要后端,不能使用 --frontend-only') if bundle and not frontend: raise ValueError('bundle 模板不能使用 --backend-only;仅 API 请使用 python-asgi 或 rust-asgi') - if frontend_version != 'auto': - raise ValueError('v2 模板不使用 --frontend-version;独立 bundle 与宿主 Vue 版本无关') + if frontend_framework != 'auto': + raise ValueError('v2 模板的 --frontend-framework 仅接受 auto;独立 bundle 与宿主前端框架无关') root = self.backend_root / 'plugin-projects' / plugin_id replacements = { @@ -86,9 +94,16 @@ class PluginV2ScaffoldBuilder: if test: entries.append((root / 'tests' / 'test_plugin.py', self._render('test_plugin.py', replacements))) if bundle: + host_framework = ( + self.frontend_framework_resolver(self.frontend_root) + if self.frontend_framework_resolver is not None + else 'auto' + ) entries.extend( (root / 'web' / 'vendor' / sdk_name, content) - for sdk_name, content in PluginBridgeSdk(self.frontend_root).build_vendor_files().items() + for sdk_name, content in PluginBridgeSdk(self.frontend_root, frontend_framework=host_framework) + .build_vendor_files() + .items() ) entries.extend( (root / 'web' / name, self._render(f'web/{name.removeprefix("src/")}', replacements)) @@ -115,7 +130,7 @@ class PluginV2ScaffoldBuilder: test=test, backend_test=test, frontend_test=False, - frontend_version=None, + frontend_framework=None, target_dirs=[str(root)], files=entries, conflicts=[str(root)] if root.exists() else [], diff --git a/ruoyi-fastapi-backend/cli/runtime/plugin/sdk.py b/ruoyi-fastapi-backend/cli/runtime/plugin/sdk.py index 0ee2f43..7a73a0c 100644 --- a/ruoyi-fastapi-backend/cli/runtime/plugin/sdk.py +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/sdk.py @@ -11,11 +11,19 @@ from uuid import uuid4 import yaml +from plugins.core.frontend import ( + PluginFrontendFrameworkResolver, + resolve_frontend_framework_request, + select_frontend_root, + validate_frontend_framework, +) + SDK_FILES = ('pluginBridge.js', 'pluginBridge.d.ts') SDK_METADATA = 'pluginBridge.sdk.json' VENDOR_FILES = (*SDK_FILES, SDK_METADATA) HASH_ALGORITHM = 'sha256-utf8-lf' -SDK_SOURCE = {'project': 'RuoYi-Vue3-FastAPI', 'directory': 'ruoyi-fastapi-frontend/src/utils'} +SDK_PROJECT = 'RuoYi-FastAPI' +SDK_SOURCE = {'project': SDK_PROJECT, 'directory': 'ruoyi-fastapi-frontend/vue3/web/src/utils'} METADATA_FIELDS = {'schemaVersion', 'sdkVersion', 'bridgeVersion', 'capabilities'} SCHEMA_VERSION = 1 MANIFEST_VERSION = 2 @@ -24,14 +32,16 @@ MANIFEST_VERSION = 2 class PluginBridgeSdk: """离线检查和更新独立 bundle 工程中的桥接 SDK 副本。""" - def __init__(self, frontend_root: Path) -> None: + def __init__(self, frontend_root: Path, frontend_framework: str = 'auto') -> None: """ 保存用于读取当前宿主 SDK 的前端目录,不初始化宿主运行环境。 - :param frontend_root: 宿主前端工程根目录 + :param frontend_root: 宿主前端工程根目录或包含框架分类的聚合目录 + :param frontend_framework: SDK 来源宿主的框架标识,auto 表示自动识别 :return: None """ - self.frontend_root = Path(frontend_root) + self.frontend_root = select_frontend_root(frontend_root, frontend_framework) + self.frontend_framework = resolve_frontend_framework_request(self.frontend_root, frontend_framework) def build_vendor_files(self) -> dict[str, str]: """ @@ -48,9 +58,14 @@ class PluginBridgeSdk: contents[name] = self._text(path.read_bytes()) metadata = self._metadata(contents[SDK_METADATA], vendor=False) self._validate_exports(contents, metadata) + self._safe_path(self.frontend_root / 'package.json') + frontend_framework = PluginFrontendFrameworkResolver.resolve(self.frontend_root, self.frontend_framework) metadata.update( hashAlgorithm=HASH_ALGORITHM, - source=dict(SDK_SOURCE), + source={ + 'project': SDK_PROJECT, + 'directory': f'ruoyi-fastapi-frontend/{frontend_framework}/web/src/utils', + }, files={name: self._hash(contents[name]) for name in SDK_FILES}, ) contents[SDK_METADATA] = json.dumps(metadata, ensure_ascii=False, indent=2) + '\n' @@ -234,8 +249,9 @@ class PluginBridgeSdk: raise ValueError('桥接 SDK 的 capabilities 无效') if vendor: hashes = metadata['files'] - if metadata['hashAlgorithm'] != HASH_ALGORITHM or metadata['source'] != SDK_SOURCE: - raise ValueError('桥接 SDK 的摘要算法或来源记录无效') + if metadata['hashAlgorithm'] != HASH_ALGORITHM: + raise ValueError('桥接 SDK 的摘要算法无效') + PluginBridgeSdk._validate_source(metadata['source']) if ( not isinstance(hashes, dict) or set(hashes) != set(SDK_FILES) @@ -246,6 +262,26 @@ class PluginBridgeSdk: raise ValueError('桥接 SDK 文件摘要记录无效') return metadata + @staticmethod + def _validate_source(source: Any) -> None: + """ + 校验来源记录使用当前项目名和规范的框架工程路径。 + + :param source: SDK 副本中的来源记录 + :return: None + """ + if not isinstance(source, dict) or set(source) != {'project', 'directory'} or source['project'] != SDK_PROJECT: + raise ValueError('桥接 SDK 的来源记录无效') + directory = source['directory'] + match = ( + re.fullmatch(r'ruoyi-fastapi-frontend/([^/]+)/web/src/utils', directory) + if isinstance(directory, str) + else None + ) + if match is None: + raise ValueError('桥接 SDK 的来源目录无效') + validate_frontend_framework(match.group(1)) + @staticmethod def _wire_versions(content: str) -> list[str]: """ diff --git a/ruoyi-fastapi-backend/cli/runtime/plugin/service.py b/ruoyi-fastapi-backend/cli/runtime/plugin/service.py index 3e2923f..09a4ffb 100644 --- a/ruoyi-fastapi-backend/cli/runtime/plugin/service.py +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/service.py @@ -253,6 +253,12 @@ class CliPluginRuntimeService: lockfile_template = PluginDependencyLockfileTemplateBuilder.build( discovered_plugin.manifest, offline_dir=offline_dir or None, + frontend_root=runtime_environment.get_frontend_dir(), + frontend_framework=( + runtime_environment.get_frontend_framework() + if discovered_plugin.manifest.dependencies.frontend + else 'auto' + ), ) if resolved_output_path.exists() and not overwrite and not dry_run: return PluginDependencyLockPayloadBuilder.build_exists_payload(plugin_id, resolved_output_path) @@ -463,7 +469,7 @@ class CliPluginRuntimeService: job: bool = True, config: bool = True, test: bool = True, - frontend_version: str = 'auto', + frontend_framework: str = 'auto', dry_run: bool = False, ) -> dict[str, object]: """ @@ -478,7 +484,7 @@ class CliPluginRuntimeService: :param job: 是否创建定时任务示例 :param config: 是否创建配置项示例 :param test: 是否创建测试样例 - :param frontend_version: 前端 Vue 版本,支持 auto、vue2、vue3 + :param frontend_framework: 前端框架标识,auto 表示自动识别;源码模板仅支持 vue2、vue3 :param dry_run: 是否仅预演 :return: 插件创建结果负载 """ @@ -487,6 +493,7 @@ class CliPluginRuntimeService: scaffold = PluginScaffoldBuilder( Path(runtime_environment.get_backend_dir()), frontend_root=Path(runtime_environment.get_frontend_dir()), + frontend_framework_resolver=runtime_environment.get_frontend_framework, ) scaffold_plan = scaffold.build_plan( plugin_id, @@ -498,7 +505,7 @@ class CliPluginRuntimeService: job=job, config=config, test=test, - frontend_version=frontend_version, + frontend_framework=frontend_framework, ) if scaffold_plan['conflicts']: return PluginScaffoldBuilder.build_conflict_payload( diff --git a/ruoyi-fastapi-backend/cli/runtime/plugin/support.py b/ruoyi-fastapi-backend/cli/runtime/plugin/support.py index 67c1b8e..21d435c 100644 --- a/ruoyi-fastapi-backend/cli/runtime/plugin/support.py +++ b/ruoyi-fastapi-backend/cli/runtime/plugin/support.py @@ -8,6 +8,7 @@ from typing import Any import yaml from cli.exit_codes import RUNTIME_ERROR, SUCCESS +from plugins.core.frontend import PluginFrontendFrameworkResolver from plugins.core.utils import validate_plugin_id_value from utils.time_util import TimezoneUtil @@ -223,6 +224,7 @@ class PluginDependencyLockfileTemplate: :param npm_dev: npmDev 依赖锁定项 :param artifact_count: 已从离线制品反填的依赖项数量 :param warnings: 生成过程告警 + :param frontend_framework: 锁定的宿主前端框架,无前端依赖分类时为 None """ plugin_id: str @@ -233,6 +235,7 @@ class PluginDependencyLockfileTemplate: npm_dev: list[dict[str, object]] artifact_count: int = 0 warnings: list[str] = field(default_factory=list) + frontend_framework: str | None = None @property def entry_count(self) -> int: @@ -256,6 +259,7 @@ class PluginDependencyLockfileTemplate: 'python': self.python, 'npm': self.npm, 'npmDev': self.npm_dev, + **({'frontendFramework': self.frontend_framework} if self.frontend_framework else {}), } def to_yaml(self) -> str: @@ -275,16 +279,36 @@ class PluginDependencyLockfileTemplateBuilder: """ @classmethod - def build(cls, manifest: Any, *, offline_dir: Path | str | None = None) -> PluginDependencyLockfileTemplate: + def build( + cls, + manifest: Any, + *, + offline_dir: Path | str | None = None, + frontend_root: Path | str | None = None, + frontend_framework: str = 'auto', + ) -> PluginDependencyLockfileTemplate: """ 根据插件 manifest 构建锁文件模板。 :param manifest: 插件 manifest :param offline_dir: 离线制品根目录 + :param frontend_root: 宿主前端工程根目录,未指定时使用插件运行时目录 + :param frontend_framework: 宿主显式框架选择,auto 时从运行时环境与工程目录解析 :return: 锁文件模板 """ generated_at = TimezoneUtil.format_rfc3339(TimezoneUtil.utc_now()) dependencies = manifest.dependencies + resolved_frontend_framework = None + if dependencies.frontend: + from plugins.core.environment import PLUGIN_RUNTIME_ENVIRONMENT # noqa: PLC0415 + + frontend_root = frontend_root or PLUGIN_RUNTIME_ENVIRONMENT.get_frontend_dir() + resolved_frontend_framework = ( + PLUGIN_RUNTIME_ENVIRONMENT.get_frontend_framework(frontend_root) + if frontend_framework == 'auto' + else PluginFrontendFrameworkResolver.resolve(Path(frontend_root), frontend_framework) + ) + frontend_dependencies = dependencies.resolve_frontend(resolved_frontend_framework or 'vue3') artifact_resolver = PluginDependencyOfflineArtifactResolver(offline_dir) warnings: list[str] = [] artifact_count = 0 @@ -297,7 +321,7 @@ class PluginDependencyLockfileTemplateBuilder: artifact_count += 1 if entry.get('resolvedVersion') else 0 if artifact_warning: warnings.append(artifact_warning) - for requirement in dependencies.npm: + for requirement in frontend_dependencies.npm: entry, artifact_warning = cls._build_npm_entry( requirement, kind='npm', @@ -307,7 +331,7 @@ class PluginDependencyLockfileTemplateBuilder: artifact_count += 1 if entry.get('resolvedVersion') else 0 if artifact_warning: warnings.append(artifact_warning) - for requirement in dependencies.npm_dev: + for requirement in frontend_dependencies.npm_dev: entry, artifact_warning = cls._build_npm_entry( requirement, kind='npmDev', @@ -326,6 +350,7 @@ class PluginDependencyLockfileTemplateBuilder: npm_dev=npm_dev_entries, artifact_count=artifact_count, warnings=warnings, + frontend_framework=resolved_frontend_framework, ) @staticmethod diff --git a/ruoyi-fastapi-backend/config/env.py b/ruoyi-fastapi-backend/config/env.py index e7e5391..3aa490e 100644 --- a/ruoyi-fastapi-backend/config/env.py +++ b/ruoyi-fastapi-backend/config/env.py @@ -22,7 +22,7 @@ class AppSettings(BaseSettings): """ app_env: str = 'dev' - app_name: str = 'RuoYi-FasAPI' + app_name: str = 'RuoYi-FastAPI' app_root_path: str = '/dev-api' app_host: str = '0.0.0.0' app_port: int = 9099 diff --git a/ruoyi-fastapi-backend/docs/cli_usage.md b/ruoyi-fastapi-backend/docs/cli_usage.md index c8bcc96..8cc4136 100644 --- a/ruoyi-fastapi-backend/docs/cli_usage.md +++ b/ruoyi-fastapi-backend/docs/cli_usage.md @@ -256,7 +256,7 @@ ruoyi dev test tests --keyword sanitize --maxfail=1 -q ```bash ruoyi plugin create demo --env=dev --template=full-stack --dry-run -ruoyi plugin create demo --env=dev --template=crud-page --frontend-version=vue2 --dry-run +ruoyi plugin create demo --env=dev --template=crud-page --frontend-framework=vue2 --dry-run ruoyi plugin check demo --env=dev ruoyi plugin check-deps demo --env=dev ruoyi plugin allowlist-example --env=dev --dry-run @@ -271,9 +271,32 @@ ruoyi plugin test demo --env=dev `allowlist-example` 会按需生成插件依赖允许列表示例,默认输出路径为 `config/plugin_dependency_allowlist.yaml`。仓库不再默认携带 `.example` 文件;需要模板时可先 `--dry-run` 查看,也可指定 `--output-path` 写入并按团队实际批准范围调整。 -`plugin create` 默认使用 `--frontend-version=auto`,从目标前端 `package.json` 的 `vue` 依赖自动识别 Vue 2/3;只有无法识别或需要覆盖时才显式传入 `vue2` 或 `vue3`。插件清单通常保持跨版本一致;确实存在 Vue 绑定库等版本专属依赖时,允许各项目的 `plugin.yaml` 分别声明,并使用按前端版本自动选择的测试覆盖两套清单。 +`plugin create` 的 `--frontend-framework` 当前固定可选 `auto`、`vue2`、`vue3`,默认值为 `auto`。显式选择 `vue2` 或 `vue3` 优先确定框架;`auto` 先采用标准目录 `ruoyi-fastapi-frontend//{web,mobile}` 中的框架标识,独立工程再通过 `package.json` 依赖识别框架。标准目录标识不会被底层依赖覆盖;无法识别的独立工程会报错。未指定目标时,目录选择默认定位 `vue3/web`。显式传入 `--frontend-framework=vue2` 会选择 `vue2/web` 并生成 Vue2 模板,传入 `vue3` 则选择 Vue3 工程,脚手架结果使用 `frontendFramework` 记录框架标识。 -`lock-deps` 会根据 `plugin.yaml` 中声明的 Python/npm/npmDev 外部依赖生成 `plugin.lock.yaml` 模板。默认模式不联网解析版本,也不生成 hash/integrity;如传入 `--offline-dir`,命令会从已有本地 wheel/tgz 反填 `resolvedVersion`、Python `hashes` 和 npm/npmDev `integrity`。该命令仍不会下载、安装或访问 registry,无法反填的项需要由人工审核或 CI 发布流水线补齐后,才能用于 `locked` 或 `offline` 策略。 +当前 v1 源码前端模板仅提供 Vue2、Vue3 实现,`auto` 解析到其他框架时,生成源码页面会明确报错,提示模板尚未支持。v2 独立模板的 `--frontend-framework` 只允许默认值 `auto`。CLI 选项的固定取值不改变底层通用框架结构。 + +插件依赖检查、安装和测试使用当前选定的 Web 宿主。可通过 `RUOYI_PLUGIN_FRONTEND_FRAMEWORK=vue2` 切换默认框架;未设置该变量时也可使用 `RUOYI_FRONTEND_FRAMEWORK`。`RUOYI_PLUGIN_FRONTEND_ROOT`/`RUOYI_FRONTEND_ROOT` 可直接指定 Web 工程,或指定前端聚合目录后按框架定位,旧版平铺目录仍受支持。此前按版本命名的选择变量和命令选项不再提供别名,已有脚本需同步更新。 + +各框架工程共用同一个后端插件清单。前端依赖分别完整声明到 `dependencies.frontend.` 的 `npm/npmDev` 中,分类键接受 `vue2`、`vue3`、`react` 等安全的小写框架标识;检查和安装只选择当前宿主的分类,不跨分类合并。未声明目标分类时,该宿主没有 npm 依赖;旧顶层 `dependencies.npm/npmDev` 会被清单校验拒绝,需迁移到对应分类。各工程分别安装前端依赖,后端插件只需安装、启用一次。依赖分类可扩展不代表对应框架的工程或源码模板已实现。 + +```powershell +# Windows PowerShell:检查 Vue2 Web 的 AI 插件依赖 +$env:RUOYI_PLUGIN_FRONTEND_FRAMEWORK = 'vue2' +ruoyi plugin check-deps ai --env=dev +ruoyi plugin install-deps ai --env=dev --dry-run +``` + +```bash +# macOS / Linux +RUOYI_PLUGIN_FRONTEND_FRAMEWORK=vue2 ruoyi plugin check-deps ai --env=dev +RUOYI_PLUGIN_FRONTEND_FRAMEWORK=vue2 ruoyi plugin install-deps ai --env=dev --dry-run +``` + +`lock-deps` 会根据 `plugin.yaml` 的 Python 依赖和当前框架分类的前端依赖生成 `plugin.lock.yaml` 模板。锁文件产物保持顶层 `python/npm/npmDev` 列表,与插件清单的分类结构不同。默认模式不联网解析版本,也不生成 hash/integrity;如传入 `--offline-dir`,命令会从已有本地 wheel/tgz 反填 `resolvedVersion`、Python `hashes` 和 npm/npmDev `integrity`。该命令仍不会下载、安装或访问 registry,无法反填的项需要由人工审核或 CI 发布流水线补齐后,才能用于 `locked` 或 `offline` 策略。 + +清单声明 `dependencies.frontend` 时,锁模板记录当前宿主的 `frontendFramework`,只锁定该框架分类的前端依赖。请为各框架分别保存锁文件,例如切换到 Vue2、Vue3 后,使用 `--output-path plugins/ai/plugin.vue2.lock.yaml`、`--output-path plugins/ai/plugin.vue3.lock.yaml` 保存,再在 `install-deps` 中通过 `--lockfile` 传入对应文件;锁文件框架与宿主不一致时会被阻断。 + +包含旧框架字段 `frontendVersion` 的锁文件会被明确拒绝,不做静默迁移,应按目标框架重新生成并审核。既没有 `frontendFramework` 也没有旧框架字段的旧锁文件,保持原有依赖项校验方式;这不代表插件清单仍接受旧顶层 npm 字段。清单中的 `compatibility.frontendVersion` 仍是前端工程的语义版本约束,保留原名,与框架标识 `frontendFramework` 不同。 `install-deps` 是真实 Python/npm 依赖安装的唯一显式入口。文本 TTY 下可省略 `--yes`,CLI 会先输出依赖安装计划和策略判定,再询问是否执行;非 TTY、JSON 输出或 CI 场景应传 `--yes`,否则会由策略返回确认阻断。 @@ -307,7 +330,7 @@ ruoyi plugin sdk update plugin-projects/report_center --dry-run --output=json ruoyi plugin sdk update plugin-projects/report_center ``` -这些命令不接受 `--env`,也不读取宿主数据库配置或执行插件代码。来源默认为相邻前端目录,可用 `--frontend-root PATH` 或 `RUOYI_PLUGIN_FRONTEND_ROOT` 指定。`check` 仅在版本、来源记录及文件摘要与选定宿主一致(`current`)时返回 0;过期副本、本地修改或缺少记录返回非零,适合纳入构建检查。`protocolCompatible` 只比较桥协议,不表示业务功能已经验收。 +这些命令不接受 `--env`,也不读取宿主数据库配置或执行插件代码。SDK 命令的 `--frontend-framework` 当前固定可选 `auto`、`vue2`、`vue3`,默认值为 `auto`。来源默认是 `ruoyi-fastapi-frontend/vue3/web`;可用 `--frontend-framework=vue2` 选择 Vue2 Web,也可用 `--frontend-root PATH` 或 `RUOYI_PLUGIN_FRONTEND_ROOT` 指定工程。`auto` 跟随框架环境变量和显式根目录选择,所选 `ruoyi-fastapi-frontend//web` 工程必须已提供对应 bridge SDK 文件。`check` 仅在版本、来源记录及文件摘要与选定宿主一致(`current`)时返回 0;过期副本、本地修改或缺少记录返回非零,适合纳入构建检查。`protocolCompatible` 只比较桥协议,不表示业务功能已经验收。 `update` 是显式写入命令,先备份再替换三个 SDK 文件。默认拒绝覆盖本地 SDK 修改或旧工程的未跟踪副本;需要纳管时先审阅 `update --force --dry-run`,再执行 `update --force`。备份位于工程 `.plugin-sdk-backups/`,业务代码保持原样。更新后应重建 bundle 并执行兼容测试;不会下载依赖、签名发布或改变运行中的插件。完整状态与摘要规则见开发手册第 16.1 节。 diff --git a/ruoyi-fastapi-backend/docs/plugin_development.md b/ruoyi-fastapi-backend/docs/plugin_development.md index d64722d..d53e2ee 100644 --- a/ruoyi-fastapi-backend/docs/plugin_development.md +++ b/ruoyi-fastapi-backend/docs/plugin_development.md @@ -23,16 +23,33 @@ ```text ruoyi-fastapi-backend/plugins// -ruoyi-fastapi-frontend/plugins// +ruoyi-fastapi-frontend//web/plugins// ``` +`` 是前端框架标识。当前仓库提供 `vue2`、`vue3` 工程,各框架的源码页面分别维护,共用一份后端代码与 `plugin.yaml`。移动端位于 `ruoyi-fastapi-frontend//mobile`,不作为 Web 插件的宿主构建目录。目录和依赖分类可以扩展到 `react` 等框架标识,但当前没有 React 工程。 + 开发示例统一位于后端 `plugins/examples/`,按实现语言分类:`python/` 保存 Python 示例,`rust/` 保存 Rust 原生插件工程。 该目录不参与插件发现和启动代际计算。使用示例时,按各自 README 构建后部署到 `plugins//`,或通过签名制品发布流程安装。 -运行时不会把前后端仓库名写死在各操作入口中。插件系统优先使用显式传入的目录,其次读取 -`RUOYI_PLUGIN_BACKEND_ROOT`/`RUOYI_BACKEND_ROOT` 和 -`RUOYI_PLUGIN_FRONTEND_ROOT`/`RUOYI_FRONTEND_ROOT`,再尝试从后端同级目录中识别前端工程; -最后才按后端目录名把 `backend` 推断为 `frontend`。非默认目录名的项目,应优先配置上述环境变量或在运行时注入目录。 +插件系统优先使用显式传入的目录,其次读取 `RUOYI_PLUGIN_BACKEND_ROOT`/`RUOYI_BACKEND_ROOT` 和 `RUOYI_PLUGIN_FRONTEND_ROOT`/`RUOYI_FRONTEND_ROOT`。默认框架为 `vue3`,对应 `ruoyi-fastapi-frontend/vue3/web`;设置 `RUOYI_PLUGIN_FRONTEND_FRAMEWORK=vue2` 可选择 `vue2/web`,未设置该变量时也可使用 `RUOYI_FRONTEND_FRAMEWORK`。前端根目录变量可以指向具体 Web 工程,也可以指向 `ruoyi-fastapi-frontend` 聚合目录,由框架标识继续定位。旧版包含 `package.json` 的平铺前端目录仍受支持。非默认目录名的项目应优先配置根目录变量。 + +脚手架和 SDK 命令的 `--frontend-framework` 当前固定可选 `auto`、`vue2`、`vue3`,默认值为 `auto`。框架身份按以下顺序确定:显式选择 `vue2` 或 `vue3` 优先;`auto` 模式先读取标准目录 `ruoyi-fastapi-frontend//{web,mobile}` 中的框架标识;独立工程再根据 `package.json` 依赖识别框架。标准目录标识不会被底层依赖覆盖。独立工程无法识别时会报错,不会退回 Vue3;未指定目标的目录选择仍默认定位 `vue3/web`。 + +CLI 选项的固定取值不改变底层通用框架目录与依赖分类结构。此前按版本命名的选择变量和命令选项不再提供别名,已有脚本需同步更新。 + +以下命令切换当前终端的宿主选择,后续依赖检查、安装及插件测试都针对该 Web 工程;不改变 HTTP 业务接口或插件生命周期状态。 + +```powershell +# Windows PowerShell +$env:RUOYI_PLUGIN_FRONTEND_FRAMEWORK = 'vue2' +``` + +```bash +# macOS / Linux +export RUOYI_PLUGIN_FRONTEND_FRAMEWORK=vue2 +``` + +切回 Vue3 时将变量值设为 `vue3`。每个 Web 工程需要单独安装前端依赖和构建,但后端插件只安装、启用一次。 后端插件必须包含 `plugin.yaml`。插件发现、安装、菜单、依赖、配置、迁移、种子数据和定时任务都以这个文件为入口。 @@ -54,14 +71,14 @@ cd ruoyi-fastapi-backend ruoyi plugin create demo --env=dev --template=full-stack ``` -脚手架默认使用 `--frontend-version=auto`,会读取目标前端 `package.json` 的 `vue` 依赖,并自动生成 Vue 2(Element UI、Options API、CommonJS 测试)或 Vue 3(Element Plus、Composition API、ESM 测试)模板。通常无需传参;识别失败或需要覆盖时可显式指定: +脚手架的 `--frontend-framework` 当前固定可选 `auto`、`vue2`、`vue3`,默认以 `auto` 识别目标框架。当前 v1 源码前端模板只提供 Vue 2(Element UI、Options API、CommonJS 测试)和 Vue 3(Element Plus、Composition API、ESM 测试)实现;`auto` 解析到其他框架时,生成源码页面会明确报错,提示该框架模板尚未支持。在聚合目录下,显式选择 `vue2` 或 `vue3` 还会选择对应的 `vue2/web` 或 `vue3/web` 输出目录: ```bash -ruoyi plugin create demo --env=dev --template=crud-page --frontend-version=vue2 -ruoyi plugin create demo --env=dev --template=crud-page --frontend-version=vue3 +ruoyi plugin create demo --env=dev --template=crud-page --frontend-framework=vue2 +ruoyi plugin create demo --env=dev --template=crud-page --frontend-framework=vue3 ``` -后端实现应在 Vue 2/3 项目间保持一致。`plugin.yaml` 通常只声明两个前端都使用的业务依赖;如果插件确实依赖不同的 Vue 绑定库或构建插件,允许各项目保留不同清单,但应分别提供 Vue 2/3 测试,并根据目标前端 `package.json` 自动选择执行。 +脚手架结果中的 `frontendFramework` 记录所选前端框架。各框架工程共用后端实现和 `plugin.yaml`,前端依赖必须分别完整写入 `dependencies.frontend.` 的 `npm/npmDev`;多个框架都使用的包也需在各自分类中声明。检查和安装时根据目标 Web 工程选择对应分类,不跨分类合并。当前 Vue2、Vue3 页面分别使用 `vue2`、`vue3` 分类,并各自测试。字段规则见[依赖声明](#56-dependencies)。 常用模板: @@ -222,9 +239,15 @@ permissions: dependencies: python: - requests>=2.32.0 - npm: - - dayjs>=1.11.0 - npmDev: [] + frontend: + vue2: + npm: + - dayjs>=1.11.0 + npmDev: [] + vue3: + npm: + - dayjs>=1.11.0 + npmDev: [] plugins: - id: ai version: ">=0.1.0" @@ -418,10 +441,36 @@ permissions: | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | | `python` | `string[]` | `[]` | Python 依赖声明,例如 `requests>=2.32.0`。 | -| `npm` | `string[]` | `[]` | 前端运行依赖声明,例如 `dayjs>=1.11.0`。 | -| `npmDev` | `string[]` | `[]` | 前端开发依赖声明。 | +| `frontend` | `object` | `{}` | 以安全的小写框架标识为键,完整声明各框架的前端依赖,如 `vue2`、`vue3`、`react`。 | +| `frontend..npm` | `string[]` | `[]` | 对应框架的全部前端运行依赖,例如 `dayjs>=1.11.0`。 | +| `frontend..npmDev` | `string[]` | `[]` | 对应框架的全部前端开发依赖。 | | `plugins` | `object[]` | `[]` | 插件间依赖声明。 | +同一个后端插件的前端依赖按框架分类声明,以下是当前 Vue2、Vue3 实现的示例: + +```yaml +dependencies: + frontend: + vue2: + npm: + - dayjs>=1.11.0 + - markstream-vue2==0.0.50 + npmDev: [] + vue3: + npm: + - dayjs>=1.11.0 + - markstream-vue==1.0.9-beta.2 + - stream-diffs==0.0.2 + npmDev: + - vite-plugin-monaco-editor-esm==2.0.2 +``` + +`frontend` 的键不限于 `vue2`、`vue3`,也接受 `react` 等安全的小写框架标识。新增分类只声明该框架的依赖,不会自动生成对应前端工程或源码模板。 + +插件系统解析选定 Web 工程的框架,仅使用对应分类内的 `npm/npmDev`,不会读取其他框架的依赖。未声明目标框架分类时,该宿主没有 npm 依赖;未声明 `frontend` 时,插件没有宿主前端依赖。多个框架都需要的包必须在各自分类中分别列出,不设公共依赖层。 + +旧顶层 `dependencies.npm`、`dependencies.npmDev` 已移除,清单校验会拒绝,不会自动迁移或合并。升级已有插件时,请将原前端依赖完整迁入对应框架分类;同时支持多个框架的插件需分别维护完整列表。这里的框架标识与 `manifestVersion: 1/2` 无关。 + `dependencies.plugins[]` 支持对象写法: | 字段 | 类型 | 默认值 | 说明 | @@ -444,13 +493,15 @@ dependencies: | --- | --- | --- | --- | | `backendVersion` | `string \| null` | `null` | 后端版本约束。 | | `hostApiVersion` | `string` | `^1.0.0` | 仅 v2:宿主插件 SDK 版本约束,独立于应用版本;当前 Host API 为 `1.4.0`。bundle 至少使用 `^1.2.0`;请求 DTO、显式事务、字典及缓存服务使用 `^1.3.0`;按需配置读取使用 `^1.4.0`。 | -| `frontendVersion` | `string \| null` | `null` | 前端版本约束。 | +| `frontendVersion` | `string \| null` | `null` | 前端工程的语义版本约束,保留此字段名;不是框架标识。 | | `pythonVersion` | `string \| null` | `null` | Python 版本约束。 | | `nodeVersion` | `string \| null` | `null` | Node.js 版本约束。 | | `databases` | `("mysql" \| "postgresql")[]` | `[]` | 插件支持的数据库类型声明,不能重复。 | 版本约束可以是版本号,也可以带比较操作符,例如 `>=3.10`、`^20.0.0`。 +`compatibility.frontendVersion` 约束前端工程的发行版本,与脚手架结果、依赖记录和锁文件中的 `frontendFramework` 不同;后者取 `vue2`、`vue3` 等框架标识,不能填入版本约束。 + ### 5.8 resources | 字段 | 类型 | 默认值 | 说明 | @@ -599,7 +650,7 @@ v2 新增或改变的字段如下,其余迁移、菜单、配置及插件依 `entrypoint`、Hook、健康检查都使用插件自身模块的完整路径;任务使用 `.`。ASGI 插件不能同时声明 `hooks.onStartup/onShutdown`,启动和关闭统一写入 lifespan。资源路径使用 `/` 分隔的相对路径,不允许绝对路径、反斜杠、`.` 或 `..` 目录。 -bundle 不因存在菜单而变为源码前端,`buildRequired` 必须为 `false`,构建依赖由插件自己的 `package.json` 管理,不写入清单的 `dependencies.npm/npmDev`。清单校验和静态预检不会导入插件代码;通过预检仍需进行实际加载、权限与生命周期测试。 +bundle 不因存在菜单而变为源码前端,`buildRequired` 必须为 `false`,构建依赖由插件自己的 `package.json` 管理,不写入清单的 `dependencies.frontend`。清单校验和静态预检不会导入插件代码;通过预检仍需进行实际加载、权限与生命周期测试。 ## 6. 后端开发约定 @@ -913,14 +964,14 @@ Rust 通过 `PluginHostContext`、`plugin_endpoint`、`plugin_lifespan` 和版 插件前端代码放在: ```text -ruoyi-fastapi-frontend/plugins// +ruoyi-fastapi-frontend//web/plugins// ``` -菜单组件路径和真实 Vue 文件的映射关系: +当前 Vue2、Vue3 实现中,菜单组件路径和真实 Vue 文件的映射关系如下,`` 分别替换为 `vue2` 或 `vue3`: ```text -plugin/demo/index -> ruoyi-fastapi-frontend/plugins/demo/views/index.vue -plugin/demo/report/list -> ruoyi-fastapi-frontend/plugins/demo/views/report/list.vue +plugin/demo/index -> ruoyi-fastapi-frontend//web/plugins/demo/views/index.vue +plugin/demo/report/list -> ruoyi-fastapi-frontend//web/plugins/demo/views/report/list.vue ``` 只允许两类组件值: @@ -946,9 +997,9 @@ UI GET/HEAD 使用普通 HTTPS,仍经过插件身份门禁;`/apps//api/. HTML 注入有效 `uiBase`、`apiBase` 与 ``。SPA 回退仅接受 UI GET/HEAD 中带 `Accept: text/html` 的无扩展名页面导航;缺失脚本、API、保留路径和非 HTML 请求不能返回首页。静态服务拒绝路径越界和链接逃逸,并限制为同源 iframe 嵌入。HTML 当前 `no-store`,其他静态资源 `no-cache`,不自动启用 immutable 缓存。 -前端各环境已显式配置 `VITE_APP_PLUGIN_BASE`:development 为 `/dev-api`、production 为 `/prod-api`、docker 为 `/docker-api`、staging 为 `/stage-api`,与各环境的 `VITE_APP_BASE_API` 一致。开发、生产、Docker 分别对齐后端 `.env.dev`、`.env.prod`、`.env.dockermy`/`.env.dockerpg` 的 `APP_ROOT_PATH`;仓库未提供独立的后端 staging 环境文件,部署 staging 时需将后端 `APP_ROOT_PATH` 配为 `/stage-api`。以开发环境为例,插件使用同源 `/dev-api/apps/` 和 `/dev-api/plugin/runtime/`,Cookie Path 和会话响应也包含此前缀。 +Vue3 Web 各环境已显式配置 `VITE_APP_PLUGIN_BASE`,Vue2 Web 使用 `VUE_APP_PLUGIN_BASE`:development 为 `/dev-api`、production 为 `/prod-api`、docker 为 `/docker-api`、staging 为 `/stage-api`,分别与各环境的 `VITE_APP_BASE_API`、`VUE_APP_BASE_API` 一致。开发、生产、Docker 分别对齐后端 `.env.dev`、`.env.prod`、`.env.dockermy`/`.env.dockerpg` 的 `APP_ROOT_PATH`;仓库未提供独立的后端 staging 环境文件,部署 staging 时需将后端 `APP_ROOT_PATH` 配为 `/stage-api`。以开发环境为例,插件使用同源 `/dev-api/apps/` 和 `/dev-api/plugin/runtime/`,Cookie Path 和会话响应也包含此前缀。 -自定义部署时,宿主前端的 `VITE_APP_PLUGIN_BASE` 必须与后端 `APP_ROOT_PATH` 一致;只有后端前缀为空时,前端才留空并使用 `/apps/` 和 `/plugin/runtime/`。浏览器侧保留部署前缀,代理转发至通过 `app.py` 启动的 Uvicorn 时剥离此前缀一次,由 Uvicorn 补回 ASGI `root_path`;保留外部 Host,HTTPS 的原始 scheme 由可信代理配置传递。仓库 Vite 已按此规则改写插件路径;两份 Docker Nginx 配置也已提供 `/docker-api/apps/` 和 `/docker-api/plugin/runtime/` 专用代理,并在转发时剥离 `/docker-api`。生产、staging 或自定义反向代理须配套设置相同前缀、专用代理入口及转发路径,保留 WebSocket 升级和 SSE 非缓冲配置,并验证 Cookie Path、Origin、Secure 和加密 AAD。独立页面属于同源可信代码;iframe 提供布局和依赖隔离,不提供对恶意插件的安全隔离。 +自定义部署时,宿主前端的 `VITE_APP_PLUGIN_BASE`(Vue3)或 `VUE_APP_PLUGIN_BASE`(Vue2)必须与后端 `APP_ROOT_PATH` 一致;只有后端前缀为空时,前端才留空并使用 `/apps/` 和 `/plugin/runtime/`。浏览器侧保留部署前缀,代理转发至通过 `app.py` 启动的 Uvicorn 时剥离此前缀一次,由 Uvicorn 补回 ASGI `root_path`;保留外部 Host,HTTPS 的原始 scheme 由可信代理配置传递。两个 Web 工程的开发代理已按此规则改写插件路径;各自的 Docker Nginx 配置也已提供 `/docker-api/apps/` 和 `/docker-api/plugin/runtime/` 专用代理,并在转发时剥离 `/docker-api`。生产、staging 或自定义反向代理须配套设置相同前缀、专用代理入口及转发路径,保留 WebSocket 升级和 SSE 非缓冲配置,并验证 Cookie Path、Origin、Secure 和加密 AAD。独立页面属于同源可信代码;iframe 提供布局和依赖隔离,不提供对恶意插件的安全隔离。 ### 7.3 bundle 浏览器 SDK @@ -1100,10 +1151,14 @@ async def summary(context: PluginRequestContext) -> dict[str, str]: 插件依赖声明在 `dependencies` 中: - `python`:Python 包。 -- `npm`:前端运行依赖。 -- `npmDev`:前端开发依赖。 +- `frontend..npm`:对应框架 Web 工程的全部前端运行依赖。 +- `frontend..npmDev`:对应框架 Web 工程的全部前端开发依赖。 - `plugins`:插件间依赖。 +未声明目标框架分类时,该宿主没有 npm 依赖。清单不再接受顶层 `dependencies.npm/npmDev`;迁移时需将依赖完整移入目标分类,同时支持多个框架的包需分别列出。 + +依赖检查、安装和锁文件生成均针对当前选定的 Web 宿主。默认是 `vue3/web`;验证 Vue2 时,按[基本模型](#1-基本模型)设置 `RUOYI_PLUGIN_FRONTEND_FRAMEWORK=vue2`,或通过根目录变量选择具体工程,再运行相同命令。各框架工程不共享 `node_modules`。 + 检查和安装: ```bash @@ -1120,6 +1175,20 @@ ruoyi plugin install-deps demo --env=dev --yes `lock-deps` 默认生成锁文件模板,输出到 `plugins//plugin.lock.yaml`;如文件已存在,需要传 `--overwrite` 才会覆盖。默认模式不会联网解析真实版本,也不会写入 hash/integrity;如果传入 `--offline-dir`,命令会从已有本地 wheel/tgz 反填 `resolvedVersion`、Python `hashes` 和 npm/npmDev `integrity`。它仍不会下载、安装或访问 registry,未能反填的项发布前应由人工审核或 CI 流水线补齐。 +清单声明 `dependencies.frontend` 时,锁模板只包含当前宿主分类的前端依赖,并记录 `frontendFramework`;安装策略会拒绝框架标识与宿主不一致的锁文件。锁文件产物仍使用顶层 `npm/npmDev` 列表,检查报告的依赖类型也仍为 `npm/npmDev`,它们与 `plugin.yaml` 清单格式不同。 + +包含旧框架字段 `frontendVersion` 的锁文件会被明确拒绝,不做静默迁移,应按目标框架重新生成并审核锁文件。既没有 `frontendFramework` 也没有旧框架字段的旧锁文件,仍沿用原有依赖项校验。这里的旧锁字段与仍受支持的 `compatibility.frontendVersion` 语义版本约束不同。 + +建议用 `--output-path` 分别维护各框架的锁文件,安装时用 `--lockfile` 选择。例如先切换到 Vue2,再执行: + +```bash +ruoyi plugin lock-deps ai --env=dev --output-path plugins/ai/plugin.vue2.lock.yaml +# 发布流程补齐并审核锁文件中的 resolvedVersion、hash/integrity 后再安装 +ruoyi plugin install-deps ai --env=dev --lockfile plugins/ai/plugin.vue2.lock.yaml --dry-run +``` + +Vue3 使用相同流程,将宿主框架和文件名改为 `vue3`。各框架分别维护宿主依赖锁,不要通过覆盖同一文件共享锁内容。 + 如果当前终端是交互式 TTY,且输出格式为 text,也可以不传 `--yes`: ```bash @@ -1290,7 +1359,8 @@ ruoyi plugin test demo --env=dev 全栈插件还应执行前端构建检查: ```bash -cd ../ruoyi-fastapi-frontend +cd ../ruoyi-fastapi-frontend/vue3/web +# Vue2 工程改为:cd ../ruoyi-fastapi-frontend/vue2/web npm run build:prod ``` @@ -1554,7 +1624,7 @@ ruoyi plugin create report_center --template rust-bundle --env dev --dry-run ruoyi plugin create report_center --template rust-bundle --env dev ``` -每个工程包含 v2 清单、受 `:view` 权限保护的 `/api/info`、显式入口、健康检查、lifespan、构建与发布 README,以及 SDK 合约测试。Rust 模板固定 `ruoyi_plugin_` Python 命名空间,包含锁定的 Cargo 依赖。bundle 复制当前宿主的 bridge SDK 及配套 `.d.ts`,使用相对资源基址和专用插件会话,不内嵌主登录 token。它与宿主 Vue 版本独立,v2 模板不接受 `--frontend-version`;bundle 也不能配合 `--backend-only`。 +每个工程包含 v2 清单、受 `:view` 权限保护的 `/api/info`、显式入口、健康检查、lifespan、构建与发布 README,以及 SDK 合约测试。Rust 模板固定 `ruoyi_plugin_` Python 命名空间,包含锁定的 Cargo 依赖。bundle 复制当前宿主的 bridge SDK 及配套 `.d.ts`,使用相对资源基址和专用插件会话,不内嵌主登录 token。独立 bundle 与宿主前端框架解耦,v2 模板的 `--frontend-framework` 只允许默认值 `auto`;bundle 也不能配合 `--backend-only`。 bundle 安装 npm 依赖后,可运行 `npm --prefix plugin-projects/report_center/web run dev`,自动打开生成的 `/dev.html`。`dev.js` 提供 `/api/info` 的内存模拟、主题、延迟、故障、重载和退出,可按业务扩展;默认模板未实现文件或 SSE 业务接口。模拟配置仅在开发服务注入,生产构建不包含开发宿主。文件与 SSE 的完整示例见 `bundle_demo`。 @@ -1605,7 +1675,7 @@ ruoyi plugin sdk update plugin-projects/report_center npm --prefix plugin-projects/report_center/web run build ``` -默认来源为宿主相邻前端目录;独立检出时可传 `--frontend-root PATH`,或设置 `RUOYI_PLUGIN_FRONTEND_ROOT`。命令只静态读取工程清单和固定 SDK 文件,不导入插件、不读取宿主运行环境、不连接数据库或 registry。清单检查仅确认 YAML 可读、`manifestVersion: 2` 和 bundle 工程类型,不执行完整插件清单校验;完整检查与业务测试仍按第 13 节执行。 +SDK 命令的 `--frontend-framework` 当前固定可选 `auto`、`vue2`、`vue3`,默认值为 `auto`。默认来源为 `ruoyi-fastapi-frontend/vue3/web`;可用 `--frontend-framework=vue2` 选择 Vue2 Web,`auto` 则跟随框架环境变量和显式根目录选择。通用来源目录为 `ruoyi-fastapi-frontend//web`,所选工程必须提供对应 bridge SDK 文件;指定框架不会生成这些文件。独立检出时可传 `--frontend-root PATH`,或设置 `RUOYI_PLUGIN_FRONTEND_ROOT`。命令只静态读取工程清单和固定 SDK 文件,不导入插件、不读取宿主运行环境、不连接数据库或 registry。清单检查仅确认 YAML 可读、`manifestVersion: 2` 和 bundle 工程类型,不执行完整插件清单校验;完整检查与业务测试仍按第 13 节执行。 | `check` 状态 | 含义与处理 | | --- | --- | @@ -1665,7 +1735,8 @@ python -m pytest tests/plugins/core/artifacts tests/plugins/core/deployment test bundle 插件完成独立前端构建后,还应执行宿主桥接测试(在后端目录执行,三种平台通用): ```bash -npm --prefix ../ruoyi-fastapi-frontend run test:plugin +npm --prefix ../ruoyi-fastapi-frontend/vue2/web run test:plugin +npm --prefix ../ruoyi-fastapi-frontend/vue3/web run test:plugin ``` ### 17.2 真实服务与多 worker 验收 @@ -1695,27 +1766,27 @@ npm --prefix ../ruoyi-fastapi-frontend run test:plugin ### 17.4 CI 覆盖 -仓库的原生插件工作流配置 Windows/Linux、Python 3.10、3.11、3.12、3.13 构建测试矩阵,覆盖 v1/v2 脚手架、SDK 溯源与离线更新命令、原生扩展以及签名制品与维护发布回归。 +统一入口为 [`ci.yml`](../../.github/workflows/ci.yml),完整分类和本地命令见[测试说明](../../ruoyi-fastapi-test/README.md)。[`ci-backend.yml`](../../.github/workflows/ci-backend.yml) 承担 v1/v2 脚手架、SDK 溯源、离线更新及 Windows CLI/路径回归;[`ci-native.yml`](../../.github/workflows/ci-native.yml) 保留 Windows/Linux × Python 3.10–3.13 的真实原生制品测试,Rust fmt/clippy 单独运行一次。 -发布集成工作流在 Python 3.10、3.11、3.12、3.13 上使用临时 MySQL、PostgreSQL 和 Redis 服务验证多 worker 发布行为。工作流配置不代表已经执行成功;发布前应核对对应提交的实际 CI 结果,并完成目标平台的部署验收。 +[`ci-integration.yml`](../../.github/workflows/ci-integration.yml) 使用临时 MySQL、PostgreSQL 和 Redis 服务验证多 worker 发布行为,PR 使用 Python 3.12,完整运行覆盖 Python 3.10–3.13。工作流配置不代表已经执行成功;发布前应核对对应提交的实际 CI 结果,并完成目标平台的部署验收。 `scripts/plugin_release_integration.py` 还检查双 worker 首次发布、升级和代码回滚后的固定目标收敛,无 worker 时单次断言不收敛,旧目标代际被拒绝、单 worker 故障立即失败,以及报告过期、缺员和遗留陈旧记录下的等待结果。足够的新 worker 全部就绪后,遗留陈旧记录不应造成永久等待;失败结果保留退出码和固定原因供流水线判断。脚本还针对两种数据库启动独立的完整 CLI 进程,验证标准输出可整体解析为一个 JSON 对象,并核对未就绪时退出 `50`、就绪后退出 `0`。 该工作流还实际构建 `task_demo` 的 bundle,并通过 `scripts/plugin_task_integration.py` 验证 Python 受控目录打包、签名、导入、维护准备、目标选择、旧结构数据保留以及当前业务 CRUD/读写分权。旧版本是仅承载 001 迁移的测试夹具;当前版本从真实签名制品加载。业务 HTTP 验收使用已认证上下文和 ASGI 适配器,不代替浏览器登录验收。运行真实发布集成脚本前须先按任务示例 README 构建 `web/dist`;本地 SQLite 交付回归使用最小 HTML 夹具,单独验证维护链路。 -[`plugin-frontend.yml`](../../.github/workflows/plugin-frontend.yml) 在 Node.js 22 上执行 `npm run test:plugin`,相关源码、测试、SDK、构建配置及依赖变更会触发检查。仓库当前不提交 npm 锁文件,因此使用 `npm install --package-lock=false`,依赖解析仍遵循 `package.json` 的版本范围。 +[`ci-frontend.yml`](../../.github/workflows/ci-frontend.yml) 在 Node.js 22 上按框架和 Web/Mobile 工程执行 `test:unit`、三时区 `test:contract` 及各目标构建;Web 单元入口包含插件、认证和任务测试,覆盖 `vue2`、`vue3`。当前两个 Web 工程不提交 npm 锁文件,因此使用 `npm install --package-lock=false`,依赖解析仍遵循各自 `package.json` 的版本范围。 -[`plugin-network-smoke.yml`](../../.github/workflows/plugin-network-smoke.yml) 在 Linux、Python 3.12、Node.js 22 与 Chromium 上执行真实网络 smoke。链路为宿主 Vue `PluginFrame` → iframe SDK → 临时 HTTPS Vite 代理 → Uvicorn → 生产插件会话与门禁,使用 `/gateway` 部署前缀。覆盖未登录拒绝、限定路径的 Secure/HttpOnly/SameSite Cookie、JSON POST、CSRF/Origin 拒绝、SSE 增量接收与取消后游标恢复、SSE/WebSocket 在权限及主会话撤销后的关闭,以及 runtime drain 后连接和 lifespan 回收。工作流保留截图、Playwright trace、代理日志与 JUnit 结果。 +`ci-integration.yml` 的 network 任务在 Linux、Python 3.12、Node.js 22 与 Chromium 上分别执行 Vue2/Vue3 真实网络 smoke。链路为宿主 `PluginFrame` → iframe SDK → 临时 HTTPS 代理 → Uvicorn → 生产插件会话与门禁,使用 `/gateway` 部署前缀。覆盖未登录拒绝、限定路径的 Secure/HttpOnly/SameSite Cookie、JSON POST、CSRF/Origin 拒绝、SSE 增量接收与取消后游标恢复、SSE/WebSocket 在权限及主会话撤销后的关闭,以及 runtime drain 后连接和 lifespan 回收。工作流保留截图、Playwright trace、代理日志与 JUnit 结果。 本地复现(在后端目录执行,三个平台通用): ```bash python -m pip install pytest pytest-asyncio aiosqlite playwright python -m playwright install chromium -npm --prefix ../ruoyi-fastapi-frontend install --no-audit --no-fund --package-lock=false +npm --prefix ../ruoyi-fastapi-frontend/vue3/web install --no-audit --no-fund --package-lock=false python -c "import os,pytest; os.environ['RUOYI_PLUGIN_NETWORK_SMOKE']='1'; raise SystemExit(pytest.main(['tests/plugins/integration/test_network_smoke.py','-q']))" ``` -常规 pytest 不设置 `RUOYI_PLUGIN_NETWORK_SMOKE=1` 时跳过此项,不要求安装浏览器。显式启用后,缺少浏览器或 Node 依赖会直接失败。可通过 `RUOYI_SMOKE_BROWSER_EXECUTABLE` 指定已有 Chromium 浏览器,通过 `RUOYI_SMOKE_OUTPUT` 指定诊断目录;默认输出在后端 `target/plugin-network-smoke/`。测试仅绑定 loopback 随机端口,临时证书不加入系统信任库,结束时回收浏览器和服务。 +常规 Backend 分类使用 marker 排除此项;单独运行但不设置 `RUOYI_PLUGIN_NETWORK_SMOKE=1` 时跳过,不要求安装浏览器。显式启用后,缺少浏览器或 Node 依赖会直接失败。设置 `RUOYI_PLUGIN_FRONTEND_FRAMEWORK=vue2` 可验证 Vue2(先安装对应 Web 依赖并设置 `NODE_OPTIONS=--openssl-legacy-provider`),默认是 Vue3。可通过 `RUOYI_SMOKE_BROWSER_EXECUTABLE` 指定已有 Chromium 浏览器,通过 `RUOYI_SMOKE_OUTPUT` 指定诊断目录;默认输出在后端 `target/plugin-network-smoke//`。测试仅绑定 loopback 随机端口,临时证书不加入系统信任库,结束时回收浏览器和服务。 该 smoke 的账号查询、数据库会话、插件启用状态和 Redis 使用隔离夹具,传输加密为 off;它验证真实网络上的生产会话协议,不覆盖真实账号登录、Redis 故障、required 加密、生产证书信任或进程信号触发的停机。runtime drain 之后才让 Uvicorn 正常退出,SIGTERM、完整宿主 lifespan 及目标部署代理仍按 17.2 节另行验收。 diff --git a/ruoyi-fastapi-backend/docs/unified_authentication_developer_guide.md b/ruoyi-fastapi-backend/docs/unified_authentication_developer_guide.md index ff0b9ae..d6d1cc2 100644 --- a/ruoyi-fastapi-backend/docs/unified_authentication_developer_guide.md +++ b/ruoyi-fastapi-backend/docs/unified_authentication_developer_guide.md @@ -1,6 +1,6 @@ # 统一认证中心开发者使用手册 -更新日期:2026-10-01。适用于当前 Vue3、Vue2 两个仓库的统一认证中心;两者使用相同的后端协议和接入配置。 +更新日期:2026-10-10。适用于本仓库 `ruoyi-fastapi-frontend/vue3/web`、`ruoyi-fastapi-frontend/vue2/web` 两个 Web 工程的统一认证中心;两者共用 `ruoyi-fastapi-backend`,使用相同的后端协议和接入配置。 本文面向平台管理员、业务应用开发者和资源服务开发者,按“启用服务 → 注册应用 → 登录 → 调用 API → 续期和退出”的顺序说明接入方法。本文的域名、客户端编号和凭据均为占位示例,使用时替换为实际值。 diff --git a/ruoyi-fastapi-backend/module_admin/service/server_service.py b/ruoyi-fastapi-backend/module_admin/service/server_service.py index 8c70add..bb9f585 100644 --- a/ruoyi-fastapi-backend/module_admin/service/server_service.py +++ b/ruoyi-fastapi-backend/module_admin/service/server_service.py @@ -80,25 +80,64 @@ class ServerService: ) # 磁盘信息 - io = psutil.disk_partitions() - sys_files = [] - for i in io: - try: - o = psutil.disk_usage(i.device) - disk_data = SysFiles( - dirName=i.device, - sysTypeName=i.fstype, - typeName='本地固定磁盘(' + i.mountpoint.replace('\\', '') + ')', - total=bytes2human(o.total), - used=bytes2human(o.used), - free=bytes2human(o.free), - usage=f'{psutil.disk_usage(i.device).percent}%', - ) - sys_files.append(disk_data) - except Exception: # noqa: PERF203 - # 忽略所有异常,跳过有问题的磁盘 - continue + sys_files = ServerService._get_disk_info() result = ServerMonitorModel(cpu=cpu, mem=mem, sys=sys, py=py, sysFiles=sys_files) return result + + @classmethod + def _get_disk_info(cls) -> list[SysFiles]: + """ + 获取可访问的磁盘信息 + + :return: 磁盘信息列表 + """ + sys_files = [] + for partition in psutil.disk_partitions(): + disk_info = cls._read_disk_info(partition.device, partition.mountpoint, partition.fstype) + if disk_info is not None: + sys_files.append(disk_info) + if sys_files: + return sys_files + + # 默认分区列表会过滤overlay等容器文件系统,无可读分区时补充根文件系统 + root_path = os.path.abspath(os.sep) + # 仅匹配根目录,避免展示proc、sysfs等虚拟挂载 + root_partition = next( + (partition for partition in psutil.disk_partitions(all=True) if partition.mountpoint == root_path), + None, + ) + disk_info = cls._read_disk_info( + root_partition.device if root_partition else root_path, + root_path, + root_partition.fstype if root_partition else '', + ) + + return [disk_info] if disk_info is not None else [] + + @staticmethod + def _read_disk_info(device: str, mountpoint: str, filesystem: str) -> SysFiles | None: + """ + 读取指定磁盘的容量信息 + + :param device: 磁盘设备名称 + :param mountpoint: 磁盘挂载路径 + :param filesystem: 文件系统类型 + :return: 磁盘容量信息,挂载路径不可访问时返回None + """ + try: + disk_usage = psutil.disk_usage(mountpoint) + except OSError: + # 跳过不存在或无权访问的挂载路径 + return None + + return SysFiles( + dirName=device, + sysTypeName=filesystem, + typeName='本地固定磁盘(' + mountpoint.replace('\\', '') + ')', + total=bytes2human(disk_usage.total), + used=bytes2human(disk_usage.used), + free=bytes2human(disk_usage.free), + usage=f'{disk_usage.percent}%', + ) diff --git a/ruoyi-fastapi-backend/module_identity/service/audit_service.py b/ruoyi-fastapi-backend/module_identity/service/audit_service.py index ddb8e7c..8a742c8 100644 --- a/ruoyi-fastapi-backend/module_identity/service/audit_service.py +++ b/ruoyi-fastapi-backend/module_identity/service/audit_service.py @@ -9,7 +9,7 @@ from exceptions.exception import OidcInteractionException from module_identity.dao.oauth_audit_dao import OAuthAuditDao from module_identity.entity.do.oauth_audit_do import SysOAuthAuditLog from module_identity.entity.vo.oauth_session_vo import AuditModel, AuditPageQueryModel -from utils.common_util import export_list2excel +from utils.excel_util import ExcelUtil from utils.oidc_util import OidcUtil @@ -270,8 +270,9 @@ class AuditService: """ rows = await OAuthAuditDao.list_admin_page(db, offset=0, limit=5000, **cls._admin_filters(query)) + mapping = {field.alias or name: field.alias or name for name, field in AuditModel.model_fields.items()} - return export_list2excel([cls._admin_model(row) for row in rows]) + return ExcelUtil.export_list2excel([cls._admin_model(row) for row in rows], mapping) @classmethod async def record_interaction_failure(cls, db: AsyncSession, event_type: str, **fields: Any) -> None: diff --git a/ruoyi-fastapi-backend/plugins/ai/README.md b/ruoyi-fastapi-backend/plugins/ai/README.md index 8535dfc..aca5767 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 对话」两个页面。 +AI 管理插件提供模型配置、供应商字典、会话对话和流式输出能力。插件保留原有接口前缀和权限标识,安装后会在系统菜单中生成「AI 管理」目录,以及「模型管理」「AI 对话」两个页面。各前端框架共用此后端插件及同一份 `plugin.yaml`,页面目录约定为 `ruoyi-fastapi-frontend//web/plugins/ai`;当前提供 Vue2、Vue3 页面,分别位于 `vue2/web/plugins/ai` 和 `vue3/web/plugins/ai`。 ## 功能 @@ -93,7 +93,17 @@ ruoyi plugin disable ai --env=dev --yes - `ollama` - 以及其他供应商 SDK -插件启动期会检查已启用插件的 Python 依赖。缺失依赖时,CLI 启动流程会提示是否安装依赖;生产环境建议在发布阶段提前安装,避免运行期临时拉取依赖。 +应用启动时只做默认启用插件的依赖门禁,不会自动安装或提示安装依赖。缺失依赖时,应在启动前显式执行 `ruoyi plugin install-deps ai --env=dev`;生产环境应在发布阶段提前准备依赖。 + +前端依赖按宿主框架完整声明,当前 AI 插件提供以下分类: + +- `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` 等安全的小写框架标识,但新增分类不会生成对应页面;当前 AI 插件仅提供 Vue2、Vue3 实现。旧顶层 `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-依赖管理)。 ## 数据库 diff --git a/ruoyi-fastapi-backend/plugins/ai/plugin.yaml b/ruoyi-fastapi-backend/plugins/ai/plugin.yaml index 7001000..9355bdc 100644 --- a/ruoyi-fastapi-backend/plugins/ai/plugin.yaml +++ b/ruoyi-fastapi-backend/plugins/ai/plugin.yaml @@ -86,18 +86,31 @@ dependencies: - ollama==0.6.2 - openai==2.52.0 - portkey-ai==2.3.4 - npm: - - '@antv/infographic^0.2.13' - - '@terrastruct/d2==0.1.33' - - katex>=0.16.27 - - markstream-vue==1.0.9-beta.2 - - mermaid>=11.15.0 - - shiki^3.21.0 - - stream-diffs==0.0.2 - - stream-markdown==0.0.16 - - stream-monaco==0.0.48 - npmDev: - - vite-plugin-monaco-editor-esm==2.0.2 + frontend: + vue2: + npm: + - '@antv/infographic^0.2.13' + - '@terrastruct/d2==0.1.33' + - katex>=0.16.27 + - markstream-vue2==0.0.50 + - mermaid>=11.15.0 + - shiki^3.21.0 + - stream-markdown==0.0.16 + - stream-monaco==0.0.48 + npmDev: [] + vue3: + npm: + - '@antv/infographic^0.2.13' + - '@terrastruct/d2==0.1.33' + - katex>=0.16.27 + - markstream-vue==1.0.9-beta.2 + - mermaid>=11.15.0 + - shiki^3.21.0 + - stream-diffs==0.0.2 + - stream-markdown==0.0.16 + - stream-monaco==0.0.48 + npmDev: + - vite-plugin-monaco-editor-esm==2.0.2 plugins: [] compatibility: diff --git a/ruoyi-fastapi-backend/plugins/core/capability.py b/ruoyi-fastapi-backend/plugins/core/capability.py index f460867..81e4b99 100644 --- a/ruoyi-fastapi-backend/plugins/core/capability.py +++ b/ruoyi-fastapi-backend/plugins/core/capability.py @@ -116,8 +116,7 @@ class PluginRuntimeCapabilityResolver: manifest = discovered_plugin.manifest has_frontend_resources = bool( manifest.frontend.menus - or manifest.dependencies.npm - or manifest.dependencies.npm_dev + or manifest.dependencies.has_frontend_dependencies or manifest.frontend.delivery.type == 'bundle' ) frontend_build_required = manifest.frontend.delivery.type != 'bundle' and ( diff --git a/ruoyi-fastapi-backend/plugins/core/environment.py b/ruoyi-fastapi-backend/plugins/core/environment.py index 016d710..6db39f3 100644 --- a/ruoyi-fastapi-backend/plugins/core/environment.py +++ b/ruoyi-fastapi-backend/plugins/core/environment.py @@ -3,19 +3,23 @@ from pathlib import Path from typing import Literal from config.env import AppConfig +from plugins.core.frontend import ( + PluginFrontendFrameworkResolver, + resolve_frontend_framework_request, + resolve_frontend_root, +) PluginFrontendMode = Literal['dev', 'built'] PluginBackendRuntimeMode = Literal['dev', 'service', 'maintenance'] BACKEND_ROOT_ENV_NAMES = ('RUOYI_PLUGIN_BACKEND_ROOT', 'RUOYI_BACKEND_ROOT') -FRONTEND_ROOT_ENV_NAMES = ('RUOYI_PLUGIN_FRONTEND_ROOT', 'RUOYI_FRONTEND_ROOT') class PluginRuntimeEnvironmentService: """ 插件运行时环境服务。 - 为插件运行时提供后端目录和 Python 可执行文件等路径信息。 + 为插件运行时提供前后端工程目录、Python 可执行文件和运行模式。 """ def __init__( @@ -23,17 +27,20 @@ class PluginRuntimeEnvironmentService: backend_root: Path | str | None = None, frontend_root: Path | str | None = None, python_executable: str | None = None, + frontend_framework: str = 'auto', ) -> None: """ 初始化插件运行时环境服务。 :param backend_root: 后端项目根目录 - :param frontend_root: 前端项目根目录 + :param frontend_root: 前端项目根目录或统一前端目录 :param python_executable: Python 可执行文件路径 + :param frontend_framework: 前端框架标识,auto 使用环境变量,未配置时默认 vue3 :return: None """ self.backend_root = self._resolve_backend_root(backend_root) - self.frontend_root = self._resolve_frontend_root(self.backend_root, frontend_root) + self.frontend_root = resolve_frontend_root(self.backend_root, frontend_root, frontend_framework) + self.frontend_framework = resolve_frontend_framework_request(self.frontend_root, frontend_framework) self.python_executable = python_executable or 'python' self.frontend_mode = self._get_frontend_mode() self.backend_runtime_mode = self._get_backend_runtime_mode() @@ -53,27 +60,6 @@ class PluginRuntimeEnvironmentService: return configured_backend_root.resolve() return Path(__file__).resolve().parents[2] - @staticmethod - def _resolve_frontend_root(backend_root: Path, frontend_root: Path | str | None) -> Path: - """ - 解析前端项目根目录。 - - 解析顺序为:显式参数、环境变量、后端同级目录中的前端工程、按后端目录名推断。 - - :param backend_root: 后端项目根目录 - :param frontend_root: 显式传入的前端项目根目录 - :return: 前端项目根目录 - """ - if frontend_root: - return Path(frontend_root).resolve() - configured_frontend_root = PluginRuntimeEnvironmentService._first_env_path(FRONTEND_ROOT_ENV_NAMES) - if configured_frontend_root: - return configured_frontend_root.resolve() - sibling_frontend_root = PluginRuntimeEnvironmentService._find_sibling_frontend_root(backend_root) - if sibling_frontend_root: - return sibling_frontend_root.resolve() - return backend_root.parent / PluginRuntimeEnvironmentService._infer_frontend_dir_name(backend_root.name) - @staticmethod def _first_env_path(env_names: tuple[str, ...]) -> Path | None: """ @@ -88,41 +74,6 @@ class PluginRuntimeEnvironmentService: return Path(value) return None - @staticmethod - def _find_sibling_frontend_root(backend_root: Path) -> Path | None: - """ - 从后端同级目录中寻找前端工程。 - - :param backend_root: 后端项目根目录 - :return: 前端项目根目录,未找到时返回 None - """ - parent = backend_root.parent - if not parent.is_dir(): - return None - candidates = [ - path - for path in parent.iterdir() - if path.is_dir() - and path != backend_root - and (path / 'package.json').is_file() - and (path / 'plugins').is_dir() - ] - if not candidates: - return None - return sorted(candidates)[0] - - @staticmethod - def _infer_frontend_dir_name(backend_dir_name: str) -> str: - """ - 根据后端目录名推断前端目录名。 - - :param backend_dir_name: 后端目录名 - :return: 推断出的前端目录名 - """ - if 'backend' in backend_dir_name: - return backend_dir_name.replace('backend', 'frontend', 1) - return 'frontend' - @staticmethod def _get_frontend_mode() -> PluginFrontendMode: """ @@ -169,6 +120,20 @@ class PluginRuntimeEnvironmentService: """ return str(self.frontend_root) + def get_frontend_framework(self, frontend_root: Path | str | None = None) -> str: + """ + 获取指定前端工程的框架标识,保留当前工程的显式选择。 + + 其他工程按自身目录和依赖解析,不沿用当前工程的框架标识。 + 仅在需要前端依赖时解析,避免后端独立运行时要求存在前端文件。 + + :param frontend_root: 前端工程根目录,未指定时使用当前工程 + :return: 前端框架标识 + """ + root = Path(os.path.abspath(frontend_root)) if frontend_root is not None else self.frontend_root + framework = self.frontend_framework if root == self.frontend_root else 'auto' + return PluginFrontendFrameworkResolver.resolve(root, framework) + def get_frontend_plugins_dir(self) -> str: """ 获取前端插件根目录。 diff --git a/ruoyi-fastapi-backend/plugins/core/frontend.py b/ruoyi-fastapi-backend/plugins/core/frontend.py new file mode 100644 index 0000000..ce6efab --- /dev/null +++ b/ruoyi-fastapi-backend/plugins/core/frontend.py @@ -0,0 +1,214 @@ +import json +import os +import re +from pathlib import Path + +FrontendFramework = str + +FRONTEND_ROOT_ENV_NAMES = ('RUOYI_PLUGIN_FRONTEND_ROOT', 'RUOYI_FRONTEND_ROOT') +FRONTEND_FRAMEWORK_ENV_NAMES = ('RUOYI_PLUGIN_FRONTEND_FRAMEWORK', 'RUOYI_FRONTEND_FRAMEWORK') +FRONTEND_FRAMEWORK_PATTERN = re.compile(r'^[a-z][a-z0-9_-]*$') + + +def validate_frontend_framework(value: str) -> str: + """ + 校验可用于依赖分类和工程目录的前端框架标识。 + + auto 仅用于请求自动解析,不能作为实际框架名称。 + + :param value: 待校验的前端框架标识 + :return: 校验通过的前端框架标识 + """ + if not isinstance(value, str) or value == 'auto' or FRONTEND_FRAMEWORK_PATTERN.fullmatch(value) is None: + raise ValueError('frontend_framework 必须以小写字母开头,且仅包含小写字母、数字、下划线或连字符,不能为 auto') + return value + + +def resolve_frontend_framework(frontend_framework: str = 'auto') -> FrontendFramework: + """ + 解析前端工程使用的框架标识。 + + 显式框架优先;auto 模式读取前端框架环境变量,未配置时默认使用 vue3。 + + :param frontend_framework: 前端框架标识,auto 表示自动选择 + :return: 解析后的前端框架标识 + """ + framework = (frontend_framework or 'auto').strip().lower() + if framework == 'auto': + framework = next( + (os.environ[name].strip().lower() for name in FRONTEND_FRAMEWORK_ENV_NAMES if os.getenv(name, '').strip()), + 'vue3', + ) + return validate_frontend_framework(framework) + + +def resolve_frontend_framework_request(frontend_root: Path | str, requested_framework: str = 'auto') -> str: + """ + 保存前端工程的显式框架选择,供后续依赖、模板与 SDK 按需解析。 + + 标准工程的 auto 保留目录框架身份;独立工程的 auto 使用已配置的框架 + 环境变量,无配置时继续保留 auto,交由包依赖识别。 + + :param frontend_root: 已选中的前端工程目录 + :param requested_framework: 请求的框架标识,auto 表示自动识别 + :return: 已规范化并校验的框架请求,可以是 auto + """ + root = Path(frontend_root) + framework = (requested_framework or 'auto').strip().lower() + standard_frontend_root = root.name in {'web', 'mobile'} and root.parent.parent.name == 'ruoyi-fastapi-frontend' + if framework == 'auto' and not standard_frontend_root: + framework = next( + (os.environ[name].strip().lower() for name in FRONTEND_FRAMEWORK_ENV_NAMES if os.getenv(name, '').strip()), + 'auto', + ) + return framework if framework == 'auto' else validate_frontend_framework(framework) + + +def select_frontend_root(frontend_root: Path | str, frontend_framework: str = 'auto') -> Path: + """ + 从统一前端目录中选择 Web 工程,保留显式指定的独立工程目录。 + + :param frontend_root: 前端工程目录、框架目录或统一前端目录 + :param frontend_framework: 前端框架标识,auto 表示自动选择 + :return: 所选前端工程的绝对路径 + """ + # 仅转换为绝对路径,保留符号链接信息供 SDK 后续执行路径安全检查。 + root = Path(os.path.abspath(frontend_root)) + framework = resolve_frontend_framework(frontend_framework) + automatic = (frontend_framework or 'auto').strip().lower() == 'auto' + if root.name == 'web' and root.parent.parent.name == 'ruoyi-fastapi-frontend': + validate_frontend_framework(root.parent.name) + # 显式框架选择同级工程,auto 模式保留当前指定的工程。 + return root if automatic else root.parent.parent / framework / 'web' + if (root / 'package.json').is_file(): + return root + if root.parent.name == 'ruoyi-fastapi-frontend': + validate_frontend_framework(root.name) + return root / 'web' if automatic else root.parent / framework / 'web' + if root.name == 'ruoyi-fastapi-frontend' or ( + root.is_dir() + and any( + path.name != 'auto' and FRONTEND_FRAMEWORK_PATTERN.fullmatch(path.name) and (path / 'web').is_dir() + for path in root.iterdir() + ) + ): + return root / framework / 'web' + return root + + +def resolve_frontend_root( + backend_root: Path | str, + frontend_root: Path | str | None = None, + frontend_framework: str = 'auto', +) -> Path: + """ + 解析前端工程根目录。 + + 依次使用显式目录、环境变量和统一前端目录,并兼容同级的旧版独立前端工程。 + 未发现现存工程时,返回统一前端目录下所选框架的 Web 工程路径。 + + :param backend_root: 后端项目根目录 + :param frontend_root: 显式指定的前端工程目录或统一前端目录 + :param frontend_framework: 前端框架标识,auto 表示自动选择 + :return: 解析后的前端工程根目录 + """ + backend = Path(backend_root).resolve() + configured = frontend_root or next( + (os.environ[name].strip() for name in FRONTEND_ROOT_ENV_NAMES if os.getenv(name, '').strip()), None + ) + if configured: + return select_frontend_root(configured, frontend_framework) + framework = resolve_frontend_framework(frontend_framework) + container = backend.parent / 'ruoyi-fastapi-frontend' + if container.is_dir(): + return select_frontend_root(container, framework) + # 兼容重命名或单独检出的旧版前端工程。 + if backend.parent.is_dir(): + candidates = sorted( + path + for path in backend.parent.iterdir() + if path.is_dir() and path != backend and (path / 'package.json').is_file() and (path / 'plugins').is_dir() + ) + if candidates: + return candidates[0] + return container / framework / 'web' + + +class PluginFrontendFrameworkResolver: + """ + 前端框架解析器。 + """ + + AUTO = 'auto' + + @classmethod + def resolve(cls, frontend_root: Path, requested_framework: str = AUTO) -> FrontendFramework: + """ + 解析宿主依赖及脚手架应使用的前端框架。 + + 显式框架优先;标准工程使用目录中的框架标识,独立工程从 package.json + 识别底层框架。无法识别的独立工程要求显式指定。 + + :param frontend_root: 前端项目根目录 + :param requested_framework: 前端框架标识,auto 表示自动识别 + :return: 解析后的前端框架标识 + """ + normalized_framework = (requested_framework or cls.AUTO).strip().lower() + if normalized_framework != cls.AUTO: + return validate_frontend_framework(normalized_framework) + + # 目录标识决定标准工程的依赖分类,避免被底层 Vue 或 React 包覆盖。 + if frontend_root.name in {'web', 'mobile'} and frontend_root.parent.parent.name == 'ruoyi-fastapi-frontend': + return validate_frontend_framework(frontend_root.parent.name) + + package_json_path = frontend_root / 'package.json' + if package_json_path.is_file(): + try: + package_payload = json.loads(package_json_path.read_text(encoding='utf-8')) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError(f'读取前端 package.json 失败:{package_json_path}({exc})') from exc + if not isinstance(package_payload, dict): + raise ValueError(f'前端 package.json 顶层必须是对象:{package_json_path}') + + dependencies = cls._collect_dependencies(package_payload) + vue_version = cls._resolve_vue_dependency(dependencies.get('vue')) + if vue_version is not None: + return vue_version + if 'element-ui' in dependencies: + return 'vue2' + if 'element-plus' in dependencies: + return 'vue3' + if 'react' in dependencies: + return 'react' + + raise ValueError(f'无法从 {package_json_path} 识别前端框架,请使用 --frontend-framework 显式指定') + + @staticmethod + def _collect_dependencies(package_payload: dict[str, object]) -> dict[str, object]: + """ + 合并 dependencies 和 devDependencies。 + + :param package_payload: package.json 负载 + :return: 依赖映射 + """ + dependencies: dict[str, object] = {} + for key in ('devDependencies', 'dependencies'): + section = package_payload.get(key) + if isinstance(section, dict): + dependencies.update(section) + return dependencies + + @staticmethod + def _resolve_vue_dependency(version_spec: object) -> FrontendFramework | None: + """ + 从 npm Vue 版本约束中提取主版本。 + + :param version_spec: Vue npm 版本约束 + :return: Vue 版本,无法识别时返回 None + """ + if not isinstance(version_spec, str): + return None + match = re.search(r'(? dict[str, FrontendDependencyManifest]: + """ + 校验前端依赖分类使用安全的小写框架标识。 + + :param value: 按宿主前端框架分类的依赖声明 + :return: 校验后的前端依赖分类 + """ + for framework in value: + validate_frontend_framework(framework) + return value + + @property + def has_frontend_dependencies(self) -> bool: + """ + 判断是否为任一前端框架声明宿主前端依赖。 + + :return: 是否存在前端依赖声明 + """ + return any(item.npm or item.npm_dev for item in self.frontend.values()) + + def resolve_frontend(self, frontend_framework: str) -> FrontendDependencyManifest: + """ + 获取指定前端框架的前端依赖声明副本。 + + 返回独立副本,避免调用方修改原始清单;未声明的分类返回空依赖声明。 + + :param frontend_framework: 宿主前端框架标识,例如 vue2、vue3、react + :return: 指定前端框架的前端依赖声明副本 + """ + profile = self.frontend.get(validate_frontend_framework(frontend_framework)) + return profile.model_copy(deep=True) if profile is not None else FrontendDependencyManifest() + @field_validator('python') @classmethod def validate_python_requirements(cls, value: list[str]) -> list[str]: @@ -1111,7 +1162,7 @@ class PluginManifest(BaseModel): :return: None """ - has_frontend_resources = bool(self.frontend.menus or self.dependencies.npm or self.dependencies.npm_dev) + has_frontend_resources = bool(self.frontend.menus or self.dependencies.has_frontend_dependencies) if has_frontend_resources and self.frontend.delivery.type == 'none': self.frontend.delivery.type = 'source' if self.frontend.delivery.type == 'source': diff --git a/ruoyi-fastapi-backend/plugins/core/manifest/v2.py b/ruoyi-fastapi-backend/plugins/core/manifest/v2.py index f290b7f..1bcde75 100644 --- a/ruoyi-fastapi-backend/plugins/core/manifest/v2.py +++ b/ruoyi-fastapi-backend/plugins/core/manifest/v2.py @@ -231,7 +231,7 @@ class PluginManifestV2(PluginManifest): raise ValueError('bundle 交付必须声明 frontend.bundle') if self.frontend.delivery.build_required: raise ValueError('bundle 不参与宿主构建,buildRequired 必须为 false') - if self.dependencies.npm or self.dependencies.npm_dev: + if self.dependencies.has_frontend_dependencies: raise ValueError('bundle 的 npm 构建依赖由插件独立管理,不能安装到宿主') def _validate_menu_components(self) -> None: diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/service/dependencies.py b/ruoyi-fastapi-backend/plugins/core/runtime/service/dependencies.py index d3210ec..643cbba 100644 --- a/ruoyi-fastapi-backend/plugins/core/runtime/service/dependencies.py +++ b/ruoyi-fastapi-backend/plugins/core/runtime/service/dependencies.py @@ -241,7 +241,9 @@ class PluginDependencyUseCase: frontend_root=Path(self.dependencies.runtime_environment.get_frontend_dir()) ).build_plan(dependency_result) resolved_policy_config = policy_config or DependencyInstallPolicyConfig.from_environment() - policy_decision = DependencyInstallPolicyEvaluator(resolved_policy_config).evaluate( + policy_decision = DependencyInstallPolicyEvaluator( + resolved_policy_config, runtime_environment=self.dependencies.runtime_environment + ).evaluate( install_plan, confirmed=confirmed, ) @@ -345,7 +347,9 @@ class PluginDependencyUseCase: frontend_root=Path(self.dependencies.runtime_environment.get_frontend_dir()) ).build_plan(dependency_result) resolved_policy_config = policy_config or DependencyInstallPolicyConfig.from_environment() - policy_decision = DependencyInstallPolicyEvaluator(resolved_policy_config).evaluate( + policy_decision = DependencyInstallPolicyEvaluator( + resolved_policy_config, runtime_environment=self.dependencies.runtime_environment + ).evaluate( install_plan, confirmed=confirmed, ) diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/service/facade.py b/ruoyi-fastapi-backend/plugins/core/runtime/service/facade.py index 50ce7e5..625883a 100644 --- a/ruoyi-fastapi-backend/plugins/core/runtime/service/facade.py +++ b/ruoyi-fastapi-backend/plugins/core/runtime/service/facade.py @@ -88,6 +88,7 @@ class PluginRuntimeService: resolved_dependency_checker = dependency_checker or PluginDependencyChecker( npm_inspector=NpmDependencyInspector(frontend_root=resolved_environment.get_frontend_dir()), frontend_mode=resolved_environment.get_frontend_mode(), + runtime_environment=resolved_environment, ) gateway_overrides = gateways or PluginRuntimeGatewayOverrides() self._replace_dependencies( @@ -159,6 +160,7 @@ class PluginRuntimeService: frontend_root=self.dependencies.runtime_environment.get_frontend_dir(), ), frontend_mode=self.dependencies.runtime_environment.get_frontend_mode(), + runtime_environment=self.dependencies.runtime_environment, ) ) diff --git a/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/documentation.py b/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/documentation.py index 95d1d43..e1356ef 100644 --- a/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/documentation.py +++ b/ruoyi-fastapi-backend/plugins/core/runtime/support/payload/documentation.py @@ -174,28 +174,26 @@ class PluginDocumentationBuilder: @staticmethod def _build_dependency_section(discovered_plugin: DiscoveredPlugin) -> list[str]: """ - 构建依赖文档段落。 + 构建依赖文档段落,按前端框架分别展示依赖分类。 :param discovered_plugin: 已发现插件 :return: Markdown 行列表 """ dependencies = discovered_plugin.manifest.dependencies lines = ['## 依赖', ''] - if not dependencies.python and not dependencies.npm and not dependencies.npm_dev and not dependencies.plugins: + if not dependencies.python and not dependencies.has_frontend_dependencies and not dependencies.plugins: return [*lines, '无依赖声明。', ''] lines.append('### Python') lines.extend(f'- `{item}`' for item in dependencies.python) if not dependencies.python: lines.append('- 无') - lines.extend(['', '### NPM']) - lines.extend(f'- `{item}`' for item in dependencies.npm) - if not dependencies.npm: - lines.append('- 无') - lines.extend(['', '### NPM 开发依赖']) - lines.extend(f'- `{item}`' for item in dependencies.npm_dev) - if not dependencies.npm_dev: - lines.append('- 无') + for framework, profile in dependencies.frontend.items(): + lines.extend(['', f'### {framework} 宿主依赖']) + lines.extend(f'- npm: `{item}`' for item in profile.npm) + lines.extend(f'- npmDev: `{item}`' for item in profile.npm_dev) + if not profile.npm and not profile.npm_dev: + lines.append('- 无') lines.extend(['', '### 插件']) lines.extend( f'- `{item.id}` {item.version or ""} {item.description or ""}'.rstrip() for item in dependencies.plugins diff --git a/ruoyi-fastapi-backend/plugins/core/validation/dependencies.py b/ruoyi-fastapi-backend/plugins/core/validation/dependencies.py index c770fed..11fd9e3 100644 --- a/ruoyi-fastapi-backend/plugins/core/validation/dependencies.py +++ b/ruoyi-fastapi-backend/plugins/core/validation/dependencies.py @@ -8,7 +8,7 @@ from typing import Literal from packaging.requirements import InvalidRequirement -from plugins.core.environment import PLUGIN_RUNTIME_ENVIRONMENT +from plugins.core.environment import PLUGIN_RUNTIME_ENVIRONMENT, PluginRuntimeEnvironmentService from plugins.core.manifest.schema import PluginManifest from plugins.core.validation.python_requirements import PythonRequirementParser from plugins.core.validation.versioning import PluginVersionComparator, PluginVersionConstraintMatcher @@ -534,32 +534,47 @@ class PluginDependencyChecker: python_inspector: PythonDependencyInspector | None = None, npm_inspector: NpmDependencyInspector | None = None, frontend_mode: Literal['dev', 'built'] = 'dev', + runtime_environment: PluginRuntimeEnvironmentService | None = None, ) -> None: """ 初始化插件依赖检查器。 :param python_inspector: Python 依赖检查器 - :param npm_inspector: npm 依赖检查器 + :param npm_inspector: 当前宿主前端的 npm 依赖检查器 + :param frontend_mode: 前端运行模式,已构建模式跳过 npm 依赖实际检查 + :param runtime_environment: 保留当前宿主显式框架选择的运行时环境 + :return: None """ + self.runtime_environment = runtime_environment or PLUGIN_RUNTIME_ENVIRONMENT self.python_inspector = python_inspector or PythonDependencyInspector() - self.npm_inspector = npm_inspector or NpmDependencyInspector() + self.npm_inspector = npm_inspector or NpmDependencyInspector( + frontend_root=self.runtime_environment.get_frontend_dir() + ) self.frontend_mode = frontend_mode def check_manifest(self, manifest: PluginManifest) -> DependencyCheckResult: """ 检查插件清单依赖。 + 根据当前宿主前端框架选择 frontend 中对应分类,只检查该分类声明的前端依赖。 + :param manifest: 插件清单 :return: 插件依赖检查结果 """ items = [] items.extend(self.python_inspector.check(manifest.dependencies.python)) + frontend_framework = ( + self.runtime_environment.get_frontend_framework(self.npm_inspector.frontend_root) + if manifest.dependencies.frontend + else 'vue3' + ) + frontend_dependencies = manifest.dependencies.resolve_frontend(frontend_framework) if self.frontend_mode == 'built': - items.extend(self.npm_inspector.skip(manifest.dependencies.npm)) - items.extend(self.npm_inspector.skip(manifest.dependencies.npm_dev, dev=True)) + items.extend(self.npm_inspector.skip(frontend_dependencies.npm)) + items.extend(self.npm_inspector.skip(frontend_dependencies.npm_dev, dev=True)) else: - items.extend(self.npm_inspector.check(manifest.dependencies.npm)) - items.extend(self.npm_inspector.check(manifest.dependencies.npm_dev, dev=True)) + items.extend(self.npm_inspector.check(frontend_dependencies.npm)) + items.extend(self.npm_inspector.check(frontend_dependencies.npm_dev, dev=True)) return DependencyCheckResult(plugin_id=manifest.id, items=items) diff --git a/ruoyi-fastapi-backend/plugins/core/validation/dependency_policy.py b/ruoyi-fastapi-backend/plugins/core/validation/dependency_policy.py index b7feb85..abf242f 100644 --- a/ruoyi-fastapi-backend/plugins/core/validation/dependency_policy.py +++ b/ruoyi-fastapi-backend/plugins/core/validation/dependency_policy.py @@ -10,6 +10,8 @@ import yaml from packaging.requirements import InvalidRequirement from pydantic import ValidationError +from plugins.core.environment import PLUGIN_RUNTIME_ENVIRONMENT, PluginRuntimeEnvironmentService +from plugins.core.frontend import validate_frontend_framework from plugins.core.validation.dependencies import ( DependencyInstallPlan, DependencyInstallPlanItem, @@ -465,10 +467,15 @@ class DependencyLockEntry: class DependencyLockfile: """ 插件依赖锁文件。 + + :param path: 锁文件路径 + :param entries: 按依赖类型和包名索引的锁定依赖 + :param frontend_framework: 锁文件对应的宿主前端框架,未声明时不检查框架 """ path: Path entries: dict[tuple[DependencyKind, str], DependencyLockEntry] + frontend_framework: str | None = None @classmethod def load(cls, path: Path | str | None) -> 'DependencyLockfile | None': @@ -477,6 +484,7 @@ class DependencyLockfile: :param path: 锁文件路径 :return: 锁文件对象,缺失时返回 None + :raises ValueError: 锁文件使用旧框架字段或包含无效的前端框架标识 """ if path is None: return None @@ -484,6 +492,15 @@ class DependencyLockfile: if not lockfile_path.is_file(): return None data = yaml.safe_load(lockfile_path.read_text(encoding='utf-8')) or {} + if not isinstance(data, dict): + raise ValueError('依赖锁文件顶层必须是对象') + if 'frontendVersion' in data: + raise ValueError('依赖锁文件 frontendVersion 已移除,请迁移为 frontendFramework 或重新生成锁文件') + frontend_framework = None + if 'frontendFramework' in data: + if not isinstance(data['frontendFramework'], str): + raise ValueError('依赖锁文件 frontendFramework 必须是小写框架标识') + frontend_framework = validate_frontend_framework(data['frontendFramework']) entries: dict[tuple[DependencyKind, str], DependencyLockEntry] = {} for kind in ('python', 'npm', 'npmDev'): for raw_entry in data.get(kind, []) or []: @@ -502,7 +519,7 @@ class DependencyLockfile: ) entries[(kind, normalize_dependency_name(kind, name))] = entry - return cls(path=lockfile_path, entries=entries) + return cls(path=lockfile_path, entries=entries, frontend_framework=frontend_framework) def get_entry(self, item: DependencyInstallPlanItem) -> DependencyLockEntry | None: """ @@ -728,14 +745,21 @@ class DependencyInstallPolicyEvaluator: 插件依赖安装策略判定器。 """ - def __init__(self, config: DependencyInstallPolicyConfig | None = None) -> None: + def __init__( + self, + config: DependencyInstallPolicyConfig | None = None, + *, + runtime_environment: PluginRuntimeEnvironmentService | None = None, + ) -> None: """ 初始化策略判定器。 :param config: 策略配置 + :param runtime_environment: 保留当前宿主显式框架选择的运行时环境 :return: None """ self.config = config or DependencyInstallPolicyConfig.from_environment() + self.runtime_environment = runtime_environment or PLUGIN_RUNTIME_ENVIRONMENT def evaluate( self, install_plan: DependencyInstallPlan, *, confirmed: bool = False @@ -977,16 +1001,16 @@ class DependencyInstallPolicyEvaluator: return True return version_satisfies_range(lock_entry.resolved_version, required_version) - @staticmethod def _build_lockfile_extra_reasons( + self, lockfile: DependencyLockfile | None, install_plan: DependencyInstallPlan, ) -> list[str]: """ - 构建锁文件额外安装项阻断原因。 + 构建锁文件宿主框架及额外依赖的阻断原因。 :param lockfile: 锁文件 - :param install_plan: 安装计划 + :param install_plan: 安装计划,包含前端依赖的目标工作目录 :return: 阻断原因 """ if lockfile is None: @@ -996,6 +1020,18 @@ class DependencyInstallPolicyEvaluator: for item in install_plan.items } reasons = [] + if lockfile.frontend_framework: + frontend_roots = {item.workdir for item in install_plan.items if item.kind in {'npm', 'npmDev'}} + for frontend_root in sorted(frontend_roots): + try: + frontend_framework = self.runtime_environment.get_frontend_framework(Path(frontend_root)) + except ValueError as exc: + reasons.append(str(exc)) + continue + if frontend_framework != lockfile.frontend_framework: + reasons.append( + f'锁文件前端框架不匹配:lock={lockfile.frontend_framework} host={frontend_framework}' + ) for entry in lockfile.entries.values(): key = (entry.kind, normalize_dependency_name(entry.kind, entry.name), entry.requirement) if key not in plan_keys: diff --git a/ruoyi-fastapi-backend/plugins/core/validation/manifest.py b/ruoyi-fastapi-backend/plugins/core/validation/manifest.py index 5aede7d..496310b 100644 --- a/ruoyi-fastapi-backend/plugins/core/validation/manifest.py +++ b/ruoyi-fastapi-backend/plugins/core/validation/manifest.py @@ -185,14 +185,19 @@ class PluginManifestChecker: """ 检查未声明版本约束的依赖。 + 检查 Python 依赖及 frontend 中所有前端框架分类,不依赖当前宿主选择。 + :param manifest: 插件 manifest :return: 未声明版本约束问题项列表 """ issues = [] for dependency_kind, requirements in ( ('python', manifest.dependencies.python), - ('npm', manifest.dependencies.npm), - ('npmDev', manifest.dependencies.npm_dev), + *[ + (f'frontend.{framework}.{kind}', requirements) + for framework, profile in manifest.dependencies.frontend.items() + for kind, requirements in (('npm', profile.npm), ('npmDev', profile.npm_dev)) + ], ): for index, requirement in enumerate(requirements): if dependency_kind == 'python': diff --git a/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/README.md b/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/README.md index 42a1284..89680c6 100644 --- a/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/README.md +++ b/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/README.md @@ -15,12 +15,12 @@ npm --prefix plugins/examples/python/bundle_demo/web install npm --prefix plugins/examples/python/bundle_demo/web run build ``` -首次安装会生成此示例自己的锁文件;独立维护插件时应保留锁文件,在 CI 中使用 `npm ci`。示例的 SDK 别名指向本仓库 `ruoyi-fastapi-frontend/src/utils/pluginBridge.js`。迁移到独立插件仓库时,将该模块及相邻的 `pluginBridge.d.ts` 一起复制为自己的版本化源码依赖,并调整 `vite.config.js` 中 `@ruoyi/plugin-bridge` 的别名。TypeScript 项目还应在自己的 `tsconfig.json` 中将同一别名映射到复制后的模块路径。 +首次安装会生成此示例自己的锁文件;独立维护插件时应保留锁文件,在 CI 中使用 `npm ci`。示例的 SDK 别名指向本仓库 `ruoyi-fastapi-frontend/vue3/web/src/utils/pluginBridge.js`。迁移到独立插件仓库时,将该模块及相邻的 `pluginBridge.d.ts` 一起复制为自己的版本化源码依赖,并调整 `vite.config.js` 中 `@ruoyi/plugin-bridge` 的别名。TypeScript 项目还应在自己的 `tsconfig.json` 中将同一别名映射到复制后的模块路径。 如果本仓库宿主前端依赖已安装,也可复用其中的 Vite 可执行文件构建**同一个插件前端项目**: ```bash -node ../ruoyi-fastapi-frontend/node_modules/vite/bin/vite.js build plugins/examples/python/bundle_demo/web --config plugins/examples/python/bundle_demo/web/vite.config.js +node ../ruoyi-fastapi-frontend/vue3/web/node_modules/vite/bin/vite.js build plugins/examples/python/bundle_demo/web --config plugins/examples/python/bundle_demo/web/vite.config.js ``` 两种方式均输出 `plugins/examples/python/bundle_demo/web/dist`。配置使用 `base: './'`、关闭 sourcemap;宿主交付 HTML 时注入有效 `` 和运行基址,因此资源和页面深层链接不需写死域名或代理前缀。直接用 `file://` 打开 dist 不会建立宿主身份桥;真实账号交互应从管理平台的插件菜单进入。 @@ -233,7 +233,7 @@ ASGI 网关对已建立的 SSE/WebSocket 按 15 秒间隔复核登录、插件 ```bash python -m pytest tests/plugins/core/runtime/test_bundle.py tests/plugins/core/runtime/test_browser_session.py tests/plugins/core/runtime/test_bundle_transport.py tests/plugins/core/runtime/test_bundle_files.py tests/plugins/core/runtime/test_bundle_events.py tests/plugins/core/runtime/test_connections.py tests/plugins/core/runtime/test_connection_sessions.py tests/plugins/core/runtime/test_configuration.py tests/module_admin/service/test_login_plugin_routes.py -q -npm --prefix ../ruoyi-fastapi-frontend run test:plugin +npm --prefix ../ruoyi-fastapi-frontend/vue3/web run test:plugin ``` 测试覆盖静态边界、Cookie/CSRF/退出、真实 RSA/AES 协议、文件大小、`root_path`、流连接复核及运行时关闭顺序;浏览器会话测试替换真实账号查询,配置读取测试使用隔离 SQLite 验证保存、解密及重启版本。实际角色配置、TLS/Nginx、多 worker 和 ASGI 服务器的优雅停机阶段仍需部署验收。网关的周期关闭与协作式取消不能强制终止忽略取消的 Python 代码,也不提供跨 worker 的即时撤销广播。 diff --git a/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/vite.config.js b/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/vite.config.js index 7b87f88..55f3a43 100644 --- a/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/vite.config.js +++ b/ruoyi-fastapi-backend/plugins/examples/python/bundle_demo/web/vite.config.js @@ -27,7 +27,7 @@ export default { alias: { '@ruoyi/plugin-bridge': fileURLToPath( new URL( - '../../../../../../ruoyi-fastapi-frontend/src/utils/pluginBridge.js', + '../../../../../../ruoyi-fastapi-frontend/vue3/web/src/utils/pluginBridge.js', import.meta.url ) ), diff --git a/ruoyi-fastapi-backend/plugins/examples/python/task_demo/web/vite.config.js b/ruoyi-fastapi-backend/plugins/examples/python/task_demo/web/vite.config.js index 32ac176..7e471c2 100644 --- a/ruoyi-fastapi-backend/plugins/examples/python/task_demo/web/vite.config.js +++ b/ruoyi-fastapi-backend/plugins/examples/python/task_demo/web/vite.config.js @@ -27,7 +27,7 @@ export default { alias: { '@ruoyi/plugin-bridge': fileURLToPath( new URL( - '../../../../../../ruoyi-fastapi-frontend/src/utils/pluginBridge.js', + '../../../../../../ruoyi-fastapi-frontend/vue3/web/src/utils/pluginBridge.js', import.meta.url ) ), diff --git a/ruoyi-fastapi-backend/pyproject.toml b/ruoyi-fastapi-backend/pyproject.toml index 19973ac..6fd99e0 100644 --- a/ruoyi-fastapi-backend/pyproject.toml +++ b/ruoyi-fastapi-backend/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "ruoyi-fastapi-backend-cli" version = "1.10.0" -description = "RuoYi FastAPI backend internal CLI entrypoint" +description = "RuoYi-FastAPI backend internal CLI entrypoint" requires-python = ">=3.10" [project.scripts] @@ -13,6 +13,12 @@ ruoyi = "cli.main:main" [tool.pytest.ini_options] pythonpath = ["."] +markers = [ + "browser: requires a real browser and the frontend network test host", + "native: requires a prebuilt native plugin artifact", + "integration: requires explicitly enabled external database or cache services", + "contract: validates public CLI arguments, output, or cold-process behavior", +] [tool.setuptools.packages.find] include = ["cli*"] diff --git a/ruoyi-fastapi-backend/requirements-test.txt b/ruoyi-fastapi-backend/requirements-test.txt new file mode 100644 index 0000000..c78db4b --- /dev/null +++ b/ruoyi-fastapi-backend/requirements-test.txt @@ -0,0 +1,4 @@ +pytest==9.1.1 +pytest-asyncio==1.4.0 +aiosqlite==0.22.1 +PyYAML==6.0.3 diff --git a/ruoyi-fastapi-backend/tests/cli/root/conftest.py b/ruoyi-fastapi-backend/tests/cli/root/conftest.py index 2f322f1..2a0716b 100644 --- a/ruoyi-fastapi-backend/tests/cli/root/conftest.py +++ b/ruoyi-fastapi-backend/tests/cli/root/conftest.py @@ -2,12 +2,14 @@ import json import os import subprocess import sys -from collections.abc import Callable +from collections.abc import Callable, Mapping from pathlib import Path from typing import Any import pytest +CLI_PROCESS_TIMEOUT_SECONDS = 60 + @pytest.fixture def backend_dir() -> Path: @@ -16,13 +18,16 @@ def backend_dir() -> Path: @pytest.fixture def run_cli_command(backend_dir: Path) -> Callable[..., subprocess.CompletedProcess[str]]: - def _run_cli_command(*args: str) -> subprocess.CompletedProcess[str]: + def _run_cli_command(*args: str, env: Mapping[str, str] | None = None) -> subprocess.CompletedProcess[str]: return subprocess.run( - [sys.executable, '-m', 'cli.main', *args], + [sys.executable, '-X', 'utf8', '-m', 'cli.main', *args], cwd=backend_dir, + env=env, capture_output=True, text=True, + encoding='utf-8', check=False, + timeout=CLI_PROCESS_TIMEOUT_SECONDS, ) return _run_cli_command @@ -49,11 +54,13 @@ def run_cli_completion_command( instruction: str = 'bash_complete', ) -> subprocess.CompletedProcess[str]: return subprocess.run( - [sys.executable, '-m', 'cli.main'], + [sys.executable, '-X', 'utf8', '-m', 'cli.main'], cwd=backend_dir, capture_output=True, text=True, + encoding='utf-8', check=False, + timeout=CLI_PROCESS_TIMEOUT_SECONDS, env={ **os.environ, 'COMP_WORDS': comp_words, @@ -65,6 +72,29 @@ def run_cli_completion_command( return _run_cli_completion_command +@pytest.fixture +def untracked_bundle_project(tmp_path: Path) -> Path: + """ + 创建带未跟踪 SDK 的隔离 bundle 工程,供 CLI 检查与更新测试复用。 + + :param tmp_path: pytest 临时目录 + :return: 不包含可执行业务插件的测试工程目录 + """ + root = tmp_path / 'example' + vendor = root / 'web' / 'vendor' + vendor.mkdir(parents=True) + (root / 'plugin.yaml').write_text( + 'manifestVersion: 2\nid: example\nname: Example\nversion: 1.0.0\n' + 'backend:\n module: plugins.example\n entrypoint: plugins.example:create_plugin\n integration: asgi\n' + 'frontend:\n delivery:\n type: bundle\n', + encoding='utf-8', + ) + (root / '__init__.py').write_text('raise RuntimeError("must never import this project")\n', encoding='utf-8') + for name in ('pluginBridge.js', 'pluginBridge.d.ts'): + (vendor / name).write_text('old untracked SDK\n', encoding='utf-8') + return root + + @pytest.fixture def parse_json_stdout() -> Callable[[subprocess.CompletedProcess[str]], dict[str, Any]]: def _parse_json_stdout(completed: subprocess.CompletedProcess[str]) -> dict[str, Any]: diff --git a/ruoyi-fastapi-backend/tests/cli/root/test_bootstrap.py b/ruoyi-fastapi-backend/tests/cli/root/test_bootstrap.py index c13565b..6cb1c3c 100644 --- a/ruoyi-fastapi-backend/tests/cli/root/test_bootstrap.py +++ b/ruoyi-fastapi-backend/tests/cli/root/test_bootstrap.py @@ -4,6 +4,9 @@ import subprocess import sys from pathlib import Path +import pytest + +pytestmark = pytest.mark.contract BACKEND_DIR = Path(__file__).resolve().parents[3] @@ -31,6 +34,7 @@ print(json.dumps({ capture_output=True, text=True, check=False, + timeout=30, ) assert completed.returncode == 0, completed.stderr @@ -78,6 +82,7 @@ print(json.dumps({ capture_output=True, text=True, check=False, + timeout=30, ) assert completed.returncode == 0, completed.stderr @@ -143,6 +148,7 @@ print(json.dumps({ capture_output=True, text=True, check=False, + timeout=30, ) assert completed.returncode == 0, completed.stderr @@ -204,6 +210,7 @@ print(json.dumps({ capture_output=True, text=True, check=False, + timeout=30, ) assert completed.returncode == 0, completed.stderr 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 810333d..ce6875b 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 @@ -5,10 +5,13 @@ from collections.abc import Callable from pathlib import Path from textwrap import dedent -from cli.exit_codes import DEPENDENCY_ERROR, SUCCESS -from cli.runtime.plugin.scaffold import PluginFrontendVersionResolver +import pytest -FRONTEND_ROOT = Path(__file__).resolve().parents[4] / 'ruoyi-fastapi-frontend' +from cli.exit_codes import ARGUMENT_ERROR, DEPENDENCY_ERROR, SUCCESS +from cli.runtime.plugin.scaffold import PluginFrontendFrameworkResolver + +pytestmark = pytest.mark.contract +FRONTEND_ROOT = Path(__file__).resolve().parents[4] / 'ruoyi-fastapi-frontend' / 'vue3' / 'web' def test_app_config_json_output_is_pure_json( @@ -469,7 +472,7 @@ def test_plugin_create_dry_run_json_output_has_stable_contract( assert payload['dryRun'] is True assert payload['backend'] is True assert payload['frontend'] is True - assert payload['frontendVersion'] == PluginFrontendVersionResolver.resolve(FRONTEND_ROOT) + assert payload['frontendFramework'] == PluginFrontendFrameworkResolver.resolve(FRONTEND_ROOT) assert payload['migration'] is True assert payload['seed'] is True assert payload['job'] is True @@ -508,7 +511,7 @@ def test_plugin_create_optional_parts_json_output_has_stable_contract( assert payload['config'] is False -def test_plugin_create_frontend_version_override_json_output_has_stable_contract( +def test_plugin_create_frontend_framework_override_json_output_has_stable_contract( run_cli_command: Callable[..., subprocess.CompletedProcess[str]], parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict], ) -> None: @@ -518,7 +521,7 @@ def test_plugin_create_frontend_version_override_json_output_has_stable_contract 'contract_vue2', '--dry-run', '--frontend-only', - '--frontend-version=vue2', + '--frontend-framework=vue2', '--env=dev', '--output=json', ) @@ -527,10 +530,38 @@ def test_plugin_create_frontend_version_override_json_output_has_stable_contract view_payload = next(file for file in payload['files'] if str(file['path']).endswith('/views/index.vue')) assert completed.returncode == SUCCESS assert payload['ok'] is True - assert payload['frontendVersion'] == 'vue2' + assert payload['frontendFramework'] == 'vue2' + assert FRONTEND_ROOT.parents[1] / 'vue2/web/plugins/contract_vue2' in [Path(path) for path in payload['targetDirs']] assert 'slot="header"' in view_payload['content'] +def test_plugin_create_rejects_framework_outside_fixed_choices( + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], +) -> None: + """校验根命令在参数解析阶段拒绝不属于固定选项的 React 框架。""" + completed = run_cli_command( + 'plugin', + 'create', + 'contract_react', + '--dry-run', + '--frontend-only', + '--frontend-framework=react', + '--env=dev', + '--output=json', + ) + assert completed.returncode == ARGUMENT_ERROR + assert 'Invalid value' in completed.stderr + assert 'react' in completed.stderr + + +def test_plugin_create_rejects_removed_frontend_version_option( + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], +) -> None: + """校验创建命令不再接受已移除的前端版本选项。""" + completed = run_cli_command('plugin', 'create', 'contract_old_option', '--dry-run', '--frontend-version=vue3') + assert completed.returncode != SUCCESS + + def test_plugin_install_dry_run_json_output_has_stable_contract( run_cli_command: Callable[..., subprocess.CompletedProcess[str]], parse_json_stdout: Callable[[subprocess.CompletedProcess[str]], dict], diff --git a/ruoyi-fastapi-backend/tests/cli/root/test_contract_cli.py b/ruoyi-fastapi-backend/tests/cli/root/test_contract_cli.py index 6ac66d7..e5f6570 100644 --- a/ruoyi-fastapi-backend/tests/cli/root/test_contract_cli.py +++ b/ruoyi-fastapi-backend/tests/cli/root/test_contract_cli.py @@ -2,8 +2,12 @@ import subprocess from collections.abc import Callable from pathlib import Path +import pytest + from cli.exit_codes import SUCCESS +pytestmark = pytest.mark.contract + def test_root_help_shows_commands_without_completion_options( run_cli_command: Callable[..., subprocess.CompletedProcess[str]], diff --git a/ruoyi-fastapi-backend/tests/cli/root/test_contract_config.py b/ruoyi-fastapi-backend/tests/cli/root/test_contract_config.py index d7f00c3..81a0adb 100644 --- a/ruoyi-fastapi-backend/tests/cli/root/test_contract_config.py +++ b/ruoyi-fastapi-backend/tests/cli/root/test_contract_config.py @@ -1,8 +1,12 @@ import subprocess from collections.abc import Callable +import pytest + from cli.exit_codes import DATABASE_ERROR, REDIS_ERROR, RUNTIME_ERROR, SUCCESS +pytestmark = pytest.mark.contract + def test_config_doctor_json_output_has_stable_contract( run_cli_command: Callable[..., subprocess.CompletedProcess[str]], diff --git a/ruoyi-fastapi-backend/tests/cli/root/test_contract_db_cache_dev.py b/ruoyi-fastapi-backend/tests/cli/root/test_contract_db_cache_dev.py index 1e5ed95..45c7b96 100644 --- a/ruoyi-fastapi-backend/tests/cli/root/test_contract_db_cache_dev.py +++ b/ruoyi-fastapi-backend/tests/cli/root/test_contract_db_cache_dev.py @@ -2,8 +2,11 @@ import subprocess from collections.abc import Callable from pathlib import Path +import pytest + from cli.exit_codes import DATABASE_ERROR, GUARD_REJECTED, REDIS_ERROR, RUNTIME_ERROR, SUCCESS +pytestmark = pytest.mark.contract DB_HISTORY_TEST_LIMIT = 5 diff --git a/ruoyi-fastapi-backend/tests/cli/root/test_contract_job_gen.py b/ruoyi-fastapi-backend/tests/cli/root/test_contract_job_gen.py index f713da5..0426aaf 100644 --- a/ruoyi-fastapi-backend/tests/cli/root/test_contract_job_gen.py +++ b/ruoyi-fastapi-backend/tests/cli/root/test_contract_job_gen.py @@ -1,8 +1,11 @@ import subprocess from collections.abc import Callable +import pytest + from cli.exit_codes import ARGUMENT_ERROR, DATABASE_ERROR, GUARD_REJECTED, RUNTIME_ERROR, SUCCESS +pytestmark = pytest.mark.contract GEN_TEST_TABLE_NAME = 'demo_table' GEN_CREATE_SQL = 'CREATE TABLE demo_cli_test (id bigint);' MISSING_JOB_ID = 999999999 diff --git a/ruoyi-fastapi-backend/tests/cli/root/test_plugin_artifact_commands.py b/ruoyi-fastapi-backend/tests/cli/root/test_plugin_artifact_commands.py index ba48c56..707dd7e 100644 --- a/ruoyi-fastapi-backend/tests/cli/root/test_plugin_artifact_commands.py +++ b/ruoyi-fastapi-backend/tests/cli/root/test_plugin_artifact_commands.py @@ -20,6 +20,7 @@ from cli.exit_codes import ARGUMENT_ERROR, GUARD_REJECTED from cli.groups.plugin.artifact_controller import PluginArtifactCommandController from cli.groups.plugin.commands.artifact import register_artifact_commands +pytestmark = pytest.mark.contract BACKEND_ROOT = Path(__file__).resolve().parents[3] DIGEST = 'a' * 64 GENERATION = 'b' * 32 @@ -257,7 +258,7 @@ def test_help_keeps_config_crypto_database_and_plugin_code_unloaded(help_argumen f'print(json.dumps({{"exit":r.exit_code,"loaded":{{name:name in sys.modules for name in {observed!r}}}}}))' ) result = subprocess.run( - [sys.executable, '-c', script], cwd=BACKEND_ROOT, capture_output=True, text=True, check=True + [sys.executable, '-c', script], cwd=BACKEND_ROOT, capture_output=True, text=True, check=True, timeout=30 ) payload = json.loads(result.stdout) assert payload['exit'] == 0 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 4fd5c4e..d2ea521 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 @@ -4,9 +4,12 @@ import pytest import typer from typer.testing import CliRunner +from cli.exit_codes import ARGUMENT_ERROR, SUCCESS from cli.groups.plugin.commands.developer import register_developer_commands from cli.groups.plugin.options import PluginCreateCommandOptions +pytestmark = pytest.mark.contract + @pytest.mark.parametrize('template', ['python-asgi', 'python-bundle', 'rust-asgi', 'rust-bundle']) def test_create_cli_forwards_v2_template_without_changing_default_options(template: str) -> None: @@ -47,3 +50,46 @@ def test_create_cli_defaults_to_existing_v1_full_stack_template() -> None: result = CliRunner().invoke(app, ['create', 'legacy_demo']) assert result.exit_code == 0 assert recorded == [PluginCreateCommandOptions()] + + +@pytest.mark.parametrize('framework', ['auto', 'vue2', 'vue3']) +def test_create_cli_accepts_fixed_frontend_framework_choices(framework: str) -> None: + """校验创建命令接受固定框架选项并将原始字符串传递给控制器。""" + recorded: list[PluginCreateCommandOptions] = [] + + class Controller: + @staticmethod + def create_plugin(plugin_id: str, env: str, output: str, options: PluginCreateCommandOptions) -> None: + recorded.append(options) + + app = typer.Typer() + register_developer_commands(app, Controller) + result = CliRunner().invoke(app, ['create', 'demo', '--frontend-framework', framework]) + assert result.exit_code == SUCCESS, result.output + assert recorded[0].frontend_framework == framework + + +@pytest.mark.parametrize('framework', ['react', 'custom_ui-1', '../react', 'Vue3']) +def test_create_cli_rejects_framework_before_resolving_controller(framework: str) -> None: + """校验非法框架选项在加载控制器或运行时之前被拒绝。""" + + def unavailable_controller() -> None: + """禁止参数错误路径加载控制器。""" + raise AssertionError('不应加载控制器') + + app = typer.Typer() + register_developer_commands(app, unavailable_controller) + result = CliRunner().invoke(app, ['create', 'demo', '--frontend-framework', framework]) + assert result.exit_code == ARGUMENT_ERROR + assert 'Invalid value' in result.output + assert framework in result.output + assert 'auto' in result.output and 'vue2' in result.output and 'vue3' in result.output + + +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 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 new file mode 100644 index 0000000..6c52f87 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/cli/root/test_plugin_frontend_environment.py @@ -0,0 +1,130 @@ +import json +import os +import subprocess +from collections.abc import Callable +from pathlib import Path + +import pytest +import yaml + +from cli.exit_codes import SUCCESS + +pytestmark = pytest.mark.contract +SDK_FILES = ('pluginBridge.js', 'pluginBridge.d.ts', 'pluginBridge.sdk.json') + + +@pytest.mark.parametrize('framework', ['vue2', 'vue3']) +def test_cold_cli_preserves_frontend_framework_across_scaffold_sdk_and_dependency_lock( + tmp_path: Path, + backend_dir: Path, + untracked_bundle_project: Path, + run_cli_command: Callable[..., subprocess.CompletedProcess[str]], + framework: str, +) -> None: + """ + 校验独立进程从框架环境变量启动后,模板、SDK 与依赖锁选择相同工程。 + + 所有宿主文件位于临时目录;创建和锁定只预演,SDK 更新仅修改测试副本。 + """ + backend = tmp_path / 'backend' + frontend_container = tmp_path / 'ruoyi-fastapi-frontend' + for host_framework in ('vue2', 'vue3'): + 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' + ) + original_source = backend_dir.parent / 'ruoyi-fastapi-frontend' / host_framework / 'web' / 'src' / 'utils' + for name in SDK_FILES: + (source / name).write_bytes((original_source / name).read_bytes()) + plugin = backend / 'plugins' / 'environment_demo' + plugin.mkdir(parents=True) + (plugin / 'plugin.yaml').write_text( + yaml.safe_dump( + { + 'id': 'environment_demo', + 'name': 'Environment Demo', + 'version': '1.0.0', + 'backend': {'module': 'plugins.environment_demo'}, + 'dependencies': { + 'frontend': { + 'vue2': {'npm': ['vue2-renderer==2.0.0']}, + 'vue3': {'npm': ['vue3-renderer==3.0.0']}, + } + }, + } + ), + encoding='utf-8', + ) + (plugin / '__init__.py').write_text('raise RuntimeError("must never import this plugin")\n', encoding='utf-8') + environment = { + key: value for key, value in os.environ.items() if key != 'APP_ENV' and not key.startswith(('RUOYI_', 'OIDC_')) + } + environment.update( + RUOYI_PLUGIN_BACKEND_ROOT=str(backend), + RUOYI_PLUGIN_FRONTEND_ROOT=str(frontend_container), + RUOYI_PLUGIN_FRONTEND_FRAMEWORK=framework, + RUOYI_FRONTEND_FRAMEWORK='vue3' if framework == 'vue2' else 'vue2', + LOG_FILE_ENABLED='false', + ) + original_files = {path: path.read_bytes() for path in tmp_path.rglob('*') if path.is_file()} + + created = run_cli_command( + 'plugin', + 'create', + 'environment_scaffold', + '--frontend-framework', + 'auto', + '--dry-run', + '--output', + 'json', + env=environment, + ) + assert created.returncode == SUCCESS, created.stdout + created.stderr + scaffold = json.loads(created.stdout) + selected_frontend = frontend_container / framework / 'web' + assert scaffold['frontendFramework'] == framework + 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']) + + locked = run_cli_command( + 'plugin', 'lock-deps', 'environment_demo', '--dry-run', '--output', 'json', env=environment + ) + assert locked.returncode == SUCCESS, locked.stdout + locked.stderr + lock_payload = json.loads(locked.stdout) + 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'] + assert lock_payload['written'] is False + assert original_files == {path: path.read_bytes() for path in tmp_path.rglob('*') if path.is_file()} + + updated = run_cli_command( + 'plugin', + 'sdk', + 'update', + str(untracked_bundle_project), + '--frontend-framework', + 'auto', + '--force', + '--output', + 'json', + env=environment, + ) + assert updated.returncode == SUCCESS, updated.stdout + updated.stderr + assert json.loads(updated.stdout)['updated'] is True + metadata = json.loads((untracked_bundle_project / 'web/vendor/pluginBridge.sdk.json').read_text(encoding='utf-8')) + assert metadata['source'] == { + 'project': 'RuoYi-FastAPI', + 'directory': f'ruoyi-fastapi-frontend/{framework}/web/src/utils', + } + host_files = { + path: content for path, content in original_files.items() if not path.is_relative_to(untracked_bundle_project) + } + assert host_files == { + path: path.read_bytes() + for path in tmp_path.rglob('*') + if path.is_file() and not path.is_relative_to(untracked_bundle_project) + } diff --git a/ruoyi-fastapi-backend/tests/cli/root/test_plugin_lazy_import.py b/ruoyi-fastapi-backend/tests/cli/root/test_plugin_lazy_import.py index 8cc1379..43e0fb3 100644 --- a/ruoyi-fastapi-backend/tests/cli/root/test_plugin_lazy_import.py +++ b/ruoyi-fastapi-backend/tests/cli/root/test_plugin_lazy_import.py @@ -4,6 +4,9 @@ import subprocess import sys from pathlib import Path +import pytest + +pytestmark = pytest.mark.contract BACKEND_ROOT = Path(__file__).resolve().parents[3] @@ -20,6 +23,7 @@ def inspect_cold_import(module_name: str, observed_modules: tuple[str, ...]) -> check=True, capture_output=True, text=True, + timeout=30, ) return json.loads(completed.stdout) @@ -132,6 +136,7 @@ print(json.dumps({'initial': initial, 'afterController': after_controller, 'afte check=True, capture_output=True, text=True, + timeout=30, ) observed = json.loads(completed.stdout) 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 49cd357..ecf760f 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 @@ -11,8 +11,9 @@ from typer.testing import CliRunner from cli.exit_codes import ARGUMENT_ERROR, DEPENDENCY_ERROR, SUCCESS from cli.groups.plugin.commands.sdk import register_sdk_commands +pytestmark = pytest.mark.contract BACKEND = Path(__file__).resolve().parents[3] -FRONTEND = BACKEND.parent / 'ruoyi-fastapi-frontend' +FRONTEND = BACKEND.parent / 'ruoyi-fastapi-frontend' / 'vue3' / 'web' def sdk_app() -> typer.Typer: @@ -27,26 +28,14 @@ def sdk_app() -> typer.Typer: @pytest.fixture -def project(tmp_path: Path) -> Path: +def project(untracked_bundle_project: Path) -> Path: """ 创建未跟踪 SDK 的旧 bundle 工程,避免读取业务插件。 - :param tmp_path: 隔离测试目录 + :param untracked_bundle_project: 共用的隔离 bundle 工程 :return: 工程根目录 """ - root = tmp_path / 'example' - vendor = root / 'web' / 'vendor' - vendor.mkdir(parents=True) - (root / 'plugin.yaml').write_text( - 'manifestVersion: 2\nid: example\nname: Example\nversion: 1.0.0\n' - 'backend:\n module: plugins.example\n entrypoint: plugins.example:create_plugin\n integration: asgi\n' - 'frontend:\n delivery:\n type: bundle\n', - encoding='utf-8', - ) - (root / '__init__.py').write_text('raise RuntimeError("must never import this project")\n', encoding='utf-8') - for name in ('pluginBridge.js', 'pluginBridge.d.ts'): - (vendor / name).write_text('old untracked SDK\n', encoding='utf-8') - return root + return untracked_bundle_project def invoke_sdk(operation: str, project: Path, *options: str) -> tuple[int, dict]: @@ -104,6 +93,87 @@ 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')]) +def test_sdk_cli_selects_supported_framework_from_frontend_container( + project: Path, framework: str, selected_framework: str +) -> None: + """校验 SDK 更新和检查命令均接受三个固定框架选项。""" + result = CliRunner().invoke( + sdk_app(), + [ + 'sdk', + 'update', + str(project), + '--frontend-root', + str(FRONTEND.parents[1]), + '--frontend-framework', + framework, + '--force', + '--output', + 'json', + ], + ) + assert result.exit_code == SUCCESS, result.stdout + metadata = json.loads((project / 'web/vendor/pluginBridge.sdk.json').read_text(encoding='utf-8')) + assert metadata['source']['directory'] == f'ruoyi-fastapi-frontend/{selected_framework}/web/src/utils' + check_result = CliRunner().invoke( + sdk_app(), + [ + 'sdk', + 'check', + str(project), + '--frontend-root', + str(FRONTEND.parents[1]), + '--frontend-framework', + framework, + '--output', + 'json', + ], + ) + assert check_result.exit_code == SUCCESS, check_result.stdout + assert json.loads(check_result.stdout)['status'] == 'current' + + +@pytest.mark.parametrize('operation', ['check', 'update']) +@pytest.mark.parametrize('framework', ['react', 'custom_ui-1', '../react', 'Vue3']) +def test_sdk_cli_rejects_unsupported_framework_before_file_operations( + project: Path, operation: str, framework: str +) -> None: + """校验 SDK 命令在参数解析阶段拒绝固定选项之外的框架且不修改工程。""" + before = {path.relative_to(project): path.read_bytes() for path in project.rglob('*') if path.is_file()} + result = CliRunner().invoke( + sdk_app(), + [ + 'sdk', + operation, + str(project), + '--frontend-framework', + framework, + '--output', + 'json', + ], + ) + assert result.exit_code == ARGUMENT_ERROR + assert 'Invalid value' in result.output + assert framework in result.output + assert 'auto' in result.output and 'vue2' in result.output and 'vue3' in result.output + assert before == {path.relative_to(project): path.read_bytes() for path in project.rglob('*') if path.is_file()} + + +@pytest.mark.parametrize('operation', ['check', 'update']) +def test_sdk_cli_help_displays_fixed_frontend_framework_choices(operation: str) -> None: + """校验两个 SDK 命令的帮助均展示固定框架选项。""" + result = CliRunner().invoke(sdk_app(), ['sdk', operation, '--help'], terminal_width=160) + assert result.exit_code == SUCCESS + assert 'auto|vue2|vue3' in result.output + + +def test_sdk_cli_rejects_removed_frontend_version_option(project: Path) -> None: + """校验旧版本选项已移除,不保留兼容别名。""" + result = CliRunner().invoke(sdk_app(), ['sdk', 'check', str(project), '--frontend-version', 'vue3']) + assert result.exit_code == ARGUMENT_ERROR + + def test_sdk_cli_invalid_output_is_argument_error(project: Path) -> None: """ 验证 SDK 子命令复用 CLI 输出格式约束。 diff --git a/ruoyi-fastapi-backend/tests/cli/runtime/plugin/conftest.py b/ruoyi-fastapi-backend/tests/cli/runtime/plugin/conftest.py index 4167f11..d305dc0 100644 --- a/ruoyi-fastapi-backend/tests/cli/runtime/plugin/conftest.py +++ b/ruoyi-fastapi-backend/tests/cli/runtime/plugin/conftest.py @@ -4,6 +4,7 @@ from subprocess import CompletedProcess from cli.runtime.plugin.service import CliPluginRuntimeService from plugins.core.environment import PluginRuntimeEnvironmentService +from plugins.core.frontend import PluginFrontendFrameworkResolver from plugins.core.validation.dependencies import ( NpmDependencyInspector, PluginDependencyChecker, @@ -37,6 +38,10 @@ class FakeRuntimeEnvironment: """获取前端项目根目录。""" return str(self.frontend_dir) + def get_frontend_framework(self, frontend_root: Path | None = None) -> str: + """获取指定测试前端工程的框架标识。""" + return PluginFrontendFrameworkResolver.resolve(frontend_root or self.frontend_dir) + def get_frontend_plugins_dir(self) -> str: """获取前端插件根目录。""" return str(self.frontend_dir / 'plugins') diff --git a/ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_dependency_lock.py b/ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_dependency_lock.py index 8c94796..e2b7abf 100644 --- a/ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_dependency_lock.py +++ b/ruoyi-fastapi-backend/tests/cli/runtime/plugin/test_runtime_dependency_lock.py @@ -15,8 +15,7 @@ def write_demo_dependency_manifest(backend_root: Path) -> None: manifest = yaml.safe_load(manifest_path.read_text(encoding='utf-8')) manifest['dependencies'] = { 'python': ['openai>=2.0.0,<3.0.0'], - 'npm': ['dayjs>=1.11.0,<2.0.0'], - 'npmDev': [], + 'frontend': {'vue3': {'npm': ['dayjs>=1.11.0,<2.0.0'], 'npmDev': []}}, 'plugins': [], } manifest_path.write_text(yaml.safe_dump(manifest, allow_unicode=True, sort_keys=False), encoding='utf-8') @@ -43,6 +42,8 @@ def test_plugin_runtime_lock_dependencies_dry_run_returns_lockfile_template(tmp_ lockfile = yaml.safe_load(payload['lockfile']) assert lockfile['plugin'] == 'demo' assert lockfile['version'] == '0.1.0' + assert lockfile['frontendFramework'] == 'vue3' + assert 'frontendVersion' not in lockfile assert lockfile['python'][0]['name'] == 'openai' assert lockfile['python'][0]['requirement'] == 'openai>=2.0.0,<3.0.0' assert lockfile['python'][0]['resolvedVersion'] == '' 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 bc63757..73f00ec 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 @@ -1,7 +1,10 @@ import json from pathlib import Path +import pytest + from cli.runtime.plugin.service import CliPluginRuntimeService +from plugins.core.environment import PluginRuntimeEnvironmentService from .conftest import FakeRuntimeEnvironment, build_runtime @@ -62,16 +65,21 @@ def test_plugin_runtime_create_plugin_dry_run_does_not_write_files(tmp_path: Pat assert payload['template'] == 'full-stack' assert payload['backend'] is True assert payload['frontend'] is True - assert payload['frontendVersion'] == 'vue3' + assert payload['frontendFramework'] == 'vue3' assert payload['test'] is True assert payload['backendTest'] is True assert payload['frontendTest'] is True assert str(backend_root / 'tests' / 'plugins' / 'demo') in payload['targetDirs'] - assert str(project_root / 'ruoyi-fastapi-frontend' / 'tests' / 'plugins' / 'demo') in payload['targetDirs'] + assert ( + str(project_root / 'ruoyi-fastapi-frontend' / 'vue3' / 'web' / 'tests' / 'plugins' / 'demo') + in payload['targetDirs'] + ) assert payload['files'] assert not (backend_root / 'plugins' / 'demo' / 'plugin.yaml').exists() assert not (backend_root / 'tests' / 'plugins' / 'demo' / 'test_ping.py').exists() - assert not (project_root / 'ruoyi-fastapi-frontend' / 'tests' / 'plugins' / 'demo' / 'pluginView.test.js').exists() + assert not ( + project_root / 'ruoyi-fastapi-frontend' / 'vue3' / 'web' / 'tests' / 'plugins' / 'demo' / 'pluginView.test.js' + ).exists() def test_plugin_runtime_create_plugin_rejects_unsafe_plugin_id(tmp_path: Path) -> None: @@ -93,6 +101,7 @@ def test_plugin_runtime_create_plugin_uses_runtime_frontend_dir(tmp_path: Path) backend_root = project_root / 'api-server' frontend_root = project_root / 'web-client' backend_root.mkdir(parents=True) + write_frontend_package(frontend_root, {'vue': '^3.5.26'}) payload = build_runtime(backend_root, frontend_root=frontend_root).create_plugin('demo') @@ -115,7 +124,7 @@ def test_plugin_runtime_create_plugin_auto_detects_vue2_frontend(tmp_path: Path) view_content = (frontend_root / 'plugins' / 'demo' / 'views' / 'index.vue').read_text(encoding='utf-8') test_content = (frontend_root / 'tests' / 'plugins' / 'demo' / 'pluginView.test.js').read_text(encoding='utf-8') assert payload['ok'] is True - assert payload['frontendVersion'] == 'vue2' + assert payload['frontendFramework'] == 'vue2' assert ' + + + + +

+ \ No newline at end of file diff --git a/ruoyi-fastapi-frontend/vue2/mobile/scripts/patch-uni-webpack.cjs b/ruoyi-fastapi-frontend/vue2/mobile/scripts/patch-uni-webpack.cjs new file mode 100644 index 0000000..b779613 --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/mobile/scripts/patch-uni-webpack.cjs @@ -0,0 +1,19 @@ +const fs = require('fs') + +// 此 uni-app 版本即使关闭独立分包,也会预加载依赖 webpack 4 GraphHelpers +// 的插件。延迟到功能启用后再加载,保证 Vue CLI 5 的普通小程序构建可用。 +const filename = require.resolve('@dcloudio/uni-mp-weixin/lib/createIndependentPlugin.js') +const source = fs.readFileSync(filename, 'utf8') +const marker = '// Lazy independent plugins for webpack 5' + +if (!source.includes(marker)) { + const imports = source.match(/^const .+ = require\('.\/independent-plugins\/.+'\)\r?\n/gm) + const guard = 'if (!independentSwitch) return []' + if (!imports || !source.includes(guard)) { + throw new Error('uni-app 独立分包插件结构已变化,请检查 webpack 兼容补丁') + } + let patched = source + imports.forEach(line => { patched = patched.replace(line, '') }) + patched = patched.replace(guard, `${guard}\n\n ${marker}\n${imports.map(line => ` ${line.trim()}`).join('\n')}`) + fs.writeFileSync(filename, patched) +} diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/App.vue b/ruoyi-fastapi-frontend/vue2/mobile/src/App.vue new file mode 100644 index 0000000..b5b848c --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/App.vue @@ -0,0 +1,70 @@ + + + diff --git a/ruoyi-fastapi-app/src/api/login.js b/ruoyi-fastapi-frontend/vue2/mobile/src/api/login.js similarity index 94% rename from ruoyi-fastapi-app/src/api/login.js rename to ruoyi-fastapi-frontend/vue2/mobile/src/api/login.js index 4fda1aa..3733b15 100644 --- a/ruoyi-fastapi-app/src/api/login.js +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/api/login.js @@ -1,62 +1,62 @@ -import request from "@/utils/request"; - -// 登录方法 -export function login(username, password, code, uuid) { - const data = { - username, - password, - code, - uuid, - }; - return request({ - url: "/login", - header: { - "Content-Type": "application/x-www-form-urlencoded", - }, - headers: { - isToken: false, - }, - method: "post", - data: data, - }); -} - -// 注册方法 -export function register(data) { - return request({ - url: "/register", - headers: { - isToken: false, - }, - method: "post", - data: data, - }); -} - -// 获取用户详细信息 -export function getInfo() { - return request({ - url: "/getInfo", - method: "get", - }); -} - -// 退出方法 -export function logout() { - return request({ - url: "/logout", - method: "post", - }); -} - -// 获取验证码 -export function getCodeImg() { - return request({ - url: "/captchaImage", - headers: { - isToken: false, - }, - method: "get", - timeout: 20000, - }); -} +import request from "@/utils/request"; + +// 登录方法 +export function login(username, password, code, uuid) { + const data = { + username, + password, + code, + uuid, + }; + return request({ + url: "/login", + header: { + "Content-Type": "application/x-www-form-urlencoded", + }, + headers: { + isToken: false, + }, + method: "post", + data: data, + }); +} + +// 注册方法 +export function register(data) { + return request({ + url: "/register", + headers: { + isToken: false, + }, + method: "post", + data: data, + }); +} + +// 获取用户详细信息 +export function getInfo() { + return request({ + url: "/getInfo", + method: "get", + }); +} + +// 退出方法 +export function logout() { + return request({ + url: "/logout", + method: "post", + }); +} + +// 获取验证码 +export function getCodeImg() { + return request({ + url: "/captchaImage", + headers: { + isToken: false, + }, + method: "get", + timeout: 20000, + }); +} diff --git a/ruoyi-fastapi-app/src/api/system/dict/data.js b/ruoyi-fastapi-frontend/vue2/mobile/src/api/system/dict/data.js similarity index 100% rename from ruoyi-fastapi-app/src/api/system/dict/data.js rename to ruoyi-fastapi-frontend/vue2/mobile/src/api/system/dict/data.js diff --git a/ruoyi-fastapi-app/src/api/system/dict/type.js b/ruoyi-fastapi-frontend/vue2/mobile/src/api/system/dict/type.js similarity index 100% rename from ruoyi-fastapi-app/src/api/system/dict/type.js rename to ruoyi-fastapi-frontend/vue2/mobile/src/api/system/dict/type.js diff --git a/ruoyi-fastapi-app/src/api/system/user.js b/ruoyi-fastapi-frontend/vue2/mobile/src/api/system/user.js similarity index 100% rename from ruoyi-fastapi-app/src/api/system/user.js rename to ruoyi-fastapi-frontend/vue2/mobile/src/api/system/user.js diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/config.js b/ruoyi-fastapi-frontend/vue2/mobile/src/config.js new file mode 100644 index 0000000..c651d42 --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/config.js @@ -0,0 +1,26 @@ +// 应用全局配置 +module.exports = { + baseUrl: "http://localhost:9099", + // 应用信息 + appInfo: { + // 应用名称 + name: "RuoYi-FastAPI-APP", + // 应用版本 + version: "1.10.0", + // 应用logo + logo: "/static/logo.png", + // 官方网站 + site_url: "https://vfadmin.insistence.tech", + // 政策协议 + agreements: [ + { + title: "隐私政策", + url: "https://ruoyi.vip/protocol.html", + }, + { + title: "用户服务协议", + url: "https://ruoyi.vip/protocol.html", + }, + ], + }, +}; diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/main.ts b/ruoyi-fastapi-frontend/vue2/mobile/src/main.ts new file mode 100644 index 0000000..72b0d10 --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/main.ts @@ -0,0 +1,17 @@ +// @ts-nocheck +import Vue from "vue"; +import App from "./App.vue"; +import store from "./store"; +import plugins from "./plugins"; +import "./permission"; +import { getDicts } from "@/api/system/dict/data"; + +Vue.use(plugins); +Vue.config.productionTip = false; +Vue.prototype.$store = store; +Vue.prototype.getDicts = getDicts; +App.mpType = "app"; +const app = new Vue({ + ...App, +}); +app.$mount(); diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/manifest.json b/ruoyi-fastapi-frontend/vue2/mobile/src/manifest.json new file mode 100644 index 0000000..e77550b --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/manifest.json @@ -0,0 +1,68 @@ +{ + "name": "RuoYi-FastAPI移动端", + "appid": "__UNI__25A9D80", + "description": "", + "versionName": "1.10.0", + "versionCode": "100", + "transformPx": false, + "app-plus": { + "usingComponents": true, + "nvueCompiler": "uni-app", + "splashscreen": { + "alwaysShowBeforeRender": true, + "waiting": true, + "autoclose": true, + "delay": 0 + }, + "modules": {}, + "distribute": { + "android": { + "permissions": [ + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ] + }, + "ios": {}, + "sdkConfigs": {} + } + }, + "quickapp": {}, + "mp-weixin": { + "appid": "wxccd7e2a0911b3397", + "setting": { + "urlCheck": false, + "es6": false, + "minified": true, + "postcss": true + }, + "optimization": { + "subPackages": true + }, + "usingComponents": true + }, + "vueVersion": "2", + "h5": { + "devServer": { + "port": 9090, + "https": false + }, + "title": "RuoYi-FastAPI-APP", + "router": { + "mode": "hash", + "base": "./" + } + } +} diff --git a/ruoyi-fastapi-app/src/pages.json b/ruoyi-fastapi-frontend/vue2/mobile/src/pages.json similarity index 95% rename from ruoyi-fastapi-app/src/pages.json rename to ruoyi-fastapi-frontend/vue2/mobile/src/pages.json index ecf76ca..6e4105e 100644 --- a/ruoyi-fastapi-app/src/pages.json +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/pages.json @@ -1,138 +1,138 @@ -{ - "pages": [ - { - "path": "pages/login", - "style": { - "navigationBarTitleText": "登录" - } - }, - { - "path": "pages/register", - "style": { - "navigationBarTitleText": "注册" - } - }, - { - "path": "pages/index", - "style": { - "navigationBarTitleText": "RuoYi-FastAPI移动端", - "navigationStyle": "custom" - } - }, - { - "path": "pages/work/index", - "style": { - "navigationBarTitleText": "工作台" - } - }, - { - "path": "pages/mine/index", - "style": { - "navigationBarTitleText": "我的" - } - }, - { - "path": "pages/mine/avatar/index", - "style": { - "navigationBarTitleText": "修改头像" - } - }, - { - "path": "pages/mine/info/index", - "style": { - "navigationBarTitleText": "个人信息" - } - }, - { - "path": "pages/mine/info/edit", - "style": { - "navigationBarTitleText": "编辑资料" - } - }, - { - "path": "pages/mine/pwd/index", - "style": { - "navigationBarTitleText": "修改密码" - } - }, - { - "path": "pages/mine/setting/index", - "style": { - "navigationBarTitleText": "应用设置" - } - }, - { - "path": "pages/mine/setting/timezone", - "style": { - "navigationBarTitleText": "时区设置" - } - }, - { - "path": "pages/mine/help/index", - "style": { - "navigationBarTitleText": "常见问题" - } - }, - { - "path": "pages/mine/about/index", - "style": { - "navigationBarTitleText": "关于我们" - } - }, - { - "path": "pages/common/agreement/index", - "style": { - "navigationBarTitleText": "用户协议" - } - }, - { - "path": "pages/common/privacy/index", - "style": { - "navigationBarTitleText": "隐私协议" - } - }, - { - "path": "pages/common/webview/index", - "style": { - "navigationBarTitleText": "浏览网页" - } - }, - { - "path": "pages/common/textview/index", - "style": { - "navigationBarTitleText": "浏览文本" - } - } - ], - "tabBar": { - "color": "#000000", - "selectedColor": "#000000", - "borderStyle": "white", - "backgroundColor": "#ffffff", - "list": [ - { - "pagePath": "pages/index", - "iconPath": "static/images/tabbar/home.png", - "selectedIconPath": "static/images/tabbar/home_.png", - "text": "首页" - }, - { - "pagePath": "pages/work/index", - "iconPath": "static/images/tabbar/work.png", - "selectedIconPath": "static/images/tabbar/work_.png", - "text": "工作台" - }, - { - "pagePath": "pages/mine/index", - "iconPath": "static/images/tabbar/mine.png", - "selectedIconPath": "static/images/tabbar/mine_.png", - "text": "我的" - } - ] - }, - "globalStyle": { - "navigationBarTextStyle": "black", - "navigationBarTitleText": "RuoYi", - "navigationBarBackgroundColor": "#FFFFFF" - } -} +{ + "pages": [ + { + "path": "pages/login", + "style": { + "navigationBarTitleText": "登录" + } + }, + { + "path": "pages/register", + "style": { + "navigationBarTitleText": "注册" + } + }, + { + "path": "pages/index", + "style": { + "navigationBarTitleText": "RuoYi-FastAPI移动端", + "navigationStyle": "custom" + } + }, + { + "path": "pages/work/index", + "style": { + "navigationBarTitleText": "工作台" + } + }, + { + "path": "pages/mine/index", + "style": { + "navigationBarTitleText": "我的" + } + }, + { + "path": "pages/mine/avatar/index", + "style": { + "navigationBarTitleText": "修改头像" + } + }, + { + "path": "pages/mine/info/index", + "style": { + "navigationBarTitleText": "个人信息" + } + }, + { + "path": "pages/mine/info/edit", + "style": { + "navigationBarTitleText": "编辑资料" + } + }, + { + "path": "pages/mine/pwd/index", + "style": { + "navigationBarTitleText": "修改密码" + } + }, + { + "path": "pages/mine/setting/index", + "style": { + "navigationBarTitleText": "应用设置" + } + }, + { + "path": "pages/mine/setting/timezone", + "style": { + "navigationBarTitleText": "时区设置" + } + }, + { + "path": "pages/mine/help/index", + "style": { + "navigationBarTitleText": "常见问题" + } + }, + { + "path": "pages/mine/about/index", + "style": { + "navigationBarTitleText": "关于我们" + } + }, + { + "path": "pages/common/agreement/index", + "style": { + "navigationBarTitleText": "用户协议" + } + }, + { + "path": "pages/common/privacy/index", + "style": { + "navigationBarTitleText": "隐私协议" + } + }, + { + "path": "pages/common/webview/index", + "style": { + "navigationBarTitleText": "浏览网页" + } + }, + { + "path": "pages/common/textview/index", + "style": { + "navigationBarTitleText": "浏览文本" + } + } + ], + "tabBar": { + "color": "#000000", + "selectedColor": "#000000", + "borderStyle": "white", + "backgroundColor": "#ffffff", + "list": [ + { + "pagePath": "pages/index", + "iconPath": "static/images/tabbar/home.png", + "selectedIconPath": "static/images/tabbar/home_.png", + "text": "首页" + }, + { + "pagePath": "pages/work/index", + "iconPath": "static/images/tabbar/work.png", + "selectedIconPath": "static/images/tabbar/work_.png", + "text": "工作台" + }, + { + "pagePath": "pages/mine/index", + "iconPath": "static/images/tabbar/mine.png", + "selectedIconPath": "static/images/tabbar/mine_.png", + "text": "我的" + } + ] + }, + "globalStyle": { + "navigationBarTextStyle": "black", + "navigationBarTitleText": "RuoYi", + "navigationBarBackgroundColor": "#FFFFFF" + } +} diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/pages/common/agreement/index.vue b/ruoyi-fastapi-frontend/vue2/mobile/src/pages/common/agreement/index.vue new file mode 100644 index 0000000..54488fc --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/pages/common/agreement/index.vue @@ -0,0 +1,69 @@ + + + diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/pages/common/privacy/index.vue b/ruoyi-fastapi-frontend/vue2/mobile/src/pages/common/privacy/index.vue new file mode 100644 index 0000000..702522d --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/pages/common/privacy/index.vue @@ -0,0 +1,93 @@ + + + diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/pages/common/textview/index.vue b/ruoyi-fastapi-frontend/vue2/mobile/src/pages/common/textview/index.vue new file mode 100644 index 0000000..1969fb7 --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/pages/common/textview/index.vue @@ -0,0 +1,39 @@ + + + + + diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/pages/common/webview/index.vue b/ruoyi-fastapi-frontend/vue2/mobile/src/pages/common/webview/index.vue new file mode 100644 index 0000000..65e4199 --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/pages/common/webview/index.vue @@ -0,0 +1,34 @@ + + + diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/pages/index.vue b/ruoyi-fastapi-frontend/vue2/mobile/src/pages/index.vue new file mode 100644 index 0000000..02b6b06 --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/pages/index.vue @@ -0,0 +1,214 @@ + + + diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/pages/login.vue b/ruoyi-fastapi-frontend/vue2/mobile/src/pages/login.vue new file mode 100644 index 0000000..5f482b0 --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/pages/login.vue @@ -0,0 +1,218 @@ + + + + + diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/pages/mine/about/index.vue b/ruoyi-fastapi-frontend/vue2/mobile/src/pages/mine/about/index.vue new file mode 100644 index 0000000..66a5453 --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/pages/mine/about/index.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/pages/mine/avatar/index.vue b/ruoyi-fastapi-frontend/vue2/mobile/src/pages/mine/avatar/index.vue new file mode 100644 index 0000000..1e91372 --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/pages/mine/avatar/index.vue @@ -0,0 +1,757 @@ + + + + + diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/pages/mine/help/index.vue b/ruoyi-fastapi-frontend/vue2/mobile/src/pages/mine/help/index.vue new file mode 100644 index 0000000..f4fc929 --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/pages/mine/help/index.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/pages/mine/index.vue b/ruoyi-fastapi-frontend/vue2/mobile/src/pages/mine/index.vue new file mode 100644 index 0000000..f5bb4fb --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/pages/mine/index.vue @@ -0,0 +1,212 @@ + + + diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/pages/mine/info/edit.vue b/ruoyi-fastapi-frontend/vue2/mobile/src/pages/mine/info/edit.vue new file mode 100644 index 0000000..5fe346c --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/pages/mine/info/edit.vue @@ -0,0 +1,147 @@ + + + + + diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/pages/mine/info/index.vue b/ruoyi-fastapi-frontend/vue2/mobile/src/pages/mine/info/index.vue new file mode 100644 index 0000000..14c7bcf --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/pages/mine/info/index.vue @@ -0,0 +1,133 @@ + + + + + diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/pages/mine/pwd/index.vue b/ruoyi-fastapi-frontend/vue2/mobile/src/pages/mine/pwd/index.vue new file mode 100644 index 0000000..5e32295 --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/pages/mine/pwd/index.vue @@ -0,0 +1,100 @@ + + + diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/pages/mine/setting/index.vue b/ruoyi-fastapi-frontend/vue2/mobile/src/pages/mine/setting/index.vue new file mode 100644 index 0000000..3a029c9 --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/pages/mine/setting/index.vue @@ -0,0 +1,98 @@ + + + + + diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/pages/mine/setting/timezone.vue b/ruoyi-fastapi-frontend/vue2/mobile/src/pages/mine/setting/timezone.vue new file mode 100644 index 0000000..d2dd990 --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/pages/mine/setting/timezone.vue @@ -0,0 +1,198 @@ + + + + + diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/pages/register.vue b/ruoyi-fastapi-frontend/vue2/mobile/src/pages/register.vue new file mode 100644 index 0000000..1b57ae9 --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/pages/register.vue @@ -0,0 +1,207 @@ + + + + + diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/pages/work/index.vue b/ruoyi-fastapi-frontend/vue2/mobile/src/pages/work/index.vue new file mode 100644 index 0000000..f4c71c5 --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/pages/work/index.vue @@ -0,0 +1,235 @@ + + + diff --git a/ruoyi-fastapi-app/src/permission.js b/ruoyi-fastapi-frontend/vue2/mobile/src/permission.js similarity index 91% rename from ruoyi-fastapi-app/src/permission.js rename to ruoyi-fastapi-frontend/vue2/mobile/src/permission.js index 31a8abf..31152ce 100644 --- a/ruoyi-fastapi-app/src/permission.js +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/permission.js @@ -1,43 +1,44 @@ -import { getToken } from "@/utils/auth"; - -// 登录页面 -const loginPage = "/pages/login"; - -// 页面白名单 -const whiteList = [ - "/pages/login", - "/pages/register", - "/pages/common/webview/index", - "/pages/common/agreement/index", - "/pages/common/privacy/index", -]; - -// 检查地址白名单 -function checkWhite(url) { - const path = url.split("?")[0]; - return whiteList.indexOf(path) !== -1; -} - -// 页面跳转验证拦截器 -let list = ["navigateTo", "redirectTo", "reLaunch", "switchTab"]; -list.forEach((item) => { - uni.addInterceptor(item, { - invoke(to) { - if (getToken()) { - if (to.url === loginPage) { - uni.reLaunch({ url: "/" }); - } - return true; - } else { - if (checkWhite(to.url)) { - return true; - } - uni.reLaunch({ url: loginPage }); - return false; - } - }, - fail(err) { - console.log(err); - }, - }); -}); +import { getToken } from "@/utils/auth"; + +// 登录页面 +const loginPage = "/pages/login"; + +// 页面白名单 +const whiteList = [ + "/pages/login", + "/pages/register", + "/pages/common/webview/index", + "/pages/common/agreement/index", + "/pages/common/privacy/index", +]; + +// 检查地址白名单 +function checkWhite(url) { + const path = url.split("?")[0]; + return whiteList.indexOf(path) !== -1; +} + +// 页面跳转验证拦截器 +let list = ["navigateTo", "redirectTo", "reLaunch", "switchTab"]; +list.forEach((item) => { + uni.addInterceptor(item, { + invoke(to) { + if (getToken()) { + if (to.url.split("?")[0] === loginPage) { + uni.reLaunch({ url: "/" }); + return false; + } + return true; + } else { + if (checkWhite(to.url)) { + return true; + } + uni.reLaunch({ url: loginPage }); + return false; + } + }, + fail(err) { + console.log(err); + }, + }); +}); diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/plugins/auth.js b/ruoyi-fastapi-frontend/vue2/mobile/src/plugins/auth.js new file mode 100644 index 0000000..4582585 --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/plugins/auth.js @@ -0,0 +1,60 @@ +import store from "@/store"; + +function authPermission(permission) { + const all_permission = "*:*:*"; + const permissions = store.getters && store.getters.permissions; + if (permission && permission.length > 0) { + return permissions.some((v) => { + return all_permission === v || v === permission; + }); + } else { + return false; + } +} + +function authRole(role) { + const super_admin = "admin"; + const roles = store.getters && store.getters.roles; + if (role && role.length > 0) { + return roles.some((v) => { + return super_admin === v || v === role; + }); + } else { + return false; + } +} + +export default { + // 验证用户是否具备某权限 + hasPermi(permission) { + return authPermission(permission); + }, + // 验证用户是否含有指定权限,只需包含其中一个 + hasPermiOr(permissions) { + return permissions.some((item) => { + return authPermission(item); + }); + }, + // 验证用户是否含有指定权限,必须全部拥有 + hasPermiAnd(permissions) { + return permissions.every((item) => { + return authPermission(item); + }); + }, + // 验证用户是否具备某角色 + hasRole(role) { + return authRole(role); + }, + // 验证用户是否含有指定角色,只需包含其中一个 + hasRoleOr(roles) { + return roles.some((item) => { + return authRole(item); + }); + }, + // 验证用户是否含有指定角色,必须全部拥有 + hasRoleAnd(roles) { + return roles.every((item) => { + return authRole(item); + }); + }, +}; diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/plugins/index.js b/ruoyi-fastapi-frontend/vue2/mobile/src/plugins/index.js new file mode 100644 index 0000000..83a40b5 --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/plugins/index.js @@ -0,0 +1,14 @@ +import tab from "./tab"; +import auth from "./auth"; +import modal from "./modal"; + +export default { + install(Vue) { + // 页签操作 + Vue.prototype.$tab = tab; + // 认证对象 + Vue.prototype.$auth = auth; + // 模态框对象 + Vue.prototype.$modal = modal; + }, +}; diff --git a/ruoyi-fastapi-app/src/plugins/modal.js b/ruoyi-fastapi-frontend/vue2/mobile/src/plugins/modal.js similarity index 94% rename from ruoyi-fastapi-app/src/plugins/modal.js rename to ruoyi-fastapi-frontend/vue2/mobile/src/plugins/modal.js index 54374e1..175d11c 100644 --- a/ruoyi-fastapi-app/src/plugins/modal.js +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/plugins/modal.js @@ -1,78 +1,78 @@ -export default { - // 消息提示 - msg(content) { - uni.showToast({ - title: content, - icon: "none", - }); - }, - // 错误消息 - msgError(content) { - uni.showToast({ - title: content, - icon: "error", - }); - }, - // 成功消息 - msgSuccess(content) { - uni.showToast({ - title: content, - icon: "success", - }); - }, - // 隐藏消息 - hideMsg(content) { - uni.hideToast(); - }, - // 弹出提示 - alert(content, title) { - uni.showModal({ - title: title || "系统提示", - content: content, - showCancel: false, - }); - }, - // 确认窗体 - confirm(content, title) { - return new Promise((resolve, reject) => { - uni.showModal({ - title: title || "系统提示", - content: content, - cancelText: "取消", - confirmText: "确定", - success: function (res) { - if (res.confirm) { - resolve(res.confirm); - } - }, - }); - }); - }, - // 提示信息 - showToast(option) { - if (typeof option === "object") { - uni.showToast(option); - } else { - uni.showToast({ - title: option, - icon: "none", - duration: 2500, - }); - } - }, - // 打开遮罩层 - loading(content) { - uni.showLoading({ - title: content, - icon: "none", - }); - }, - // 关闭遮罩层 - closeLoading() { - try { - uni.hideLoading(); - } catch (e) { - console.log(e); - } - }, -}; +export default { + // 消息提示 + msg(content) { + uni.showToast({ + title: content, + icon: "none", + }); + }, + // 错误消息 + msgError(content) { + uni.showToast({ + title: content, + icon: "error", + }); + }, + // 成功消息 + msgSuccess(content) { + uni.showToast({ + title: content, + icon: "success", + }); + }, + // 隐藏消息 + hideMsg(content) { + uni.hideToast(); + }, + // 弹出提示 + alert(content, title) { + uni.showModal({ + title: title || "系统提示", + content: content, + showCancel: false, + }); + }, + // 确认窗体 + confirm(content, title) { + return new Promise((resolve, reject) => { + uni.showModal({ + title: title || "系统提示", + content: content, + cancelText: "取消", + confirmText: "确定", + success: function (res) { + if (res.confirm) { + resolve(res.confirm); + } + }, + }); + }); + }, + // 提示信息 + showToast(option) { + if (typeof option === "object") { + uni.showToast(option); + } else { + uni.showToast({ + title: option, + icon: "none", + duration: 2500, + }); + } + }, + // 打开遮罩层 + loading(content) { + uni.showLoading({ + title: content, + icon: "none", + }); + }, + // 关闭遮罩层 + closeLoading() { + try { + uni.hideLoading(); + } catch (e) { + console.log(e); + } + }, +}; diff --git a/ruoyi-fastapi-app/src/plugins/tab.js b/ruoyi-fastapi-frontend/vue2/mobile/src/plugins/tab.js similarity index 95% rename from ruoyi-fastapi-app/src/plugins/tab.js rename to ruoyi-fastapi-frontend/vue2/mobile/src/plugins/tab.js index bd3135d..c68475b 100644 --- a/ruoyi-fastapi-app/src/plugins/tab.js +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/plugins/tab.js @@ -1,30 +1,30 @@ -export default { - // 关闭所有页面,打开到应用内的某个页面 - reLaunch(url) { - return uni.reLaunch({ - url: url, - }); - }, - // 跳转到tabBar页面,并关闭其他所有非tabBar页面 - switchTab(url) { - return uni.switchTab({ - url: url, - }); - }, - // 关闭当前页面,跳转到应用内的某个页面 - redirectTo(url) { - return uni.redirectTo({ - url: url, - }); - }, - // 保留当前页面,跳转到应用内的某个页面 - navigateTo(url) { - return uni.navigateTo({ - url: url, - }); - }, - // 关闭当前页面,返回上一页面或多级页面 - navigateBack() { - return uni.navigateBack(); - }, -}; +export default { + // 关闭所有页面,打开到应用内的某个页面 + reLaunch(url) { + return uni.reLaunch({ + url: url, + }); + }, + // 跳转到tabBar页面,并关闭其他所有非tabBar页面 + switchTab(url) { + return uni.switchTab({ + url: url, + }); + }, + // 关闭当前页面,跳转到应用内的某个页面 + redirectTo(url) { + return uni.redirectTo({ + url: url, + }); + }, + // 保留当前页面,跳转到应用内的某个页面 + navigateTo(url) { + return uni.navigateTo({ + url: url, + }); + }, + // 关闭当前页面,返回上一页面或多级页面 + navigateBack() { + return uni.navigateBack(); + }, +}; diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/sfc.d.ts b/ruoyi-fastapi-frontend/vue2/mobile/src/sfc.d.ts new file mode 100644 index 0000000..0660bd6 --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/sfc.d.ts @@ -0,0 +1,4 @@ +declare module "*.vue" { + import Vue from "vue"; + export default Vue; +} diff --git a/ruoyi-fastapi-app/src/static/favicon.ico b/ruoyi-fastapi-frontend/vue2/mobile/src/static/favicon.ico similarity index 100% rename from ruoyi-fastapi-app/src/static/favicon.ico rename to ruoyi-fastapi-frontend/vue2/mobile/src/static/favicon.ico diff --git a/ruoyi-fastapi-app/src/static/images/banner/banner01.jpg b/ruoyi-fastapi-frontend/vue2/mobile/src/static/images/banner/banner01.jpg similarity index 100% rename from ruoyi-fastapi-app/src/static/images/banner/banner01.jpg rename to ruoyi-fastapi-frontend/vue2/mobile/src/static/images/banner/banner01.jpg diff --git a/ruoyi-fastapi-app/src/static/images/banner/banner02.jpg b/ruoyi-fastapi-frontend/vue2/mobile/src/static/images/banner/banner02.jpg similarity index 100% rename from ruoyi-fastapi-app/src/static/images/banner/banner02.jpg rename to ruoyi-fastapi-frontend/vue2/mobile/src/static/images/banner/banner02.jpg diff --git a/ruoyi-fastapi-app/src/static/images/banner/banner03.jpg b/ruoyi-fastapi-frontend/vue2/mobile/src/static/images/banner/banner03.jpg similarity index 100% rename from ruoyi-fastapi-app/src/static/images/banner/banner03.jpg rename to ruoyi-fastapi-frontend/vue2/mobile/src/static/images/banner/banner03.jpg diff --git a/ruoyi-fastapi-app/src/static/images/profile.jpg b/ruoyi-fastapi-frontend/vue2/mobile/src/static/images/profile.jpg similarity index 100% rename from ruoyi-fastapi-app/src/static/images/profile.jpg rename to ruoyi-fastapi-frontend/vue2/mobile/src/static/images/profile.jpg diff --git a/ruoyi-fastapi-app/src/static/images/tabbar/home.png b/ruoyi-fastapi-frontend/vue2/mobile/src/static/images/tabbar/home.png similarity index 100% rename from ruoyi-fastapi-app/src/static/images/tabbar/home.png rename to ruoyi-fastapi-frontend/vue2/mobile/src/static/images/tabbar/home.png diff --git a/ruoyi-fastapi-app/src/static/images/tabbar/home_.png b/ruoyi-fastapi-frontend/vue2/mobile/src/static/images/tabbar/home_.png similarity index 100% rename from ruoyi-fastapi-app/src/static/images/tabbar/home_.png rename to ruoyi-fastapi-frontend/vue2/mobile/src/static/images/tabbar/home_.png diff --git a/ruoyi-fastapi-app/src/static/images/tabbar/mine.png b/ruoyi-fastapi-frontend/vue2/mobile/src/static/images/tabbar/mine.png similarity index 100% rename from ruoyi-fastapi-app/src/static/images/tabbar/mine.png rename to ruoyi-fastapi-frontend/vue2/mobile/src/static/images/tabbar/mine.png diff --git a/ruoyi-fastapi-app/src/static/images/tabbar/mine_.png b/ruoyi-fastapi-frontend/vue2/mobile/src/static/images/tabbar/mine_.png similarity index 100% rename from ruoyi-fastapi-app/src/static/images/tabbar/mine_.png rename to ruoyi-fastapi-frontend/vue2/mobile/src/static/images/tabbar/mine_.png diff --git a/ruoyi-fastapi-app/src/static/images/tabbar/work.png b/ruoyi-fastapi-frontend/vue2/mobile/src/static/images/tabbar/work.png similarity index 100% rename from ruoyi-fastapi-app/src/static/images/tabbar/work.png rename to ruoyi-fastapi-frontend/vue2/mobile/src/static/images/tabbar/work.png diff --git a/ruoyi-fastapi-app/src/static/images/tabbar/work_.png b/ruoyi-fastapi-frontend/vue2/mobile/src/static/images/tabbar/work_.png similarity index 100% rename from ruoyi-fastapi-app/src/static/images/tabbar/work_.png rename to ruoyi-fastapi-frontend/vue2/mobile/src/static/images/tabbar/work_.png diff --git a/ruoyi-fastapi-app/src/static/logo.png b/ruoyi-fastapi-frontend/vue2/mobile/src/static/logo.png similarity index 100% rename from ruoyi-fastapi-app/src/static/logo.png rename to ruoyi-fastapi-frontend/vue2/mobile/src/static/logo.png diff --git a/ruoyi-fastapi-app/src/static/logo200.png b/ruoyi-fastapi-frontend/vue2/mobile/src/static/logo200.png similarity index 100% rename from ruoyi-fastapi-app/src/static/logo200.png rename to ruoyi-fastapi-frontend/vue2/mobile/src/static/logo200.png diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/store/getters.js b/ruoyi-fastapi-frontend/vue2/mobile/src/store/getters.js new file mode 100644 index 0000000..b580ea0 --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/store/getters.js @@ -0,0 +1,9 @@ +const getters = { + token: state => state.user.token, + avatar: state => state.user.avatar, + id: state => state.user.id, + name: state => state.user.name, + roles: state => state.user.roles, + permissions: state => state.user.permissions +} +export default getters diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/store/index.js b/ruoyi-fastapi-frontend/vue2/mobile/src/store/index.js new file mode 100644 index 0000000..8d0d94c --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/store/index.js @@ -0,0 +1,15 @@ +import Vue from "vue"; +import Vuex from "vuex"; +import user from "@/store/modules/user"; +import getters from "./getters"; + +Vue.use(Vuex); + +const store = new Vuex.Store({ + modules: { + user, + }, + getters, +}); + +export default store; diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/store/modules/user.js b/ruoyi-fastapi-frontend/vue2/mobile/src/store/modules/user.js new file mode 100644 index 0000000..4682d86 --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/store/modules/user.js @@ -0,0 +1,147 @@ +import { setBusinessTimezone, setUserTimezone } from "@/utils/time"; +import config from "@/config"; +import storage from "@/utils/storage"; +import constant from "@/utils/constant"; +import { isHttp, isEmpty } from "@/utils/validate"; +import { login, logout, getInfo } from "@/api/login"; +import { getToken, setToken, removeToken } from "@/utils/auth"; +import defAva from "@/static/images/profile.jpg"; + +const baseUrl = config.baseUrl; + +const user = { + state: { + token: getToken(), + appTimezone: "Asia/Shanghai", + timeZone: "auto", + id: storage.get(constant.id), + name: storage.get(constant.name), + avatar: storage.get(constant.avatar), + roles: storage.get(constant.roles), + permissions: storage.get(constant.permissions), + }, + + mutations: { + SET_APP_TIMEZONE: (state, timezone) => { + setBusinessTimezone(timezone); + state.appTimezone = timezone; + }, + SET_TIMEZONE: (state, preference = "auto") => { + setUserTimezone(preference); + state.timeZone = preference; + storage.set(constant.timezone, { appTimezone: state.appTimezone, timeZone: preference }); + }, + SET_TOKEN: (state, token) => { + state.token = token; + }, + SET_ID: (state, id) => { + state.id = id; + storage.set(constant.id, id); + }, + SET_NAME: (state, name) => { + state.name = name; + storage.set(constant.name, name); + }, + SET_AVATAR: (state, avatar) => { + state.avatar = avatar; + storage.set(constant.avatar, avatar); + }, + SET_ROLES: (state, roles) => { + state.roles = roles; + storage.set(constant.roles, roles); + }, + SET_PERMISSIONS: (state, permissions) => { + state.permissions = permissions; + storage.set(constant.permissions, permissions); + }, + }, + + actions: { + ApplyTimezone({ commit }, preference = "auto") { + commit("SET_TIMEZONE", preference); + }, + // 登录 + Login({ commit }, userInfo) { + const username = userInfo.username.trim(); + const password = userInfo.password; + const code = userInfo.code; + const uuid = userInfo.uuid; + return new Promise((resolve, reject) => { + login(username, password, code, uuid) + .then((res) => { + setToken(res.token); + commit("SET_TOKEN", res.token); + resolve(); + }) + .catch((error) => { + reject(error); + }); + }); + }, + + // 获取用户信息 + GetInfo({ commit, state }) { + return new Promise((resolve, reject) => { + getInfo() + .then((res) => { + const user = res.user; + commit("SET_APP_TIMEZONE", res.appTimezone); + commit("SET_TIMEZONE", user.timeZone); + let avatar = user.avatar || ""; + if (!isHttp(avatar)) { + avatar = isEmpty(avatar) ? defAva : baseUrl + avatar; + } + const userid = + isEmpty(user) || isEmpty(user.userId) ? "" : user.userId; + const username = + isEmpty(user) || isEmpty(user.userName) ? "" : user.userName; + if (res.roles && res.roles.length > 0) { + commit("SET_ROLES", res.roles); + commit("SET_PERMISSIONS", res.permissions); + } else { + commit("SET_ROLES", ["ROLE_DEFAULT"]); + } + commit("SET_ID", userid); + commit("SET_NAME", username); + commit("SET_AVATAR", avatar); + resolve(res); + }) + .catch((error) => { + reject(error); + }); + }); + }, + + // 退出系统 + LogOut({ commit, state }) { + return new Promise((resolve, reject) => { + logout(state.token) + .then(() => { + commit("SET_TOKEN", ""); + commit("SET_TIMEZONE", "auto"); + commit("SET_ROLES", []); + commit("SET_PERMISSIONS", []); + removeToken(); + storage.clean(); + resolve(); + }) + .catch((error) => { + reject(error); + }); + }); + }, + }, +}; + +// 恢复已登录账号的偏好,避免冷启动首屏使用错误时区。 +const savedTimezone = storage.get(constant.timezone); +if (user.state.token && savedTimezone) { + try { + user.mutations.SET_APP_TIMEZONE(user.state, savedTimezone.appTimezone); + user.mutations.SET_TIMEZONE(user.state, savedTimezone.timeZone); + } catch { + storage.remove(constant.timezone); + } +} + +export default user; diff --git a/ruoyi-fastapi-app/src/uni.scss b/ruoyi-fastapi-frontend/vue2/mobile/src/uni.scss similarity index 100% rename from ruoyi-fastapi-app/src/uni.scss rename to ruoyi-fastapi-frontend/vue2/mobile/src/uni.scss diff --git a/ruoyi-fastapi-app/src/utils/auth.js b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/auth.js similarity index 95% rename from ruoyi-fastapi-app/src/utils/auth.js rename to ruoyi-fastapi-frontend/vue2/mobile/src/utils/auth.js index f22d9b5..0e5b269 100644 --- a/ruoyi-fastapi-app/src/utils/auth.js +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/auth.js @@ -1,13 +1,13 @@ -const TokenKey = "App-Token"; - -export function getToken() { - return uni.getStorageSync(TokenKey); -} - -export function setToken(token) { - return uni.setStorageSync(TokenKey, token); -} - -export function removeToken() { - return uni.removeStorageSync(TokenKey); -} +const TokenKey = "App-Token"; + +export function getToken() { + return uni.getStorageSync(TokenKey); +} + +export function setToken(token) { + return uni.setStorageSync(TokenKey, token); +} + +export function removeToken() { + return uni.removeStorageSync(TokenKey); +} diff --git a/ruoyi-fastapi-app/src/utils/common.js b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/common.js similarity index 100% rename from ruoyi-fastapi-app/src/utils/common.js rename to ruoyi-fastapi-frontend/vue2/mobile/src/utils/common.js diff --git a/ruoyi-fastapi-app/src/utils/constant.js b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/constant.js similarity index 100% rename from ruoyi-fastapi-app/src/utils/constant.js rename to ruoyi-fastapi-frontend/vue2/mobile/src/utils/constant.js diff --git a/ruoyi-fastapi-app/src/utils/errorCode.js b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/errorCode.js similarity index 97% rename from ruoyi-fastapi-app/src/utils/errorCode.js rename to ruoyi-fastapi-frontend/vue2/mobile/src/utils/errorCode.js index 59c0a01..984fded 100644 --- a/ruoyi-fastapi-app/src/utils/errorCode.js +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/errorCode.js @@ -1,6 +1,6 @@ -export default { - 401: "认证失败,无法访问系统资源", - 403: "当前操作没有权限", - 404: "访问资源不存在", - default: "系统未知错误,请反馈给管理员", -}; +export default { + 401: "认证失败,无法访问系统资源", + 403: "当前操作没有权限", + 404: "访问资源不存在", + default: "系统未知错误,请反馈给管理员", +}; diff --git a/ruoyi-fastapi-app/src/utils/permission.js b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/permission.js similarity index 96% rename from ruoyi-fastapi-app/src/utils/permission.js rename to ruoyi-fastapi-frontend/vue2/mobile/src/utils/permission.js index cd67d99..e30a24c 100644 --- a/ruoyi-fastapi-app/src/utils/permission.js +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/permission.js @@ -1,55 +1,55 @@ -import store from "@/store"; - -/** - * 字符权限校验 - * @param {Array} value 校验值 - * @returns {Boolean} - */ -export function checkPermi(value) { - if (value && value instanceof Array && value.length > 0) { - const permissions = store.getters && store.getters.permissions; - const permissionDatas = value; - const all_permission = "*:*:*"; - - const hasPermission = permissions.some((permission) => { - return ( - all_permission === permission || permissionDatas.includes(permission) - ); - }); - - if (!hasPermission) { - return false; - } - return true; - } else { - console.error( - `need roles! Like checkPermi="['system:user:add','system:user:edit']"`, - ); - return false; - } -} - -/** - * 角色权限校验 - * @param {Array} value 校验值 - * @returns {Boolean} - */ -export function checkRole(value) { - if (value && value instanceof Array && value.length > 0) { - const roles = store.getters && store.getters.roles; - const permissionRoles = value; - const super_admin = "admin"; - - const hasRole = roles.some((role) => { - return super_admin === role || permissionRoles.includes(role); - }); - - if (!hasRole) { - return false; - } - return true; - } else { - console.error(`need roles! Like checkRole="['admin','editor']"`); - return false; - } -} +import store from "@/store"; + +/** + * 字符权限校验 + * @param {Array} value 校验值 + * @returns {Boolean} + */ +export function checkPermi(value) { + if (value && value instanceof Array && value.length > 0) { + const permissions = store.getters && store.getters.permissions; + const permissionDatas = value; + const all_permission = "*:*:*"; + + const hasPermission = permissions.some((permission) => { + return ( + all_permission === permission || permissionDatas.includes(permission) + ); + }); + + if (!hasPermission) { + return false; + } + return true; + } else { + console.error( + `need roles! Like checkPermi="['system:user:add','system:user:edit']"`, + ); + return false; + } +} + +/** + * 角色权限校验 + * @param {Array} value 校验值 + * @returns {Boolean} + */ +export function checkRole(value) { + if (value && value instanceof Array && value.length > 0) { + const roles = store.getters && store.getters.roles; + const permissionRoles = value; + const super_admin = "admin"; + + const hasRole = roles.some((role) => { + return super_admin === role || permissionRoles.includes(role); + }); + + if (!hasRole) { + return false; + } + return true; + } else { + console.error(`need roles! Like checkRole="['admin','editor']"`); + return false; + } +} diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/utils/request.js b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/request.js new file mode 100644 index 0000000..ca2040b --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/request.js @@ -0,0 +1,140 @@ +import { getDisplayTimezone } from "@/utils/time"; +import store from "@/store"; +import config from "@/config"; +import { getToken } from "@/utils/auth"; +import errorCode from "@/utils/errorCode"; +import { toast, showConfirm, tansParams } from "@/utils/common"; +import { + decryptTransportErrorResponse, + decryptTransportResponse, + encryptTransportRequest, + invalidateTransportKeyMeta, + resetTransportRequestConfig, + shouldRetryTransportWithFreshKey, +} from "@/utils/transportCrypto"; + +let timeout = 10000; +const baseUrl = config.baseUrl; + +/** + * 对外暴露的统一请求方法。 + * + * @param {Object} config 请求配置 + * @returns {Promise} 业务响应数据 + */ +const request = async (config) => { + // 是否需要设置 token + const isToken = (config.headers || {}).isToken === false; + config.header = config.header || {}; + config.headers = config.headers || {}; + config.header["X-Timezone"] = config.header["X-Timezone"] || getDisplayTimezone(); + if (getToken() && !isToken) { + config.header["Authorization"] = "Bearer " + getToken(); + } + + try { + // 在参数拼接前完成传输层加密,避免明文查询串提前写入 URL。 + config = await encryptTransportRequest(config); + + // get请求映射params参数 + if (config.params) { + let url = config.url + "?" + tansParams(config.params); + url = url.slice(0, -1); + config.url = url; + } + + return await new Promise((resolve, reject) => { + uni.request({ + method: config.method || "get", + timeout: config.timeout || timeout, + url: config.baseUrl || baseUrl + config.url, + data: config.data, + header: config.header, + dataType: "json", + success: async (response) => { + try { + // 命中传输层加密时,先解密成标准业务响应结构。 + const res = await decryptTransportResponse(response, config); + + // 若当前响应提示密钥失效,则刷新公钥缓存并基于原始请求重试一次。 + if (shouldRetryTransportWithFreshKey(res) && !config.__transportRetried) { + invalidateTransportKeyMeta(); + config.__transportRetried = true; + config.headers.repeatSubmit = false; + resetTransportRequestConfig(config); + resolve(await request(config)); + return; + } + + const code = res.data.code || 200; + const msg = errorCode[code] || res.data.msg || errorCode["default"]; + if (code === 401) { + showConfirm( + "登录状态已过期,您可以继续留在该页面,或者重新登录?", + ).then((res) => { + if (res.confirm) { + store.dispatch("LogOut").then(() => { + uni.reLaunch({ url: "/pages/login" }); + }); + } + }); + const error = new Error("无效的会话,或者会话已过期,请重新登录。"); + error.response = res; + reject(error); + } else if (code === 500) { + const error = new Error(msg); + error.response = res; + reject(error); + } else if (code !== 200) { + const error = new Error(msg); + error.response = res; + reject(error); + } else { + resolve(res.data); + } + } catch (error) { + reject(error); + } + }, + fail: reject, + }); + }); + } catch (error) { + error = await decryptTransportErrorResponse(error, config); + if (shouldRetryTransportWithFreshKey(error) && !config.__transportRetried) { + invalidateTransportKeyMeta(); + config.__transportRetried = true; + config.headers.repeatSubmit = false; + resetTransportRequestConfig(config); + return request(config); + } + + const response = error.response; + const responseStatus = response?.status ?? response?.statusCode; + const responseCode = response?.data?.code; + const responseMsg = response?.data?.msg; + if (responseMsg) { + uni.showToast({ + title: responseMsg, + icon: "none", + duration: responseStatus === 429 || responseCode === 429 ? 5000 : 3000, + }); + throw error; + } + + let { message } = error; + if (message === "Network Error") { + message = "后端接口连接异常"; + } else if (message && message.includes("timeout")) { + message = "系统接口请求超时"; + } else if (message && message.includes("Request failed with status code")) { + message = "系统接口" + message.substr(message.length - 3) + "异常"; + } + if (message) { + toast(message); + } + throw error; + } +}; + +export default request; diff --git a/ruoyi-fastapi-app/src/utils/storage.js b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/storage.js similarity index 100% rename from ruoyi-fastapi-app/src/utils/storage.js rename to ruoyi-fastapi-frontend/vue2/mobile/src/utils/storage.js diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/utils/time.js b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/time.js new file mode 100644 index 0000000..e170874 --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/time.js @@ -0,0 +1,535 @@ +// 补齐小程序运行时可能缺失的 Intl 能力和 IANA 时区数据。 +// 同步加载,保证业务时区初始化和首屏渲染时已具备完整能力。 +import '@formatjs/intl-getcanonicallocales/polyfill.js' +import '@formatjs/intl-locale/polyfill.js' +import '@formatjs/intl-pluralrules/polyfill.js' +import '@formatjs/intl-pluralrules/locale-data/en.js' +import '@formatjs/intl-numberformat/polyfill.js' +import '@formatjs/intl-numberformat/locale-data/en.js' +import '@formatjs/intl-datetimeformat/polyfill.js' +import '@formatjs/intl-datetimeformat/locale-data/en.js' +import '@formatjs/intl-datetimeformat/add-all-tz.js' + +import Vue from 'vue' +import dayjs from 'dayjs' +import utc from 'dayjs/plugin/utc.js' + +dayjs.extend(utc) + +const DATE_ONLY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/ +const WALL_TIME_PATTERN = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?$/ +const RFC3339_PATTERN = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(\.\d+)?(Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/ +const WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六'] +const MILLISECONDS_PER_SECOND = 1000 +const MILLISECONDS_PER_MINUTE = 60 * MILLISECONDS_PER_SECOND +const MILLISECONDS_PER_HOUR = 60 * MILLISECONDS_PER_MINUTE +const ORIGINAL_TIME_FIELDS = Symbol('originalTimeFields') +const timezoneFormatters = new Map() + +const businessTimezone = Vue.observable({ value: 'Asia/Shanghai' }) +const userTimezone = Vue.observable({ value: 'auto' }) +const deviceTimezone = Vue.observable({ value: null }) + +/** + * 日期时间输入校验异常,包含夏令时重复时间的候选值。 + */ +export class TimeInputError extends Error { + /** + * @param {string} message 校验提示 + * @param {string} code 错误类型 + * @param {Array<{epoch: number, offset: string, value: string}>} candidates 可选的真实时刻 + */ + constructor(message, code, candidates = []) { + super(message) + this.code = code + this.candidates = candidates + } +} + +/** + * 获取并缓存指定时区的日期时间格式化器。 + * + * @param {string} timezoneName IANA 时区名称 + * @returns {Intl.DateTimeFormat} 格式化器实例 + */ +function getFormatter(timezoneName) { + if (typeof timezoneName !== 'string' || !timezoneName.trim() || /^[+-]/.test(timezoneName)) { + throw new Error(`无效的 IANA 时区: ${timezoneName}`) + } + + const name = timezoneName.trim() + if (!timezoneFormatters.has(name)) { + try { + const formatter = new Intl.DateTimeFormat('en', { + timeZone: name, + calendar: 'gregory', + numberingSystem: 'latn', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23' + }) + timezoneFormatters.set(name, formatter) + } catch { + throw new Error(`无效的 IANA 时区: ${timezoneName}`) + } + } + return timezoneFormatters.get(name) +} + +/** + * 更新默认业务时区。 + * + * @param {string} timezoneName 服务端返回的 IANA 时区名称 + * @returns {void} + */ +export function setBusinessTimezone(timezoneName) { + getFormatter(timezoneName) + businessTimezone.value = timezoneName.trim() +} + +/** + * 获取当前业务时区。 + * + * @returns {string} IANA 时区名称 + */ +export function getBusinessTimezone() { + return businessTimezone.value +} + +/** + * 设置账号显示偏好。auto 跟随设备;IANA 名称表示手动选择。 + * + * @param {string} preference 账号保存的时区偏好 + * @returns {void} + */ +export function setUserTimezone(preference = 'auto') { + if (preference !== 'auto') { + getFormatter(preference) + } + userTimezone.value = preference.trim() + refreshDeviceTimezone() +} + +/** + * 获取账号时区偏好。 + * + * @returns {string} auto 或 IANA 时区名称 + */ +export function getUserTimezone() { + return userTimezone.value +} + +/** + * 重新识别设备的 IANA 时区。不能从单个 UTC 偏移猜测地区或夏令时规则。 + * + * @returns {string|null} 设备时区;当前运行时不支持识别时返回 null + */ +export function refreshDeviceTimezone() { + try { + // FormatJS 默认时区是 UTC,不能把它误报为设备时区。 + if (Intl.DateTimeFormat.polyfilled) { + deviceTimezone.value = null + return null + } + const name = new Intl.DateTimeFormat().resolvedOptions().timeZone + getFormatter(name) + deviceTimezone.value = name + } catch { + deviceTimezone.value = null + } + return deviceTimezone.value +} + +/** + * 获取最近一次识别的设备时区。 + * + * @returns {string|null} IANA 时区;无法识别时返回 null + */ +export function getDeviceTimezone() { + return deviceTimezone.value +} + +/** + * 获取页面展示、日期输入和普通日期筛选使用的有效时区。 + * + * @returns {string} IANA 时区名称 + */ +export function getDisplayTimezone() { + return userTimezone.value === 'auto' ? deviceTimezone.value || businessTimezone.value : userTimezone.value +} + +/** + * 获取已打开表单的时区,确保控件提示与提交转换一致。 + * + * @param {Object} record 经 prepareTimeFields 准备的表单或子表行 + * @returns {string} 表单绑定的时区 + */ +export function getTimeFieldsTimezone(record) { + return record?.[ORIGINAL_TIME_FIELDS]?.timezoneName || getDisplayTimezone() +} + +/** + * 过滤当前运行时不支持的时区,保留设备时区和已保存的有效选择。 + * + * @param {string[]} names 服务端提供的 IANA 名称 + * @returns {string[]} 按名称排序的可选时区 + */ +export function getSupportedTimezones(names = []) { + return [...new Set([...names, getDisplayTimezone(), getBusinessTimezone(), 'UTC'])] + .filter(name => { + try { + getFormatter(name) + return true + } catch { + return false + } + }) + .sort() +} + +/** + * 校验当地日期时间,并将各时间分量放入 UTC 毫秒坐标以便计算。 + * + * @param {string} value 不含时区的日期或日期时间字符串 + * @param {boolean} dateOnly 是否仅校验纯日期 + * @returns {number|null} 用于日历计算的毫秒值,非法输入返回 null + */ +function parseWallTimeMilliseconds(value, dateOnly = false) { + const match = typeof value === 'string' && value.match(dateOnly ? DATE_ONLY_PATTERN : WALL_TIME_PATTERN) + if (!match) { + return null + } + + const [year, month, day, hour = 0, minute = 0, second = 0] = match.slice(1, 7).map(Number) + const millisecond = Number((match[7] || '').padEnd(3, '0')) + const date = new Date(0) + date.setUTCFullYear(year, month - 1, day) + date.setUTCHours(hour, minute, second, millisecond) + + // Date 会自动进位非法日期,逐项回读以拒绝越界输入。 + if ( + year < 1 || + date.getUTCFullYear() !== year || + date.getUTCMonth() !== month - 1 || + date.getUTCDate() !== day || + date.getUTCHours() !== hour || + date.getUTCMinutes() !== minute || + date.getUTCSeconds() !== second + ) { + return null + } + return date.getTime() +} + +/** + * 解析带偏移的真实时刻,拒绝无时区的日期时间。 + * + * @param {string|Date} value RFC 3339 字符串或 Date 对象 + * @returns {number|null} Unix 毫秒时间戳,非法输入返回 null + */ +function parseInstantMilliseconds(value) { + if (value instanceof Date) { + return Number.isFinite(value.getTime()) ? value.getTime() : null + } + + const match = typeof value === 'string' && value.match(RFC3339_PATTERN) + if (!match || parseWallTimeMilliseconds(match[1]) === null) { + return null + } + + const result = Date.parse(`${match[1]}${(match[2] || '.000').slice(0, 4)}${match[3]}`) + return Number.isFinite(result) ? result : null +} + +/** + * 计算指定时刻在目标时区的 UTC 偏移。 + * + * @param {number} epoch Unix 毫秒时间戳 + * @param {string} timezoneName IANA 时区名称 + * @returns {number} 相对 UTC 的偏移分钟数 + */ +function getTimezoneOffsetMinutes(epoch, timezoneName) { + const parts = Object.fromEntries( + getFormatter(timezoneName) + .formatToParts(epoch) + .map(part => [part.type, part.value]) + ) + const wallTime = `${parts.year.padStart(4, '0')}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}:${parts.second}` + const instantMilliseconds = Math.floor(epoch / MILLISECONDS_PER_SECOND) * MILLISECONDS_PER_SECOND + return (parseWallTimeMilliseconds(wallTime) - instantMilliseconds) / MILLISECONDS_PER_MINUTE +} + +/** + * 按显式偏移创建业务时间,避免经过宿主机本地时区转换。 + * + * @param {number} epoch Unix 毫秒时间戳 + * @param {string} timezoneName IANA 时区名称 + * @returns {import('dayjs').Dayjs} 目标时区下的日期时间 + */ +function createZonedDateTime(epoch, timezoneName) { + return dayjs.utc(epoch).utcOffset(getTimezoneOffsetMinutes(epoch, timezoneName)) +} + +/** + * 格式化毫秒时间戳,兼容项目原有的日期格式占位符。 + * + * @param {number} epoch Unix 毫秒时间戳 + * @param {string} pattern Day.js 格式或项目日期格式占位符 + * @param {string} timezoneName IANA 时区名称 + * @returns {string} 业务时间展示文本 + */ +function formatEpoch(epoch, pattern, timezoneName) { + const value = createZonedDateTime(epoch, timezoneName) + const format = pattern + .replaceAll('{y}', 'YYYY') + .replaceAll('{m}', 'MM') + .replaceAll('{d}', 'DD') + .replaceAll('{h}', 'HH') + .replaceAll('{i}', 'mm') + .replaceAll('{s}', 'ss') + .replaceAll('{a}', `[${WEEKDAYS[value.day()]}]`) + return value.format(format) +} + +/** + * 将真实时刻格式化为业务时间。 + * + * @param {string|Date} value RFC 3339 字符串或 Date 对象 + * @param {string} pattern 展示格式 + * @param {string} timezoneName 目标时区,默认使用当前用户展示时区 + * @returns {string|null} 展示文本,非法输入返回 null + */ +export function formatBusinessTime(value, pattern = 'YYYY-MM-DD HH:mm:ss', timezoneName = getDisplayTimezone()) { + const epoch = parseInstantMilliseconds(value) + return epoch === null ? null : formatEpoch(epoch, pattern, timezoneName) +} + +/** + * 格式化明确以毫秒为单位的 Unix 时间戳。 + * + * @param {number} value Unix 毫秒时间戳 + * @param {string} pattern 展示格式 + * @param {string} timezoneName 目标时区,默认使用当前用户展示时区 + * @returns {string|null} 展示文本,非法输入返回 null + */ +export function formatEpochMilliseconds(value, pattern = 'YYYY-MM-DD HH:mm:ss', timezoneName = getDisplayTimezone()) { + if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isFinite(new Date(value).getTime())) { + return null + } + return formatEpoch(value, pattern, timezoneName) +} + +/** + * 格式化明确以秒为单位的 Unix 时间戳。 + * + * @param {number} value Unix 秒时间戳 + * @param {string} pattern 展示格式 + * @param {string} timezoneName 目标时区,默认使用当前用户展示时区 + * @returns {string|null} 展示文本,非法输入返回 null + */ +export function formatEpochSeconds(value, pattern, timezoneName) { + return typeof value === 'number' + ? formatEpochMilliseconds(value * MILLISECONDS_PER_SECOND, pattern, timezoneName) + : null +} + +/** + * 查找业务当地时间对应的真实时刻候选值。 + * + * @param {string} value 不含偏移的业务日期时间 + * @param {string} timezoneName IANA 时区名称 + * @returns {Array<{epoch: number, offset: string, value: string}>} 按时刻排序的候选值 + */ +export function getWallTimeCandidates(value, timezoneName = getDisplayTimezone()) { + const wallMilliseconds = parseWallTimeMilliseconds(value) + if (wallMilliseconds === null) { + throw new TimeInputError(`无效的日期时间: ${value}`, 'INVALID_TIME') + } + + const offsets = new Set() + // 采样跳转前后的偏移,覆盖半小时夏令时和跨日期变更。 + for (let hours = -48; hours <= 48; hours += 6) { + offsets.add(getTimezoneOffsetMinutes(wallMilliseconds + hours * MILLISECONDS_PER_HOUR, timezoneName)) + } + + return [...offsets] + .map(offset => ({ offset, epoch: wallMilliseconds - offset * MILLISECONDS_PER_MINUTE })) + .filter(candidate => getTimezoneOffsetMinutes(candidate.epoch, timezoneName) === candidate.offset) + .sort((first, second) => first.epoch - second.epoch) + .map(candidate => ({ + epoch: candidate.epoch, + offset: createZonedDateTime(candidate.epoch, timezoneName).format('Z'), + value: createZonedDateTime(candidate.epoch, timezoneName).format('YYYY-MM-DDTHH:mm:ss.SSSZ') + })) +} + +/** + * 将真实时刻或业务当地时间转换为毫秒精度 RFC 3339 字符串。 + * + * @param {string|Date} value 原始时刻或业务日期时间 + * @param {Object} options 时区和重复时间的偏移选择 + * @param {string} options.timezoneName 业务 IANA 时区名称 + * @param {string} options.offset 夏令时重复时间使用的 UTC 偏移 + * @returns {string|undefined} RFC 3339 字符串,空输入返回 undefined + * @throws {TimeInputError} 当地时间不存在或重复时间尚未选择偏移时抛出异常 + */ +export function toRfc3339(value, { timezoneName = getDisplayTimezone(), offset } = {}) { + if (value === undefined || value === null || value === '') { + return undefined + } + + const epoch = parseInstantMilliseconds(value) + if (epoch !== null) { + if (value instanceof Date) { + return value.toISOString() + } + const match = value.match(RFC3339_PATTERN) + return `${match[1]}.${(match[2]?.slice(1) || '').padEnd(3, '0').slice(0, 3)}${match[3]}` + } + + const candidates = getWallTimeCandidates(value, timezoneName) + if (!candidates.length) { + throw new TimeInputError(`${value} 在 ${timezoneName} 不存在,请选择夏令时跳转后的有效时间`, 'DST_GAP') + } + if (candidates.length === 1) { + return candidates[0].value + } + + const selected = candidates.find(candidate => candidate.offset === offset) + if (selected) { + return selected.value + } + throw new TimeInputError(`${value} 在 ${timezoneName} 出现两次,请选择 UTC 偏移`, 'DST_FOLD', candidates) +} + +/** + * 将真实时刻转换为日期时间输入控件使用的业务时间。 + * + * @param {string|Date} value 原始真实时刻 + * @param {string} timezoneName 业务 IANA 时区名称 + * @returns {string|null} 不含偏移的日期时间,非法输入返回 null + */ +export function toBusinessDateTimeInput(value, timezoneName = getDisplayTimezone()) { + return formatBusinessTime(value, 'YYYY-MM-DD HH:mm:ss', timezoneName) +} + +/** + * 按字段语义规范化日期或精确时刻查询边界。 + * + * @param {string|Date} value 日期或真实时刻 + * @returns {string|undefined} 纯日期或 RFC 3339 字符串,空输入返回 undefined + */ +export function normalizeRangeBoundary(value) { + if (value === undefined || value === null || value === '') { + return undefined + } + if (typeof value === 'string' && DATE_ONLY_PATTERN.test(value)) { + if (parseWallTimeMilliseconds(value, true) === null) { + throw new TimeInputError(`无效的日期: ${value}`, 'INVALID_DATE') + } + return value + } + return toRfc3339(value) +} + +/** + * 创建可编辑的业务时间字段,并保存原始时刻供无修改提交时使用。 + * + * @param {Object} record API 返回的原始记录 + * @param {string[]} fields 需要转换的真实时刻字段 + * @param {string} timezoneName 业务 IANA 时区名称 + * @returns {Object} 包含业务时间和原值快照的新记录 + */ +export function prepareTimeFields(record, fields, timezoneName = getDisplayTimezone()) { + const result = { ...record } + const originals = {} + for (const field of fields) { + if (!(field in record)) { + continue + } + + const original = record[field] + const input = original == null || original === '' ? original : toBusinessDateTimeInput(original, timezoneName) + if (input === null && original != null) { + throw new TimeInputError(`字段 ${field} 包含无效的 RFC 3339 时间`, 'INVALID_TIME') + } + result[field] = input + originals[field] = { original, input } + } + result[ORIGINAL_TIME_FIELDS] = { originals, timezoneName } + return result +} + +/** + * 构造提交记录,保留未修改字段的原始毫秒和偏移。 + * + * @param {Object} record 当前编辑记录 + * @param {string[]} fields 需要转换的真实时刻字段 + * @param {Object} offsets 各重复时间字段选定的 UTC 偏移 + * @returns {Object} 可提交的新记录,不修改当前表单 + */ +export function serializeTimeFields(record, fields, offsets = {}) { + const result = { ...record } + const state = record[ORIGINAL_TIME_FIELDS] + delete result[ORIGINAL_TIME_FIELDS] + + for (const field of fields) { + if (!(field in record)) { + continue + } + + const value = record[field] + const original = state?.originals[field] + if (original && value === original.input) { + result[field] = original.original + } else if (value == null) { + result[field] = value + } else if (value === '') { + result[field] = null + } else { + try { + result[field] = toRfc3339(value, { + timezoneName: state?.timezoneName || getDisplayTimezone(), + offset: offsets[field] + }) + } catch (error) { + error.field = field + throw error + } + } + } + return result +} + +/** + * 逐项处理重复时间的偏移选择,再生成可提交记录。 + * + * @param {Object} record 当前编辑记录 + * @param {string[]} fields 需要转换的真实时刻字段 + * @param {function(TimeInputError): Promise} chooseOffset 请求用户选择偏移的回调 + * @returns {Promise} 已解决重复时间歧义的提交记录 + */ +export async function resolveTimeFields(record, fields, chooseOffset) { + const offsets = {} + while (true) { + try { + return serializeTimeFields(record, fields, offsets) + } catch (error) { + if (error.code !== 'DST_FOLD') { + throw error + } + const offset = await chooseOffset(error) + if (!error.candidates.some(candidate => candidate.offset === offset)) { + throw error + } + offsets[error.field] = offset + } + } +} + +setBusinessTimezone(businessTimezone.value) +refreshDeviceTimezone() diff --git a/ruoyi-fastapi-app/src/utils/transportCrypto.js b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/transportCrypto.js similarity index 100% rename from ruoyi-fastapi-app/src/utils/transportCrypto.js rename to ruoyi-fastapi-frontend/vue2/mobile/src/utils/transportCrypto.js diff --git a/ruoyi-fastapi-app/src/utils/transportCryptoPolicy.js b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/transportCryptoPolicy.js similarity index 100% rename from ruoyi-fastapi-app/src/utils/transportCryptoPolicy.js rename to ruoyi-fastapi-frontend/vue2/mobile/src/utils/transportCryptoPolicy.js diff --git a/ruoyi-fastapi-app/src/utils/transportForge.js b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/transportForge.js similarity index 100% rename from ruoyi-fastapi-app/src/utils/transportForge.js rename to ruoyi-fastapi-frontend/vue2/mobile/src/utils/transportForge.js diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/utils/upload.js b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/upload.js new file mode 100644 index 0000000..d35df23 --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/upload.js @@ -0,0 +1,72 @@ +import store from "@/store"; +import config from "@/config"; +import { getToken } from "@/utils/auth"; +import errorCode from "@/utils/errorCode"; +import { toast, showConfirm, tansParams } from "@/utils/common"; + +let timeout = 10000; +const baseUrl = config.baseUrl; + +const upload = (config) => { + // 是否需要设置 token + const isToken = (config.headers || {}).isToken === false; + config.header = config.header || {}; + if (getToken() && !isToken) { + config.header["Authorization"] = "Bearer " + getToken(); + } + // get请求映射params参数 + if (config.params) { + let url = config.url + "?" + tansParams(config.params); + url = url.slice(0, -1); + config.url = url; + } + return new Promise((resolve, reject) => { + uni.uploadFile({ + timeout: config.timeout || timeout, + url: baseUrl + config.url, + filePath: config.filePath, + name: config.name || "file", + header: config.header, + formData: config.formData, + success: (res) => { + let result = JSON.parse(res.data); + const code = result.code || 200; + const msg = errorCode[code] || result.msg || errorCode["default"]; + if (code === 200) { + resolve(result); + } else if (code == 401) { + showConfirm( + "登录状态已过期,您可以继续留在该页面,或者重新登录?", + ).then((res) => { + if (res.confirm) { + store.dispatch("LogOut").then((res) => { + uni.reLaunch({ url: "/pages/login/login" }); + }); + } + }); + reject("无效的会话,或者会话已过期,请重新登录。"); + } else if (code === 500) { + toast(msg); + reject("500"); + } else if (code !== 200) { + toast(msg); + reject(code); + } + }, + fail: (error) => { + let { message } = error; + if (message == "Network Error") { + message = "后端接口连接异常"; + } else if (message.includes("timeout")) { + message = "系统接口请求超时"; + } else if (message.includes("Request failed with status code")) { + message = "系统接口" + message.substr(message.length - 3) + "异常"; + } + toast(message); + reject(error); + }, + }); + }); +}; + +export default upload; diff --git a/ruoyi-fastapi-app/src/utils/validate.js b/ruoyi-fastapi-frontend/vue2/mobile/src/utils/validate.js similarity index 100% rename from ruoyi-fastapi-app/src/utils/validate.js rename to ruoyi-fastapi-frontend/vue2/mobile/src/utils/validate.js diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/vendor/node-forge/lib/prng.js b/ruoyi-fastapi-frontend/vue2/mobile/src/vendor/node-forge/lib/prng.js new file mode 100644 index 0000000..89836a1 --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/vendor/node-forge/lib/prng.js @@ -0,0 +1,421 @@ +/** + * A javascript implementation of a cryptographically-secure + * Pseudo Random Number Generator (PRNG). The Fortuna algorithm is followed + * here though the use of SHA-256 is not enforced; when generating an + * a PRNG context, the hashing algorithm and block cipher used for + * the generator are specified via a plugin. + * + * @author Dave Longley + * + * Copyright (c) 2010-2014 Digital Bazaar, Inc. + */ +var forge = require('node-forge/lib/forge'); +require('node-forge/lib/util'); + +var _crypto = null; +if(forge.util.isNodejs && !forge.options.usePureJavaScript && + !process.versions['node-webkit']) { + try { + _crypto = eval('require')('crypto'); + } catch(e) {} +} + +/* PRNG API */ +var prng = module.exports = forge.prng = forge.prng || {}; + +/** + * Creates a new PRNG context. + * + * A PRNG plugin must be passed in that will provide: + * + * 1. A function that initializes the key and seed of a PRNG context. It + * will be given a 16 byte key and a 16 byte seed. Any key expansion + * or transformation of the seed from a byte string into an array of + * integers (or similar) should be performed. + * 2. The cryptographic function used by the generator. It takes a key and + * a seed. + * 3. A seed increment function. It takes the seed and returns seed + 1. + * 4. An api to create a message digest. + * + * For an example, see random.js. + * + * @param plugin the PRNG plugin to use. + */ +prng.create = function(plugin) { + var ctx = { + plugin: plugin, + key: null, + seed: null, + time: null, + // number of reseeds so far + reseeds: 0, + // amount of data generated so far + generated: 0, + // no initial key bytes + keyBytes: '' + }; + + // create 32 entropy pools (each is a message digest) + var md = plugin.md; + var pools = new Array(32); + for(var i = 0; i < 32; ++i) { + pools[i] = md.create(); + } + ctx.pools = pools; + + // entropy pools are written to cyclically, starting at index 0 + ctx.pool = 0; + + /** + * Generates random bytes. The bytes may be generated synchronously or + * asynchronously. Web workers must use the asynchronous interface or + * else the behavior is undefined. + * + * @param count the number of random bytes to generate. + * @param [callback(err, bytes)] called once the operation completes. + * + * @return count random bytes as a string. + */ + ctx.generate = function(count, callback) { + // do synchronously + if(!callback) { + return ctx.generateSync(count); + } + + // simple generator using counter-based CBC + var cipher = ctx.plugin.cipher; + var increment = ctx.plugin.increment; + var formatKey = ctx.plugin.formatKey; + var formatSeed = ctx.plugin.formatSeed; + var b = forge.util.createBuffer(); + + // paranoid deviation from Fortuna: + // reset key for every request to protect previously + // generated random bytes should the key be discovered; + // there is no 100ms based reseeding because of this + // forced reseed for every `generate` call + ctx.key = null; + + generate(); + + function generate(err) { + if(err) { + return callback(err); + } + + // sufficient bytes generated + if(b.length() >= count) { + return callback(null, b.getBytes(count)); + } + + // if amount of data generated is greater than 1 MiB, trigger reseed + if(ctx.generated > 0xfffff) { + ctx.key = null; + } + + if(ctx.key === null) { + // prevent stack overflow + return forge.util.nextTick(function() { + _reseed(generate); + }); + } + + // generate the random bytes + var bytes = cipher(ctx.key, ctx.seed); + ctx.generated += bytes.length; + b.putBytes(bytes); + + // generate bytes for a new key and seed + ctx.key = formatKey(cipher(ctx.key, increment(ctx.seed))); + ctx.seed = formatSeed(cipher(ctx.key, ctx.seed)); + + forge.util.setImmediate(generate); + } + }; + + /** + * Generates random bytes synchronously. + * + * @param count the number of random bytes to generate. + * + * @return count random bytes as a string. + */ + ctx.generateSync = function(count) { + // simple generator using counter-based CBC + var cipher = ctx.plugin.cipher; + var increment = ctx.plugin.increment; + var formatKey = ctx.plugin.formatKey; + var formatSeed = ctx.plugin.formatSeed; + + // paranoid deviation from Fortuna: + // reset key for every request to protect previously + // generated random bytes should the key be discovered; + // there is no 100ms based reseeding because of this + // forced reseed for every `generateSync` call + ctx.key = null; + + var b = forge.util.createBuffer(); + while(b.length() < count) { + // if amount of data generated is greater than 1 MiB, trigger reseed + if(ctx.generated > 0xfffff) { + ctx.key = null; + } + + if(ctx.key === null) { + _reseedSync(); + } + + // generate the random bytes + var bytes = cipher(ctx.key, ctx.seed); + ctx.generated += bytes.length; + b.putBytes(bytes); + + // generate bytes for a new key and seed + ctx.key = formatKey(cipher(ctx.key, increment(ctx.seed))); + ctx.seed = formatSeed(cipher(ctx.key, ctx.seed)); + } + + return b.getBytes(count); + }; + + /** + * Private function that asynchronously reseeds a generator. + * + * @param callback(err) called once the operation completes. + */ + function _reseed(callback) { + if(ctx.pools[0].messageLength >= 32) { + _seed(); + return callback(); + } + // not enough seed data... + var needed = (32 - ctx.pools[0].messageLength) << 5; + ctx.seedFile(needed, function(err, bytes) { + if(err) { + return callback(err); + } + ctx.collect(bytes); + _seed(); + callback(); + }); + } + + /** + * Private function that synchronously reseeds a generator. + */ + function _reseedSync() { + if(ctx.pools[0].messageLength >= 32) { + return _seed(); + } + // not enough seed data... + var needed = (32 - ctx.pools[0].messageLength) << 5; + ctx.collect(ctx.seedFileSync(needed)); + _seed(); + } + + /** + * Private function that seeds a generator once enough bytes are available. + */ + function _seed() { + // update reseed count + ctx.reseeds = (ctx.reseeds === 0xffffffff) ? 0 : ctx.reseeds + 1; + + // goal is to update `key` via: + // key = hash(key + s) + // where 's' is all collected entropy from selected pools, then... + + // create a plugin-based message digest + var md = ctx.plugin.md.create(); + + // consume current key bytes + md.update(ctx.keyBytes); + + // digest the entropy of pools whose index k meet the + // condition 'n mod 2^k == 0' where n is the number of reseeds + var _2powK = 1; + for(var k = 0; k < 32; ++k) { + if(ctx.reseeds % _2powK === 0) { + md.update(ctx.pools[k].digest().getBytes()); + ctx.pools[k].start(); + } + _2powK = _2powK << 1; + } + + // get digest for key bytes + ctx.keyBytes = md.digest().getBytes(); + + // paranoid deviation from Fortuna: + // update `seed` via `seed = hash(key)` + // instead of initializing to zero once and only + // ever incrementing it + md.start(); + md.update(ctx.keyBytes); + var seedBytes = md.digest().getBytes(); + + // update state + ctx.key = ctx.plugin.formatKey(ctx.keyBytes); + ctx.seed = ctx.plugin.formatSeed(seedBytes); + ctx.generated = 0; + } + + /** + * The built-in default seedFile. This seedFile is used when entropy + * is needed immediately. + * + * @param needed the number of bytes that are needed. + * + * @return the random bytes. + */ + function defaultSeedFile(needed) { + // use window.crypto.getRandomValues strong source of entropy if available + var getRandomValues = null; + var globalScope = forge.util.globalScope || {}; + var _crypto = (globalScope && (globalScope.crypto || globalScope.msCrypto)) || null; + if(_crypto && _crypto.getRandomValues) { + getRandomValues = function(arr) { + return _crypto.getRandomValues(arr); + }; + } + + var b = forge.util.createBuffer(); + if(getRandomValues) { + while(b.length() < needed) { + // max byte length is 65536 before QuotaExceededError is thrown + // http://www.w3.org/TR/WebCryptoAPI/#RandomSource-method-getRandomValues + var count = Math.max(1, Math.min(needed - b.length(), 65536) / 4); + var entropy = new Uint32Array(Math.floor(count)); + try { + getRandomValues(entropy); + for(var i = 0; i < entropy.length; ++i) { + b.putInt32(entropy[i]); + } + } catch(e) { + /* only ignore QuotaExceededError */ + if(!(typeof QuotaExceededError !== 'undefined' && + e instanceof QuotaExceededError)) { + throw e; + } + } + } + } + + // be sad and add some weak random data + if(b.length() < needed) { + /* Draws from Park-Miller "minimal standard" 31 bit PRNG, + implemented with David G. Carta's optimization: with 32 bit math + and without division (Public Domain). */ + var hi, lo, next; + var seed = Math.floor(Math.random() * 0x010000); + while(b.length() < needed) { + lo = 16807 * (seed & 0xFFFF); + hi = 16807 * (seed >> 16); + lo += (hi & 0x7FFF) << 16; + lo += hi >> 15; + lo = (lo & 0x7FFFFFFF) + (lo >> 31); + seed = lo & 0xFFFFFFFF; + + // consume lower 3 bytes of seed + for(var i = 0; i < 3; ++i) { + // throw in more pseudo random + next = seed >>> (i << 3); + next ^= Math.floor(Math.random() * 0x0100); + b.putByte(next & 0xFF); + } + } + } + + return b.getBytes(needed); + } + // initialize seed file APIs + if(_crypto) { + // use nodejs async API + ctx.seedFile = function(needed, callback) { + _crypto.randomBytes(needed, function(err, bytes) { + if(err) { + return callback(err); + } + callback(null, bytes.toString()); + }); + }; + // use nodejs sync API + ctx.seedFileSync = function(needed) { + return _crypto.randomBytes(needed).toString(); + }; + } else { + ctx.seedFile = function(needed, callback) { + try { + callback(null, defaultSeedFile(needed)); + } catch(e) { + callback(e); + } + }; + ctx.seedFileSync = defaultSeedFile; + } + + /** + * Adds entropy to a prng ctx's accumulator. + * + * @param bytes the bytes of entropy as a string. + */ + ctx.collect = function(bytes) { + // iterate over pools distributing entropy cyclically + var count = bytes.length; + for(var i = 0; i < count; ++i) { + ctx.pools[ctx.pool].update(bytes.substr(i, 1)); + ctx.pool = (ctx.pool === 31) ? 0 : ctx.pool + 1; + } + }; + + /** + * Collects an integer of n bits. + * + * @param i the integer entropy. + * @param n the number of bits in the integer. + */ + ctx.collectInt = function(i, n) { + var bytes = ''; + for(var x = 0; x < n; x += 8) { + bytes += String.fromCharCode((i >> x) & 0xFF); + } + ctx.collect(bytes); + }; + + /** + * Registers a Web Worker to receive immediate entropy from the main thread. + * This method is required until Web Workers can access the native crypto + * API. This method should be called twice for each created worker, once in + * the main thread, and once in the worker itself. + * + * @param worker the worker to register. + */ + ctx.registerWorker = function(worker) { + // worker receives random bytes + if(worker === self) { + ctx.seedFile = function(needed, callback) { + function listener(e) { + var data = e.data; + if(data.forge && data.forge.prng) { + self.removeEventListener('message', listener); + callback(data.forge.prng.err, data.forge.prng.bytes); + } + } + self.addEventListener('message', listener); + self.postMessage({forge: {prng: {needed: needed}}}); + }; + } else { + // main thread sends random bytes upon request + var listener = function(e) { + var data = e.data; + if(data.forge && data.forge.prng) { + ctx.seedFile(data.forge.prng.needed, function(err, bytes) { + worker.postMessage({forge: {prng: {err: err, bytes: bytes}}}); + }); + } + }; + // TODO: do we need to remove the event listener when the worker dies? + worker.addEventListener('message', listener); + } + }; + + return ctx; +}; diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/vendor/node-forge/lib/random.js b/ruoyi-fastapi-frontend/vue2/mobile/src/vendor/node-forge/lib/random.js new file mode 100644 index 0000000..516684e --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/vendor/node-forge/lib/random.js @@ -0,0 +1,191 @@ +/** + * An API for getting cryptographically-secure random bytes. The bytes are + * generated using the Fortuna algorithm devised by Bruce Schneier and + * Niels Ferguson. + * + * Getting strong random bytes is not yet easy to do in javascript. The only + * truish random entropy that can be collected is from the mouse, keyboard, or + * from timing with respect to page loads, etc. This generator makes a poor + * attempt at providing random bytes when those sources haven't yet provided + * enough entropy to initially seed or to reseed the PRNG. + * + * @author Dave Longley + * + * Copyright (c) 2009-2014 Digital Bazaar, Inc. + */ +var forge = require('node-forge/lib/forge'); +require('node-forge/lib/aes'); +require('node-forge/lib/sha256'); +require('node-forge/lib/prng'); +require('node-forge/lib/util'); + +(function() { + +// forge.random already defined +if(forge.random && forge.random.getBytes) { + module.exports = forge.random; + return; +} + +(function(jQuery) { + +// the default prng plugin, uses AES-128 +var prng_aes = {}; +var _prng_aes_output = new Array(4); +var _prng_aes_buffer = forge.util.createBuffer(); +prng_aes.formatKey = function(key) { + // convert the key into 32-bit integers + var tmp = forge.util.createBuffer(key); + key = new Array(4); + key[0] = tmp.getInt32(); + key[1] = tmp.getInt32(); + key[2] = tmp.getInt32(); + key[3] = tmp.getInt32(); + + // return the expanded key + return forge.aes._expandKey(key, false); +}; +prng_aes.formatSeed = function(seed) { + // convert seed into 32-bit integers + var tmp = forge.util.createBuffer(seed); + seed = new Array(4); + seed[0] = tmp.getInt32(); + seed[1] = tmp.getInt32(); + seed[2] = tmp.getInt32(); + seed[3] = tmp.getInt32(); + return seed; +}; +prng_aes.cipher = function(key, seed) { + forge.aes._updateBlock(key, seed, _prng_aes_output, false); + _prng_aes_buffer.putInt32(_prng_aes_output[0]); + _prng_aes_buffer.putInt32(_prng_aes_output[1]); + _prng_aes_buffer.putInt32(_prng_aes_output[2]); + _prng_aes_buffer.putInt32(_prng_aes_output[3]); + return _prng_aes_buffer.getBytes(); +}; +prng_aes.increment = function(seed) { + // FIXME: do we care about carry or signed issues? + ++seed[3]; + return seed; +}; +prng_aes.md = forge.md.sha256; + +/** + * Creates a new PRNG. + */ +function spawnPrng() { + var ctx = forge.prng.create(prng_aes); + + /** + * Gets random bytes. If a native secure crypto API is unavailable, this + * method tries to make the bytes more unpredictable by drawing from data that + * can be collected from the user of the browser, eg: mouse movement. + * + * If a callback is given, this method will be called asynchronously. + * + * @param count the number of random bytes to get. + * @param [callback(err, bytes)] called once the operation completes. + * + * @return the random bytes in a string. + */ + ctx.getBytes = function(count, callback) { + return ctx.generate(count, callback); + }; + + /** + * Gets random bytes asynchronously. If a native secure crypto API is + * unavailable, this method tries to make the bytes more unpredictable by + * drawing from data that can be collected from the user of the browser, + * eg: mouse movement. + * + * @param count the number of random bytes to get. + * + * @return the random bytes in a string. + */ + ctx.getBytesSync = function(count) { + return ctx.generate(count); + }; + + return ctx; +} + +// create default prng context +var _ctx = spawnPrng(); + +// add other sources of entropy only if window.crypto.getRandomValues is not +// available -- otherwise this source will be automatically used by the prng +var getRandomValues = null; +var globalScope = forge.util.globalScope; +var _crypto = (globalScope && (globalScope.crypto || globalScope.msCrypto)) || null; +if(_crypto && _crypto.getRandomValues) { + getRandomValues = function(arr) { + return _crypto.getRandomValues(arr); + }; +} + +if(forge.options.usePureJavaScript || + (!forge.util.isNodejs && !getRandomValues)) { + // if this is a web worker, do not use weak entropy, instead register to + // receive strong entropy asynchronously from the main thread + if(typeof window === 'undefined' || window.document === undefined) { + // FIXME: + } + + // get load time entropy + _ctx.collectInt(+new Date(), 32); + + // add some entropy from navigator object + if(typeof(navigator) !== 'undefined') { + var _navBytes = ''; + for(var key in navigator) { + try { + if(typeof(navigator[key]) == 'string') { + _navBytes += navigator[key]; + } + } catch(e) { + /* Some navigator keys might not be accessible, e.g. the geolocation + attribute throws an exception if touched in Mozilla chrome:// + context. + + Silently ignore this and just don't use this as a source of + entropy. */ + } + } + _ctx.collect(_navBytes); + _navBytes = null; + } + + // add mouse and keyboard collectors if jquery is available + if(jQuery) { + // set up mouse entropy capture + jQuery().mousemove(function(e) { + // add mouse coords + _ctx.collectInt(e.clientX, 16); + _ctx.collectInt(e.clientY, 16); + }); + + // set up keyboard entropy capture + jQuery().keypress(function(e) { + _ctx.collectInt(e.charCode, 8); + }); + } +} + +/* Random API */ +if(!forge.random) { + forge.random = _ctx; +} else { + // extend forge.random with _ctx + for(var key in _ctx) { + forge.random[key] = _ctx[key]; + } +} + +// expose spawn PRNG +forge.random.createInstance = spawnPrng; + +module.exports = forge.random; + +})(typeof(jQuery) !== 'undefined' ? jQuery : null); + +})(); diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/vendor/node-forge/lib/rsa.js b/ruoyi-fastapi-frontend/vue2/mobile/src/vendor/node-forge/lib/rsa.js new file mode 100644 index 0000000..1db0ce4 --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/vendor/node-forge/lib/rsa.js @@ -0,0 +1,1954 @@ +/** + * Javascript implementation of basic RSA algorithms. + * + * @author Dave Longley + * + * Copyright (c) 2010-2014 Digital Bazaar, Inc. + * + * The only algorithm currently supported for PKI is RSA. + * + * An RSA key is often stored in ASN.1 DER format. The SubjectPublicKeyInfo + * ASN.1 structure is composed of an algorithm of type AlgorithmIdentifier + * and a subjectPublicKey of type bit string. + * + * The AlgorithmIdentifier contains an Object Identifier (OID) and parameters + * for the algorithm, if any. In the case of RSA, there aren't any. + * + * SubjectPublicKeyInfo ::= SEQUENCE { + * algorithm AlgorithmIdentifier, + * subjectPublicKey BIT STRING + * } + * + * AlgorithmIdentifer ::= SEQUENCE { + * algorithm OBJECT IDENTIFIER, + * parameters ANY DEFINED BY algorithm OPTIONAL + * } + * + * For an RSA public key, the subjectPublicKey is: + * + * RSAPublicKey ::= SEQUENCE { + * modulus INTEGER, -- n + * publicExponent INTEGER -- e + * } + * + * PrivateKeyInfo ::= SEQUENCE { + * version Version, + * privateKeyAlgorithm PrivateKeyAlgorithmIdentifier, + * privateKey PrivateKey, + * attributes [0] IMPLICIT Attributes OPTIONAL + * } + * + * Version ::= INTEGER + * PrivateKeyAlgorithmIdentifier ::= AlgorithmIdentifier + * PrivateKey ::= OCTET STRING + * Attributes ::= SET OF Attribute + * + * An RSA private key as the following structure: + * + * RSAPrivateKey ::= SEQUENCE { + * version Version, + * modulus INTEGER, -- n + * publicExponent INTEGER, -- e + * privateExponent INTEGER, -- d + * prime1 INTEGER, -- p + * prime2 INTEGER, -- q + * exponent1 INTEGER, -- d mod (p-1) + * exponent2 INTEGER, -- d mod (q-1) + * coefficient INTEGER -- (inverse of q) mod p + * } + * + * Version ::= INTEGER + * + * The OID for the RSA key algorithm is: 1.2.840.113549.1.1.1 + */ +var forge = require('node-forge/lib/forge'); +require('node-forge/lib/asn1'); +require('node-forge/lib/jsbn'); +require('node-forge/lib/oids'); +require('node-forge/lib/pkcs1'); +require('node-forge/lib/prime'); +require('node-forge/lib/random'); +require('node-forge/lib/util'); + +if(typeof BigInteger === 'undefined') { + var BigInteger = forge.jsbn.BigInteger; +} + +var _crypto = null; +if(forge.util.isNodejs) { + try { + _crypto = eval('require')('crypto'); + } catch(e) {} +} + +// shortcut for asn.1 API +var asn1 = forge.asn1; + +// shortcut for util API +var util = forge.util; + +/* + * RSA encryption and decryption, see RFC 2313. + */ +forge.pki = forge.pki || {}; +module.exports = forge.pki.rsa = forge.rsa = forge.rsa || {}; +var pki = forge.pki; + +// for finding primes, which are 30k+i for i = 1, 7, 11, 13, 17, 19, 23, 29 +var GCD_30_DELTA = [6, 4, 2, 4, 2, 4, 6, 2]; + +// validator for a PrivateKeyInfo structure +var privateKeyValidator = { + // PrivateKeyInfo + name: 'PrivateKeyInfo', + tagClass: asn1.Class.UNIVERSAL, + type: asn1.Type.SEQUENCE, + constructed: true, + value: [{ + // Version (INTEGER) + name: 'PrivateKeyInfo.version', + tagClass: asn1.Class.UNIVERSAL, + type: asn1.Type.INTEGER, + constructed: false, + capture: 'privateKeyVersion' + }, { + // privateKeyAlgorithm + name: 'PrivateKeyInfo.privateKeyAlgorithm', + tagClass: asn1.Class.UNIVERSAL, + type: asn1.Type.SEQUENCE, + constructed: true, + value: [{ + name: 'AlgorithmIdentifier.algorithm', + tagClass: asn1.Class.UNIVERSAL, + type: asn1.Type.OID, + constructed: false, + capture: 'privateKeyOid' + }] + }, { + // PrivateKey + name: 'PrivateKeyInfo', + tagClass: asn1.Class.UNIVERSAL, + type: asn1.Type.OCTETSTRING, + constructed: false, + capture: 'privateKey' + }] +}; + +// validator for an RSA private key +var rsaPrivateKeyValidator = { + // RSAPrivateKey + name: 'RSAPrivateKey', + tagClass: asn1.Class.UNIVERSAL, + type: asn1.Type.SEQUENCE, + constructed: true, + value: [{ + // Version (INTEGER) + name: 'RSAPrivateKey.version', + tagClass: asn1.Class.UNIVERSAL, + type: asn1.Type.INTEGER, + constructed: false, + capture: 'privateKeyVersion' + }, { + // modulus (n) + name: 'RSAPrivateKey.modulus', + tagClass: asn1.Class.UNIVERSAL, + type: asn1.Type.INTEGER, + constructed: false, + capture: 'privateKeyModulus' + }, { + // publicExponent (e) + name: 'RSAPrivateKey.publicExponent', + tagClass: asn1.Class.UNIVERSAL, + type: asn1.Type.INTEGER, + constructed: false, + capture: 'privateKeyPublicExponent' + }, { + // privateExponent (d) + name: 'RSAPrivateKey.privateExponent', + tagClass: asn1.Class.UNIVERSAL, + type: asn1.Type.INTEGER, + constructed: false, + capture: 'privateKeyPrivateExponent' + }, { + // prime1 (p) + name: 'RSAPrivateKey.prime1', + tagClass: asn1.Class.UNIVERSAL, + type: asn1.Type.INTEGER, + constructed: false, + capture: 'privateKeyPrime1' + }, { + // prime2 (q) + name: 'RSAPrivateKey.prime2', + tagClass: asn1.Class.UNIVERSAL, + type: asn1.Type.INTEGER, + constructed: false, + capture: 'privateKeyPrime2' + }, { + // exponent1 (d mod (p-1)) + name: 'RSAPrivateKey.exponent1', + tagClass: asn1.Class.UNIVERSAL, + type: asn1.Type.INTEGER, + constructed: false, + capture: 'privateKeyExponent1' + }, { + // exponent2 (d mod (q-1)) + name: 'RSAPrivateKey.exponent2', + tagClass: asn1.Class.UNIVERSAL, + type: asn1.Type.INTEGER, + constructed: false, + capture: 'privateKeyExponent2' + }, { + // coefficient ((inverse of q) mod p) + name: 'RSAPrivateKey.coefficient', + tagClass: asn1.Class.UNIVERSAL, + type: asn1.Type.INTEGER, + constructed: false, + capture: 'privateKeyCoefficient' + }] +}; + +// validator for an RSA public key +var rsaPublicKeyValidator = { + // RSAPublicKey + name: 'RSAPublicKey', + tagClass: asn1.Class.UNIVERSAL, + type: asn1.Type.SEQUENCE, + constructed: true, + value: [{ + // modulus (n) + name: 'RSAPublicKey.modulus', + tagClass: asn1.Class.UNIVERSAL, + type: asn1.Type.INTEGER, + constructed: false, + capture: 'publicKeyModulus' + }, { + // publicExponent (e) + name: 'RSAPublicKey.exponent', + tagClass: asn1.Class.UNIVERSAL, + type: asn1.Type.INTEGER, + constructed: false, + capture: 'publicKeyExponent' + }] +}; + +// validator for an SubjectPublicKeyInfo structure +// Note: Currently only works with an RSA public key +var publicKeyValidator = forge.pki.rsa.publicKeyValidator = { + name: 'SubjectPublicKeyInfo', + tagClass: asn1.Class.UNIVERSAL, + type: asn1.Type.SEQUENCE, + constructed: true, + captureAsn1: 'subjectPublicKeyInfo', + value: [{ + name: 'SubjectPublicKeyInfo.AlgorithmIdentifier', + tagClass: asn1.Class.UNIVERSAL, + type: asn1.Type.SEQUENCE, + constructed: true, + value: [{ + name: 'AlgorithmIdentifier.algorithm', + tagClass: asn1.Class.UNIVERSAL, + type: asn1.Type.OID, + constructed: false, + capture: 'publicKeyOid' + }] + }, { + // subjectPublicKey + name: 'SubjectPublicKeyInfo.subjectPublicKey', + tagClass: asn1.Class.UNIVERSAL, + type: asn1.Type.BITSTRING, + constructed: false, + value: [{ + // RSAPublicKey + name: 'SubjectPublicKeyInfo.subjectPublicKey.RSAPublicKey', + tagClass: asn1.Class.UNIVERSAL, + type: asn1.Type.SEQUENCE, + constructed: true, + optional: true, + captureAsn1: 'rsaPublicKey' + }] + }] +}; + +// validator for a DigestInfo structure +var digestInfoValidator = { + name: 'DigestInfo', + tagClass: asn1.Class.UNIVERSAL, + type: asn1.Type.SEQUENCE, + constructed: true, + value: [{ + name: 'DigestInfo.DigestAlgorithm', + tagClass: asn1.Class.UNIVERSAL, + type: asn1.Type.SEQUENCE, + constructed: true, + value: [{ + name: 'DigestInfo.DigestAlgorithm.algorithmIdentifier', + tagClass: asn1.Class.UNIVERSAL, + type: asn1.Type.OID, + constructed: false, + capture: 'algorithmIdentifier' + }, { + // NULL parameters + name: 'DigestInfo.DigestAlgorithm.parameters', + tagClass: asn1.Class.UNIVERSAL, + type: asn1.Type.NULL, + // captured only to check existence for md2 and md5 + capture: 'parameters', + optional: true, + constructed: false + }] + }, { + // digest + name: 'DigestInfo.digest', + tagClass: asn1.Class.UNIVERSAL, + type: asn1.Type.OCTETSTRING, + constructed: false, + capture: 'digest' + }] +}; + +/** + * Wrap digest in DigestInfo object. + * + * This function implements EMSA-PKCS1-v1_5-ENCODE as per RFC 3447. + * + * DigestInfo ::= SEQUENCE { + * digestAlgorithm DigestAlgorithmIdentifier, + * digest Digest + * } + * + * DigestAlgorithmIdentifier ::= AlgorithmIdentifier + * Digest ::= OCTET STRING + * + * @param md the message digest object with the hash to sign. + * + * @return the encoded message (ready for RSA encryption) + */ +var emsaPkcs1v15encode = function(md) { + // get the oid for the algorithm + var oid; + if(md.algorithm in pki.oids) { + oid = pki.oids[md.algorithm]; + } else { + var error = new Error('Unknown message digest algorithm.'); + error.algorithm = md.algorithm; + throw error; + } + var oidBytes = asn1.oidToDer(oid).getBytes(); + + // create the digest info + var digestInfo = asn1.create( + asn1.Class.UNIVERSAL, asn1.Type.SEQUENCE, true, []); + var digestAlgorithm = asn1.create( + asn1.Class.UNIVERSAL, asn1.Type.SEQUENCE, true, []); + digestAlgorithm.value.push(asn1.create( + asn1.Class.UNIVERSAL, asn1.Type.OID, false, oidBytes)); + digestAlgorithm.value.push(asn1.create( + asn1.Class.UNIVERSAL, asn1.Type.NULL, false, '')); + var digest = asn1.create( + asn1.Class.UNIVERSAL, asn1.Type.OCTETSTRING, + false, md.digest().getBytes()); + digestInfo.value.push(digestAlgorithm); + digestInfo.value.push(digest); + + // encode digest info + return asn1.toDer(digestInfo).getBytes(); +}; + +/** + * Performs x^c mod n (RSA encryption or decryption operation). + * + * @param x the number to raise and mod. + * @param key the key to use. + * @param pub true if the key is public, false if private. + * + * @return the result of x^c mod n. + */ +var _modPow = function(x, key, pub) { + if(pub) { + return x.modPow(key.e, key.n); + } + + if(!key.p || !key.q) { + // allow calculation without CRT params (slow) + return x.modPow(key.d, key.n); + } + + // pre-compute dP, dQ, and qInv if necessary + if(!key.dP) { + key.dP = key.d.mod(key.p.subtract(BigInteger.ONE)); + } + if(!key.dQ) { + key.dQ = key.d.mod(key.q.subtract(BigInteger.ONE)); + } + if(!key.qInv) { + key.qInv = key.q.modInverse(key.p); + } + + /* Chinese remainder theorem (CRT) states: + + Suppose n1, n2, ..., nk are positive integers which are pairwise + coprime (n1 and n2 have no common factors other than 1). For any + integers x1, x2, ..., xk there exists an integer x solving the + system of simultaneous congruences (where ~= means modularly + congruent so a ~= b mod n means a mod n = b mod n): + + x ~= x1 mod n1 + x ~= x2 mod n2 + ... + x ~= xk mod nk + + This system of congruences has a single simultaneous solution x + between 0 and n - 1. Furthermore, each xk solution and x itself + is congruent modulo the product n = n1*n2*...*nk. + So x1 mod n = x2 mod n = xk mod n = x mod n. + + The single simultaneous solution x can be solved with the following + equation: + + x = sum(xi*ri*si) mod n where ri = n/ni and si = ri^-1 mod ni. + + Where x is less than n, xi = x mod ni. + + For RSA we are only concerned with k = 2. The modulus n = pq, where + p and q are coprime. The RSA decryption algorithm is: + + y = x^d mod n + + Given the above: + + x1 = x^d mod p + r1 = n/p = q + s1 = q^-1 mod p + x2 = x^d mod q + r2 = n/q = p + s2 = p^-1 mod q + + So y = (x1r1s1 + x2r2s2) mod n + = ((x^d mod p)q(q^-1 mod p) + (x^d mod q)p(p^-1 mod q)) mod n + + According to Fermat's Little Theorem, if the modulus P is prime, + for any integer A not evenly divisible by P, A^(P-1) ~= 1 mod P. + Since A is not divisible by P it follows that if: + N ~= M mod (P - 1), then A^N mod P = A^M mod P. Therefore: + + A^N mod P = A^(M mod (P - 1)) mod P. (The latter takes less effort + to calculate). In order to calculate x^d mod p more quickly the + exponent d mod (p - 1) is stored in the RSA private key (the same + is done for x^d mod q). These values are referred to as dP and dQ + respectively. Therefore we now have: + + y = ((x^dP mod p)q(q^-1 mod p) + (x^dQ mod q)p(p^-1 mod q)) mod n + + Since we'll be reducing x^dP by modulo p (same for q) we can also + reduce x by p (and q respectively) before hand. Therefore, let + + xp = ((x mod p)^dP mod p), and + xq = ((x mod q)^dQ mod q), yielding: + + y = (xp*q*(q^-1 mod p) + xq*p*(p^-1 mod q)) mod n + + This can be further reduced to a simple algorithm that only + requires 1 inverse (the q inverse is used) to be used and stored. + The algorithm is called Garner's algorithm. If qInv is the + inverse of q, we simply calculate: + + y = (qInv*(xp - xq) mod p) * q + xq + + However, there are two further complications. First, we need to + ensure that xp > xq to prevent signed BigIntegers from being used + so we add p until this is true (since we will be mod'ing with + p anyway). Then, there is a known timing attack on algorithms + using the CRT. To mitigate this risk, "cryptographic blinding" + should be used. This requires simply generating a random number r + between 0 and n-1 and its inverse and multiplying x by r^e before + calculating y and then multiplying y by r^-1 afterwards. Note that + r must be coprime with n (gcd(r, n) === 1) in order to have an + inverse. + */ + + // cryptographic blinding + var r; + do { + r = new BigInteger( + forge.util.bytesToHex(forge.random.getBytes(key.n.bitLength() / 8)), + 16); + } while(r.compareTo(key.n) >= 0 || !r.gcd(key.n).equals(BigInteger.ONE)); + x = x.multiply(r.modPow(key.e, key.n)).mod(key.n); + + // calculate xp and xq + var xp = x.mod(key.p).modPow(key.dP, key.p); + var xq = x.mod(key.q).modPow(key.dQ, key.q); + + // xp must be larger than xq to avoid signed bit usage + while(xp.compareTo(xq) < 0) { + xp = xp.add(key.p); + } + + // do last step + var y = xp.subtract(xq) + .multiply(key.qInv).mod(key.p) + .multiply(key.q).add(xq); + + // remove effect of random for cryptographic blinding + y = y.multiply(r.modInverse(key.n)).mod(key.n); + + return y; +}; + +/** + * NOTE: THIS METHOD IS DEPRECATED, use 'sign' on a private key object or + * 'encrypt' on a public key object instead. + * + * Performs RSA encryption. + * + * The parameter bt controls whether to put padding bytes before the + * message passed in. Set bt to either true or false to disable padding + * completely (in order to handle e.g. EMSA-PSS encoding separately before), + * signaling whether the encryption operation is a public key operation + * (i.e. encrypting data) or not, i.e. private key operation (data signing). + * + * For PKCS#1 v1.5 padding pass in the block type to use, i.e. either 0x01 + * (for signing) or 0x02 (for encryption). The key operation mode (private + * or public) is derived from this flag in that case). + * + * @param m the message to encrypt as a byte string. + * @param key the RSA key to use. + * @param bt for PKCS#1 v1.5 padding, the block type to use + * (0x01 for private key, 0x02 for public), + * to disable padding: true = public key, false = private key. + * + * @return the encrypted bytes as a string. + */ +pki.rsa.encrypt = function(m, key, bt) { + var pub = bt; + var eb; + + // get the length of the modulus in bytes + var k = Math.ceil(key.n.bitLength() / 8); + + if(bt !== false && bt !== true) { + // legacy, default to PKCS#1 v1.5 padding + pub = (bt === 0x02); + eb = _encodePkcs1_v1_5(m, key, bt); + } else { + eb = forge.util.createBuffer(); + eb.putBytes(m); + } + + // load encryption block as big integer 'x' + // FIXME: hex conversion inefficient, get BigInteger w/byte strings + var x = new BigInteger(eb.toHex(), 16); + + // do RSA encryption + var y = _modPow(x, key, pub); + + // convert y into the encrypted data byte string, if y is shorter in + // bytes than k, then prepend zero bytes to fill up ed + // FIXME: hex conversion inefficient, get BigInteger w/byte strings + var yhex = y.toString(16); + var ed = forge.util.createBuffer(); + var zeros = k - Math.ceil(yhex.length / 2); + while(zeros > 0) { + ed.putByte(0x00); + --zeros; + } + ed.putBytes(forge.util.hexToBytes(yhex)); + return ed.getBytes(); +}; + +/** + * NOTE: THIS METHOD IS DEPRECATED, use 'decrypt' on a private key object or + * 'verify' on a public key object instead. + * + * Performs RSA decryption. + * + * The parameter ml controls whether to apply PKCS#1 v1.5 padding + * or not. Set ml = false to disable padding removal completely + * (in order to handle e.g. EMSA-PSS later on) and simply pass back + * the RSA encryption block. + * + * @param ed the encrypted data to decrypt in as a byte string. + * @param key the RSA key to use. + * @param pub true for a public key operation, false for private. + * @param ml the message length, if known, false to disable padding. + * + * @return the decrypted message as a byte string. + */ +pki.rsa.decrypt = function(ed, key, pub, ml) { + // get the length of the modulus in bytes + var k = Math.ceil(key.n.bitLength() / 8); + + // error if the length of the encrypted data ED is not k + if(ed.length !== k) { + var error = new Error('Encrypted message length is invalid.'); + error.length = ed.length; + error.expected = k; + throw error; + } + + // convert encrypted data into a big integer + // FIXME: hex conversion inefficient, get BigInteger w/byte strings + var y = new BigInteger(forge.util.createBuffer(ed).toHex(), 16); + + // y must be less than the modulus or it wasn't the result of + // a previous mod operation (encryption) using that modulus + if(y.compareTo(key.n) >= 0) { + throw new Error('Encrypted message is invalid.'); + } + + // do RSA decryption + var x = _modPow(y, key, pub); + + // create the encryption block, if x is shorter in bytes than k, then + // prepend zero bytes to fill up eb + // FIXME: hex conversion inefficient, get BigInteger w/byte strings + var xhex = x.toString(16); + var eb = forge.util.createBuffer(); + var zeros = k - Math.ceil(xhex.length / 2); + while(zeros > 0) { + eb.putByte(0x00); + --zeros; + } + eb.putBytes(forge.util.hexToBytes(xhex)); + + if(ml !== false) { + // legacy, default to PKCS#1 v1.5 padding + return _decodePkcs1_v1_5(eb.getBytes(), key, pub); + } + + // return message + return eb.getBytes(); +}; + +/** + * Creates an RSA key-pair generation state object. It is used to allow + * key-generation to be performed in steps. It also allows for a UI to + * display progress updates. + * + * @param bits the size for the private key in bits, defaults to 2048. + * @param e the public exponent to use, defaults to 65537 (0x10001). + * @param [options] the options to use. + * prng a custom crypto-secure pseudo-random number generator to use, + * that must define "getBytesSync". + * algorithm the algorithm to use (default: 'PRIMEINC'). + * + * @return the state object to use to generate the key-pair. + */ +pki.rsa.createKeyPairGenerationState = function(bits, e, options) { + // TODO: migrate step-based prime generation code to forge.prime + + // set default bits + if(typeof(bits) === 'string') { + bits = parseInt(bits, 10); + } + bits = bits || 2048; + + // create prng with api that matches BigInteger secure random + options = options || {}; + var prng = options.prng || forge.random; + var rng = { + // x is an array to fill with bytes + nextBytes: function(x) { + var b = prng.getBytesSync(x.length); + for(var i = 0; i < x.length; ++i) { + x[i] = b.charCodeAt(i); + } + } + }; + + var algorithm = options.algorithm || 'PRIMEINC'; + + // create PRIMEINC algorithm state + var rval; + if(algorithm === 'PRIMEINC') { + rval = { + algorithm: algorithm, + state: 0, + bits: bits, + rng: rng, + eInt: e || 65537, + e: new BigInteger(null), + p: null, + q: null, + qBits: bits >> 1, + pBits: bits - (bits >> 1), + pqState: 0, + num: null, + keys: null + }; + rval.e.fromInt(rval.eInt); + } else { + throw new Error('Invalid key generation algorithm: ' + algorithm); + } + + return rval; +}; + +/** + * Attempts to runs the key-generation algorithm for at most n seconds + * (approximately) using the given state. When key-generation has completed, + * the keys will be stored in state.keys. + * + * To use this function to update a UI while generating a key or to prevent + * causing browser lockups/warnings, set "n" to a value other than 0. A + * simple pattern for generating a key and showing a progress indicator is: + * + * var state = pki.rsa.createKeyPairGenerationState(2048); + * var step = function() { + * // step key-generation, run algorithm for 100 ms, repeat + * if(!forge.pki.rsa.stepKeyPairGenerationState(state, 100)) { + * setTimeout(step, 1); + * } else { + * // key-generation complete + * // TODO: turn off progress indicator here + * // TODO: use the generated key-pair in "state.keys" + * } + * }; + * // TODO: turn on progress indicator here + * setTimeout(step, 0); + * + * @param state the state to use. + * @param n the maximum number of milliseconds to run the algorithm for, 0 + * to run the algorithm to completion. + * + * @return true if the key-generation completed, false if not. + */ +pki.rsa.stepKeyPairGenerationState = function(state, n) { + // set default algorithm if not set + if(!('algorithm' in state)) { + state.algorithm = 'PRIMEINC'; + } + + // TODO: migrate step-based prime generation code to forge.prime + // TODO: abstract as PRIMEINC algorithm + + // do key generation (based on Tom Wu's rsa.js, see jsbn.js license) + // with some minor optimizations and designed to run in steps + + // local state vars + var THIRTY = new BigInteger(null); + THIRTY.fromInt(30); + var deltaIdx = 0; + var op_or = function(x, y) {return x | y;}; + + // keep stepping until time limit is reached or done + var t1 = +new Date(); + var t2; + var total = 0; + while(state.keys === null && (n <= 0 || total < n)) { + // generate p or q + if(state.state === 0) { + /* Note: All primes are of the form: + + 30k+i, for i < 30 and gcd(30, i)=1, where there are 8 values for i + + When we generate a random number, we always align it at 30k + 1. Each + time the number is determined not to be prime we add to get to the + next 'i', eg: if the number was at 30k + 1 we add 6. */ + var bits = (state.p === null) ? state.pBits : state.qBits; + var bits1 = bits - 1; + + // get a random number + if(state.pqState === 0) { + state.num = new BigInteger(bits, state.rng); + // force MSB set + if(!state.num.testBit(bits1)) { + state.num.bitwiseTo( + BigInteger.ONE.shiftLeft(bits1), op_or, state.num); + } + // align number on 30k+1 boundary + state.num.dAddOffset(31 - state.num.mod(THIRTY).byteValue(), 0); + deltaIdx = 0; + + ++state.pqState; + } else if(state.pqState === 1) { + // try to make the number a prime + if(state.num.bitLength() > bits) { + // overflow, try again + state.pqState = 0; + // do primality test + } else if(state.num.isProbablePrime( + _getMillerRabinTests(state.num.bitLength()))) { + ++state.pqState; + } else { + // get next potential prime + state.num.dAddOffset(GCD_30_DELTA[deltaIdx++ % 8], 0); + } + } else if(state.pqState === 2) { + // ensure number is coprime with e + state.pqState = + (state.num.subtract(BigInteger.ONE).gcd(state.e) + .compareTo(BigInteger.ONE) === 0) ? 3 : 0; + } else if(state.pqState === 3) { + // store p or q + state.pqState = 0; + if(state.p === null) { + state.p = state.num; + } else { + state.q = state.num; + } + + // advance state if both p and q are ready + if(state.p !== null && state.q !== null) { + ++state.state; + } + state.num = null; + } + } else if(state.state === 1) { + // ensure p is larger than q (swap them if not) + if(state.p.compareTo(state.q) < 0) { + state.num = state.p; + state.p = state.q; + state.q = state.num; + } + ++state.state; + } else if(state.state === 2) { + // compute phi: (p - 1)(q - 1) (Euler's totient function) + state.p1 = state.p.subtract(BigInteger.ONE); + state.q1 = state.q.subtract(BigInteger.ONE); + state.phi = state.p1.multiply(state.q1); + ++state.state; + } else if(state.state === 3) { + // ensure e and phi are coprime + if(state.phi.gcd(state.e).compareTo(BigInteger.ONE) === 0) { + // phi and e are coprime, advance + ++state.state; + } else { + // phi and e aren't coprime, so generate a new p and q + state.p = null; + state.q = null; + state.state = 0; + } + } else if(state.state === 4) { + // create n, ensure n is has the right number of bits + state.n = state.p.multiply(state.q); + + // ensure n is right number of bits + if(state.n.bitLength() === state.bits) { + // success, advance + ++state.state; + } else { + // failed, get new q + state.q = null; + state.state = 0; + } + } else if(state.state === 5) { + // set keys + var d = state.e.modInverse(state.phi); + state.keys = { + privateKey: pki.rsa.setPrivateKey( + state.n, state.e, d, state.p, state.q, + d.mod(state.p1), d.mod(state.q1), + state.q.modInverse(state.p)), + publicKey: pki.rsa.setPublicKey(state.n, state.e) + }; + } + + // update timing + t2 = +new Date(); + total += t2 - t1; + t1 = t2; + } + + return state.keys !== null; +}; + +/** + * Generates an RSA public-private key pair in a single call. + * + * To generate a key-pair in steps (to allow for progress updates and to + * prevent blocking or warnings in slow browsers) then use the key-pair + * generation state functions. + * + * To generate a key-pair asynchronously (either through web-workers, if + * available, or by breaking up the work on the main thread), pass a + * callback function. + * + * @param [bits] the size for the private key in bits, defaults to 2048. + * @param [e] the public exponent to use, defaults to 65537. + * @param [options] options for key-pair generation, if given then 'bits' + * and 'e' must *not* be given: + * bits the size for the private key in bits, (default: 2048). + * e the public exponent to use, (default: 65537 (0x10001)). + * workerScript the worker script URL. + * workers the number of web workers (if supported) to use, + * (default: 2). + * workLoad the size of the work load, ie: number of possible prime + * numbers for each web worker to check per work assignment, + * (default: 100). + * prng a custom crypto-secure pseudo-random number generator to use, + * that must define "getBytesSync". Disables use of native APIs. + * algorithm the algorithm to use (default: 'PRIMEINC'). + * @param [callback(err, keypair)] called once the operation completes. + * + * @return an object with privateKey and publicKey properties. + */ +pki.rsa.generateKeyPair = function(bits, e, options, callback) { + // (bits), (options), (callback) + if(arguments.length === 1) { + if(typeof bits === 'object') { + options = bits; + bits = undefined; + } else if(typeof bits === 'function') { + callback = bits; + bits = undefined; + } + } else if(arguments.length === 2) { + // (bits, e), (bits, options), (bits, callback), (options, callback) + if(typeof bits === 'number') { + if(typeof e === 'function') { + callback = e; + e = undefined; + } else if(typeof e !== 'number') { + options = e; + e = undefined; + } + } else { + options = bits; + callback = e; + bits = undefined; + e = undefined; + } + } else if(arguments.length === 3) { + // (bits, e, options), (bits, e, callback), (bits, options, callback) + if(typeof e === 'number') { + if(typeof options === 'function') { + callback = options; + options = undefined; + } + } else { + callback = options; + options = e; + e = undefined; + } + } + options = options || {}; + if(bits === undefined) { + bits = options.bits || 2048; + } + if(e === undefined) { + e = options.e || 0x10001; + } + + // use native code if permitted, available, and parameters are acceptable + if(!forge.options.usePureJavaScript && !options.prng && + bits >= 256 && bits <= 16384 && (e === 0x10001 || e === 3)) { + if(callback) { + // try native async + if(_detectNodeCrypto('generateKeyPair')) { + return _crypto.generateKeyPair('rsa', { + modulusLength: bits, + publicExponent: e, + publicKeyEncoding: { + type: 'spki', + format: 'pem' + }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem' + } + }, function(err, pub, priv) { + if(err) { + return callback(err); + } + callback(null, { + privateKey: pki.privateKeyFromPem(priv), + publicKey: pki.publicKeyFromPem(pub) + }); + }); + } + if(_detectSubtleCrypto('generateKey') && + _detectSubtleCrypto('exportKey')) { + // use standard native generateKey + return util.globalScope.crypto.subtle.generateKey({ + name: 'RSASSA-PKCS1-v1_5', + modulusLength: bits, + publicExponent: _intToUint8Array(e), + hash: {name: 'SHA-256'} + }, true /* key can be exported*/, ['sign', 'verify']) + .then(function(pair) { + return util.globalScope.crypto.subtle.exportKey( + 'pkcs8', pair.privateKey); + // avoiding catch(function(err) {...}) to support IE <= 8 + }).then(undefined, function(err) { + callback(err); + }).then(function(pkcs8) { + if(pkcs8) { + var privateKey = pki.privateKeyFromAsn1( + asn1.fromDer(forge.util.createBuffer(pkcs8))); + callback(null, { + privateKey: privateKey, + publicKey: pki.setRsaPublicKey(privateKey.n, privateKey.e) + }); + } + }); + } + if(_detectSubtleMsCrypto('generateKey') && + _detectSubtleMsCrypto('exportKey')) { + var genOp = util.globalScope.msCrypto.subtle.generateKey({ + name: 'RSASSA-PKCS1-v1_5', + modulusLength: bits, + publicExponent: _intToUint8Array(e), + hash: {name: 'SHA-256'} + }, true /* key can be exported*/, ['sign', 'verify']); + genOp.oncomplete = function(e) { + var pair = e.target.result; + var exportOp = util.globalScope.msCrypto.subtle.exportKey( + 'pkcs8', pair.privateKey); + exportOp.oncomplete = function(e) { + var pkcs8 = e.target.result; + var privateKey = pki.privateKeyFromAsn1( + asn1.fromDer(forge.util.createBuffer(pkcs8))); + callback(null, { + privateKey: privateKey, + publicKey: pki.setRsaPublicKey(privateKey.n, privateKey.e) + }); + }; + exportOp.onerror = function(err) { + callback(err); + }; + }; + genOp.onerror = function(err) { + callback(err); + }; + return; + } + } else { + // try native sync + if(_detectNodeCrypto('generateKeyPairSync')) { + var keypair = _crypto.generateKeyPairSync('rsa', { + modulusLength: bits, + publicExponent: e, + publicKeyEncoding: { + type: 'spki', + format: 'pem' + }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem' + } + }); + return { + privateKey: pki.privateKeyFromPem(keypair.privateKey), + publicKey: pki.publicKeyFromPem(keypair.publicKey) + }; + } + } + } + + // use JavaScript implementation + var state = pki.rsa.createKeyPairGenerationState(bits, e, options); + if(!callback) { + pki.rsa.stepKeyPairGenerationState(state, 0); + return state.keys; + } + _generateKeyPair(state, options, callback); +}; + +/** + * Sets an RSA public key from BigIntegers modulus and exponent. + * + * @param n the modulus. + * @param e the exponent. + * + * @return the public key. + */ +pki.setRsaPublicKey = pki.rsa.setPublicKey = function(n, e) { + var key = { + n: n, + e: e + }; + + /** + * Encrypts the given data with this public key. Newer applications + * should use the 'RSA-OAEP' decryption scheme, 'RSAES-PKCS1-V1_5' is for + * legacy applications. + * + * @param data the byte string to encrypt. + * @param scheme the encryption scheme to use: + * 'RSAES-PKCS1-V1_5' (default), + * 'RSA-OAEP', + * 'RAW', 'NONE', or null to perform raw RSA encryption, + * an object with an 'encode' property set to a function + * with the signature 'function(data, key)' that returns + * a binary-encoded string representing the encoded data. + * @param schemeOptions any scheme-specific options. + * + * @return the encrypted byte string. + */ + key.encrypt = function(data, scheme, schemeOptions) { + if(typeof scheme === 'string') { + scheme = scheme.toUpperCase(); + } else if(scheme === undefined) { + scheme = 'RSAES-PKCS1-V1_5'; + } + + if(scheme === 'RSAES-PKCS1-V1_5') { + scheme = { + encode: function(m, key, pub) { + return _encodePkcs1_v1_5(m, key, 0x02).getBytes(); + } + }; + } else if(scheme === 'RSA-OAEP' || scheme === 'RSAES-OAEP') { + scheme = { + encode: function(m, key) { + return forge.pkcs1.encode_rsa_oaep(key, m, schemeOptions); + } + }; + } else if(['RAW', 'NONE', 'NULL', null].indexOf(scheme) !== -1) { + scheme = {encode: function(e) {return e;}}; + } else if(typeof scheme === 'string') { + throw new Error('Unsupported encryption scheme: "' + scheme + '".'); + } + + // do scheme-based encoding then rsa encryption + var e = scheme.encode(data, key, true); + return pki.rsa.encrypt(e, key, true); + }; + + /** + * Verifies the given signature against the given digest. + * + * PKCS#1 supports multiple (currently two) signature schemes: + * RSASSA-PKCS1-V1_5 and RSASSA-PSS. + * + * By default this implementation uses the "old scheme", i.e. + * RSASSA-PKCS1-V1_5, in which case once RSA-decrypted, the + * signature is an OCTET STRING that holds a DigestInfo. + * + * DigestInfo ::= SEQUENCE { + * digestAlgorithm DigestAlgorithmIdentifier, + * digest Digest + * } + * DigestAlgorithmIdentifier ::= AlgorithmIdentifier + * Digest ::= OCTET STRING + * + * To perform PSS signature verification, provide an instance + * of Forge PSS object as the scheme parameter. + * + * @param digest the message digest hash to compare against the signature, + * as a binary-encoded string. + * @param signature the signature to verify, as a binary-encoded string. + * @param scheme signature verification scheme to use: + * 'RSASSA-PKCS1-V1_5' or undefined for RSASSA PKCS#1 v1.5, + * a Forge PSS object for RSASSA-PSS, + * 'NONE' or null for none, DigestInfo will not be expected, but + * PKCS#1 v1.5 padding will still be used. + * @param options optional verify options + * _parseAllDigestBytes testing flag to control parsing of all + * digest bytes. Unsupported and not for general usage. + * (default: true) + * + * @return true if the signature was verified, false if not. + */ + key.verify = function(digest, signature, scheme, options) { + if(typeof scheme === 'string') { + scheme = scheme.toUpperCase(); + } else if(scheme === undefined) { + scheme = 'RSASSA-PKCS1-V1_5'; + } + if(options === undefined) { + options = { + _parseAllDigestBytes: true + }; + } + if(!('_parseAllDigestBytes' in options)) { + options._parseAllDigestBytes = true; + } + + if(scheme === 'RSASSA-PKCS1-V1_5') { + scheme = { + verify: function(digest, d) { + // remove padding + d = _decodePkcs1_v1_5(d, key, true); + // d is ASN.1 BER-encoded DigestInfo + var obj = asn1.fromDer(d, { + parseAllBytes: options._parseAllDigestBytes + }); + + // validate DigestInfo + var capture = {}; + var errors = []; + if(!asn1.validate(obj, digestInfoValidator, capture, errors)) { + var error = new Error( + 'ASN.1 object does not contain a valid RSASSA-PKCS1-v1_5 ' + + 'DigestInfo value.'); + error.errors = errors; + throw error; + } + // check hash algorithm identifier + // see PKCS1-v1-5DigestAlgorithms in RFC 8017 + // FIXME: add support to validator for strict value choices + var oid = asn1.derToOid(capture.algorithmIdentifier); + if(!(oid === forge.oids.md2 || + oid === forge.oids.md5 || + oid === forge.oids.sha1 || + oid === forge.oids.sha224 || + oid === forge.oids.sha256 || + oid === forge.oids.sha384 || + oid === forge.oids.sha512 || + oid === forge.oids['sha512-224'] || + oid === forge.oids['sha512-256'])) { + var error = new Error( + 'Unknown RSASSA-PKCS1-v1_5 DigestAlgorithm identifier.'); + error.oid = oid; + throw error; + } + + // special check for md2 and md5 that NULL parameters exist + if(oid === forge.oids.md2 || oid === forge.oids.md5) { + if(!('parameters' in capture)) { + throw new Error( + 'ASN.1 object does not contain a valid RSASSA-PKCS1-v1_5 ' + + 'DigestInfo value. ' + + 'Missing algorithm identifier NULL parameters.'); + } + } + + // compare the given digest to the decrypted one + return digest === capture.digest; + } + }; + } else if(scheme === 'NONE' || scheme === 'NULL' || scheme === null) { + scheme = { + verify: function(digest, d) { + // remove padding + d = _decodePkcs1_v1_5(d, key, true); + return digest === d; + } + }; + } + + // do rsa decryption w/o any decoding, then verify -- which does decoding + var d = pki.rsa.decrypt(signature, key, true, false); + return scheme.verify(digest, d, key.n.bitLength()); + }; + + return key; +}; + +/** + * Sets an RSA private key from BigIntegers modulus, exponent, primes, + * prime exponents, and modular multiplicative inverse. + * + * @param n the modulus. + * @param e the public exponent. + * @param d the private exponent ((inverse of e) mod n). + * @param p the first prime. + * @param q the second prime. + * @param dP exponent1 (d mod (p-1)). + * @param dQ exponent2 (d mod (q-1)). + * @param qInv ((inverse of q) mod p) + * + * @return the private key. + */ +pki.setRsaPrivateKey = pki.rsa.setPrivateKey = function( + n, e, d, p, q, dP, dQ, qInv) { + var key = { + n: n, + e: e, + d: d, + p: p, + q: q, + dP: dP, + dQ: dQ, + qInv: qInv + }; + + /** + * Decrypts the given data with this private key. The decryption scheme + * must match the one used to encrypt the data. + * + * @param data the byte string to decrypt. + * @param scheme the decryption scheme to use: + * 'RSAES-PKCS1-V1_5' (default), + * 'RSA-OAEP', + * 'RAW', 'NONE', or null to perform raw RSA decryption. + * @param schemeOptions any scheme-specific options. + * + * @return the decrypted byte string. + */ + key.decrypt = function(data, scheme, schemeOptions) { + if(typeof scheme === 'string') { + scheme = scheme.toUpperCase(); + } else if(scheme === undefined) { + scheme = 'RSAES-PKCS1-V1_5'; + } + + // do rsa decryption w/o any decoding + var d = pki.rsa.decrypt(data, key, false, false); + + if(scheme === 'RSAES-PKCS1-V1_5') { + scheme = {decode: _decodePkcs1_v1_5}; + } else if(scheme === 'RSA-OAEP' || scheme === 'RSAES-OAEP') { + scheme = { + decode: function(d, key) { + return forge.pkcs1.decode_rsa_oaep(key, d, schemeOptions); + } + }; + } else if(['RAW', 'NONE', 'NULL', null].indexOf(scheme) !== -1) { + scheme = {decode: function(d) {return d;}}; + } else { + throw new Error('Unsupported encryption scheme: "' + scheme + '".'); + } + + // decode according to scheme + return scheme.decode(d, key, false); + }; + + /** + * Signs the given digest, producing a signature. + * + * PKCS#1 supports multiple (currently two) signature schemes: + * RSASSA-PKCS1-V1_5 and RSASSA-PSS. + * + * By default this implementation uses the "old scheme", i.e. + * RSASSA-PKCS1-V1_5. In order to generate a PSS signature, provide + * an instance of Forge PSS object as the scheme parameter. + * + * @param md the message digest object with the hash to sign. + * @param scheme the signature scheme to use: + * 'RSASSA-PKCS1-V1_5' or undefined for RSASSA PKCS#1 v1.5, + * a Forge PSS object for RSASSA-PSS, + * 'NONE' or null for none, DigestInfo will not be used but + * PKCS#1 v1.5 padding will still be used. + * + * @return the signature as a byte string. + */ + key.sign = function(md, scheme) { + /* Note: The internal implementation of RSA operations is being + transitioned away from a PKCS#1 v1.5 hard-coded scheme. Some legacy + code like the use of an encoding block identifier 'bt' will eventually + be removed. */ + + // private key operation + var bt = false; + + if(typeof scheme === 'string') { + scheme = scheme.toUpperCase(); + } + + if(scheme === undefined || scheme === 'RSASSA-PKCS1-V1_5') { + scheme = {encode: emsaPkcs1v15encode}; + bt = 0x01; + } else if(scheme === 'NONE' || scheme === 'NULL' || scheme === null) { + scheme = {encode: function() {return md;}}; + bt = 0x01; + } + + // encode and then encrypt + var d = scheme.encode(md, key.n.bitLength()); + return pki.rsa.encrypt(d, key, bt); + }; + + return key; +}; + +/** + * Wraps an RSAPrivateKey ASN.1 object in an ASN.1 PrivateKeyInfo object. + * + * @param rsaKey the ASN.1 RSAPrivateKey. + * + * @return the ASN.1 PrivateKeyInfo. + */ +pki.wrapRsaPrivateKey = function(rsaKey) { + // PrivateKeyInfo + return asn1.create(asn1.Class.UNIVERSAL, asn1.Type.SEQUENCE, true, [ + // version (0) + asn1.create(asn1.Class.UNIVERSAL, asn1.Type.INTEGER, false, + asn1.integerToDer(0).getBytes()), + // privateKeyAlgorithm + asn1.create(asn1.Class.UNIVERSAL, asn1.Type.SEQUENCE, true, [ + asn1.create( + asn1.Class.UNIVERSAL, asn1.Type.OID, false, + asn1.oidToDer(pki.oids.rsaEncryption).getBytes()), + asn1.create(asn1.Class.UNIVERSAL, asn1.Type.NULL, false, '') + ]), + // PrivateKey + asn1.create(asn1.Class.UNIVERSAL, asn1.Type.OCTETSTRING, false, + asn1.toDer(rsaKey).getBytes()) + ]); +}; + +/** + * Converts a private key from an ASN.1 object. + * + * @param obj the ASN.1 representation of a PrivateKeyInfo containing an + * RSAPrivateKey or an RSAPrivateKey. + * + * @return the private key. + */ +pki.privateKeyFromAsn1 = function(obj) { + // get PrivateKeyInfo + var capture = {}; + var errors = []; + if(asn1.validate(obj, privateKeyValidator, capture, errors)) { + obj = asn1.fromDer(forge.util.createBuffer(capture.privateKey)); + } + + // get RSAPrivateKey + capture = {}; + errors = []; + if(!asn1.validate(obj, rsaPrivateKeyValidator, capture, errors)) { + var error = new Error('Cannot read private key. ' + + 'ASN.1 object does not contain an RSAPrivateKey.'); + error.errors = errors; + throw error; + } + + // Note: Version is currently ignored. + // capture.privateKeyVersion + // FIXME: inefficient, get a BigInteger that uses byte strings + var n, e, d, p, q, dP, dQ, qInv; + n = forge.util.createBuffer(capture.privateKeyModulus).toHex(); + e = forge.util.createBuffer(capture.privateKeyPublicExponent).toHex(); + d = forge.util.createBuffer(capture.privateKeyPrivateExponent).toHex(); + p = forge.util.createBuffer(capture.privateKeyPrime1).toHex(); + q = forge.util.createBuffer(capture.privateKeyPrime2).toHex(); + dP = forge.util.createBuffer(capture.privateKeyExponent1).toHex(); + dQ = forge.util.createBuffer(capture.privateKeyExponent2).toHex(); + qInv = forge.util.createBuffer(capture.privateKeyCoefficient).toHex(); + + // set private key + return pki.setRsaPrivateKey( + new BigInteger(n, 16), + new BigInteger(e, 16), + new BigInteger(d, 16), + new BigInteger(p, 16), + new BigInteger(q, 16), + new BigInteger(dP, 16), + new BigInteger(dQ, 16), + new BigInteger(qInv, 16)); +}; + +/** + * Converts a private key to an ASN.1 RSAPrivateKey. + * + * @param key the private key. + * + * @return the ASN.1 representation of an RSAPrivateKey. + */ +pki.privateKeyToAsn1 = pki.privateKeyToRSAPrivateKey = function(key) { + // RSAPrivateKey + return asn1.create(asn1.Class.UNIVERSAL, asn1.Type.SEQUENCE, true, [ + // version (0 = only 2 primes, 1 multiple primes) + asn1.create(asn1.Class.UNIVERSAL, asn1.Type.INTEGER, false, + asn1.integerToDer(0).getBytes()), + // modulus (n) + asn1.create(asn1.Class.UNIVERSAL, asn1.Type.INTEGER, false, + _bnToBytes(key.n)), + // publicExponent (e) + asn1.create(asn1.Class.UNIVERSAL, asn1.Type.INTEGER, false, + _bnToBytes(key.e)), + // privateExponent (d) + asn1.create(asn1.Class.UNIVERSAL, asn1.Type.INTEGER, false, + _bnToBytes(key.d)), + // privateKeyPrime1 (p) + asn1.create(asn1.Class.UNIVERSAL, asn1.Type.INTEGER, false, + _bnToBytes(key.p)), + // privateKeyPrime2 (q) + asn1.create(asn1.Class.UNIVERSAL, asn1.Type.INTEGER, false, + _bnToBytes(key.q)), + // privateKeyExponent1 (dP) + asn1.create(asn1.Class.UNIVERSAL, asn1.Type.INTEGER, false, + _bnToBytes(key.dP)), + // privateKeyExponent2 (dQ) + asn1.create(asn1.Class.UNIVERSAL, asn1.Type.INTEGER, false, + _bnToBytes(key.dQ)), + // coefficient (qInv) + asn1.create(asn1.Class.UNIVERSAL, asn1.Type.INTEGER, false, + _bnToBytes(key.qInv)) + ]); +}; + +/** + * Converts a public key from an ASN.1 SubjectPublicKeyInfo or RSAPublicKey. + * + * @param obj the asn1 representation of a SubjectPublicKeyInfo or RSAPublicKey. + * + * @return the public key. + */ +pki.publicKeyFromAsn1 = function(obj) { + // get SubjectPublicKeyInfo + var capture = {}; + var errors = []; + if(asn1.validate(obj, publicKeyValidator, capture, errors)) { + // get oid + var oid = asn1.derToOid(capture.publicKeyOid); + if(oid !== pki.oids.rsaEncryption) { + var error = new Error('Cannot read public key. Unknown OID.'); + error.oid = oid; + throw error; + } + obj = capture.rsaPublicKey; + } + + // get RSA params + errors = []; + if(!asn1.validate(obj, rsaPublicKeyValidator, capture, errors)) { + var error = new Error('Cannot read public key. ' + + 'ASN.1 object does not contain an RSAPublicKey.'); + error.errors = errors; + throw error; + } + + // FIXME: inefficient, get a BigInteger that uses byte strings + var n = forge.util.createBuffer(capture.publicKeyModulus).toHex(); + var e = forge.util.createBuffer(capture.publicKeyExponent).toHex(); + + // set public key + return pki.setRsaPublicKey( + new BigInteger(n, 16), + new BigInteger(e, 16)); +}; + +/** + * Converts a public key to an ASN.1 SubjectPublicKeyInfo. + * + * @param key the public key. + * + * @return the asn1 representation of a SubjectPublicKeyInfo. + */ +pki.publicKeyToAsn1 = pki.publicKeyToSubjectPublicKeyInfo = function(key) { + // SubjectPublicKeyInfo + return asn1.create(asn1.Class.UNIVERSAL, asn1.Type.SEQUENCE, true, [ + // AlgorithmIdentifier + asn1.create(asn1.Class.UNIVERSAL, asn1.Type.SEQUENCE, true, [ + // algorithm + asn1.create(asn1.Class.UNIVERSAL, asn1.Type.OID, false, + asn1.oidToDer(pki.oids.rsaEncryption).getBytes()), + // parameters (null) + asn1.create(asn1.Class.UNIVERSAL, asn1.Type.NULL, false, '') + ]), + // subjectPublicKey + asn1.create(asn1.Class.UNIVERSAL, asn1.Type.BITSTRING, false, [ + pki.publicKeyToRSAPublicKey(key) + ]) + ]); +}; + +/** + * Converts a public key to an ASN.1 RSAPublicKey. + * + * @param key the public key. + * + * @return the asn1 representation of a RSAPublicKey. + */ +pki.publicKeyToRSAPublicKey = function(key) { + // RSAPublicKey + return asn1.create(asn1.Class.UNIVERSAL, asn1.Type.SEQUENCE, true, [ + // modulus (n) + asn1.create(asn1.Class.UNIVERSAL, asn1.Type.INTEGER, false, + _bnToBytes(key.n)), + // publicExponent (e) + asn1.create(asn1.Class.UNIVERSAL, asn1.Type.INTEGER, false, + _bnToBytes(key.e)) + ]); +}; + +/** + * Encodes a message using PKCS#1 v1.5 padding. + * + * @param m the message to encode. + * @param key the RSA key to use. + * @param bt the block type to use, i.e. either 0x01 (for signing) or 0x02 + * (for encryption). + * + * @return the padded byte buffer. + */ +function _encodePkcs1_v1_5(m, key, bt) { + var eb = forge.util.createBuffer(); + + // get the length of the modulus in bytes + var k = Math.ceil(key.n.bitLength() / 8); + + /* use PKCS#1 v1.5 padding */ + if(m.length > (k - 11)) { + var error = new Error('Message is too long for PKCS#1 v1.5 padding.'); + error.length = m.length; + error.max = k - 11; + throw error; + } + + /* A block type BT, a padding string PS, and the data D shall be + formatted into an octet string EB, the encryption block: + + EB = 00 || BT || PS || 00 || D + + The block type BT shall be a single octet indicating the structure of + the encryption block. For this version of the document it shall have + value 00, 01, or 02. For a private-key operation, the block type + shall be 00 or 01. For a public-key operation, it shall be 02. + + The padding string PS shall consist of k-3-||D|| octets. For block + type 00, the octets shall have value 00; for block type 01, they + shall have value FF; and for block type 02, they shall be + pseudorandomly generated and nonzero. This makes the length of the + encryption block EB equal to k. */ + + // build the encryption block + eb.putByte(0x00); + eb.putByte(bt); + + // create the padding + var padNum = k - 3 - m.length; + var padByte; + // private key op + if(bt === 0x00 || bt === 0x01) { + padByte = (bt === 0x00) ? 0x00 : 0xFF; + for(var i = 0; i < padNum; ++i) { + eb.putByte(padByte); + } + } else { + // public key op + // pad with random non-zero values + while(padNum > 0) { + var numZeros = 0; + var padBytes = forge.random.getBytes(padNum); + for(var i = 0; i < padNum; ++i) { + padByte = padBytes.charCodeAt(i); + if(padByte === 0) { + ++numZeros; + } else { + eb.putByte(padByte); + } + } + padNum = numZeros; + } + } + + // zero followed by message + eb.putByte(0x00); + eb.putBytes(m); + + return eb; +} + +/** + * Decodes a message using PKCS#1 v1.5 padding. + * + * @param em the message to decode. + * @param key the RSA key to use. + * @param pub true if the key is a public key, false if it is private. + * @param ml the message length, if specified. + * + * @return the decoded bytes. + */ +function _decodePkcs1_v1_5(em, key, pub, ml) { + // get the length of the modulus in bytes + var k = Math.ceil(key.n.bitLength() / 8); + + /* It is an error if any of the following conditions occurs: + + 1. The encryption block EB cannot be parsed unambiguously. + 2. The padding string PS consists of fewer than eight octets + or is inconsistent with the block type BT. + 3. The decryption process is a public-key operation and the block + type BT is not 00 or 01, or the decryption process is a + private-key operation and the block type is not 02. + */ + + // parse the encryption block + var eb = forge.util.createBuffer(em); + var first = eb.getByte(); + var bt = eb.getByte(); + if(first !== 0x00 || + (pub && bt !== 0x00 && bt !== 0x01) || + (!pub && bt != 0x02) || + (pub && bt === 0x00 && typeof(ml) === 'undefined')) { + throw new Error('Encryption block is invalid.'); + } + + var padNum = 0; + if(bt === 0x00) { + // check all padding bytes for 0x00 + padNum = k - 3 - ml; + for(var i = 0; i < padNum; ++i) { + if(eb.getByte() !== 0x00) { + throw new Error('Encryption block is invalid.'); + } + } + } else if(bt === 0x01) { + // find the first byte that isn't 0xFF, should be after all padding + padNum = 0; + while(eb.length() > 1) { + if(eb.getByte() !== 0xFF) { + --eb.read; + break; + } + ++padNum; + } + } else if(bt === 0x02) { + // look for 0x00 byte + padNum = 0; + while(eb.length() > 1) { + if(eb.getByte() === 0x00) { + --eb.read; + break; + } + ++padNum; + } + } + + // zero must be 0x00 and padNum must be (k - 3 - message length) + var zero = eb.getByte(); + if(zero !== 0x00 || padNum !== (k - 3 - eb.length())) { + throw new Error('Encryption block is invalid.'); + } + + return eb.getBytes(); +} + +/** + * Runs the key-generation algorithm asynchronously, either in the background + * via Web Workers, or using the main thread and setImmediate. + * + * @param state the key-pair generation state. + * @param [options] options for key-pair generation: + * workerScript the worker script URL. + * workers the number of web workers (if supported) to use, + * (default: 2, -1 to use estimated cores minus one). + * workLoad the size of the work load, ie: number of possible prime + * numbers for each web worker to check per work assignment, + * (default: 100). + * @param callback(err, keypair) called once the operation completes. + */ +function _generateKeyPair(state, options, callback) { + if(typeof options === 'function') { + callback = options; + options = {}; + } + options = options || {}; + + var opts = { + algorithm: { + name: options.algorithm || 'PRIMEINC', + options: { + workers: options.workers || 2, + workLoad: options.workLoad || 100, + workerScript: options.workerScript + } + } + }; + if('prng' in options) { + opts.prng = options.prng; + } + + generate(); + + function generate() { + // find p and then q (done in series to simplify) + getPrime(state.pBits, function(err, num) { + if(err) { + return callback(err); + } + state.p = num; + if(state.q !== null) { + return finish(err, state.q); + } + getPrime(state.qBits, finish); + }); + } + + function getPrime(bits, callback) { + forge.prime.generateProbablePrime(bits, opts, callback); + } + + function finish(err, num) { + if(err) { + return callback(err); + } + + // set q + state.q = num; + + // ensure p is larger than q (swap them if not) + if(state.p.compareTo(state.q) < 0) { + var tmp = state.p; + state.p = state.q; + state.q = tmp; + } + + // ensure p is coprime with e + if(state.p.subtract(BigInteger.ONE).gcd(state.e) + .compareTo(BigInteger.ONE) !== 0) { + state.p = null; + generate(); + return; + } + + // ensure q is coprime with e + if(state.q.subtract(BigInteger.ONE).gcd(state.e) + .compareTo(BigInteger.ONE) !== 0) { + state.q = null; + getPrime(state.qBits, finish); + return; + } + + // compute phi: (p - 1)(q - 1) (Euler's totient function) + state.p1 = state.p.subtract(BigInteger.ONE); + state.q1 = state.q.subtract(BigInteger.ONE); + state.phi = state.p1.multiply(state.q1); + + // ensure e and phi are coprime + if(state.phi.gcd(state.e).compareTo(BigInteger.ONE) !== 0) { + // phi and e aren't coprime, so generate a new p and q + state.p = state.q = null; + generate(); + return; + } + + // create n, ensure n is has the right number of bits + state.n = state.p.multiply(state.q); + if(state.n.bitLength() !== state.bits) { + // failed, get new q + state.q = null; + getPrime(state.qBits, finish); + return; + } + + // set keys + var d = state.e.modInverse(state.phi); + state.keys = { + privateKey: pki.rsa.setPrivateKey( + state.n, state.e, d, state.p, state.q, + d.mod(state.p1), d.mod(state.q1), + state.q.modInverse(state.p)), + publicKey: pki.rsa.setPublicKey(state.n, state.e) + }; + + callback(null, state.keys); + } +} + +/** + * Converts a positive BigInteger into 2's-complement big-endian bytes. + * + * @param b the big integer to convert. + * + * @return the bytes. + */ +function _bnToBytes(b) { + // prepend 0x00 if first byte >= 0x80 + var hex = b.toString(16); + if(hex[0] >= '8') { + hex = '00' + hex; + } + var bytes = forge.util.hexToBytes(hex); + + // ensure integer is minimally-encoded + if(bytes.length > 1 && + // leading 0x00 for positive integer + ((bytes.charCodeAt(0) === 0 && + (bytes.charCodeAt(1) & 0x80) === 0) || + // leading 0xFF for negative integer + (bytes.charCodeAt(0) === 0xFF && + (bytes.charCodeAt(1) & 0x80) === 0x80))) { + return bytes.substr(1); + } + return bytes; +} + +/** + * Returns the required number of Miller-Rabin tests to generate a + * prime with an error probability of (1/2)^80. + * + * See Handbook of Applied Cryptography Chapter 4, Table 4.4. + * + * @param bits the bit size. + * + * @return the required number of iterations. + */ +function _getMillerRabinTests(bits) { + if(bits <= 100) return 27; + if(bits <= 150) return 18; + if(bits <= 200) return 15; + if(bits <= 250) return 12; + if(bits <= 300) return 9; + if(bits <= 350) return 8; + if(bits <= 400) return 7; + if(bits <= 500) return 6; + if(bits <= 600) return 5; + if(bits <= 800) return 4; + if(bits <= 1250) return 3; + return 2; +} + +/** + * Performs feature detection on the Node crypto interface. + * + * @param fn the feature (function) to detect. + * + * @return true if detected, false if not. + */ +function _detectNodeCrypto(fn) { + return forge.util.isNodejs && typeof _crypto[fn] === 'function'; +} + +/** + * Performs feature detection on the SubtleCrypto interface. + * + * @param fn the feature (function) to detect. + * + * @return true if detected, false if not. + */ +function _detectSubtleCrypto(fn) { + var globalScope = util.globalScope || {}; + return (typeof globalScope.crypto === 'object' && + typeof globalScope.crypto.subtle === 'object' && + typeof globalScope.crypto.subtle[fn] === 'function'); +} + +/** + * Performs feature detection on the deprecated Microsoft Internet Explorer + * outdated SubtleCrypto interface. This function should only be used after + * checking for the modern, standard SubtleCrypto interface. + * + * @param fn the feature (function) to detect. + * + * @return true if detected, false if not. + */ +function _detectSubtleMsCrypto(fn) { + var globalScope = util.globalScope || {}; + return (typeof globalScope.msCrypto === 'object' && + typeof globalScope.msCrypto.subtle === 'object' && + typeof globalScope.msCrypto.subtle[fn] === 'function'); +} + +function _intToUint8Array(x) { + var bytes = forge.util.hexToBytes(x.toString(16)); + var buffer = new Uint8Array(bytes.length); + for(var i = 0; i < bytes.length; ++i) { + buffer[i] = bytes.charCodeAt(i); + } + return buffer; +} + +function _privateKeyFromJwk(jwk) { + if(jwk.kty !== 'RSA') { + throw new Error( + 'Unsupported key algorithm "' + jwk.kty + '"; algorithm must be "RSA".'); + } + return pki.setRsaPrivateKey( + _base64ToBigInt(jwk.n), + _base64ToBigInt(jwk.e), + _base64ToBigInt(jwk.d), + _base64ToBigInt(jwk.p), + _base64ToBigInt(jwk.q), + _base64ToBigInt(jwk.dp), + _base64ToBigInt(jwk.dq), + _base64ToBigInt(jwk.qi)); +} + +function _publicKeyFromJwk(jwk) { + if(jwk.kty !== 'RSA') { + throw new Error('Key algorithm must be "RSA".'); + } + return pki.setRsaPublicKey( + _base64ToBigInt(jwk.n), + _base64ToBigInt(jwk.e)); +} + +function _base64ToBigInt(b64) { + return new BigInteger(forge.util.bytesToHex(forge.util.decode64(b64)), 16); +} diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/vendor/node-forge/lib/util.js b/ruoyi-fastapi-frontend/vue2/mobile/src/vendor/node-forge/lib/util.js new file mode 100644 index 0000000..ba4ac56 --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/vendor/node-forge/lib/util.js @@ -0,0 +1,2652 @@ +/** + * Utility functions for web applications. + * + * @author Dave Longley + * + * Copyright (c) 2010-2018 Digital Bazaar, Inc. + */ +var forge = require('node-forge/lib/forge'); +var baseN = require('node-forge/lib/baseN'); + +/* Utilities API */ +var util = module.exports = forge.util = forge.util || {}; + +// define setImmediate and nextTick +(function() { + // use native nextTick (unless we're in webpack) + // webpack (or better node-libs-browser polyfill) sets process.browser. + // this way we can detect webpack properly + if(typeof process !== 'undefined' && process.nextTick && !process.browser) { + util.nextTick = process.nextTick; + if(typeof setImmediate === 'function') { + util.setImmediate = setImmediate; + } else { + // polyfill setImmediate with nextTick, older versions of node + // (those w/o setImmediate) won't totally starve IO + util.setImmediate = util.nextTick; + } + return; + } + + // polyfill nextTick with native setImmediate + if(typeof setImmediate === 'function') { + util.setImmediate = function() { return setImmediate.apply(undefined, arguments); }; + util.nextTick = function(callback) { + return setImmediate(callback); + }; + return; + } + + /* Note: A polyfill upgrade pattern is used here to allow combining + polyfills. For example, MutationObserver is fast, but blocks UI updates, + so it needs to allow UI updates periodically, so it falls back on + postMessage or setTimeout. */ + + // polyfill with setTimeout + util.setImmediate = function(callback) { + setTimeout(callback, 0); + }; + + // upgrade polyfill to use postMessage + if(typeof window !== 'undefined' && + typeof window.postMessage === 'function') { + var msg = 'forge.setImmediate'; + var callbacks = []; + util.setImmediate = function(callback) { + callbacks.push(callback); + // only send message when one hasn't been sent in + // the current turn of the event loop + if(callbacks.length === 1) { + window.postMessage(msg, '*'); + } + }; + function handler(event) { + if(event.source === window && event.data === msg) { + event.stopPropagation(); + var copy = callbacks.slice(); + callbacks.length = 0; + copy.forEach(function(callback) { + callback(); + }); + } + } + window.addEventListener('message', handler, true); + } + + // upgrade polyfill to use MutationObserver + if(typeof MutationObserver !== 'undefined') { + // polyfill with MutationObserver + var now = Date.now(); + var attr = true; + var div = document.createElement('div'); + var callbacks = []; + new MutationObserver(function() { + var copy = callbacks.slice(); + callbacks.length = 0; + copy.forEach(function(callback) { + callback(); + }); + }).observe(div, {attributes: true}); + var oldSetImmediate = util.setImmediate; + util.setImmediate = function(callback) { + if(Date.now() - now > 15) { + now = Date.now(); + oldSetImmediate(callback); + } else { + callbacks.push(callback); + // only trigger observer when it hasn't been triggered in + // the current turn of the event loop + if(callbacks.length === 1) { + div.setAttribute('a', attr = !attr); + } + } + }; + } + + util.nextTick = util.setImmediate; +})(); + +// check if running under Node.js +util.isNodejs = + typeof process !== 'undefined' && process.versions && process.versions.node; + + +// 'self' will also work in Web Workers (instance of WorkerGlobalScope) while +// it will point to `window` in the main thread. +// To remain compatible with older browsers, we fall back to 'window' if 'self' +// is not available. +util.globalScope = (function() { + if(util.isNodejs) { + return global; + } + + return typeof globalThis !== 'undefined' ? globalThis : (typeof self === 'undefined' ? (typeof window === 'undefined' ? {} : window) : self); +})(); + +// define isArray +util.isArray = Array.isArray || function(x) { + return Object.prototype.toString.call(x) === '[object Array]'; +}; + +// define isArrayBuffer +util.isArrayBuffer = function(x) { + return typeof ArrayBuffer !== 'undefined' && x instanceof ArrayBuffer; +}; + +// define isArrayBufferView +util.isArrayBufferView = function(x) { + return x && util.isArrayBuffer(x.buffer) && x.byteLength !== undefined; +}; + +/** + * Ensure a bits param is 8, 16, 24, or 32. Used to validate input for + * algorithms where bit manipulation, JavaScript limitations, and/or algorithm + * design only allow for byte operations of a limited size. + * + * @param n number of bits. + * + * Throw Error if n invalid. + */ +function _checkBitsParam(n) { + if(!(n === 8 || n === 16 || n === 24 || n === 32)) { + throw new Error('Only 8, 16, 24, or 32 bits supported: ' + n); + } +} + +// TODO: set ByteBuffer to best available backing +util.ByteBuffer = ByteStringBuffer; + +/** Buffer w/BinaryString backing */ + +/** + * Constructor for a binary string backed byte buffer. + * + * @param [b] the bytes to wrap (either encoded as string, one byte per + * character, or as an ArrayBuffer or Typed Array). + */ +function ByteStringBuffer(b) { + // TODO: update to match DataBuffer API + + // the data in this buffer + this.data = ''; + // the pointer for reading from this buffer + this.read = 0; + + if(typeof b === 'string') { + this.data = b; + } else if(util.isArrayBuffer(b) || util.isArrayBufferView(b)) { + if(typeof Buffer !== 'undefined' && b instanceof Buffer) { + this.data = b.toString('binary'); + } else { + // convert native buffer to forge buffer + // FIXME: support native buffers internally instead + var arr = new Uint8Array(b); + try { + this.data = String.fromCharCode.apply(null, arr); + } catch(e) { + for(var i = 0; i < arr.length; ++i) { + this.putByte(arr[i]); + } + } + } + } else if(b instanceof ByteStringBuffer || + (typeof b === 'object' && typeof b.data === 'string' && + typeof b.read === 'number')) { + // copy existing buffer + this.data = b.data; + this.read = b.read; + } + + // used for v8 optimization + this._constructedStringLength = 0; +} +util.ByteStringBuffer = ByteStringBuffer; + +/* Note: This is an optimization for V8-based browsers. When V8 concatenates + a string, the strings are only joined logically using a "cons string" or + "constructed/concatenated string". These containers keep references to one + another and can result in very large memory usage. For example, if a 2MB + string is constructed by concatenating 4 bytes together at a time, the + memory usage will be ~44MB; so ~22x increase. The strings are only joined + together when an operation requiring their joining takes place, such as + substr(). This function is called when adding data to this buffer to ensure + these types of strings are periodically joined to reduce the memory + footprint. */ +var _MAX_CONSTRUCTED_STRING_LENGTH = 4096; +util.ByteStringBuffer.prototype._optimizeConstructedString = function(x) { + this._constructedStringLength += x; + if(this._constructedStringLength > _MAX_CONSTRUCTED_STRING_LENGTH) { + // this substr() should cause the constructed string to join + this.data.substr(0, 1); + this._constructedStringLength = 0; + } +}; + +/** + * Gets the number of bytes in this buffer. + * + * @return the number of bytes in this buffer. + */ +util.ByteStringBuffer.prototype.length = function() { + return this.data.length - this.read; +}; + +/** + * Gets whether or not this buffer is empty. + * + * @return true if this buffer is empty, false if not. + */ +util.ByteStringBuffer.prototype.isEmpty = function() { + return this.length() <= 0; +}; + +/** + * Puts a byte in this buffer. + * + * @param b the byte to put. + * + * @return this buffer. + */ +util.ByteStringBuffer.prototype.putByte = function(b) { + return this.putBytes(String.fromCharCode(b)); +}; + +/** + * Puts a byte in this buffer N times. + * + * @param b the byte to put. + * @param n the number of bytes of value b to put. + * + * @return this buffer. + */ +util.ByteStringBuffer.prototype.fillWithByte = function(b, n) { + b = String.fromCharCode(b); + var d = this.data; + while(n > 0) { + if(n & 1) { + d += b; + } + n >>>= 1; + if(n > 0) { + b += b; + } + } + this.data = d; + this._optimizeConstructedString(n); + return this; +}; + +/** + * Puts bytes in this buffer. + * + * @param bytes the bytes (as a binary encoded string) to put. + * + * @return this buffer. + */ +util.ByteStringBuffer.prototype.putBytes = function(bytes) { + this.data += bytes; + this._optimizeConstructedString(bytes.length); + return this; +}; + +/** + * Puts a UTF-16 encoded string into this buffer. + * + * @param str the string to put. + * + * @return this buffer. + */ +util.ByteStringBuffer.prototype.putString = function(str) { + return this.putBytes(util.encodeUtf8(str)); +}; + +/** + * Puts a 16-bit integer in this buffer in big-endian order. + * + * @param i the 16-bit integer. + * + * @return this buffer. + */ +util.ByteStringBuffer.prototype.putInt16 = function(i) { + return this.putBytes( + String.fromCharCode(i >> 8 & 0xFF) + + String.fromCharCode(i & 0xFF)); +}; + +/** + * Puts a 24-bit integer in this buffer in big-endian order. + * + * @param i the 24-bit integer. + * + * @return this buffer. + */ +util.ByteStringBuffer.prototype.putInt24 = function(i) { + return this.putBytes( + String.fromCharCode(i >> 16 & 0xFF) + + String.fromCharCode(i >> 8 & 0xFF) + + String.fromCharCode(i & 0xFF)); +}; + +/** + * Puts a 32-bit integer in this buffer in big-endian order. + * + * @param i the 32-bit integer. + * + * @return this buffer. + */ +util.ByteStringBuffer.prototype.putInt32 = function(i) { + return this.putBytes( + String.fromCharCode(i >> 24 & 0xFF) + + String.fromCharCode(i >> 16 & 0xFF) + + String.fromCharCode(i >> 8 & 0xFF) + + String.fromCharCode(i & 0xFF)); +}; + +/** + * Puts a 16-bit integer in this buffer in little-endian order. + * + * @param i the 16-bit integer. + * + * @return this buffer. + */ +util.ByteStringBuffer.prototype.putInt16Le = function(i) { + return this.putBytes( + String.fromCharCode(i & 0xFF) + + String.fromCharCode(i >> 8 & 0xFF)); +}; + +/** + * Puts a 24-bit integer in this buffer in little-endian order. + * + * @param i the 24-bit integer. + * + * @return this buffer. + */ +util.ByteStringBuffer.prototype.putInt24Le = function(i) { + return this.putBytes( + String.fromCharCode(i & 0xFF) + + String.fromCharCode(i >> 8 & 0xFF) + + String.fromCharCode(i >> 16 & 0xFF)); +}; + +/** + * Puts a 32-bit integer in this buffer in little-endian order. + * + * @param i the 32-bit integer. + * + * @return this buffer. + */ +util.ByteStringBuffer.prototype.putInt32Le = function(i) { + return this.putBytes( + String.fromCharCode(i & 0xFF) + + String.fromCharCode(i >> 8 & 0xFF) + + String.fromCharCode(i >> 16 & 0xFF) + + String.fromCharCode(i >> 24 & 0xFF)); +}; + +/** + * Puts an n-bit integer in this buffer in big-endian order. + * + * @param i the n-bit integer. + * @param n the number of bits in the integer (8, 16, 24, or 32). + * + * @return this buffer. + */ +util.ByteStringBuffer.prototype.putInt = function(i, n) { + _checkBitsParam(n); + var bytes = ''; + do { + n -= 8; + bytes += String.fromCharCode((i >> n) & 0xFF); + } while(n > 0); + return this.putBytes(bytes); +}; + +/** + * Puts a signed n-bit integer in this buffer in big-endian order. Two's + * complement representation is used. + * + * @param i the n-bit integer. + * @param n the number of bits in the integer (8, 16, 24, or 32). + * + * @return this buffer. + */ +util.ByteStringBuffer.prototype.putSignedInt = function(i, n) { + // putInt checks n + if(i < 0) { + i += 2 << (n - 1); + } + return this.putInt(i, n); +}; + +/** + * Puts the given buffer into this buffer. + * + * @param buffer the buffer to put into this one. + * + * @return this buffer. + */ +util.ByteStringBuffer.prototype.putBuffer = function(buffer) { + return this.putBytes(buffer.getBytes()); +}; + +/** + * Gets a byte from this buffer and advances the read pointer by 1. + * + * @return the byte. + */ +util.ByteStringBuffer.prototype.getByte = function() { + return this.data.charCodeAt(this.read++); +}; + +/** + * Gets a uint16 from this buffer in big-endian order and advances the read + * pointer by 2. + * + * @return the uint16. + */ +util.ByteStringBuffer.prototype.getInt16 = function() { + var rval = ( + this.data.charCodeAt(this.read) << 8 ^ + this.data.charCodeAt(this.read + 1)); + this.read += 2; + return rval; +}; + +/** + * Gets a uint24 from this buffer in big-endian order and advances the read + * pointer by 3. + * + * @return the uint24. + */ +util.ByteStringBuffer.prototype.getInt24 = function() { + var rval = ( + this.data.charCodeAt(this.read) << 16 ^ + this.data.charCodeAt(this.read + 1) << 8 ^ + this.data.charCodeAt(this.read + 2)); + this.read += 3; + return rval; +}; + +/** + * Gets a uint32 from this buffer in big-endian order and advances the read + * pointer by 4. + * + * @return the word. + */ +util.ByteStringBuffer.prototype.getInt32 = function() { + var rval = ( + this.data.charCodeAt(this.read) << 24 ^ + this.data.charCodeAt(this.read + 1) << 16 ^ + this.data.charCodeAt(this.read + 2) << 8 ^ + this.data.charCodeAt(this.read + 3)); + this.read += 4; + return rval; +}; + +/** + * Gets a uint16 from this buffer in little-endian order and advances the read + * pointer by 2. + * + * @return the uint16. + */ +util.ByteStringBuffer.prototype.getInt16Le = function() { + var rval = ( + this.data.charCodeAt(this.read) ^ + this.data.charCodeAt(this.read + 1) << 8); + this.read += 2; + return rval; +}; + +/** + * Gets a uint24 from this buffer in little-endian order and advances the read + * pointer by 3. + * + * @return the uint24. + */ +util.ByteStringBuffer.prototype.getInt24Le = function() { + var rval = ( + this.data.charCodeAt(this.read) ^ + this.data.charCodeAt(this.read + 1) << 8 ^ + this.data.charCodeAt(this.read + 2) << 16); + this.read += 3; + return rval; +}; + +/** + * Gets a uint32 from this buffer in little-endian order and advances the read + * pointer by 4. + * + * @return the word. + */ +util.ByteStringBuffer.prototype.getInt32Le = function() { + var rval = ( + this.data.charCodeAt(this.read) ^ + this.data.charCodeAt(this.read + 1) << 8 ^ + this.data.charCodeAt(this.read + 2) << 16 ^ + this.data.charCodeAt(this.read + 3) << 24); + this.read += 4; + return rval; +}; + +/** + * Gets an n-bit integer from this buffer in big-endian order and advances the + * read pointer by ceil(n/8). + * + * @param n the number of bits in the integer (8, 16, 24, or 32). + * + * @return the integer. + */ +util.ByteStringBuffer.prototype.getInt = function(n) { + _checkBitsParam(n); + var rval = 0; + do { + // TODO: Use (rval * 0x100) if adding support for 33 to 53 bits. + rval = (rval << 8) + this.data.charCodeAt(this.read++); + n -= 8; + } while(n > 0); + return rval; +}; + +/** + * Gets a signed n-bit integer from this buffer in big-endian order, using + * two's complement, and advances the read pointer by n/8. + * + * @param n the number of bits in the integer (8, 16, 24, or 32). + * + * @return the integer. + */ +util.ByteStringBuffer.prototype.getSignedInt = function(n) { + // getInt checks n + var x = this.getInt(n); + var max = 2 << (n - 2); + if(x >= max) { + x -= max << 1; + } + return x; +}; + +/** + * Reads bytes out as a binary encoded string and clears them from the + * buffer. Note that the resulting string is binary encoded (in node.js this + * encoding is referred to as `binary`, it is *not* `utf8`). + * + * @param count the number of bytes to read, undefined or null for all. + * + * @return a binary encoded string of bytes. + */ +util.ByteStringBuffer.prototype.getBytes = function(count) { + var rval; + if(count) { + // read count bytes + count = Math.min(this.length(), count); + rval = this.data.slice(this.read, this.read + count); + this.read += count; + } else if(count === 0) { + rval = ''; + } else { + // read all bytes, optimize to only copy when needed + rval = (this.read === 0) ? this.data : this.data.slice(this.read); + this.clear(); + } + return rval; +}; + +/** + * Gets a binary encoded string of the bytes from this buffer without + * modifying the read pointer. + * + * @param count the number of bytes to get, omit to get all. + * + * @return a string full of binary encoded characters. + */ +util.ByteStringBuffer.prototype.bytes = function(count) { + return (typeof(count) === 'undefined' ? + this.data.slice(this.read) : + this.data.slice(this.read, this.read + count)); +}; + +/** + * Gets a byte at the given index without modifying the read pointer. + * + * @param i the byte index. + * + * @return the byte. + */ +util.ByteStringBuffer.prototype.at = function(i) { + return this.data.charCodeAt(this.read + i); +}; + +/** + * Puts a byte at the given index without modifying the read pointer. + * + * @param i the byte index. + * @param b the byte to put. + * + * @return this buffer. + */ +util.ByteStringBuffer.prototype.setAt = function(i, b) { + this.data = this.data.substr(0, this.read + i) + + String.fromCharCode(b) + + this.data.substr(this.read + i + 1); + return this; +}; + +/** + * Gets the last byte without modifying the read pointer. + * + * @return the last byte. + */ +util.ByteStringBuffer.prototype.last = function() { + return this.data.charCodeAt(this.data.length - 1); +}; + +/** + * Creates a copy of this buffer. + * + * @return the copy. + */ +util.ByteStringBuffer.prototype.copy = function() { + var c = util.createBuffer(this.data); + c.read = this.read; + return c; +}; + +/** + * Compacts this buffer. + * + * @return this buffer. + */ +util.ByteStringBuffer.prototype.compact = function() { + if(this.read > 0) { + this.data = this.data.slice(this.read); + this.read = 0; + } + return this; +}; + +/** + * Clears this buffer. + * + * @return this buffer. + */ +util.ByteStringBuffer.prototype.clear = function() { + this.data = ''; + this.read = 0; + return this; +}; + +/** + * Shortens this buffer by trimming bytes off of the end of this buffer. + * + * @param count the number of bytes to trim off. + * + * @return this buffer. + */ +util.ByteStringBuffer.prototype.truncate = function(count) { + var len = Math.max(0, this.length() - count); + this.data = this.data.substr(this.read, len); + this.read = 0; + return this; +}; + +/** + * Converts this buffer to a hexadecimal string. + * + * @return a hexadecimal string. + */ +util.ByteStringBuffer.prototype.toHex = function() { + var rval = ''; + for(var i = this.read; i < this.data.length; ++i) { + var b = this.data.charCodeAt(i); + if(b < 16) { + rval += '0'; + } + rval += b.toString(16); + } + return rval; +}; + +/** + * Converts this buffer to a UTF-16 string (standard JavaScript string). + * + * @return a UTF-16 string. + */ +util.ByteStringBuffer.prototype.toString = function() { + return util.decodeUtf8(this.bytes()); +}; + +/** End Buffer w/BinaryString backing */ + +/** Buffer w/UInt8Array backing */ + +/** + * FIXME: Experimental. Do not use yet. + * + * Constructor for an ArrayBuffer-backed byte buffer. + * + * The buffer may be constructed from a string, an ArrayBuffer, DataView, or a + * TypedArray. + * + * If a string is given, its encoding should be provided as an option, + * otherwise it will default to 'binary'. A 'binary' string is encoded such + * that each character is one byte in length and size. + * + * If an ArrayBuffer, DataView, or TypedArray is given, it will be used + * *directly* without any copying. Note that, if a write to the buffer requires + * more space, the buffer will allocate a new backing ArrayBuffer to + * accommodate. The starting read and write offsets for the buffer may be + * given as options. + * + * @param [b] the initial bytes for this buffer. + * @param options the options to use: + * [readOffset] the starting read offset to use (default: 0). + * [writeOffset] the starting write offset to use (default: the + * length of the first parameter). + * [growSize] the minimum amount, in bytes, to grow the buffer by to + * accommodate writes (default: 1024). + * [encoding] the encoding ('binary', 'utf8', 'utf16', 'hex') for the + * first parameter, if it is a string (default: 'binary'). + */ +function DataBuffer(b, options) { + // default options + options = options || {}; + + // pointers for read from/write to buffer + this.read = options.readOffset || 0; + this.growSize = options.growSize || 1024; + + var isArrayBuffer = util.isArrayBuffer(b); + var isArrayBufferView = util.isArrayBufferView(b); + if(isArrayBuffer || isArrayBufferView) { + // use ArrayBuffer directly + if(isArrayBuffer) { + this.data = new DataView(b); + } else { + // TODO: adjust read/write offset based on the type of view + // or specify that this must be done in the options ... that the + // offsets are byte-based + this.data = new DataView(b.buffer, b.byteOffset, b.byteLength); + } + this.write = ('writeOffset' in options ? + options.writeOffset : this.data.byteLength); + return; + } + + // initialize to empty array buffer and add any given bytes using putBytes + this.data = new DataView(new ArrayBuffer(0)); + this.write = 0; + + if(b !== null && b !== undefined) { + this.putBytes(b); + } + + if('writeOffset' in options) { + this.write = options.writeOffset; + } +} +util.DataBuffer = DataBuffer; + +/** + * Gets the number of bytes in this buffer. + * + * @return the number of bytes in this buffer. + */ +util.DataBuffer.prototype.length = function() { + return this.write - this.read; +}; + +/** + * Gets whether or not this buffer is empty. + * + * @return true if this buffer is empty, false if not. + */ +util.DataBuffer.prototype.isEmpty = function() { + return this.length() <= 0; +}; + +/** + * Ensures this buffer has enough empty space to accommodate the given number + * of bytes. An optional parameter may be given that indicates a minimum + * amount to grow the buffer if necessary. If the parameter is not given, + * the buffer will be grown by some previously-specified default amount + * or heuristic. + * + * @param amount the number of bytes to accommodate. + * @param [growSize] the minimum amount, in bytes, to grow the buffer by if + * necessary. + */ +util.DataBuffer.prototype.accommodate = function(amount, growSize) { + if(this.length() >= amount) { + return this; + } + growSize = Math.max(growSize || this.growSize, amount); + + // grow buffer + var src = new Uint8Array( + this.data.buffer, this.data.byteOffset, this.data.byteLength); + var dst = new Uint8Array(this.length() + growSize); + dst.set(src); + this.data = new DataView(dst.buffer); + + return this; +}; + +/** + * Puts a byte in this buffer. + * + * @param b the byte to put. + * + * @return this buffer. + */ +util.DataBuffer.prototype.putByte = function(b) { + this.accommodate(1); + this.data.setUint8(this.write++, b); + return this; +}; + +/** + * Puts a byte in this buffer N times. + * + * @param b the byte to put. + * @param n the number of bytes of value b to put. + * + * @return this buffer. + */ +util.DataBuffer.prototype.fillWithByte = function(b, n) { + this.accommodate(n); + for(var i = 0; i < n; ++i) { + this.data.setUint8(b); + } + return this; +}; + +/** + * Puts bytes in this buffer. The bytes may be given as a string, an + * ArrayBuffer, a DataView, or a TypedArray. + * + * @param bytes the bytes to put. + * @param [encoding] the encoding for the first parameter ('binary', 'utf8', + * 'utf16', 'hex'), if it is a string (default: 'binary'). + * + * @return this buffer. + */ +util.DataBuffer.prototype.putBytes = function(bytes, encoding) { + if(util.isArrayBufferView(bytes)) { + var src = new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength); + var len = src.byteLength - src.byteOffset; + this.accommodate(len); + var dst = new Uint8Array(this.data.buffer, this.write); + dst.set(src); + this.write += len; + return this; + } + + if(util.isArrayBuffer(bytes)) { + var src = new Uint8Array(bytes); + this.accommodate(src.byteLength); + var dst = new Uint8Array(this.data.buffer); + dst.set(src, this.write); + this.write += src.byteLength; + return this; + } + + // bytes is a util.DataBuffer or equivalent + if(bytes instanceof util.DataBuffer || + (typeof bytes === 'object' && + typeof bytes.read === 'number' && typeof bytes.write === 'number' && + util.isArrayBufferView(bytes.data))) { + var src = new Uint8Array(bytes.data.byteLength, bytes.read, bytes.length()); + this.accommodate(src.byteLength); + var dst = new Uint8Array(bytes.data.byteLength, this.write); + dst.set(src); + this.write += src.byteLength; + return this; + } + + if(bytes instanceof util.ByteStringBuffer) { + // copy binary string and process as the same as a string parameter below + bytes = bytes.data; + encoding = 'binary'; + } + + // string conversion + encoding = encoding || 'binary'; + if(typeof bytes === 'string') { + var view; + + // decode from string + if(encoding === 'hex') { + this.accommodate(Math.ceil(bytes.length / 2)); + view = new Uint8Array(this.data.buffer, this.write); + this.write += util.binary.hex.decode(bytes, view, this.write); + return this; + } + if(encoding === 'base64') { + this.accommodate(Math.ceil(bytes.length / 4) * 3); + view = new Uint8Array(this.data.buffer, this.write); + this.write += util.binary.base64.decode(bytes, view, this.write); + return this; + } + + // encode text as UTF-8 bytes + if(encoding === 'utf8') { + // encode as UTF-8 then decode string as raw binary + bytes = util.encodeUtf8(bytes); + encoding = 'binary'; + } + + // decode string as raw binary + if(encoding === 'binary' || encoding === 'raw') { + // one byte per character + this.accommodate(bytes.length); + view = new Uint8Array(this.data.buffer, this.write); + this.write += util.binary.raw.decode(view); + return this; + } + + // encode text as UTF-16 bytes + if(encoding === 'utf16') { + // two bytes per character + this.accommodate(bytes.length * 2); + view = new Uint16Array(this.data.buffer, this.write); + this.write += util.text.utf16.encode(view); + return this; + } + + throw new Error('Invalid encoding: ' + encoding); + } + + throw Error('Invalid parameter: ' + bytes); +}; + +/** + * Puts the given buffer into this buffer. + * + * @param buffer the buffer to put into this one. + * + * @return this buffer. + */ +util.DataBuffer.prototype.putBuffer = function(buffer) { + this.putBytes(buffer); + buffer.clear(); + return this; +}; + +/** + * Puts a string into this buffer. + * + * @param str the string to put. + * @param [encoding] the encoding for the string (default: 'utf16'). + * + * @return this buffer. + */ +util.DataBuffer.prototype.putString = function(str) { + return this.putBytes(str, 'utf16'); +}; + +/** + * Puts a 16-bit integer in this buffer in big-endian order. + * + * @param i the 16-bit integer. + * + * @return this buffer. + */ +util.DataBuffer.prototype.putInt16 = function(i) { + this.accommodate(2); + this.data.setInt16(this.write, i); + this.write += 2; + return this; +}; + +/** + * Puts a 24-bit integer in this buffer in big-endian order. + * + * @param i the 24-bit integer. + * + * @return this buffer. + */ +util.DataBuffer.prototype.putInt24 = function(i) { + this.accommodate(3); + this.data.setInt16(this.write, i >> 8 & 0xFFFF); + this.data.setInt8(this.write, i >> 16 & 0xFF); + this.write += 3; + return this; +}; + +/** + * Puts a 32-bit integer in this buffer in big-endian order. + * + * @param i the 32-bit integer. + * + * @return this buffer. + */ +util.DataBuffer.prototype.putInt32 = function(i) { + this.accommodate(4); + this.data.setInt32(this.write, i); + this.write += 4; + return this; +}; + +/** + * Puts a 16-bit integer in this buffer in little-endian order. + * + * @param i the 16-bit integer. + * + * @return this buffer. + */ +util.DataBuffer.prototype.putInt16Le = function(i) { + this.accommodate(2); + this.data.setInt16(this.write, i, true); + this.write += 2; + return this; +}; + +/** + * Puts a 24-bit integer in this buffer in little-endian order. + * + * @param i the 24-bit integer. + * + * @return this buffer. + */ +util.DataBuffer.prototype.putInt24Le = function(i) { + this.accommodate(3); + this.data.setInt8(this.write, i >> 16 & 0xFF); + this.data.setInt16(this.write, i >> 8 & 0xFFFF, true); + this.write += 3; + return this; +}; + +/** + * Puts a 32-bit integer in this buffer in little-endian order. + * + * @param i the 32-bit integer. + * + * @return this buffer. + */ +util.DataBuffer.prototype.putInt32Le = function(i) { + this.accommodate(4); + this.data.setInt32(this.write, i, true); + this.write += 4; + return this; +}; + +/** + * Puts an n-bit integer in this buffer in big-endian order. + * + * @param i the n-bit integer. + * @param n the number of bits in the integer (8, 16, 24, or 32). + * + * @return this buffer. + */ +util.DataBuffer.prototype.putInt = function(i, n) { + _checkBitsParam(n); + this.accommodate(n / 8); + do { + n -= 8; + this.data.setInt8(this.write++, (i >> n) & 0xFF); + } while(n > 0); + return this; +}; + +/** + * Puts a signed n-bit integer in this buffer in big-endian order. Two's + * complement representation is used. + * + * @param i the n-bit integer. + * @param n the number of bits in the integer. + * + * @return this buffer. + */ +util.DataBuffer.prototype.putSignedInt = function(i, n) { + _checkBitsParam(n); + this.accommodate(n / 8); + if(i < 0) { + i += 2 << (n - 1); + } + return this.putInt(i, n); +}; + +/** + * Gets a byte from this buffer and advances the read pointer by 1. + * + * @return the byte. + */ +util.DataBuffer.prototype.getByte = function() { + return this.data.getInt8(this.read++); +}; + +/** + * Gets a uint16 from this buffer in big-endian order and advances the read + * pointer by 2. + * + * @return the uint16. + */ +util.DataBuffer.prototype.getInt16 = function() { + var rval = this.data.getInt16(this.read); + this.read += 2; + return rval; +}; + +/** + * Gets a uint24 from this buffer in big-endian order and advances the read + * pointer by 3. + * + * @return the uint24. + */ +util.DataBuffer.prototype.getInt24 = function() { + var rval = ( + this.data.getInt16(this.read) << 8 ^ + this.data.getInt8(this.read + 2)); + this.read += 3; + return rval; +}; + +/** + * Gets a uint32 from this buffer in big-endian order and advances the read + * pointer by 4. + * + * @return the word. + */ +util.DataBuffer.prototype.getInt32 = function() { + var rval = this.data.getInt32(this.read); + this.read += 4; + return rval; +}; + +/** + * Gets a uint16 from this buffer in little-endian order and advances the read + * pointer by 2. + * + * @return the uint16. + */ +util.DataBuffer.prototype.getInt16Le = function() { + var rval = this.data.getInt16(this.read, true); + this.read += 2; + return rval; +}; + +/** + * Gets a uint24 from this buffer in little-endian order and advances the read + * pointer by 3. + * + * @return the uint24. + */ +util.DataBuffer.prototype.getInt24Le = function() { + var rval = ( + this.data.getInt8(this.read) ^ + this.data.getInt16(this.read + 1, true) << 8); + this.read += 3; + return rval; +}; + +/** + * Gets a uint32 from this buffer in little-endian order and advances the read + * pointer by 4. + * + * @return the word. + */ +util.DataBuffer.prototype.getInt32Le = function() { + var rval = this.data.getInt32(this.read, true); + this.read += 4; + return rval; +}; + +/** + * Gets an n-bit integer from this buffer in big-endian order and advances the + * read pointer by n/8. + * + * @param n the number of bits in the integer (8, 16, 24, or 32). + * + * @return the integer. + */ +util.DataBuffer.prototype.getInt = function(n) { + _checkBitsParam(n); + var rval = 0; + do { + // TODO: Use (rval * 0x100) if adding support for 33 to 53 bits. + rval = (rval << 8) + this.data.getInt8(this.read++); + n -= 8; + } while(n > 0); + return rval; +}; + +/** + * Gets a signed n-bit integer from this buffer in big-endian order, using + * two's complement, and advances the read pointer by n/8. + * + * @param n the number of bits in the integer (8, 16, 24, or 32). + * + * @return the integer. + */ +util.DataBuffer.prototype.getSignedInt = function(n) { + // getInt checks n + var x = this.getInt(n); + var max = 2 << (n - 2); + if(x >= max) { + x -= max << 1; + } + return x; +}; + +/** + * Reads bytes out as a binary encoded string and clears them from the + * buffer. + * + * @param count the number of bytes to read, undefined or null for all. + * + * @return a binary encoded string of bytes. + */ +util.DataBuffer.prototype.getBytes = function(count) { + // TODO: deprecate this method, it is poorly named and + // this.toString('binary') replaces it + // add a toTypedArray()/toArrayBuffer() function + var rval; + if(count) { + // read count bytes + count = Math.min(this.length(), count); + rval = this.data.slice(this.read, this.read + count); + this.read += count; + } else if(count === 0) { + rval = ''; + } else { + // read all bytes, optimize to only copy when needed + rval = (this.read === 0) ? this.data : this.data.slice(this.read); + this.clear(); + } + return rval; +}; + +/** + * Gets a binary encoded string of the bytes from this buffer without + * modifying the read pointer. + * + * @param count the number of bytes to get, omit to get all. + * + * @return a string full of binary encoded characters. + */ +util.DataBuffer.prototype.bytes = function(count) { + // TODO: deprecate this method, it is poorly named, add "getString()" + return (typeof(count) === 'undefined' ? + this.data.slice(this.read) : + this.data.slice(this.read, this.read + count)); +}; + +/** + * Gets a byte at the given index without modifying the read pointer. + * + * @param i the byte index. + * + * @return the byte. + */ +util.DataBuffer.prototype.at = function(i) { + return this.data.getUint8(this.read + i); +}; + +/** + * Puts a byte at the given index without modifying the read pointer. + * + * @param i the byte index. + * @param b the byte to put. + * + * @return this buffer. + */ +util.DataBuffer.prototype.setAt = function(i, b) { + this.data.setUint8(i, b); + return this; +}; + +/** + * Gets the last byte without modifying the read pointer. + * + * @return the last byte. + */ +util.DataBuffer.prototype.last = function() { + return this.data.getUint8(this.write - 1); +}; + +/** + * Creates a copy of this buffer. + * + * @return the copy. + */ +util.DataBuffer.prototype.copy = function() { + return new util.DataBuffer(this); +}; + +/** + * Compacts this buffer. + * + * @return this buffer. + */ +util.DataBuffer.prototype.compact = function() { + if(this.read > 0) { + var src = new Uint8Array(this.data.buffer, this.read); + var dst = new Uint8Array(src.byteLength); + dst.set(src); + this.data = new DataView(dst); + this.write -= this.read; + this.read = 0; + } + return this; +}; + +/** + * Clears this buffer. + * + * @return this buffer. + */ +util.DataBuffer.prototype.clear = function() { + this.data = new DataView(new ArrayBuffer(0)); + this.read = this.write = 0; + return this; +}; + +/** + * Shortens this buffer by trimming bytes off of the end of this buffer. + * + * @param count the number of bytes to trim off. + * + * @return this buffer. + */ +util.DataBuffer.prototype.truncate = function(count) { + this.write = Math.max(0, this.length() - count); + this.read = Math.min(this.read, this.write); + return this; +}; + +/** + * Converts this buffer to a hexadecimal string. + * + * @return a hexadecimal string. + */ +util.DataBuffer.prototype.toHex = function() { + var rval = ''; + for(var i = this.read; i < this.data.byteLength; ++i) { + var b = this.data.getUint8(i); + if(b < 16) { + rval += '0'; + } + rval += b.toString(16); + } + return rval; +}; + +/** + * Converts this buffer to a string, using the given encoding. If no + * encoding is given, 'utf8' (UTF-8) is used. + * + * @param [encoding] the encoding to use: 'binary', 'utf8', 'utf16', 'hex', + * 'base64' (default: 'utf8'). + * + * @return a string representation of the bytes in this buffer. + */ +util.DataBuffer.prototype.toString = function(encoding) { + var view = new Uint8Array(this.data, this.read, this.length()); + encoding = encoding || 'utf8'; + + // encode to string + if(encoding === 'binary' || encoding === 'raw') { + return util.binary.raw.encode(view); + } + if(encoding === 'hex') { + return util.binary.hex.encode(view); + } + if(encoding === 'base64') { + return util.binary.base64.encode(view); + } + + // decode to text + if(encoding === 'utf8') { + return util.text.utf8.decode(view); + } + if(encoding === 'utf16') { + return util.text.utf16.decode(view); + } + + throw new Error('Invalid encoding: ' + encoding); +}; + +/** End Buffer w/UInt8Array backing */ + +/** + * Creates a buffer that stores bytes. A value may be given to populate the + * buffer with data. This value can either be string of encoded bytes or a + * regular string of characters. When passing a string of binary encoded + * bytes, the encoding `raw` should be given. This is also the default. When + * passing a string of characters, the encoding `utf8` should be given. + * + * @param [input] a string with encoded bytes to store in the buffer. + * @param [encoding] (default: 'raw', other: 'utf8'). + */ +util.createBuffer = function(input, encoding) { + // TODO: deprecate, use new ByteBuffer() instead + encoding = encoding || 'raw'; + if(input !== undefined && encoding === 'utf8') { + input = util.encodeUtf8(input); + } + return new util.ByteBuffer(input); +}; + +/** + * Fills a string with a particular value. If you want the string to be a byte + * string, pass in String.fromCharCode(theByte). + * + * @param c the character to fill the string with, use String.fromCharCode + * to fill the string with a byte value. + * @param n the number of characters of value c to fill with. + * + * @return the filled string. + */ +util.fillString = function(c, n) { + var s = ''; + while(n > 0) { + if(n & 1) { + s += c; + } + n >>>= 1; + if(n > 0) { + c += c; + } + } + return s; +}; + +/** + * Performs a per byte XOR between two byte strings and returns the result as a + * string of bytes. + * + * @param s1 first string of bytes. + * @param s2 second string of bytes. + * @param n the number of bytes to XOR. + * + * @return the XOR'd result. + */ +util.xorBytes = function(s1, s2, n) { + var s3 = ''; + var b = ''; + var t = ''; + var i = 0; + var c = 0; + for(; n > 0; --n, ++i) { + b = s1.charCodeAt(i) ^ s2.charCodeAt(i); + if(c >= 10) { + s3 += t; + t = ''; + c = 0; + } + t += String.fromCharCode(b); + ++c; + } + s3 += t; + return s3; +}; + +/** + * Converts a hex string into a 'binary' encoded string of bytes. + * + * @param hex the hexadecimal string to convert. + * + * @return the binary-encoded string of bytes. + */ +util.hexToBytes = function(hex) { + // TODO: deprecate: "Deprecated. Use util.binary.hex.decode instead." + var rval = ''; + var i = 0; + if(hex.length & 1 == 1) { + // odd number of characters, convert first character alone + i = 1; + rval += String.fromCharCode(parseInt(hex[0], 16)); + } + // convert 2 characters (1 byte) at a time + for(; i < hex.length; i += 2) { + rval += String.fromCharCode(parseInt(hex.substr(i, 2), 16)); + } + return rval; +}; + +/** + * Converts a 'binary' encoded string of bytes to hex. + * + * @param bytes the byte string to convert. + * + * @return the string of hexadecimal characters. + */ +util.bytesToHex = function(bytes) { + // TODO: deprecate: "Deprecated. Use util.binary.hex.encode instead." + return util.createBuffer(bytes).toHex(); +}; + +/** + * Converts an 32-bit integer to 4-big-endian byte string. + * + * @param i the integer. + * + * @return the byte string. + */ +util.int32ToBytes = function(i) { + return ( + String.fromCharCode(i >> 24 & 0xFF) + + String.fromCharCode(i >> 16 & 0xFF) + + String.fromCharCode(i >> 8 & 0xFF) + + String.fromCharCode(i & 0xFF)); +}; + +// base64 characters, reverse mapping +var _base64 = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; +var _base64Idx = [ +/*43 -43 = 0*/ +/*'+', 1, 2, 3,'/' */ + 62, -1, -1, -1, 63, + +/*'0','1','2','3','4','5','6','7','8','9' */ + 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, + +/*15, 16, 17,'=', 19, 20, 21 */ + -1, -1, -1, 64, -1, -1, -1, + +/*65 - 43 = 22*/ +/*'A','B','C','D','E','F','G','H','I','J','K','L','M', */ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, + +/*'N','O','P','Q','R','S','T','U','V','W','X','Y','Z' */ + 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, + +/*91 - 43 = 48 */ +/*48, 49, 50, 51, 52, 53 */ + -1, -1, -1, -1, -1, -1, + +/*97 - 43 = 54*/ +/*'a','b','c','d','e','f','g','h','i','j','k','l','m' */ + 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, + +/*'n','o','p','q','r','s','t','u','v','w','x','y','z' */ + 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51 +]; + +// base58 characters (Bitcoin alphabet) +var _base58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; + +/** + * Base64 encodes a 'binary' encoded string of bytes. + * + * @param input the binary encoded string of bytes to base64-encode. + * @param maxline the maximum number of encoded characters per line to use, + * defaults to none. + * + * @return the base64-encoded output. + */ +util.encode64 = function(input, maxline) { + // TODO: deprecate: "Deprecated. Use util.binary.base64.encode instead." + var line = ''; + var output = ''; + var chr1, chr2, chr3; + var i = 0; + while(i < input.length) { + chr1 = input.charCodeAt(i++); + chr2 = input.charCodeAt(i++); + chr3 = input.charCodeAt(i++); + + // encode 4 character group + line += _base64.charAt(chr1 >> 2); + line += _base64.charAt(((chr1 & 3) << 4) | (chr2 >> 4)); + if(isNaN(chr2)) { + line += '=='; + } else { + line += _base64.charAt(((chr2 & 15) << 2) | (chr3 >> 6)); + line += isNaN(chr3) ? '=' : _base64.charAt(chr3 & 63); + } + + if(maxline && line.length > maxline) { + output += line.substr(0, maxline) + '\r\n'; + line = line.substr(maxline); + } + } + output += line; + return output; +}; + +/** + * Base64 decodes a string into a 'binary' encoded string of bytes. + * + * @param input the base64-encoded input. + * + * @return the binary encoded string. + */ +util.decode64 = function(input) { + // TODO: deprecate: "Deprecated. Use util.binary.base64.decode instead." + + // remove all non-base64 characters + input = input.replace(/[^A-Za-z0-9\+\/\=]/g, ''); + + var output = ''; + var enc1, enc2, enc3, enc4; + var i = 0; + + while(i < input.length) { + enc1 = _base64Idx[input.charCodeAt(i++) - 43]; + enc2 = _base64Idx[input.charCodeAt(i++) - 43]; + enc3 = _base64Idx[input.charCodeAt(i++) - 43]; + enc4 = _base64Idx[input.charCodeAt(i++) - 43]; + + output += String.fromCharCode((enc1 << 2) | (enc2 >> 4)); + if(enc3 !== 64) { + // decoded at least 2 bytes + output += String.fromCharCode(((enc2 & 15) << 4) | (enc3 >> 2)); + if(enc4 !== 64) { + // decoded 3 bytes + output += String.fromCharCode(((enc3 & 3) << 6) | enc4); + } + } + } + + return output; +}; + +/** + * Encodes the given string of characters (a standard JavaScript + * string) as a binary encoded string where the bytes represent + * a UTF-8 encoded string of characters. Non-ASCII characters will be + * encoded as multiple bytes according to UTF-8. + * + * @param str a standard string of characters to encode. + * + * @return the binary encoded string. + */ +util.encodeUtf8 = function(str) { + return unescape(encodeURIComponent(str)); +}; + +/** + * Decodes a binary encoded string that contains bytes that + * represent a UTF-8 encoded string of characters -- into a + * string of characters (a standard JavaScript string). + * + * @param str the binary encoded string to decode. + * + * @return the resulting standard string of characters. + */ +util.decodeUtf8 = function(str) { + return decodeURIComponent(escape(str)); +}; + +// binary encoding/decoding tools +// FIXME: Experimental. Do not use yet. +util.binary = { + raw: {}, + hex: {}, + base64: {}, + base58: {}, + baseN : { + encode: baseN.encode, + decode: baseN.decode + } +}; + +/** + * Encodes a Uint8Array as a binary-encoded string. This encoding uses + * a value between 0 and 255 for each character. + * + * @param bytes the Uint8Array to encode. + * + * @return the binary-encoded string. + */ +util.binary.raw.encode = function(bytes) { + return String.fromCharCode.apply(null, bytes); +}; + +/** + * Decodes a binary-encoded string to a Uint8Array. This encoding uses + * a value between 0 and 255 for each character. + * + * @param str the binary-encoded string to decode. + * @param [output] an optional Uint8Array to write the output to; if it + * is too small, an exception will be thrown. + * @param [offset] the start offset for writing to the output (default: 0). + * + * @return the Uint8Array or the number of bytes written if output was given. + */ +util.binary.raw.decode = function(str, output, offset) { + var out = output; + if(!out) { + out = new Uint8Array(str.length); + } + offset = offset || 0; + var j = offset; + for(var i = 0; i < str.length; ++i) { + out[j++] = str.charCodeAt(i); + } + return output ? (j - offset) : out; +}; + +/** + * Encodes a 'binary' string, ArrayBuffer, DataView, TypedArray, or + * ByteBuffer as a string of hexadecimal characters. + * + * @param bytes the bytes to convert. + * + * @return the string of hexadecimal characters. + */ +util.binary.hex.encode = util.bytesToHex; + +/** + * Decodes a hex-encoded string to a Uint8Array. + * + * @param hex the hexadecimal string to convert. + * @param [output] an optional Uint8Array to write the output to; if it + * is too small, an exception will be thrown. + * @param [offset] the start offset for writing to the output (default: 0). + * + * @return the Uint8Array or the number of bytes written if output was given. + */ +util.binary.hex.decode = function(hex, output, offset) { + var out = output; + if(!out) { + out = new Uint8Array(Math.ceil(hex.length / 2)); + } + offset = offset || 0; + var i = 0, j = offset; + if(hex.length & 1) { + // odd number of characters, convert first character alone + i = 1; + out[j++] = parseInt(hex[0], 16); + } + // convert 2 characters (1 byte) at a time + for(; i < hex.length; i += 2) { + out[j++] = parseInt(hex.substr(i, 2), 16); + } + return output ? (j - offset) : out; +}; + +/** + * Base64-encodes a Uint8Array. + * + * @param input the Uint8Array to encode. + * @param maxline the maximum number of encoded characters per line to use, + * defaults to none. + * + * @return the base64-encoded output string. + */ +util.binary.base64.encode = function(input, maxline) { + var line = ''; + var output = ''; + var chr1, chr2, chr3; + var i = 0; + while(i < input.byteLength) { + chr1 = input[i++]; + chr2 = input[i++]; + chr3 = input[i++]; + + // encode 4 character group + line += _base64.charAt(chr1 >> 2); + line += _base64.charAt(((chr1 & 3) << 4) | (chr2 >> 4)); + if(isNaN(chr2)) { + line += '=='; + } else { + line += _base64.charAt(((chr2 & 15) << 2) | (chr3 >> 6)); + line += isNaN(chr3) ? '=' : _base64.charAt(chr3 & 63); + } + + if(maxline && line.length > maxline) { + output += line.substr(0, maxline) + '\r\n'; + line = line.substr(maxline); + } + } + output += line; + return output; +}; + +/** + * Decodes a base64-encoded string to a Uint8Array. + * + * @param input the base64-encoded input string. + * @param [output] an optional Uint8Array to write the output to; if it + * is too small, an exception will be thrown. + * @param [offset] the start offset for writing to the output (default: 0). + * + * @return the Uint8Array or the number of bytes written if output was given. + */ +util.binary.base64.decode = function(input, output, offset) { + var out = output; + if(!out) { + out = new Uint8Array(Math.ceil(input.length / 4) * 3); + } + + // remove all non-base64 characters + input = input.replace(/[^A-Za-z0-9\+\/\=]/g, ''); + + offset = offset || 0; + var enc1, enc2, enc3, enc4; + var i = 0, j = offset; + + while(i < input.length) { + enc1 = _base64Idx[input.charCodeAt(i++) - 43]; + enc2 = _base64Idx[input.charCodeAt(i++) - 43]; + enc3 = _base64Idx[input.charCodeAt(i++) - 43]; + enc4 = _base64Idx[input.charCodeAt(i++) - 43]; + + out[j++] = (enc1 << 2) | (enc2 >> 4); + if(enc3 !== 64) { + // decoded at least 2 bytes + out[j++] = ((enc2 & 15) << 4) | (enc3 >> 2); + if(enc4 !== 64) { + // decoded 3 bytes + out[j++] = ((enc3 & 3) << 6) | enc4; + } + } + } + + // make sure result is the exact decoded length + return output ? (j - offset) : out.subarray(0, j); +}; + +// add support for base58 encoding/decoding with Bitcoin alphabet +util.binary.base58.encode = function(input, maxline) { + return util.binary.baseN.encode(input, _base58, maxline); +}; +util.binary.base58.decode = function(input, maxline) { + return util.binary.baseN.decode(input, _base58, maxline); +}; + +// text encoding/decoding tools +// FIXME: Experimental. Do not use yet. +util.text = { + utf8: {}, + utf16: {} +}; + +/** + * Encodes the given string as UTF-8 in a Uint8Array. + * + * @param str the string to encode. + * @param [output] an optional Uint8Array to write the output to; if it + * is too small, an exception will be thrown. + * @param [offset] the start offset for writing to the output (default: 0). + * + * @return the Uint8Array or the number of bytes written if output was given. + */ +util.text.utf8.encode = function(str, output, offset) { + str = util.encodeUtf8(str); + var out = output; + if(!out) { + out = new Uint8Array(str.length); + } + offset = offset || 0; + var j = offset; + for(var i = 0; i < str.length; ++i) { + out[j++] = str.charCodeAt(i); + } + return output ? (j - offset) : out; +}; + +/** + * Decodes the UTF-8 contents from a Uint8Array. + * + * @param bytes the Uint8Array to decode. + * + * @return the resulting string. + */ +util.text.utf8.decode = function(bytes) { + return util.decodeUtf8(String.fromCharCode.apply(null, bytes)); +}; + +/** + * Encodes the given string as UTF-16 in a Uint8Array. + * + * @param str the string to encode. + * @param [output] an optional Uint8Array to write the output to; if it + * is too small, an exception will be thrown. + * @param [offset] the start offset for writing to the output (default: 0). + * + * @return the Uint8Array or the number of bytes written if output was given. + */ +util.text.utf16.encode = function(str, output, offset) { + var out = output; + if(!out) { + out = new Uint8Array(str.length * 2); + } + var view = new Uint16Array(out.buffer); + offset = offset || 0; + var j = offset; + var k = offset; + for(var i = 0; i < str.length; ++i) { + view[k++] = str.charCodeAt(i); + j += 2; + } + return output ? (j - offset) : out; +}; + +/** + * Decodes the UTF-16 contents from a Uint8Array. + * + * @param bytes the Uint8Array to decode. + * + * @return the resulting string. + */ +util.text.utf16.decode = function(bytes) { + return String.fromCharCode.apply(null, new Uint16Array(bytes.buffer)); +}; + +/** + * Deflates the given data using a flash interface. + * + * @param api the flash interface. + * @param bytes the data. + * @param raw true to return only raw deflate data, false to include zlib + * header and trailer. + * + * @return the deflated data as a string. + */ +util.deflate = function(api, bytes, raw) { + bytes = util.decode64(api.deflate(util.encode64(bytes)).rval); + + // strip zlib header and trailer if necessary + if(raw) { + // zlib header is 2 bytes (CMF,FLG) where FLG indicates that + // there is a 4-byte DICT (alder-32) block before the data if + // its 5th bit is set + var start = 2; + var flg = bytes.charCodeAt(1); + if(flg & 0x20) { + start = 6; + } + // zlib trailer is 4 bytes of adler-32 + bytes = bytes.substring(start, bytes.length - 4); + } + + return bytes; +}; + +/** + * Inflates the given data using a flash interface. + * + * @param api the flash interface. + * @param bytes the data. + * @param raw true if the incoming data has no zlib header or trailer and is + * raw DEFLATE data. + * + * @return the inflated data as a string, null on error. + */ +util.inflate = function(api, bytes, raw) { + // TODO: add zlib header and trailer if necessary/possible + var rval = api.inflate(util.encode64(bytes)).rval; + return (rval === null) ? null : util.decode64(rval); +}; + +/** + * Sets a storage object. + * + * @param api the storage interface. + * @param id the storage ID to use. + * @param obj the storage object, null to remove. + */ +var _setStorageObject = function(api, id, obj) { + if(!api) { + throw new Error('WebStorage not available.'); + } + + var rval; + if(obj === null) { + rval = api.removeItem(id); + } else { + // json-encode and base64-encode object + obj = util.encode64(JSON.stringify(obj)); + rval = api.setItem(id, obj); + } + + // handle potential flash error + if(typeof(rval) !== 'undefined' && rval.rval !== true) { + var error = new Error(rval.error.message); + error.id = rval.error.id; + error.name = rval.error.name; + throw error; + } +}; + +/** + * Gets a storage object. + * + * @param api the storage interface. + * @param id the storage ID to use. + * + * @return the storage object entry or null if none exists. + */ +var _getStorageObject = function(api, id) { + if(!api) { + throw new Error('WebStorage not available.'); + } + + // get the existing entry + var rval = api.getItem(id); + + /* Note: We check api.init because we can't do (api == localStorage) + on IE because of "Class doesn't support Automation" exception. Only + the flash api has an init method so this works too, but we need a + better solution in the future. */ + + // flash returns item wrapped in an object, handle special case + if(api.init) { + if(rval.rval === null) { + if(rval.error) { + var error = new Error(rval.error.message); + error.id = rval.error.id; + error.name = rval.error.name; + throw error; + } + // no error, but also no item + rval = null; + } else { + rval = rval.rval; + } + } + + // handle decoding + if(rval !== null) { + // base64-decode and json-decode data + rval = JSON.parse(util.decode64(rval)); + } + + return rval; +}; + +/** + * Stores an item in local storage. + * + * @param api the storage interface. + * @param id the storage ID to use. + * @param key the key for the item. + * @param data the data for the item (any javascript object/primitive). + */ +var _setItem = function(api, id, key, data) { + // get storage object + var obj = _getStorageObject(api, id); + if(obj === null) { + // create a new storage object + obj = {}; + } + // update key + obj[key] = data; + + // set storage object + _setStorageObject(api, id, obj); +}; + +/** + * Gets an item from local storage. + * + * @param api the storage interface. + * @param id the storage ID to use. + * @param key the key for the item. + * + * @return the item. + */ +var _getItem = function(api, id, key) { + // get storage object + var rval = _getStorageObject(api, id); + if(rval !== null) { + // return data at key + rval = (key in rval) ? rval[key] : null; + } + + return rval; +}; + +/** + * Removes an item from local storage. + * + * @param api the storage interface. + * @param id the storage ID to use. + * @param key the key for the item. + */ +var _removeItem = function(api, id, key) { + // get storage object + var obj = _getStorageObject(api, id); + if(obj !== null && key in obj) { + // remove key + delete obj[key]; + + // see if entry has no keys remaining + var empty = true; + for(var prop in obj) { + empty = false; + break; + } + if(empty) { + // remove entry entirely if no keys are left + obj = null; + } + + // set storage object + _setStorageObject(api, id, obj); + } +}; + +/** + * Clears the local disk storage identified by the given ID. + * + * @param api the storage interface. + * @param id the storage ID to use. + */ +var _clearItems = function(api, id) { + _setStorageObject(api, id, null); +}; + +/** + * Calls a storage function. + * + * @param func the function to call. + * @param args the arguments for the function. + * @param location the location argument. + * + * @return the return value from the function. + */ +var _callStorageFunction = function(func, args, location) { + var rval = null; + + // default storage types + if(typeof(location) === 'undefined') { + location = ['web', 'flash']; + } + + // apply storage types in order of preference + var type; + var done = false; + var exception = null; + for(var idx in location) { + type = location[idx]; + try { + if(type === 'flash' || type === 'both') { + if(args[0] === null) { + throw new Error('Flash local storage not available.'); + } + rval = func.apply(this, args); + done = (type === 'flash'); + } + if(type === 'web' || type === 'both') { + args[0] = localStorage; + rval = func.apply(this, args); + done = true; + } + } catch(ex) { + exception = ex; + } + if(done) { + break; + } + } + + if(!done) { + throw exception; + } + + return rval; +}; + +/** + * Stores an item on local disk. + * + * The available types of local storage include 'flash', 'web', and 'both'. + * + * The type 'flash' refers to flash local storage (SharedObject). In order + * to use flash local storage, the 'api' parameter must be valid. The type + * 'web' refers to WebStorage, if supported by the browser. The type 'both' + * refers to storing using both 'flash' and 'web', not just one or the + * other. + * + * The location array should list the storage types to use in order of + * preference: + * + * ['flash']: flash only storage + * ['web']: web only storage + * ['both']: try to store in both + * ['flash','web']: store in flash first, but if not available, 'web' + * ['web','flash']: store in web first, but if not available, 'flash' + * + * The location array defaults to: ['web', 'flash'] + * + * @param api the flash interface, null to use only WebStorage. + * @param id the storage ID to use. + * @param key the key for the item. + * @param data the data for the item (any javascript object/primitive). + * @param location an array with the preferred types of storage to use. + */ +util.setItem = function(api, id, key, data, location) { + _callStorageFunction(_setItem, arguments, location); +}; + +/** + * Gets an item on local disk. + * + * Set setItem() for details on storage types. + * + * @param api the flash interface, null to use only WebStorage. + * @param id the storage ID to use. + * @param key the key for the item. + * @param location an array with the preferred types of storage to use. + * + * @return the item. + */ +util.getItem = function(api, id, key, location) { + return _callStorageFunction(_getItem, arguments, location); +}; + +/** + * Removes an item on local disk. + * + * Set setItem() for details on storage types. + * + * @param api the flash interface. + * @param id the storage ID to use. + * @param key the key for the item. + * @param location an array with the preferred types of storage to use. + */ +util.removeItem = function(api, id, key, location) { + _callStorageFunction(_removeItem, arguments, location); +}; + +/** + * Clears the local disk storage identified by the given ID. + * + * Set setItem() for details on storage types. + * + * @param api the flash interface if flash is available. + * @param id the storage ID to use. + * @param location an array with the preferred types of storage to use. + */ +util.clearItems = function(api, id, location) { + _callStorageFunction(_clearItems, arguments, location); +}; + +/** + * Check if an object is empty. + * + * Taken from: + * http://stackoverflow.com/questions/679915/how-do-i-test-for-an-empty-javascript-object-from-json/679937#679937 + * + * @param object the object to check. + */ +util.isEmpty = function(obj) { + for(var prop in obj) { + if(obj.hasOwnProperty(prop)) { + return false; + } + } + return true; +}; + +/** + * Format with simple printf-style interpolation. + * + * %%: literal '%' + * %s,%o: convert next argument into a string. + * + * @param format the string to format. + * @param ... arguments to interpolate into the format string. + */ +util.format = function(format) { + var re = /%./g; + // current match + var match; + // current part + var part; + // current arg index + var argi = 0; + // collected parts to recombine later + var parts = []; + // last index found + var last = 0; + // loop while matches remain + while((match = re.exec(format))) { + part = format.substring(last, re.lastIndex - 2); + // don't add empty strings (ie, parts between %s%s) + if(part.length > 0) { + parts.push(part); + } + last = re.lastIndex; + // switch on % code + var code = match[0][1]; + switch(code) { + case 's': + case 'o': + // check if enough arguments were given + if(argi < arguments.length) { + parts.push(arguments[argi++ + 1]); + } else { + parts.push(''); + } + break; + // FIXME: do proper formatting for numbers, etc + //case 'f': + //case 'd': + case '%': + parts.push('%'); + break; + default: + parts.push('<%' + code + '?>'); + } + } + // add trailing part of format string + parts.push(format.substring(last)); + return parts.join(''); +}; + +/** + * Formats a number. + * + * http://snipplr.com/view/5945/javascript-numberformat--ported-from-php/ + */ +util.formatNumber = function(number, decimals, dec_point, thousands_sep) { + // http://kevin.vanzonneveld.net + // + original by: Jonas Raoni Soares Silva (http://www.jsfromhell.com) + // + improved by: Kevin van Zonneveld (http://kevin.vanzonneveld.net) + // + bugfix by: Michael White (http://crestidg.com) + // + bugfix by: Benjamin Lupton + // + bugfix by: Allan Jensen (http://www.winternet.no) + // + revised by: Jonas Raoni Soares Silva (http://www.jsfromhell.com) + // * example 1: number_format(1234.5678, 2, '.', ''); + // * returns 1: 1234.57 + + var n = number, c = isNaN(decimals = Math.abs(decimals)) ? 2 : decimals; + var d = dec_point === undefined ? ',' : dec_point; + var t = thousands_sep === undefined ? + '.' : thousands_sep, s = n < 0 ? '-' : ''; + var i = parseInt((n = Math.abs(+n || 0).toFixed(c)), 10) + ''; + var j = (i.length > 3) ? i.length % 3 : 0; + return s + (j ? i.substr(0, j) + t : '') + + i.substr(j).replace(/(\d{3})(?=\d)/g, '$1' + t) + + (c ? d + Math.abs(n - i).toFixed(c).slice(2) : ''); +}; + +/** + * Formats a byte size. + * + * http://snipplr.com/view/5949/format-humanize-file-byte-size-presentation-in-javascript/ + */ +util.formatSize = function(size) { + if(size >= 1073741824) { + size = util.formatNumber(size / 1073741824, 2, '.', '') + ' GiB'; + } else if(size >= 1048576) { + size = util.formatNumber(size / 1048576, 2, '.', '') + ' MiB'; + } else if(size >= 1024) { + size = util.formatNumber(size / 1024, 0) + ' KiB'; + } else { + size = util.formatNumber(size, 0) + ' bytes'; + } + return size; +}; + +/** + * Converts an IPv4 or IPv6 string representation into bytes (in network order). + * + * @param ip the IPv4 or IPv6 address to convert. + * + * @return the 4-byte IPv6 or 16-byte IPv6 address or null if the address can't + * be parsed. + */ +util.bytesFromIP = function(ip) { + if(ip.indexOf('.') !== -1) { + return util.bytesFromIPv4(ip); + } + if(ip.indexOf(':') !== -1) { + return util.bytesFromIPv6(ip); + } + return null; +}; + +/** + * Converts an IPv4 string representation into bytes (in network order). + * + * @param ip the IPv4 address to convert. + * + * @return the 4-byte address or null if the address can't be parsed. + */ +util.bytesFromIPv4 = function(ip) { + ip = ip.split('.'); + if(ip.length !== 4) { + return null; + } + var b = util.createBuffer(); + for(var i = 0; i < ip.length; ++i) { + var num = parseInt(ip[i], 10); + if(isNaN(num)) { + return null; + } + b.putByte(num); + } + return b.getBytes(); +}; + +/** + * Converts an IPv6 string representation into bytes (in network order). + * + * @param ip the IPv6 address to convert. + * + * @return the 16-byte address or null if the address can't be parsed. + */ +util.bytesFromIPv6 = function(ip) { + var blanks = 0; + ip = ip.split(':').filter(function(e) { + if(e.length === 0) ++blanks; + return true; + }); + var zeros = (8 - ip.length + blanks) * 2; + var b = util.createBuffer(); + for(var i = 0; i < 8; ++i) { + if(!ip[i] || ip[i].length === 0) { + b.fillWithByte(0, zeros); + zeros = 0; + continue; + } + var bytes = util.hexToBytes(ip[i]); + if(bytes.length < 2) { + b.putByte(0); + } + b.putBytes(bytes); + } + return b.getBytes(); +}; + +/** + * Converts 4-bytes into an IPv4 string representation or 16-bytes into + * an IPv6 string representation. The bytes must be in network order. + * + * @param bytes the bytes to convert. + * + * @return the IPv4 or IPv6 string representation if 4 or 16 bytes, + * respectively, are given, otherwise null. + */ +util.bytesToIP = function(bytes) { + if(bytes.length === 4) { + return util.bytesToIPv4(bytes); + } + if(bytes.length === 16) { + return util.bytesToIPv6(bytes); + } + return null; +}; + +/** + * Converts 4-bytes into an IPv4 string representation. The bytes must be + * in network order. + * + * @param bytes the bytes to convert. + * + * @return the IPv4 string representation or null for an invalid # of bytes. + */ +util.bytesToIPv4 = function(bytes) { + if(bytes.length !== 4) { + return null; + } + var ip = []; + for(var i = 0; i < bytes.length; ++i) { + ip.push(bytes.charCodeAt(i)); + } + return ip.join('.'); +}; + +/** + * Converts 16-bytes into an IPv16 string representation. The bytes must be + * in network order. + * + * @param bytes the bytes to convert. + * + * @return the IPv16 string representation or null for an invalid # of bytes. + */ +util.bytesToIPv6 = function(bytes) { + if(bytes.length !== 16) { + return null; + } + var ip = []; + var zeroGroups = []; + var zeroMaxGroup = 0; + for(var i = 0; i < bytes.length; i += 2) { + var hex = util.bytesToHex(bytes[i] + bytes[i + 1]); + // canonicalize zero representation + while(hex[0] === '0' && hex !== '0') { + hex = hex.substr(1); + } + if(hex === '0') { + var last = zeroGroups[zeroGroups.length - 1]; + var idx = ip.length; + if(!last || idx !== last.end + 1) { + zeroGroups.push({start: idx, end: idx}); + } else { + last.end = idx; + if((last.end - last.start) > + (zeroGroups[zeroMaxGroup].end - zeroGroups[zeroMaxGroup].start)) { + zeroMaxGroup = zeroGroups.length - 1; + } + } + } + ip.push(hex); + } + if(zeroGroups.length > 0) { + var group = zeroGroups[zeroMaxGroup]; + // only shorten group of length > 0 + if(group.end - group.start > 0) { + ip.splice(group.start, group.end - group.start + 1, ''); + if(group.start === 0) { + ip.unshift(''); + } + if(group.end === 7) { + ip.push(''); + } + } + } + return ip.join(':'); +}; + +/** + * Estimates the number of processes that can be run concurrently. If + * creating Web Workers, keep in mind that the main JavaScript process needs + * its own core. + * + * @param options the options to use: + * update true to force an update (not use the cached value). + * @param callback(err, max) called once the operation completes. + */ +util.estimateCores = function(options, callback) { + if(typeof options === 'function') { + callback = options; + options = {}; + } + options = options || {}; + if('cores' in util && !options.update) { + return callback(null, util.cores); + } + if(typeof navigator !== 'undefined' && + 'hardwareConcurrency' in navigator && + navigator.hardwareConcurrency > 0) { + util.cores = navigator.hardwareConcurrency; + return callback(null, util.cores); + } + if(typeof Worker === 'undefined') { + // workers not available + util.cores = 1; + return callback(null, util.cores); + } + if(typeof Blob === 'undefined') { + // can't estimate, default to 2 + util.cores = 2; + return callback(null, util.cores); + } + + // create worker concurrency estimation code as blob + var blobUrl = URL.createObjectURL(new Blob(['(', + function() { + self.addEventListener('message', function(e) { + // run worker for 4 ms + var st = Date.now(); + var et = st + 4; + while(Date.now() < et); + self.postMessage({st: st, et: et}); + }); + }.toString(), + ')()'], {type: 'application/javascript'})); + + // take 5 samples using 16 workers + sample([], 5, 16); + + function sample(max, samples, numWorkers) { + if(samples === 0) { + // get overlap average + var avg = Math.floor(max.reduce(function(avg, x) { + return avg + x; + }, 0) / max.length); + util.cores = Math.max(1, avg); + URL.revokeObjectURL(blobUrl); + return callback(null, util.cores); + } + map(numWorkers, function(err, results) { + max.push(reduce(numWorkers, results)); + sample(max, samples - 1, numWorkers); + }); + } + + function map(numWorkers, callback) { + var workers = []; + var results = []; + for(var i = 0; i < numWorkers; ++i) { + var worker = new Worker(blobUrl); + worker.addEventListener('message', function(e) { + results.push(e.data); + if(results.length === numWorkers) { + for(var i = 0; i < numWorkers; ++i) { + workers[i].terminate(); + } + callback(null, results); + } + }); + workers.push(worker); + } + for(var i = 0; i < numWorkers; ++i) { + workers[i].postMessage(i); + } + } + + function reduce(numWorkers, results) { + // find overlapping time windows + var overlaps = []; + for(var n = 0; n < numWorkers; ++n) { + var r1 = results[n]; + var overlap = overlaps[n] = []; + for(var i = 0; i < numWorkers; ++i) { + if(n === i) { + continue; + } + var r2 = results[i]; + if((r1.st > r2.st && r1.st < r2.et) || + (r2.st > r1.st && r2.st < r1.et)) { + overlap.push(i); + } + } + } + // get maximum overlaps ... don't include overlapping worker itself + // as the main JS process was also being scheduled during the work and + // would have to be subtracted from the estimate anyway + return overlaps.reduce(function(max, overlap) { + return Math.max(max, overlap.length); + }, 0); + } +}; diff --git a/ruoyi-fastapi-frontend/vue2/mobile/tailwind.config.js b/ruoyi-fastapi-frontend/vue2/mobile/tailwind.config.js new file mode 100644 index 0000000..607ac4b --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/mobile/tailwind.config.js @@ -0,0 +1,43 @@ +import { isMp } from "./platform"; + +const cssMacro = require("weapp-tailwindcss/css-macro"); +const { + iconsPlugin, + getIconCollections, +} = require("@egoist/tailwindcss-icons"); +// 基础配置,无需任何preset +// https://github.com/sonofmagic/weapp-tailwindcss-webpack-plugin/blob/main/demo/uni-app/tailwind.config.js +/** @type {import('@types/tailwindcss/tailwind-config').TailwindConfig} */ +module.exports = { + content: ["public/index.html", "./src/**/*.{vue,js,ts,jsx,tsx,wxml}"], + theme: { + extend: {}, + }, + variants: {}, + plugins: [ + // https://weapp-tw.icebreaker.top/docs/quick-start/uni-app-css-macro + cssMacro({ + variantsMap: { + wx: "MP-WEIXIN", + "-wx": { + value: "MP-WEIXIN", + negative: true, + }, + // mv: { + // value: 'H5 || MP-WEIXIN' + // }, + // '-mv': { + // value: 'H5 || MP-WEIXIN', + // negative: true + // } + }, + }), + iconsPlugin({ + collections: getIconCollections(["mdi"]), + }), + ], + corePlugins: { + preflight: !isMp, + container: !isMp, + }, +}; diff --git a/ruoyi-fastapi-frontend/vue2/mobile/tests/behavior.test.mjs b/ruoyi-fastapi-frontend/vue2/mobile/tests/behavior.test.mjs new file mode 100644 index 0000000..8e53342 --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/mobile/tests/behavior.test.mjs @@ -0,0 +1,3 @@ +import { testMobileBehavior } from '../../../../ruoyi-fastapi-test/frontend/mobile-behavior.mjs' + +testMobileBehavior(new URL('../', import.meta.url), 'vue2') diff --git a/ruoyi-fastapi-frontend/vue2/mobile/tests/time-bundle.test.cjs b/ruoyi-fastapi-frontend/vue2/mobile/tests/time-bundle.test.cjs new file mode 100644 index 0000000..f4dec95 --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/mobile/tests/time-bundle.test.cjs @@ -0,0 +1,68 @@ +const assert = require('node:assert/strict') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const vm = require('node:vm') +const test = require('node:test') +const webpack = require('webpack') + +const appRoot = path.resolve(__dirname, '..') + +// Node can resolve a leaked require; a browser cannot. Execute the real bundled +// time module without Node globals and without native Intl to cover both paths. +for (const mode of ['development', 'production']) { + test(`H5 ${mode} time bundle runs without require or native Intl`, async () => { + const outputPath = fs.mkdtempSync(path.join(os.tmpdir(), 'ruoyi-time-bundle-')) + const filename = path.join(outputPath, 'time-bundle.js') + const previousPlatform = process.env.UNI_PLATFORM + const previousMode = process.env.NODE_ENV + process.env.UNI_PLATFORM = 'h5' + process.env.NODE_ENV = mode + try { + const compiler = webpack({ + mode, + context: appRoot, + target: 'web', + entry: path.join(appRoot, 'src/utils/time.js'), + devtool: false, + output: { path: outputPath, filename: 'time-bundle.js', library: { name: 'TimeBundle', type: 'var' } }, + optimization: { minimize: false }, + module: { + rules: [{ + test: /\.m?js$/, + include: /[/\\]node_modules[/\\]@formatjs[/\\]/, + use: { + loader: require.resolve('babel-loader'), + options: { cwd: appRoot, configFile: path.join(appRoot, 'babel.config.js') } + } + }] + } + }) + await new Promise((resolve, reject) => { + compiler.run((error, stats) => { + compiler.close(closeError => { + if (error || closeError) return reject(error || closeError) + if (stats.hasErrors()) return reject(new Error(stats.toString({ all: false, errors: true }))) + resolve() + }) + }) + }) + const sandbox = { Intl: undefined, console, setTimeout, clearTimeout } + vm.runInNewContext(fs.readFileSync(filename, 'utf8'), sandbox, { timeout: 20000 }) + assert.equal(sandbox.require, undefined) + const time = sandbox.TimeBundle + assert.equal( + time.formatBusinessTime('2026-01-01T00:00:00Z', 'YYYY-MM-DD HH:mm:ss', 'America/New_York'), + '2025-12-31 19:00:00' + ) + assert.equal(time.getWallTimeCandidates('2026-11-01 01:30:00', 'America/New_York').length, 2) + } finally { + if (previousPlatform === undefined) delete process.env.UNI_PLATFORM + else process.env.UNI_PLATFORM = previousPlatform + if (previousMode === undefined) delete process.env.NODE_ENV + else process.env.NODE_ENV = previousMode + if (fs.existsSync(filename)) fs.unlinkSync(filename) + fs.rmdirSync(outputPath) + } + }) +} diff --git a/ruoyi-fastapi-frontend/vue2/mobile/tests/time-polyfill.test.mjs b/ruoyi-fastapi-frontend/vue2/mobile/tests/time-polyfill.test.mjs new file mode 100644 index 0000000..eb21830 --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/mobile/tests/time-polyfill.test.mjs @@ -0,0 +1,5 @@ +import { testTimeContract } from '../../../../ruoyi-fastapi-test/time-contract/contract.mjs' +// 在独立测试进程中模拟缺少原生 Intl 的运行时,再加载 App 时间工具。 +globalThis.Intl = undefined +const time = await import('../src/utils/time.js') +testTimeContract(time) diff --git a/ruoyi-fastapi-frontend/vue2/mobile/tests/time.test.mjs b/ruoyi-fastapi-frontend/vue2/mobile/tests/time.test.mjs new file mode 100644 index 0000000..363fd19 --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/mobile/tests/time.test.mjs @@ -0,0 +1,3 @@ +import * as time from '../src/utils/time.js' +import { testTimeContract } from '../../../../ruoyi-fastapi-test/time-contract/contract.mjs' +testTimeContract(time) diff --git a/ruoyi-fastapi-frontend/vue2/mobile/tsconfig.json b/ruoyi-fastapi-frontend/vue2/mobile/tsconfig.json new file mode 100644 index 0000000..b64dc17 --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/mobile/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "strict": true, + "jsx": "preserve", + "importHelpers": true, + "moduleResolution": "bundler", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "experimentalDecorators": true, + "sourceMap": true, + "skipLibCheck": true, + "baseUrl": ".", + "types": [ + "webpack-env", + "@dcloudio/types", + "miniprogram-api-typings", + "mini-types" + ], + "paths": { + "@/*": ["./src/*"] + }, + "lib": ["esnext", "dom", "dom.iterable", "scripthost"] + }, + "exclude": ["node_modules", "unpackage", "dist", "src/**/*.nvue"] +} diff --git a/ruoyi-fastapi-frontend/vue2/mobile/vue.config.js b/ruoyi-fastapi-frontend/vue2/mobile/vue.config.js new file mode 100644 index 0000000..c9afa72 --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/mobile/vue.config.js @@ -0,0 +1,71 @@ +const path = require("path"); +const { WeappTailwindcssDisabled } = require("./platform"); +const { UnifiedWebpackPluginV5 } = require("weapp-tailwindcss/webpack"); + +/** + * @type {import('@vue/cli-service').ProjectOptions} + */ +const config = { + //.... + configureWebpack: { + resolve: { + alias: { + "node-forge/lib/util": path.resolve( + __dirname, + "src/vendor/node-forge/lib/util.js", + ), + "node-forge/lib/random": path.resolve( + __dirname, + "src/vendor/node-forge/lib/random.js", + ), + "node-forge/lib/prng": path.resolve( + __dirname, + "src/vendor/node-forge/lib/prng.js", + ), + "node-forge/lib/rsa": path.resolve( + __dirname, + "src/vendor/node-forge/lib/rsa.js", + ), + }, + }, + plugins: [ + new (require("webpack").NormalModuleReplacementPlugin)( + /node-forge[\\/]lib[\\/]util\.js$/, + path.resolve(__dirname, "src/vendor/node-forge/lib/util.js"), + ), + new (require("webpack").NormalModuleReplacementPlugin)( + /node-forge[\\/]lib[\\/]random\.js$/, + path.resolve(__dirname, "src/vendor/node-forge/lib/random.js"), + ), + new (require("webpack").NormalModuleReplacementPlugin)( + /node-forge[\\/]lib[\\/]prng\.js$/, + path.resolve(__dirname, "src/vendor/node-forge/lib/prng.js"), + ), + new (require("webpack").NormalModuleReplacementPlugin)( + /node-forge[\\/]lib[\\/]rsa\.js$/, + path.resolve(__dirname, "src/vendor/node-forge/lib/rsa.js"), + ), + new UnifiedWebpackPluginV5({ + rem2rpx: true, + disabled: WeappTailwindcssDisabled, + }), + ], + }, + chainWebpack: (config) => { + // 去除ts类型检测,因为uni-app ts type 支持其实不咋好 + config.plugins.delete("fork-ts-checker"); + }, + css: { + loaderOptions: { + scss: { + sassOptions: { + silenceDeprecations: ["import", "legacy-js-api"], + }, + }, + }, + }, + transpileDependencies: ["uview-ui", /@formatjs/], + //.... +}; + +module.exports = config; diff --git a/ruoyi-fastapi-frontend/vue2/web/.dockerignore b/ruoyi-fastapi-frontend/vue2/web/.dockerignore new file mode 100644 index 0000000..e11342f --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/web/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +.cache +*.log +.git +package-lock.json diff --git a/ruoyi-fastapi-frontend/.editorconfig b/ruoyi-fastapi-frontend/vue2/web/.editorconfig similarity index 100% rename from ruoyi-fastapi-frontend/.editorconfig rename to ruoyi-fastapi-frontend/vue2/web/.editorconfig diff --git a/ruoyi-fastapi-frontend/vue2/web/.env.development b/ruoyi-fastapi-frontend/vue2/web/.env.development new file mode 100644 index 0000000..406aa2e --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/web/.env.development @@ -0,0 +1,14 @@ +# 页面标题 +VUE_APP_TITLE = vfadmin管理系统 + +# 开发环境配置 +ENV = 'development' + +# vfadmin管理系统/开发环境 +VUE_APP_BASE_API = '/dev-api' + +# 插件浏览器路径需与后端 APP_ROOT_PATH 一致 +VUE_APP_PLUGIN_BASE = '/dev-api' + +# 路由懒加载 +VUE_CLI_BABEL_TRANSPILE_MODULES = true diff --git a/ruoyi-fastapi-frontend/vue2/web/.env.docker b/ruoyi-fastapi-frontend/vue2/web/.env.docker new file mode 100644 index 0000000..9151bfd --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/web/.env.docker @@ -0,0 +1,15 @@ +# 页面标题 +VUE_APP_TITLE = vfadmin管理系统 + +BABEL_ENV = production + +NODE_ENV = production + +# 测试环境配置 +ENV = 'docker' + +# vfadmin管理系统/测试环境 +VUE_APP_BASE_API = '/docker-api' + +# 插件浏览器路径需与后端 APP_ROOT_PATH 一致 +VUE_APP_PLUGIN_BASE = '/docker-api' diff --git a/ruoyi-fastapi-frontend/vue2/web/.env.production b/ruoyi-fastapi-frontend/vue2/web/.env.production new file mode 100644 index 0000000..76c067b --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/web/.env.production @@ -0,0 +1,11 @@ +# 页面标题 +VUE_APP_TITLE = vfadmin管理系统 + +# 生产环境配置 +ENV = 'production' + +# vfadmin管理系统/生产环境 +VUE_APP_BASE_API = '/prod-api' + +# 插件浏览器路径需与后端 APP_ROOT_PATH 一致 +VUE_APP_PLUGIN_BASE = '/prod-api' diff --git a/ruoyi-fastapi-frontend/vue2/web/.env.staging b/ruoyi-fastapi-frontend/vue2/web/.env.staging new file mode 100644 index 0000000..02293ff --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/web/.env.staging @@ -0,0 +1,15 @@ +# 页面标题 +VUE_APP_TITLE = vfadmin管理系统 + +BABEL_ENV = production + +NODE_ENV = production + +# 测试环境配置 +ENV = 'staging' + +# vfadmin管理系统/测试环境 +VUE_APP_BASE_API = '/stage-api' + +# 插件浏览器路径需与后端 APP_ROOT_PATH 一致 +VUE_APP_PLUGIN_BASE = '/stage-api' diff --git a/ruoyi-fastapi-frontend/.gitattributes b/ruoyi-fastapi-frontend/vue2/web/.gitattributes similarity index 100% rename from ruoyi-fastapi-frontend/.gitattributes rename to ruoyi-fastapi-frontend/vue2/web/.gitattributes diff --git a/ruoyi-fastapi-frontend/.gitignore b/ruoyi-fastapi-frontend/vue2/web/.gitignore similarity index 100% rename from ruoyi-fastapi-frontend/.gitignore rename to ruoyi-fastapi-frontend/vue2/web/.gitignore diff --git a/ruoyi-fastapi-frontend/vue2/web/.prettierignore b/ruoyi-fastapi-frontend/vue2/web/.prettierignore new file mode 100644 index 0000000..fe91461 --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/web/.prettierignore @@ -0,0 +1,30 @@ +# Keep the HTML entry DOCTYPE and formatting manually maintained. +/public/index.html +/public/html/ie.html +/public/styles/theme-chalk/index.css + +# Dependencies, build output and caches +node_modules/ +dist/ +coverage/ +.cache/ +.vite/ + +# Test output +tests/**/coverage/ +tests/e2e/reports/ +playwright-report/ +test-results/ + +# Package-manager output and generated declarations +package-lock.json +pnpm-lock.yaml +yarn.lock +auto-imports.d.ts +components.d.ts + +# Generated or external assets +**/*.min.js +**/*.min.css +**/*.svg +**/*.map diff --git a/ruoyi-fastapi-frontend/.prettierrc.json b/ruoyi-fastapi-frontend/vue2/web/.prettierrc.json similarity index 100% rename from ruoyi-fastapi-frontend/.prettierrc.json rename to ruoyi-fastapi-frontend/vue2/web/.prettierrc.json diff --git a/ruoyi-fastapi-frontend/vue2/web/Dockerfile b/ruoyi-fastapi-frontend/vue2/web/Dockerfile new file mode 100644 index 0000000..04032c0 --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/web/Dockerfile @@ -0,0 +1,31 @@ +# 构建阶段 +FROM node:18-slim AS builder +ENV TZ=UTC +ENV NODE_OPTIONS=--openssl-legacy-provider +WORKDIR /app + +# 复制源代码 +COPY . . + +# 设置npm镜像源 +RUN npm config set registry https://registry.npmmirror.com + +# 仓库不提交 npm 锁文件,Vue CLI 4 使用旧版 webpack 依赖。 +RUN npm install --legacy-peer-deps --no-package-lock + +# 执行docker构建命令 +RUN npm run build:docker + +# 运行阶段 +FROM nginx:latest +ENV TZ=UTC +WORKDIR /usr/share/nginx/html + +# 复制构建产物 +COPY --from=builder /app/dist . + +# 暴露端口 +EXPOSE 80 + +# 启动nginx +CMD ["nginx", "-g", "daemon off;"] diff --git a/ruoyi-fastapi-frontend/vue2/web/FORMATTING.md b/ruoyi-fastapi-frontend/vue2/web/FORMATTING.md new file mode 100644 index 0000000..c9062fb --- /dev/null +++ b/ruoyi-fastapi-frontend/vue2/web/FORMATTING.md @@ -0,0 +1,122 @@ +# 管理端前端格式化规范 + +本规范适用于 `ruoyi-fastapi-frontend/vue2/web`,覆盖 Vue 单文件组件、JavaScript / TypeScript、CSS / SCSS / Less、HTML、JSON、Markdown 和 YAML,包括 `src`、`plugins`、`build`、`tests` 以及自行维护的 `public` 脚本。 + +## 参考项目与工具 + +参考 [Element Plus 官方项目的 Prettier 配置](https://github.com/element-plus/element-plus/blob/dev/.prettierrc)(核对日期:2026-09-14)。当前管理端使用 Vue 2.7 和 Element UI,沿用 Vue 3 版本的排版规范,采用 `semi: false`、`singleQuote: true`、`trailingComma: 'es5'` 作为基础风格,适合现有 JavaScript 代码。 + +格式化统一使用 **Prettier 3.6.2**,以 `.prettierrc.json` 为规则来源。该版本与项目已有的本地安装及锁文件一致,并在 `devDependencies` 中固定版本,避免开发者因格式化器版本不同产生差异。 + +## 具体规则 + +| 项目 | 规则 | 说明 | +| --------------------- | ---------------------------------- | --------------------------------------------------------------------- | +| 缩进 | 2 个空格,不使用 Tab | 模板按嵌套层级缩进 | +| 行宽 | `printWidth: 100` | 本项目适配;作为自动换行参考宽度,长字符串等可以超出 | +| JavaScript 字符串 | 优先单引号 | 沿用 Element Plus;必要时由 Prettier 选择更少转义的引号 | +| HTML / Vue 属性、JSON | 双引号 | JavaScript 的单引号设置不改变这些语法的属性引号 | +| JavaScript 分号 | 不写行末分号 | 沿用 Element Plus;Prettier 会保留避免自动分号插入歧义所需的分号 | +| 尾逗号 | `trailingComma: 'es5'` | 沿用 Element Plus;多行对象、数组等保留尾逗号,函数参数不添加尾逗号 | +| 箭头函数 | 参数始终带括号 | `(item) => item.id`,便于增加参数、默认值或类型 | +| 对象字面量 | 大括号内侧加空格 | `{ name: 'demo' }`,属性名仅在语法需要时加引号 | +| Vue / HTML 多属性标签 | 每个属性独占一行 | 本项目适配;减少表单、表格和权限指令挤在同一行的情况 | +| 多行标签的 `>` | 不紧跟最后一个属性 | 使用 `bracketSameLine: false` | +| Vue 的 script / style | 内容不额外缩进一层 | `vueIndentScriptAndStyle: false` | +| HTML 空白 | `htmlWhitespaceSensitivity: 'css'` | 保留 Prettier 默认的空白处理方式,兼顾行内文本空格语义 | +| 文本文件 | UTF-8、LF、末尾换行 | `.editorconfig` 与 `.gitattributes` 配合;Windows 批处理脚本使用 CRLF | +| 行尾空格 | 删除 | Markdown 例外,保留用于硬换行的两个空格 | +| Markdown 段落 | 保留原有换行 | `proseWrap: 'preserve'`,避免中文段落被反复重排 | + +除上述项目适配外,使用 Prettier 默认行为。选项含义见 [Prettier 官方文档](https://prettier.io/docs/options)。 + +Vue 单文件组件沿用 `