From 5759c1862ebdb4b23dca83636331cd6f2d5c50d6 Mon Sep 17 00:00:00 2001 From: ben Date: Wed, 12 Aug 2026 11:45:54 +0800 Subject: [PATCH] =?UTF-8?q?AI=20Gateway=20Go=200.10.0=20=E6=BA=90=E7=A0=81?= =?UTF-8?q?=E5=BF=AB=E7=85=A7=20+=20=E6=97=97=E8=88=B0=E7=89=88=E9=9C=80?= =?UTF-8?q?=E6=B1=82=E8=A7=84=E5=88=92=E6=8A=A5=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M0-M7 已完成:核心网关(身份/RBAC/TOTP/OIDC/SAML/Provider/配额/路由/内容策略/审计/定价)+ 资源市场(MCP/Skills/数字员工)。 含 22 个 PostgreSQL 迁移、管理端/门户端前端源码、OpenAPI 契约、部署 compose。 Co-Authored-By: Claude --- .dockerignore | 9 + .env.example | 75 + .gitignore | 9 + Makefile | 32 + README.md | 112 + RELEASE.md | 20 + api/openapi/gateway.yaml | 1627 ++++ cmd/gateway-api/main.go | 383 + cmd/gateway-bootstrap/main.go | 84 + cmd/gateway-legacy-import/main.go | 178 + cmd/gateway-legacy-import/main_test.go | 27 + cmd/gateway-loadtest/main.go | 137 + cmd/gateway-loadtest/main_test.go | 16 + cmd/gateway-maintenance/main.go | 52 + cmd/gateway-migrator/main.go | 44 + cmd/gateway-notification-worker/main.go | 61 + cmd/gateway-outbox-worker/main.go | 68 + deploy/Dockerfile | 33 + deploy/Dockerfile.web | 16 + deploy/PRODUCTION.md | 74 + deploy/docker-compose.production.yml | 189 + deploy/docker-compose.yml | 154 + deploy/nginx-web.conf | 71 + deploy/production.env.example | 42 + docs/adr/0001-baseline-storage.md | 18 + docs/cutover-runbook.md | 35 + docs/legacy-data-dictionary.md | 625 ++ docs/legacy-import-runbook.md | 42 + docs/rewrite-progress.md | 115 + docs/route-contract-matrix.md | 213 + docs/旗舰版需求规划与完成情况.md | 170 + go.mod | 27 + go.sum | 78 + internal/apikey/admin_http.go | 203 + internal/apikey/apikey.go | 75 + internal/apikey/apikey_test.go | 73 + internal/apikey/authenticator.go | 124 + internal/apikey/repository.go | 179 + internal/apikey/usage.go | 42 + internal/audit/admin_http.go | 144 + internal/audit/maintenance.go | 147 + .../audit/maintenance_integration_test.go | 73 + internal/audit/maintenance_test.go | 23 + internal/audit/query.go | 151 + internal/audit/recorder.go | 282 + internal/contentpolicy/admin_http.go | 119 + internal/contentpolicy/engine.go | 272 + internal/contentpolicy/engine_test.go | 44 + internal/contentpolicy/store.go | 156 + internal/factcheck/admin_http.go | 154 + internal/factcheck/engine.go | 298 + internal/factcheck/engine_test.go | 56 + internal/factcheck/service.go | 235 + .../factcheck/service_integration_test.go | 100 + internal/gateway/admission.go | 139 + internal/gateway/admission_test.go | 30 + internal/gateway/audit.go | 221 + .../content_pricing_integration_test.go | 141 + internal/gateway/context.go | 14 + internal/gateway/proxy.go | 430 + internal/gateway/proxy_test.go | 252 + internal/gateway/request_budget.go | 46 + internal/gateway/resilience.go | 149 + internal/gateway/resilience_test.go | 61 + internal/gateway/routing.go | 76 + internal/gateway/token_quota.go | 129 + internal/gateway/usage.go | 261 + internal/gateway/usage_test.go | 21 + internal/identity/account.go | 128 + internal/identity/departments.go | 297 + internal/identity/http.go | 429 + internal/identity/management.go | 415 + internal/identity/oidc.go | 818 ++ internal/identity/oidc_test.go | 140 + internal/identity/password.go | 103 + internal/identity/password_test.go | 36 + internal/identity/permissions_test.go | 29 + internal/identity/ratelimit.go | 104 + internal/identity/ratelimit_test.go | 34 + internal/identity/repository.go | 335 + internal/identity/saml.go | 636 ++ internal/identity/saml_test.go | 115 + internal/identity/service.go | 362 + internal/identity/session.go | 211 + internal/identity/totp.go | 118 + internal/identity/totp_test.go | 43 + internal/operations/admin_http.go | 84 + internal/outbox/admin_http.go | 95 + internal/outbox/consumer_integration_test.go | 55 + internal/outbox/publisher.go | 98 + internal/outbox/store.go | 201 + internal/outbox/worker.go | 91 + internal/outbox/worker_test.go | 18 + internal/platform/apiresponse/response.go | 26 + internal/platform/cache/redis.go | 23 + internal/platform/config/config.go | 348 + internal/platform/config/config_test.go | 45 + internal/platform/cryptox/aesgcm.go | 77 + internal/platform/cryptox/keyring.go | 100 + internal/platform/cryptox/keyring_test.go | 37 + internal/platform/database/postgres.go | 31 + internal/platform/health/checker.go | 85 + internal/platform/health/checker_test.go | 35 + internal/platform/httpserver/server.go | 113 + internal/platform/id/uuid.go | 17 + internal/platform/legacyid/legacyid.go | 36 + internal/platform/legacyid/legacyid_test.go | 26 + internal/platform/migrate/migrate.go | 113 + internal/portal/admin_http.go | 92 + internal/portal/conversations.go | 239 + internal/portal/credentials.go | 93 + internal/portal/http.go | 526 ++ internal/portal/portal_integration_test.go | 117 + internal/portal/service.go | 368 + internal/pricing/admin_http.go | 114 + internal/pricing/service.go | 252 + internal/pricing/service_test.go | 29 + internal/provider/admin_http.go | 498 + internal/provider/controlplane/service.go | 262 + .../provider/controlplane/service_test.go | 36 + internal/provider/credentials.go | 15 + internal/provider/credentials_test.go | 26 + internal/provider/model_routes.go | 177 + internal/provider/openai/adapter.go | 55 + internal/provider/openai/adapter_test.go | 21 + internal/provider/provider.go | 82 + internal/provider/repository.go | 357 + internal/provider/runtime/resolver.go | 330 + internal/provider/runtime/resolver_test.go | 79 + internal/provider/url_validator.go | 44 + internal/provider/url_validator_test.go | 16 + internal/shadow/middleware.go | 235 + internal/shadow/middleware_test.go | 83 + internal/workbench/admin_http.go | 783 ++ internal/workbench/admin_marketplace.go | 435 + internal/workbench/applications.go | 380 + internal/workbench/digital_employees.go | 378 + internal/workbench/knowledge.go | 448 + internal/workbench/marketplace.go | 444 + .../workbench/marketplace_integration_test.go | 179 + internal/workbench/mcp.go | 253 + internal/workbench/mcp_client.go | 369 + internal/workbench/mcp_client_test.go | 237 + internal/workbench/notifications.go | 437 + internal/workbench/prompts.go | 377 + internal/workbench/runtime_http.go | 688 ++ internal/workbench/runtime_marketplace.go | 438 + internal/workbench/service.go | 69 + internal/workbench/skills.go | 223 + internal/workbench/tools.go | 365 + internal/workbench/types.go | 251 + .../workbench/workbench_integration_test.go | 155 + internal/workbench/workbench_test.go | 51 + migrations/000001_foundation.sql | 97 + migrations/000002_identity.sql | 82 + migrations/000003_api_key_actor.sql | 8 + migrations/000004_provider_default.sql | 6 + migrations/000005_provider_models.sql | 20 + migrations/000006_identity_permissions.sql | 11 + migrations/000007_departments.sql | 13 + migrations/000008_oidc.sql | 31 + migrations/000009_legacy_id_mappings.sql | 19 + migrations/000010_saml.sql | 15 + migrations/000011_api_key_limits.sql | 18 + migrations/000012_api_key_token_quota.sql | 12 + migrations/000013_model_routes.sql | 21 + migrations/000014_audit_usage.sql | 26 + migrations/000015_outbox_delivery.sql | 14 + migrations/000016_content_policy_pricing.sql | 60 + migrations/000017_ai_assets_orchestration.sql | 212 + migrations/000018_legacy_import_staging.sql | 45 + migrations/000019_portal_workspace.sql | 38 + migrations/000020_portal_conversations.sql | 49 + migrations/000021_fact_check.sql | 51 + migrations/000022_resource_marketplace.sql | 130 + scripts/check_openapi_routes.py | 27 + scripts/compare_route_contracts.py | 203 + scripts/cutover.sh | 84 + scripts/export_legacy_data.py | 58 + scripts/export_legacy_dictionary.py | 97 + web/README.md | 18 + web/apps/admin/.env.development | 13 + web/apps/admin/.env.production | 13 + web/apps/admin/.gitattributes | 2 + web/apps/admin/.gitignore | 11 + web/apps/admin/.husky/commit-msg | 1 + web/apps/admin/.husky/pre-commit | 1 + web/apps/admin/.prettierignore | 3 + web/apps/admin/.prettierrc | 20 + web/apps/admin/.stylelintignore | 9 + web/apps/admin/.stylelintrc.cjs | 82 + web/apps/admin/LICENSE | 21 + web/apps/admin/commitlint.config.cjs | 97 + web/apps/admin/eslint.config.mjs | 83 + web/apps/admin/index.html | 47 + web/apps/admin/package.json | 124 + web/apps/admin/public/favicon.ico | Bin 0 -> 4286 bytes web/apps/admin/scripts/clean-dev.ts | 838 ++ web/apps/admin/src/App.vue | 41 + web/apps/admin/src/api/apiKeys.ts | 45 + web/apps/admin/src/api/audit.ts | 45 + web/apps/admin/src/api/auth.ts | 36 + web/apps/admin/src/api/content-policy.ts | 12 + web/apps/admin/src/api/governance.ts | 15 + web/apps/admin/src/api/identities.ts | 162 + web/apps/admin/src/api/marketplace.ts | 71 + web/apps/admin/src/api/modelRoutes.ts | 40 + web/apps/admin/src/api/outbox.ts | 29 + web/apps/admin/src/api/pricing.ts | 12 + web/apps/admin/src/api/providers.ts | 91 + web/apps/admin/src/api/system-manage.ts | 25 + web/apps/admin/src/api/workbench.ts | 59 + .../src/assets/images/avatar/avatar.webp | Bin 0 -> 954 bytes .../src/assets/images/avatar/avatar1.webp | Bin 0 -> 2296 bytes .../src/assets/images/avatar/avatar10.webp | Bin 0 -> 1410 bytes .../src/assets/images/avatar/avatar2.webp | Bin 0 -> 1214 bytes .../src/assets/images/avatar/avatar3.webp | Bin 0 -> 726 bytes .../src/assets/images/avatar/avatar4.webp | Bin 0 -> 944 bytes .../src/assets/images/avatar/avatar5.webp | Bin 0 -> 2272 bytes .../src/assets/images/avatar/avatar6.webp | Bin 0 -> 810 bytes .../src/assets/images/avatar/avatar7.webp | Bin 0 -> 2712 bytes .../src/assets/images/avatar/avatar8.webp | Bin 0 -> 3946 bytes .../src/assets/images/avatar/avatar9.webp | Bin 0 -> 1680 bytes .../admin/src/assets/images/ceremony/hb.png | Bin 0 -> 2275 bytes .../admin/src/assets/images/ceremony/sd.png | Bin 0 -> 4752 bytes .../admin/src/assets/images/ceremony/xc.png | Bin 0 -> 4910 bytes .../admin/src/assets/images/ceremony/yd.png | Bin 0 -> 4629 bytes .../admin/src/assets/images/common/logo.webp | Bin 0 -> 2484 bytes .../admin/src/assets/images/draw/draw1.png | Bin 0 -> 11315 bytes web/apps/admin/src/assets/images/favicon.ico | Bin 0 -> 4286 bytes .../admin/src/assets/images/lock/bg_dark.webp | Bin 0 -> 70592 bytes .../src/assets/images/lock/bg_light.webp | Bin 0 -> 67246 bytes .../src/assets/images/login/lf_icon2.webp | Bin 0 -> 25016 bytes .../settings/menu_layouts/dual_column.png | Bin 0 -> 514 bytes .../settings/menu_layouts/horizontal.png | Bin 0 -> 409 bytes .../images/settings/menu_layouts/mixed.png | Bin 0 -> 431 bytes .../images/settings/menu_layouts/vertical.png | Bin 0 -> 439 bytes .../images/settings/menu_styles/dark.png | Bin 0 -> 292 bytes .../images/settings/menu_styles/design.png | Bin 0 -> 286 bytes .../images/settings/menu_styles/light.png | Bin 0 -> 293 bytes .../images/settings/theme_styles/dark.png | Bin 0 -> 448 bytes .../images/settings/theme_styles/light.png | Bin 0 -> 416 bytes .../images/settings/theme_styles/system.png | Bin 0 -> 509 bytes web/apps/admin/src/assets/images/svg/403.svg | 1 + web/apps/admin/src/assets/images/svg/404.svg | 1 + web/apps/admin/src/assets/images/svg/500.svg | 5 + .../src/assets/images/svg/login_icon.svg | 1 + .../admin/src/assets/images/user/avatar.webp | Bin 0 -> 2130 bytes web/apps/admin/src/assets/images/user/bg.webp | Bin 0 -> 12352 bytes .../admin/src/assets/styles/core/app.scss | 292 + .../admin/src/assets/styles/core/dark.scss | 93 + .../admin/src/assets/styles/core/el-dark.scss | 2 + .../src/assets/styles/core/el-light.scss | 34 + .../admin/src/assets/styles/core/el-ui.scss | 519 ++ web/apps/admin/src/assets/styles/core/md.scss | 1036 +++ .../admin/src/assets/styles/core/mixin.scss | 157 + .../admin/src/assets/styles/core/reset.scss | 41 + .../assets/styles/core/router-transition.scss | 104 + .../admin/src/assets/styles/core/tailwind.css | 208 + .../assets/styles/core/theme-animation.scss | 63 + .../src/assets/styles/core/theme-change.scss | 11 + .../assets/styles/custom/one-dark-pro.scss | 98 + web/apps/admin/src/assets/styles/index.scss | 23 + web/apps/admin/src/assets/svg/loading.ts | 32 + .../core/banners/art-basic-banner/index.vue | 343 + .../core/banners/art-card-banner/index.vue | 114 + .../core/base/art-back-to-top/index.vue | 40 + .../components/core/base/art-logo/index.vue | 21 + .../core/base/art-svg-icon/index.vue | 24 + .../core/cards/art-bar-chart-card/index.vue | 103 + .../core/cards/art-data-list-card/index.vue | 74 + .../core/cards/art-donut-chart-card/index.vue | 124 + .../core/cards/art-image-card/index.vue | 89 + .../core/cards/art-line-chart-card/index.vue | 126 + .../core/cards/art-progress-card/index.vue | 86 + .../core/cards/art-stats-card/index.vue | 67 + .../cards/art-timeline-list-card/index.vue | 69 + .../core/charts/art-bar-chart/index.vue | 203 + .../art-dual-bar-compare-chart/index.vue | 195 + .../core/charts/art-h-bar-chart/index.vue | 208 + .../core/charts/art-k-line-chart/index.vue | 152 + .../core/charts/art-line-chart/index.vue | 371 + .../core/charts/art-radar-chart/index.vue | 105 + .../core/charts/art-ring-chart/index.vue | 133 + .../core/charts/art-scatter-chart/index.vue | 115 + .../core/forms/art-button-more/index.vue | 71 + .../core/forms/art-button-table/index.vue | 59 + .../core/forms/art-drag-verify/index.vue | 430 + .../core/forms/art-excel-export/index.vue | 389 + .../core/forms/art-excel-import/index.vue | 62 + .../components/core/forms/art-form/index.vue | 507 + .../core/forms/art-search-bar/index.vue | 581 ++ .../core/forms/art-wang-editor/index.vue | 257 + .../core/forms/art-wang-editor/style.scss | 273 + .../core/layouts/art-breadcrumb/index.vue | 142 + .../core/layouts/art-chat-window/index.vue | 262 + .../core/layouts/art-fast-enter/index.vue | 113 + .../layouts/art-fireworks-effect/index.vue | 633 ++ .../layouts/art-global-component/index.vue | 14 + .../core/layouts/art-global-search/index.vue | 431 + .../core/layouts/art-header-bar/index.vue | 501 + .../art-header-bar/widget/ArtUserMenu.vue | 167 + .../art-menus/art-horizontal-menu/index.vue | 110 + .../widget/HorizontalSubmenu.vue | 106 + .../art-menus/art-mixed-menu/index.vue | 279 + .../art-menus/art-sidebar-menu/index.vue | 354 + .../art-menus/art-sidebar-menu/style.scss | 251 + .../art-menus/art-sidebar-menu/theme.scss | 274 + .../widget/SidebarSubmenu.vue | 199 + .../core/layouts/art-notification/index.vue | 456 + .../core/layouts/art-page-content/index.vue | 136 + .../core/layouts/art-screen-lock/index.vue | 519 ++ .../composables/useSettingsConfig.ts | 248 + .../composables/useSettingsHandlers.ts | 167 + .../composables/useSettingsPanel.ts | 233 + .../composables/useSettingsState.ts | 37 + .../core/layouts/art-settings-panel/index.vue | 72 + .../layouts/art-settings-panel/style.scss | 92 + .../widget/BasicSettings.vue | 77 + .../widget/BoxStyleSettings.vue | 38 + .../widget/ColorSettings.vue | 35 + .../widget/ContainerSettings.vue | 33 + .../widget/MenuLayoutSettings.vue | 31 + .../widget/MenuStyleSettings.vue | 44 + .../widget/SectionTitle.vue | 17 + .../widget/SettingActions.vue | 235 + .../widget/SettingDrawer.vue | 51 + .../widget/SettingHeader.vue | 18 + .../art-settings-panel/widget/SettingItem.vue | 101 + .../widget/ThemeSettings.vue | 28 + .../core/layouts/art-work-tab/index.vue | 584 ++ .../core/media/art-cutter-img/index.vue | 350 + .../core/media/art-video-player/index.vue | 111 + .../core/others/art-menu-right/index.vue | 415 + .../core/others/art-watermark/index.vue | 64 + .../core/tables/art-table-header/index.vue | 339 + .../core/tables/art-table/index.vue | 368 + .../core/tables/art-table/style.scss | 99 + .../core/text-effect/art-count-to/index.vue | 310 + .../art-festival-text-scroll/index.vue | 32 + .../text-effect/art-text-scroll/index.vue | 285 + .../components/core/theme/theme-svg/index.vue | 100 + .../core/views/exception/ArtException.vue | 55 + .../core/views/login/AuthTopBar.vue | 171 + .../core/views/login/LoginLeftView.vue | 453 + .../core/views/result/ArtResultPage.vue | 43 + .../core/widget/art-icon-button/index.vue | 23 + web/apps/admin/src/config/assets/images.ts | 61 + web/apps/admin/src/config/fastEnter.ts | 79 + web/apps/admin/src/config/index.ts | 136 + .../admin/src/config/modules/component.ts | 105 + .../admin/src/config/modules/fastEnter.ts | 127 + web/apps/admin/src/config/modules/festival.ts | 51 + .../admin/src/config/modules/headerBar.ts | 63 + web/apps/admin/src/config/setting.ts | 109 + .../src/directives/business/highlight.ts | 250 + .../admin/src/directives/business/ripple.ts | 116 + web/apps/admin/src/directives/core/auth.ts | 66 + web/apps/admin/src/directives/core/roles.ts | 87 + web/apps/admin/src/directives/index.ts | 14 + web/apps/admin/src/enums/appEnum.ts | 81 + web/apps/admin/src/enums/formEnum.ts | 24 + web/apps/admin/src/env.d.ts | 34 + web/apps/admin/src/hooks/core/useAppMode.ts | 45 + web/apps/admin/src/hooks/core/useAuth.ts | 74 + web/apps/admin/src/hooks/core/useCeremony.ts | 184 + web/apps/admin/src/hooks/core/useChart.ts | 745 ++ web/apps/admin/src/hooks/core/useCommon.ts | 87 + web/apps/admin/src/hooks/core/useFastEnter.ts | 55 + web/apps/admin/src/hooks/core/useHeaderBar.ts | 201 + .../admin/src/hooks/core/useLayoutHeight.ts | 148 + web/apps/admin/src/hooks/core/useTable.ts | 736 ++ .../admin/src/hooks/core/useTableColumns.ts | 312 + .../admin/src/hooks/core/useTableHeight.ts | 105 + web/apps/admin/src/hooks/core/useTheme.ts | 174 + web/apps/admin/src/hooks/index.ts | 32 + web/apps/admin/src/locales/index.ts | 123 + web/apps/admin/src/locales/langs/en.json | 296 + web/apps/admin/src/locales/langs/zh.json | 296 + web/apps/admin/src/main.ts | 25 + web/apps/admin/src/mock/temp/formData.ts | 273 + web/apps/admin/src/mock/upgrade/changeLog.ts | 12 + web/apps/admin/src/plugins/echarts.ts | 76 + web/apps/admin/src/plugins/index.ts | 6 + .../admin/src/router/core/ComponentLoader.ts | 82 + .../src/router/core/IframeRouteManager.ts | 78 + .../admin/src/router/core/MenuProcessor.ts | 280 + .../router/core/RoutePermissionValidator.ts | 161 + .../admin/src/router/core/RouteRegistry.ts | 90 + .../admin/src/router/core/RouteTransformer.ts | 132 + .../admin/src/router/core/RouteValidator.ts | 187 + web/apps/admin/src/router/core/index.ts | 14 + web/apps/admin/src/router/guards/afterEach.ts | 34 + .../admin/src/router/guards/beforeEach.ts | 418 + web/apps/admin/src/router/index.ts | 23 + .../admin/src/router/modules/dashboard.ts | 24 + .../admin/src/router/modules/exception.ts | 46 + web/apps/admin/src/router/modules/gateway.ts | 64 + web/apps/admin/src/router/modules/index.ts | 17 + web/apps/admin/src/router/modules/result.ts | 33 + web/apps/admin/src/router/modules/system.ts | 60 + .../admin/src/router/routes/asyncRoutes.ts | 9 + .../admin/src/router/routes/staticRoutes.ts | 72 + web/apps/admin/src/router/routesAlias.ts | 8 + web/apps/admin/src/store/index.ts | 52 + web/apps/admin/src/store/modules/menu.ts | 109 + web/apps/admin/src/store/modules/setting.ts | 450 + web/apps/admin/src/store/modules/table.ts | 97 + web/apps/admin/src/store/modules/user.ts | 235 + web/apps/admin/src/store/modules/worktab.ts | 568 ++ web/apps/admin/src/types/api/api.d.ts | 143 + web/apps/admin/src/types/common/index.ts | 95 + web/apps/admin/src/types/common/response.ts | 30 + web/apps/admin/src/types/component/chart.ts | 324 + web/apps/admin/src/types/component/index.ts | 145 + web/apps/admin/src/types/config/index.ts | 213 + .../admin/src/types/directive/directive.d.ts | 15 + web/apps/admin/src/types/index.ts | 22 + web/apps/admin/src/types/router/index.ts | 80 + web/apps/admin/src/types/store/index.ts | 157 + web/apps/admin/src/utils/constants/index.ts | 8 + web/apps/admin/src/utils/constants/links.ts | 35 + web/apps/admin/src/utils/form/index.ts | 12 + web/apps/admin/src/utils/form/responsive.ts | 122 + web/apps/admin/src/utils/form/validator.ts | 316 + web/apps/admin/src/utils/http/error.ts | 182 + web/apps/admin/src/utils/http/index.ts | 217 + web/apps/admin/src/utils/http/status.ts | 18 + web/apps/admin/src/utils/index.ts | 34 + web/apps/admin/src/utils/navigation/index.ts | 10 + web/apps/admin/src/utils/navigation/jump.ts | 68 + web/apps/admin/src/utils/navigation/route.ts | 85 + .../admin/src/utils/navigation/worktab.ts | 67 + web/apps/admin/src/utils/router.ts | 61 + web/apps/admin/src/utils/socket/index.ts | 423 + web/apps/admin/src/utils/storage/index.ts | 7 + .../admin/src/utils/storage/storage-config.ts | 125 + .../src/utils/storage/storage-key-manager.ts | 97 + web/apps/admin/src/utils/storage/storage.ts | 250 + web/apps/admin/src/utils/sys/console.ts | 13 + web/apps/admin/src/utils/sys/error-handle.ts | 143 + web/apps/admin/src/utils/sys/index.ts | 6 + web/apps/admin/src/utils/sys/mittBus.ts | 63 + web/apps/admin/src/utils/sys/upgrade.ts | 277 + web/apps/admin/src/utils/table/tableCache.ts | 266 + web/apps/admin/src/utils/table/tableConfig.ts | 55 + web/apps/admin/src/utils/table/tableUtils.ts | 297 + web/apps/admin/src/utils/ui/animation.ts | 80 + web/apps/admin/src/utils/ui/colors.ts | 273 + web/apps/admin/src/utils/ui/emojo.ts | 24 + web/apps/admin/src/utils/ui/iconify-loader.ts | 31 + web/apps/admin/src/utils/ui/index.ts | 11 + web/apps/admin/src/utils/ui/loading.ts | 84 + web/apps/admin/src/utils/ui/tabs.ts | 60 + .../src/views/auth/forget-password/index.vue | 62 + web/apps/admin/src/views/auth/login/index.vue | 352 + web/apps/admin/src/views/auth/login/style.css | 104 + .../admin/src/views/auth/register/index.vue | 240 + .../src/views/dashboard/console/index.vue | 41 + .../console/modules/about-project.vue | 44 + .../dashboard/console/modules/active-user.vue | 47 + .../dashboard/console/modules/card-list.vue | 74 + .../console/modules/dynamic-stats.vue | 79 + .../dashboard/console/modules/new-user.vue | 169 + .../console/modules/sales-overview.vue | 43 + .../dashboard/console/modules/todo-list.vue | 71 + .../admin/src/views/exception/403/index.vue | 16 + .../admin/src/views/exception/404/index.vue | 16 + .../admin/src/views/exception/500/index.vue | 16 + .../src/views/gateway/api-keys/index.vue | 176 + .../src/views/gateway/applications/index.vue | 4 + .../src/views/gateway/audit-usage/index.vue | 95 + .../views/gateway/content-policies/index.vue | 50 + .../views/gateway/digital-employees/index.vue | 110 + .../src/views/gateway/governance/index.vue | 15 + .../src/views/gateway/knowledge/index.vue | 5 + .../src/views/gateway/marketplace/index.vue | 79 + .../src/views/gateway/mcp-servers/index.vue | 94 + .../src/views/gateway/model-prices/index.vue | 31 + .../src/views/gateway/model-routes/index.vue | 95 + .../src/views/gateway/notifications/index.vue | 3 + .../admin/src/views/gateway/outbox/index.vue | 67 + .../admin/src/views/gateway/prompts/index.vue | 17 + .../src/views/gateway/providers/index.vue | 308 + .../admin/src/views/gateway/skills/index.vue | 103 + .../admin/src/views/gateway/tools/index.vue | 4 + web/apps/admin/src/views/index/index.vue | 29 + web/apps/admin/src/views/index/style.scss | 103 + web/apps/admin/src/views/outside/Iframe.vue | 42 + .../admin/src/views/result/fail/index.vue | 28 + .../admin/src/views/result/success/index.vue | 21 + .../admin/src/views/system/menu/index.vue | 479 + .../views/system/menu/modules/menu-dialog.vue | 384 + .../admin/src/views/system/role/index.vue | 241 + .../system/role/modules/role-edit-dialog.vue | 162 + .../role/modules/role-permission-dialog.vue | 254 + .../views/system/role/modules/role-search.vue | 125 + .../src/views/system/user-center/index.vue | 247 + .../admin/src/views/system/user/index.vue | 529 ++ .../views/system/user/modules/user-dialog.vue | 143 + .../views/system/user/modules/user-search.vue | 112 + web/apps/admin/tsconfig.json | 28 + web/apps/admin/vite.config.ts | 156 + web/apps/portal/.env.development | 13 + web/apps/portal/.env.production | 13 + web/apps/portal/.gitattributes | 2 + web/apps/portal/.gitignore | 11 + web/apps/portal/.husky/commit-msg | 1 + web/apps/portal/.husky/pre-commit | 1 + web/apps/portal/.prettierignore | 3 + web/apps/portal/.prettierrc | 20 + web/apps/portal/.stylelintignore | 9 + web/apps/portal/.stylelintrc.cjs | 82 + web/apps/portal/LICENSE | 21 + web/apps/portal/commitlint.config.cjs | 97 + web/apps/portal/eslint.config.mjs | 83 + web/apps/portal/index.html | 47 + web/apps/portal/package.json | 124 + web/apps/portal/public/favicon.ico | Bin 0 -> 4286 bytes web/apps/portal/scripts/clean-dev.ts | 838 ++ web/apps/portal/src/App.vue | 41 + web/apps/portal/src/api/auth.ts | 53 + web/apps/portal/src/api/portal.ts | 33 + web/apps/portal/src/api/system-manage.ts | 25 + .../src/assets/images/avatar/avatar.webp | Bin 0 -> 954 bytes .../src/assets/images/avatar/avatar1.webp | Bin 0 -> 2296 bytes .../src/assets/images/avatar/avatar10.webp | Bin 0 -> 1410 bytes .../src/assets/images/avatar/avatar2.webp | Bin 0 -> 1214 bytes .../src/assets/images/avatar/avatar3.webp | Bin 0 -> 726 bytes .../src/assets/images/avatar/avatar4.webp | Bin 0 -> 944 bytes .../src/assets/images/avatar/avatar5.webp | Bin 0 -> 2272 bytes .../src/assets/images/avatar/avatar6.webp | Bin 0 -> 810 bytes .../src/assets/images/avatar/avatar7.webp | Bin 0 -> 2712 bytes .../src/assets/images/avatar/avatar8.webp | Bin 0 -> 3946 bytes .../src/assets/images/avatar/avatar9.webp | Bin 0 -> 1680 bytes .../portal/src/assets/images/ceremony/hb.png | Bin 0 -> 2275 bytes .../portal/src/assets/images/ceremony/sd.png | Bin 0 -> 4752 bytes .../portal/src/assets/images/ceremony/xc.png | Bin 0 -> 4910 bytes .../portal/src/assets/images/ceremony/yd.png | Bin 0 -> 4629 bytes .../portal/src/assets/images/common/logo.webp | Bin 0 -> 2484 bytes .../portal/src/assets/images/draw/draw1.png | Bin 0 -> 11315 bytes web/apps/portal/src/assets/images/favicon.ico | Bin 0 -> 4286 bytes .../src/assets/images/lock/bg_dark.webp | Bin 0 -> 70592 bytes .../src/assets/images/lock/bg_light.webp | Bin 0 -> 67246 bytes .../src/assets/images/login/lf_icon2.webp | Bin 0 -> 25016 bytes .../settings/menu_layouts/dual_column.png | Bin 0 -> 514 bytes .../settings/menu_layouts/horizontal.png | Bin 0 -> 409 bytes .../images/settings/menu_layouts/mixed.png | Bin 0 -> 431 bytes .../images/settings/menu_layouts/vertical.png | Bin 0 -> 439 bytes .../images/settings/menu_styles/dark.png | Bin 0 -> 292 bytes .../images/settings/menu_styles/design.png | Bin 0 -> 286 bytes .../images/settings/menu_styles/light.png | Bin 0 -> 293 bytes .../images/settings/theme_styles/dark.png | Bin 0 -> 448 bytes .../images/settings/theme_styles/light.png | Bin 0 -> 416 bytes .../images/settings/theme_styles/system.png | Bin 0 -> 509 bytes web/apps/portal/src/assets/images/svg/403.svg | 1 + web/apps/portal/src/assets/images/svg/404.svg | 1 + web/apps/portal/src/assets/images/svg/500.svg | 5 + .../src/assets/images/svg/login_icon.svg | 1 + .../portal/src/assets/images/user/avatar.webp | Bin 0 -> 2130 bytes .../portal/src/assets/images/user/bg.webp | Bin 0 -> 12352 bytes .../portal/src/assets/styles/core/app.scss | 292 + .../portal/src/assets/styles/core/dark.scss | 93 + .../src/assets/styles/core/el-dark.scss | 2 + .../src/assets/styles/core/el-light.scss | 34 + .../portal/src/assets/styles/core/el-ui.scss | 519 ++ .../portal/src/assets/styles/core/md.scss | 1036 +++ .../portal/src/assets/styles/core/mixin.scss | 157 + .../portal/src/assets/styles/core/reset.scss | 41 + .../assets/styles/core/router-transition.scss | 104 + .../src/assets/styles/core/tailwind.css | 208 + .../assets/styles/core/theme-animation.scss | 63 + .../src/assets/styles/core/theme-change.scss | 11 + .../assets/styles/custom/one-dark-pro.scss | 98 + web/apps/portal/src/assets/styles/index.scss | 23 + web/apps/portal/src/assets/svg/loading.ts | 32 + .../core/banners/art-basic-banner/index.vue | 343 + .../core/banners/art-card-banner/index.vue | 114 + .../core/base/art-back-to-top/index.vue | 40 + .../components/core/base/art-logo/index.vue | 21 + .../core/base/art-svg-icon/index.vue | 24 + .../core/cards/art-bar-chart-card/index.vue | 103 + .../core/cards/art-data-list-card/index.vue | 74 + .../core/cards/art-donut-chart-card/index.vue | 124 + .../core/cards/art-image-card/index.vue | 89 + .../core/cards/art-line-chart-card/index.vue | 126 + .../core/cards/art-progress-card/index.vue | 86 + .../core/cards/art-stats-card/index.vue | 67 + .../cards/art-timeline-list-card/index.vue | 69 + .../core/charts/art-bar-chart/index.vue | 203 + .../art-dual-bar-compare-chart/index.vue | 195 + .../core/charts/art-h-bar-chart/index.vue | 208 + .../core/charts/art-k-line-chart/index.vue | 152 + .../core/charts/art-line-chart/index.vue | 371 + .../core/charts/art-radar-chart/index.vue | 105 + .../core/charts/art-ring-chart/index.vue | 133 + .../core/charts/art-scatter-chart/index.vue | 115 + .../core/forms/art-button-more/index.vue | 71 + .../core/forms/art-button-table/index.vue | 59 + .../core/forms/art-drag-verify/index.vue | 430 + .../core/forms/art-excel-export/index.vue | 389 + .../core/forms/art-excel-import/index.vue | 62 + .../components/core/forms/art-form/index.vue | 507 + .../core/forms/art-search-bar/index.vue | 581 ++ .../core/forms/art-wang-editor/index.vue | 257 + .../core/forms/art-wang-editor/style.scss | 273 + .../core/layouts/art-breadcrumb/index.vue | 142 + .../core/layouts/art-chat-window/index.vue | 262 + .../core/layouts/art-fast-enter/index.vue | 113 + .../layouts/art-fireworks-effect/index.vue | 633 ++ .../layouts/art-global-component/index.vue | 14 + .../core/layouts/art-global-search/index.vue | 431 + .../core/layouts/art-header-bar/index.vue | 501 + .../art-header-bar/widget/ArtUserMenu.vue | 167 + .../art-menus/art-horizontal-menu/index.vue | 110 + .../widget/HorizontalSubmenu.vue | 106 + .../art-menus/art-mixed-menu/index.vue | 279 + .../art-menus/art-sidebar-menu/index.vue | 354 + .../art-menus/art-sidebar-menu/style.scss | 251 + .../art-menus/art-sidebar-menu/theme.scss | 274 + .../widget/SidebarSubmenu.vue | 199 + .../core/layouts/art-notification/index.vue | 456 + .../core/layouts/art-page-content/index.vue | 136 + .../core/layouts/art-screen-lock/index.vue | 519 ++ .../composables/useSettingsConfig.ts | 248 + .../composables/useSettingsHandlers.ts | 167 + .../composables/useSettingsPanel.ts | 233 + .../composables/useSettingsState.ts | 37 + .../core/layouts/art-settings-panel/index.vue | 72 + .../layouts/art-settings-panel/style.scss | 92 + .../widget/BasicSettings.vue | 77 + .../widget/BoxStyleSettings.vue | 38 + .../widget/ColorSettings.vue | 35 + .../widget/ContainerSettings.vue | 33 + .../widget/MenuLayoutSettings.vue | 31 + .../widget/MenuStyleSettings.vue | 44 + .../widget/SectionTitle.vue | 17 + .../widget/SettingActions.vue | 235 + .../widget/SettingDrawer.vue | 51 + .../widget/SettingHeader.vue | 18 + .../art-settings-panel/widget/SettingItem.vue | 101 + .../widget/ThemeSettings.vue | 28 + .../core/layouts/art-work-tab/index.vue | 584 ++ .../core/media/art-cutter-img/index.vue | 350 + .../core/media/art-video-player/index.vue | 111 + .../core/others/art-menu-right/index.vue | 415 + .../core/others/art-watermark/index.vue | 64 + .../core/tables/art-table-header/index.vue | 339 + .../core/tables/art-table/index.vue | 368 + .../core/tables/art-table/style.scss | 99 + .../core/text-effect/art-count-to/index.vue | 310 + .../art-festival-text-scroll/index.vue | 32 + .../text-effect/art-text-scroll/index.vue | 285 + .../components/core/theme/theme-svg/index.vue | 100 + .../core/views/exception/ArtException.vue | 55 + .../core/views/login/AuthTopBar.vue | 171 + .../core/views/login/LoginLeftView.vue | 453 + .../core/views/result/ArtResultPage.vue | 43 + .../core/widget/art-icon-button/index.vue | 23 + web/apps/portal/src/config/assets/images.ts | 61 + web/apps/portal/src/config/fastEnter.ts | 79 + web/apps/portal/src/config/index.ts | 136 + .../portal/src/config/modules/component.ts | 105 + .../portal/src/config/modules/fastEnter.ts | 127 + .../portal/src/config/modules/festival.ts | 51 + .../portal/src/config/modules/headerBar.ts | 63 + web/apps/portal/src/config/setting.ts | 109 + .../src/directives/business/highlight.ts | 250 + .../portal/src/directives/business/ripple.ts | 116 + web/apps/portal/src/directives/core/auth.ts | 66 + web/apps/portal/src/directives/core/roles.ts | 87 + web/apps/portal/src/directives/index.ts | 14 + web/apps/portal/src/enums/appEnum.ts | 81 + web/apps/portal/src/enums/formEnum.ts | 24 + web/apps/portal/src/env.d.ts | 34 + web/apps/portal/src/hooks/core/useAppMode.ts | 45 + web/apps/portal/src/hooks/core/useAuth.ts | 74 + web/apps/portal/src/hooks/core/useCeremony.ts | 184 + web/apps/portal/src/hooks/core/useChart.ts | 745 ++ web/apps/portal/src/hooks/core/useCommon.ts | 87 + .../portal/src/hooks/core/useFastEnter.ts | 55 + .../portal/src/hooks/core/useHeaderBar.ts | 201 + .../portal/src/hooks/core/useLayoutHeight.ts | 148 + web/apps/portal/src/hooks/core/useTable.ts | 736 ++ .../portal/src/hooks/core/useTableColumns.ts | 312 + .../portal/src/hooks/core/useTableHeight.ts | 105 + web/apps/portal/src/hooks/core/useTheme.ts | 174 + web/apps/portal/src/hooks/index.ts | 32 + web/apps/portal/src/locales/index.ts | 123 + web/apps/portal/src/locales/langs/en.json | 296 + web/apps/portal/src/locales/langs/zh.json | 296 + web/apps/portal/src/main.ts | 25 + web/apps/portal/src/mock/temp/formData.ts | 273 + web/apps/portal/src/mock/upgrade/changeLog.ts | 12 + web/apps/portal/src/plugins/echarts.ts | 76 + web/apps/portal/src/plugins/index.ts | 6 + .../portal/src/router/core/ComponentLoader.ts | 82 + .../src/router/core/IframeRouteManager.ts | 78 + .../portal/src/router/core/MenuProcessor.ts | 280 + .../router/core/RoutePermissionValidator.ts | 161 + .../portal/src/router/core/RouteRegistry.ts | 90 + .../src/router/core/RouteTransformer.ts | 132 + .../portal/src/router/core/RouteValidator.ts | 187 + web/apps/portal/src/router/core/index.ts | 14 + .../portal/src/router/guards/afterEach.ts | 34 + .../portal/src/router/guards/beforeEach.ts | 418 + web/apps/portal/src/router/index.ts | 23 + .../portal/src/router/modules/dashboard.ts | 24 + .../portal/src/router/modules/exception.ts | 46 + web/apps/portal/src/router/modules/index.ts | 15 + web/apps/portal/src/router/modules/result.ts | 33 + web/apps/portal/src/router/modules/system.ts | 60 + .../portal/src/router/routes/asyncRoutes.ts | 9 + .../portal/src/router/routes/staticRoutes.ts | 72 + web/apps/portal/src/router/routesAlias.ts | 8 + web/apps/portal/src/store/index.ts | 52 + web/apps/portal/src/store/modules/menu.ts | 109 + web/apps/portal/src/store/modules/setting.ts | 450 + web/apps/portal/src/store/modules/table.ts | 97 + web/apps/portal/src/store/modules/user.ts | 235 + web/apps/portal/src/store/modules/worktab.ts | 568 ++ web/apps/portal/src/types/api/api.d.ts | 143 + web/apps/portal/src/types/common/index.ts | 95 + web/apps/portal/src/types/common/response.ts | 30 + web/apps/portal/src/types/component/chart.ts | 324 + web/apps/portal/src/types/component/index.ts | 145 + web/apps/portal/src/types/config/index.ts | 213 + .../portal/src/types/directive/directive.d.ts | 15 + web/apps/portal/src/types/index.ts | 22 + web/apps/portal/src/types/router/index.ts | 80 + web/apps/portal/src/types/store/index.ts | 157 + web/apps/portal/src/utils/constants/index.ts | 8 + web/apps/portal/src/utils/constants/links.ts | 35 + web/apps/portal/src/utils/form/index.ts | 12 + web/apps/portal/src/utils/form/responsive.ts | 122 + web/apps/portal/src/utils/form/validator.ts | 316 + web/apps/portal/src/utils/http/error.ts | 182 + web/apps/portal/src/utils/http/index.ts | 217 + web/apps/portal/src/utils/http/status.ts | 18 + web/apps/portal/src/utils/index.ts | 34 + web/apps/portal/src/utils/navigation/index.ts | 10 + web/apps/portal/src/utils/navigation/jump.ts | 68 + web/apps/portal/src/utils/navigation/route.ts | 85 + .../portal/src/utils/navigation/worktab.ts | 67 + web/apps/portal/src/utils/router.ts | 61 + web/apps/portal/src/utils/socket/index.ts | 423 + web/apps/portal/src/utils/storage/index.ts | 7 + .../src/utils/storage/storage-config.ts | 125 + .../src/utils/storage/storage-key-manager.ts | 97 + web/apps/portal/src/utils/storage/storage.ts | 250 + web/apps/portal/src/utils/sys/console.ts | 13 + web/apps/portal/src/utils/sys/error-handle.ts | 143 + web/apps/portal/src/utils/sys/index.ts | 6 + web/apps/portal/src/utils/sys/mittBus.ts | 63 + web/apps/portal/src/utils/sys/upgrade.ts | 277 + web/apps/portal/src/utils/table/tableCache.ts | 266 + .../portal/src/utils/table/tableConfig.ts | 55 + web/apps/portal/src/utils/table/tableUtils.ts | 297 + web/apps/portal/src/utils/ui/animation.ts | 80 + web/apps/portal/src/utils/ui/colors.ts | 273 + web/apps/portal/src/utils/ui/emojo.ts | 24 + .../portal/src/utils/ui/iconify-loader.ts | 31 + web/apps/portal/src/utils/ui/index.ts | 11 + web/apps/portal/src/utils/ui/loading.ts | 84 + web/apps/portal/src/utils/ui/tabs.ts | 60 + .../src/views/auth/forget-password/index.vue | 62 + .../portal/src/views/auth/login/index.vue | 385 + .../portal/src/views/auth/login/style.css | 104 + .../portal/src/views/auth/register/index.vue | 240 + .../src/views/dashboard/console/index.vue | 41 + .../console/modules/about-project.vue | 44 + .../dashboard/console/modules/active-user.vue | 47 + .../dashboard/console/modules/card-list.vue | 74 + .../console/modules/dynamic-stats.vue | 79 + .../dashboard/console/modules/new-user.vue | 169 + .../console/modules/sales-overview.vue | 43 + .../dashboard/console/modules/todo-list.vue | 71 + .../portal/src/views/exception/403/index.vue | 16 + .../portal/src/views/exception/404/index.vue | 16 + .../portal/src/views/exception/500/index.vue | 16 + web/apps/portal/src/views/index/index.vue | 29 + web/apps/portal/src/views/index/style.scss | 103 + web/apps/portal/src/views/outside/Iframe.vue | 42 + .../portal/src/views/portal/access/index.vue | 10 + .../portal/src/views/portal/catalog/index.vue | 22 + .../src/views/portal/marketplace/index.vue | 127 + .../portal/src/views/portal/prompts/index.vue | 13 + .../portal/src/views/portal/usage/index.vue | 11 + .../portal/src/views/result/fail/index.vue | 28 + .../portal/src/views/result/success/index.vue | 21 + .../portal/src/views/system/menu/index.vue | 479 + .../views/system/menu/modules/menu-dialog.vue | 384 + .../portal/src/views/system/role/index.vue | 241 + .../system/role/modules/role-edit-dialog.vue | 162 + .../role/modules/role-permission-dialog.vue | 254 + .../views/system/role/modules/role-search.vue | 125 + .../src/views/system/user-center/index.vue | 247 + .../portal/src/views/system/user/index.vue | 261 + .../views/system/user/modules/user-dialog.vue | 143 + .../views/system/user/modules/user-search.vue | 112 + web/apps/portal/tsconfig.json | 28 + web/apps/portal/vite.config.ts | 156 + web/package.json | 25 + web/packages/api-client/package.json | 8 + web/packages/api-client/src/index.ts | 41 + web/pnpm-lock.yaml | 8172 +++++++++++++++++ web/pnpm-workspace.yaml | 4 + 807 files changed, 114727 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 RELEASE.md create mode 100644 api/openapi/gateway.yaml create mode 100644 cmd/gateway-api/main.go create mode 100644 cmd/gateway-bootstrap/main.go create mode 100644 cmd/gateway-legacy-import/main.go create mode 100644 cmd/gateway-legacy-import/main_test.go create mode 100644 cmd/gateway-loadtest/main.go create mode 100644 cmd/gateway-loadtest/main_test.go create mode 100644 cmd/gateway-maintenance/main.go create mode 100644 cmd/gateway-migrator/main.go create mode 100644 cmd/gateway-notification-worker/main.go create mode 100644 cmd/gateway-outbox-worker/main.go create mode 100644 deploy/Dockerfile create mode 100644 deploy/Dockerfile.web create mode 100644 deploy/PRODUCTION.md create mode 100644 deploy/docker-compose.production.yml create mode 100644 deploy/docker-compose.yml create mode 100644 deploy/nginx-web.conf create mode 100644 deploy/production.env.example create mode 100644 docs/adr/0001-baseline-storage.md create mode 100644 docs/cutover-runbook.md create mode 100644 docs/legacy-data-dictionary.md create mode 100644 docs/legacy-import-runbook.md create mode 100644 docs/rewrite-progress.md create mode 100644 docs/route-contract-matrix.md create mode 100644 docs/旗舰版需求规划与完成情况.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/apikey/admin_http.go create mode 100644 internal/apikey/apikey.go create mode 100644 internal/apikey/apikey_test.go create mode 100644 internal/apikey/authenticator.go create mode 100644 internal/apikey/repository.go create mode 100644 internal/apikey/usage.go create mode 100644 internal/audit/admin_http.go create mode 100644 internal/audit/maintenance.go create mode 100644 internal/audit/maintenance_integration_test.go create mode 100644 internal/audit/maintenance_test.go create mode 100644 internal/audit/query.go create mode 100644 internal/audit/recorder.go create mode 100644 internal/contentpolicy/admin_http.go create mode 100644 internal/contentpolicy/engine.go create mode 100644 internal/contentpolicy/engine_test.go create mode 100644 internal/contentpolicy/store.go create mode 100644 internal/factcheck/admin_http.go create mode 100644 internal/factcheck/engine.go create mode 100644 internal/factcheck/engine_test.go create mode 100644 internal/factcheck/service.go create mode 100644 internal/factcheck/service_integration_test.go create mode 100644 internal/gateway/admission.go create mode 100644 internal/gateway/admission_test.go create mode 100644 internal/gateway/audit.go create mode 100644 internal/gateway/content_pricing_integration_test.go create mode 100644 internal/gateway/context.go create mode 100644 internal/gateway/proxy.go create mode 100644 internal/gateway/proxy_test.go create mode 100644 internal/gateway/request_budget.go create mode 100644 internal/gateway/resilience.go create mode 100644 internal/gateway/resilience_test.go create mode 100644 internal/gateway/routing.go create mode 100644 internal/gateway/token_quota.go create mode 100644 internal/gateway/usage.go create mode 100644 internal/gateway/usage_test.go create mode 100644 internal/identity/account.go create mode 100644 internal/identity/departments.go create mode 100644 internal/identity/http.go create mode 100644 internal/identity/management.go create mode 100644 internal/identity/oidc.go create mode 100644 internal/identity/oidc_test.go create mode 100644 internal/identity/password.go create mode 100644 internal/identity/password_test.go create mode 100644 internal/identity/permissions_test.go create mode 100644 internal/identity/ratelimit.go create mode 100644 internal/identity/ratelimit_test.go create mode 100644 internal/identity/repository.go create mode 100644 internal/identity/saml.go create mode 100644 internal/identity/saml_test.go create mode 100644 internal/identity/service.go create mode 100644 internal/identity/session.go create mode 100644 internal/identity/totp.go create mode 100644 internal/identity/totp_test.go create mode 100644 internal/operations/admin_http.go create mode 100644 internal/outbox/admin_http.go create mode 100644 internal/outbox/consumer_integration_test.go create mode 100644 internal/outbox/publisher.go create mode 100644 internal/outbox/store.go create mode 100644 internal/outbox/worker.go create mode 100644 internal/outbox/worker_test.go create mode 100644 internal/platform/apiresponse/response.go create mode 100644 internal/platform/cache/redis.go create mode 100644 internal/platform/config/config.go create mode 100644 internal/platform/config/config_test.go create mode 100644 internal/platform/cryptox/aesgcm.go create mode 100644 internal/platform/cryptox/keyring.go create mode 100644 internal/platform/cryptox/keyring_test.go create mode 100644 internal/platform/database/postgres.go create mode 100644 internal/platform/health/checker.go create mode 100644 internal/platform/health/checker_test.go create mode 100644 internal/platform/httpserver/server.go create mode 100644 internal/platform/id/uuid.go create mode 100644 internal/platform/legacyid/legacyid.go create mode 100644 internal/platform/legacyid/legacyid_test.go create mode 100644 internal/platform/migrate/migrate.go create mode 100644 internal/portal/admin_http.go create mode 100644 internal/portal/conversations.go create mode 100644 internal/portal/credentials.go create mode 100644 internal/portal/http.go create mode 100644 internal/portal/portal_integration_test.go create mode 100644 internal/portal/service.go create mode 100644 internal/pricing/admin_http.go create mode 100644 internal/pricing/service.go create mode 100644 internal/pricing/service_test.go create mode 100644 internal/provider/admin_http.go create mode 100644 internal/provider/controlplane/service.go create mode 100644 internal/provider/controlplane/service_test.go create mode 100644 internal/provider/credentials.go create mode 100644 internal/provider/credentials_test.go create mode 100644 internal/provider/model_routes.go create mode 100644 internal/provider/openai/adapter.go create mode 100644 internal/provider/openai/adapter_test.go create mode 100644 internal/provider/provider.go create mode 100644 internal/provider/repository.go create mode 100644 internal/provider/runtime/resolver.go create mode 100644 internal/provider/runtime/resolver_test.go create mode 100644 internal/provider/url_validator.go create mode 100644 internal/provider/url_validator_test.go create mode 100644 internal/shadow/middleware.go create mode 100644 internal/shadow/middleware_test.go create mode 100644 internal/workbench/admin_http.go create mode 100644 internal/workbench/admin_marketplace.go create mode 100644 internal/workbench/applications.go create mode 100644 internal/workbench/digital_employees.go create mode 100644 internal/workbench/knowledge.go create mode 100644 internal/workbench/marketplace.go create mode 100644 internal/workbench/marketplace_integration_test.go create mode 100644 internal/workbench/mcp.go create mode 100644 internal/workbench/mcp_client.go create mode 100644 internal/workbench/mcp_client_test.go create mode 100644 internal/workbench/notifications.go create mode 100644 internal/workbench/prompts.go create mode 100644 internal/workbench/runtime_http.go create mode 100644 internal/workbench/runtime_marketplace.go create mode 100644 internal/workbench/service.go create mode 100644 internal/workbench/skills.go create mode 100644 internal/workbench/tools.go create mode 100644 internal/workbench/types.go create mode 100644 internal/workbench/workbench_integration_test.go create mode 100644 internal/workbench/workbench_test.go create mode 100644 migrations/000001_foundation.sql create mode 100644 migrations/000002_identity.sql create mode 100644 migrations/000003_api_key_actor.sql create mode 100644 migrations/000004_provider_default.sql create mode 100644 migrations/000005_provider_models.sql create mode 100644 migrations/000006_identity_permissions.sql create mode 100644 migrations/000007_departments.sql create mode 100644 migrations/000008_oidc.sql create mode 100644 migrations/000009_legacy_id_mappings.sql create mode 100644 migrations/000010_saml.sql create mode 100644 migrations/000011_api_key_limits.sql create mode 100644 migrations/000012_api_key_token_quota.sql create mode 100644 migrations/000013_model_routes.sql create mode 100644 migrations/000014_audit_usage.sql create mode 100644 migrations/000015_outbox_delivery.sql create mode 100644 migrations/000016_content_policy_pricing.sql create mode 100644 migrations/000017_ai_assets_orchestration.sql create mode 100644 migrations/000018_legacy_import_staging.sql create mode 100644 migrations/000019_portal_workspace.sql create mode 100644 migrations/000020_portal_conversations.sql create mode 100644 migrations/000021_fact_check.sql create mode 100644 migrations/000022_resource_marketplace.sql create mode 100644 scripts/check_openapi_routes.py create mode 100755 scripts/compare_route_contracts.py create mode 100755 scripts/cutover.sh create mode 100644 scripts/export_legacy_data.py create mode 100644 scripts/export_legacy_dictionary.py create mode 100644 web/README.md create mode 100755 web/apps/admin/.env.development create mode 100755 web/apps/admin/.env.production create mode 100644 web/apps/admin/.gitattributes create mode 100755 web/apps/admin/.gitignore create mode 100644 web/apps/admin/.husky/commit-msg create mode 100644 web/apps/admin/.husky/pre-commit create mode 100644 web/apps/admin/.prettierignore create mode 100644 web/apps/admin/.prettierrc create mode 100644 web/apps/admin/.stylelintignore create mode 100644 web/apps/admin/.stylelintrc.cjs create mode 100755 web/apps/admin/LICENSE create mode 100644 web/apps/admin/commitlint.config.cjs create mode 100644 web/apps/admin/eslint.config.mjs create mode 100755 web/apps/admin/index.html create mode 100755 web/apps/admin/package.json create mode 100755 web/apps/admin/public/favicon.ico create mode 100644 web/apps/admin/scripts/clean-dev.ts create mode 100755 web/apps/admin/src/App.vue create mode 100644 web/apps/admin/src/api/apiKeys.ts create mode 100644 web/apps/admin/src/api/audit.ts create mode 100644 web/apps/admin/src/api/auth.ts create mode 100644 web/apps/admin/src/api/content-policy.ts create mode 100644 web/apps/admin/src/api/governance.ts create mode 100644 web/apps/admin/src/api/identities.ts create mode 100644 web/apps/admin/src/api/marketplace.ts create mode 100644 web/apps/admin/src/api/modelRoutes.ts create mode 100644 web/apps/admin/src/api/outbox.ts create mode 100644 web/apps/admin/src/api/pricing.ts create mode 100644 web/apps/admin/src/api/providers.ts create mode 100644 web/apps/admin/src/api/system-manage.ts create mode 100644 web/apps/admin/src/api/workbench.ts create mode 100644 web/apps/admin/src/assets/images/avatar/avatar.webp create mode 100644 web/apps/admin/src/assets/images/avatar/avatar1.webp create mode 100644 web/apps/admin/src/assets/images/avatar/avatar10.webp create mode 100644 web/apps/admin/src/assets/images/avatar/avatar2.webp create mode 100644 web/apps/admin/src/assets/images/avatar/avatar3.webp create mode 100644 web/apps/admin/src/assets/images/avatar/avatar4.webp create mode 100644 web/apps/admin/src/assets/images/avatar/avatar5.webp create mode 100644 web/apps/admin/src/assets/images/avatar/avatar6.webp create mode 100644 web/apps/admin/src/assets/images/avatar/avatar7.webp create mode 100644 web/apps/admin/src/assets/images/avatar/avatar8.webp create mode 100644 web/apps/admin/src/assets/images/avatar/avatar9.webp create mode 100644 web/apps/admin/src/assets/images/ceremony/hb.png create mode 100644 web/apps/admin/src/assets/images/ceremony/sd.png create mode 100644 web/apps/admin/src/assets/images/ceremony/xc.png create mode 100644 web/apps/admin/src/assets/images/ceremony/yd.png create mode 100644 web/apps/admin/src/assets/images/common/logo.webp create mode 100644 web/apps/admin/src/assets/images/draw/draw1.png create mode 100644 web/apps/admin/src/assets/images/favicon.ico create mode 100644 web/apps/admin/src/assets/images/lock/bg_dark.webp create mode 100644 web/apps/admin/src/assets/images/lock/bg_light.webp create mode 100644 web/apps/admin/src/assets/images/login/lf_icon2.webp create mode 100644 web/apps/admin/src/assets/images/settings/menu_layouts/dual_column.png create mode 100644 web/apps/admin/src/assets/images/settings/menu_layouts/horizontal.png create mode 100644 web/apps/admin/src/assets/images/settings/menu_layouts/mixed.png create mode 100644 web/apps/admin/src/assets/images/settings/menu_layouts/vertical.png create mode 100644 web/apps/admin/src/assets/images/settings/menu_styles/dark.png create mode 100644 web/apps/admin/src/assets/images/settings/menu_styles/design.png create mode 100644 web/apps/admin/src/assets/images/settings/menu_styles/light.png create mode 100644 web/apps/admin/src/assets/images/settings/theme_styles/dark.png create mode 100644 web/apps/admin/src/assets/images/settings/theme_styles/light.png create mode 100644 web/apps/admin/src/assets/images/settings/theme_styles/system.png create mode 100644 web/apps/admin/src/assets/images/svg/403.svg create mode 100644 web/apps/admin/src/assets/images/svg/404.svg create mode 100644 web/apps/admin/src/assets/images/svg/500.svg create mode 100644 web/apps/admin/src/assets/images/svg/login_icon.svg create mode 100644 web/apps/admin/src/assets/images/user/avatar.webp create mode 100644 web/apps/admin/src/assets/images/user/bg.webp create mode 100755 web/apps/admin/src/assets/styles/core/app.scss create mode 100755 web/apps/admin/src/assets/styles/core/dark.scss create mode 100644 web/apps/admin/src/assets/styles/core/el-dark.scss create mode 100644 web/apps/admin/src/assets/styles/core/el-light.scss create mode 100644 web/apps/admin/src/assets/styles/core/el-ui.scss create mode 100644 web/apps/admin/src/assets/styles/core/md.scss create mode 100755 web/apps/admin/src/assets/styles/core/mixin.scss create mode 100755 web/apps/admin/src/assets/styles/core/reset.scss create mode 100644 web/apps/admin/src/assets/styles/core/router-transition.scss create mode 100644 web/apps/admin/src/assets/styles/core/tailwind.css create mode 100644 web/apps/admin/src/assets/styles/core/theme-animation.scss create mode 100755 web/apps/admin/src/assets/styles/core/theme-change.scss create mode 100644 web/apps/admin/src/assets/styles/custom/one-dark-pro.scss create mode 100644 web/apps/admin/src/assets/styles/index.scss create mode 100644 web/apps/admin/src/assets/svg/loading.ts create mode 100644 web/apps/admin/src/components/core/banners/art-basic-banner/index.vue create mode 100644 web/apps/admin/src/components/core/banners/art-card-banner/index.vue create mode 100644 web/apps/admin/src/components/core/base/art-back-to-top/index.vue create mode 100644 web/apps/admin/src/components/core/base/art-logo/index.vue create mode 100644 web/apps/admin/src/components/core/base/art-svg-icon/index.vue create mode 100644 web/apps/admin/src/components/core/cards/art-bar-chart-card/index.vue create mode 100644 web/apps/admin/src/components/core/cards/art-data-list-card/index.vue create mode 100644 web/apps/admin/src/components/core/cards/art-donut-chart-card/index.vue create mode 100644 web/apps/admin/src/components/core/cards/art-image-card/index.vue create mode 100644 web/apps/admin/src/components/core/cards/art-line-chart-card/index.vue create mode 100644 web/apps/admin/src/components/core/cards/art-progress-card/index.vue create mode 100644 web/apps/admin/src/components/core/cards/art-stats-card/index.vue create mode 100644 web/apps/admin/src/components/core/cards/art-timeline-list-card/index.vue create mode 100644 web/apps/admin/src/components/core/charts/art-bar-chart/index.vue create mode 100644 web/apps/admin/src/components/core/charts/art-dual-bar-compare-chart/index.vue create mode 100644 web/apps/admin/src/components/core/charts/art-h-bar-chart/index.vue create mode 100644 web/apps/admin/src/components/core/charts/art-k-line-chart/index.vue create mode 100644 web/apps/admin/src/components/core/charts/art-line-chart/index.vue create mode 100644 web/apps/admin/src/components/core/charts/art-radar-chart/index.vue create mode 100644 web/apps/admin/src/components/core/charts/art-ring-chart/index.vue create mode 100644 web/apps/admin/src/components/core/charts/art-scatter-chart/index.vue create mode 100644 web/apps/admin/src/components/core/forms/art-button-more/index.vue create mode 100644 web/apps/admin/src/components/core/forms/art-button-table/index.vue create mode 100644 web/apps/admin/src/components/core/forms/art-drag-verify/index.vue create mode 100644 web/apps/admin/src/components/core/forms/art-excel-export/index.vue create mode 100644 web/apps/admin/src/components/core/forms/art-excel-import/index.vue create mode 100644 web/apps/admin/src/components/core/forms/art-form/index.vue create mode 100644 web/apps/admin/src/components/core/forms/art-search-bar/index.vue create mode 100644 web/apps/admin/src/components/core/forms/art-wang-editor/index.vue create mode 100644 web/apps/admin/src/components/core/forms/art-wang-editor/style.scss create mode 100755 web/apps/admin/src/components/core/layouts/art-breadcrumb/index.vue create mode 100644 web/apps/admin/src/components/core/layouts/art-chat-window/index.vue create mode 100644 web/apps/admin/src/components/core/layouts/art-fast-enter/index.vue create mode 100644 web/apps/admin/src/components/core/layouts/art-fireworks-effect/index.vue create mode 100644 web/apps/admin/src/components/core/layouts/art-global-component/index.vue create mode 100644 web/apps/admin/src/components/core/layouts/art-global-search/index.vue create mode 100755 web/apps/admin/src/components/core/layouts/art-header-bar/index.vue create mode 100644 web/apps/admin/src/components/core/layouts/art-header-bar/widget/ArtUserMenu.vue create mode 100644 web/apps/admin/src/components/core/layouts/art-menus/art-horizontal-menu/index.vue create mode 100644 web/apps/admin/src/components/core/layouts/art-menus/art-horizontal-menu/widget/HorizontalSubmenu.vue create mode 100644 web/apps/admin/src/components/core/layouts/art-menus/art-mixed-menu/index.vue create mode 100755 web/apps/admin/src/components/core/layouts/art-menus/art-sidebar-menu/index.vue create mode 100755 web/apps/admin/src/components/core/layouts/art-menus/art-sidebar-menu/style.scss create mode 100644 web/apps/admin/src/components/core/layouts/art-menus/art-sidebar-menu/theme.scss create mode 100755 web/apps/admin/src/components/core/layouts/art-menus/art-sidebar-menu/widget/SidebarSubmenu.vue create mode 100755 web/apps/admin/src/components/core/layouts/art-notification/index.vue create mode 100644 web/apps/admin/src/components/core/layouts/art-page-content/index.vue create mode 100644 web/apps/admin/src/components/core/layouts/art-screen-lock/index.vue create mode 100644 web/apps/admin/src/components/core/layouts/art-settings-panel/composables/useSettingsConfig.ts create mode 100644 web/apps/admin/src/components/core/layouts/art-settings-panel/composables/useSettingsHandlers.ts create mode 100644 web/apps/admin/src/components/core/layouts/art-settings-panel/composables/useSettingsPanel.ts create mode 100644 web/apps/admin/src/components/core/layouts/art-settings-panel/composables/useSettingsState.ts create mode 100755 web/apps/admin/src/components/core/layouts/art-settings-panel/index.vue create mode 100755 web/apps/admin/src/components/core/layouts/art-settings-panel/style.scss create mode 100644 web/apps/admin/src/components/core/layouts/art-settings-panel/widget/BasicSettings.vue create mode 100644 web/apps/admin/src/components/core/layouts/art-settings-panel/widget/BoxStyleSettings.vue create mode 100644 web/apps/admin/src/components/core/layouts/art-settings-panel/widget/ColorSettings.vue create mode 100644 web/apps/admin/src/components/core/layouts/art-settings-panel/widget/ContainerSettings.vue create mode 100644 web/apps/admin/src/components/core/layouts/art-settings-panel/widget/MenuLayoutSettings.vue create mode 100644 web/apps/admin/src/components/core/layouts/art-settings-panel/widget/MenuStyleSettings.vue create mode 100644 web/apps/admin/src/components/core/layouts/art-settings-panel/widget/SectionTitle.vue create mode 100644 web/apps/admin/src/components/core/layouts/art-settings-panel/widget/SettingActions.vue create mode 100644 web/apps/admin/src/components/core/layouts/art-settings-panel/widget/SettingDrawer.vue create mode 100644 web/apps/admin/src/components/core/layouts/art-settings-panel/widget/SettingHeader.vue create mode 100644 web/apps/admin/src/components/core/layouts/art-settings-panel/widget/SettingItem.vue create mode 100644 web/apps/admin/src/components/core/layouts/art-settings-panel/widget/ThemeSettings.vue create mode 100755 web/apps/admin/src/components/core/layouts/art-work-tab/index.vue create mode 100644 web/apps/admin/src/components/core/media/art-cutter-img/index.vue create mode 100644 web/apps/admin/src/components/core/media/art-video-player/index.vue create mode 100644 web/apps/admin/src/components/core/others/art-menu-right/index.vue create mode 100644 web/apps/admin/src/components/core/others/art-watermark/index.vue create mode 100644 web/apps/admin/src/components/core/tables/art-table-header/index.vue create mode 100644 web/apps/admin/src/components/core/tables/art-table/index.vue create mode 100644 web/apps/admin/src/components/core/tables/art-table/style.scss create mode 100644 web/apps/admin/src/components/core/text-effect/art-count-to/index.vue create mode 100644 web/apps/admin/src/components/core/text-effect/art-festival-text-scroll/index.vue create mode 100644 web/apps/admin/src/components/core/text-effect/art-text-scroll/index.vue create mode 100644 web/apps/admin/src/components/core/theme/theme-svg/index.vue create mode 100644 web/apps/admin/src/components/core/views/exception/ArtException.vue create mode 100644 web/apps/admin/src/components/core/views/login/AuthTopBar.vue create mode 100644 web/apps/admin/src/components/core/views/login/LoginLeftView.vue create mode 100644 web/apps/admin/src/components/core/views/result/ArtResultPage.vue create mode 100644 web/apps/admin/src/components/core/widget/art-icon-button/index.vue create mode 100644 web/apps/admin/src/config/assets/images.ts create mode 100644 web/apps/admin/src/config/fastEnter.ts create mode 100644 web/apps/admin/src/config/index.ts create mode 100644 web/apps/admin/src/config/modules/component.ts create mode 100644 web/apps/admin/src/config/modules/fastEnter.ts create mode 100644 web/apps/admin/src/config/modules/festival.ts create mode 100644 web/apps/admin/src/config/modules/headerBar.ts create mode 100644 web/apps/admin/src/config/setting.ts create mode 100644 web/apps/admin/src/directives/business/highlight.ts create mode 100644 web/apps/admin/src/directives/business/ripple.ts create mode 100755 web/apps/admin/src/directives/core/auth.ts create mode 100644 web/apps/admin/src/directives/core/roles.ts create mode 100755 web/apps/admin/src/directives/index.ts create mode 100755 web/apps/admin/src/enums/appEnum.ts create mode 100644 web/apps/admin/src/enums/formEnum.ts create mode 100755 web/apps/admin/src/env.d.ts create mode 100644 web/apps/admin/src/hooks/core/useAppMode.ts create mode 100644 web/apps/admin/src/hooks/core/useAuth.ts create mode 100644 web/apps/admin/src/hooks/core/useCeremony.ts create mode 100644 web/apps/admin/src/hooks/core/useChart.ts create mode 100644 web/apps/admin/src/hooks/core/useCommon.ts create mode 100644 web/apps/admin/src/hooks/core/useFastEnter.ts create mode 100644 web/apps/admin/src/hooks/core/useHeaderBar.ts create mode 100644 web/apps/admin/src/hooks/core/useLayoutHeight.ts create mode 100644 web/apps/admin/src/hooks/core/useTable.ts create mode 100644 web/apps/admin/src/hooks/core/useTableColumns.ts create mode 100644 web/apps/admin/src/hooks/core/useTableHeight.ts create mode 100644 web/apps/admin/src/hooks/core/useTheme.ts create mode 100644 web/apps/admin/src/hooks/index.ts create mode 100644 web/apps/admin/src/locales/index.ts create mode 100644 web/apps/admin/src/locales/langs/en.json create mode 100644 web/apps/admin/src/locales/langs/zh.json create mode 100755 web/apps/admin/src/main.ts create mode 100755 web/apps/admin/src/mock/temp/formData.ts create mode 100644 web/apps/admin/src/mock/upgrade/changeLog.ts create mode 100644 web/apps/admin/src/plugins/echarts.ts create mode 100644 web/apps/admin/src/plugins/index.ts create mode 100644 web/apps/admin/src/router/core/ComponentLoader.ts create mode 100644 web/apps/admin/src/router/core/IframeRouteManager.ts create mode 100644 web/apps/admin/src/router/core/MenuProcessor.ts create mode 100644 web/apps/admin/src/router/core/RoutePermissionValidator.ts create mode 100644 web/apps/admin/src/router/core/RouteRegistry.ts create mode 100644 web/apps/admin/src/router/core/RouteTransformer.ts create mode 100644 web/apps/admin/src/router/core/RouteValidator.ts create mode 100644 web/apps/admin/src/router/core/index.ts create mode 100644 web/apps/admin/src/router/guards/afterEach.ts create mode 100644 web/apps/admin/src/router/guards/beforeEach.ts create mode 100755 web/apps/admin/src/router/index.ts create mode 100644 web/apps/admin/src/router/modules/dashboard.ts create mode 100644 web/apps/admin/src/router/modules/exception.ts create mode 100644 web/apps/admin/src/router/modules/gateway.ts create mode 100644 web/apps/admin/src/router/modules/index.ts create mode 100644 web/apps/admin/src/router/modules/result.ts create mode 100644 web/apps/admin/src/router/modules/system.ts create mode 100644 web/apps/admin/src/router/routes/asyncRoutes.ts create mode 100644 web/apps/admin/src/router/routes/staticRoutes.ts create mode 100644 web/apps/admin/src/router/routesAlias.ts create mode 100755 web/apps/admin/src/store/index.ts create mode 100755 web/apps/admin/src/store/modules/menu.ts create mode 100755 web/apps/admin/src/store/modules/setting.ts create mode 100644 web/apps/admin/src/store/modules/table.ts create mode 100755 web/apps/admin/src/store/modules/user.ts create mode 100755 web/apps/admin/src/store/modules/worktab.ts create mode 100644 web/apps/admin/src/types/api/api.d.ts create mode 100644 web/apps/admin/src/types/common/index.ts create mode 100644 web/apps/admin/src/types/common/response.ts create mode 100644 web/apps/admin/src/types/component/chart.ts create mode 100644 web/apps/admin/src/types/component/index.ts create mode 100644 web/apps/admin/src/types/config/index.ts create mode 100644 web/apps/admin/src/types/directive/directive.d.ts create mode 100644 web/apps/admin/src/types/index.ts create mode 100644 web/apps/admin/src/types/router/index.ts create mode 100644 web/apps/admin/src/types/store/index.ts create mode 100644 web/apps/admin/src/utils/constants/index.ts create mode 100644 web/apps/admin/src/utils/constants/links.ts create mode 100644 web/apps/admin/src/utils/form/index.ts create mode 100644 web/apps/admin/src/utils/form/responsive.ts create mode 100644 web/apps/admin/src/utils/form/validator.ts create mode 100644 web/apps/admin/src/utils/http/error.ts create mode 100755 web/apps/admin/src/utils/http/index.ts create mode 100755 web/apps/admin/src/utils/http/status.ts create mode 100644 web/apps/admin/src/utils/index.ts create mode 100644 web/apps/admin/src/utils/navigation/index.ts create mode 100644 web/apps/admin/src/utils/navigation/jump.ts create mode 100644 web/apps/admin/src/utils/navigation/route.ts create mode 100644 web/apps/admin/src/utils/navigation/worktab.ts create mode 100644 web/apps/admin/src/utils/router.ts create mode 100644 web/apps/admin/src/utils/socket/index.ts create mode 100644 web/apps/admin/src/utils/storage/index.ts create mode 100644 web/apps/admin/src/utils/storage/storage-config.ts create mode 100644 web/apps/admin/src/utils/storage/storage-key-manager.ts create mode 100644 web/apps/admin/src/utils/storage/storage.ts create mode 100644 web/apps/admin/src/utils/sys/console.ts create mode 100644 web/apps/admin/src/utils/sys/error-handle.ts create mode 100644 web/apps/admin/src/utils/sys/index.ts create mode 100644 web/apps/admin/src/utils/sys/mittBus.ts create mode 100644 web/apps/admin/src/utils/sys/upgrade.ts create mode 100644 web/apps/admin/src/utils/table/tableCache.ts create mode 100644 web/apps/admin/src/utils/table/tableConfig.ts create mode 100644 web/apps/admin/src/utils/table/tableUtils.ts create mode 100644 web/apps/admin/src/utils/ui/animation.ts create mode 100755 web/apps/admin/src/utils/ui/colors.ts create mode 100644 web/apps/admin/src/utils/ui/emojo.ts create mode 100644 web/apps/admin/src/utils/ui/iconify-loader.ts create mode 100644 web/apps/admin/src/utils/ui/index.ts create mode 100644 web/apps/admin/src/utils/ui/loading.ts create mode 100644 web/apps/admin/src/utils/ui/tabs.ts create mode 100644 web/apps/admin/src/views/auth/forget-password/index.vue create mode 100755 web/apps/admin/src/views/auth/login/index.vue create mode 100644 web/apps/admin/src/views/auth/login/style.css create mode 100644 web/apps/admin/src/views/auth/register/index.vue create mode 100755 web/apps/admin/src/views/dashboard/console/index.vue create mode 100644 web/apps/admin/src/views/dashboard/console/modules/about-project.vue create mode 100755 web/apps/admin/src/views/dashboard/console/modules/active-user.vue create mode 100755 web/apps/admin/src/views/dashboard/console/modules/card-list.vue create mode 100755 web/apps/admin/src/views/dashboard/console/modules/dynamic-stats.vue create mode 100755 web/apps/admin/src/views/dashboard/console/modules/new-user.vue create mode 100755 web/apps/admin/src/views/dashboard/console/modules/sales-overview.vue create mode 100755 web/apps/admin/src/views/dashboard/console/modules/todo-list.vue create mode 100755 web/apps/admin/src/views/exception/403/index.vue create mode 100755 web/apps/admin/src/views/exception/404/index.vue create mode 100755 web/apps/admin/src/views/exception/500/index.vue create mode 100644 web/apps/admin/src/views/gateway/api-keys/index.vue create mode 100644 web/apps/admin/src/views/gateway/applications/index.vue create mode 100644 web/apps/admin/src/views/gateway/audit-usage/index.vue create mode 100644 web/apps/admin/src/views/gateway/content-policies/index.vue create mode 100644 web/apps/admin/src/views/gateway/digital-employees/index.vue create mode 100644 web/apps/admin/src/views/gateway/governance/index.vue create mode 100644 web/apps/admin/src/views/gateway/knowledge/index.vue create mode 100644 web/apps/admin/src/views/gateway/marketplace/index.vue create mode 100644 web/apps/admin/src/views/gateway/mcp-servers/index.vue create mode 100644 web/apps/admin/src/views/gateway/model-prices/index.vue create mode 100644 web/apps/admin/src/views/gateway/model-routes/index.vue create mode 100644 web/apps/admin/src/views/gateway/notifications/index.vue create mode 100644 web/apps/admin/src/views/gateway/outbox/index.vue create mode 100644 web/apps/admin/src/views/gateway/prompts/index.vue create mode 100644 web/apps/admin/src/views/gateway/providers/index.vue create mode 100644 web/apps/admin/src/views/gateway/skills/index.vue create mode 100644 web/apps/admin/src/views/gateway/tools/index.vue create mode 100755 web/apps/admin/src/views/index/index.vue create mode 100644 web/apps/admin/src/views/index/style.scss create mode 100644 web/apps/admin/src/views/outside/Iframe.vue create mode 100755 web/apps/admin/src/views/result/fail/index.vue create mode 100755 web/apps/admin/src/views/result/success/index.vue create mode 100644 web/apps/admin/src/views/system/menu/index.vue create mode 100644 web/apps/admin/src/views/system/menu/modules/menu-dialog.vue create mode 100755 web/apps/admin/src/views/system/role/index.vue create mode 100644 web/apps/admin/src/views/system/role/modules/role-edit-dialog.vue create mode 100644 web/apps/admin/src/views/system/role/modules/role-permission-dialog.vue create mode 100644 web/apps/admin/src/views/system/role/modules/role-search.vue create mode 100755 web/apps/admin/src/views/system/user-center/index.vue create mode 100644 web/apps/admin/src/views/system/user/index.vue create mode 100644 web/apps/admin/src/views/system/user/modules/user-dialog.vue create mode 100644 web/apps/admin/src/views/system/user/modules/user-search.vue create mode 100755 web/apps/admin/tsconfig.json create mode 100755 web/apps/admin/vite.config.ts create mode 100755 web/apps/portal/.env.development create mode 100755 web/apps/portal/.env.production create mode 100644 web/apps/portal/.gitattributes create mode 100755 web/apps/portal/.gitignore create mode 100644 web/apps/portal/.husky/commit-msg create mode 100644 web/apps/portal/.husky/pre-commit create mode 100644 web/apps/portal/.prettierignore create mode 100644 web/apps/portal/.prettierrc create mode 100644 web/apps/portal/.stylelintignore create mode 100644 web/apps/portal/.stylelintrc.cjs create mode 100755 web/apps/portal/LICENSE create mode 100644 web/apps/portal/commitlint.config.cjs create mode 100644 web/apps/portal/eslint.config.mjs create mode 100755 web/apps/portal/index.html create mode 100755 web/apps/portal/package.json create mode 100755 web/apps/portal/public/favicon.ico create mode 100644 web/apps/portal/scripts/clean-dev.ts create mode 100755 web/apps/portal/src/App.vue create mode 100644 web/apps/portal/src/api/auth.ts create mode 100644 web/apps/portal/src/api/portal.ts create mode 100644 web/apps/portal/src/api/system-manage.ts create mode 100644 web/apps/portal/src/assets/images/avatar/avatar.webp create mode 100644 web/apps/portal/src/assets/images/avatar/avatar1.webp create mode 100644 web/apps/portal/src/assets/images/avatar/avatar10.webp create mode 100644 web/apps/portal/src/assets/images/avatar/avatar2.webp create mode 100644 web/apps/portal/src/assets/images/avatar/avatar3.webp create mode 100644 web/apps/portal/src/assets/images/avatar/avatar4.webp create mode 100644 web/apps/portal/src/assets/images/avatar/avatar5.webp create mode 100644 web/apps/portal/src/assets/images/avatar/avatar6.webp create mode 100644 web/apps/portal/src/assets/images/avatar/avatar7.webp create mode 100644 web/apps/portal/src/assets/images/avatar/avatar8.webp create mode 100644 web/apps/portal/src/assets/images/avatar/avatar9.webp create mode 100644 web/apps/portal/src/assets/images/ceremony/hb.png create mode 100644 web/apps/portal/src/assets/images/ceremony/sd.png create mode 100644 web/apps/portal/src/assets/images/ceremony/xc.png create mode 100644 web/apps/portal/src/assets/images/ceremony/yd.png create mode 100644 web/apps/portal/src/assets/images/common/logo.webp create mode 100644 web/apps/portal/src/assets/images/draw/draw1.png create mode 100644 web/apps/portal/src/assets/images/favicon.ico create mode 100644 web/apps/portal/src/assets/images/lock/bg_dark.webp create mode 100644 web/apps/portal/src/assets/images/lock/bg_light.webp create mode 100644 web/apps/portal/src/assets/images/login/lf_icon2.webp create mode 100644 web/apps/portal/src/assets/images/settings/menu_layouts/dual_column.png create mode 100644 web/apps/portal/src/assets/images/settings/menu_layouts/horizontal.png create mode 100644 web/apps/portal/src/assets/images/settings/menu_layouts/mixed.png create mode 100644 web/apps/portal/src/assets/images/settings/menu_layouts/vertical.png create mode 100644 web/apps/portal/src/assets/images/settings/menu_styles/dark.png create mode 100644 web/apps/portal/src/assets/images/settings/menu_styles/design.png create mode 100644 web/apps/portal/src/assets/images/settings/menu_styles/light.png create mode 100644 web/apps/portal/src/assets/images/settings/theme_styles/dark.png create mode 100644 web/apps/portal/src/assets/images/settings/theme_styles/light.png create mode 100644 web/apps/portal/src/assets/images/settings/theme_styles/system.png create mode 100644 web/apps/portal/src/assets/images/svg/403.svg create mode 100644 web/apps/portal/src/assets/images/svg/404.svg create mode 100644 web/apps/portal/src/assets/images/svg/500.svg create mode 100644 web/apps/portal/src/assets/images/svg/login_icon.svg create mode 100644 web/apps/portal/src/assets/images/user/avatar.webp create mode 100644 web/apps/portal/src/assets/images/user/bg.webp create mode 100755 web/apps/portal/src/assets/styles/core/app.scss create mode 100755 web/apps/portal/src/assets/styles/core/dark.scss create mode 100644 web/apps/portal/src/assets/styles/core/el-dark.scss create mode 100644 web/apps/portal/src/assets/styles/core/el-light.scss create mode 100644 web/apps/portal/src/assets/styles/core/el-ui.scss create mode 100644 web/apps/portal/src/assets/styles/core/md.scss create mode 100755 web/apps/portal/src/assets/styles/core/mixin.scss create mode 100755 web/apps/portal/src/assets/styles/core/reset.scss create mode 100644 web/apps/portal/src/assets/styles/core/router-transition.scss create mode 100644 web/apps/portal/src/assets/styles/core/tailwind.css create mode 100644 web/apps/portal/src/assets/styles/core/theme-animation.scss create mode 100755 web/apps/portal/src/assets/styles/core/theme-change.scss create mode 100644 web/apps/portal/src/assets/styles/custom/one-dark-pro.scss create mode 100644 web/apps/portal/src/assets/styles/index.scss create mode 100644 web/apps/portal/src/assets/svg/loading.ts create mode 100644 web/apps/portal/src/components/core/banners/art-basic-banner/index.vue create mode 100644 web/apps/portal/src/components/core/banners/art-card-banner/index.vue create mode 100644 web/apps/portal/src/components/core/base/art-back-to-top/index.vue create mode 100644 web/apps/portal/src/components/core/base/art-logo/index.vue create mode 100644 web/apps/portal/src/components/core/base/art-svg-icon/index.vue create mode 100644 web/apps/portal/src/components/core/cards/art-bar-chart-card/index.vue create mode 100644 web/apps/portal/src/components/core/cards/art-data-list-card/index.vue create mode 100644 web/apps/portal/src/components/core/cards/art-donut-chart-card/index.vue create mode 100644 web/apps/portal/src/components/core/cards/art-image-card/index.vue create mode 100644 web/apps/portal/src/components/core/cards/art-line-chart-card/index.vue create mode 100644 web/apps/portal/src/components/core/cards/art-progress-card/index.vue create mode 100644 web/apps/portal/src/components/core/cards/art-stats-card/index.vue create mode 100644 web/apps/portal/src/components/core/cards/art-timeline-list-card/index.vue create mode 100644 web/apps/portal/src/components/core/charts/art-bar-chart/index.vue create mode 100644 web/apps/portal/src/components/core/charts/art-dual-bar-compare-chart/index.vue create mode 100644 web/apps/portal/src/components/core/charts/art-h-bar-chart/index.vue create mode 100644 web/apps/portal/src/components/core/charts/art-k-line-chart/index.vue create mode 100644 web/apps/portal/src/components/core/charts/art-line-chart/index.vue create mode 100644 web/apps/portal/src/components/core/charts/art-radar-chart/index.vue create mode 100644 web/apps/portal/src/components/core/charts/art-ring-chart/index.vue create mode 100644 web/apps/portal/src/components/core/charts/art-scatter-chart/index.vue create mode 100644 web/apps/portal/src/components/core/forms/art-button-more/index.vue create mode 100644 web/apps/portal/src/components/core/forms/art-button-table/index.vue create mode 100644 web/apps/portal/src/components/core/forms/art-drag-verify/index.vue create mode 100644 web/apps/portal/src/components/core/forms/art-excel-export/index.vue create mode 100644 web/apps/portal/src/components/core/forms/art-excel-import/index.vue create mode 100644 web/apps/portal/src/components/core/forms/art-form/index.vue create mode 100644 web/apps/portal/src/components/core/forms/art-search-bar/index.vue create mode 100644 web/apps/portal/src/components/core/forms/art-wang-editor/index.vue create mode 100644 web/apps/portal/src/components/core/forms/art-wang-editor/style.scss create mode 100755 web/apps/portal/src/components/core/layouts/art-breadcrumb/index.vue create mode 100644 web/apps/portal/src/components/core/layouts/art-chat-window/index.vue create mode 100644 web/apps/portal/src/components/core/layouts/art-fast-enter/index.vue create mode 100644 web/apps/portal/src/components/core/layouts/art-fireworks-effect/index.vue create mode 100644 web/apps/portal/src/components/core/layouts/art-global-component/index.vue create mode 100644 web/apps/portal/src/components/core/layouts/art-global-search/index.vue create mode 100755 web/apps/portal/src/components/core/layouts/art-header-bar/index.vue create mode 100644 web/apps/portal/src/components/core/layouts/art-header-bar/widget/ArtUserMenu.vue create mode 100644 web/apps/portal/src/components/core/layouts/art-menus/art-horizontal-menu/index.vue create mode 100644 web/apps/portal/src/components/core/layouts/art-menus/art-horizontal-menu/widget/HorizontalSubmenu.vue create mode 100644 web/apps/portal/src/components/core/layouts/art-menus/art-mixed-menu/index.vue create mode 100755 web/apps/portal/src/components/core/layouts/art-menus/art-sidebar-menu/index.vue create mode 100755 web/apps/portal/src/components/core/layouts/art-menus/art-sidebar-menu/style.scss create mode 100644 web/apps/portal/src/components/core/layouts/art-menus/art-sidebar-menu/theme.scss create mode 100755 web/apps/portal/src/components/core/layouts/art-menus/art-sidebar-menu/widget/SidebarSubmenu.vue create mode 100755 web/apps/portal/src/components/core/layouts/art-notification/index.vue create mode 100644 web/apps/portal/src/components/core/layouts/art-page-content/index.vue create mode 100644 web/apps/portal/src/components/core/layouts/art-screen-lock/index.vue create mode 100644 web/apps/portal/src/components/core/layouts/art-settings-panel/composables/useSettingsConfig.ts create mode 100644 web/apps/portal/src/components/core/layouts/art-settings-panel/composables/useSettingsHandlers.ts create mode 100644 web/apps/portal/src/components/core/layouts/art-settings-panel/composables/useSettingsPanel.ts create mode 100644 web/apps/portal/src/components/core/layouts/art-settings-panel/composables/useSettingsState.ts create mode 100755 web/apps/portal/src/components/core/layouts/art-settings-panel/index.vue create mode 100755 web/apps/portal/src/components/core/layouts/art-settings-panel/style.scss create mode 100644 web/apps/portal/src/components/core/layouts/art-settings-panel/widget/BasicSettings.vue create mode 100644 web/apps/portal/src/components/core/layouts/art-settings-panel/widget/BoxStyleSettings.vue create mode 100644 web/apps/portal/src/components/core/layouts/art-settings-panel/widget/ColorSettings.vue create mode 100644 web/apps/portal/src/components/core/layouts/art-settings-panel/widget/ContainerSettings.vue create mode 100644 web/apps/portal/src/components/core/layouts/art-settings-panel/widget/MenuLayoutSettings.vue create mode 100644 web/apps/portal/src/components/core/layouts/art-settings-panel/widget/MenuStyleSettings.vue create mode 100644 web/apps/portal/src/components/core/layouts/art-settings-panel/widget/SectionTitle.vue create mode 100644 web/apps/portal/src/components/core/layouts/art-settings-panel/widget/SettingActions.vue create mode 100644 web/apps/portal/src/components/core/layouts/art-settings-panel/widget/SettingDrawer.vue create mode 100644 web/apps/portal/src/components/core/layouts/art-settings-panel/widget/SettingHeader.vue create mode 100644 web/apps/portal/src/components/core/layouts/art-settings-panel/widget/SettingItem.vue create mode 100644 web/apps/portal/src/components/core/layouts/art-settings-panel/widget/ThemeSettings.vue create mode 100755 web/apps/portal/src/components/core/layouts/art-work-tab/index.vue create mode 100644 web/apps/portal/src/components/core/media/art-cutter-img/index.vue create mode 100644 web/apps/portal/src/components/core/media/art-video-player/index.vue create mode 100644 web/apps/portal/src/components/core/others/art-menu-right/index.vue create mode 100644 web/apps/portal/src/components/core/others/art-watermark/index.vue create mode 100644 web/apps/portal/src/components/core/tables/art-table-header/index.vue create mode 100644 web/apps/portal/src/components/core/tables/art-table/index.vue create mode 100644 web/apps/portal/src/components/core/tables/art-table/style.scss create mode 100644 web/apps/portal/src/components/core/text-effect/art-count-to/index.vue create mode 100644 web/apps/portal/src/components/core/text-effect/art-festival-text-scroll/index.vue create mode 100644 web/apps/portal/src/components/core/text-effect/art-text-scroll/index.vue create mode 100644 web/apps/portal/src/components/core/theme/theme-svg/index.vue create mode 100644 web/apps/portal/src/components/core/views/exception/ArtException.vue create mode 100644 web/apps/portal/src/components/core/views/login/AuthTopBar.vue create mode 100644 web/apps/portal/src/components/core/views/login/LoginLeftView.vue create mode 100644 web/apps/portal/src/components/core/views/result/ArtResultPage.vue create mode 100644 web/apps/portal/src/components/core/widget/art-icon-button/index.vue create mode 100644 web/apps/portal/src/config/assets/images.ts create mode 100644 web/apps/portal/src/config/fastEnter.ts create mode 100644 web/apps/portal/src/config/index.ts create mode 100644 web/apps/portal/src/config/modules/component.ts create mode 100644 web/apps/portal/src/config/modules/fastEnter.ts create mode 100644 web/apps/portal/src/config/modules/festival.ts create mode 100644 web/apps/portal/src/config/modules/headerBar.ts create mode 100644 web/apps/portal/src/config/setting.ts create mode 100644 web/apps/portal/src/directives/business/highlight.ts create mode 100644 web/apps/portal/src/directives/business/ripple.ts create mode 100755 web/apps/portal/src/directives/core/auth.ts create mode 100644 web/apps/portal/src/directives/core/roles.ts create mode 100755 web/apps/portal/src/directives/index.ts create mode 100755 web/apps/portal/src/enums/appEnum.ts create mode 100644 web/apps/portal/src/enums/formEnum.ts create mode 100755 web/apps/portal/src/env.d.ts create mode 100644 web/apps/portal/src/hooks/core/useAppMode.ts create mode 100644 web/apps/portal/src/hooks/core/useAuth.ts create mode 100644 web/apps/portal/src/hooks/core/useCeremony.ts create mode 100644 web/apps/portal/src/hooks/core/useChart.ts create mode 100644 web/apps/portal/src/hooks/core/useCommon.ts create mode 100644 web/apps/portal/src/hooks/core/useFastEnter.ts create mode 100644 web/apps/portal/src/hooks/core/useHeaderBar.ts create mode 100644 web/apps/portal/src/hooks/core/useLayoutHeight.ts create mode 100644 web/apps/portal/src/hooks/core/useTable.ts create mode 100644 web/apps/portal/src/hooks/core/useTableColumns.ts create mode 100644 web/apps/portal/src/hooks/core/useTableHeight.ts create mode 100644 web/apps/portal/src/hooks/core/useTheme.ts create mode 100644 web/apps/portal/src/hooks/index.ts create mode 100644 web/apps/portal/src/locales/index.ts create mode 100644 web/apps/portal/src/locales/langs/en.json create mode 100644 web/apps/portal/src/locales/langs/zh.json create mode 100755 web/apps/portal/src/main.ts create mode 100755 web/apps/portal/src/mock/temp/formData.ts create mode 100644 web/apps/portal/src/mock/upgrade/changeLog.ts create mode 100644 web/apps/portal/src/plugins/echarts.ts create mode 100644 web/apps/portal/src/plugins/index.ts create mode 100644 web/apps/portal/src/router/core/ComponentLoader.ts create mode 100644 web/apps/portal/src/router/core/IframeRouteManager.ts create mode 100644 web/apps/portal/src/router/core/MenuProcessor.ts create mode 100644 web/apps/portal/src/router/core/RoutePermissionValidator.ts create mode 100644 web/apps/portal/src/router/core/RouteRegistry.ts create mode 100644 web/apps/portal/src/router/core/RouteTransformer.ts create mode 100644 web/apps/portal/src/router/core/RouteValidator.ts create mode 100644 web/apps/portal/src/router/core/index.ts create mode 100644 web/apps/portal/src/router/guards/afterEach.ts create mode 100644 web/apps/portal/src/router/guards/beforeEach.ts create mode 100755 web/apps/portal/src/router/index.ts create mode 100644 web/apps/portal/src/router/modules/dashboard.ts create mode 100644 web/apps/portal/src/router/modules/exception.ts create mode 100644 web/apps/portal/src/router/modules/index.ts create mode 100644 web/apps/portal/src/router/modules/result.ts create mode 100644 web/apps/portal/src/router/modules/system.ts create mode 100644 web/apps/portal/src/router/routes/asyncRoutes.ts create mode 100644 web/apps/portal/src/router/routes/staticRoutes.ts create mode 100644 web/apps/portal/src/router/routesAlias.ts create mode 100755 web/apps/portal/src/store/index.ts create mode 100755 web/apps/portal/src/store/modules/menu.ts create mode 100755 web/apps/portal/src/store/modules/setting.ts create mode 100644 web/apps/portal/src/store/modules/table.ts create mode 100755 web/apps/portal/src/store/modules/user.ts create mode 100755 web/apps/portal/src/store/modules/worktab.ts create mode 100644 web/apps/portal/src/types/api/api.d.ts create mode 100644 web/apps/portal/src/types/common/index.ts create mode 100644 web/apps/portal/src/types/common/response.ts create mode 100644 web/apps/portal/src/types/component/chart.ts create mode 100644 web/apps/portal/src/types/component/index.ts create mode 100644 web/apps/portal/src/types/config/index.ts create mode 100644 web/apps/portal/src/types/directive/directive.d.ts create mode 100644 web/apps/portal/src/types/index.ts create mode 100644 web/apps/portal/src/types/router/index.ts create mode 100644 web/apps/portal/src/types/store/index.ts create mode 100644 web/apps/portal/src/utils/constants/index.ts create mode 100644 web/apps/portal/src/utils/constants/links.ts create mode 100644 web/apps/portal/src/utils/form/index.ts create mode 100644 web/apps/portal/src/utils/form/responsive.ts create mode 100644 web/apps/portal/src/utils/form/validator.ts create mode 100644 web/apps/portal/src/utils/http/error.ts create mode 100755 web/apps/portal/src/utils/http/index.ts create mode 100755 web/apps/portal/src/utils/http/status.ts create mode 100644 web/apps/portal/src/utils/index.ts create mode 100644 web/apps/portal/src/utils/navigation/index.ts create mode 100644 web/apps/portal/src/utils/navigation/jump.ts create mode 100644 web/apps/portal/src/utils/navigation/route.ts create mode 100644 web/apps/portal/src/utils/navigation/worktab.ts create mode 100644 web/apps/portal/src/utils/router.ts create mode 100644 web/apps/portal/src/utils/socket/index.ts create mode 100644 web/apps/portal/src/utils/storage/index.ts create mode 100644 web/apps/portal/src/utils/storage/storage-config.ts create mode 100644 web/apps/portal/src/utils/storage/storage-key-manager.ts create mode 100644 web/apps/portal/src/utils/storage/storage.ts create mode 100644 web/apps/portal/src/utils/sys/console.ts create mode 100644 web/apps/portal/src/utils/sys/error-handle.ts create mode 100644 web/apps/portal/src/utils/sys/index.ts create mode 100644 web/apps/portal/src/utils/sys/mittBus.ts create mode 100644 web/apps/portal/src/utils/sys/upgrade.ts create mode 100644 web/apps/portal/src/utils/table/tableCache.ts create mode 100644 web/apps/portal/src/utils/table/tableConfig.ts create mode 100644 web/apps/portal/src/utils/table/tableUtils.ts create mode 100644 web/apps/portal/src/utils/ui/animation.ts create mode 100755 web/apps/portal/src/utils/ui/colors.ts create mode 100644 web/apps/portal/src/utils/ui/emojo.ts create mode 100644 web/apps/portal/src/utils/ui/iconify-loader.ts create mode 100644 web/apps/portal/src/utils/ui/index.ts create mode 100644 web/apps/portal/src/utils/ui/loading.ts create mode 100644 web/apps/portal/src/utils/ui/tabs.ts create mode 100644 web/apps/portal/src/views/auth/forget-password/index.vue create mode 100755 web/apps/portal/src/views/auth/login/index.vue create mode 100644 web/apps/portal/src/views/auth/login/style.css create mode 100644 web/apps/portal/src/views/auth/register/index.vue create mode 100755 web/apps/portal/src/views/dashboard/console/index.vue create mode 100644 web/apps/portal/src/views/dashboard/console/modules/about-project.vue create mode 100755 web/apps/portal/src/views/dashboard/console/modules/active-user.vue create mode 100755 web/apps/portal/src/views/dashboard/console/modules/card-list.vue create mode 100755 web/apps/portal/src/views/dashboard/console/modules/dynamic-stats.vue create mode 100755 web/apps/portal/src/views/dashboard/console/modules/new-user.vue create mode 100755 web/apps/portal/src/views/dashboard/console/modules/sales-overview.vue create mode 100755 web/apps/portal/src/views/dashboard/console/modules/todo-list.vue create mode 100755 web/apps/portal/src/views/exception/403/index.vue create mode 100755 web/apps/portal/src/views/exception/404/index.vue create mode 100755 web/apps/portal/src/views/exception/500/index.vue create mode 100755 web/apps/portal/src/views/index/index.vue create mode 100644 web/apps/portal/src/views/index/style.scss create mode 100644 web/apps/portal/src/views/outside/Iframe.vue create mode 100644 web/apps/portal/src/views/portal/access/index.vue create mode 100644 web/apps/portal/src/views/portal/catalog/index.vue create mode 100644 web/apps/portal/src/views/portal/marketplace/index.vue create mode 100644 web/apps/portal/src/views/portal/prompts/index.vue create mode 100644 web/apps/portal/src/views/portal/usage/index.vue create mode 100755 web/apps/portal/src/views/result/fail/index.vue create mode 100755 web/apps/portal/src/views/result/success/index.vue create mode 100644 web/apps/portal/src/views/system/menu/index.vue create mode 100644 web/apps/portal/src/views/system/menu/modules/menu-dialog.vue create mode 100755 web/apps/portal/src/views/system/role/index.vue create mode 100644 web/apps/portal/src/views/system/role/modules/role-edit-dialog.vue create mode 100644 web/apps/portal/src/views/system/role/modules/role-permission-dialog.vue create mode 100644 web/apps/portal/src/views/system/role/modules/role-search.vue create mode 100755 web/apps/portal/src/views/system/user-center/index.vue create mode 100644 web/apps/portal/src/views/system/user/index.vue create mode 100644 web/apps/portal/src/views/system/user/modules/user-dialog.vue create mode 100644 web/apps/portal/src/views/system/user/modules/user-search.vue create mode 100755 web/apps/portal/tsconfig.json create mode 100755 web/apps/portal/vite.config.ts create mode 100644 web/package.json create mode 100644 web/packages/api-client/package.json create mode 100644 web/packages/api-client/src/index.ts create mode 100644 web/pnpm-lock.yaml create mode 100644 web/pnpm-workspace.yaml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9540ce3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.env +deploy/production.env +bin +coverage.out +web/node_modules +web/**/node_modules +web/**/dist +web/.pnpm-store diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9e081ca --- /dev/null +++ b/.env.example @@ -0,0 +1,75 @@ +APP_ENV=local +HTTP_ADDR=:8080 +HTTP_READ_HEADER_TIMEOUT=5s +HTTP_IDLE_TIMEOUT=120s +HTTP_SHUTDOWN_TIMEOUT=20s +HTTP_MAX_BODY_BYTES=33554432 + +DATABASE_URL=postgres://gateway:gateway@localhost:5432/gateway?sslmode=disable +DATABASE_MAX_CONNS=40 +DATABASE_MIN_CONNS=4 + +REDIS_CRITICAL_URL=redis://localhost:6379/0 +REDIS_CACHE_URL=redis://localhost:6380/0 + +AUTH_SESSION_TTL=12h +AUTH_TOTP_CHALLENGE_TTL=5m +LOGIN_MAX_FAILURES=5 +LOGIN_LOCK_DURATION=15m + +# 登录防爆破:按 IP 的滑动窗口限流(与上面的账号锁定叠加)。 +# 单 IP 在窗口内最多 LOGIN_RATE_LIMIT_MAX 次登录尝试,超出返回 429。 +LOGIN_RATE_LIMIT_MAX=30 +LOGIN_RATE_LIMIT_WINDOW=5m + +# Base64-encoded 32-byte AES-256 key. Generate with: openssl rand -base64 32 +CREDENTIAL_MASTER_KEY= +CREDENTIAL_KEK_VERSION=1 +ALLOW_PRIVATE_PROVIDER_URLS=false +ALLOW_PRIVATE_TOOL_URLS=false +ALLOW_PRIVATE_WEBHOOK_URLS=false +PROVIDER_REFRESH_INTERVAL=5s + +# Transitional bootstrap credential. It will be replaced by database-backed API keys. +# Disabled by default for security; it bypasses per-key rate limits and tenant +# isolation, so only enable it during migration with a strong random value. +GATEWAY_BOOTSTRAP_API_KEY_ENABLED=false +GATEWAY_BOOTSTRAP_API_KEY= +UPSTREAM_BASE_URL=https://api.openai.com +UPSTREAM_API_KEY= +UPSTREAM_FALLBACK_ENABLED=true + +AUDIT_QUEUE_SIZE=4096 +AUDIT_BATCH_SIZE=200 +AUDIT_FLUSH_INTERVAL=1s +AUDIT_RETENTION=2160h +USAGE_RETENTION=17520h +AUDIT_PARTITION_MONTHS_AHEAD=3 +AUDIT_MAINTENANCE_INTERVAL=6h + +# Keep the {outbox} hash tag so marker and Stream keys share a Redis Cluster slot. +OUTBOX_STREAM=gateway:{outbox}:events +OUTBOX_BATCH_SIZE=100 +OUTBOX_POLL_INTERVAL=500ms +OUTBOX_LEASE=30s +OUTBOX_MAX_ATTEMPTS=10 +OUTBOX_MAX_BACKOFF=5m +OUTBOX_STREAM_MAX_LENGTH=100000 +OUTBOX_MARKER_TTL=720h + +# Multi-instance immutable snapshot refresh. Admin writes also reload the local process immediately. +CONTENT_POLICY_REFRESH_INTERVAL=30s +PRICING_REFRESH_INTERVAL=30s + +# Opt-in non-streaming JSON shadow traffic. A dedicated credential is mandatory; +# production client Authorization headers are never forwarded to the shadow. +SHADOW_BASE_URL= +SHADOW_API_KEY= +SHADOW_SAMPLE_RATE=0 +SHADOW_TIMEOUT=20s +SHADOW_MAX_BODY_BYTES=2097152 +SHADOW_MAX_CONCURRENT=16 + +# Used only by cmd/gateway-bootstrap; never commit the real value. +BOOTSTRAP_ADMIN_USERNAME=admin +BOOTSTRAP_ADMIN_PASSWORD= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0b438b9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.env +deploy/production.env +.idea/ +.vscode/ +.DS_Store +bin/ +coverage.out +web/node_modules/ +web/**/dist/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0f82176 --- /dev/null +++ b/Makefile @@ -0,0 +1,32 @@ +.PHONY: fmt test vet build run migrate bootstrap compose-up compose-down + +fmt: + gofmt -w $$(find cmd internal -name '*.go' -type f) + +test: + go test ./... + +vet: + go vet ./... + +build: + go build -trimpath -o bin/gateway-api ./cmd/gateway-api + go build -trimpath -o bin/gateway-migrator ./cmd/gateway-migrator + go build -trimpath -o bin/gateway-bootstrap ./cmd/gateway-bootstrap + go build -trimpath -o bin/gateway-outbox-worker ./cmd/gateway-outbox-worker + go build -trimpath -o bin/gateway-maintenance ./cmd/gateway-maintenance + +run: + go run ./cmd/gateway-api + +migrate: + go run ./cmd/gateway-migrator + +bootstrap: + go run ./cmd/gateway-bootstrap + +compose-up: + docker compose -f deploy/docker-compose.yml up -d --build + +compose-down: + docker compose -f deploy/docker-compose.yml down diff --git a/README.md b/README.md new file mode 100644 index 0000000..e4f67dd --- /dev/null +++ b/README.md @@ -0,0 +1,112 @@ +# AI Gateway Go + +AI Gateway 的全量 Go 重构工程。M0–M6 工程实现已完成,当前可运行基线包含 PostgreSQL、双 Redis、独立迁移器、Art Design Pro 管理端/门户端、OpenAI 兼容网关和可发布的 AI 资产编排运行时。 + +## 当前能力 + +- `GET /healthz`:进程存活检查。 +- `GET /readyz`:PostgreSQL、critical Redis 和 cache Redis 状态;前两者失败会返回 `503`。 +- `GET /metrics`:不依赖外部组件的基础 Prometheus 文本指标。 +- `POST /v1/chat/completions`、`POST /v1/responses`、`POST /v1/embeddings`、`POST /v1/messages`:透明代理,支持 SSE。 +- `GET /v1/models`:透明代理。 +- 独立 `gateway-migrator`,API 启动时不会修改数据库结构。 +- PostgreSQL 权威管理员/门户身份、PBKDF2 旧密码兼容和 Redis 可撤销会话。 +- 管理端与门户端 RFC 6238 TOTP 两步验证、5 分钟挑战令牌、时间步防重放和一次性备用码。 +- Provider 管理 API;凭据使用 AES-256-GCM 加密,变更与 outbox 同事务提交。 +- PostgreSQL Provider 原子运行时快照、默认/显式路由、能力校验和共享 HTTP 连接池;刷新失败保留上一有效版本。 +- Provider 连接测试和模型目录同步;上游缺失模型保留历史记录并自动停用,同步事件与目录更新同事务提交。 +- 管理员/门户账号管理、内置角色与可扩展 `resource:action` 权限字符串;账号和权限变更即时生效。 +- 层级部门管理和门户账号部门绑定;循环层级、停用父部门和停用在用部门均由服务端约束。 +- OIDC Authorization Code + PKCE 登录、RS256 ID Token 校验、外部账号映射与自动开户。 +- SAML 2.0 SP 发起登录、SP metadata、签名断言验证、RelayState/Request ID 绑定、断言防重放与自动开户。 +- 旧系统 38 个持久化实体的数据字典,以及幂等的旧 ID → UUIDv5 兼容映射基础设施。 +- PostgreSQL API Key(明文仅显示一次、scope、过期时间、Redis 共享缓存、即时撤销)及 Art 管理页面。 +- 按 API Key 配置的每分钟请求上限、UTC 自然月请求配额和 Token 配额;critical Redis Lua 原子执行,Token 采用请求前预留、JSON/SSE usage 回写校准,返回 429、`Retry-After` 和配额响应头。 +- Provider 级响应头超时、幂等安全重试与熔断;POST 只有在客户端提供 `Idempotency-Key` 且请求体可重放时才会重试。 +- PostgreSQL 模型别名和条件路由,按 endpoint、API Key、tenant 过滤,并在最高优先级组内做确定性加权 Provider 选择;规则通过不可变快照运行并由 Redis 通知多实例刷新。 +- 有界异步调用审计和按日 usage 聚合:批量写 PostgreSQL,记录状态、延迟及输入/输出 Token;管理端提供权限隔离的筛选查询,审计存储异常不会阻塞模型调用。 +- 独立事务 Outbox Worker:多实例 `SKIP LOCKED` 抢占、Redis Stream 原子去重投递、指数退避与死信;管理端可审阅并人工重试,消费者可用同事务幂等入口。 +- PostgreSQL 审计月分区维护:自动预创建、default 分区在线迁移、过期整分区快速丢弃、边界精确清理和更长期的 usage 保留策略。 +- 可扩展内容策略:Go RE2 不可变编译快照,按端点、模型/API Key 匹配,支持仅审计、阻断与提示词文本脱敏;默认保护常见 API Key、Token、密码和 secret。 +- 带时间版本的模型价格与成本核算:按 Provider/模型选择价格,输入与输出 Token 分别计价,结果进入调用审计和 PostgreSQL 按日聚合。 +- Prompt 分类、模板和不可变版本,支持显式变量定义、必填校验、历史版本激活与 API Key 渲染接口。 +- PostgreSQL 知识库:2 MiB 有界文本正文、段落感知重叠分块、FTS + 中文二元词片混合检索,以及可替换的 `Retriever` 接口;不依赖对象存储或向量数据库。 +- 声明式 HTTP 工具:JSON Schema 基础校验、KEK 加密请求头、注册和拨号双层 SSRF 防护、禁止重定向、1 MiB 响应限制与调用记录。 +- AI 应用草稿和不可变发布版本,将模型、Prompt、知识库、工具组合为 `/v1/applications/{code}/chat/completions`;所有模型轮次继续经过鉴权、配额、内容策略、路由、成本和审计。 +- 独立通知 Worker 消费可靠 outbox,按精确事件或末尾 `*` 模式投递 HMAC-SHA256 Webhook;内容策略命中由审计批处理异步产生脱敏事件,失败投递可在 Art 管理端重试。 +- 门户自助工作台:部门范围资产目录、Prompt 搜索/收藏、个人审计/用量/成本、模型访问申请与管理员审批。 +- 门户应用托管会话:服务端加密运行凭证、单会话租约、不可变消息序列和 SHA-256 哈希链,不向浏览器暴露应用 API Key。 +- 独立事实核验配置、作用域策略与事件契约,复用 Provider 加密凭据和知识库引用,为同步/异步执行器保留清晰模块边界。 +- 旧 Python 源码 201 条路由全部有覆盖、替代或退役决策,未决契约缺口为 0;OpenAPI 0.10.0 覆盖全部 Go 字面量路由。 + +MinIO/S3 和 ClickHouse 不属于基线部署,也不是启动依赖。审计与统计第一阶段存放在 PostgreSQL;对象存储和分析库仅保留后续 adapter 扩展点。 + +## 本地启动 + +1. 复制 `.env.example` 为 `.env` 并修改密钥。 +2. 启动 PostgreSQL 和两套 Redis:`docker compose -f deploy/docker-compose.yml up -d postgres redis-critical redis-cache`。 +3. 执行迁移:`go run ./cmd/gateway-migrator`。 +4. 设置 `BOOTSTRAP_ADMIN_PASSWORD` 后执行 `go run ./cmd/gateway-bootstrap` 创建初始管理员。 +5. 启动 API:`go run ./cmd/gateway-api`。 +6. 启动可靠事件投递:`go run ./cmd/gateway-outbox-worker`。 +7. 启动审计分区与保留维护:`go run ./cmd/gateway-maintenance`。 +8. 启动通知投递:`go run ./cmd/gateway-notification-worker`。 + +完整容器部署可执行 `docker compose -f deploy/docker-compose.yml up -d --build`。Art Design Pro 管理端默认暴露在 `http://127.0.0.1:8081`,门户端在 `http://127.0.0.1:8082`;两者同源代理 `/api/*`、`/v1/*`、`/healthz` 和 `/readyz` 到 Go API,Go API 仍可从 `8080` 端口直接访问。 + +生产部署请使用 `deploy/docker-compose.production.yml` 和 +`deploy/production.env.example`,完整步骤见 +[`deploy/PRODUCTION.md`](deploy/PRODUCTION.md)。生产编排默认仅绑定回环地址、要求显式提供数据库/Redis/加密密钥,并关闭迁移期 bootstrap API key。 + +如果主机未安装 Go,可以在 Compose 依赖启动和迁移完成后执行: + +```bash +docker compose -f deploy/docker-compose.yml run --rm \ + -e BOOTSTRAP_ADMIN_PASSWORD='替换为强口令' \ + --entrypoint gateway-bootstrap gateway-api +``` + +未设置 `HTTP_ADDR` 时只监听 `127.0.0.1:8080`;容器配置会显式监听 `:8080`。 + +生产环境必须设置 `APP_ENV=production`,并使用带 TLS 的数据库和 Redis 地址。启用环境变量上游回退时必须设置 `UPSTREAM_API_KEY`。数据库 API Key 已可用于网关请求;bootstrap key 默认关闭,仅用于迁移兼容,需要时显式设置 `GATEWAY_BOOTSTRAP_API_KEY_ENABLED=true` 并配置强随机值,迁移结束后立即关闭,此时无需再配置 bootstrap key。`gateway_bootstrap_api_key_uses_total` 指标可用于确认旧客户端是否已经清零。bootstrap key 会绕过按 key 的限流与配额,切勿长期开启。 + +内置管理员角色为 `superadmin`、`operator`、`auditor`。超级管理员拥有通配权限;运维管理员默认管理 Provider 和 API Key;审计员只有读取权限。账号还可获得独立的 `resource:action` 权限,服务端每次请求都会从 PostgreSQL 复核账号状态和权限,因此停用或权限回收即时生效。 + +内容策略和模型价格以 PostgreSQL 为权威源,管理端保存后本实例立即刷新;其他实例分别按 `CONTENT_POLICY_REFRESH_INTERVAL`、`PRICING_REFRESH_INTERVAL`(默认 30 秒)加载不可变快照。正则采用 Go RE2,不支持回溯、环视等可能导致灾难性耗时的表达式。价格金额以微货币单位整数保存,避免浮点累计误差;例如 USD 2.50/百万 Token 存为 `2500000`。 + +部门采用稳定 UUID 与邻接表层级。部门仍包含启用门户用户或启用子部门时不能停用;移动部门时会在事务内检测自身引用和任意深度循环。门户账号只能绑定启用部门,解绑不会删除任何历史身份数据。 + +OIDC 登录使用 Redis 保存 5 分钟一次性 `state`、`nonce` 和 PKCE verifier;回调校验发现文档、JWKS/RS256 签名、issuer、audience/azp、iat/nbf/exp 与 nonce。门户会话令牌不进入回调 URL,只使用 60 秒一次性交换码。OIDC Client Secret 使用 `identity-provider-credentials` 独立 AEAD 用途加密。 + +SAML 当前只开放 SP-initiated Redirect 登录和 POST ACS,显式拒绝 IdP-initiated 与 Artifact 绑定。IdP metadata 通过 SSRF 安全客户端获取并短时缓存,启用前校验 Entity ID、Redirect SSO 端点、签名证书和过期时间。ACS 依次验证 XML 签名、issuer、audience、destination、recipient、InResponseTo 和时间窗,只接受 SHA-256 及以上的签名/摘要算法,并使用 Redis 防止 RelayState 和 Assertion ID 重放。 + +登录防爆破采用两层叠加:账号锁定(`LOGIN_MAX_FAILURES` 次失败后锁定 `LOGIN_LOCK_DURATION`,针对单账号)与按 IP 的 Redis 滑动窗口限流(`LOGIN_RATE_LIMIT_MAX` 次/`LOGIN_RATE_LIMIT_WINDOW`,超出返回 429,针对跨账号撞库)。限流在 Redis 不可用时 fail-open,账号锁定仍然生效。来源 IP 取自 `X-Forwarded-For`:`deploy/nginx-web.conf` 在 `/api/` 路径用 `$remote_addr` 覆盖该头,避免客户端伪造头绕过限流;直接访问网关端口绕过 nginx 的请求仍可伪造该头,因此生产建议将管理面代理收敛在受信网络内。 + +旧 Python 数据结构的完整字段字典见 `docs/legacy-data-dictionary.md`。迁移使用 `gateway.legacy_id_mappings` 保存来源、实体、旧 ID 与新 UUID 的对应关系;新 UUID 由固定 namespace 的 UUIDv5 生成,同一条旧数据可安全重跑而不会生成不同主键。字段字典可用 `scripts/export_legacy_dictionary.py` 从旧工程重新生成。 + +`CREDENTIAL_MASTER_KEY` 必须是 32 字节密钥的 Base64 编码。Compose 中的默认值只允许本地开发,生产环境必须替换并纳入密钥托管与备份;丢失该密钥会导致 Provider 凭据、TOTP、工具请求头、通知签名密钥和应用运行凭证无法解密。每类密文使用独立用途标签,不能相互替换。 + +工具端点和通知 Webhook 默认只允许公网 HTTP(S) 地址,并在实际拨号时重新解析与校验地址。确有内网服务时分别显式设置 `ALLOW_PRIVATE_TOOL_URLS=true`、`ALLOW_PRIVATE_WEBHOOK_URLS=true`;这两个开关与 Provider 私网开关相互独立。 + +影子流量默认关闭。设置 `SHADOW_BASE_URL`、专用 `SHADOW_API_KEY` 和大于 0 的 `SHADOW_SAMPLE_RATE` 后,只复制确定性采样的非流式 JSON POST;生产客户端凭据不会转发。主响应不等待影子请求,Prometheus 指标只比较 HTTP 状态和 JSON 结构签名,不记录提示词或模型正文。压测使用独立 `gateway-loadtest` 二进制,可用错误率和 p95 阈值直接控制退出码。 + +旧路由矩阵由 `scripts/compare_route_contracts.py` 从 Python AST 与 Go OpenAPI 生成。旧源码当前实际包含 201 个路由装饰器,并非早期估算的 141 个;矩阵明确区分同契约覆盖、新契约替代、退役和真实缺口。切换步骤见 `docs/cutover-runbook.md`,旧数据暂存与密文边界见 `docs/legacy-import-runbook.md`。 + +KEK 轮换时,将 `CREDENTIAL_KEK_VERSION` 和 `CREDENTIAL_MASTER_KEY` 设置为新活动版本与新密钥,并用 `CREDENTIAL_KEK_KEYRING` 加载历史密钥,例如 `{"1":""}`。服务重启并确认可解密后,在供应商页面执行“轮换凭据”;所有旧版本 Provider 凭据和对应 outbox 事件会在一个事务内提交。确认全部 Provider 已切换后才能从 keyring 移除旧密钥。TOTP 同样通过 keyring 保持历史版本可解密,新配置会使用活动版本。 + +Provider 以 PostgreSQL 为权威源,在进程内构建不可变快照。管理 API 修改后会刷新本实例并通过 critical Redis Pub/Sub 通知其他实例立即刷新;默认每 5 秒轮询仍作为通知丢失或 Redis 短暂异常时的兜底。请求可通过 `X-Gateway-Provider` 指定 Provider;未指定时使用 `config.default=true` 的启用项,否则使用按 code 排序的第一项。`UPSTREAM_FALLBACK_ENABLED=false` 可关闭环境变量上游回退。 + +供应商连接测试和模型同步访问 OpenAI-compatible `/v1/models`。控制面请求限制为 10 秒、4 MiB 响应,拒绝重定向,并在禁用私网 Provider 时对实际拨号地址再次执行 SSRF 校验。模型同步不会物理删除旧模型,本次未出现的模型会标记为停用,为后续别名和路由规则保留引用稳定性。 + +## 目录 + +- `api/openapi`:对外契约。 +- `cmd`:可部署二进制。 +- `internal/platform`:配置、数据库、缓存、HTTP 运行时。 +- `internal/provider`:上游 Provider 扩展接口及实现。 +- `internal/gateway`:协议入口和代理编排。 +- `internal/workbench`:Prompt、知识、工具、应用编排和通知。 +- `internal/portal`:门户目录、申请、个人统计、加密应用凭证与托管会话。 +- `internal/factcheck`:事实核验设置、策略和事件契约。 +- `migrations`:仅由 migrator 执行的 PostgreSQL 迁移。 +- `web`:Art Design Pro 管理端和门户端。 diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..9a629eb --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,20 @@ +# AI Gateway Go 0.10.0 clean deployment bundle + +Generated on 2026-08-11 from `/home/ben/ai-gateway-go`. + +Included: + +- Go backend and worker source +- 21 PostgreSQL migrations +- Admin and portal Art Design Pro source +- OpenAPI contract, operations documents and legacy migration tools +- Local and production Docker Compose definitions + +Excluded: + +- `node_modules`, frontend `dist`, Go binaries and caches +- local `.env`, `deploy/production.env`, logs and coverage output +- database volumes, Redis data and all runtime credentials + +Start with `deploy/PRODUCTION.md`. Verify the accompanying ZIP checksum before +copying the bundle to a deployment host. diff --git a/api/openapi/gateway.yaml b/api/openapi/gateway.yaml new file mode 100644 index 0000000..9572728 --- /dev/null +++ b/api/openapi/gateway.yaml @@ -0,0 +1,1627 @@ +openapi: 3.1.0 +info: + title: AI Gateway Runtime API + version: 0.10.0 + description: Go rewrite contract. OpenAI-compatible payloads remain intentionally open. +servers: + - url: / +paths: + /healthz: + get: + operationId: getHealth + responses: + "200": + description: Process is alive + content: + application/json: + schema: + $ref: "#/components/schemas/Health" + /readyz: + get: + operationId: getReadiness + responses: + "200": + description: Required dependencies are ready + "503": + description: A required dependency is unavailable + /metrics: + get: + operationId: getMetrics + responses: + "200": { description: Prometheus text metrics for runtime, audit, bootstrap and shadow traffic } + /api/v1/admin/login: + post: + operationId: loginAdmin + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/LoginRequest" } + responses: + "200": { description: Opaque Redis-backed session token } + "401": { description: Invalid credentials } + "429": { description: Account locked } + /api/v1/admin/whoami: + get: + operationId: getAdminIdentity + security: [{ bearerAuth: [] }] + responses: + "200": { description: Active administrator identity and role } + "401": { description: Invalid or expired session } + /api/v1/admin/password: + post: + operationId: changeAdminPassword + security: [{ bearerAuth: [] }] + responses: + "200": { description: Administrator password changed after current-password verification } + "401": { description: Current password invalid } + /api/v1/admin/system-info: + get: + operationId: getSystemInfo + security: [{ bearerAuth: [] }] + responses: { "200": { description: Version, runtime, uptime and enabled resource counts } } + /api/v1/admin/monitoring/overview: + get: + operationId: getMonitoringOverview + security: [{ bearerAuth: [] }] + responses: { "200": { description: PostgreSQL-backed 24-hour request, token, cost and latency summary } } + /api/v1/admin/reload: + post: + operationId: reloadRuntimeSnapshots + security: [{ bearerAuth: [] }] + responses: { "200": { description: Provider, content-policy and pricing snapshots reloaded } } + /api/v1/admin/fact-check/settings: + get: + operationId: getFactCheckSettings + security: [{ bearerAuth: [] }] + responses: { "200": { description: Fact-check Provider reference, model and timeout without duplicated credentials } } + put: + operationId: updateFactCheckSettings + security: [{ bearerAuth: [] }] + responses: { "200": { description: Fact-check settings updated } } + /api/v1/admin/fact-check/policies: + get: + operationId: listFactCheckPolicies + security: [{ bearerAuth: [] }] + responses: { "200": { description: Scoped fact-check policies } } + post: + operationId: createFactCheckPolicy + security: [{ bearerAuth: [] }] + responses: { "200": { description: Validated fact-check policy created } } + /api/v1/admin/fact-check/policies/{policy_id}: + put: + operationId: updateFactCheckPolicy + security: [{ bearerAuth: [] }] + parameters: [{ name: policy_id, in: path, required: true, schema: { type: string, format: uuid } }] + responses: { "200": { description: Fact-check policy revision updated } } + delete: + operationId: deleteFactCheckPolicy + security: [{ bearerAuth: [] }] + parameters: [{ name: policy_id, in: path, required: true, schema: { type: string, format: uuid } }] + responses: { "200": { description: Fact-check policy deleted } } + /api/v1/admin/fact-check/events: + get: + operationId: listFactCheckEvents + security: [{ bearerAuth: [] }] + responses: { "200": { description: Bounded fact-check event summaries } } + /api/v1/admin/fact-check/events/{event_id}: + get: + operationId: getFactCheckEvent + security: [{ bearerAuth: [] }] + parameters: [{ name: event_id, in: path, required: true, schema: { type: string, format: uuid } }] + responses: { "200": { description: Fact-check claims and evidence detail } } + /api/v1/admin/login/totp: + post: + operationId: completeAdminTOTPLogin + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/TOTPLoginRequest" } + responses: + "200": { description: TOTP challenge completed and session issued } + "401": { description: Invalid, reused, or consumed factor } + /api/v1/admin/totp/status: + get: + operationId: getAdminTOTPStatus + security: [{ bearerAuth: [] }] + responses: + "200": { description: TOTP enrollment status } + /api/v1/admin/totp/setup: + post: + operationId: setupAdminTOTP + security: [{ bearerAuth: [] }] + responses: + "200": { description: New secret and otpauth provisioning URI } + /api/v1/admin/totp/confirm: + post: + operationId: confirmAdminTOTP + security: [{ bearerAuth: [] }] + responses: + "200": { description: Enrollment confirmed; backup codes returned once } + /api/v1/admin/totp/disable: + post: + operationId: disableAdminTOTP + security: [{ bearerAuth: [] }] + responses: + "200": { description: TOTP disabled after password and factor verification } + /api/v1/admin/totp/backup-codes/regenerate: + post: + operationId: regenerateAdminTOTPBackupCodes + security: [{ bearerAuth: [] }] + responses: + "200": { description: Previous backup codes revoked and replacements returned once } + /api/v1/admin/logout: + post: + operationId: logoutAdmin + security: [{ bearerAuth: [] }] + responses: + "200": { description: Session revoked } + /api/v1/admin/menus: + get: + operationId: getAdminMenus + security: [{ bearerAuth: [] }] + responses: + "200": { description: Art Design Pro route tree for the active role } + /api/v1/admin/identities/admins: + get: + operationId: listAdministrators + security: [{ bearerAuth: [] }] + responses: + "200": { description: Administrator accounts with direct and effective permissions } + "403": { description: identity:manage permission required } + post: + operationId: createAdministrator + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/IdentityInput" } + responses: + "200": { description: Administrator created and outbox event committed } + "409": { description: Login already exists } + /api/v1/admin/identities/admins/{identity_id}: + put: + operationId: updateAdministrator + security: [{ bearerAuth: [] }] + parameters: + - $ref: "#/components/parameters/IdentityID" + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/IdentityInput" } + responses: + "200": { description: Account and permissions updated immediately } + "409": { description: Self-disable, self-role change, or duplicate login } + /api/v1/admin/identities/portal-users: + get: + operationId: listPortalUsers + security: [{ bearerAuth: [] }] + responses: + "200": { description: Portal accounts with direct and effective permissions } + post: + operationId: createPortalUser + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/IdentityInput" } + responses: + "200": { description: Local portal account created } + /api/v1/admin/identities/portal-users/{identity_id}: + put: + operationId: updatePortalUser + security: [{ bearerAuth: [] }] + parameters: + - $ref: "#/components/parameters/IdentityID" + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/IdentityInput" } + responses: + "200": { description: Portal account updated } + /api/v1/admin/departments: + get: + operationId: listDepartments + security: [{ bearerAuth: [] }] + responses: + "200": { description: Flat department catalog with parent names and active user counts } + post: + operationId: createDepartment + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/DepartmentInput" } + responses: + "200": { description: Department created with outbox event } + "409": { description: Duplicate code or invalid hierarchy } + /api/v1/admin/departments/{department_id}: + put: + operationId: updateDepartment + security: [{ bearerAuth: [] }] + parameters: + - name: department_id + in: path + required: true + schema: { type: string, format: uuid } + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/DepartmentInput" } + responses: + "200": { description: Department updated with hierarchy validation } + "409": { description: Cycle or active users/children prevent the update } + /api/v1/admin/identity-providers: + get: + operationId: listIdentityProviders + security: [{ bearerAuth: [] }] + responses: + "200": { description: OIDC providers without client secret material } + post: + operationId: createIdentityProvider + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/OIDCProviderInput" } + responses: + "200": { description: Encrypted OIDC provider configuration created } + /api/v1/admin/identity-providers/{provider_id}: + put: + operationId: updateIdentityProvider + security: [{ bearerAuth: [] }] + parameters: + - $ref: "#/components/parameters/ProviderID" + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/OIDCProviderInput" } + responses: + "200": { description: OIDC provider updated; omitted secret remains unchanged } + /api/v1/admin/saml-providers: + get: + operationId: listSAMLProviders + security: [{ bearerAuth: [] }] + responses: + "200": { description: SAML identity provider configurations } + post: + operationId: createSAMLProvider + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/SAMLProviderInput" } + responses: + "200": { description: SAML provider created after metadata and signing certificate validation } + /api/v1/admin/saml-providers/{provider_id}: + put: + operationId: updateSAMLProvider + security: [{ bearerAuth: [] }] + parameters: + - $ref: "#/components/parameters/ProviderID" + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/SAMLProviderInput" } + responses: + "200": { description: SAML provider updated and revision incremented } + /api/v1/admin/providers: + get: + operationId: listProviders + security: [{ bearerAuth: [] }] + responses: + "200": { description: Providers with masked credentials } + post: + operationId: createProvider + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ProviderInput" } + responses: + "200": { description: Provider created and outbox event committed } + "403": { description: Superadmin role required } + /api/v1/admin/api-keys: + get: + operationId: listAPIKeys + security: [{ bearerAuth: [] }] + responses: + "200": { description: API keys without secret material } + "403": { description: Superadmin role required } + post: + operationId: createAPIKey + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/APIKeyInput" } + responses: + "200": { description: API key secret returned exactly once } + /api/v1/admin/api-keys/{api_key_id}: + delete: + operationId: revokeAPIKey + security: [{ bearerAuth: [] }] + parameters: + - name: api_key_id + in: path + required: true + schema: { type: string, format: uuid } + responses: + "200": { description: Key revoked and shared cache invalidated } + "404": { description: Key not found or already revoked } + /api/v1/admin/api-keys/{api_key_id}/limits: + put: + operationId: updateAPIKeyLimits + security: [{ bearerAuth: [] }] + parameters: + - name: api_key_id + in: path + required: true + schema: { type: string, format: uuid } + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/APIKeyLimitsInput" } + responses: + "200": { description: Request and token limits updated; authentication cache invalidated } + "404": { description: Key not found or already revoked } + /api/v1/admin/model-routes: + get: + operationId: listModelRoutes + security: [{ bearerAuth: [] }] + responses: + "200": { description: Model alias and weighted route rules } + post: + operationId: createModelRoute + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ModelRouteInput" } + responses: + "200": { description: Route committed, snapshot refreshed, and peers notified } + "409": { description: Duplicate route } + /api/v1/admin/model-routes/{model_route_id}: + put: + operationId: updateModelRoute + security: [{ bearerAuth: [] }] + parameters: + - $ref: "#/components/parameters/ModelRouteID" + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ModelRouteInput" } + responses: + "200": { description: Route updated and active snapshots refreshed } + "404": { description: Route not found } + delete: + operationId: deleteModelRoute + security: [{ bearerAuth: [] }] + parameters: + - $ref: "#/components/parameters/ModelRouteID" + responses: + "200": { description: Route deleted and active snapshots refreshed } + "404": { description: Route not found } + /api/v1/admin/audit-events: + get: + operationId: listAuditEvents + security: [{ bearerAuth: [] }] + parameters: + - { name: from, in: query, schema: { type: string, format: date-time } } + - { name: to, in: query, schema: { type: string, format: date-time } } + - { name: before, in: query, schema: { type: string, format: date-time } } + - { name: api_key_id, in: query, schema: { type: string, format: uuid } } + - { name: provider, in: query, schema: { type: string } } + - { name: model, in: query, schema: { type: string } } + - { name: status, in: query, schema: { type: integer, minimum: 100, maximum: 599 } } + - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 50 } } + responses: + "200": { description: Cursor-paginated gateway audit events } + "403": { description: audit:read permission required } + /api/v1/admin/usage/daily: + get: + operationId: listDailyUsage + security: [{ bearerAuth: [] }] + parameters: + - { name: from, in: query, schema: { type: string, format: date } } + - { name: to, in: query, schema: { type: string, format: date } } + - { name: api_key_id, in: query, schema: { type: string, format: uuid } } + - { name: provider, in: query, schema: { type: string } } + - { name: model, in: query, schema: { type: string } } + responses: + "200": { description: Daily request, failure, and token aggregates } + "403": { description: usage:read permission required } + /api/v1/admin/outbox-events: + get: + operationId: listOutboxEvents + security: [{ bearerAuth: [] }] + parameters: + - { name: status, in: query, schema: { type: string, enum: [pending, dead, processed] } } + - { name: type, in: query, schema: { type: string } } + - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 500, default: 100 } } + responses: + "200": { description: Pending, dead-lettered, or processed transactional outbox events } + "403": { description: outbox:read permission required } + /api/v1/admin/outbox-events/{event_id}/retry: + post: + operationId: retryOutboxEvent + security: [{ bearerAuth: [] }] + parameters: + - name: event_id + in: path + required: true + schema: { type: string, format: uuid } + responses: + "200": { description: Pending or dead-lettered event made immediately claimable } + "403": { description: outbox:manage permission required } + "404": { description: Unprocessed event not found } + /api/v1/admin/content-policies: + get: + operationId: listContentPolicies + security: [{ bearerAuth: [] }] + responses: + "200": { description: Ordered content policy definitions } + "403": { description: content_policy:read permission required } + post: + operationId: createContentPolicy + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ContentPolicyInput" } + responses: + "200": { description: Policy committed, compiled, and activated locally } + "400": { description: Invalid RE2 rule or policy scope } + /api/v1/admin/content-policies/{policy_id}: + put: + operationId: updateContentPolicy + security: [{ bearerAuth: [] }] + parameters: [{ name: policy_id, in: path, required: true, schema: { type: string, format: uuid } }] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ContentPolicyInput" } + responses: + "200": { description: Policy revision updated and snapshot reloaded } + "404": { description: Policy not found } + delete: + operationId: deleteContentPolicy + security: [{ bearerAuth: [] }] + parameters: [{ name: policy_id, in: path, required: true, schema: { type: string, format: uuid } }] + responses: + "200": { description: Policy deleted and snapshot reloaded } + /api/v1/admin/model-prices: + get: + operationId: listModelPrices + security: [{ bearerAuth: [] }] + responses: + "200": { description: Versioned model price definitions } + "403": { description: pricing:read permission required } + post: + operationId: createModelPrice + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ModelPriceInput" } + responses: + "200": { description: Price version created and runtime snapshot reloaded } + /api/v1/admin/model-prices/{price_id}: + put: + operationId: updateModelPrice + security: [{ bearerAuth: [] }] + parameters: [{ name: price_id, in: path, required: true, schema: { type: string, format: uuid } }] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ModelPriceInput" } + responses: + "200": { description: Price revision updated and runtime snapshot reloaded } + delete: + operationId: deleteModelPrice + security: [{ bearerAuth: [] }] + parameters: [{ name: price_id, in: path, required: true, schema: { type: string, format: uuid } }] + responses: + "200": { description: Price version deleted } + /api/v1/admin/prompts: + get: + operationId: listPrompts + security: [{ bearerAuth: [] }] + responses: { "200": { description: Prompt templates and active immutable versions } } + post: + operationId: createPrompt + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/PromptInput" } } } } + responses: { "200": { description: Template and initial immutable version created } } + /api/v1/admin/prompts/{prompt_id}: + get: + operationId: getPrompt + security: [{ bearerAuth: [] }] + parameters: [{ name: prompt_id, in: path, required: true, schema: { type: string, format: uuid } }] + responses: { "200": { description: Prompt metadata and current immutable version } } + put: + operationId: updatePromptMetadata + security: [{ bearerAuth: [] }] + parameters: [{ name: prompt_id, in: path, required: true, schema: { type: string, format: uuid } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/PromptInput" } } } } + responses: { "200": { description: Prompt metadata updated without mutating version content } } + delete: + operationId: deletePrompt + security: [{ bearerAuth: [] }] + parameters: [{ name: prompt_id, in: path, required: true, schema: { type: string, format: uuid } }] + responses: { "200": { description: Prompt and versions deleted } } + /api/v1/admin/prompts/{prompt_id}/versions: + get: + operationId: listPromptVersions + security: [{ bearerAuth: [] }] + parameters: [{ name: prompt_id, in: path, required: true, schema: { type: string, format: uuid } }] + responses: { "200": { description: Immutable Prompt version history } } + post: + operationId: createPromptVersion + security: [{ bearerAuth: [] }] + parameters: [{ name: prompt_id, in: path, required: true, schema: { type: string, format: uuid } }] + responses: { "200": { description: New immutable version created and optionally activated } } + /api/v1/admin/prompts/{prompt_id}/versions/{version}/activate: + post: + operationId: activatePromptVersion + security: [{ bearerAuth: [] }] + parameters: + - { name: prompt_id, in: path, required: true, schema: { type: string, format: uuid } } + - { name: version, in: path, required: true, schema: { type: integer, minimum: 1 } } + responses: { "200": { description: Historical immutable version made current } } + /api/v1/admin/prompts/{prompt_id}/render: + post: + operationId: previewPrompt + security: [{ bearerAuth: [] }] + parameters: [{ name: prompt_id, in: path, required: true, schema: { type: string, format: uuid } }] + responses: { "200": { description: Current Prompt rendered for control-plane preview } } + /api/v1/admin/prompt-categories: + get: + operationId: listPromptCategories + security: [{ bearerAuth: [] }] + responses: { "200": { description: Prompt categories with template counts } } + post: + operationId: createPromptCategory + security: [{ bearerAuth: [] }] + responses: { "200": { description: Prompt category created } } + /api/v1/admin/prompt-categories/{category_id}: + delete: + operationId: deletePromptCategory + security: [{ bearerAuth: [] }] + parameters: [{ name: category_id, in: path, required: true, schema: { type: string, format: uuid } }] + responses: { "200": { description: Category removed and templates made uncategorized } } + /api/v1/admin/knowledge-bases: + get: + operationId: listKnowledgeBases + security: [{ bearerAuth: [] }] + responses: { "200": { description: PostgreSQL-backed knowledge bases } } + post: + operationId: createKnowledgeBase + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/KnowledgeBaseInput" } } } } + responses: { "200": { description: Knowledge base created } } + /api/v1/admin/knowledge-bases/{knowledge_base_id}: + put: + operationId: updateKnowledgeBase + security: [{ bearerAuth: [] }] + parameters: [{ name: knowledge_base_id, in: path, required: true, schema: { type: string, format: uuid } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/KnowledgeBaseInput" } } } } + responses: { "200": { description: Knowledge base updated } } + delete: + operationId: deleteKnowledgeBase + security: [{ bearerAuth: [] }] + parameters: [{ name: knowledge_base_id, in: path, required: true, schema: { type: string, format: uuid } }] + responses: { "200": { description: Unreferenced knowledge base deleted } } + /api/v1/admin/knowledge-bases/{knowledge_base_id}/documents: + get: + operationId: listKnowledgeDocuments + security: [{ bearerAuth: [] }] + parameters: [{ name: knowledge_base_id, in: path, required: true, schema: { type: string, format: uuid } }] + responses: { "200": { description: Bounded extracted-text documents } } + post: + operationId: addKnowledgeDocument + security: [{ bearerAuth: [] }] + parameters: [{ name: knowledge_base_id, in: path, required: true, schema: { type: string, format: uuid } }] + responses: { "200": { description: Document stored in PostgreSQL and synchronously chunked } } + /api/v1/admin/knowledge-bases/{knowledge_base_id}/documents/{document_id}: + delete: + operationId: deleteKnowledgeDocument + security: [{ bearerAuth: [] }] + parameters: + - { name: knowledge_base_id, in: path, required: true, schema: { type: string, format: uuid } } + - { name: document_id, in: path, required: true, schema: { type: string, format: uuid } } + responses: { "200": { description: Document and chunks deleted } } + /api/v1/admin/knowledge-bases/{knowledge_base_id}/documents/{document_id}/reprocess: + post: + operationId: reprocessKnowledgeDocument + security: [{ bearerAuth: [] }] + parameters: + - { name: knowledge_base_id, in: path, required: true, schema: { type: string, format: uuid } } + - { name: document_id, in: path, required: true, schema: { type: string, format: uuid } } + responses: { "200": { description: Stored bounded text re-chunked transactionally } } + /api/v1/admin/knowledge-bases/{knowledge_base_id}/search: + post: + operationId: testKnowledgeSearch + security: [{ bearerAuth: [] }] + parameters: [{ name: knowledge_base_id, in: path, required: true, schema: { type: string, format: uuid } }] + responses: { "200": { description: Retriever test results } } + /api/v1/admin/tools: + get: + operationId: listTools + security: [{ bearerAuth: [] }] + responses: { "200": { description: HTTP tool definitions without decrypted secret headers } } + post: + operationId: createTool + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/ToolInput" } } } } + responses: { "200": { description: SSRF-validated tool with encrypted headers created } } + /api/v1/admin/tools/{tool_id}: + put: + operationId: updateTool + security: [{ bearerAuth: [] }] + parameters: [{ name: tool_id, in: path, required: true, schema: { type: string, format: uuid } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/ToolInput" } } } } + responses: { "200": { description: Tool revision updated } } + delete: + operationId: deleteTool + security: [{ bearerAuth: [] }] + parameters: [{ name: tool_id, in: path, required: true, schema: { type: string, format: uuid } }] + responses: { "200": { description: Unreferenced tool deleted } } + /api/v1/admin/tools/{tool_id}/test: + post: + operationId: testTool + security: [{ bearerAuth: [] }] + parameters: [{ name: tool_id, in: path, required: true, schema: { type: string, format: uuid } }] + responses: { "200": { description: Bounded SSRF-safe administrative tool test } } + /api/v1/admin/applications: + get: + operationId: listApplications + security: [{ bearerAuth: [] }] + responses: { "200": { description: Application drafts and current published versions } } + post: + operationId: createApplication + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/ApplicationInput" } } } } + responses: { "200": { description: Application draft created } } + /api/v1/admin/applications/catalog: + get: + operationId: getApplicationCompositionCatalog + security: [{ bearerAuth: [] }] + responses: { "200": { description: Prompt, knowledge and tool assets available for composition } } + /api/v1/admin/applications/asset-dependencies: + get: + operationId: getApplicationAssetDependencies + security: [{ bearerAuth: [] }] + parameters: + - { name: asset_type, in: query, required: true, schema: { type: string, enum: [prompt, knowledge, tool] } } + - { name: asset_id, in: query, required: true, schema: { type: string, format: uuid } } + responses: { "200": { description: Draft and published application references to an asset } } + /api/v1/admin/applications/{application_id}/publish: + post: + operationId: publishApplication + security: [{ bearerAuth: [] }] + parameters: [{ name: application_id, in: path, required: true, schema: { type: string, format: uuid } }] + responses: { "200": { description: Validated immutable application composition published } } + /api/v1/admin/applications/{application_id}: + get: + operationId: getApplication + security: [{ bearerAuth: [] }] + parameters: [{ name: application_id, in: path, required: true, schema: { type: string, format: uuid } }] + responses: { "200": { description: Application composition and immutable version history } } + put: + operationId: updateApplication + security: [{ bearerAuth: [] }] + parameters: [{ name: application_id, in: path, required: true, schema: { type: string, format: uuid } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/ApplicationInput" } } } } + responses: { "200": { description: Application draft updated } } + delete: + operationId: deleteApplication + security: [{ bearerAuth: [] }] + parameters: [{ name: application_id, in: path, required: true, schema: { type: string, format: uuid } }] + responses: { "200": { description: Application and its versions deleted } } + /api/v1/admin/applications/{application_id}/rollback/{version}: + post: + operationId: rollbackApplication + security: [{ bearerAuth: [] }] + parameters: + - { name: application_id, in: path, required: true, schema: { type: string, format: uuid } } + - { name: version, in: path, required: true, schema: { type: integer, minimum: 1 } } + responses: { "200": { description: Historical composition copied into a new immutable published version } } + /api/v1/admin/applications/{application_id}/versions: + get: + operationId: listApplicationVersions + security: [{ bearerAuth: [] }] + parameters: [{ name: application_id, in: path, required: true, schema: { type: string, format: uuid } }] + responses: { "200": { description: Immutable application publication history } } + /api/v1/admin/applications/{application_id}/runs: + get: + operationId: listApplicationRuns + security: [{ bearerAuth: [] }] + parameters: [{ name: application_id, in: path, required: true, schema: { type: string, format: uuid } }] + responses: { "200": { description: Recent application orchestration runs } } + /api/v1/admin/notification-channels: + get: + operationId: listNotificationChannels + security: [{ bearerAuth: [] }] + responses: { "200": { description: Webhook subscriptions without decrypted signing secrets } } + post: + operationId: createNotificationChannel + security: [{ bearerAuth: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/NotificationChannelInput" } } } } + responses: { "200": { description: Signed Webhook subscription created } } + /api/v1/admin/notification-deliveries: + get: + operationId: listNotificationDeliveries + security: [{ bearerAuth: [] }] + responses: { "200": { description: Durable Webhook delivery history } } + /api/v1/admin/notification-channels/{channel_id}: + put: + operationId: updateNotificationChannel + security: [{ bearerAuth: [] }] + parameters: [{ name: channel_id, in: path, required: true, schema: { type: string, format: uuid } }] + requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/NotificationChannelInput" } } } } + responses: { "200": { description: Notification channel updated } } + delete: + operationId: deleteNotificationChannel + security: [{ bearerAuth: [] }] + parameters: [{ name: channel_id, in: path, required: true, schema: { type: string, format: uuid } }] + responses: { "200": { description: Notification channel and deliveries deleted } } + /api/v1/admin/notification-deliveries/{delivery_id}/retry: + post: + operationId: retryNotificationDelivery + security: [{ bearerAuth: [] }] + parameters: [{ name: delivery_id, in: path, required: true, schema: { type: string, format: uuid } }] + responses: { "200": { description: Failed delivery attempted immediately } } + /v1/prompts: + get: + operationId: listRuntimePrompts + security: [{ apiKeyAuth: [] }] + responses: { "200": { description: Visible enabled Prompt definitions without template bodies } } + /v1/prompts/{prompt_name}/render: + post: + operationId: renderPrompt + security: [{ apiKeyAuth: [] }] + parameters: [{ name: prompt_name, in: path, required: true, schema: { type: string } }] + responses: { "200": { description: Active template rendered after required-variable validation } } + /v1/knowledge/{knowledge_base_id}/search: + post: + operationId: searchKnowledge + security: [{ apiKeyAuth: [] }] + parameters: [{ name: knowledge_base_id, in: path, required: true, schema: { type: string, format: uuid } }] + responses: { "200": { description: Department-scoped Retriever results } } + /v1/knowledge/search: + post: + operationId: searchKnowledgeLegacyShape + security: [{ apiKeyAuth: [] }] + responses: { "200": { description: Knowledge search with knowledge_base_id in the JSON body } } + /v1/tools: + get: + operationId: listRuntimeTools + security: [{ apiKeyAuth: [] }] + responses: { "200": { description: Department-scoped tool schemas without encrypted headers } } + /v1/tools/{tool_code}/invoke: + post: + operationId: invokeTool + security: [{ apiKeyAuth: [] }] + parameters: [{ name: tool_code, in: path, required: true, schema: { type: string } }] + responses: { "200": { description: Schema-validated, SSRF-safe bounded HTTP tool result } } + /v1/applications/{application_code}/chat/completions: + post: + operationId: runApplication + security: [{ apiKeyAuth: [] }] + parameters: [{ name: application_code, in: path, required: true, schema: { type: string } }] + responses: { "200": { description: Prompt, retrieval and bounded tool loop executed through the governed gateway } } + /api/v1/admin/providers/{provider_id}: + put: + operationId: updateProvider + security: [{ bearerAuth: [] }] + parameters: + - name: provider_id + in: path + required: true + schema: { type: string, format: uuid } + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ProviderInput" } + responses: + "200": { description: Provider updated and revision incremented } + "404": { description: Provider not found } + /api/v1/admin/providers/{provider_id}/test: + post: + operationId: testProviderConnection + security: [{ bearerAuth: [] }] + parameters: + - $ref: "#/components/parameters/ProviderID" + responses: + "200": + description: Bounded connectivity test against the upstream models endpoint + content: + application/json: + schema: { $ref: "#/components/schemas/ProviderConnectionResult" } + "404": { description: Provider not found } + "502": { description: Upstream network request failed } + /api/v1/admin/providers/{provider_id}/models: + get: + operationId: listProviderModels + security: [{ bearerAuth: [] }] + parameters: + - $ref: "#/components/parameters/ProviderID" + responses: + "200": + description: Current and historical provider model catalog + "404": { description: Provider not found } + /api/v1/admin/providers/{provider_id}/models/sync: + post: + operationId: syncProviderModels + security: [{ bearerAuth: [] }] + parameters: + - $ref: "#/components/parameters/ProviderID" + responses: + "200": + description: Model catalog transactionally synchronized and outbox event committed + content: + application/json: + schema: { $ref: "#/components/schemas/ModelSyncResult" } + "404": { description: Provider not found } + "502": { description: Upstream response failed validation } + /api/v1/admin/providers/credentials/rotate: + post: + operationId: rotateProviderCredentials + description: Re-encrypt every Provider credential not already using the active KEK version. + security: [{ bearerAuth: [] }] + responses: + "200": + description: All required credential updates and outbox events committed atomically + content: + application/json: + schema: { $ref: "#/components/schemas/CredentialRotationResult" } + /api/v1/portal/login: + post: + operationId: loginPortal + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/LoginRequest" } + responses: + "200": { description: Opaque Redis-backed portal session token } + "401": { description: Invalid credentials } + /api/v1/portal/sso/providers: + get: + operationId: listPublicSSOProviders + responses: + "200": { description: Enabled OIDC and SAML providers safe for login-page display } + /api/v1/portal/sso/{provider_code}/start: + get: + operationId: startSSOLogin + parameters: + - name: provider_code + in: path + required: true + schema: { type: string } + responses: + "302": { description: Redirect to the OIDC authorization endpoint or SAML Redirect-binding SSO endpoint } + /api/v1/portal/sso/{provider_code}/callback: + get: + operationId: completeOIDCLogin + parameters: + - name: provider_code + in: path + required: true + schema: { type: string } + responses: + "302": { description: Verified identity redirects with a one-minute single-use exchange code } + "401": { description: State or ID Token validation failed } + post: + operationId: completeSAMLLogin + parameters: + - name: provider_code + in: path + required: true + schema: { type: string } + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + required: [SAMLResponse, RelayState] + properties: + SAMLResponse: { type: string, format: byte } + RelayState: { type: string } + responses: + "302": { description: Signed assertion accepted and browser redirected with a single-use exchange code } + "401": { description: Signature, request correlation, assertion, or replay validation failed } + /api/v1/portal/sso/{provider_code}/metadata: + get: + operationId: getSAMLServiceProviderMetadata + parameters: + - name: provider_code + in: path + required: true + schema: { type: string } + responses: + "200": { description: SAML Service Provider metadata XML } + "404": { description: SAML provider not found } + /api/v1/portal/sso/exchange: + post: + operationId: exchangeOIDCCode + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [code] + properties: + code: { type: string } + responses: + "200": { description: Redis-backed portal session token } + "401": { description: Exchange code invalid, expired, or already consumed } + /api/v1/portal/me: + get: + operationId: getPortalIdentity + security: [{ bearerAuth: [] }] + responses: + "200": { description: Active portal identity } + /api/v1/portal/login/totp: + post: + operationId: completePortalTOTPLogin + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/TOTPLoginRequest" } + responses: + "200": { description: TOTP challenge completed and session issued } + "401": { description: Invalid, reused, or consumed factor } + /api/v1/portal/totp/status: + get: + operationId: getPortalTOTPStatus + security: [{ bearerAuth: [] }] + responses: + "200": { description: TOTP enrollment status } + /api/v1/portal/totp/setup: + post: + operationId: setupPortalTOTP + security: [{ bearerAuth: [] }] + responses: + "200": { description: New secret and otpauth provisioning URI } + /api/v1/portal/totp/confirm: + post: + operationId: confirmPortalTOTP + security: [{ bearerAuth: [] }] + responses: + "200": { description: Enrollment confirmed; backup codes returned once } + /api/v1/portal/totp/disable: + post: + operationId: disablePortalTOTP + security: [{ bearerAuth: [] }] + responses: + "200": { description: TOTP disabled after password and factor verification } + /api/v1/portal/totp/backup-codes/regenerate: + post: + operationId: regeneratePortalTOTPBackupCodes + security: [{ bearerAuth: [] }] + responses: + "200": { description: Previous backup codes revoked and replacements returned once } + /api/v1/portal/logout: + post: + operationId: logoutPortal + security: [{ bearerAuth: [] }] + responses: + "200": { description: Session revoked } + /api/v1/portal/menus: + get: + operationId: getPortalMenus + security: [{ bearerAuth: [] }] + responses: + "200": { description: Art Design Pro portal route tree } + /api/v1/portal/password: + post: + operationId: changePortalPassword + security: [{ bearerAuth: [] }] + responses: { "200": { description: Local password changed after current-password verification } } + /api/v1/portal/applications: + get: + operationId: listPortalApplications + security: [{ bearerAuth: [] }] + responses: { "200": { description: Published applications visible to the portal user's department } } + /api/v1/portal/catalog: + get: + operationId: getPortalCatalog + security: [{ bearerAuth: [] }] + responses: { "200": { description: Department-scoped application and AI asset catalog } } + /api/v1/portal/knowledge: + get: + operationId: listPortalKnowledge + security: [{ bearerAuth: [] }] + responses: { "200": { description: Enabled visible knowledge bases } } + /api/v1/portal/tools: + get: + operationId: listPortalTools + security: [{ bearerAuth: [] }] + responses: { "200": { description: Enabled visible tool metadata without endpoint or secret headers } } + /api/v1/portal/prompts: + get: + operationId: listPortalPrompts + security: [{ bearerAuth: [] }] + responses: { "200": { description: Enabled visible prompts with favorite state } } + /api/v1/portal/prompts/{prompt_id}: + get: + operationId: getPortalPrompt + security: [{ bearerAuth: [] }] + parameters: [{ name: prompt_id, in: path, required: true, schema: { type: string, format: uuid } }] + responses: { "200": { description: Visible prompt and current immutable content } } + /api/v1/portal/prompts/{prompt_id}/favorite: + post: + operationId: favoritePortalPrompt + security: [{ bearerAuth: [] }] + parameters: [{ name: prompt_id, in: path, required: true, schema: { type: string, format: uuid } }] + responses: { "200": { description: Prompt added to favorites idempotently } } + delete: + operationId: unfavoritePortalPrompt + security: [{ bearerAuth: [] }] + parameters: [{ name: prompt_id, in: path, required: true, schema: { type: string, format: uuid } }] + responses: { "200": { description: Prompt removed from favorites idempotently } } + /api/v1/portal/model-requests/available: + get: + operationId: listPortalAvailableModels + security: [{ bearerAuth: [] }] + responses: { "200": { description: Enabled provider model catalog } } + /api/v1/portal/model-requests: + get: + operationId: listPortalModelRequests + security: [{ bearerAuth: [] }] + responses: { "200": { description: Current user's model access requests } } + post: + operationId: createPortalModelRequest + security: [{ bearerAuth: [] }] + responses: { "200": { description: Pending model access request created with outbox event } } + /api/v1/portal/logs: + get: + operationId: listPortalAuditEvents + security: [{ bearerAuth: [] }] + responses: { "200": { description: Bounded audit events owned through portal API keys } } + /api/v1/portal/logs/{audit_event_id}: + get: + operationId: getPortalAuditEvent + security: [{ bearerAuth: [] }] + parameters: [{ name: audit_event_id, in: path, required: true, schema: { type: string, format: uuid } }] + responses: { "200": { description: Owned audit event with bounded previews } } + /api/v1/portal/stats: + get: + operationId: getPortalStats + security: [{ bearerAuth: [] }] + responses: { "200": { description: Personal requests, tokens, cost and latency aggregate } } + /api/v1/portal/cost: + get: + operationId: getPortalCost + security: [{ bearerAuth: [] }] + responses: { "200": { description: Personal month-to-date cost in integer microunits } } + /api/v1/portal/docs-info: + get: + operationId: getPortalIntegrationDocs + security: [{ bearerAuth: [] }] + responses: { "200": { description: Safe runtime protocol and authentication overview } } + /api/v1/portal/apps/{application_code}/chat: + post: + operationId: runPortalApplicationCompatibilityChat + security: [{ bearerAuth: [] }] + parameters: [{ name: application_code, in: path, required: true, schema: { type: string } }] + responses: { "200": { description: Single user message executed with a server-held encrypted application credential } } + /api/v1/portal/apps/{application_code}/conversations: + post: + operationId: createPortalApplicationConversation + security: [{ bearerAuth: [] }] + parameters: [{ name: application_code, in: path, required: true, schema: { type: string } }] + responses: { "200": { description: Server-managed conversation created } } + /api/v1/portal/apps/{application_code}/conversations/{conversation_id}: + get: + operationId: getPortalApplicationConversation + security: [{ bearerAuth: [] }] + parameters: + - { name: application_code, in: path, required: true, schema: { type: string } } + - { name: conversation_id, in: path, required: true, schema: { type: string, format: uuid } } + responses: { "200": { description: Ownership-checked conversation with verified immutable hash chain } } + /api/v1/portal/apps/{application_code}/conversations/{conversation_id}/messages: + post: + operationId: appendPortalApplicationConversationMessage + security: [{ bearerAuth: [] }] + parameters: + - { name: application_code, in: path, required: true, schema: { type: string } } + - { name: conversation_id, in: path, required: true, schema: { type: string, format: uuid } } + responses: { "200": { description: Leased serial message execution with verified server-side history } } + /api/v1/admin/models: + get: + operationId: listAdministrativeModels + security: [{ bearerAuth: [] }] + responses: { "200": { description: Enabled provider model catalog } } + /api/v1/admin/model-requests: + get: + operationId: listAdministrativeModelRequests + security: [{ bearerAuth: [] }] + responses: { "200": { description: Portal model access requests by status } } + /api/v1/admin/model-requests/{request_id}/approve: + post: + operationId: approveModelRequest + security: [{ bearerAuth: [] }] + parameters: [{ name: request_id, in: path, required: true, schema: { type: string, format: uuid } }] + responses: { "200": { description: Pending request approved transactionally } } + /api/v1/admin/model-requests/{request_id}/reject: + post: + operationId: rejectModelRequest + security: [{ bearerAuth: [] }] + parameters: [{ name: request_id, in: path, required: true, schema: { type: string, format: uuid } }] + responses: { "200": { description: Pending request rejected transactionally } } + /v1/models: + get: + operationId: listModels + security: [{ bearerAuth: [] }] + parameters: + - $ref: "#/components/parameters/GatewayProvider" + responses: + "200": { description: OpenAI-compatible model list } + "429": { description: Per-minute, monthly request, or monthly token quota exceeded } + "503": { description: Authentication, admission control, or provider configuration unavailable } + /v1/chat/completions: + post: + operationId: createChatCompletion + security: [{ bearerAuth: [] }] + parameters: + - $ref: "#/components/parameters/GatewayProvider" + - $ref: "#/components/parameters/IdempotencyKey" + requestBody: + required: true + content: + application/json: + schema: { type: object, additionalProperties: true } + responses: + "200": { description: JSON response or SSE stream } + "429": { description: Per-minute, monthly request, or monthly token quota exceeded } + "503": { description: Authentication, admission control, or provider configuration unavailable } + /v1/responses: + post: + operationId: createResponse + security: [{ bearerAuth: [] }] + parameters: + - $ref: "#/components/parameters/GatewayProvider" + - $ref: "#/components/parameters/IdempotencyKey" + requestBody: + required: true + content: + application/json: + schema: { type: object, additionalProperties: true } + responses: + "200": { description: JSON response or SSE stream } + "429": { description: Per-minute, monthly request, or monthly token quota exceeded } + "503": { description: Authentication, admission control, or provider configuration unavailable } + /v1/embeddings: + post: + operationId: createEmbedding + security: [{ bearerAuth: [] }] + parameters: + - $ref: "#/components/parameters/GatewayProvider" + - $ref: "#/components/parameters/IdempotencyKey" + requestBody: + required: true + content: + application/json: + schema: { type: object, additionalProperties: true } + responses: + "200": { description: OpenAI-compatible embedding response } + "429": { description: Per-minute, monthly request, or monthly token quota exceeded } + "503": { description: Authentication, admission control, or provider configuration unavailable } + /v1/messages: + post: + operationId: createAnthropicMessage + security: [{ bearerAuth: [] }] + parameters: + - $ref: "#/components/parameters/GatewayProvider" + - $ref: "#/components/parameters/IdempotencyKey" + requestBody: + required: true + content: + application/json: + schema: { type: object, additionalProperties: true } + responses: + "200": { description: Anthropic-compatible response or SSE stream } + "429": { description: Per-minute, monthly request, or monthly token quota exceeded } + "503": { description: Authentication, admission control, or provider configuration unavailable } +components: + parameters: + ModelRouteID: + name: model_route_id + in: path + required: true + schema: { type: string, format: uuid } + IdempotencyKey: + name: Idempotency-Key + in: header + required: false + description: Enables bounded retries for replayable POST requests. Reusing a key is the caller's idempotency contract. + schema: { type: string, minLength: 1, maxLength: 255 } + IdentityID: + name: identity_id + in: path + required: true + schema: { type: string, format: uuid } + ProviderID: + name: provider_id + in: path + required: true + schema: { type: string, format: uuid } + GatewayProvider: + name: X-Gateway-Provider + in: header + required: false + description: Enabled database Provider code. Omit to use the current default snapshot entry. + schema: { type: string } + securitySchemes: + bearerAuth: + type: http + scheme: bearer + apiKeyAuth: + type: http + scheme: bearer + description: Gateway API Key with gateway:invoke scope; X-Gateway-API-Key is also accepted. + schemas: + Health: + type: object + required: [status, version] + properties: + status: { type: string, const: ok } + version: { type: string } + LoginRequest: + type: object + required: [userName, password] + properties: + userName: { type: string, minLength: 1, maxLength: 128 } + password: { type: string, minLength: 1, maxLength: 1024 } + TOTPLoginRequest: + type: object + required: [temp_token] + properties: + temp_token: { type: string, minLength: 1 } + code: { type: string, pattern: "^[0-9]{6}$" } + backup_code: { type: string, pattern: "^[A-Za-z2-9]{4}-?[A-Za-z2-9]{4}$" } + anyOf: + - required: [code] + - required: [backup_code] + ProviderInput: + type: object + required: [code, adapter, base_url, capabilities, config, enabled] + properties: + code: + type: string + pattern: "^[a-z][a-z0-9_-]{1,63}$" + adapter: + type: string + enum: [openai-compatible] + base_url: { type: string, format: uri } + api_key: + type: string + description: Omit during update to retain the existing encrypted credential. + capabilities: + type: array + items: { type: string } + config: { type: object, additionalProperties: true } + enabled: { type: boolean } + IdentityInput: + type: object + required: [login, display_name, role, permissions, active] + properties: + login: { type: string, minLength: 2, maxLength: 128 } + display_name: { type: string, maxLength: 64 } + role: { type: string, enum: [superadmin, operator, auditor, member] } + password: + type: string + minLength: 12 + maxLength: 1024 + description: Required on create; omit on update to retain the current password. + permissions: + type: array + uniqueItems: true + items: + type: string + pattern: "^[a-z][a-z0-9_.:-]{2,127}$|^\\*$" + active: { type: boolean } + department_id: + type: [string, "null"] + format: uuid + description: Portal accounts may bind to an active department. + DepartmentInput: + type: object + required: [code, name, description, active] + properties: + code: { type: string, pattern: "^[a-z][a-z0-9_-]{1,63}$" } + name: { type: string, minLength: 1, maxLength: 128 } + description: { type: string, maxLength: 1024 } + parent_id: { type: [string, "null"], format: uuid } + active: { type: boolean } + OIDCProviderInput: + type: object + required: + [code, display_name, issuer_url, client_id, redirect_uri, portal_return_url, scopes, auto_provision, enabled] + properties: + code: { type: string, pattern: "^[a-z][a-z0-9_-]{1,63}$" } + display_name: { type: string, minLength: 1, maxLength: 128 } + issuer_url: { type: string, format: uri } + client_id: { type: string, minLength: 1 } + client_secret: + type: string + description: Required on create and omitted on update to retain the encrypted value. + redirect_uri: { type: string, format: uri } + portal_return_url: { type: string, format: uri } + scopes: + type: array + contains: { const: openid } + items: { type: string } + auto_provision: { type: boolean } + default_department_id: { type: [string, "null"], format: uuid } + enabled: { type: boolean } + SAMLProviderInput: + type: object + required: + [code, display_name, metadata_url, sp_entity_id, acs_url, portal_return_url, email_attribute, name_attribute, auto_provision, enabled] + properties: + code: { type: string, pattern: "^[a-z][a-z0-9_-]{1,63}$" } + display_name: { type: string, minLength: 1, maxLength: 128 } + metadata_url: { type: string, format: uri } + sp_entity_id: + type: string + minLength: 1 + maxLength: 512 + description: Absolute URI used as the SAML audience. + acs_url: + type: string + format: uri + description: Must end with /api/v1/portal/sso/{code}/callback. + portal_return_url: { type: string, format: uri } + email_attribute: { type: string, maxLength: 256 } + name_attribute: { type: string, maxLength: 256 } + auto_provision: { type: boolean } + default_department_id: { type: [string, "null"], format: uuid } + enabled: { type: boolean } + ProviderConnectionResult: + type: object + required: [connected, latency_ms, message] + properties: + connected: { type: boolean } + status_code: { type: integer } + latency_ms: { type: integer, format: int64, minimum: 0 } + message: { type: string } + ProviderModel: + type: object + required: + [id, provider_id, provider_model_id, metadata, enabled, discovered_at, last_seen_at] + properties: + id: { type: string, format: uuid } + provider_id: { type: string, format: uuid } + provider_model_id: { type: string, minLength: 1, maxLength: 512 } + owned_by: { type: string } + metadata: { type: object, additionalProperties: true } + enabled: { type: boolean } + discovered_at: { type: string, format: date-time } + last_seen_at: { type: string, format: date-time } + ModelSyncResult: + type: object + required: [discovered, active, disabled, synced_at] + properties: + discovered: { type: integer, minimum: 0 } + active: { type: integer, minimum: 0 } + disabled: { type: integer, minimum: 0 } + synced_at: { type: string, format: date-time } + CredentialRotationResult: + type: object + required: [active_version, loaded_versions, rotated, skipped] + properties: + active_version: { type: integer, minimum: 1 } + loaded_versions: + type: array + items: { type: integer, minimum: 1 } + rotated: { type: integer, minimum: 0 } + skipped: { type: integer, minimum: 0 } + APIKeyInput: + type: object + required: [name, scopes, requests_per_minute, monthly_request_quota, monthly_token_quota] + properties: + name: { type: string, minLength: 1, maxLength: 128 } + scopes: + type: array + minItems: 1 + items: { type: string, enum: [gateway:invoke, "*"] } + requests_per_minute: + type: integer + minimum: 0 + maximum: 1000000 + description: Per-API-key UTC minute request limit; zero means unlimited. + monthly_request_quota: + type: integer + format: int64 + minimum: 0 + maximum: 1000000000000 + description: Accepted requests per UTC calendar month; zero means unlimited. + monthly_token_quota: + type: integer + format: int64 + minimum: 0 + maximum: 1000000000000000 + description: Input plus output tokens per UTC calendar month; zero means unlimited. + expires_at: { type: [string, "null"], format: date-time } + APIKeyLimitsInput: + type: object + required: [requests_per_minute, monthly_request_quota, monthly_token_quota] + properties: + requests_per_minute: { type: integer, minimum: 0, maximum: 1000000 } + monthly_request_quota: { type: integer, format: int64, minimum: 0, maximum: 1000000000000 } + monthly_token_quota: { type: integer, format: int64, minimum: 0, maximum: 1000000000000000 } + ModelRouteInput: + type: object + required: [name, source_model, target_model, provider_id, weight, priority, conditions, enabled] + properties: + name: { type: string, minLength: 1, maxLength: 128 } + source_model: { type: string, minLength: 1, maxLength: 512 } + target_model: { type: string, minLength: 1, maxLength: 512 } + provider_id: { type: string, format: uuid } + weight: { type: integer, minimum: 1, maximum: 10000 } + priority: { type: integer, minimum: -100000, maximum: 100000 } + enabled: { type: boolean } + conditions: + type: object + additionalProperties: true + properties: + endpoints: + type: array + items: { type: string, enum: [/v1/chat/completions, /v1/responses, /v1/embeddings, /v1/messages] } + api_key_ids: + type: array + items: { type: string, format: uuid } + tenant_ids: + type: array + items: { type: string, format: uuid } + ContentPolicyInput: + type: object + required: [name, description, action, priority, paths, models, api_key_ids, rules, enabled] + properties: + name: { type: string, minLength: 1, maxLength: 128 } + description: { type: string, maxLength: 1000 } + action: { type: string, enum: [audit, block, redact] } + priority: { type: integer, minimum: -100000, maximum: 100000 } + paths: { type: array, items: { type: string } } + models: { type: array, items: { type: string } } + api_key_ids: { type: array, items: { type: string, format: uuid } } + enabled: { type: boolean } + rules: + type: array + minItems: 1 + maxItems: 20 + items: + type: object + required: [name, pattern, replacement] + properties: + name: { type: string, minLength: 1 } + pattern: { type: string, minLength: 1, maxLength: 512, description: Go RE2 expression } + replacement: { type: string } + ModelPriceInput: + type: object + required: [provider_code, model_pattern, input_microunits_per_million, output_microunits_per_million, currency, effective_from, enabled] + properties: + provider_code: { type: string, pattern: "^[a-z][a-z0-9_-]{1,63}$" } + model_pattern: { type: string, description: Exact model name or a prefix ending in one asterisk } + input_microunits_per_million: { type: integer, format: int64, minimum: 0 } + output_microunits_per_million: { type: integer, format: int64, minimum: 0 } + currency: { type: string, minLength: 3, maxLength: 3, default: USD } + effective_from: { type: string, format: date-time } + effective_to: { type: [string, "null"], format: date-time } + enabled: { type: boolean } + PromptVariable: + type: object + required: [name, required] + properties: + name: { type: string, pattern: "^[A-Za-z_][A-Za-z0-9_]*$" } + label: { type: string, maxLength: 128 } + default: { type: string, maxLength: 10000 } + required: { type: boolean } + PromptInput: + type: object + required: [name, description, tags, department_ids, enabled] + properties: + name: { type: string, minLength: 1, maxLength: 128 } + description: { type: string, maxLength: 4000 } + category_id: { type: [string, "null"], format: uuid } + tags: { type: array, maxItems: 30, items: { type: string } } + department_ids: { type: array, maxItems: 100, items: { type: string, format: uuid } } + enabled: { type: boolean } + content: { type: string, maxLength: 100000, description: Required only when creating the initial version. } + variables: { type: array, maxItems: 100, items: { $ref: "#/components/schemas/PromptVariable" } } + change_note: { type: string } + KnowledgeBaseInput: + type: object + required: [name, description, retrieval_mode, chunk_size, chunk_overlap, department_ids, enabled] + properties: + name: { type: string, minLength: 1, maxLength: 128 } + description: { type: string, maxLength: 4000 } + retrieval_mode: { type: string, enum: [postgres_fts] } + chunk_size: { type: integer, minimum: 200, maximum: 8000 } + chunk_overlap: { type: integer, minimum: 0 } + department_ids: { type: array, maxItems: 100, items: { type: string, format: uuid } } + enabled: { type: boolean } + ToolInput: + type: object + required: [code, name, description, endpoint_url, http_method, input_schema, timeout_seconds, department_ids, enabled] + properties: + code: { type: string, pattern: "^[a-z][a-z0-9_-]{1,63}$" } + name: { type: string, minLength: 1, maxLength: 128 } + description: { type: string, maxLength: 4000 } + endpoint_url: { type: string, format: uri } + http_method: { type: string, enum: [GET, POST, PUT, PATCH, DELETE] } + headers: + type: object + writeOnly: true + additionalProperties: { type: string } + description: Omit during update to preserve the existing KEK-encrypted headers. + input_schema: { type: object, additionalProperties: true } + timeout_seconds: { type: integer, minimum: 1, maximum: 120 } + department_ids: { type: array, maxItems: 100, items: { type: string, format: uuid } } + enabled: { type: boolean } + ApplicationConfig: + type: object + required: [model, knowledge_base_ids, tool_ids, retrieval_top_k, temperature, max_tool_rounds] + properties: + model: { type: string, maxLength: 255 } + prompt_template_id: { type: string, format: uuid } + knowledge_base_ids: { type: array, maxItems: 20, items: { type: string, format: uuid } } + tool_ids: { type: array, maxItems: 20, items: { type: string, format: uuid } } + retrieval_top_k: { type: integer, minimum: 1, maximum: 20 } + temperature: { type: number, minimum: 0, maximum: 2 } + max_tool_rounds: { type: integer, minimum: 0, maximum: 8 } + ApplicationInput: + type: object + required: [code, name, description, status, department_ids, draft_config] + properties: + code: { type: string, pattern: "^[a-z][a-z0-9_-]{2,63}$" } + name: { type: string, minLength: 1, maxLength: 128 } + description: { type: string, maxLength: 4000 } + status: { type: string, enum: [draft, active, suspended, retired] } + department_ids: { type: array, maxItems: 100, items: { type: string, format: uuid } } + draft_config: { $ref: "#/components/schemas/ApplicationConfig" } + NotificationChannelInput: + type: object + required: [name, webhook_url, event_patterns, enabled] + properties: + name: { type: string, minLength: 1, maxLength: 128 } + webhook_url: { type: string, format: uri } + signing_secret: + type: string + writeOnly: true + maxLength: 4096 + description: Omit during update to preserve the existing KEK-encrypted signing secret. + event_patterns: + type: array + minItems: 1 + maxItems: 100 + items: { type: string, description: Exact event type or a prefix ending in one asterisk. } + enabled: { type: boolean } diff --git a/cmd/gateway-api/main.go b/cmd/gateway-api/main.go new file mode 100644 index 0000000..333b19d --- /dev/null +++ b/cmd/gateway-api/main.go @@ -0,0 +1,383 @@ +package main + +import ( + "context" + "errors" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "aigateway.local/core/internal/apikey" + "aigateway.local/core/internal/audit" + "aigateway.local/core/internal/contentpolicy" + "aigateway.local/core/internal/factcheck" + "aigateway.local/core/internal/gateway" + "aigateway.local/core/internal/identity" + "aigateway.local/core/internal/operations" + "aigateway.local/core/internal/outbox" + "aigateway.local/core/internal/platform/cache" + "aigateway.local/core/internal/platform/config" + "aigateway.local/core/internal/platform/cryptox" + "aigateway.local/core/internal/platform/database" + "aigateway.local/core/internal/platform/health" + "aigateway.local/core/internal/platform/httpserver" + "aigateway.local/core/internal/portal" + "aigateway.local/core/internal/pricing" + "aigateway.local/core/internal/provider" + providercontrolplane "aigateway.local/core/internal/provider/controlplane" + provideropenai "aigateway.local/core/internal/provider/openai" + providerruntime "aigateway.local/core/internal/provider/runtime" + "aigateway.local/core/internal/shadow" + "aigateway.local/core/internal/workbench" +) + +var version = "dev" + +func main() { + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) + cfg, err := config.Load() + if err != nil { + logger.Error("invalid configuration", "error", err) + os.Exit(1) + } + if err := cfg.ValidateRuntime(); err != nil { + logger.Error("invalid runtime configuration", "error", err) + os.Exit(1) + } + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + db, err := database.Open(ctx, cfg.Database) + if err != nil { + logger.Error("database initialization failed", "error", err) + os.Exit(1) + } + if db != nil { + defer db.Close() + } + criticalRedis, err := cache.Open(cfg.Redis.CriticalURL) + if err != nil { + logger.Error("critical redis initialization failed", "error", err) + os.Exit(1) + } + if criticalRedis != nil { + defer criticalRedis.Close() + } + cacheRedis, err := cache.Open(cfg.Redis.CacheURL) + if err != nil { + logger.Error("cache redis initialization failed", "error", err) + os.Exit(1) + } + if cacheRedis != nil { + defer cacheRedis.Close() + } + + checker := health.Checker{Timeout: 1500 * time.Millisecond} + checker.Dependencies = append(checker.Dependencies, + health.Dependency{Name: "postgres", Required: true, Probe: func(probeCtx context.Context) error { + if db == nil { + return errors.New("not configured") + } + return db.Ping(probeCtx) + }}, + health.Dependency{Name: "redis_critical", Required: true, Probe: func(probeCtx context.Context) error { + if criticalRedis == nil { + return errors.New("not configured") + } + return criticalRedis.Ping(probeCtx).Err() + }}, + health.Dependency{Name: "redis_cache", Required: false, Probe: func(probeCtx context.Context) error { + if cacheRedis == nil { + return errors.New("not configured") + } + return cacheRedis.Ping(probeCtx).Err() + }}, + ) + + adapter, err := provideropenai.New(cfg.Upstream.BaseURL, cfg.Upstream.APIKey) + if err != nil { + logger.Error("provider initialization failed", "error", err) + os.Exit(1) + } + var fallbackAdapter provider.Adapter + if cfg.Upstream.FallbackEnabled { + fallbackAdapter = adapter + } + credentialCipher, err := provider.NewCredentialCipher( + cfg.Credentials.MasterKey, cfg.Credentials.KEKVersion, cfg.Credentials.KEKKeyring, + ) + if err != nil { + logger.Error("credential encryption initialization failed", "error", err) + os.Exit(1) + } + providerRepository := provider.NewRepository(db) + providerResolver := providerruntime.NewResolver( + providerRepository, credentialCipher, fallbackAdapter, cfg.Credentials.ProviderRefreshInterval, logger, + ) + providerResolver.SetNotificationClient(criticalRedis) + go providerResolver.Run(ctx) + apiKeyRepository := apikey.NewRepository(db) + bootstrapAPIKey := "" + if cfg.Security.BootstrapAPIKeyEnabled { + bootstrapAPIKey = cfg.Security.BootstrapAPIKey + logger.Warn("bootstrap API key compatibility is enabled; monitor usage and disable after migration") + } + apiKeyAuthenticator := apikey.NewAuthenticator(apiKeyRepository, criticalRedis, bootstrapAPIKey) + apiKeyAuthenticator.SetLogger(logger) + proxy := gateway.NewDynamicProxy(providerResolver, apiKeyAuthenticator, cfg.Server.MaxBodyBytes, logger) + proxy.SetAdmissionController(gateway.NewRedisAdmissionController(criticalRedis)) + proxy.SetTokenQuotaController(gateway.NewRedisTokenQuotaController(criticalRedis)) + proxy.SetResiliencePolicy(gateway.ResiliencePolicy{ + ResponseHeaderTimeout: cfg.Upstream.ResponseHeaderTimeout, MaxRetries: cfg.Upstream.MaxRetries, + RetryBackoff: cfg.Upstream.RetryBackoff, CircuitThreshold: cfg.Upstream.CircuitThreshold, + CircuitOpenDuration: cfg.Upstream.CircuitOpenDuration, + }) + auditRecorder := audit.NewRecorder(db, logger, cfg.Audit.QueueSize, cfg.Audit.BatchSize, cfg.Audit.FlushInterval) + auditContext, stopAudit := context.WithCancel(context.Background()) + auditStopped := make(chan struct{}) + go func() { + auditRecorder.Run(auditContext) + close(auditStopped) + }() + proxy.SetAuditRecorder(auditRecorder) + contentPolicyEngine := contentpolicy.NewEngine(db, cfg.RuntimeData.ContentPolicyRefreshInterval, logger) + pricingService := pricing.NewService(db, cfg.RuntimeData.PricingRefreshInterval, logger) + if err := contentPolicyEngine.Reload(ctx); err != nil { + logger.Error("content policy initialization failed", "error", err) + os.Exit(1) + } + if err := pricingService.Reload(ctx); err != nil { + logger.Error("model pricing initialization failed", "error", err) + os.Exit(1) + } + go contentPolicyEngine.Run(ctx) + go pricingService.Run(ctx) + proxy.SetContentPolicyEngine(contentPolicyEngine) + proxy.SetPricingService(pricingService) + identityRepository := identity.NewRepository(db) + sessionStore := identity.NewSessionStore(criticalRedis, cfg.Auth.SessionTTL) + loginLimiter := identity.NewLoginLimiter(criticalRedis, cfg.Auth.LoginRateLimitMax, cfg.Auth.LoginRateLimitWindow) + totpCipher, err := cryptox.NewKeyring( + cfg.Credentials.MasterKey, cfg.Credentials.KEKVersion, cfg.Credentials.KEKKeyring, "totp-secret", + ) + if err != nil { + logger.Error("TOTP encryption initialization failed", "error", err) + os.Exit(1) + } + identityService := identity.NewService(identityRepository, sessionStore, loginLimiter, cfg.Auth, totpCipher) + idpCipher, err := cryptox.NewKeyring( + cfg.Credentials.MasterKey, cfg.Credentials.KEKVersion, cfg.Credentials.KEKKeyring, "identity-provider-credentials", + ) + if err != nil { + logger.Error("identity provider encryption initialization failed", "error", err) + os.Exit(1) + } + identityService.SetIdentityProviderCipher(idpCipher, cfg.Credentials.AllowPrivateProviderURL) + identityHandler := identity.NewHTTPHandler(identityService) + identityManagementHandler := identity.NewManagementHTTPHandler(identityService) + providerHandler := provider.NewAdminHTTPHandler( + providerRepository, credentialCipher, identityService, cfg.Credentials.AllowPrivateProviderURL, + ) + providerOperations := providercontrolplane.NewService( + providerRepository, credentialCipher, cfg.Credentials.AllowPrivateProviderURL, + ) + providerHandler.SetOperations(providerOperations) + providerHandler.SetChangeHook(func(changeCtx context.Context) error { + reloadErr := providerResolver.Reload(changeCtx) + notifyErr := providerResolver.Notify(changeCtx) + if reloadErr != nil || notifyErr != nil { + logger.Warn("provider change propagation was incomplete", "reload_error", reloadErr, "notify_error", notifyErr) + } + return errors.Join(reloadErr, notifyErr) + }) + apiKeyHandler := apikey.NewAdminHTTPHandler(apiKeyRepository, apiKeyAuthenticator, identityService) + apiKeyHandler.SetUsageStore(apikey.NewUsageStore(criticalRedis)) + auditHandler := audit.NewAdminHTTPHandler(audit.NewQueryService(db), identityService) + outboxHandler := outbox.NewAdminHTTPHandler(outbox.NewStore(db), identityService) + contentPolicyHandler := contentpolicy.NewAdminHTTPHandler(contentpolicy.NewStore(db), contentPolicyEngine, identityService) + pricingHandler := pricing.NewAdminHTTPHandler(pricingService, identityService) + factCheckHandler := factcheck.NewAdminHTTPHandler(factcheck.NewService(db), identityService) + workbenchService := workbench.NewService(db) + toolCipher, err := cryptox.NewKeyring( + cfg.Credentials.MasterKey, cfg.Credentials.KEKVersion, cfg.Credentials.KEKKeyring, "tool-request-headers", + ) + if err != nil { + logger.Error("tool credential encryption initialization failed", "error", err) + os.Exit(1) + } + notificationCipher, err := cryptox.NewKeyring( + cfg.Credentials.MasterKey, cfg.Credentials.KEKVersion, cfg.Credentials.KEKKeyring, "notification-signing-secret", + ) + if err != nil { + logger.Error("notification encryption initialization failed", "error", err) + os.Exit(1) + } + toolService := workbench.NewToolService(workbenchService, toolCipher, cfg.Credentials.AllowPrivateToolURL) + notificationService := workbench.NewNotificationService(workbenchService, notificationCipher, cfg.Credentials.AllowPrivateWebhookURL) + workbenchHandler := workbench.NewAdminHTTPHandler(workbenchService, toolService, notificationService, identityService) + mcpServerCipher, err := cryptox.NewKeyring( + cfg.Credentials.MasterKey, cfg.Credentials.KEKVersion, cfg.Credentials.KEKKeyring, "mcp-server-headers", + ) + if err != nil { + logger.Error("MCP server credential encryption initialization failed", "error", err) + os.Exit(1) + } + mcpServerService := workbench.NewMCPServerService(workbenchService, mcpServerCipher, cfg.Credentials.AllowPrivateToolURL) + skillService := workbench.NewSkillService(workbenchService) + digitalEmployeeService := workbench.NewDigitalEmployeeService(workbenchService, skillService, toolService, mcpServerService) + marketplaceService := workbench.NewMarketplaceService(workbenchService, mcpServerService, skillService, digitalEmployeeService) + mcpClient := workbench.NewMCPClient(cfg.Credentials.AllowPrivateToolURL, 60*time.Second) + marketplaceHandler := workbench.NewMarketplaceAdminHTTPHandler(marketplaceService, mcpServerService, skillService, digitalEmployeeService, mcpClient, identityService) + applicationKeyCipher, err := cryptox.NewKeyring( + cfg.Credentials.MasterKey, cfg.Credentials.KEKVersion, cfg.Credentials.KEKKeyring, "application-runtime-key", + ) + if err != nil { + logger.Error("application runtime credential initialization failed", "error", err) + os.Exit(1) + } + shadowMiddleware := shadow.New(cfg.Shadow, logger) + governedGateway := shadowMiddleware.Wrap(proxy) + workbenchRuntime := workbench.NewRuntimeHTTPHandler(workbenchService, toolService, workbench.NewPostgreSQLRetriever(workbenchService), apiKeyAuthenticator, governedGateway, workbench.MarketplaceDeps{ + MCPServers: mcpServerService, + Skills: skillService, + Employees: digitalEmployeeService, + Market: marketplaceService, + MCPClient: mcpClient, + }) + workbenchRuntime.SetLogger(logger) + // Wire the fact-check engine: the admin fact-check settings/policies UI now + // actually governs application answers instead of being inert configuration. + factCheckEngine := factcheck.NewEngine(db, workbench.NewFactCheckRetriever(workbench.NewPostgreSQLRetriever(workbenchService)), logger) + workbenchRuntime.SetFactCheckEngine(factCheckEngine) + portalService := portal.NewService(db, workbenchService, toolService, identityService) + portalService.SetApplicationRuntime(portal.NewRuntimeCredentials(db, apiKeyRepository, applicationKeyCipher), workbenchRuntime) + portalService.SetMarketplace(marketplaceService) + portalHandler := portal.NewHTTPHandler(portalService, identityService) + portalAdminHandler := portal.NewAdminHTTPHandler(portalService, identityService) + startedAt := time.Now() + operationsHandler := operations.NewAdminHTTPHandler(db, identityService, version, startedAt, func(reloadCtx context.Context) error { + return errors.Join(providerResolver.Reload(reloadCtx), contentPolicyEngine.Reload(reloadCtx), pricingService.Reload(reloadCtx)) + }) + controlMux := http.NewServeMux() + controlMux.Handle("/api/v1/admin/providers", providerHandler) + controlMux.Handle("/api/v1/admin/providers/", providerHandler) + controlMux.Handle("/api/v1/admin/model-routes", providerHandler) + controlMux.Handle("/api/v1/admin/model-routes/", providerHandler) + controlMux.Handle("/api/v1/admin/api-keys", apiKeyHandler) + controlMux.Handle("/api/v1/admin/api-keys/", apiKeyHandler) + controlMux.Handle("/api/v1/admin/audit-events", auditHandler) + controlMux.Handle("/api/v1/admin/usage/", auditHandler) + controlMux.Handle("/api/v1/admin/outbox-events", outboxHandler) + controlMux.Handle("/api/v1/admin/outbox-events/", outboxHandler) + controlMux.Handle("/api/v1/admin/content-policies", contentPolicyHandler) + controlMux.Handle("/api/v1/admin/content-policies/", contentPolicyHandler) + controlMux.Handle("/api/v1/admin/model-prices", pricingHandler) + controlMux.Handle("/api/v1/admin/model-prices/", pricingHandler) + controlMux.Handle("/api/v1/admin/fact-check/", factCheckHandler) + controlMux.Handle("/api/v1/admin/prompt-categories", workbenchHandler) + controlMux.Handle("/api/v1/admin/prompt-categories/", workbenchHandler) + controlMux.Handle("/api/v1/admin/prompts", workbenchHandler) + controlMux.Handle("/api/v1/admin/prompts/", workbenchHandler) + controlMux.Handle("/api/v1/admin/knowledge-bases", workbenchHandler) + controlMux.Handle("/api/v1/admin/knowledge-bases/", workbenchHandler) + controlMux.Handle("/api/v1/admin/tools", workbenchHandler) + controlMux.Handle("/api/v1/admin/tools/", workbenchHandler) + controlMux.Handle("/api/v1/admin/applications", workbenchHandler) + controlMux.Handle("/api/v1/admin/applications/", workbenchHandler) + controlMux.Handle("/api/v1/admin/marketplace-categories", marketplaceHandler) + controlMux.Handle("/api/v1/admin/marketplace-categories/", marketplaceHandler) + controlMux.Handle("/api/v1/admin/mcp-servers", marketplaceHandler) + controlMux.Handle("/api/v1/admin/mcp-servers/", marketplaceHandler) + controlMux.Handle("/api/v1/admin/skills", marketplaceHandler) + controlMux.Handle("/api/v1/admin/skills/", marketplaceHandler) + controlMux.Handle("/api/v1/admin/digital-employees", marketplaceHandler) + controlMux.Handle("/api/v1/admin/digital-employees/", marketplaceHandler) + controlMux.Handle("/api/v1/admin/marketplace/", marketplaceHandler) + controlMux.Handle("/api/v1/portal/marketplace", portalHandler) + controlMux.Handle("/api/v1/portal/marketplace/", portalHandler) + controlMux.Handle("/api/v1/admin/notification-channels", workbenchHandler) + controlMux.Handle("/api/v1/admin/notification-channels/", workbenchHandler) + controlMux.Handle("/api/v1/admin/notification-deliveries", workbenchHandler) + controlMux.Handle("/api/v1/admin/notification-deliveries/", workbenchHandler) + controlMux.Handle("/api/v1/admin/models", portalAdminHandler) + controlMux.Handle("/api/v1/admin/model-requests", portalAdminHandler) + controlMux.Handle("/api/v1/admin/model-requests/", portalAdminHandler) + controlMux.Handle("/api/v1/admin/system-info", operationsHandler) + controlMux.Handle("/api/v1/admin/monitoring/overview", operationsHandler) + controlMux.Handle("/api/v1/admin/reload", operationsHandler) + controlMux.Handle("/api/v1/admin/identities/", identityManagementHandler) + controlMux.Handle("/api/v1/admin/departments", identityManagementHandler) + controlMux.Handle("/api/v1/admin/departments/", identityManagementHandler) + controlMux.Handle("/api/v1/admin/identity-providers", identityManagementHandler) + controlMux.Handle("/api/v1/admin/identity-providers/", identityManagementHandler) + controlMux.Handle("/api/v1/admin/saml-providers", identityManagementHandler) + controlMux.Handle("/api/v1/admin/saml-providers/", identityManagementHandler) + controlMux.Handle("/api/v1/portal/applications", portalHandler) + controlMux.Handle("/api/v1/portal/apps/", portalHandler) + controlMux.Handle("/api/v1/portal/catalog", portalHandler) + controlMux.Handle("/api/v1/portal/cost", portalHandler) + controlMux.Handle("/api/v1/portal/docs-info", portalHandler) + controlMux.Handle("/api/v1/portal/knowledge", portalHandler) + controlMux.Handle("/api/v1/portal/logs", portalHandler) + controlMux.Handle("/api/v1/portal/logs/", portalHandler) + controlMux.Handle("/api/v1/portal/model-requests", portalHandler) + controlMux.Handle("/api/v1/portal/model-requests/", portalHandler) + controlMux.Handle("/api/v1/portal/password", portalHandler) + controlMux.Handle("/api/v1/portal/prompts", portalHandler) + controlMux.Handle("/api/v1/portal/prompts/", portalHandler) + controlMux.Handle("/api/v1/portal/stats", portalHandler) + controlMux.Handle("/api/v1/portal/tools", portalHandler) + controlMux.Handle("/api/v1/", identityHandler) + publicMux := http.NewServeMux() + publicMux.Handle("/v1/prompts", workbenchRuntime) + publicMux.Handle("/v1/prompts/", workbenchRuntime) + publicMux.Handle("/v1/knowledge/", workbenchRuntime) + publicMux.Handle("/v1/tools", workbenchRuntime) + publicMux.Handle("/v1/tools/", workbenchRuntime) + publicMux.Handle("/v1/applications/", workbenchRuntime) + publicMux.Handle("/v1/skills/", workbenchRuntime) + publicMux.Handle("/v1/mcp-servers", workbenchRuntime) + publicMux.Handle("/v1/mcp-servers/", workbenchRuntime) + publicMux.Handle("/v1/digital-employees/", workbenchRuntime) + publicMux.Handle("/v1/", governedGateway) + server := httpserver.New(httpserver.Dependencies{ + Config: cfg, Logger: logger, Checker: checker, Gateway: publicMux, Control: controlMux, + Version: version, StartedAt: startedAt, + BootstrapUses: apiKeyAuthenticator.BootstrapUses, + ExtraMetrics: func() string { return auditRecorder.Prometheus() + shadowMiddleware.Prometheus() }, + }) + + serverErrors := make(chan error, 1) + go func() { + logger.Info("gateway API started", "address", server.Addr, "version", version) + serverErrors <- server.ListenAndServe() + }() + + select { + case <-ctx.Done(): + logger.Info("shutdown requested") + case err := <-serverErrors: + if !errors.Is(err, http.ErrServerClosed) { + logger.Error("gateway API stopped unexpectedly", "error", err) + os.Exit(1) + } + } + + shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.Server.ShutdownTimeout) + defer cancel() + if err := server.Shutdown(shutdownCtx); err != nil { + logger.Error("graceful shutdown failed", "error", err) + _ = server.Close() + os.Exit(1) + } + stopAudit() + select { + case <-auditStopped: + case <-shutdownCtx.Done(): + logger.Warn("audit recorder drain timed out") + } + logger.Info("gateway API stopped") +} diff --git a/cmd/gateway-bootstrap/main.go b/cmd/gateway-bootstrap/main.go new file mode 100644 index 0000000..d8922d6 --- /dev/null +++ b/cmd/gateway-bootstrap/main.go @@ -0,0 +1,84 @@ +package main + +import ( + "context" + "flag" + "log/slog" + "os" + "strings" + + "aigateway.local/core/internal/identity" + "aigateway.local/core/internal/platform/config" + "aigateway.local/core/internal/platform/database" +) + +func main() { + adminUsername := flag.String("admin-username", env("BOOTSTRAP_ADMIN_USERNAME", "admin"), "initial administrator username") + adminDisplayName := flag.String("admin-display-name", "系统管理员", "initial administrator display name") + portalAccount := flag.String("portal-account", strings.TrimSpace(os.Getenv("BOOTSTRAP_PORTAL_ACCOUNT")), "optional initial portal account") + portalName := flag.String("portal-name", "初始用户", "initial portal user display name") + flag.Parse() + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + + cfg, err := config.Load() + if err != nil { + logger.Error("invalid configuration", "error", err) + os.Exit(1) + } + if cfg.Database.URL == "" { + logger.Error("DATABASE_URL is required") + os.Exit(1) + } + adminPassword := os.Getenv("BOOTSTRAP_ADMIN_PASSWORD") + portalPassword := os.Getenv("BOOTSTRAP_PORTAL_PASSWORD") + if len(adminPassword) < 12 { + logger.Error("BOOTSTRAP_ADMIN_PASSWORD must contain at least 12 characters") + os.Exit(1) + } + if *portalAccount != "" && len(portalPassword) < 12 { + logger.Error("BOOTSTRAP_PORTAL_PASSWORD must contain at least 12 characters when creating a portal user") + os.Exit(1) + } + + ctx := context.Background() + pool, err := database.Open(ctx, cfg.Database) + if err != nil { + logger.Error("database initialization failed", "error", err) + os.Exit(1) + } + defer pool.Close() + repository := identity.NewRepository(pool) + hasher := identity.PasswordHasher{} + adminHash, err := hasher.Hash(adminPassword) + if err != nil { + logger.Error("password hashing failed", "error", err) + os.Exit(1) + } + adminID, err := repository.CreateAdmin(ctx, *adminUsername, *adminDisplayName, "superadmin", adminHash) + if err != nil { + logger.Error("administrator bootstrap failed", "error", err) + os.Exit(1) + } + logger.Info("administrator created", "id", adminID, "username", strings.ToLower(strings.TrimSpace(*adminUsername))) + + if *portalAccount != "" { + portalHash, err := hasher.Hash(portalPassword) + if err != nil { + logger.Error("portal password hashing failed", "error", err) + os.Exit(1) + } + portalID, err := repository.CreatePortalUser(ctx, *portalAccount, *portalName, portalHash) + if err != nil { + logger.Error("portal user bootstrap failed", "error", err) + os.Exit(1) + } + logger.Info("portal user created", "id", portalID, "account", strings.ToLower(strings.TrimSpace(*portalAccount))) + } +} + +func env(key, fallback string) string { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + return fallback +} diff --git a/cmd/gateway-legacy-import/main.go b/cmd/gateway-legacy-import/main.go new file mode 100644 index 0000000..1d63686 --- /dev/null +++ b/cmd/gateway-legacy-import/main.go @@ -0,0 +1,178 @@ +package main + +import ( + "bufio" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "strings" + + "aigateway.local/core/internal/platform/config" + "aigateway.local/core/internal/platform/database" + platformid "aigateway.local/core/internal/platform/id" + "aigateway.local/core/internal/platform/legacyid" + "github.com/jackc/pgx/v5/pgxpool" +) + +type record struct { + SourceSystem string `json:"source_system"` + EntityType string `json:"entity_type"` + LegacyID string `json:"legacy_id"` + Data json.RawMessage `json:"data"` + Checksum string `json:"checksum"` +} +type staged struct { + record + NewID string +} + +func main() { + input := flag.String("input", "-", "JSONL file from export_legacy_data.py, or - for stdin") + dryRun := flag.Bool("dry-run", false, "validate without database writes") + flag.Parse() + reader, closeInput, err := openInput(*input) + if err != nil { + fatal(err) + } + defer closeInput() + records, sourceChecksum, counts, err := readRecords(reader) + if err != nil { + fatal(err) + } + summary := map[string]any{"records": len(records), "entities": counts, "source_checksum": sourceChecksum, "dry_run": *dryRun} + if *dryRun { + write(summary) + return + } + cfg, err := config.Load() + if err != nil { + fatal(err) + } + if cfg.Database.URL == "" { + fatal(errors.New("DATABASE_URL is required")) + } + ctx := context.Background() + pool, err := database.Open(ctx, cfg.Database) + if err != nil { + fatal(err) + } + defer pool.Close() + batchID, err := stage(ctx, pool, records, sourceChecksum, counts) + if err != nil { + fatal(err) + } + summary["batch_id"] = batchID + summary["status"] = "staged" + write(summary) +} + +func openInput(path string) (io.Reader, func(), error) { + if path == "-" { + return os.Stdin, func() {}, nil + } + file, err := os.Open(path) + if err != nil { + return nil, func() {}, err + } + return file, func() { _ = file.Close() }, nil +} +func readRecords(reader io.Reader) ([]staged, string, map[string]int, error) { + scanner := bufio.NewScanner(reader) + scanner.Buffer(make([]byte, 64<<10), 32<<20) + aggregate := sha256.New() + items := []staged{} + counts := map[string]int{} + source := "" + line := 0 + for scanner.Scan() { + line++ + raw := bytesTrimSpace(scanner.Bytes()) + if len(raw) == 0 { + continue + } + _, _ = aggregate.Write(raw) + _, _ = aggregate.Write([]byte{'\n'}) + var item record + if err := json.Unmarshal(raw, &item); err != nil { + return nil, "", nil, fmt.Errorf("line %d: %w", line, err) + } + item.SourceSystem = strings.TrimSpace(item.SourceSystem) + item.EntityType = strings.TrimSpace(item.EntityType) + item.LegacyID = strings.TrimSpace(item.LegacyID) + if source == "" { + source = item.SourceSystem + } + if item.SourceSystem != source { + return nil, "", nil, fmt.Errorf("line %d: mixed source systems", line) + } + canonical, _ := json.Marshal(map[string]any{"source_system": item.SourceSystem, "entity_type": item.EntityType, "legacy_id": item.LegacyID, "data": item.Data}) + digest := sha256.Sum256(canonical) + if hex.EncodeToString(digest[:]) != item.Checksum { + return nil, "", nil, fmt.Errorf("line %d: checksum mismatch", line) + } + newID, err := legacyid.UUID(item.SourceSystem, item.EntityType, item.LegacyID) + if err != nil { + return nil, "", nil, fmt.Errorf("line %d: %w", line, err) + } + items = append(items, staged{record: item, NewID: newID}) + counts[item.EntityType]++ + } + if err := scanner.Err(); err != nil { + return nil, "", nil, err + } + if len(items) == 0 { + return nil, "", nil, errors.New("input contains no records") + } + return items, hex.EncodeToString(aggregate.Sum(nil)), counts, nil +} + +func stage(ctx context.Context, pool *pgxpool.Pool, items []staged, sourceChecksum string, counts map[string]int) (string, error) { + batchID, err := platformid.NewUUID() + if err != nil { + return "", err + } + tx, err := pool.Begin(ctx) + if err != nil { + return "", err + } + defer func() { _ = tx.Rollback(ctx) }() + countsJSON, _ := json.Marshal(counts) + source := items[0].SourceSystem + err = tx.QueryRow(ctx, `INSERT INTO gateway.legacy_import_batches(id,source_system,source_checksum,status,record_count,entity_counts) VALUES($1,$2,$3,'staged',$4,$5) ON CONFLICT(source_system,source_checksum) DO UPDATE SET source_checksum=excluded.source_checksum RETURNING id::text`, batchID, source, sourceChecksum, len(items), countsJSON).Scan(&batchID) + if err != nil { + return "", err + } + for _, item := range items { + tag, insertErr := tx.Exec(ctx, `INSERT INTO gateway.legacy_import_records(first_seen_batch_id,source_system,entity_type,legacy_id,new_id,payload,payload_checksum) VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT(source_system,entity_type,legacy_id) DO UPDATE SET payload_checksum=gateway.legacy_import_records.payload_checksum WHERE gateway.legacy_import_records.payload_checksum=excluded.payload_checksum`, batchID, item.SourceSystem, item.EntityType, item.LegacyID, item.NewID, item.Data, item.Checksum) + if insertErr != nil { + return "", insertErr + } + if tag.RowsAffected() == 0 { + return "", fmt.Errorf("legacy record changed since an earlier import: %s/%s", item.EntityType, item.LegacyID) + } + if _, insertErr = tx.Exec(ctx, `INSERT INTO gateway.legacy_import_batch_records(batch_id,source_system,entity_type,legacy_id,payload_checksum) VALUES($1,$2,$3,$4,$5) ON CONFLICT DO NOTHING`, batchID, item.SourceSystem, item.EntityType, item.LegacyID, item.Checksum); insertErr != nil { + return "", insertErr + } + metadata, _ := json.Marshal(map[string]any{"batch_id": batchID, "payload_checksum": item.Checksum}) + if _, insertErr = tx.Exec(ctx, `INSERT INTO gateway.legacy_id_mappings(source_system,entity_type,legacy_id,new_id,metadata) VALUES($1,$2,$3,$4,$5) ON CONFLICT(source_system,entity_type,legacy_id) DO UPDATE SET metadata=excluded.metadata WHERE gateway.legacy_id_mappings.new_id=excluded.new_id`, item.SourceSystem, item.EntityType, item.LegacyID, item.NewID, metadata); insertErr != nil { + return "", insertErr + } + } + if err = tx.Commit(ctx); err != nil { + return "", err + } + return batchID, nil +} + +func bytesTrimSpace(value []byte) []byte { return []byte(strings.TrimSpace(string(value))) } +func write(value any) { + encoded, _ := json.MarshalIndent(value, "", " ") + fmt.Println(string(encoded)) +} +func fatal(err error) { fmt.Fprintln(os.Stderr, err); os.Exit(1) } diff --git a/cmd/gateway-legacy-import/main_test.go b/cmd/gateway-legacy-import/main_test.go new file mode 100644 index 0000000..eb5db75 --- /dev/null +++ b/cmd/gateway-legacy-import/main_test.go @@ -0,0 +1,27 @@ +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "testing" +) + +func TestReadRecordsValidatesChecksumAndDeterministicID(t *testing.T) { + data := json.RawMessage(`{"id":7,"name":"研发"}`) + canonical, _ := json.Marshal(map[string]any{"source_system": "python-gateway", "entity_type": "departments", "legacy_id": "7", "data": data}) + digest := sha256.Sum256(canonical) + line, _ := json.Marshal(record{SourceSystem: "python-gateway", EntityType: "departments", LegacyID: "7", Data: data, Checksum: hex.EncodeToString(digest[:])}) + items, source, counts, err := readRecords(bytes.NewReader(append(line, '\n'))) + if err != nil { + t.Fatal(err) + } + if len(items) != 1 || items[0].NewID == "" || source == "" || counts["departments"] != 1 { + t.Fatalf("unexpected result: %#v %s %#v", items, source, counts) + } + line[len(line)-2] ^= 1 + if _, _, _, err = readRecords(bytes.NewReader(append(line, '\n'))); err == nil { + t.Fatal("expected tamper detection") + } +} diff --git a/cmd/gateway-loadtest/main.go b/cmd/gateway-loadtest/main.go new file mode 100644 index 0000000..f2a49af --- /dev/null +++ b/cmd/gateway-loadtest/main.go @@ -0,0 +1,137 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "os" + "sort" + "strings" + "sync" + "sync/atomic" + "time" +) + +type sample struct { + latency time.Duration + status int + err string +} +type report struct { + Requests int `json:"requests"` + Success int `json:"success"` + Errors int `json:"errors"` + ErrorRate float64 `json:"error_rate"` + RequestsPerSecond float64 `json:"requests_per_second"` + P50MS float64 `json:"p50_ms"` + P95MS float64 `json:"p95_ms"` + P99MS float64 `json:"p99_ms"` + DurationSeconds float64 `json:"duration_seconds"` + StatusCounts map[int]int `json:"status_counts"` +} + +func main() { + base := flag.String("url", "http://127.0.0.1:8080", "gateway base URL") + path := flag.String("path", "/v1/chat/completions", "request path") + key := flag.String("api-key", os.Getenv("GATEWAY_LOADTEST_API_KEY"), "dedicated load-test API key") + model := flag.String("model", "test-model", "model or model alias") + concurrency := flag.Int("concurrency", 16, "concurrent workers") + duration := flag.Duration("duration", 30*time.Second, "test duration") + timeout := flag.Duration("timeout", 60*time.Second, "per-request timeout") + maxError := flag.Float64("max-error-rate", 0.01, "failure threshold") + maxP95 := flag.Duration("max-p95", 2*time.Second, "p95 latency threshold") + flag.Parse() + if *concurrency < 1 || *concurrency > 2000 || *duration < time.Second || *key == "" { + fmt.Fprintln(os.Stderr, "invalid arguments: api-key, positive duration and concurrency 1..2000 are required") + os.Exit(2) + } + target := strings.TrimRight(*base, "/") + *path + payload, _ := json.Marshal(map[string]any{"model": *model, "messages": []map[string]string{{"role": "user", "content": "Reply with OK."}}, "stream": false, "max_tokens": 8}) + client := &http.Client{Timeout: *timeout, Transport: &http.Transport{MaxIdleConns: *concurrency * 2, MaxIdleConnsPerHost: *concurrency, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 5 * time.Second}} + ctx, cancel := context.WithTimeout(context.Background(), *duration) + defer cancel() + started := time.Now() + results := make(chan sample, *concurrency*4) + var sequence atomic.Uint64 + var workers sync.WaitGroup + for worker := 0; worker < *concurrency; worker++ { + workers.Add(1) + go func() { + defer workers.Done() + for ctx.Err() == nil { + number := sequence.Add(1) + request, err := http.NewRequestWithContext(ctx, http.MethodPost, target, bytes.NewReader(payload)) + if err != nil { + results <- sample{err: err.Error()} + continue + } + request.Header.Set("Authorization", "Bearer "+*key) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("X-Request-ID", fmt.Sprintf("load-%d", number)) + request.Header.Set("Idempotency-Key", fmt.Sprintf("load-%d", number)) + begin := time.Now() + response, err := client.Do(request) + elapsed := time.Since(begin) + if err != nil { + if ctx.Err() == nil { + results <- sample{latency: elapsed, err: err.Error()} + } + continue + } + _, readErr := io.Copy(io.Discard, io.LimitReader(response.Body, 2<<20)) + response.Body.Close() + entry := sample{latency: elapsed, status: response.StatusCode} + if readErr != nil { + entry.err = readErr.Error() + } + results <- entry + } + }() + } + go func() { workers.Wait(); close(results) }() + samples := []sample{} + for result := range results { + samples = append(samples, result) + } + elapsed := time.Since(started) + summary := summarize(samples, elapsed) + encoded, _ := json.MarshalIndent(summary, "", " ") + fmt.Println(string(encoded)) + if summary.ErrorRate > *maxError || time.Duration(summary.P95MS*float64(time.Millisecond)) > *maxP95 { + os.Exit(1) + } +} + +func summarize(samples []sample, elapsed time.Duration) report { + summary := report{Requests: len(samples), StatusCounts: map[int]int{}, DurationSeconds: elapsed.Seconds()} + latencies := make([]time.Duration, 0, len(samples)) + for _, item := range samples { + summary.StatusCounts[item.status]++ + latencies = append(latencies, item.latency) + if item.err == "" && item.status >= 200 && item.status < 300 { + summary.Success++ + } else { + summary.Errors++ + } + } + sort.Slice(latencies, func(i, j int) bool { return latencies[i] < latencies[j] }) + if summary.Requests > 0 { + summary.ErrorRate = float64(summary.Errors) / float64(summary.Requests) + summary.RequestsPerSecond = float64(summary.Requests) / elapsed.Seconds() + summary.P50MS = percentile(latencies, .50) + summary.P95MS = percentile(latencies, .95) + summary.P99MS = percentile(latencies, .99) + } + return summary +} +func percentile(values []time.Duration, p float64) float64 { + if len(values) == 0 { + return 0 + } + index := int(float64(len(values)-1) * p) + return float64(values[index]) / float64(time.Millisecond) +} diff --git a/cmd/gateway-loadtest/main_test.go b/cmd/gateway-loadtest/main_test.go new file mode 100644 index 0000000..9669e69 --- /dev/null +++ b/cmd/gateway-loadtest/main_test.go @@ -0,0 +1,16 @@ +package main + +import ( + "testing" + "time" +) + +func TestSummarize(t *testing.T) { + summary := summarize([]sample{{latency: 10 * time.Millisecond, status: 200}, {latency: 20 * time.Millisecond, status: 200}, {latency: 100 * time.Millisecond, status: 500}}, time.Second) + if summary.Requests != 3 || summary.Success != 2 || summary.Errors != 1 { + t.Fatalf("unexpected counts: %#v", summary) + } + if summary.P95MS != 20 { + t.Fatalf("unexpected p95: %v", summary.P95MS) + } +} diff --git a/cmd/gateway-maintenance/main.go b/cmd/gateway-maintenance/main.go new file mode 100644 index 0000000..95ac223 --- /dev/null +++ b/cmd/gateway-maintenance/main.go @@ -0,0 +1,52 @@ +package main + +import ( + "context" + "log/slog" + "os" + "os/signal" + "syscall" + "time" + + "aigateway.local/core/internal/audit" + "aigateway.local/core/internal/platform/config" + "aigateway.local/core/internal/platform/database" +) + +func main() { + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) + cfg, err := config.Load() + if err != nil { + logger.Error("invalid configuration", "error", err) + os.Exit(1) + } + if cfg.Database.URL == "" { + logger.Error("DATABASE_URL is required") + os.Exit(1) + } + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + db, err := database.Open(ctx, cfg.Database) + if err != nil { + logger.Error("database initialization failed", "error", err) + os.Exit(1) + } + defer db.Close() + maintenance := audit.NewMaintenance(db, cfg.Audit.Retention, cfg.Audit.UsageRetention, cfg.Audit.PartitionMonthsAhead) + ticker := time.NewTicker(cfg.Audit.MaintenanceInterval) + defer ticker.Stop() + for { + result, runErr := maintenance.Run(ctx, time.Now()) + if runErr != nil && ctx.Err() == nil { + logger.Error("audit maintenance failed", "error", runErr) + } else if runErr == nil { + logger.Info("audit maintenance complete", "created_partitions", result.CreatedPartitions, "dropped_partitions", result.DroppedPartitions, "deleted_audit_rows", result.DeletedAuditRows, "deleted_usage_rows", result.DeletedUsageRows) + } + select { + case <-ctx.Done(): + logger.Info("maintenance worker stopped") + return + case <-ticker.C: + } + } +} diff --git a/cmd/gateway-migrator/main.go b/cmd/gateway-migrator/main.go new file mode 100644 index 0000000..3e32ff5 --- /dev/null +++ b/cmd/gateway-migrator/main.go @@ -0,0 +1,44 @@ +package main + +import ( + "context" + "flag" + "log/slog" + "os" + + "aigateway.local/core/internal/platform/config" + "aigateway.local/core/internal/platform/database" + "aigateway.local/core/internal/platform/migrate" +) + +func main() { + directory := flag.String("dir", "migrations", "directory containing ordered SQL migrations") + flag.Parse() + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + cfg, err := config.Load() + if err != nil { + logger.Error("invalid configuration", "error", err) + os.Exit(1) + } + if cfg.Database.URL == "" { + logger.Error("DATABASE_URL is required by the migrator") + os.Exit(1) + } + ctx := context.Background() + pool, err := database.Open(ctx, cfg.Database) + if err != nil { + logger.Error("database initialization failed", "error", err) + os.Exit(1) + } + defer pool.Close() + migrations, err := migrate.Load(*directory) + if err != nil { + logger.Error("migration loading failed", "error", err) + os.Exit(1) + } + if err := migrate.Apply(ctx, pool, migrations); err != nil { + logger.Error("migration failed", "error", err) + os.Exit(1) + } + logger.Info("migrations complete", "count", len(migrations)) +} diff --git a/cmd/gateway-notification-worker/main.go b/cmd/gateway-notification-worker/main.go new file mode 100644 index 0000000..a207453 --- /dev/null +++ b/cmd/gateway-notification-worker/main.go @@ -0,0 +1,61 @@ +package main + +import ( + "context" + "log/slog" + "os" + "os/signal" + "syscall" + + "aigateway.local/core/internal/platform/cache" + "aigateway.local/core/internal/platform/config" + "aigateway.local/core/internal/platform/cryptox" + "aigateway.local/core/internal/platform/database" + platformid "aigateway.local/core/internal/platform/id" + "aigateway.local/core/internal/workbench" +) + +func main() { + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) + cfg, err := config.Load() + if err != nil { + logger.Error("invalid configuration", "error", err) + os.Exit(1) + } + if cfg.Database.URL == "" || cfg.Redis.CriticalURL == "" { + logger.Error("DATABASE_URL and REDIS_CRITICAL_URL are required") + os.Exit(1) + } + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + db, err := database.Open(ctx, cfg.Database) + if err != nil { + logger.Error("database initialization failed", "error", err) + os.Exit(1) + } + defer db.Close() + client, err := cache.Open(cfg.Redis.CriticalURL) + if err != nil { + logger.Error("redis initialization failed", "error", err) + os.Exit(1) + } + defer client.Close() + cipher, err := cryptox.NewKeyring(cfg.Credentials.MasterKey, cfg.Credentials.KEKVersion, cfg.Credentials.KEKKeyring, "notification-signing-secret") + if err != nil { + logger.Error("notification cipher initialization failed", "error", err) + os.Exit(1) + } + consumer, err := platformid.NewUUID() + if err != nil { + logger.Error("consumer ID generation failed", "error", err) + os.Exit(1) + } + service := workbench.NewNotificationService(workbench.NewService(db), cipher, cfg.Credentials.AllowPrivateWebhookURL) + dispatcher := workbench.NewNotificationDispatcher(service, client, cfg.Outbox.Stream, "notification-"+consumer, logger) + logger.Info("notification worker started", "consumer", consumer, "stream", cfg.Outbox.Stream) + if err = dispatcher.Run(ctx); err != nil { + logger.Error("notification worker stopped unexpectedly", "error", err) + os.Exit(1) + } + logger.Info("notification worker stopped", "consumer", consumer) +} diff --git a/cmd/gateway-outbox-worker/main.go b/cmd/gateway-outbox-worker/main.go new file mode 100644 index 0000000..ea7c0ab --- /dev/null +++ b/cmd/gateway-outbox-worker/main.go @@ -0,0 +1,68 @@ +package main + +import ( + "context" + "log/slog" + "os" + "os/signal" + "syscall" + + "aigateway.local/core/internal/outbox" + "aigateway.local/core/internal/platform/cache" + "aigateway.local/core/internal/platform/config" + "aigateway.local/core/internal/platform/database" + platformid "aigateway.local/core/internal/platform/id" +) + +func main() { + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) + cfg, err := config.Load() + if err != nil { + logger.Error("invalid configuration", "error", err) + os.Exit(1) + } + if cfg.Database.URL == "" || cfg.Redis.CriticalURL == "" { + logger.Error("DATABASE_URL and REDIS_CRITICAL_URL are required") + os.Exit(1) + } + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + db, err := database.Open(ctx, cfg.Database) + if err != nil { + logger.Error("database initialization failed", "error", err) + os.Exit(1) + } + defer db.Close() + redisClient, err := cache.Open(cfg.Redis.CriticalURL) + if err != nil { + logger.Error("redis initialization failed", "error", err) + os.Exit(1) + } + defer redisClient.Close() + if err := db.Ping(ctx); err != nil { + logger.Error("database is unavailable", "error", err) + os.Exit(1) + } + if err := redisClient.Ping(ctx).Err(); err != nil { + logger.Error("redis is unavailable", "error", err) + os.Exit(1) + } + workerID, err := platformid.NewUUID() + if err != nil { + logger.Error("worker ID generation failed", "error", err) + os.Exit(1) + } + workerID = "outbox-" + workerID + worker := outbox.NewWorker( + outbox.NewStore(db), + outbox.NewRedisPublisher(redisClient, cfg.Outbox.Stream, cfg.Outbox.StreamMaxLength, cfg.Outbox.MarkerTTL), + outbox.WorkerConfig{WorkerID: workerID, BatchSize: cfg.Outbox.BatchSize, PollInterval: cfg.Outbox.PollInterval, Lease: cfg.Outbox.Lease, MaxAttempts: cfg.Outbox.MaxAttempts, MaxBackoff: cfg.Outbox.MaxBackoff}, + logger, + ) + logger.Info("outbox worker started", "worker_id", workerID, "stream", cfg.Outbox.Stream) + if err := worker.Run(ctx); err != nil { + logger.Error("outbox worker stopped unexpectedly", "error", err) + os.Exit(1) + } + logger.Info("outbox worker stopped", "worker_id", workerID) +} diff --git a/deploy/Dockerfile b/deploy/Dockerfile new file mode 100644 index 0000000..07a4079 --- /dev/null +++ b/deploy/Dockerfile @@ -0,0 +1,33 @@ +FROM golang:1.26.5-alpine AS builder +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY cmd ./cmd +COPY internal ./internal +ARG VERSION=dev +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -o /out/gateway-api ./cmd/gateway-api +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/gateway-migrator ./cmd/gateway-migrator +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/gateway-bootstrap ./cmd/gateway-bootstrap +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/gateway-outbox-worker ./cmd/gateway-outbox-worker +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/gateway-maintenance ./cmd/gateway-maintenance +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/gateway-notification-worker ./cmd/gateway-notification-worker +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/gateway-loadtest ./cmd/gateway-loadtest +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/gateway-legacy-import ./cmd/gateway-legacy-import + +FROM alpine:3.22 +RUN apk add --no-cache ca-certificates tzdata \ + && addgroup -S gateway \ + && adduser -S -G gateway gateway +WORKDIR /app +COPY --from=builder /out/gateway-api /usr/local/bin/gateway-api +COPY --from=builder /out/gateway-migrator /usr/local/bin/gateway-migrator +COPY --from=builder /out/gateway-bootstrap /usr/local/bin/gateway-bootstrap +COPY --from=builder /out/gateway-outbox-worker /usr/local/bin/gateway-outbox-worker +COPY --from=builder /out/gateway-maintenance /usr/local/bin/gateway-maintenance +COPY --from=builder /out/gateway-notification-worker /usr/local/bin/gateway-notification-worker +COPY --from=builder /out/gateway-loadtest /usr/local/bin/gateway-loadtest +COPY --from=builder /out/gateway-legacy-import /usr/local/bin/gateway-legacy-import +COPY migrations ./migrations +USER gateway +EXPOSE 8080 +ENTRYPOINT ["gateway-api"] diff --git a/deploy/Dockerfile.web b/deploy/Dockerfile.web new file mode 100644 index 0000000..1af8367 --- /dev/null +++ b/deploy/Dockerfile.web @@ -0,0 +1,16 @@ +FROM node:24-alpine AS builder +ARG APP=admin +WORKDIR /src/web +RUN corepack enable +COPY web/package.json web/pnpm-lock.yaml web/pnpm-workspace.yaml ./ +COPY web/apps ./apps +COPY web/packages ./packages +RUN CI=1 corepack pnpm@10.15.1 install --frozen-lockfile +RUN corepack pnpm@10.15.1 --filter @aigateway/${APP} build + +FROM nginx:1.29-alpine +ARG APP=admin +COPY deploy/nginx-web.conf /etc/nginx/conf.d/default.conf +RUN sed -i "s/__APP__/${APP}/g" /etc/nginx/conf.d/default.conf +COPY --from=builder /src/web/apps/${APP}/dist /usr/share/nginx/html/${APP} +EXPOSE 80 diff --git a/deploy/PRODUCTION.md b/deploy/PRODUCTION.md new file mode 100644 index 0000000..c2fb516 --- /dev/null +++ b/deploy/PRODUCTION.md @@ -0,0 +1,74 @@ +# Production deployment + +This bundle builds the Go services and both Art Design Pro applications from +source. PostgreSQL and two Redis roles are included; MinIO/S3 and ClickHouse are +not required. + +## Prerequisites + +- Docker Engine with Compose v2 +- At least 4 CPU cores, 8 GiB RAM and 30 GiB free disk for an initial build +- An external TLS reverse proxy or load balancer +- A backup destination for the PostgreSQL volume + +## First deployment + +Run all commands from the repository root: + +```bash +cp deploy/production.env.example deploy/production.env +chmod 600 deploy/production.env +# Edit deploy/production.env and replace every CHANGE_ME value. + +docker compose \ + --env-file deploy/production.env \ + -f deploy/docker-compose.production.yml \ + config --quiet + +docker compose \ + --env-file deploy/production.env \ + -f deploy/docker-compose.production.yml \ + up -d --build +``` + +Create the initial administrator once: + +```bash +docker compose \ + --env-file deploy/production.env \ + -f deploy/docker-compose.production.yml \ + --profile tools run --rm bootstrap-admin +``` + +Then remove `BOOTSTRAP_ADMIN_PASSWORD` from `deploy/production.env` and use the +admin UI to create database-backed gateway API keys. + +## Endpoints + +- API and OpenAI-compatible gateway: `127.0.0.1:8080` +- Admin UI: `http://127.0.0.1:8081/admin/` +- Portal UI: `http://127.0.0.1:8082/portal/` +- Liveness/readiness: `/healthz` and `/readyz` + +Ports bind to loopback by default. Terminate TLS at a reverse proxy and forward +to these endpoints. Change `*_BIND_IP` only when the host firewall and network +policy are already in place. + +## Operations + +Check status and logs: + +```bash +docker compose --env-file deploy/production.env -f deploy/docker-compose.production.yml ps +docker compose --env-file deploy/production.env -f deploy/docker-compose.production.yml logs --tail=200 gateway-api +curl --fail http://127.0.0.1:8080/readyz +``` + +For upgrades, back up PostgreSQL first, change `GATEWAY_VERSION`, then run the +same `up -d --build` command. The one-shot migrator applies forward migrations +before the API starts. Do not use `docker compose down -v` in production because +it removes persistent data. + +The bundled database URLs use `sslmode=disable` only for the private Compose +network. When using an external PostgreSQL or Redis service, require TLS and use +`sslmode=verify-full` / `rediss://` as supported by that service. diff --git a/deploy/docker-compose.production.yml b/deploy/docker-compose.production.yml new file mode 100644 index 0000000..b02966f --- /dev/null +++ b/deploy/docker-compose.production.yml @@ -0,0 +1,189 @@ +name: ai-gateway + +x-backend-build: &backend-build + context: .. + dockerfile: deploy/Dockerfile + args: + VERSION: ${GATEWAY_VERSION:-0.10.0} + +x-gateway-environment: &gateway-environment + APP_ENV: production + HTTP_ADDR: :8080 + DATABASE_URL: ${DATABASE_URL:?DATABASE_URL is required} + REDIS_CRITICAL_URL: ${REDIS_CRITICAL_URL:?REDIS_CRITICAL_URL is required} + REDIS_CACHE_URL: ${REDIS_CACHE_URL:-} + GATEWAY_BOOTSTRAP_API_KEY_ENABLED: ${GATEWAY_BOOTSTRAP_API_KEY_ENABLED:-false} + GATEWAY_BOOTSTRAP_API_KEY: ${GATEWAY_BOOTSTRAP_API_KEY:-} + CREDENTIAL_MASTER_KEY: ${CREDENTIAL_MASTER_KEY:?CREDENTIAL_MASTER_KEY is required} + CREDENTIAL_KEK_VERSION: ${CREDENTIAL_KEK_VERSION:-1} + CREDENTIAL_KEK_KEYRING: ${CREDENTIAL_KEK_KEYRING:-} + ALLOW_PRIVATE_PROVIDER_URLS: ${ALLOW_PRIVATE_PROVIDER_URLS:-false} + ALLOW_PRIVATE_TOOL_URLS: ${ALLOW_PRIVATE_TOOL_URLS:-false} + ALLOW_PRIVATE_WEBHOOK_URLS: ${ALLOW_PRIVATE_WEBHOOK_URLS:-false} + UPSTREAM_FALLBACK_ENABLED: ${UPSTREAM_FALLBACK_ENABLED:-false} + UPSTREAM_BASE_URL: ${UPSTREAM_BASE_URL:-https://api.openai.com} + UPSTREAM_API_KEY: ${UPSTREAM_API_KEY:-} + SHADOW_BASE_URL: ${SHADOW_BASE_URL:-} + SHADOW_API_KEY: ${SHADOW_API_KEY:-} + SHADOW_SAMPLE_RATE: ${SHADOW_SAMPLE_RATE:-0} + +x-backend-service: &backend-service + image: ai-gateway-go:${GATEWAY_VERSION:-0.10.0} + build: *backend-build + environment: *gateway-environment + read_only: true + tmpfs: + - /tmp:size=64m,mode=1777 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + +services: + postgres: + image: postgres:17-alpine + environment: + POSTGRES_DB: ${POSTGRES_DB:-gateway} + POSTGRES_USER: ${POSTGRES_USER:-gateway} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 5s + timeout: 3s + retries: 30 + restart: unless-stopped + + redis-critical: + image: redis:8.2-alpine + environment: + REDIS_PASSWORD: ${REDIS_PASSWORD:?REDIS_PASSWORD is required} + command: + - sh + - -c + - exec redis-server --requirepass "$${REDIS_PASSWORD}" --appendonly yes --appendfsync everysec --maxmemory-policy noeviction + volumes: + - redis-critical-data:/data + healthcheck: + test: ["CMD-SHELL", "redis-cli --no-auth-warning -a \"$${REDIS_PASSWORD}\" ping | grep -q PONG"] + interval: 5s + timeout: 3s + retries: 30 + restart: unless-stopped + + redis-cache: + image: redis:8.2-alpine + environment: + REDIS_PASSWORD: ${REDIS_PASSWORD:?REDIS_PASSWORD is required} + REDIS_CACHE_MAXMEMORY: ${REDIS_CACHE_MAXMEMORY:-256mb} + command: + - sh + - -c + - exec redis-server --requirepass "$${REDIS_PASSWORD}" --maxmemory "$${REDIS_CACHE_MAXMEMORY}" --maxmemory-policy allkeys-lfu --save "" + healthcheck: + test: ["CMD-SHELL", "redis-cli --no-auth-warning -a \"$${REDIS_PASSWORD}\" ping | grep -q PONG"] + interval: 5s + timeout: 3s + retries: 30 + restart: unless-stopped + + migrator: + <<: *backend-service + entrypoint: ["gateway-migrator"] + depends_on: + postgres: + condition: service_healthy + restart: "no" + + gateway-api: + <<: *backend-service + ports: + - "${GATEWAY_BIND_IP:-127.0.0.1}:${GATEWAY_PORT:-8080}:8080" + depends_on: + migrator: + condition: service_completed_successfully + redis-critical: + condition: service_healthy + redis-cache: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:8080/readyz"] + interval: 10s + timeout: 3s + retries: 30 + start_period: 10s + restart: unless-stopped + + admin-web: + image: ai-gateway-admin:${GATEWAY_VERSION:-0.10.0} + build: + context: .. + dockerfile: deploy/Dockerfile.web + args: + APP: admin + ports: + - "${WEB_BIND_IP:-127.0.0.1}:${ADMIN_PORT:-8081}:80" + depends_on: + gateway-api: + condition: service_healthy + restart: unless-stopped + + portal-web: + image: ai-gateway-portal:${GATEWAY_VERSION:-0.10.0} + build: + context: .. + dockerfile: deploy/Dockerfile.web + args: + APP: portal + ports: + - "${WEB_BIND_IP:-127.0.0.1}:${PORTAL_PORT:-8082}:80" + depends_on: + gateway-api: + condition: service_healthy + restart: unless-stopped + + outbox-worker: + <<: *backend-service + entrypoint: ["gateway-outbox-worker"] + depends_on: + migrator: + condition: service_completed_successfully + redis-critical: + condition: service_healthy + restart: unless-stopped + + maintenance-worker: + <<: *backend-service + entrypoint: ["gateway-maintenance"] + depends_on: + migrator: + condition: service_completed_successfully + restart: unless-stopped + + notification-worker: + <<: *backend-service + entrypoint: ["gateway-notification-worker"] + depends_on: + migrator: + condition: service_completed_successfully + redis-critical: + condition: service_healthy + restart: unless-stopped + + bootstrap-admin: + <<: *backend-service + profiles: ["tools"] + entrypoint: ["gateway-bootstrap"] + environment: + <<: *gateway-environment + BOOTSTRAP_ADMIN_USERNAME: ${BOOTSTRAP_ADMIN_USERNAME:-admin} + BOOTSTRAP_ADMIN_PASSWORD: ${BOOTSTRAP_ADMIN_PASSWORD:-} + depends_on: + migrator: + condition: service_completed_successfully + restart: "no" + +volumes: + postgres-data: + redis-critical-data: diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..6c54a75 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,154 @@ +services: + postgres: + image: postgres:17-alpine + environment: + POSTGRES_DB: gateway + POSTGRES_USER: gateway + POSTGRES_PASSWORD: gateway + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U gateway -d gateway"] + interval: 5s + timeout: 3s + retries: 20 + + redis-critical: + image: redis:8.2-alpine + command: ["redis-server", "--appendonly", "yes", "--appendfsync", "everysec", "--maxmemory-policy", "noeviction"] + volumes: + - redis-critical-data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 20 + + redis-cache: + image: redis:8.2-alpine + command: ["redis-server", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lfu", "--save", ""] + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 20 + + migrator: + build: + context: .. + dockerfile: deploy/Dockerfile + args: + VERSION: ${GATEWAY_VERSION:-0.10.0} + entrypoint: ["gateway-migrator"] + environment: &gateway-environment + APP_ENV: local + HTTP_ADDR: :8080 + DATABASE_URL: postgres://gateway:gateway@postgres:5432/gateway?sslmode=disable + REDIS_CRITICAL_URL: redis://redis-critical:6379/0 + REDIS_CACHE_URL: redis://redis-cache:6379/0 + GATEWAY_BOOTSTRAP_API_KEY: ${GATEWAY_BOOTSTRAP_API_KEY:-} + GATEWAY_BOOTSTRAP_API_KEY_ENABLED: ${GATEWAY_BOOTSTRAP_API_KEY_ENABLED:-false} + CREDENTIAL_MASTER_KEY: ${CREDENTIAL_MASTER_KEY:-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=} + CREDENTIAL_KEK_VERSION: 1 + CREDENTIAL_KEK_KEYRING: ${CREDENTIAL_KEK_KEYRING:-} + ALLOW_PRIVATE_TOOL_URLS: ${ALLOW_PRIVATE_TOOL_URLS:-false} + ALLOW_PRIVATE_WEBHOOK_URLS: ${ALLOW_PRIVATE_WEBHOOK_URLS:-false} + UPSTREAM_BASE_URL: ${UPSTREAM_BASE_URL:-https://api.openai.com} + UPSTREAM_API_KEY: ${UPSTREAM_API_KEY:-} + SHADOW_BASE_URL: ${SHADOW_BASE_URL:-} + SHADOW_API_KEY: ${SHADOW_API_KEY:-} + SHADOW_SAMPLE_RATE: ${SHADOW_SAMPLE_RATE:-0} + depends_on: + postgres: + condition: service_healthy + + gateway-api: + build: + context: .. + dockerfile: deploy/Dockerfile + args: + VERSION: ${GATEWAY_VERSION:-0.10.0} + environment: *gateway-environment + ports: + - "${GATEWAY_PORT:-8080}:8080" + depends_on: + migrator: + condition: service_completed_successfully + redis-critical: + condition: service_healthy + redis-cache: + condition: service_healthy + restart: unless-stopped + + admin-web: + build: + context: .. + dockerfile: deploy/Dockerfile.web + args: + APP: admin + ports: + - "${ADMIN_PORT:-8081}:80" + depends_on: + gateway-api: + condition: service_started + restart: unless-stopped + + portal-web: + build: + context: .. + dockerfile: deploy/Dockerfile.web + args: + APP: portal + ports: + - "${PORTAL_PORT:-8082}:80" + depends_on: + gateway-api: + condition: service_started + restart: unless-stopped + + outbox-worker: + build: + context: .. + dockerfile: deploy/Dockerfile + args: + VERSION: ${GATEWAY_VERSION:-0.10.0} + entrypoint: ["gateway-outbox-worker"] + environment: *gateway-environment + depends_on: + migrator: + condition: service_completed_successfully + redis-critical: + condition: service_healthy + restart: unless-stopped + + maintenance-worker: + build: + context: .. + dockerfile: deploy/Dockerfile + args: + VERSION: ${GATEWAY_VERSION:-0.10.0} + entrypoint: ["gateway-maintenance"] + environment: *gateway-environment + depends_on: + migrator: + condition: service_completed_successfully + restart: unless-stopped + + notification-worker: + build: + context: .. + dockerfile: deploy/Dockerfile + args: + VERSION: ${GATEWAY_VERSION:-0.10.0} + entrypoint: ["gateway-notification-worker"] + environment: *gateway-environment + depends_on: + migrator: + condition: service_completed_successfully + redis-critical: + condition: service_healthy + restart: unless-stopped + +volumes: + postgres-data: + redis-critical-data: diff --git a/deploy/nginx-web.conf b/deploy/nginx-web.conf new file mode 100644 index 0000000..30c14cd --- /dev/null +++ b/deploy/nginx-web.conf @@ -0,0 +1,71 @@ +server { + listen 80; + server_name _; + + # Keep Location headers relative (Location: /admin/) instead of letting + # nginx absolute_redirect rebuild them from $host + the listening port. + # The gateway is commonly published behind a non-standard port (e.g. 18081), + # and an absolute redirect would drop that port and send browsers to :80. + absolute_redirect off; + + # Match the gateway's HTTP_MAX_BODY_BYTES (default 32 MiB). nginx's default + # of 1 MiB otherwise rejects large prompts and knowledge-base imports with + # 413 before they ever reach the gateway. + client_max_body_size 32m; + + root /usr/share/nginx/html; + index index.html; + + location = / { + return 302 /__APP__/; + } + + location = /__APP__ { + return 301 /__APP__/; + } + + location = /healthz { + proxy_pass http://gateway-api:8080/healthz; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location = /readyz { + proxy_pass http://gateway-api:8080/readyz; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location /api/ { + proxy_pass http://gateway-api:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Request-ID $request_id; + proxy_set_header X-Forwarded-Proto $scheme; + # nginx 是控制台(/api/)的唯一入口:用 $remote_addr 覆盖 X-Forwarded-For, + # 避免客户端自带 X-Forwarded-For 头伪造来源 IP、绕过登录限流。 + proxy_set_header X-Forwarded-For $remote_addr; + } + + location /v1/ { + proxy_pass http://gateway-api:8080; + proxy_http_version 1.1; + proxy_buffering off; + proxy_read_timeout 600s; + proxy_set_header Host $host; + proxy_set_header X-Request-ID $request_id; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + # Vite emits assets and client-side routes below /admin/ or /portal/. + location /__APP__/ { + try_files $uri $uri/ /__APP__/index.html; + } + + location / { + return 404; + } +} diff --git a/deploy/production.env.example b/deploy/production.env.example new file mode 100644 index 0000000..0cc1ff2 --- /dev/null +++ b/deploy/production.env.example @@ -0,0 +1,42 @@ +# Copy this file to deploy/production.env and replace every CHANGE_ME value. +# Keep production.env outside version control and restrict it to the deploy user. +GATEWAY_VERSION=0.10.0 + +POSTGRES_DB=gateway +POSTGRES_USER=gateway +POSTGRES_PASSWORD=CHANGE_ME_LONG_RANDOM_POSTGRES_PASSWORD +REDIS_PASSWORD=CHANGE_ME_LONG_RANDOM_REDIS_PASSWORD + +# URL-encode special characters in passwords used in URLs. +DATABASE_URL=postgres://gateway:CHANGE_ME_URL_ENCODED_POSTGRES_PASSWORD@postgres:5432/gateway?sslmode=disable +REDIS_CRITICAL_URL=redis://:CHANGE_ME_URL_ENCODED_REDIS_PASSWORD@redis-critical:6379/0 +REDIS_CACHE_URL=redis://:CHANGE_ME_URL_ENCODED_REDIS_PASSWORD@redis-cache:6379/0 + +# Generate once with: openssl rand -base64 32 +# Never rotate this value without following a KEK rotation procedure. +CREDENTIAL_MASTER_KEY=CHANGE_ME_BASE64_32_BYTE_KEY +CREDENTIAL_KEK_VERSION=1 +CREDENTIAL_KEK_KEYRING= + +# Disabled by default. Create database-backed API keys after bootstrap. +GATEWAY_BOOTSTRAP_API_KEY_ENABLED=false +GATEWAY_BOOTSTRAP_API_KEY= + +UPSTREAM_FALLBACK_ENABLED=false +UPSTREAM_BASE_URL=https://api.openai.com +UPSTREAM_API_KEY= + +ALLOW_PRIVATE_PROVIDER_URLS=false +ALLOW_PRIVATE_TOOL_URLS=false +ALLOW_PRIVATE_WEBHOOK_URLS=false + +GATEWAY_BIND_IP=127.0.0.1 +GATEWAY_PORT=8080 +WEB_BIND_IP=127.0.0.1 +ADMIN_PORT=8081 +PORTAL_PORT=8082 +REDIS_CACHE_MAXMEMORY=256mb + +# Used only for the one-time bootstrap-admin command; remove after use. +BOOTSTRAP_ADMIN_USERNAME=admin +BOOTSTRAP_ADMIN_PASSWORD=CHANGE_ME_AT_LEAST_12_CHARACTERS diff --git a/docs/adr/0001-baseline-storage.md b/docs/adr/0001-baseline-storage.md new file mode 100644 index 0000000..95e46e6 --- /dev/null +++ b/docs/adr/0001-baseline-storage.md @@ -0,0 +1,18 @@ +# ADR-0001:基线存储只使用 PostgreSQL 与 Redis + +状态:已接受 +日期:2026-08-10 + +## 决策 + +第一版部署不包含 MinIO/S3 与 ClickHouse。 + +- PostgreSQL 保存权威业务数据、outbox、有限长度的审计请求/响应预览和聚合统计。 +- critical Redis 保存限流、配额、幂等、配置协调与短期可靠流,使用 AOF 和 `noeviction`。 +- cache Redis 保存可丢弃缓存,使用 `allkeys-lfu`,故障时网关降级运行。 +- 不把完整且无上限的请求正文、响应正文和知识库原始文件写入本地磁盘。 +- 知识库只保存经过提取、大小不超过 2 MiB 的纯文本及其分块;`Retriever` 隔离检索实现,基线使用 PostgreSQL FTS 和中文词片评分。 + +## 影响 + +基线更容易部署和运维,但不承诺无限期保存完整审计正文,也不针对超大规模多年期分析查询优化。未来只有在容量和合规需求明确时,才通过 `AuditBodyStore` 或 `AnalyticsSink` 接口接入对象存储或分析库。 diff --git a/docs/cutover-runbook.md b/docs/cutover-runbook.md new file mode 100644 index 0000000..9bbcd59 --- /dev/null +++ b/docs/cutover-runbook.md @@ -0,0 +1,35 @@ +# 双系统切换与回退手册 + +## 进入切换窗口前 + +1. 对照 `route-contract-matrix.md`,确认所有仍有调用量的旧契约已经完成客户端迁移或兼容层验证。 +2. 连续观察影子指标;`gateway_shadow_errors_total` 不增长,契约 mismatch 已逐项解释,且 shadow 专用 Key 没有生产权限扩张。 +3. 使用生产等价数据量执行 `gateway-loadtest`,确认错误率、p95、数据库连接池、critical Redis 延迟和审计队列丢弃数均在阈值内。 +4. 冻结旧系统配置写入,执行最后一次幂等数据导入和数量/外键校验。 +5. 确认 Go 与旧网关的 `/readyz` 均为成功,并备份 Nginx 活动 upstream include。 + +## 原子切换 + +`scripts/cutover.sh` 只修改一个专用 Nginx include。它先探活目标,写临时文件后原子替换,执行 `nginx -t`,再 reload;验证或 reload 失败会恢复备份。 + +```bash +export NGINX_ACTIVE_INCLUDE=/etc/nginx/conf.d/ai-gateway-active.conf +export GO_UPSTREAM=10.0.0.21:8080 +export LEGACY_UPSTREAM=10.0.0.20:8000 +export GO_HEALTH_URL=http://10.0.0.21:8080/readyz +export LEGACY_HEALTH_URL=http://10.0.0.20:8000/readyz +export PUBLIC_HEALTH_URL=https://gateway.example.com/readyz + +scripts/cutover.sh preflight +scripts/cutover.sh go +scripts/cutover.sh status +``` + +## 回退触发条件 + +- 5 分钟窗口错误率超过既定 SLO,或出现鉴权/配额错误的系统性抬升; +- p95/p99 持续超过容量验收阈值; +- 审计队列出现丢弃、critical Redis 不稳定、数据库连接池耗尽; +- 与旧系统相比出现未经批准的响应结构差异或关键业务功能缺失。 + +执行 `scripts/cutover.sh rollback`。回退只切流量,不回写/删除 Go 数据;outbox 与审计 Worker 保持运行,便于事后核对。确认旧系统恢复后保存本次 include 备份、指标和日志,再分析问题。 diff --git a/docs/legacy-data-dictionary.md b/docs/legacy-data-dictionary.md new file mode 100644 index 0000000..e87bc9c --- /dev/null +++ b/docs/legacy-data-dictionary.md @@ -0,0 +1,625 @@ +# 旧 Python 网关数据字典与 ID 迁移映射 + +> 由 `scripts/export_legacy_dictionary.py` 从旧工程 SQLAlchemy metadata 生成。 +> `audit_log_views` 是兼容查询投影,不作为独立持久化实体,因此这里统计 38 个实体。 + +## 映射约定 + +- 旧整数主键统一通过 `gateway.legacy_id_mappings` 映射为 UUID,不在新业务表保留双主键。 +- UUID 使用固定 namespace 的 UUIDv5 生成,输入为 `source_system/entity_type/legacy_id`,重复导入保持幂等。 +- 外键迁移必须先查映射表;缺失映射时整批失败,不允许写入悬空引用。 +- 旧密文不能直接复用:Provider、TOTP、内部服务 Key 等敏感字段在导入时解密后用新用途标签重新加密。 +- `audit_logs` 的完整请求/响应正文不进入新库,只迁移受长度约束的预览与结构化元数据。 + +## 实体去向 + +| 旧表 | 新域/目标 | 状态 | +|---|---|---| +| `admin_accounts` | gateway.admin_accounts(已落地) | 已建表,待批量导入器 | +| `application_revisions` | gateway.application_versions | 已建表,待批量导入器转换 config 引用 | +| `application_runs` | gateway.application_runs | 已建表,待批量导入器 | +| `applications` | gateway.applications | 已建表,待批量导入器 | +| `audit_logs` | gateway.audit_events(已落地,不迁移无限长正文) | 已建表,待批量导入器 | +| `audit_search_records` | 对应 M2–M4 领域表 | 已登记,随对应里程碑建表 | +| `audit_settings` | 对应 M2–M4 领域表 | 已登记,随对应里程碑建表 | +| `content_audit_scopes` | 对应 M2–M4 领域表 | 已登记,随对应里程碑建表 | +| `departments` | gateway.departments(已落地) | 已建表,待批量导入器 | +| `dept_budgets` | 对应 M2–M4 领域表 | 已登记,随对应里程碑建表 | +| `feishu_auth_settings` | 对应 M2–M4 领域表 | 已登记,随对应里程碑建表 | +| `filter_rules` | 对应 M2–M4 领域表 | 已登记,随对应里程碑建表 | +| `hallucination_events` | 对应 M2–M4 领域表 | 已登记,随对应里程碑建表 | +| `hallucination_policies` | 对应 M2–M4 领域表 | 已登记,随对应里程碑建表 | +| `hallucination_settings` | 对应 M2–M4 领域表 | 已登记,随对应里程碑建表 | +| `internal_service_keys` | 对应 M2–M4 领域表 | 已登记,随对应里程碑建表 | +| `knowledge_bases` | gateway.knowledge_bases | 已建表,旧 embedding_model 转检索器迁移策略待导入器处理 | +| `knowledge_chunks` | gateway.knowledge_chunks | 已建表,基线重新分块/建 PostgreSQL 搜索向量 | +| `knowledge_documents` | gateway.knowledge_documents | 已建表,只导入不超过 2 MiB 的提取文本 | +| `managed_conversations` | 对应 M2–M4 领域表 | 已登记,随对应里程碑建表 | +| `managed_messages` | 对应 M2–M4 领域表 | 已登记,随对应里程碑建表 | +| `model_aliases` | 对应 M2–M4 领域表 | 已登记,随对应里程碑建表 | +| `model_pricing` | 对应 M2–M4 领域表 | 已登记,随对应里程碑建表 | +| `model_qps_configs` | 对应 M2–M4 领域表 | 已登记,随对应里程碑建表 | +| `model_requests` | 对应 M2–M4 领域表 | 已登记,随对应里程碑建表 | +| `notify_config` | gateway.notification_channels | 已建表,待解密后重新加密签名密钥 | +| `pricing_config` | 对应 M2–M4 领域表 | 已登记,随对应里程碑建表 | +| `prompt_categories` | gateway.prompt_categories | 已建表,待批量导入器 | +| `prompt_favorites` | 对应 M2–M4 领域表 | 已登记,随对应里程碑建表 | +| `prompt_templates` | gateway.prompt_templates | 已建表,待批量导入器 | +| `prompt_versions` | gateway.prompt_versions | 已建表,待批量导入器 | +| `providers` | gateway.providers + gateway.provider_models(已落地,models 拆表) | 已建表,待批量导入器 | +| `rate_limit_rules` | 对应 M2–M4 领域表 | 已登记,随对应里程碑建表 | +| `routing_groups` | 对应 M2–M4 领域表 | 已登记,随对应里程碑建表 | +| `routing_targets` | 对应 M2–M4 领域表 | 已登记,随对应里程碑建表 | +| `tool_definitions` | gateway.tool_definitions | 已建表,待解密后用新用途标签加密请求头 | +| `users` | gateway.portal_users(已落地) | 已建表,待批量导入器 | +| `virtual_keys` | gateway.api_keys(已落地,字段需转换) | 已建表,待批量导入器 | + +## 字段字典 + +### `admin_accounts` + +| 字段 | 旧类型 | 可空 | 默认值 | 键/引用 | +|---|---|---:|---|---| +| `id` | `INTEGER` | 否 | `—` | PK | +| `username` | `VARCHAR(64)` | 否 | `—` | UNIQUE | +| `password_hash` | `VARCHAR(128)` | 否 | `—` | — | +| `display_name` | `VARCHAR(64)` | 是 | `` | — | +| `role` | `VARCHAR(16)` | 是 | `superadmin` | — | +| `active` | `BOOLEAN` | 是 | `True` | — | +| `last_login` | `DATETIME` | 是 | `—` | — | +| `failed_logins` | `INTEGER` | 是 | `0` | — | +| `locked_until` | `DATETIME` | 是 | `—` | — | +| `created_at` | `DATETIME` | 是 | `now` | — | +| `totp_secret` | `VARCHAR(255)` | 是 | `` | — | +| `totp_enabled` | `BOOLEAN` | 是 | `False` | — | +| `totp_last_step` | `INTEGER` | 是 | `—` | — | +| `totp_backup_codes` | `TEXT` | 是 | `[]` | — | +| `totp_confirmed_at` | `DATETIME` | 是 | `—` | — | + +### `application_revisions` + +| 字段 | 旧类型 | 可空 | 默认值 | 键/引用 | +|---|---|---:|---|---| +| `id` | `INTEGER` | 否 | `—` | PK | +| `application_id` | `INTEGER` | 否 | `—` | FK → `applications.id` | +| `version` | `INTEGER` | 否 | `—` | — | +| `config` | `TEXT` | 否 | `—` | — | +| `change_note` | `VARCHAR(256)` | 是 | `` | — | +| `published_by` | `VARCHAR(128)` | 是 | `` | — | +| `created_at` | `DATETIME` | 是 | `now` | — | + +### `application_runs` + +| 字段 | 旧类型 | 可空 | 默认值 | 键/引用 | +|---|---|---:|---|---| +| `id` | `INTEGER` | 否 | `—` | PK | +| `application_id` | `INTEGER` | 否 | `—` | FK → `applications.id` | +| `version` | `INTEGER` | 是 | `0` | — | +| `user_id` | `INTEGER` | 是 | `—` | FK → `users.id` | +| `status` | `VARCHAR(16)` | 是 | `success` | — | +| `latency_ms` | `INTEGER` | 是 | `0` | — | +| `retrieval_count` | `INTEGER` | 是 | `0` | — | +| `tool_calls` | `INTEGER` | 是 | `0` | — | +| `error` | `VARCHAR(512)` | 是 | `` | — | +| `created_at` | `DATETIME` | 是 | `now` | — | + +### `applications` + +| 字段 | 旧类型 | 可空 | 默认值 | 键/引用 | +|---|---|---:|---|---| +| `id` | `INTEGER` | 否 | `—` | PK | +| `app_code` | `VARCHAR(64)` | 否 | `—` | UNIQUE | +| `name` | `VARCHAR(128)` | 否 | `—` | — | +| `description` | `TEXT` | 是 | `` | — | +| `dept_id` | `INTEGER` | 是 | `—` | FK → `departments.id` | +| `owner_user_id` | `INTEGER` | 是 | `—` | FK → `users.id` | +| `status` | `VARCHAR(16)` | 是 | `active` | — | +| `allowed_models` | `TEXT` | 是 | `[]` | — | +| `quota_tokens` | `INTEGER` | 是 | `—` | — | +| `rpm_limit` | `INTEGER` | 是 | `—` | — | +| `draft_config` | `TEXT` | 是 | `{}` | — | +| `published_config` | `TEXT` | 是 | `{}` | — | +| `release_version` | `INTEGER` | 是 | `0` | — | +| `published_at` | `DATETIME` | 是 | `—` | — | +| `runtime_key_id` | `INTEGER` | 是 | `—` | — | +| `runtime_key_secret` | `TEXT` | 是 | `` | — | +| `created_by` | `VARCHAR(128)` | 是 | `` | — | +| `created_at` | `DATETIME` | 是 | `now` | — | +| `updated_at` | `DATETIME` | 是 | `now` | — | + +### `audit_logs` + +| 字段 | 旧类型 | 可空 | 默认值 | 键/引用 | +|---|---|---:|---|---| +| `id` | `INTEGER` | 否 | `—` | PK | +| `event_id` | `VARCHAR(32)` | 是 | `—` | UNIQUE | +| `ts` | `DATETIME` | 是 | `now` | — | +| `key_id` | `INTEGER` | 是 | `—` | — | +| `key_name` | `VARCHAR(128)` | 是 | `` | — | +| `user_id` | `INTEGER` | 是 | `—` | — | +| `owner` | `VARCHAR(128)` | 是 | `—` | — | +| `model` | `VARCHAR(64)` | 是 | `—` | — | +| `client_ip` | `VARCHAR(64)` | 是 | `—` | — | +| `request_body` | `TEXT` | 是 | `—` | — | +| `response_body` | `TEXT` | 是 | `—` | — | +| `request_body_ref` | `VARCHAR(512)` | 是 | `` | — | +| `response_body_ref` | `VARCHAR(512)` | 是 | `` | — | +| `request_body_size` | `INTEGER` | 是 | `0` | — | +| `response_body_size` | `INTEGER` | 是 | `0` | — | +| `request_body_sha256` | `VARCHAR(64)` | 是 | `` | — | +| `response_body_sha256` | `VARCHAR(64)` | 是 | `` | — | +| `prompt_tokens` | `INTEGER` | 是 | `0` | — | +| `completion_tokens` | `INTEGER` | 是 | `0` | — | +| `filter_hits` | `TEXT` | 是 | `[]` | — | +| `outcome` | `VARCHAR(16)` | 是 | `pass` | — | +| `blocked` | `BOOLEAN` | 是 | `False` | — | +| `status_code` | `INTEGER` | 是 | `200` | — | +| `latency_ms` | `INTEGER` | 是 | `0` | — | +| `search_text` | `TEXT` | 是 | `` | — | + +### `audit_search_records` + +| 字段 | 旧类型 | 可空 | 默认值 | 键/引用 | +|---|---|---:|---|---| +| `id` | `INTEGER` | 否 | `—` | PK | +| `viewer` | `VARCHAR(128)` | 否 | `—` | — | +| `query_hash` | `VARCHAR(64)` | 否 | `—` | — | +| `start_at` | `DATETIME` | 否 | `—` | — | +| `end_at` | `DATETIME` | 否 | `—` | — | +| `owner_filter` | `VARCHAR(128)` | 是 | `` | — | +| `model_filter` | `VARCHAR(128)` | 是 | `` | — | +| `result_count` | `INTEGER` | 是 | `0` | — | +| `duration_ms` | `INTEGER` | 是 | `0` | — | +| `ts` | `DATETIME` | 是 | `now` | — | + +### `audit_settings` + +| 字段 | 旧类型 | 可空 | 默认值 | 键/引用 | +|---|---|---:|---|---| +| `id` | `INTEGER` | 否 | `—` | PK | +| `image_audit_enabled` | `BOOLEAN` | 是 | `True` | — | +| `media_audit_async` | `BOOLEAN` | 是 | `False` | — | +| `text_audit_enabled` | `BOOLEAN` | 是 | `False` | — | +| `deep_scan_enabled` | `BOOLEAN` | 是 | `False` | — | +| `text_audit_api_base` | `VARCHAR(256)` | 是 | `` | — | +| `text_audit_api_key` | `VARCHAR(512)` | 是 | `` | — | +| `text_audit_model` | `VARCHAR(128)` | 是 | `` | — | +| `image_audit_api_base` | `VARCHAR(256)` | 是 | `` | — | +| `image_audit_api_key` | `VARCHAR(512)` | 是 | `` | — | +| `image_audit_model` | `VARCHAR(128)` | 是 | `` | — | +| `updated_at` | `DATETIME` | 是 | `now` | — | + +### `content_audit_scopes` + +| 字段 | 旧类型 | 可空 | 默认值 | 键/引用 | +|---|---|---:|---|---| +| `id` | `INTEGER` | 否 | `—` | PK | +| `kind` | `VARCHAR(16)` | 否 | `—` | — | +| `scope` | `VARCHAR(32)` | 否 | `—` | — | +| `enabled` | `BOOLEAN` | 是 | `True` | — | +| `mode` | `VARCHAR(16)` | 是 | `async` | — | +| `created_at` | `DATETIME` | 是 | `now` | — | + +### `departments` + +| 字段 | 旧类型 | 可空 | 默认值 | 键/引用 | +|---|---|---:|---|---| +| `id` | `INTEGER` | 否 | `—` | PK | +| `name` | `VARCHAR(64)` | 否 | `—` | UNIQUE | +| `created_at` | `DATETIME` | 是 | `now` | — | + +### `dept_budgets` + +| 字段 | 旧类型 | 可空 | 默认值 | 键/引用 | +|---|---|---:|---|---| +| `id` | `INTEGER` | 否 | `—` | PK | +| `dept_id` | `INTEGER` | 否 | `—` | UNIQUE;FK → `departments.id` | +| `monthly_point_budget` | `FLOAT` | 是 | `0.0` | — | +| `alert_threshold_pct` | `INTEGER` | 是 | `80` | — | +| `notify_enabled` | `BOOLEAN` | 是 | `True` | — | +| `last_alert_period` | `VARCHAR(7)` | 是 | `` | — | +| `last_alert_threshold` | `INTEGER` | 是 | `0` | — | +| `updated_at` | `DATETIME` | 是 | `now` | — | + +### `feishu_auth_settings` + +| 字段 | 旧类型 | 可空 | 默认值 | 键/引用 | +|---|---|---:|---|---| +| `id` | `INTEGER` | 否 | `—` | PK | +| `enabled` | `BOOLEAN` | 是 | `False` | — | +| `app_id` | `VARCHAR(128)` | 是 | `` | — | +| `app_secret` | `VARCHAR(512)` | 是 | `` | — | +| `default_dept` | `VARCHAR(128)` | 是 | `飞书用户` | — | +| `allowed_tenants` | `TEXT` | 是 | `[]` | — | +| `provisioning_mode` | `VARCHAR(16)` | 是 | `pending` | — | +| `updated_at` | `DATETIME` | 是 | `now` | — | + +### `filter_rules` + +| 字段 | 旧类型 | 可空 | 默认值 | 键/引用 | +|---|---|---:|---|---| +| `id` | `INTEGER` | 否 | `—` | PK | +| `name` | `VARCHAR(64)` | 否 | `—` | — | +| `rule_type` | `VARCHAR(16)` | 是 | `keyword` | — | +| `pattern` | `TEXT` | 否 | `—` | — | +| `action` | `VARCHAR(16)` | 是 | `block` | — | +| `severity` | `VARCHAR(8)` | 是 | `中` | — | +| `enabled` | `BOOLEAN` | 是 | `True` | — | +| `builtin` | `BOOLEAN` | 是 | `False` | — | +| `scope` | `VARCHAR(32)` | 是 | `global` | — | +| `dimension` | `VARCHAR(32)` | 是 | `sensitive_content` | — | +| `created_at` | `DATETIME` | 是 | `now` | — | + +### `hallucination_events` + +| 字段 | 旧类型 | 可空 | 默认值 | 键/引用 | +|---|---|---:|---|---| +| `id` | `INTEGER` | 否 | `—` | PK | +| `request_id` | `VARCHAR(64)` | 是 | `` | — | +| `key_id` | `INTEGER` | 是 | `—` | FK → `virtual_keys.id` | +| `application_id` | `INTEGER` | 是 | `—` | FK → `applications.id` | +| `dept_id` | `INTEGER` | 是 | `—` | FK → `departments.id` | +| `model` | `VARCHAR(128)` | 是 | `` | — | +| `mode` | `VARCHAR(16)` | 是 | `async` | — | +| `action` | `VARCHAR(16)` | 是 | `observe` | — | +| `verdict` | `VARCHAR(24)` | 是 | `insufficient` | — | +| `support_score` | `INTEGER` | 是 | `0` | — | +| `question` | `TEXT` | 是 | `` | — | +| `answer` | `TEXT` | 是 | `` | — | +| `claims` | `TEXT` | 是 | `[]` | — | +| `evidence` | `TEXT` | 是 | `[]` | — | +| `latency_ms` | `INTEGER` | 是 | `0` | — | +| `error` | `VARCHAR(512)` | 是 | `` | — | +| `created_at` | `DATETIME` | 是 | `now` | — | + +### `hallucination_policies` + +| 字段 | 旧类型 | 可空 | 默认值 | 键/引用 | +|---|---|---:|---|---| +| `id` | `INTEGER` | 否 | `—` | PK | +| `scope` | `VARCHAR(32)` | 否 | `global` | — | +| `enabled` | `BOOLEAN` | 是 | `False` | — | +| `mode` | `VARCHAR(16)` | 是 | `async` | — | +| `action` | `VARCHAR(16)` | 是 | `observe` | — | +| `kb_ids` | `TEXT` | 是 | `[]` | — | +| `support_threshold` | `INTEGER` | 是 | `70` | — | +| `evidence_threshold` | `FLOAT` | 是 | `0.35` | — | +| `top_k` | `INTEGER` | 是 | `4` | — | +| `max_claims` | `INTEGER` | 是 | `8` | — | +| `created_at` | `DATETIME` | 是 | `now` | — | +| `updated_at` | `DATETIME` | 是 | `now` | — | + +### `hallucination_settings` + +| 字段 | 旧类型 | 可空 | 默认值 | 键/引用 | +|---|---|---:|---|---| +| `id` | `INTEGER` | 否 | `1` | PK | +| `api_base` | `VARCHAR(256)` | 是 | `` | — | +| `api_key` | `VARCHAR(512)` | 是 | `` | — | +| `model` | `VARCHAR(128)` | 是 | `` | — | +| `timeout_seconds` | `INTEGER` | 是 | `15` | — | +| `updated_at` | `DATETIME` | 是 | `now` | — | + +### `internal_service_keys` + +| 字段 | 旧类型 | 可空 | 默认值 | 键/引用 | +|---|---|---:|---|---| +| `id` | `INTEGER` | 否 | `—` | PK | +| `virtual_key_id` | `INTEGER` | 是 | `—` | FK → `virtual_keys.id` | +| `raw_key_encrypted` | `VARCHAR(512)` | 是 | `` | — | +| `created_at` | `DATETIME` | 是 | `now` | — | + +### `knowledge_bases` + +| 字段 | 旧类型 | 可空 | 默认值 | 键/引用 | +|---|---|---:|---|---| +| `id` | `INTEGER` | 否 | `—` | PK | +| `name` | `VARCHAR(128)` | 否 | `—` | UNIQUE | +| `description` | `TEXT` | 是 | `` | — | +| `embedding_model` | `VARCHAR(128)` | 否 | `—` | — | +| `chunk_size` | `INTEGER` | 是 | `800` | — | +| `chunk_overlap` | `INTEGER` | 是 | `100` | — | +| `dept_scope` | `TEXT` | 是 | `[]` | — | +| `enabled` | `BOOLEAN` | 是 | `True` | — | +| `created_by` | `VARCHAR(128)` | 是 | `` | — | +| `created_at` | `DATETIME` | 是 | `now` | — | +| `updated_at` | `DATETIME` | 是 | `now` | — | + +### `knowledge_chunks` + +| 字段 | 旧类型 | 可空 | 默认值 | 键/引用 | +|---|---|---:|---|---| +| `id` | `INTEGER` | 否 | `—` | PK | +| `kb_id` | `INTEGER` | 否 | `—` | FK → `knowledge_bases.id` | +| `doc_id` | `INTEGER` | 否 | `—` | FK → `knowledge_documents.id` | +| `chunk_index` | `INTEGER` | 是 | `0` | — | +| `content` | `TEXT` | 是 | `` | — | +| `embedding` | `TEXT` | 是 | `[]` | — | +| `token_count` | `INTEGER` | 是 | `0` | — | +| `created_at` | `DATETIME` | 是 | `now` | — | + +### `knowledge_documents` + +| 字段 | 旧类型 | 可空 | 默认值 | 键/引用 | +|---|---|---:|---|---| +| `id` | `INTEGER` | 否 | `—` | PK | +| `kb_id` | `INTEGER` | 否 | `—` | FK → `knowledge_bases.id` | +| `filename` | `VARCHAR(256)` | 是 | `` | — | +| `source_type` | `VARCHAR(16)` | 是 | `upload` | — | +| `status` | `VARCHAR(16)` | 是 | `pending` | — | +| `status_message` | `TEXT` | 是 | `` | — | +| `content` | `TEXT` | 是 | `` | — | +| `char_count` | `INTEGER` | 是 | `0` | — | +| `chunk_count` | `INTEGER` | 是 | `0` | — | +| `uploaded_by` | `VARCHAR(128)` | 是 | `` | — | +| `created_at` | `DATETIME` | 是 | `now` | — | +| `updated_at` | `DATETIME` | 是 | `now` | — | + +### `managed_conversations` + +| 字段 | 旧类型 | 可空 | 默认值 | 键/引用 | +|---|---|---:|---|---| +| `id` | `VARCHAR(48)` | 否 | `—` | PK | +| `user_id` | `INTEGER` | 否 | `—` | FK → `users.id` | +| `application_id` | `INTEGER` | 否 | `—` | FK → `applications.id` | +| `title` | `VARCHAR(160)` | 是 | `新会话` | — | +| `status` | `VARCHAR(16)` | 否 | `active` | — | +| `next_seq` | `INTEGER` | 否 | `1` | — | +| `head_hash` | `VARCHAR(64)` | 否 | `` | — | +| `busy` | `BOOLEAN` | 否 | `False` | — | +| `busy_token` | `VARCHAR(48)` | 否 | `` | — | +| `busy_since` | `DATETIME` | 是 | `—` | — | +| `created_at` | `DATETIME` | 是 | `now` | — | +| `updated_at` | `DATETIME` | 是 | `now` | — | + +### `managed_messages` + +| 字段 | 旧类型 | 可空 | 默认值 | 键/引用 | +|---|---|---:|---|---| +| `id` | `INTEGER` | 否 | `—` | PK | +| `conversation_id` | `VARCHAR(48)` | 否 | `—` | FK → `managed_conversations.id` | +| `seq` | `INTEGER` | 否 | `—` | — | +| `role` | `VARCHAR(16)` | 否 | `—` | — | +| `content` | `TEXT` | 否 | `—` | — | +| `content_sha256` | `VARCHAR(64)` | 否 | `—` | — | +| `prev_hash` | `VARCHAR(64)` | 否 | `` | — | +| `chain_hash` | `VARCHAR(64)` | 否 | `—` | — | +| `created_at` | `DATETIME` | 是 | `now` | — | + +### `model_aliases` + +| 字段 | 旧类型 | 可空 | 默认值 | 键/引用 | +|---|---|---:|---|---| +| `id` | `INTEGER` | 否 | `—` | PK | +| `alias` | `VARCHAR(128)` | 否 | `—` | UNIQUE | +| `target_model` | `VARCHAR(128)` | 否 | `—` | — | +| `enabled` | `BOOLEAN` | 是 | `True` | — | +| `note` | `VARCHAR(256)` | 是 | `` | — | +| `created_at` | `DATETIME` | 是 | `now` | — | +| `updated_at` | `DATETIME` | 是 | `now` | — | + +### `model_pricing` + +| 字段 | 旧类型 | 可空 | 默认值 | 键/引用 | +|---|---|---:|---|---| +| `id` | `INTEGER` | 否 | `—` | PK | +| `model` | `VARCHAR(128)` | 否 | `—` | — | +| `input_price_per_1k` | `FLOAT` | 是 | `0.0` | — | +| `output_price_per_1k` | `FLOAT` | 是 | `0.0` | — | +| `effective_from` | `DATETIME` | 是 | `now` | — | +| `note` | `VARCHAR(256)` | 是 | `` | — | +| `created_at` | `DATETIME` | 是 | `now` | — | + +### `model_qps_configs` + +| 字段 | 旧类型 | 可空 | 默认值 | 键/引用 | +|---|---|---:|---|---| +| `id` | `INTEGER` | 否 | `—` | PK | +| `model` | `VARCHAR(128)` | 否 | `—` | UNIQUE | +| `qps` | `INTEGER` | 否 | `—` | — | +| `enabled` | `BOOLEAN` | 是 | `True` | — | +| `updated_at` | `DATETIME` | 是 | `now` | — | + +### `model_requests` + +| 字段 | 旧类型 | 可空 | 默认值 | 键/引用 | +|---|---|---:|---|---| +| `id` | `INTEGER` | 否 | `—` | PK | +| `ts` | `DATETIME` | 是 | `now` | — | +| `user_id` | `INTEGER` | 否 | `—` | FK → `users.id` | +| `dept_id` | `INTEGER` | 是 | `—` | — | +| `user_snapshot` | `VARCHAR(128)` | 是 | `` | — | +| `model_name` | `VARCHAR(128)` | 否 | `—` | — | +| `provider_name` | `VARCHAR(64)` | 是 | `` | — | +| `purpose` | `TEXT` | 是 | `` | — | +| `quota_required` | `INTEGER` | 是 | `—` | — | +| `status` | `VARCHAR(16)` | 是 | `pending` | — | +| `target_key_id` | `INTEGER` | 是 | `—` | — | +| `reviewer` | `VARCHAR(128)` | 是 | `` | — | +| `review_comment` | `TEXT` | 是 | `` | — | +| `reviewed_at` | `DATETIME` | 是 | `—` | — | +| `created_at` | `DATETIME` | 是 | `now` | — | + +### `notify_config` + +| 字段 | 旧类型 | 可空 | 默认值 | 键/引用 | +|---|---|---:|---|---| +| `id` | `INTEGER` | 否 | `—` | PK | +| `enabled` | `BOOLEAN` | 是 | `False` | — | +| `webhook_url` | `VARCHAR(512)` | 是 | `` | — | +| `signing_secret` | `VARCHAR(512)` | 是 | `` | — | +| `notify_actions` | `VARCHAR(32)` | 是 | `block,redact` | — | +| `dept_scope` | `TEXT` | 是 | `[]` | — | +| `updated_at` | `DATETIME` | 是 | `now` | — | + +### `pricing_config` + +| 字段 | 旧类型 | 可空 | 默认值 | 键/引用 | +|---|---|---:|---|---| +| `id` | `INTEGER` | 否 | `—` | PK | +| `points_per_yuan` | `FLOAT` | 是 | `100.0` | — | +| `overhead_factor` | `FLOAT` | 是 | `1.0` | — | +| `updated_at` | `DATETIME` | 是 | `now` | — | + +### `prompt_categories` + +| 字段 | 旧类型 | 可空 | 默认值 | 键/引用 | +|---|---|---:|---|---| +| `id` | `INTEGER` | 否 | `—` | PK | +| `name` | `VARCHAR(64)` | 否 | `—` | UNIQUE | +| `description` | `VARCHAR(256)` | 是 | `` | — | +| `created_at` | `DATETIME` | 是 | `now` | — | + +### `prompt_favorites` + +| 字段 | 旧类型 | 可空 | 默认值 | 键/引用 | +|---|---|---:|---|---| +| `id` | `INTEGER` | 否 | `—` | PK | +| `user_id` | `INTEGER` | 否 | `—` | FK → `users.id` | +| `template_id` | `INTEGER` | 否 | `—` | FK → `prompt_templates.id` | +| `created_at` | `DATETIME` | 是 | `now` | — | + +### `prompt_templates` + +| 字段 | 旧类型 | 可空 | 默认值 | 键/引用 | +|---|---|---:|---|---| +| `id` | `INTEGER` | 否 | `—` | PK | +| `name` | `VARCHAR(128)` | 否 | `—` | UNIQUE | +| `description` | `TEXT` | 是 | `` | — | +| `category_id` | `INTEGER` | 是 | `—` | FK → `prompt_categories.id` | +| `tags` | `TEXT` | 是 | `[]` | — | +| `dept_scope` | `TEXT` | 是 | `[]` | — | +| `current_version_id` | `INTEGER` | 是 | `—` | — | +| `use_count` | `INTEGER` | 是 | `0` | — | +| `enabled` | `BOOLEAN` | 是 | `True` | — | +| `created_by` | `VARCHAR(128)` | 是 | `` | — | +| `created_at` | `DATETIME` | 是 | `now` | — | +| `updated_at` | `DATETIME` | 是 | `now` | — | + +### `prompt_versions` + +| 字段 | 旧类型 | 可空 | 默认值 | 键/引用 | +|---|---|---:|---|---| +| `id` | `INTEGER` | 否 | `—` | PK | +| `template_id` | `INTEGER` | 否 | `—` | FK → `prompt_templates.id` | +| `version_number` | `INTEGER` | 否 | `—` | — | +| `content` | `TEXT` | 否 | `—` | — | +| `variables` | `TEXT` | 是 | `[]` | — | +| `change_note` | `VARCHAR(256)` | 是 | `` | — | +| `created_by` | `VARCHAR(128)` | 是 | `` | — | +| `created_at` | `DATETIME` | 是 | `now` | — | + +### `providers` + +| 字段 | 旧类型 | 可空 | 默认值 | 键/引用 | +|---|---|---:|---|---| +| `id` | `INTEGER` | 否 | `—` | PK | +| `name` | `VARCHAR(64)` | 否 | `—` | UNIQUE | +| `base_url` | `VARCHAR(256)` | 否 | `—` | — | +| `api_key` | `VARCHAR(512)` | 是 | `` | — | +| `models` | `TEXT` | 是 | `[]` | — | +| `backends` | `TEXT` | 是 | `` | — | +| `enabled` | `BOOLEAN` | 是 | `True` | — | +| `created_at` | `DATETIME` | 是 | `now` | — | + +### `rate_limit_rules` + +| 字段 | 旧类型 | 可空 | 默认值 | 键/引用 | +|---|---|---:|---|---| +| `id` | `INTEGER` | 否 | `—` | PK | +| `name` | `VARCHAR(64)` | 否 | `—` | — | +| `enabled` | `BOOLEAN` | 是 | `True` | — | +| `dimension` | `VARCHAR(16)` | 是 | `consumer` | — | +| `field_name` | `VARCHAR(64)` | 是 | `` | — | +| `match_type` | `VARCHAR(16)` | 是 | `any` | — | +| `pattern` | `VARCHAR(256)` | 是 | `` | — | +| `limit_count` | `INTEGER` | 否 | `60` | — | +| `window_seconds` | `INTEGER` | 是 | `60` | — | +| `created_at` | `DATETIME` | 是 | `now` | — | +| `updated_at` | `DATETIME` | 是 | `now` | — | + +### `routing_groups` + +| 字段 | 旧类型 | 可空 | 默认值 | 键/引用 | +|---|---|---:|---|---| +| `id` | `INTEGER` | 否 | `—` | PK | +| `virtual_model` | `VARCHAR(128)` | 否 | `—` | UNIQUE | +| `strategy` | `VARCHAR(16)` | 是 | `priority` | — | +| `description` | `VARCHAR(256)` | 是 | `` | — | +| `enabled` | `BOOLEAN` | 是 | `True` | — | +| `created_by` | `VARCHAR(128)` | 是 | `` | — | +| `created_at` | `DATETIME` | 是 | `now` | — | +| `updated_at` | `DATETIME` | 是 | `now` | — | + +### `routing_targets` + +| 字段 | 旧类型 | 可空 | 默认值 | 键/引用 | +|---|---|---:|---|---| +| `id` | `INTEGER` | 否 | `—` | PK | +| `group_id` | `INTEGER` | 否 | `—` | FK → `routing_groups.id` | +| `target_model` | `VARCHAR(128)` | 否 | `—` | — | +| `priority` | `INTEGER` | 是 | `100` | — | +| `capability_tags` | `TEXT` | 是 | `[]` | — | +| `enabled` | `BOOLEAN` | 是 | `True` | — | +| `created_at` | `DATETIME` | 是 | `now` | — | + +### `tool_definitions` + +| 字段 | 旧类型 | 可空 | 默认值 | 键/引用 | +|---|---|---:|---|---| +| `id` | `INTEGER` | 否 | `—` | PK | +| `name` | `VARCHAR(128)` | 否 | `—` | UNIQUE | +| `description` | `TEXT` | 是 | `` | — | +| `endpoint_url` | `VARCHAR(512)` | 否 | `—` | — | +| `http_method` | `VARCHAR(8)` | 是 | `POST` | — | +| `headers_template` | `TEXT` | 是 | `` | — | +| `input_schema` | `TEXT` | 是 | `{}` | — | +| `timeout_seconds` | `INTEGER` | 是 | `15` | — | +| `dept_scope` | `TEXT` | 是 | `[]` | — | +| `enabled` | `BOOLEAN` | 是 | `True` | — | +| `created_by` | `VARCHAR(128)` | 是 | `` | — | +| `created_at` | `DATETIME` | 是 | `now` | — | +| `updated_at` | `DATETIME` | 是 | `now` | — | + +### `users` + +| 字段 | 旧类型 | 可空 | 默认值 | 键/引用 | +|---|---|---:|---|---| +| `id` | `INTEGER` | 否 | `—` | PK | +| `account` | `VARCHAR(128)` | 否 | `—` | UNIQUE | +| `name` | `VARCHAR(64)` | 是 | `` | — | +| `dept_id` | `INTEGER` | 是 | `—` | FK → `departments.id` | +| `password_hash` | `VARCHAR(128)` | 是 | `` | — | +| `feishu_open_id` | `VARCHAR(128)` | 是 | `—` | UNIQUE | +| `feishu_union_id` | `VARCHAR(128)` | 是 | `—` | — | +| `auth_source` | `VARCHAR(16)` | 是 | `local` | — | +| `active` | `BOOLEAN` | 是 | `True` | — | +| `provisioning_status` | `VARCHAR(16)` | 是 | `active` | — | +| `failed_logins` | `INTEGER` | 是 | `0` | — | +| `locked_until` | `DATETIME` | 是 | `—` | — | +| `deleted_name_snapshot` | `VARCHAR(128)` | 是 | `` | — | +| `created_at` | `DATETIME` | 是 | `now` | — | +| `totp_secret` | `VARCHAR(255)` | 是 | `` | — | +| `totp_enabled` | `BOOLEAN` | 是 | `False` | — | +| `totp_last_step` | `INTEGER` | 是 | `—` | — | +| `totp_backup_codes` | `TEXT` | 是 | `[]` | — | +| `totp_confirmed_at` | `DATETIME` | 是 | `—` | — | + +### `virtual_keys` + +| 字段 | 旧类型 | 可空 | 默认值 | 键/引用 | +|---|---|---:|---|---| +| `id` | `INTEGER` | 否 | `—` | PK | +| `key_hash` | `VARCHAR(64)` | 否 | `—` | UNIQUE | +| `key_prefix` | `VARCHAR(16)` | 是 | `—` | — | +| `name` | `VARCHAR(128)` | 是 | `—` | — | +| `user_id` | `INTEGER` | 是 | `—` | FK → `users.id` | +| `application_id` | `INTEGER` | 是 | `—` | FK → `applications.id` | +| `owner` | `VARCHAR(128)` | 是 | `—` | — | +| `allowed_models` | `TEXT` | 是 | `[]` | — | +| `quota_tokens` | `INTEGER` | 是 | `—` | — | +| `used_tokens` | `INTEGER` | 是 | `0` | — | +| `rpm_limit` | `INTEGER` | 是 | `—` | — | +| `active` | `BOOLEAN` | 是 | `True` | — | +| `expires_at` | `DATETIME` | 是 | `—` | — | +| `created_at` | `DATETIME` | 是 | `now` | — | diff --git a/docs/legacy-import-runbook.md b/docs/legacy-import-runbook.md new file mode 100644 index 0000000..7a4d644 --- /dev/null +++ b/docs/legacy-import-runbook.md @@ -0,0 +1,42 @@ +# 旧数据导入手册 + +当前工程提供两阶段、可重复执行的迁移入口,避免直接把旧 SQLite 密文或不完整外键写进新业务表。 + +## 1. 只读导出 + +在旧系统停止配置写入后,对数据库快照执行: + +```bash +python3 scripts/export_legacy_data.py /secure-snapshot/gateway.db > /secure-transfer/gateway.jsonl +``` + +默认不导出密码、API Key、TOTP、请求头等敏感列。需要迁移 Provider/工具等凭据时,只能在隔离迁移环境使用 `--include-encrypted`;旧密文不能作为新密文直接写入,必须由专用转换步骤用旧密钥解密,再用新的用途标签和活动 KEK 加密。 + +## 2. 校验和暂存 + +先执行不连接数据库的校验: + +```bash +gateway-legacy-import --input /secure-transfer/gateway.jsonl --dry-run +``` + +再写入 PostgreSQL 不可变暂存区: + +```bash +export DATABASE_URL='postgres://...' +gateway-legacy-import --input /secure-transfer/gateway.jsonl +``` + +每条记录会验证 SHA-256,使用固定 namespace 生成 UUIDv5,并同时写入 `legacy_import_records` 与 `legacy_id_mappings`。相同快照重复执行会复用 batch;同一旧 ID 的内容发生变化时失败关闭,不会静默覆盖先前暂存数据。 + +## 3. 领域转换与验收 + +暂存成功不等于已写入生产业务表。必须按外键拓扑执行领域转换:部门 → 身份 → Provider/API Key → Prompt/知识/工具 → 应用版本 → 审计历史。每批转换后核对: + +- 源记录数、目标记录数、明确跳过数和失败数之和相等; +- 所有旧外键都能从 `legacy_id_mappings` 找到映射; +- Provider、工具、通知、TOTP 等密文可使用新 keyring 解密,且数据库中不存在旧明文; +- 审计只迁移有界预览与结构化元数据,不迁移无限长原始正文; +- 应用发布版本引用的 Prompt、知识库和工具均存在且启用。 + +当前仓库没有用户的实际 SQLite 数据文件,因此已完成的是可验证暂存管道;生产领域转换必须在拿到脱敏快照及旧密钥托管授权后执行,不能凭空假设旧密文格式。 diff --git a/docs/rewrite-progress.md b/docs/rewrite-progress.md new file mode 100644 index 0000000..30ec68f --- /dev/null +++ b/docs/rewrite-progress.md @@ -0,0 +1,115 @@ +# 重构进度 + +更新时间:2026-08-11 + +## 已完成:M0 工程基线 + +- Go 1.26 模块、配置校验、结构化日志和优雅退出。 +- PostgreSQL `pgx` 连接池、critical/cache 双 Redis 客户端。 +- 独立且带校验和、事务和 advisory lock 的数据库迁移器。 +- OpenAI 兼容入口、bootstrap key 鉴权、请求体限制、连接池和 SSE 透传。 +- `healthz`、依赖感知 `readyz`、基础 Prometheus 指标。 +- PostgreSQL outbox、API Key、Provider 与分区审计表的首版 schema。 +- 不包含 MinIO/S3、ClickHouse 的 Docker Compose 基线。 +- Art Design Pro v3.0.2 管理端与门户端,已执行官方精简流程并通过生产构建。 +- OpenAPI 运行时契约和共享 TypeScript 系统客户端起点。 + +## 已完成:M1 身份、Provider 与配置面 + +已完成: + +- 管理员、门户用户 schema 和独立 bootstrap 命令。 +- 兼容 Python 版本 60 万轮/旧 12 万轮 PBKDF2-SHA256,并在成功登录时升级旧哈希。 +- critical Redis 可撤销不透明会话、数据库 active/role 复核、失败次数原子更新和账号锁定。 +- `/api/v1/admin/*`、`/api/v1/portal/*` 登录、身份、菜单、退出契约。 +- 管理端与门户端 TOTP 配置、二次登录、停用、备用码重置;含临时挑战令牌、时间步原子防重放和备用码一次性消费。 +- 两套 Art 登录页已接入动态验证码/备用码,并通过生产构建。 +- 数据库 API Key 创建、列表、scope、过期、Redis 共享缓存和即时撤销;明文只显示一次,创建/撤销与 outbox 同事务。 +- Art 管理端 API Key 页面、动态菜单入口及生产构建。 +- Provider AES-256-GCM 凭据、SSRF 地址校验、revision 和事务 outbox。 +- Provider 数据库运行时原子快照、默认/显式路由、能力约束、共享连接池、周期刷新和上一有效版本回退。 +- Art 管理端供应商页面及生产后端权限模式。 +- Provider 连接测试、模型目录同步、历史模型停用策略和 `provider.models_synced` 事务 outbox。 +- Art 管理端连接测试、同步结果和模型目录查看交互。 +- 版本化 KEK keyring、历史密钥解密、Provider 凭据事务轮换和管理端轮换入口。 +- PostgreSQL 权威 Provider 配置、critical Redis 多实例变更通知、进程内不可变快照与周期兜底刷新。 +- 管理员/门户账号管理、内置角色、直接权限字符串、权限感知动态菜单和即时数据库复核。 +- bootstrap API Key 显式关闭开关及兼容入口使用量指标。 +- 层级部门 CRUD、循环检测、在用停用保护、门户账号部门绑定和部门 outbox。 +- OIDC 配置面、加密 Client Secret、Authorization Code + PKCE、RS256/JWKS 校验、自动开户和一次性交换码。 +- SAML 配置面、SP metadata、SP-initiated Redirect/POST 流程、SHA-256+ XML 签名断言校验、RelayState/InResponseTo 绑定、断言防重放、自动开户和部门绑定。 +- 旧 Python 网关 38 个持久化实体字段字典、实体去向清单、确定性 UUIDv5 生成器和幂等旧 ID 映射表。 +- SAML 与 OIDC 均已通过 PostgreSQL + Redis + 本地模拟 IdP 的端到端登录、自动开户、一次性交换和重放拦截测试。 + +## 已完成:M2 流量治理与路由 + +已完成: + +- API Key 级每分钟请求上限和 UTC 自然月请求配额,`0` 明确表示不限制。 +- critical Redis Lua 原子准入;限流返回 `429`/`Retry-After`/`X-RateLimit-*`,配置限制时 Redis 故障失败关闭。 +- API Key 限额的 PostgreSQL 权威配置、鉴权缓存传递、Art 管理表单与 OpenAPI 契约。 +- PostgreSQL + Redis + 模拟上游端到端测试:RPM=2 精确放行 2 次,月配额=3 精确放行 3 次。 +- API Key UTC 自然月 Token 配额:请求前按输入体积与最大输出预算原子预留,响应结束按上游 usage 原子校准。 +- OpenAI/Responses/Anthropic 的 JSON 与 SSE usage 增量提取;不缓冲完整流式响应,上游未返回 usage 时按预留预算保守计量。 +- Art 管理端展示当月 Token 用量并可即时调整 RPM、月请求和月 Token 配额;更新同时清除共享鉴权缓存并写入 outbox。 +- Provider 级响应头超时、有限指数退避和熔断;仅 GET/HEAD 或携带 `Idempotency-Key` 的可重放请求允许重试,所有阈值均可通过环境变量配置。 +- PostgreSQL 模型别名/路由规则、事务 outbox、管理 CRUD、Redis 多实例变更通知和进程内不可变路由快照。 +- 按 endpoint、API Key、tenant 条件匹配;最高优先级分组内使用请求 ID 做确定性加权选择,显式 Provider 作为候选约束。 +- 已登记的模型别名在条件不匹配、规则停用或显式 Provider 不属于候选集时失败关闭,避免把别名透传到默认上游绕过策略。 +- 代理在命中规则时改写上游模型并返回 `X-Gateway-Model`;未配置规则时不读取请求体,保持原代理热路径性能。 +- Art 管理端模型路由页面,可维护别名、上游模型、Provider、权重、优先级、条件和启停状态。 + +## 已完成:M3 审计、usage、内容策略与成本 + +已完成: + +- 网关调用的有界异步审计队列、批量 PostgreSQL COPY、失败保留/重试、队列满降级和优雅停机排空。 +- 请求 ID、API Key、tenant、Provider、原始/目标模型、协议、HTTP 状态、延迟及输入/输出 Token 采集。 +- PostgreSQL 按日 API Key/Provider/模型 usage 聚合,不在请求热路径同步写数据库。 +- `audit:read`/`usage:read` 权限、筛选查询 API、Art 管理端审计与用量页面。 +- 审计 accepted/dropped/written/flush failures Prometheus 指标。 +- 独立 `gateway-outbox-worker`,使用 PostgreSQL `FOR UPDATE SKIP LOCKED` 租约支持多实例并行抢占。 +- Redis Lua 原子事件 ID 去重与 Stream 写入,数据库确认丢失后重投不会产生重复 Stream 消息。 +- 指数退避、最大尝试次数、死信状态、`outbox:read`/`outbox:manage` 权限、管理 API 与 Art 人工重试页面。 +- `event_consumptions` 与业务 handler 共用 PostgreSQL 事务的消费者幂等入口,handler 回滚时消费标记同步回滚。 +- 独立 `gateway-maintenance` 周期任务,按 UTC 月预创建审计分区,事务迁移 default 分区数据,并按配置直接丢弃完整过期分区、精确清理边界月和长期 usage。 +- 审计维护使用 PostgreSQL advisory lock 与表级事务锁,多副本启动时仍只有一个实例执行;重复运行保持幂等。 +- PostgreSQL 内容策略 CRUD、事务 outbox、`content_policy:read/manage` 权限与多实例周期刷新;运行时使用不可变 RE2 编译快照。 +- 内容策略支持 `audit`、`block`、`redact`,按 endpoint、模型和 API Key 限定范围;只遍历提示词文本字段,不改写工具 schema、图片/Base64 等非文本载荷。 +- 默认敏感信息脱敏策略、阻断响应、`X-Gateway-Content-Redacted` 响应头,以及不包含原始命中值的审计标签。 +- Art 管理端内容策略页面,可维护优先级、范围、规则、替换文本和启停状态。 +- 带生效/失效时间的模型价格版本,支持 Provider + 精确模型或末尾 `*` 前缀,精确规则优先并使用请求时刻选择版本。 +- 输入/输出价格均以“每百万 Token 的微货币单位”精确存储;usage 完成时核算请求成本,审计与按日聚合均保存 `cost_microunits`。 +- `pricing:read/manage` 权限、模型价格管理 API/Art 页面,审计与用量页面展示估算成本。 + +## 已完成:M4 Prompt、知识、工具、应用编排和通知 + +- Prompt 分类/模板和只增不改的版本,显式变量声明、必填/默认值校验、历史版本激活、管理端和 API Key 渲染接口。 +- PostgreSQL 知识库保存不超过 2 MiB 的提取文本,不依赖 MinIO/S3;段落感知重叠分块,FTS 与中文二元词片混合召回,并通过 `Retriever` 接口隔离未来向量实现。 +- 声明式 HTTP 工具、基础 JSON Schema 入参验证、KEK 加密请求头、注册及拨号时 SSRF 校验、禁止重定向、1 MiB 响应上限和工具调用记录。 +- AI 应用草稿、引用完整性校验、不可变发布版本和运行记录;应用入口组合 Prompt/RAG/OpenAI 工具调用循环,内部每个模型请求仍走原网关全治理链路。 +- 通知通道与可靠投递记录,独立 Worker 消费 outbox,精确/前缀事件订阅、HMAC-SHA256 签名、失败留痕与人工重试。 +- 内容策略命中由异步审计批处理在同一事务写入 `content_policy.matched` outbox,不在模型请求热路径同步投递 Webhook,也不包含原始敏感命中值。 +- Art Design Pro 管理端新增 Prompt、知识库、工具、应用和通知五个页面,权限与动态菜单均由服务端控制。 +- 独立前端多阶段镜像将 Art 管理端生产构建交付给 Nginx,并同源代理管理 API、模型 API、SSE 与健康检查;Compose 可直接启动完整管理面。 + +## 已完成:M5 契约、迁移与切换工程 + +- 通过 Python AST 自动盘点旧源码,确认实际为 201 个路由装饰器,而不是早期估算的 141 个;与 OpenAPI 生成可重复的覆盖/替代/退役/缺口矩阵。 +- 可选影子中间件只复制确定性采样的非流式 JSON POST,使用独立影子 Key,不转发生产凭据;异步比较状态码和 JSON 结构签名并输出 Prometheus 指标。 +- 独立 `gateway-loadtest` 支持并发、时长、超时、错误率和 p95 阈值,输出机器可读 JSON 并以退出码阻断不达标发布。 +- 原子 Nginx upstream 切换脚本在目标探活、`nginx -t` 和 reload 任一步失败时恢复备份;提供明确的回退触发条件与演练手册。 +- 旧 SQLite 只读 JSONL 导出、逐记录 SHA-256、确定性 UUIDv5 和 PostgreSQL 不可变暂存管道已完成;默认排除敏感列,相同快照幂等,旧记录变化失败关闭。 +- 201 条旧路由已全部形成明确决策:85 条同契约覆盖、112 条由新契约替代、4 条基于安全或架构 ADR 退役、未决缺口 0;OpenAPI 0.10.0 覆盖全部 146 条 Go 字面量路由。 + +## 已完成:M6 门户工作台与扩展治理 + +- 门户资产目录、Prompt 查看/搜索/收藏、个人审计/Token/成本统计、接入说明和模型访问申请;所有列表按登录用户与部门范围服务端过滤。 +- 管理端模型申请审批、事实核验 Provider 设置、作用域策略与核验事件页面;事实核验复用 Provider 加密凭据,不重复保存 API Key/Base URL。 +- 已发布应用的门户单轮入口和服务端托管会话;运行凭证使用独立 AEAD purpose 加密,明文不返回浏览器。 +- 会话串行租约避免并发回答交错,用户/助手消息以不可变顺序和 SHA-256 哈希链保存,重放前验证完整性,最多 200 条消息。 +- 应用目录/资产依赖分析、历史版本回滚、知识文档重新分块、Prompt 详情、运行时快照刷新、系统信息和 24 小时监控汇总。 +- Art Design Pro 管理端新增模型治理页面;门户端新增资产目录、Prompt 广场、个人用量与模型权限四个生产页面。 +- Compose 同时交付 API、管理端 `8081` 和门户端 `8082`;同一前端 Dockerfile 通过受限 build arg 构建两套独立 SPA。 + +工程实现与本地生产等价验证已关闭。正式上线仍是外部发布门禁:拿到实际旧数据库脱敏快照和旧密钥迁移授权后执行领域转换,并在真实新旧双系统与生产等价流量下完成影子观察、容量验收及切换/回退演练;这些动作不会在缺少生产数据和授权时伪造为已执行。 diff --git a/docs/route-contract-matrix.md b/docs/route-contract-matrix.md new file mode 100644 index 0000000..ddf8d54 --- /dev/null +++ b/docs/route-contract-matrix.md @@ -0,0 +1,213 @@ +# 旧 Python → Go 路由契约矩阵 + +> 由 `scripts/compare_route_contracts.py` 通过 AST 与 OpenAPI 自动生成;`replaced` 表示能力有新契约承接,仍需兼容层或客户端迁移,不能视为原路由原样可用。 + +- 旧源码路由装饰器:201 +- 同方法同规范路径:85 +- 新契约替代:112 +- 明确退役:4 +- 尚无契约:0 + +| 状态 | 方法 | 旧路径 | Go 契约/预期目标 | 来源 | +|---|---|---|---|---| +| 新契约替代 | `GET` | `/admin/api/admins` | `/api/v1/admin/identities/admins` | `admin.py:290` | +| 新契约替代 | `POST` | `/admin/api/admins` | `/api/v1/admin/identities/admins` | `admin.py:306` | +| 新契约替代 | `POST` | `/admin/api/admins/change_password` | `/api/v1/admin/password` | `admin.py:361` | +| 新契约替代 | `DELETE` | `/admin/api/admins/{admin_id}` | `/api/v1/admin/identities/*/{identity_id} active=false` | `admin.py:381` | +| 新契约替代 | `PATCH` | `/admin/api/admins/{admin_id}` | `/api/v1/admin/identities/admins/{identity_id} (PUT with full revision payload)` | `admin.py:329` | +| 已覆盖 | `GET` | `/admin/api/applications` | `/api/v1/admin/applications` | `applications.py:158` | +| 已覆盖 | `POST` | `/admin/api/applications` | `/api/v1/admin/applications` | `applications.py:287` | +| 已覆盖 | `GET` | `/admin/api/applications/asset-dependencies` | `/api/v1/admin/applications/asset-dependencies` | `applications.py:172` | +| 已覆盖 | `GET` | `/admin/api/applications/catalog` | `/api/v1/admin/applications/catalog` | `applications.py:165` | +| 已覆盖 | `DELETE` | `/admin/api/applications/{app_id}` | `/api/v1/admin/applications/{application_id}` | `applications.py:370` | +| 新契约替代 | `PATCH` | `/admin/api/applications/{app_id}` | `/api/v1/admin/applications/{application_id} (PUT with full revision payload)` | `applications.py:312` | +| 新契约替代 | `GET` | `/admin/api/applications/{app_id}/composition` | `/api/v1/admin/applications/{application_id}` | `applications.py:190` | +| 新契约替代 | `PUT` | `/admin/api/applications/{app_id}/composition` | `/api/v1/admin/applications/{application_id}` | `applications.py:202` | +| 新契约替代 | `POST` | `/admin/api/applications/{app_id}/keys` | `/api/v1/admin/api-keys (scoped credentials)` | `applications.py:384` | +| 已覆盖 | `POST` | `/admin/api/applications/{app_id}/publish` | `/api/v1/admin/applications/{application_id}/publish` | `applications.py:214` | +| 已覆盖 | `POST` | `/admin/api/applications/{app_id}/rollback/{version}` | `/api/v1/admin/applications/{application_id}/rollback/{version}` | `applications.py:233` | +| 已覆盖 | `GET` | `/admin/api/applications/{app_id}/runs` | `/api/v1/admin/applications/{application_id}/runs` | `applications.py:254` | +| 新契约替代 | `POST` | `/admin/api/applications/{app_id}/test` | `/v1/applications/{application_code}/chat/completions` | `applications.py:272` | +| 新契约替代 | `GET` | `/admin/api/audit_settings` | `/api/v1/admin/content-policies and /api/v1/admin/notification-channels` | `admin.py:2297` | +| 新契约替代 | `PUT` | `/admin/api/audit_settings` | `/api/v1/admin/content-policies and /api/v1/admin/notification-channels` | `admin.py:2340` | +| 新契约替代 | `POST` | `/admin/api/audit_settings/test_image_model` | `/api/v1/admin/content-policies and /api/v1/admin/notification-channels` | `admin.py:2379` | +| 新契约替代 | `GET` | `/admin/api/content_audit_scopes` | `/api/v1/admin/content-policies` | `admin.py:2413` | +| 新契约替代 | `POST` | `/admin/api/content_audit_scopes` | `/api/v1/admin/content-policies` | `admin.py:2436` | +| 新契约替代 | `DELETE` | `/admin/api/content_audit_scopes/{scope_id}` | `/api/v1/admin/content-policies` | `admin.py:2470` | +| 新契约替代 | `GET` | `/admin/api/cost_report` | `/api/v1/admin/usage/daily and /api/v1/admin/model-prices` | `admin.py:3936` | +| 新契约替代 | `GET` | `/admin/api/cost_report/export` | `/api/v1/admin/usage/daily and /api/v1/admin/model-prices` | `admin.py:3967` | +| 已覆盖 | `GET` | `/admin/api/departments` | `/api/v1/admin/departments` | `admin.py:550` | +| 已覆盖 | `POST` | `/admin/api/departments` | `/api/v1/admin/departments` | `admin.py:565` | +| 新契约替代 | `DELETE` | `/admin/api/departments/{dept_id}` | `/api/v1/admin/departments/{department_id} active=false` | `admin.py:615` | +| 新契约替代 | `PATCH` | `/admin/api/departments/{dept_id}` | `/api/v1/admin/departments/{department_id} (PUT with full revision payload)` | `admin.py:584` | +| 新契约替代 | `GET` | `/admin/api/dept_budgets` | `/api/v1/admin/usage/daily and /api/v1/admin/model-prices` | `admin.py:3781` | +| 新契约替代 | `POST` | `/admin/api/dept_budgets` | `/api/v1/admin/usage/daily and /api/v1/admin/model-prices` | `admin.py:3807` | +| 新契约替代 | `DELETE` | `/admin/api/dept_budgets/{budget_id}` | `/api/v1/admin/usage/daily and /api/v1/admin/model-prices` | `admin.py:3839` | +| 新契约替代 | `GET` | `/admin/api/dept_stats` | `/api/v1/admin/usage/daily and /api/v1/admin/model-prices` | `admin.py:2934` | +| 已覆盖 | `GET` | `/admin/api/fact-check/events` | `/api/v1/admin/fact-check/events` | `hallucination.py:390` | +| 已覆盖 | `GET` | `/admin/api/fact-check/events/{event_id}` | `/api/v1/admin/fact-check/events/{event_id}` | `hallucination.py:405` | +| 已覆盖 | `GET` | `/admin/api/fact-check/policies` | `/api/v1/admin/fact-check/policies` | `hallucination.py:348` | +| 已覆盖 | `POST` | `/admin/api/fact-check/policies` | `/api/v1/admin/fact-check/policies` | `hallucination.py:355` | +| 已覆盖 | `DELETE` | `/admin/api/fact-check/policies/{policy_id}` | `/api/v1/admin/fact-check/policies/{policy_id}` | `hallucination.py:378` | +| 已覆盖 | `GET` | `/admin/api/fact-check/settings` | `/api/v1/admin/fact-check/settings` | `hallucination.py:312` | +| 已覆盖 | `PUT` | `/admin/api/fact-check/settings` | `/api/v1/admin/fact-check/settings` | `hallucination.py:323` | +| 新契约替代 | `GET` | `/admin/api/feishu_auth` | `/api/v1/admin/identity-providers (generic OIDC/SAML)` | `admin.py:3314` | +| 新契约替代 | `POST` | `/admin/api/feishu_auth` | `/api/v1/admin/identity-providers (generic OIDC/SAML)` | `admin.py:3335` | +| 新契约替代 | `POST` | `/admin/api/feishu_auth/test` | `/api/v1/admin/identity-providers (generic OIDC/SAML)` | `admin.py:3364` | +| 新契约替代 | `GET` | `/admin/api/kb` | `/api/v1/admin/knowledge-bases` | `knowledge.py:395` | +| 新契约替代 | `POST` | `/admin/api/kb` | `/api/v1/admin/knowledge-bases` | `knowledge.py:410` | +| 新契约替代 | `DELETE` | `/admin/api/kb/{kb_id}` | `/api/v1/admin/knowledge-bases/{knowledge_base_id}` | `knowledge.py:475` | +| 新契约替代 | `PATCH` | `/admin/api/kb/{kb_id}` | `/api/v1/admin/knowledge-bases/{knowledge_base_id} (PUT with full revision payload)` | `knowledge.py:439` | +| 新契约替代 | `GET` | `/admin/api/kb/{kb_id}/documents` | `/api/v1/admin/knowledge-bases/{knowledge_base_id}/documents` | `knowledge.py:494` | +| 新契约替代 | `POST` | `/admin/api/kb/{kb_id}/documents/paste` | `/api/v1/admin/knowledge-bases/{knowledge_base_id}/documents` | `knowledge.py:550` | +| 新契约替代 | `POST` | `/admin/api/kb/{kb_id}/documents/upload` | `/api/v1/admin/knowledge-bases/{knowledge_base_id}/documents` | `knowledge.py:508` | +| 新契约替代 | `DELETE` | `/admin/api/kb/{kb_id}/documents/{doc_id}` | `/api/v1/admin/knowledge-bases/{knowledge_base_id}/documents/{document_id}` | `knowledge.py:590` | +| 新契约替代 | `POST` | `/admin/api/kb/{kb_id}/documents/{doc_id}/reprocess` | `/api/v1/admin/knowledge-bases/{knowledge_base_id}/documents/{document_id}/reprocess` | `knowledge.py:574` | +| 新契约替代 | `POST` | `/admin/api/kb/{kb_id}/test_search` | `/api/v1/admin/knowledge-bases/{knowledge_base_id}/search` | `knowledge.py:613` | +| 新契约替代 | `GET` | `/admin/api/keys` | `/api/v1/admin/api-keys` | `admin.py:912` | +| 新契约替代 | `POST` | `/admin/api/keys` | `/api/v1/admin/api-keys` | `admin.py:943` | +| 新契约替代 | `DELETE` | `/admin/api/keys/{key_id}` | `/api/v1/admin/api-keys/{api_key_id}` | `admin.py:1017` | +| 新契约替代 | `PATCH` | `/admin/api/keys/{key_id}` | `/api/v1/admin/api-keys/{api_key_id}/limits` | `admin.py:977` | +| 新契约替代 | `GET` | `/admin/api/log_search` | `/api/v1/admin/audit-events` | `admin.py:2817` | +| 新契约替代 | `GET` | `/admin/api/log_views` | `/api/v1/admin/audit-events` | `admin.py:2879` | +| 已覆盖 | `POST` | `/admin/api/login` | `/api/v1/admin/login` | `admin.py:151` | +| 已覆盖 | `POST` | `/admin/api/login/totp` | `/api/v1/admin/login/totp` | `admin.py:193` | +| 新契约替代 | `DELETE` | `/admin/api/logs` | `/api/v1/admin/audit-events (bounded immutable audit query)` | `admin.py:2633` | +| 新契约替代 | `GET` | `/admin/api/logs` | `/api/v1/admin/audit-events` | `admin.py:2540` | +| 新契约替代 | `GET` | `/admin/api/logs/search` | `/api/v1/admin/audit-events (bounded immutable audit query)` | `admin.py:2689` | +| 新契约替代 | `GET` | `/admin/api/logs/{log_id}/view_history` | `/api/v1/admin/audit-events (bounded immutable audit query)` | `admin.py:2862` | +| 明确退役 | `POST` | `/admin/api/logs/{log_id}/view_raw` | `retired: unbounded raw-body access is excluded by storage/compliance ADR` | `admin.py:2653` | +| 新契约替代 | `GET` | `/admin/api/malicious_domains` | `/api/v1/admin/content-policies` | `admin.py:2501` | +| 新契约替代 | `POST` | `/admin/api/malicious_domains` | `/api/v1/admin/content-policies` | `admin.py:2512` | +| 新契约替代 | `DELETE` | `/admin/api/malicious_domains/{domain}` | `/api/v1/admin/content-policies` | `admin.py:2528` | +| 已覆盖 | `GET` | `/admin/api/model-requests` | `/api/v1/admin/model-requests` | `admin.py:3506` | +| 已覆盖 | `POST` | `/admin/api/model-requests/{req_id}/approve` | `/api/v1/admin/model-requests/{request_id}/approve` | `admin.py:3522` | +| 已覆盖 | `POST` | `/admin/api/model-requests/{req_id}/reject` | `/api/v1/admin/model-requests/{request_id}/reject` | `admin.py:3569` | +| 新契约替代 | `GET` | `/admin/api/model_aliases` | `/api/v1/admin/model-routes` | `admin.py:2204` | +| 新契约替代 | `POST` | `/admin/api/model_aliases` | `/api/v1/admin/model-routes` | `admin.py:2224` | +| 新契约替代 | `DELETE` | `/admin/api/model_aliases/{alias_id}` | `/api/v1/admin/model-routes` | `admin.py:2262` | +| 新契约替代 | `PUT` | `/admin/api/model_aliases/{alias_id}` | `/api/v1/admin/model-routes` | `admin.py:2240` | +| 新契约替代 | `GET` | `/admin/api/model_pricing` | `/api/v1/admin/model-prices` | `admin.py:3615` | +| 新契约替代 | `POST` | `/admin/api/model_pricing` | `/api/v1/admin/model-prices` | `admin.py:3646` | +| 新契约替代 | `DELETE` | `/admin/api/model_pricing/{pricing_id}` | `/api/v1/admin/model-prices/{price_id}` | `admin.py:3677` | +| 新契约替代 | `GET` | `/admin/api/model_qps` | `/api/v1/admin/api-keys and /api/v1/admin/model-routes` | `admin.py:2114` | +| 新契约替代 | `POST` | `/admin/api/model_qps` | `/api/v1/admin/api-keys and /api/v1/admin/model-routes` | `admin.py:2125` | +| 新契约替代 | `DELETE` | `/admin/api/model_qps/{config_id}` | `/api/v1/admin/api-keys and /api/v1/admin/model-routes` | `admin.py:2147` | +| 已覆盖 | `GET` | `/admin/api/models` | `/api/v1/admin/models` | `admin.py:1093` | +| 已覆盖 | `GET` | `/admin/api/monitoring/overview` | `/api/v1/admin/monitoring/overview` | `admin.py:3153` | +| 新契约替代 | `GET` | `/admin/api/notify_config` | `/api/v1/admin/notification-channels` | `admin.py:3392` | +| 新契约替代 | `POST` | `/admin/api/notify_config` | `/api/v1/admin/notification-channels` | `admin.py:3415` | +| 新契约替代 | `POST` | `/admin/api/notify_config/test` | `/api/v1/admin/notification-channels` | `admin.py:3447` | +| 新契约替代 | `GET` | `/admin/api/pricing_config` | `/api/v1/admin/usage/daily and /api/v1/admin/model-prices` | `admin.py:3702` | +| 新契约替代 | `PUT` | `/admin/api/pricing_config` | `/api/v1/admin/usage/daily and /api/v1/admin/model-prices` | `admin.py:3717` | +| 已覆盖 | `GET` | `/admin/api/prompts` | `/api/v1/admin/prompts` | `prompts.py:227` | +| 已覆盖 | `POST` | `/admin/api/prompts` | `/api/v1/admin/prompts` | `prompts.py:246` | +| 新契约替代 | `GET` | `/admin/api/prompts/categories` | `/api/v1/admin/prompt-categories` | `prompts.py:135` | +| 新契约替代 | `POST` | `/admin/api/prompts/categories` | `/api/v1/admin/prompt-categories` | `prompts.py:150` | +| 新契约替代 | `DELETE` | `/admin/api/prompts/categories/{cat_id}` | `/api/v1/admin/prompt-categories` | `prompts.py:168` | +| 已覆盖 | `DELETE` | `/admin/api/prompts/{tpl_id}` | `/api/v1/admin/prompts/{prompt_id}` | `prompts.py:323` | +| 已覆盖 | `GET` | `/admin/api/prompts/{tpl_id}` | `/api/v1/admin/prompts/{prompt_id}` | `prompts.py:279` | +| 新契约替代 | `PATCH` | `/admin/api/prompts/{tpl_id}` | `/api/v1/admin/prompts/{prompt_id} (PUT with full revision payload)` | `prompts.py:292` | +| 新契约替代 | `POST` | `/admin/api/prompts/{tpl_id}/preview` | `/api/v1/admin/prompts/{prompt_id}/render` | `prompts.py:392` | +| 已覆盖 | `POST` | `/admin/api/prompts/{tpl_id}/versions` | `/api/v1/admin/prompts/{prompt_id}/versions` | `prompts.py:344` | +| 已覆盖 | `POST` | `/admin/api/prompts/{tpl_id}/versions/{version_id}/activate` | `/api/v1/admin/prompts/{prompt_id}/versions/{version}/activate` | `prompts.py:371` | +| 已覆盖 | `GET` | `/admin/api/providers` | `/api/v1/admin/providers` | `admin.py:1083` | +| 已覆盖 | `POST` | `/admin/api/providers` | `/api/v1/admin/providers` | `admin.py:1106` | +| 新契约替代 | `DELETE` | `/admin/api/providers/{provider_id}` | `/api/v1/admin/providers/{provider_id} enabled=false` | `admin.py:1152` | +| 新契约替代 | `PATCH` | `/admin/api/providers/{provider_id}` | `/api/v1/admin/providers/{provider_id} (PUT with full revision payload)` | `admin.py:1169` | +| 已覆盖 | `PUT` | `/admin/api/providers/{provider_id}` | `/api/v1/admin/providers/{provider_id}` | `admin.py:1128` | +| 新契约替代 | `GET` | `/admin/api/providers/{provider_id}/backends` | `/api/v1/admin/providers/{provider_id}/models and /api/v1/admin/model-routes` | `admin.py:1333` | +| 新契约替代 | `PUT` | `/admin/api/providers/{provider_id}/backends` | `/api/v1/admin/providers/{provider_id}/models and /api/v1/admin/model-routes` | `admin.py:1358` | +| 新契约替代 | `DELETE` | `/admin/api/providers/{provider_id}/backends/{backend_name}` | `/api/v1/admin/providers/{provider_id}/models and /api/v1/admin/model-routes` | `admin.py:1455` | +| 新契约替代 | `PATCH` | `/admin/api/providers/{provider_id}/backends/{backend_name}` | `/api/v1/admin/providers/{provider_id}/models and /api/v1/admin/model-routes` | `admin.py:1414` | +| 新契约替代 | `POST` | `/admin/api/providers/{provider_id}/fetch_models` | `/api/v1/admin/providers/{provider_id}/models/sync` | `admin.py:1255` | +| 已覆盖 | `POST` | `/admin/api/providers/{provider_id}/test` | `/api/v1/admin/providers/{provider_id}/test` | `admin.py:1231` | +| 新契约替代 | `GET` | `/admin/api/rate_limit_rules` | `/api/v1/admin/api-keys and /api/v1/admin/model-routes` | `admin.py:2033` | +| 新契约替代 | `POST` | `/admin/api/rate_limit_rules` | `/api/v1/admin/api-keys and /api/v1/admin/model-routes` | `admin.py:2044` | +| 新契约替代 | `DELETE` | `/admin/api/rate_limit_rules/{rule_id}` | `/api/v1/admin/api-keys and /api/v1/admin/model-routes` | `admin.py:2090` | +| 新契约替代 | `PATCH` | `/admin/api/rate_limit_rules/{rule_id}` | `/api/v1/admin/api-keys and /api/v1/admin/model-routes` | `admin.py:2064` | +| 已覆盖 | `POST` | `/admin/api/reload` | `/api/v1/admin/reload` | `admin.py:3292` | +| 新契约替代 | `GET` | `/admin/api/routing_groups` | `/api/v1/admin/model-routes` | `smart_router.py:309` | +| 新契约替代 | `POST` | `/admin/api/routing_groups` | `/api/v1/admin/model-routes` | `smart_router.py:326` | +| 新契约替代 | `DELETE` | `/admin/api/routing_groups/{group_id}` | `/api/v1/admin/model-routes` | `smart_router.py:372` | +| 新契约替代 | `PATCH` | `/admin/api/routing_groups/{group_id}` | `/api/v1/admin/model-routes` | `smart_router.py:346` | +| 新契约替代 | `POST` | `/admin/api/routing_groups/{group_id}/preview` | `/api/v1/admin/model-routes` | `smart_router.py:468` | +| 新契约替代 | `POST` | `/admin/api/routing_groups/{group_id}/targets` | `/api/v1/admin/model-routes` | `smart_router.py:390` | +| 新契约替代 | `DELETE` | `/admin/api/routing_groups/{group_id}/targets/{target_id}` | `/api/v1/admin/model-routes` | `smart_router.py:448` | +| 新契约替代 | `PATCH` | `/admin/api/routing_groups/{group_id}/targets/{target_id}` | `/api/v1/admin/model-routes` | `smart_router.py:422` | +| 新契约替代 | `GET` | `/admin/api/rule_dimensions` | `/api/v1/admin/content-policies` | `admin.py:1854` | +| 新契约替代 | `GET` | `/admin/api/rules` | `/api/v1/admin/content-policies` | `admin.py:1717` | +| 新契约替代 | `POST` | `/admin/api/rules` | `/api/v1/admin/content-policies` | `admin.py:1753` | +| 新契约替代 | `POST` | `/admin/api/rules/batch` | `/api/v1/admin/content-policies` | `admin.py:1775` | +| 新契约替代 | `DELETE` | `/admin/api/rules/{rule_id}` | `/api/v1/admin/content-policies` | `admin.py:1835` | +| 新契约替代 | `PATCH` | `/admin/api/rules/{rule_id}` | `/api/v1/admin/content-policies` | `admin.py:1808` | +| 新契约替代 | `GET` | `/admin/api/stats` | `/api/v1/admin/usage/daily and /api/v1/admin/model-prices` | `admin.py:3022` | +| 新契约替代 | `GET` | `/admin/api/system_info` | `/api/v1/admin/system-info` | `admin.py:2488` | +| 已覆盖 | `GET` | `/admin/api/tools` | `/api/v1/admin/tools` | `tools.py:218` | +| 已覆盖 | `POST` | `/admin/api/tools` | `/api/v1/admin/tools` | `tools.py:229` | +| 已覆盖 | `DELETE` | `/admin/api/tools/{tool_id}` | `/api/v1/admin/tools/{tool_id}` | `tools.py:305` | +| 新契约替代 | `PATCH` | `/admin/api/tools/{tool_id}` | `/api/v1/admin/tools/{tool_id} (PUT with full revision payload)` | `tools.py:260` | +| 明确退役 | `GET` | `/admin/api/tools/{tool_id}/headers` | `retired: decrypted secret headers are intentionally write-only` | `tools.py:320` | +| 新契约替代 | `POST` | `/admin/api/tools/{tool_id}/test_invoke` | `/api/v1/admin/tools/{tool_id}/test` | `tools.py:338` | +| 新契约替代 | `POST` | `/admin/api/totp/backup_codes/regenerate` | `/api/v1/admin/totp/backup-codes/regenerate` | `admin.py:521` | +| 已覆盖 | `POST` | `/admin/api/totp/confirm` | `/api/v1/admin/totp/confirm` | `admin.py:464` | +| 已覆盖 | `POST` | `/admin/api/totp/disable` | `/api/v1/admin/totp/disable` | `admin.py:487` | +| 已覆盖 | `POST` | `/admin/api/totp/setup` | `/api/v1/admin/totp/setup` | `admin.py:439` | +| 已覆盖 | `GET` | `/admin/api/totp/status` | `/api/v1/admin/totp/status` | `admin.py:422` | +| 新契约替代 | `GET` | `/admin/api/users` | `/api/v1/admin/identities/portal-users` | `admin.py:683` | +| 新契约替代 | `POST` | `/admin/api/users` | `/api/v1/admin/identities/portal-users` | `admin.py:721` | +| 新契约替代 | `DELETE` | `/admin/api/users/{user_id}` | `/api/v1/admin/identities/*/{identity_id} active=false` | `admin.py:826` | +| 新契约替代 | `PATCH` | `/admin/api/users/{user_id}` | `/api/v1/admin/identities/portal-users/{identity_id} (PUT with full revision payload)` | `admin.py:745` | +| 新契约替代 | `POST` | `/admin/api/users/{user_id}/approve` | `/api/v1/admin/identities/portal-users/{identity_id} active=true` | `admin.py:788` | +| 新契约替代 | `DELETE` | `/admin/api/users/{user_id}/feishu` | `/api/v1/admin/identity-providers (generic identity link lifecycle)` | `admin.py:806` | +| 新契约替代 | `POST` | `/admin/api/users/{user_id}/reset_password` | `/api/v1/admin/identities/portal-users/{identity_id} password field` | `admin.py:771` | +| 已覆盖 | `GET` | `/admin/api/whoami` | `/api/v1/admin/whoami` | `admin.py:256` | +| 明确退役 | `GET` | `/admin/ui` | `Art Design Pro SPA deployment` | `main.py:3838` | +| 已覆盖 | `GET` | `/healthz` | `/healthz` | `main.py:3756` | +| 已覆盖 | `GET` | `/metrics` | `/metrics` | `main.py:3793` | +| 已覆盖 | `GET` | `/portal/api/applications` | `/api/v1/portal/applications` | `applications.py:408` | +| 已覆盖 | `POST` | `/portal/api/apps/{app_code}/chat` | `/api/v1/portal/apps/{application_code}/chat` | `orchestration.py:227` | +| 已覆盖 | `POST` | `/portal/api/apps/{app_code}/conversations` | `/api/v1/portal/apps/{application_code}/conversations` | `orchestration.py:245` | +| 已覆盖 | `GET` | `/portal/api/apps/{app_code}/conversations/{conversation_id}` | `/api/v1/portal/apps/{application_code}/conversations/{conversation_id}` | `orchestration.py:260` | +| 已覆盖 | `POST` | `/portal/api/apps/{app_code}/conversations/{conversation_id}/messages` | `/api/v1/portal/apps/{application_code}/conversations/{conversation_id}/messages` | `orchestration.py:275` | +| 已覆盖 | `GET` | `/portal/api/catalog` | `/api/v1/portal/catalog` | `portal.py:938` | +| 已覆盖 | `GET` | `/portal/api/cost` | `/api/v1/portal/cost` | `portal.py:550` | +| 已覆盖 | `GET` | `/portal/api/docs-info` | `/api/v1/portal/docs-info` | `portal.py:690` | +| 新契约替代 | `GET` | `/portal/api/feishu/callback` | `/api/v1/portal/sso (generic OIDC/SAML)` | `portal.py:214` | +| 新契约替代 | `GET` | `/portal/api/feishu/enabled` | `/api/v1/portal/sso (generic OIDC/SAML)` | `portal.py:189` | +| 新契约替代 | `GET` | `/portal/api/feishu/login` | `/api/v1/portal/sso (generic OIDC/SAML)` | `portal.py:195` | +| 已覆盖 | `GET` | `/portal/api/knowledge` | `/api/v1/portal/knowledge` | `portal.py:830` | +| 已覆盖 | `POST` | `/portal/api/login` | `/api/v1/portal/login` | `portal.py:76` | +| 已覆盖 | `POST` | `/portal/api/login/totp` | `/api/v1/portal/login/totp` | `portal.py:123` | +| 已覆盖 | `POST` | `/portal/api/logout` | `/api/v1/portal/logout` | `portal.py:181` | +| 已覆盖 | `GET` | `/portal/api/logs` | `/api/v1/portal/logs` | `portal.py:597` | +| 已覆盖 | `GET` | `/portal/api/logs/{log_id}` | `/api/v1/portal/logs/{audit_event_id}` | `portal.py:657` | +| 已覆盖 | `GET` | `/portal/api/me` | `/api/v1/portal/me` | `portal.py:432` | +| 已覆盖 | `GET` | `/portal/api/model-requests` | `/api/v1/portal/model-requests` | `portal.py:803` | +| 已覆盖 | `POST` | `/portal/api/model-requests` | `/api/v1/portal/model-requests` | `portal.py:754` | +| 已覆盖 | `GET` | `/portal/api/model-requests/available` | `/api/v1/portal/model-requests/available` | `portal.py:716` | +| 已覆盖 | `POST` | `/portal/api/password` | `/api/v1/portal/password` | `portal.py:273` | +| 已覆盖 | `GET` | `/portal/api/prompts` | `/api/v1/portal/prompts` | `portal.py:855` | +| 已覆盖 | `GET` | `/portal/api/prompts/{tpl_id}` | `/api/v1/portal/prompts/{prompt_id}` | `portal.py:887` | +| 已覆盖 | `DELETE` | `/portal/api/prompts/{tpl_id}/favorite` | `/api/v1/portal/prompts/{prompt_id}/favorite` | `portal.py:926` | +| 已覆盖 | `POST` | `/portal/api/prompts/{tpl_id}/favorite` | `/api/v1/portal/prompts/{prompt_id}/favorite` | `portal.py:910` | +| 已覆盖 | `GET` | `/portal/api/stats` | `/api/v1/portal/stats` | `portal.py:457` | +| 已覆盖 | `GET` | `/portal/api/tools` | `/api/v1/portal/tools` | `portal.py:838` | +| 新契约替代 | `POST` | `/portal/api/totp/backup_codes/regenerate` | `/api/v1/portal/totp/backup-codes/regenerate` | `portal.py:409` | +| 已覆盖 | `POST` | `/portal/api/totp/confirm` | `/api/v1/portal/totp/confirm` | `portal.py:354` | +| 已覆盖 | `POST` | `/portal/api/totp/disable` | `/api/v1/portal/totp/disable` | `portal.py:377` | +| 已覆盖 | `POST` | `/portal/api/totp/setup` | `/api/v1/portal/totp/setup` | `portal.py:328` | +| 已覆盖 | `GET` | `/portal/api/totp/status` | `/api/v1/portal/totp/status` | `portal.py:310` | +| 明确退役 | `GET` | `/portal/ui` | `Art Design Pro SPA deployment` | `main.py:3846` | +| 已覆盖 | `GET` | `/readyz` | `/readyz` | `main.py:3762` | +| 新契约替代 | `POST` | `/v1/apps/{app_code}/chat/completions` | `/v1/applications/{application_code}/chat/completions` | `orchestration.py:213` | +| 已覆盖 | `POST` | `/v1/chat/completions` | `/v1/chat/completions` | `main.py:2502` | +| 已覆盖 | `POST` | `/v1/embeddings` | `/v1/embeddings` | `main.py:2541` | +| 已覆盖 | `POST` | `/v1/knowledge/search` | `/v1/knowledge/search` | `main.py:3543` | +| 已覆盖 | `POST` | `/v1/messages` | `/v1/messages` | `main.py:2515` | +| 已覆盖 | `GET` | `/v1/models` | `/v1/models` | `main.py:3512` | +| 已覆盖 | `GET` | `/v1/prompts` | `/v1/prompts` | `main.py:3657` | +| 已覆盖 | `POST` | `/v1/prompts/{prompt_name}/render` | `/v1/prompts/{prompt_name}/render` | `main.py:3680` | +| 已覆盖 | `POST` | `/v1/responses` | `/v1/responses` | `main.py:2530` | +| 已覆盖 | `GET` | `/v1/tools` | `/v1/tools` | `main.py:3601` | +| 已覆盖 | `POST` | `/v1/tools/{tool_name}/invoke` | `/v1/tools/{tool_code}/invoke` | `main.py:3610` | diff --git a/docs/旗舰版需求规划与完成情况.md b/docs/旗舰版需求规划与完成情况.md new file mode 100644 index 0000000..888644a --- /dev/null +++ b/docs/旗舰版需求规划与完成情况.md @@ -0,0 +1,170 @@ +# AI Gateway Go — 旗舰版(Ultra)需求规划与实现情况报告 + +- **报告日期**: 2026-08-12 +- **工程**: AI Gateway 全量 Go 重构(替代原 Python/FastAPI 网关) +- **活跃工作树**: `/home/ben/ai-gateway-src/ai-gateway-go-deploy-0.10.0` +- **当前版本**: 0.10.0(Go 1.26,PostgreSQL 17 + 双 Redis,22 个迁移) + +--- + +## 一、项目现状概览 + +| 项 | 状态 | +|---|---| +| 部署形态 | Docker Compose(项目名 `deploy`),gateway-api `:8080` / admin-web `:8081` / portal-web `:8082` | +| 数据层 | PostgreSQL 17(权威配置 + 审计分区)+ critical/cache 双 Redis;22 个迁移已应用 | +| 迁移 | `000001`…`000022`(含旗舰版资源市场 `000022_resource_marketplace.sql`) | +| 验证 | `go build ./...`、`go vet`、全套单测、资源市场集成测试连真实库 **全部通过** | +| 前端 | Art Design Pro 管理端 + 门户端,已构建进镜像并运行 | +| 版本控制 | 无 git(目录无 `.git`),靠目录快照;建议尽快接入版本控制 | + +--- + +## 二、已完成里程碑(M0–M7) + +### M0 工程基线 +Go 1.26 模块、配置校验、结构化日志、优雅退出;pgx 连接池、双 Redis 客户端;带校验和/事务/advisory lock 的独立迁移器;OpenAI 兼容入口、bootstrap key、请求体限制、SSE 透传;healthz/readyz/Prometheus;outbox/API Key/Provider/审计分区首版 schema。 + +### M1 身份、Provider 与配置面 +管理员/门户用户 + PBKDF2 旧密码兼容升级;可撤销会话/账号锁定;admin/portal 登录、身份、菜单、退出;TOTP 两步验证 + 备用码;API Key 全生命周期 + Redis 即时撤销;Provider AES-256-GCM 凭据 + SSRF 校验 + 事务 outbox + 原子快照/路由/能力约束;KEK 版本化轮换;层级部门 CRUD;OIDC(PKCE/RS256/JWKS)与 SAML 2.0(SP-initiated/断言防重放/自动开户)端到端通过。 + +### M2 流量治理与路由 +API Key 级 RPM + UTC 月请求配额 + Token 配额(请求前预留、usage 回写校准),Redis Lua 原子准入,429/Retry-After/X-RateLimit-*;Provider 超时/退避/熔断(幂等安全重试);模型别名条件路由(确定性加权 + 失败关闭 + X-Gateway-Model);Art 管理端限流/路由页面。 + +### M3 审计、usage、内容策略与成本 +有界异步审计批量 COPY + 失败保留 + 优雅停机;按日 usage 聚合;audit/usage 权限隔离查询;outbox-worker(SKIP LOCKED 多实例 + Redis Stream 原子去重 + 死信重试);审计月分区维护(advisory lock 防多实例重复);内容策略(RE2 不可变快照,audit/block/redact,默认脱敏);时间版本模型定价 + 按日成本核算;Art 审计/用量/内容策略/定价页面。 + +### M4 Prompt、知识、工具、应用编排和通知 +Prompt 分类/模板/不可变版本/必填校验;知识库(2 MiB 有界正文、段落感知重叠分块、FTS+中文二元词片检索、Retriever 接口);声明式 HTTP 工具(JSON Schema 校验、KEK 加密头、双层 SSRF、禁止重定向、1 MiB 响应限制);AI 应用草稿 + 不可发布版本 → `/v1/applications/{code}/chat/completions`(仍过鉴权/配额/内容策略/路由/成本/审计);通知 worker(HMAC-SHA256 Webhook、精确/通配事件、失败重试)。 + +### M5 契约、迁移与切换工程 +旧 Python 网关 38 个持久化实体数据字典;确定性 UUIDv5 旧 ID 映射;201 条旧路由全覆盖/替代/退役决策,未决契约缺口为 0;OpenAPI 0.10.0 覆盖全部 Go 路由;legacy 导入工具 + cutover runbook + 路由契约矩阵 + 校验脚本。 + +### M6 门户工作台与扩展治理 +门户资产目录、Prompt 查看/搜索/收藏、个人审计/Token/成本统计、接入说明、模型访问申请与管理员审批;事实核验(复用 Provider 凭据 + 作用域策略 + 事件契约);已发布应用门户单轮入口 + 服务端托管会话(独立 AEAD purpose 加密凭证、串行租约、SHA-256 哈希链、最多 200 条);Compose 同时交付 API/管理端 8081/门户端 8082。 + +### M7 旗舰版资源市场(本次新增) +三类可发布资产 **MCP 服务器 / Skills / 数字员工** + 共享分类与标签 + 市场安装(工作区绑定 + 权限): + +- 数据层:6 张表(`marketplace_categories` / `mcp_servers` / `skills` / `digital_employees` / `digital_employee_runs` / `marketplace_installations`) +- 后端:管理端 CRUD + 在线编辑/发布(`admin_marketplace.go`);统一市场目录合并查询(`marketplace.go`);运行时渲染 skill、列出/调用 MCP 工具、运行数字员工(含知识检索 + 工具执行 + 访问控制,`runtime_marketplace.go`);MCP 客户端(SSE + JSON-RPC 工具发现/调用/缓存,`mcp_client.go`) +- 前端:管理端 4 页(市场总览 / MCP 服务器 / Skills / 数字员工)+ 门户资源市场页 +- 测试:`TestMarketplaceLifecycle` / `TestWorkbenchPostgreSQLLifecycle` 连真实 PostgreSQL 通过 + +--- + +## 三、旗舰版(Ultra)功能矩阵对照 + +状态图例:**✅ 已完成** | **⚠️ 部分覆盖** | **❌ 未实现** + +### 管理平台 + +| 功能模块 | 功能 | 社区 Free | 专业 Pro | 旗舰 Ultra | 当前状态 | +|---|---|---|---|---|---| +| 管理平台 | AI 助手:通过 AI 助手查看平台信息和权限控制 | — | ✓ | ✓ | ❌ | +| 管理平台 | 收藏:常用功能收藏 | ✓ | ✓ | ✓ | ❌ | +| 管理平台 | 概览:平台总览与基础看板 | — | ✓ | ✓ | ⚠️ 仅有 24h 监控汇总(`monitoring/overview`),非完整看板 | +| 管理平台 | 数据报表:token/工具/渠道/审批授权/安全事件汇总统计 | — | ✓ | ✓ | ❌(仅有 usage/stats 单维) | +| 资源市场 | 自定义 MCP/Skills/数字员工资源 | ✓ | ✓ | ✓ | ✅ M7 | +| 资源市场 | 在线编辑和发布 MCP/Skills/数字员工 | 不支持 | ✓ | ✓ | ✅ 管理端 CRUD + publish 路由 | +| 资源市场 | 资源分类管理和标签管理 | ✓ | ✓ | ✓ | ✅ `marketplace_categories` | +| 配置管理 | 平台配置敏感参数,skill/mcp 运行时动态注入环境变量 | — | ✓ | ✓ | ❌ | +| 智能体管理 | 智能体节点:运行监控/动态创建/裸机安装/节点池分配/公有私有池路由 | — | ✓ | ✓ | ❌ | +| 智能体管理 | LLMTrace:会话中大模型调用、工具调用执行性能跟踪 | — | ✓ | ✓ | ❌ | +| 智能体管理 | 智能体会话:会话列表,区分普通会话与数字员工会话 | — | ✓ | ✓ | ❌(有应用托管会话,非会话列表体系) | +| 模型管理 | 对接国内外主流大模型供应商 | ✓ | ✓ | ✓ | ✅ providers + 模型目录同步 | +| 模型管理 | 供应商中添加配置大模型 | ✓ | ✓ | ✓ | ✅ | +| 模型管理 | 对接本地模型(Ollama/vLLM) | 不支持 | ✓ | ✓ | ✅ 走 OpenAI 兼容通用 provider | +| 模型管理 | 不同大模型 Token 配额、使用量统计 | 不支持 | ✓ | ✓ | ✅ quota + usage 聚合 | +| 模型管理 | 大模型使用权限分级管控(用户/角色) | ✓ | ✓ | ✓ | ✅ RBAC scope | +| 知识库 | 外部文档导入、自动切分、向量化、语义匹配召回 | — | ✓ | ✓ | ⚠️ 导入/切分/检索 ✅;向量化+语义匹配 ❌(现为 FTS+中文分词) | +| 记忆管理 | 记忆集合:个人/部门/全局多层记忆,提炼与语义匹配召回 | — | ✓ | ✓ | ❌ | +| 记忆管理 | 根据调用自动裁剪衰减片段 | — | ✓ | ✓ | ❌ | +| 记忆管理 | 记忆授权:提炼内容/沉淀经验授权给其他用户 | — | ✓ | ✓ | ❌ | +| 权限管理 | 完整 RBAC 角色权限管理 | 不支持 | ✓ | ✓ | ✅ | +| 权限管理 | 用户/部门/角色管理 | ✓ | ✓ | ✓ | ✅ | +| 权限管理 | 系统历史操作审计日志 | — | ✓ | ✓ | ✅ `admin/audit-events` | +| 权限管理 | 资源(mcp/skills/数字员工)权限管控、资源授权 | ✓ | ✓ | ✓ | ✅ marketplace 访问控制 | +| 权限管理 | 资源/大模型/渠道使用申请审批 | — | ✓ | ✓ | ⚠️ 仅模型申请审批(model-requests);资源安装/渠道无审批流 | +| API 集成 | API Key 调用大模型/mcp/skills 组合或数字员工 | — | ✓ | ✓ | ✅ API Key + runtime marketplace | +| 渠道管理 | Web 聊天界面 | ✓ | ✓ | ✓ | ✅ portal | +| 渠道管理 | 企业微信/个人微信/钉钉/飞书渠道 | ✓ | ✓ | ✓ | ❌ | +| 渠道管理 | 渠道权限管控、使用权限授权(用户/角色) | ✓ | ✓ | ✓ | ❌ | +| 渠道管理 | 多渠道治理、审计、使用量统计 | ✓ | ✓ | ✓ | ❌ | +| 安装部署 | x86_64 安装包 | ✓ | ✓ | ✓ | ⚠️ Docker Compose 交付,非传统安装包 | +| 安装部署 | ARM64 安装包 | — | ✓ | ✓ | ❌ | +| 部署方式 | 单机 / 冷备 / 集群 | 单机 | 单机/冷备 | 单机/冷备/集群 | ⚠️ outbox 支持多实例 SKIP LOCKED(集群一部分);冷备/完整集群方案未做 | +| 租户管理 | 单租户使用 | ✓ | ✓ | ✓ | ✅ | +| 租户管理 | 平台管理员多租户管理 | — | — | ✓ | ❌ | +| 安全策略 | 运行时安全:网络/工具命令执行安全校验审批/工具调用频率限制 | — | ✓ | ✓ | ⚠️ 工具 SSRF/拨号防护 ✅;命令执行审批、工具限流 ❌ | +| 安全策略 | 供应链安全:skill/mcp 资源安全扫描 | — | ✓ | ✓ | ❌ | +| 安全策略 | 数据安全:工具数据输入输出脱敏 + 大模型回答隐私敏感信息拦截替换 | — | ✓ | ✓ | ⚠️ 提示词输入脱敏 ✅;工具输出/回答拦截替换 ❌ | +| 站内消息 | 平台推送站内消息与动态 | — | ✓ | ✓ | ❌(通知 worker 仅 webhook) | +| 审批授权 | 资源/模型/渠道使用申请流程审批管理 | — | ✓ | ✓ | ⚠️ 仅模型申请 | +| 文件管理 | 平台文件资源与对象存储文件浏览管理 | — | ✓ | ✓ | ❌(MinIO/S3 不在基线) | +| 审计日志 | 系统全量历史操作审计日志查询 | — | ✓ | ✓ | ✅ | +| 企业报表 | 企业级运营数据报表统计分析 | — | ✓ | ✓ | ❌ | +| License | 平台 License 授权管理与有效期管控 | ✓ | ✓ | ✓ | ❌ | + +### 工作台 + +| 功能 | 功能说明 | 当前状态 | +|---|---|---| +| 聊天 | 新建会话、授权大模型对话 | ✅ | +| 聊天 | 删除会话、会话重命名 | ✅ | +| 定时任务 | 创建定时任务(配置提示词/渠道/mcp/skills/数字员工/会话ID) | ❌ | +| 定时任务 | 启动/修改/立即执行/删除定时任务配置 | ❌ | +| 定时任务 | 查看定时任务执行历史 | ❌ | +| 个人渠道 | 配置个人微信/企业微信/钉钉/飞书 | ❌ | +| 个人渠道 | 个人微信/企业微信快速扫码对接 | ❌ | +| 个人渠道 | 绑定特定大模型执行对话 | ❌ | +| 我的资源 | 查看被授权资源(mcp/skills/数字员工)详细信息 | ✅ portal/marketplace | +| 我的资源 | 从被授权数字员工进入会话 | ⚠️ runtime 可跑,无会话列表入口 | +| 我的资源 | 通过权限申请从插件市场安装 MCP/Skills/数字员工 | ✅ marketplace install | +| 我的资源 | 查看被授权大模型使用量 | ✅ portal/stats | +| 我的资源 | 查看插件资源权限等级(可查看/仅使用/管理) | ⚠️ 有访问控制,三档等级未成体系 | +| 我的资源 | 申请大模型/token量/skill/mcp/数字员工/渠道权限 | ⚠️ 仅模型申请 | +| 配置管理 | 个人配置环境变量,供 skill/mcp 使用 | ❌ | +| 个人文件仓库 | 对话产生的报告/文件存入个人仓库 | ❌ | +| 安全策略 | 个人智能体安全策略(网络/工具命令校验审批/限流/脱敏/隐私拦截) | ❌ | +| 个人中心 | 账号信息、密码修改、登录记录查看 | ✅ | +| 消息通知 | 系统消息、审批待办、任务执行结果提醒 | ⚠️ webhook 投递 ✅;站内消息/待办/结果提醒 ❌ | + +--- + +## 四、差距汇总 + +- **完全未实现(❌,约 20 项)**:AI 助手、收藏、数据报表/企业报表、配置管理(env 注入)、智能体管理三项(节点/LLMTrace/会话)、记忆管理三项、渠道管理全项、多租户、供应链安全扫描、站内消息、完整审批流、文件管理(对象存储)、License、定时任务全项、个人渠道、个人文件仓库、个人安全策略、ARM64。 +- **部分覆盖需补齐(⚠️,约 10 项)**:平台概览看板、知识库向量化语义召回、资源/渠道审批、工具输出脱敏与大模型回答拦截替换、工具命令审批与工具限流、集群部署方案、站内消息/审批待办/任务结果、数字员工会话入口、资源权限等级三档、全类型权限申请。 + +--- + +## 五、后续里程碑规划(M8–M13) + +| 里程碑 | 内容 | 依赖 | +|---|---|---| +| **M8 基础设施层** | 对象存储(MinIO)、向量化(pgvector)、定时任务调度器、站内消息 | — | +| **M9 智能体与可观测** | LLMTrace、智能体会话、智能体节点(监控/节点池/路由)、AI 助手 | M8 | +| **M10 记忆管理** | 多层记忆集合、语义召回、裁剪衰减、记忆授权 | M8(pgvector) | +| **M11 渠道与审批** | 渠道管理(企业微信/个人微信/钉钉/飞书)、个人渠道、完整审批流、资源权限等级 | M8 | +| **M12 数据安全与供应链** | 知识库向量化、输出脱敏/回答拦截、工具命令审批/限流、供应链扫描、个人安全策略 | M8(pgvector) | +| **M13 平台运营** | 数据报表/企业报表、完整看板、收藏、多租户、License、ARM64/集群部署 | M8 | + +已建任务跟踪:`#4`–`#9`。 + +--- + +## 六、验证情况(当前基线) + +- `go build ./...` ✅、`go vet ./internal/workbench/` ✅ +- 全套单测(全部包)✅;资源市场单测(含 MCP 客户端)✅ +- 集成测试 `TestMarketplaceLifecycle`、`TestWorkbenchPostgreSQLLifecycle` 连真实 PostgreSQL ✅ +- 部署冒烟:healthz/readyz ✅、admin :8081 / portal :8082 302 ✅、22 迁移应用 ✅ + +## 七、部署与已知坑 + +- 活跃工作树:`/home/ben/ai-gateway-src/ai-gateway-go-deploy-0.10.0`;compose 项目名 `deploy`,workdir 在 `deploy/` 下。 +- **重启恢复**:postgres/redis restart 策略为 `no`,重启后需进 `deploy/` 执行 `docker compose up -d`;若容器 `networks` 为空需 `--force-recreate`。 +- **旧 Python 网关**:`llm-gateway.service`(systemd)监听 8080 已 `systemctl disable`,不再开机抢占。 +- **本机无 go 工具链**:编译/测试用 `docker run --rm -v $PWD:/src -w /src -e GOCACHE=/tmp/gocache golang:1.26.5-alpine sh -c 'go build ./...'`;集成测试加 `--network deploy_default` + `WORKBENCH_TEST_DATABASE_URL=postgres://gateway:gateway@postgres:5432/gateway?sslmode=disable`。 +- **上线门禁未过**:旧库脱敏快照迁移、影子观察、容量验收、切换/回退演练需真实生产数据与授权后方可执行。 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..87e513b --- /dev/null +++ b/go.mod @@ -0,0 +1,27 @@ +module aigateway.local/core + +go 1.26.0 + +toolchain go1.26.5 + +require ( + github.com/beevik/etree v1.5.0 + github.com/crewjam/saml v0.5.1 + github.com/jackc/pgx/v5 v5.10.0 + github.com/redis/go-redis/v9 v9.21.0 + github.com/russellhaering/goxmldsig v1.4.0 +) + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jonboulle/clockwork v0.2.2 // indirect + github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + golang.org/x/crypto v0.33.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/text v0.29.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8ae1c91 --- /dev/null +++ b/go.sum @@ -0,0 +1,78 @@ +github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= +github.com/beevik/etree v1.5.0 h1:iaQZFSDS+3kYZiGoc9uKeOkUY3nYMXOKLl6KIJxiJWs= +github.com/beevik/etree v1.5.0/go.mod h1:gPNJNaBGVZ9AwsidazFZyygnd+0pAU38N4D+WemwKNs= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/crewjam/saml v0.5.1 h1:g+mfp0CrLuLRZCK793PgJcZeg5dS/0CDwoeAX2zcwNI= +github.com/crewjam/saml v0.5.1/go.mod h1:r0fDkmFe5URDgPrmtH0IYokva6fac3AUdstiPhyEolQ= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ= +github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU= +github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E= +github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/russellhaering/goxmldsig v1.4.0 h1:8UcDh/xGyQiyrW+Fq5t8f+l2DLB1+zlhYzkPUJ7Qhys= +github.com/russellhaering/goxmldsig v1.4.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= diff --git a/internal/apikey/admin_http.go b/internal/apikey/admin_http.go new file mode 100644 index 0000000..0b4aac4 --- /dev/null +++ b/internal/apikey/admin_http.go @@ -0,0 +1,203 @@ +package apikey + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + "time" + + "aigateway.local/core/internal/identity" + "aigateway.local/core/internal/platform/apiresponse" +) + +type AdminHTTPHandler struct { + repository *Repository + authenticator *Authenticator + usage *UsageStore + identity *identity.Service + mux *http.ServeMux +} + +type createRequest struct { + Name string `json:"name"` + Scopes []string `json:"scopes"` + RequestsPerMinute int `json:"requests_per_minute"` + MonthlyRequestQuota int64 `json:"monthly_request_quota"` + MonthlyTokenQuota int64 `json:"monthly_token_quota"` + ExpiresAt *time.Time `json:"expires_at"` +} + +type limitsRequest struct { + RequestsPerMinute int `json:"requests_per_minute"` + MonthlyRequestQuota int64 `json:"monthly_request_quota"` + MonthlyTokenQuota int64 `json:"monthly_token_quota"` +} + +func NewAdminHTTPHandler(repository *Repository, authenticator *Authenticator, identityService *identity.Service) *AdminHTTPHandler { + h := &AdminHTTPHandler{repository: repository, authenticator: authenticator, identity: identityService, mux: http.NewServeMux()} + h.mux.HandleFunc("GET /api/v1/admin/api-keys", h.list) + h.mux.HandleFunc("POST /api/v1/admin/api-keys", h.create) + h.mux.HandleFunc("PUT /api/v1/admin/api-keys/{api_key_id}/limits", h.updateLimits) + h.mux.HandleFunc("DELETE /api/v1/admin/api-keys/{api_key_id}", h.revoke) + return h +} + +func (h *AdminHTTPHandler) SetUsageStore(store *UsageStore) { h.usage = store } + +func (h *AdminHTTPHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) { + h.mux.ServeHTTP(writer, request) +} + +func (h *AdminHTTPHandler) list(writer http.ResponseWriter, request *http.Request) { + if _, ok := h.requirePermission(writer, request, identity.PermissionAPIKeyRead); !ok { + return + } + records, err := h.repository.List(request.Context()) + if err != nil { + h.writeError(writer, err) + return + } + ids := make([]string, 0, len(records)) + for _, record := range records { + ids = append(ids, record.ID) + } + usage, err := h.usage.MonthlyTokens(request.Context(), ids, time.Now()) + if err != nil { + h.writeError(writer, err) + return + } + items := make([]map[string]any, 0, len(records)) + for _, record := range records { + item := publicRecord(record) + item["monthly_token_usage"] = usage[record.ID] + items = append(items, item) + } + apiresponse.OK(writer, items) +} + +func (h *AdminHTTPHandler) updateLimits(writer http.ResponseWriter, request *http.Request) { + account, ok := h.requirePermission(writer, request, identity.PermissionAPIKeyManage) + if !ok { + return + } + var input limitsRequest + decoder := json.NewDecoder(http.MaxBytesReader(writer, request.Body, 1<<20)) + decoder.DisallowUnknownFields() + if decoder.Decode(&input) != nil || !validLimits(input.RequestsPerMinute, input.MonthlyRequestQuota, input.MonthlyTokenQuota) { + apiresponse.Error(writer, http.StatusBadRequest, "API Key 限流或月配额无效") + return + } + record, hash, err := h.repository.UpdateLimits(request.Context(), request.PathValue("api_key_id"), input.RequestsPerMinute, input.MonthlyRequestQuota, input.MonthlyTokenQuota, account.ID) + if err != nil { + h.writeError(writer, err) + return + } + if err := h.authenticator.Invalidate(request.Context(), hash); err != nil { + h.writeError(writer, err) + return + } + response := publicRecord(record) + if h.usage != nil { + usage, usageErr := h.usage.MonthlyTokens(request.Context(), []string{record.ID}, time.Now()) + if usageErr == nil { + response["monthly_token_usage"] = usage[record.ID] + } + } + apiresponse.OK(writer, response) +} + +func validLimits(requestsPerMinute int, monthlyRequestQuota, monthlyTokenQuota int64) bool { + return requestsPerMinute >= 0 && requestsPerMinute <= 1_000_000 && + monthlyRequestQuota >= 0 && monthlyRequestQuota <= 1_000_000_000_000 && + monthlyTokenQuota >= 0 && monthlyTokenQuota <= 1_000_000_000_000_000 +} + +func (h *AdminHTTPHandler) create(writer http.ResponseWriter, request *http.Request) { + account, ok := h.requirePermission(writer, request, identity.PermissionAPIKeyManage) + if !ok { + return + } + var input createRequest + decoder := json.NewDecoder(http.MaxBytesReader(writer, request.Body, 1<<20)) + decoder.DisallowUnknownFields() + if decoder.Decode(&input) != nil { + apiresponse.Error(writer, http.StatusBadRequest, "请求格式无效") + return + } + input.Name = strings.TrimSpace(input.Name) + if input.Name == "" || len(input.Name) > 128 || len(input.Scopes) == 0 || input.ExpiresAt != nil && !input.ExpiresAt.After(time.Now()) { + apiresponse.Error(writer, http.StatusBadRequest, "名称、权限范围或过期时间无效") + return + } + if !validLimits(input.RequestsPerMinute, input.MonthlyRequestQuota, input.MonthlyTokenQuota) { + apiresponse.Error(writer, http.StatusBadRequest, "API Key 限流或月配额无效") + return + } + for _, scope := range input.Scopes { + if scope != "gateway:invoke" && scope != "*" { + apiresponse.Error(writer, http.StatusBadRequest, "包含不支持的权限范围") + return + } + } + record, secret, err := h.repository.Create(request.Context(), input.Name, input.Scopes, input.RequestsPerMinute, input.MonthlyRequestQuota, input.MonthlyTokenQuota, input.ExpiresAt, account.ID) + if err != nil { + h.writeError(writer, err) + return + } + response := publicRecord(record) + response["key"] = secret + response["warning"] = "密钥只显示一次,请立即安全保存" + apiresponse.OK(writer, response) +} + +func (h *AdminHTTPHandler) revoke(writer http.ResponseWriter, request *http.Request) { + account, ok := h.requirePermission(writer, request, identity.PermissionAPIKeyManage) + if !ok { + return + } + hash, err := h.repository.Revoke(request.Context(), request.PathValue("api_key_id"), account.ID) + if err != nil { + h.writeError(writer, err) + return + } + if err := h.authenticator.Invalidate(request.Context(), hash); err != nil { + h.writeError(writer, err) + return + } + apiresponse.OK(writer, map[string]bool{"revoked": true}) +} + +func (h *AdminHTTPHandler) requirePermission(writer http.ResponseWriter, request *http.Request, permission string) (identity.Account, bool) { + account, err := h.identity.Authenticate(request.Context(), identity.KindAdmin, request.Header.Get("Authorization")) + if err != nil { + apiresponse.Error(writer, http.StatusUnauthorized, "登录状态无效或已过期") + return identity.Account{}, false + } + if !identity.HasPermission(account, permission) { + apiresponse.Error(writer, http.StatusForbidden, "缺少 API Key 操作权限") + return identity.Account{}, false + } + return account, true +} + +func (h *AdminHTTPHandler) writeError(writer http.ResponseWriter, err error) { + switch { + case errors.Is(err, ErrInvalid): + apiresponse.Error(writer, http.StatusNotFound, "API Key 不存在或已撤销") + case errors.Is(err, ErrStore): + apiresponse.Error(writer, http.StatusServiceUnavailable, "API Key 服务暂不可用") + default: + apiresponse.Error(writer, http.StatusInternalServerError, "API Key 处理失败") + } +} + +func publicRecord(record Record) map[string]any { + return map[string]any{ + "id": record.ID, "name": record.Name, "key_prefix": record.KeyPrefix, + "scopes": record.Scopes, "enabled": record.Enabled, "expires_at": record.ExpiresAt, + "requests_per_minute": record.RequestsPerMinute, "monthly_request_quota": record.MonthlyRequestQuota, + "monthly_token_quota": record.MonthlyTokenQuota, + "last_used_at": record.LastUsedAt, "created_at": record.CreatedAt, + } +} diff --git a/internal/apikey/apikey.go b/internal/apikey/apikey.go new file mode 100644 index 0000000..c619cfa --- /dev/null +++ b/internal/apikey/apikey.go @@ -0,0 +1,75 @@ +package apikey + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "strings" + "time" +) + +var ( + ErrInvalid = errors.New("invalid API key") + ErrStore = errors.New("API key store unavailable") +) + +type Record struct { + ID string + TenantID *string + Name string + KeyPrefix string + KeyHash []byte + Scopes []string + Enabled bool + RequestsPerMinute int + MonthlyRequestQuota int64 + MonthlyTokenQuota int64 + ExpiresAt *time.Time + LastUsedAt *time.Time + CreatedAt time.Time +} + +type Principal struct { + APIKeyID string `json:"api_key_id"` + TenantID *string `json:"tenant_id,omitempty"` + Scopes []string `json:"scopes"` + RequestsPerMinute int `json:"requests_per_minute"` + MonthlyRequestQuota int64 `json:"monthly_request_quota"` + MonthlyTokenQuota int64 `json:"monthly_token_quota"` +} + +func Generate() (secret, prefix string, hash []byte, err error) { + random := make([]byte, 32) + if _, err = rand.Read(random); err != nil { + return "", "", nil, err + } + secret = "gw_" + base64.RawURLEncoding.EncodeToString(random) + prefix = secret[:16] + digest := sha256.Sum256([]byte(secret)) + return secret, prefix, digest[:], nil +} + +func Digest(secret string) ([]byte, string) { + digest := sha256.Sum256([]byte(strings.TrimSpace(secret))) + return digest[:], hex.EncodeToString(digest[:]) +} + +func HasScope(scopes []string, required string) bool { + for _, scope := range scopes { + if scope == "*" || scope == required { + return true + } + } + return false +} + +type KeyAuthenticator interface { + Authenticate(context.Context, string) error +} + +type PrincipalAuthenticator interface { + AuthenticatePrincipal(context.Context, string) (Principal, error) +} diff --git a/internal/apikey/apikey_test.go b/internal/apikey/apikey_test.go new file mode 100644 index 0000000..a96bf1b --- /dev/null +++ b/internal/apikey/apikey_test.go @@ -0,0 +1,73 @@ +package apikey + +import ( + "bytes" + "context" + "testing" +) + +func TestBootstrapCompatibilityUsageCounter(t *testing.T) { + authenticator := NewAuthenticator(nil, nil, "temporary-bootstrap-key") + if err := authenticator.Authenticate(context.Background(), "temporary-bootstrap-key"); err != nil { + t.Fatal(err) + } + if authenticator.BootstrapUses() != 1 { + t.Fatalf("unexpected bootstrap usage count %d", authenticator.BootstrapUses()) + } +} + +func TestGenerateAndDigest(t *testing.T) { + first, prefix, hash, err := Generate() + if err != nil { + t.Fatal(err) + } + if len(first) < 40 || len(prefix) != 16 || prefix != first[:16] { + t.Fatal("invalid API key format") + } + computed, _ := Digest(first) + if !bytes.Equal(hash, computed) { + t.Fatal("stored digest differs") + } + second, _, _, err := Generate() + if err != nil || second == first { + t.Fatal("API keys must be independently random") + } +} + +func TestScopes(t *testing.T) { + if !HasScope([]string{"gateway:invoke"}, "gateway:invoke") || !HasScope([]string{"*"}, "gateway:invoke") { + t.Fatal("expected scope is missing") + } + if HasScope([]string{"gateway:read"}, "gateway:invoke") { + t.Fatal("unexpected scope accepted") + } +} + +// TestInvokeScope pins the regression where portal application runtime +// credentials ("application:run") could never reach the gateway: the scope +// check only admitted "gateway:invoke", so every hosted application chat +// returned 401 even though the credential is legitimate. +func TestInvokeScope(t *testing.T) { + accept := map[string][]string{ + "gateway key": {"gateway:invoke"}, + "admin wildcard": {"*"}, + "application runtime key": {"application:run"}, + "runtime + read": {"application:run", "gateway:read"}, + } + for name, scopes := range accept { + if !invokeScope(scopes) { + t.Fatalf("invokeScope(%v) = false, want true for %s", scopes, name) + } + } + reject := map[string][]string{ + "read-only": {"gateway:read"}, + "unrelated scope": {"workbench:run"}, + "empty": {}, + "runtime scope missing": {"gateway:read", "workbench:run"}, + } + for name, scopes := range reject { + if invokeScope(scopes) { + t.Fatalf("invokeScope(%v) = true, want false for %s", scopes, name) + } + } +} diff --git a/internal/apikey/authenticator.go b/internal/apikey/authenticator.go new file mode 100644 index 0000000..9f2cc36 --- /dev/null +++ b/internal/apikey/authenticator.go @@ -0,0 +1,124 @@ +package apikey + +import ( + "context" + "crypto/subtle" + "encoding/json" + "errors" + "fmt" + "log/slog" + "strings" + "sync/atomic" + "time" + + "github.com/redis/go-redis/v9" +) + +type Authenticator struct { + repository *Repository + redis *redis.Client + bootstrap string + cacheTTL time.Duration + bootstrapUses atomic.Uint64 + logger *slog.Logger +} + +func NewAuthenticator(repository *Repository, client *redis.Client, bootstrap string) *Authenticator { + return &Authenticator{repository: repository, redis: client, bootstrap: bootstrap, cacheTTL: 30 * time.Second} +} + +// SetLogger wires an optional logger used for best-effort cache diagnostics. +func (a *Authenticator) SetLogger(logger *slog.Logger) { a.logger = logger } + +func (a *Authenticator) Authenticate(ctx context.Context, secret string) error { + _, err := a.AuthenticatePrincipal(ctx, secret) + return err +} + +// invokeScope reports whether the key carries a scope that may reach the +// gateway. Besides regular gateway keys it admits the server-side +// application runtime credential ("application:run"), which is created only +// by the portal runtime (encrypted in PostgreSQL, tenant-bound, never shown +// to browsers) so that hosted application conversations can call the gateway +// on behalf of the app owner. Both call paths in the workbench runtime and +// the main proxy use this single method, so the runtime credential works +// end-to-end without widening any other surface. +func invokeScope(scopes []string) bool { + return HasScope(scopes, "gateway:invoke") || HasScope(scopes, "application:run") +} + +func (a *Authenticator) AuthenticatePrincipal(ctx context.Context, secret string) (Principal, error) { + secret = strings.TrimSpace(secret) + if secret == "" { + return Principal{}, ErrInvalid + } + if a.bootstrap != "" && len(secret) == len(a.bootstrap) && subtle.ConstantTimeCompare([]byte(secret), []byte(a.bootstrap)) == 1 { + a.bootstrapUses.Add(1) + // The bootstrap credential is a real gateway key, not an anonymous + // pass. Return a stable, well-known identity so the admission and + // token-quota paths run (they allow it outright: RPM/quota are 0 by + // design for a migration key) and audit records attribute usage to + // "bootstrap" instead of an empty principal that silently skips every + // policy stage. An empty APIKeyID previously bypassed rate limiting, + // quota and audit attribution entirely. + return Principal{APIKeyID: "bootstrap", Scopes: []string{"gateway:invoke"}}, nil + } + hash, hexHash := Digest(secret) + if a.redis != nil { + payload, err := a.redis.Get(ctx, cacheKey(hexHash)).Bytes() + if err == nil { + var principal Principal + if json.Unmarshal(payload, &principal) == nil && invokeScope(principal.Scopes) { + return principal, nil + } + return Principal{}, ErrInvalid + } + if err != nil && !errors.Is(err, redis.Nil) { + return Principal{}, fmt.Errorf("%w: %v", ErrStore, err) + } + } + principal, err := a.repository.Validate(ctx, hash) + if err != nil { + return Principal{}, err + } + if !invokeScope(principal.Scopes) { + return Principal{}, ErrInvalid + } + // Best-effort cache: the database is the source of truth for validation. + // A cache write failure must not turn a key the database just accepted + // into a 503, or a transient Redis blip would take the whole gateway down. + if a.redis != nil { + payload, _ := json.Marshal(principal) + if err := a.redis.Set(ctx, cacheKey(hexHash), payload, a.cacheTTL).Err(); err != nil && a.logger != nil { + a.logger.Warn("api key cache write failed; continuing with database result", "error", err) + } + } + return principal, nil +} + +func (a *Authenticator) BootstrapUses() uint64 { + if a == nil { + return 0 + } + return a.bootstrapUses.Load() +} + +func (a *Authenticator) Invalidate(ctx context.Context, hash []byte) error { + if a.redis == nil { + return nil + } + _, hexHash := DigestFromHash(hash) + return a.redis.Del(ctx, cacheKey(hexHash)).Err() +} + +func DigestFromHash(hash []byte) ([]byte, string) { + const hex = "0123456789abcdef" + encoded := make([]byte, len(hash)*2) + for i, value := range hash { + encoded[i*2] = hex[value>>4] + encoded[i*2+1] = hex[value&15] + } + return hash, string(encoded) +} + +func cacheKey(hexHash string) string { return "gateway:api-key:v1:" + hexHash } diff --git a/internal/apikey/repository.go b/internal/apikey/repository.go new file mode 100644 index 0000000..9fb0c24 --- /dev/null +++ b/internal/apikey/repository.go @@ -0,0 +1,179 @@ +package apikey + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + platformid "aigateway.local/core/internal/platform/id" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Repository struct{ pool *pgxpool.Pool } + +func NewRepository(pool *pgxpool.Pool) *Repository { return &Repository{pool: pool} } + +func (r *Repository) List(ctx context.Context) ([]Record, error) { + if r.pool == nil { + return nil, ErrStore + } + rows, err := r.pool.Query(ctx, ` + SELECT id::text, tenant_id::text, name, key_prefix, scopes, enabled, + requests_per_minute, monthly_request_quota, monthly_token_quota, expires_at, last_used_at, created_at + FROM gateway.api_keys ORDER BY created_at DESC`) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrStore, err) + } + defer rows.Close() + var records []Record + for rows.Next() { + var record Record + if err := rows.Scan(&record.ID, &record.TenantID, &record.Name, &record.KeyPrefix, &record.Scopes, &record.Enabled, &record.RequestsPerMinute, &record.MonthlyRequestQuota, &record.MonthlyTokenQuota, &record.ExpiresAt, &record.LastUsedAt, &record.CreatedAt); err != nil { + return nil, fmt.Errorf("%w: %v", ErrStore, err) + } + records = append(records, record) + } + return records, rows.Err() +} + +func (r *Repository) Create(ctx context.Context, name string, scopes []string, requestsPerMinute int, monthlyRequestQuota, monthlyTokenQuota int64, expiresAt *time.Time, actorID string) (Record, string, error) { + if r.pool == nil { + return Record{}, "", ErrStore + } + secret, prefix, hash, err := Generate() + if err != nil { + return Record{}, "", err + } + id, err := platformid.NewUUID() + if err != nil { + return Record{}, "", err + } + eventID, err := platformid.NewUUID() + if err != nil { + return Record{}, "", err + } + tx, err := r.pool.Begin(ctx) + if err != nil { + return Record{}, "", fmt.Errorf("%w: %v", ErrStore, err) + } + defer func() { _ = tx.Rollback(ctx) }() + record := Record{ID: id, Name: name, KeyPrefix: prefix, KeyHash: hash, Scopes: scopes, Enabled: true, RequestsPerMinute: requestsPerMinute, MonthlyRequestQuota: monthlyRequestQuota, MonthlyTokenQuota: monthlyTokenQuota, ExpiresAt: expiresAt} + err = tx.QueryRow(ctx, ` + INSERT INTO gateway.api_keys (id, name, key_prefix, key_hash, scopes, requests_per_minute, monthly_request_quota, monthly_token_quota, expires_at, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, nullif($10,'')::uuid) + RETURNING created_at`, id, name, prefix, hash, scopes, requestsPerMinute, monthlyRequestQuota, monthlyTokenQuota, expiresAt, actorID).Scan(&record.CreatedAt) + if err != nil { + return Record{}, "", fmt.Errorf("%w: %v", ErrStore, err) + } + payload, _ := json.Marshal(map[string]any{"api_key_id": id, "name": name, "key_prefix": prefix, "requests_per_minute": requestsPerMinute, "monthly_request_quota": monthlyRequestQuota, "monthly_token_quota": monthlyTokenQuota}) + if _, err := tx.Exec(ctx, ` + INSERT INTO gateway.outbox_events (event_id, event_type, event_version, aggregate_type, aggregate_id, payload) + VALUES ($1, 'api_key.created', 1, 'api_key', $2, $3)`, eventID, id, payload); err != nil { + return Record{}, "", fmt.Errorf("%w: %v", ErrStore, err) + } + if err := tx.Commit(ctx); err != nil { + return Record{}, "", fmt.Errorf("%w: %v", ErrStore, err) + } + return record, secret, nil +} + +func (r *Repository) Validate(ctx context.Context, hash []byte) (Principal, error) { + if r.pool == nil { + return Principal{}, ErrStore + } + var principal Principal + err := r.pool.QueryRow(ctx, ` + UPDATE gateway.api_keys + SET last_used_at = CASE WHEN last_used_at IS NULL OR last_used_at < clock_timestamp() - interval '5 minutes' THEN clock_timestamp() ELSE last_used_at END + WHERE key_hash = $1 AND enabled AND (expires_at IS NULL OR expires_at > clock_timestamp()) + RETURNING id::text, tenant_id::text, scopes, requests_per_minute, monthly_request_quota, monthly_token_quota`, hash).Scan(&principal.APIKeyID, &principal.TenantID, &principal.Scopes, &principal.RequestsPerMinute, &principal.MonthlyRequestQuota, &principal.MonthlyTokenQuota) + if errors.Is(err, pgx.ErrNoRows) { + return Principal{}, ErrInvalid + } + if err != nil { + return Principal{}, fmt.Errorf("%w: %v", ErrStore, err) + } + return principal, nil +} + +func (r *Repository) Revoke(ctx context.Context, id, actorID string) ([]byte, error) { + if r.pool == nil { + return nil, ErrStore + } + eventID, err := platformid.NewUUID() + if err != nil { + return nil, err + } + tx, err := r.pool.Begin(ctx) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrStore, err) + } + defer func() { _ = tx.Rollback(ctx) }() + var hash []byte + err = tx.QueryRow(ctx, ` + UPDATE gateway.api_keys SET enabled = false, updated_at = clock_timestamp() + WHERE id = $1 AND enabled RETURNING key_hash`, id).Scan(&hash) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrInvalid + } + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrStore, err) + } + payload, _ := json.Marshal(map[string]any{"api_key_id": id, "actor_id": actorID}) + if _, err := tx.Exec(ctx, ` + INSERT INTO gateway.outbox_events (event_id, event_type, event_version, aggregate_type, aggregate_id, payload) + VALUES ($1, 'api_key.revoked', 1, 'api_key', $2, $3)`, eventID, id, payload); err != nil { + return nil, fmt.Errorf("%w: %v", ErrStore, err) + } + if err := tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("%w: %v", ErrStore, err) + } + return hash, nil +} + +func (r *Repository) UpdateLimits(ctx context.Context, id string, requestsPerMinute int, monthlyRequestQuota, monthlyTokenQuota int64, actorID string) (Record, []byte, error) { + if r.pool == nil { + return Record{}, nil, ErrStore + } + eventID, err := platformid.NewUUID() + if err != nil { + return Record{}, nil, err + } + tx, err := r.pool.Begin(ctx) + if err != nil { + return Record{}, nil, fmt.Errorf("%w: %v", ErrStore, err) + } + defer func() { _ = tx.Rollback(ctx) }() + var record Record + err = tx.QueryRow(ctx, ` + UPDATE gateway.api_keys + SET requests_per_minute = $2, monthly_request_quota = $3, monthly_token_quota = $4, updated_at = clock_timestamp() + WHERE id = $1 AND enabled + RETURNING id::text, tenant_id::text, name, key_prefix, key_hash, scopes, enabled, + requests_per_minute, monthly_request_quota, monthly_token_quota, expires_at, last_used_at, created_at`, + id, requestsPerMinute, monthlyRequestQuota, monthlyTokenQuota, + ).Scan(&record.ID, &record.TenantID, &record.Name, &record.KeyPrefix, &record.KeyHash, &record.Scopes, &record.Enabled, + &record.RequestsPerMinute, &record.MonthlyRequestQuota, &record.MonthlyTokenQuota, &record.ExpiresAt, &record.LastUsedAt, &record.CreatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return Record{}, nil, ErrInvalid + } + if err != nil { + return Record{}, nil, fmt.Errorf("%w: %v", ErrStore, err) + } + payload, _ := json.Marshal(map[string]any{ + "api_key_id": id, "actor_id": actorID, "requests_per_minute": requestsPerMinute, + "monthly_request_quota": monthlyRequestQuota, "monthly_token_quota": monthlyTokenQuota, + }) + if _, err := tx.Exec(ctx, ` + INSERT INTO gateway.outbox_events (event_id, event_type, event_version, aggregate_type, aggregate_id, payload) + VALUES ($1, 'api_key.limits_updated', 1, 'api_key', $2, $3)`, eventID, id, payload); err != nil { + return Record{}, nil, fmt.Errorf("%w: %v", ErrStore, err) + } + if err := tx.Commit(ctx); err != nil { + return Record{}, nil, fmt.Errorf("%w: %v", ErrStore, err) + } + return record, record.KeyHash, nil +} diff --git a/internal/apikey/usage.go b/internal/apikey/usage.go new file mode 100644 index 0000000..8f1f852 --- /dev/null +++ b/internal/apikey/usage.go @@ -0,0 +1,42 @@ +package apikey + +import ( + "context" + "fmt" + "time" + + "github.com/redis/go-redis/v9" +) + +type UsageStore struct{ redis *redis.Client } + +func NewUsageStore(client *redis.Client) *UsageStore { return &UsageStore{redis: client} } + +func MonthlyTokenUsageKey(apiKeyID string, now time.Time) string { + return fmt.Sprintf("gateway:usage:api-key:%s:tokens:%s", apiKeyID, now.UTC().Format("200601")) +} + +func (s *UsageStore) MonthlyTokens(ctx context.Context, apiKeyIDs []string, now time.Time) (map[string]int64, error) { + usage := make(map[string]int64, len(apiKeyIDs)) + if len(apiKeyIDs) == 0 || s == nil || s.redis == nil { + return usage, nil + } + pipe := s.redis.Pipeline() + commands := make(map[string]*redis.StringCmd, len(apiKeyIDs)) + for _, id := range apiKeyIDs { + commands[id] = pipe.Get(ctx, MonthlyTokenUsageKey(id, now)) + } + _, err := pipe.Exec(ctx) + if err != nil && err != redis.Nil { + return nil, fmt.Errorf("%w: %v", ErrStore, err) + } + for id, command := range commands { + value, commandErr := command.Int64() + if commandErr == nil { + usage[id] = max(value, 0) + } else if commandErr != redis.Nil { + return nil, fmt.Errorf("%w: %v", ErrStore, commandErr) + } + } + return usage, nil +} diff --git a/internal/audit/admin_http.go b/internal/audit/admin_http.go new file mode 100644 index 0000000..7cab178 --- /dev/null +++ b/internal/audit/admin_http.go @@ -0,0 +1,144 @@ +package audit + +import ( + "errors" + "net/http" + "strconv" + "strings" + "time" + + "aigateway.local/core/internal/identity" + "aigateway.local/core/internal/platform/apiresponse" +) + +type AdminHTTPHandler struct { + query *QueryService + identity *identity.Service + mux *http.ServeMux +} + +func NewAdminHTTPHandler(query *QueryService, identityService *identity.Service) *AdminHTTPHandler { + h := &AdminHTTPHandler{query: query, identity: identityService, mux: http.NewServeMux()} + h.mux.HandleFunc("GET /api/v1/admin/audit-events", h.listEvents) + h.mux.HandleFunc("GET /api/v1/admin/usage/daily", h.listDailyUsage) + return h +} + +func (h *AdminHTTPHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) { + h.mux.ServeHTTP(writer, request) +} + +func (h *AdminHTTPHandler) listEvents(writer http.ResponseWriter, request *http.Request) { + if !h.requirePermission(writer, request, identity.PermissionAuditRead) { + return + } + now := time.Now().UTC() + from, err := queryTime(request, "from", now.Add(-24*time.Hour)) + if err != nil { + apiresponse.Error(writer, http.StatusBadRequest, "from 时间无效") + return + } + to, err := queryTime(request, "to", now.Add(time.Second)) + if err != nil || !to.After(from) || to.Sub(from) > 366*24*time.Hour { + apiresponse.Error(writer, http.StatusBadRequest, "审计查询时间范围无效或超过 366 天") + return + } + limit := 50 + if value := request.URL.Query().Get("limit"); value != "" { + limit, err = strconv.Atoi(value) + if err != nil || limit < 1 || limit > 200 { + apiresponse.Error(writer, http.StatusBadRequest, "limit 必须在 1 到 200 之间") + return + } + } + var before *time.Time + if value := request.URL.Query().Get("before"); value != "" { + parsed, parseErr := time.Parse(time.RFC3339Nano, value) + if parseErr != nil { + apiresponse.Error(writer, http.StatusBadRequest, "before 游标无效") + return + } + before = &parsed + } + var status *int + if value := request.URL.Query().Get("status"); value != "" { + parsed, parseErr := strconv.Atoi(value) + if parseErr != nil || parsed < 100 || parsed > 599 { + apiresponse.Error(writer, http.StatusBadRequest, "status 无效") + return + } + status = &parsed + } + items, err := h.query.ListEvents(request.Context(), EventFilter{ + From: from, To: to, Before: before, APIKeyID: strings.TrimSpace(request.URL.Query().Get("api_key_id")), + Provider: strings.TrimSpace(request.URL.Query().Get("provider")), Model: strings.TrimSpace(request.URL.Query().Get("model")), StatusCode: status, Limit: limit, + }) + if err != nil { + apiresponse.Error(writer, http.StatusServiceUnavailable, "审计查询服务暂不可用") + return + } + next := "" + if len(items) == limit { + next = items[len(items)-1].RecordedAt.Format(time.RFC3339Nano) + } + apiresponse.OK(writer, map[string]any{"items": items, "next_before": next}) +} + +func (h *AdminHTTPHandler) listDailyUsage(writer http.ResponseWriter, request *http.Request) { + if !h.requirePermission(writer, request, identity.PermissionUsageRead) { + return + } + now := time.Now().UTC() + from, err := queryDate(request, "from", now.AddDate(0, 0, -29)) + if err != nil { + apiresponse.Error(writer, http.StatusBadRequest, "from 日期无效") + return + } + to, err := queryDate(request, "to", now) + if err != nil || to.Before(from) || to.Sub(from) > 366*24*time.Hour { + apiresponse.Error(writer, http.StatusBadRequest, "usage 查询日期范围无效或超过 366 天") + return + } + items, err := h.query.ListDailyUsage(request.Context(), UsageFilter{ + From: from, To: to, APIKeyID: strings.TrimSpace(request.URL.Query().Get("api_key_id")), + Provider: strings.TrimSpace(request.URL.Query().Get("provider")), Model: strings.TrimSpace(request.URL.Query().Get("model")), + }) + if err != nil { + apiresponse.Error(writer, http.StatusServiceUnavailable, "usage 查询服务暂不可用") + return + } + apiresponse.OK(writer, items) +} + +func (h *AdminHTTPHandler) requirePermission(writer http.ResponseWriter, request *http.Request, permission string) bool { + account, err := h.identity.Authenticate(request.Context(), identity.KindAdmin, request.Header.Get("Authorization")) + if err != nil { + status := http.StatusUnauthorized + if !errors.Is(err, identity.ErrInvalidSession) && !errors.Is(err, identity.ErrNotFound) { + status = http.StatusServiceUnavailable + } + apiresponse.Error(writer, status, "登录状态无效或身份服务暂不可用") + return false + } + if !identity.HasPermission(account, permission) { + apiresponse.Error(writer, http.StatusForbidden, "缺少审计或 usage 查看权限") + return false + } + return true +} + +func queryTime(request *http.Request, name string, fallback time.Time) (time.Time, error) { + value := request.URL.Query().Get(name) + if value == "" { + return fallback, nil + } + return time.Parse(time.RFC3339, value) +} + +func queryDate(request *http.Request, name string, fallback time.Time) (time.Time, error) { + value := request.URL.Query().Get(name) + if value == "" { + value = fallback.Format("2006-01-02") + } + return time.Parse("2006-01-02", value) +} diff --git a/internal/audit/maintenance.go b/internal/audit/maintenance.go new file mode 100644 index 0000000..ad4fee3 --- /dev/null +++ b/internal/audit/maintenance.go @@ -0,0 +1,147 @@ +package audit + +import ( + "context" + "errors" + "fmt" + "regexp" + "sort" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +const maintenanceLockID int64 = 6720240816 + +var auditPartitionPattern = regexp.MustCompile(`^audit_events_(\d{4})(\d{2})$`) + +type MaintenanceResult struct { + CreatedPartitions []string `json:"created_partitions"` + DroppedPartitions []string `json:"dropped_partitions"` + DeletedAuditRows int64 `json:"deleted_audit_rows"` + DeletedUsageRows int64 `json:"deleted_usage_rows"` +} + +type Maintenance struct { + pool *pgxpool.Pool + auditRetention time.Duration + usageRetention time.Duration + monthsAhead int +} + +func NewMaintenance(pool *pgxpool.Pool, auditRetention, usageRetention time.Duration, monthsAhead int) *Maintenance { + return &Maintenance{pool: pool, auditRetention: auditRetention, usageRetention: usageRetention, monthsAhead: monthsAhead} +} + +func (m *Maintenance) Run(ctx context.Context, now time.Time) (MaintenanceResult, error) { + var result MaintenanceResult + if m == nil || m.pool == nil { + return result, errors.New("audit maintenance store unavailable") + } + now = now.UTC() + auditCutoff := now.Add(-m.auditRetention) + usageCutoff := now.Add(-m.usageRetention) + tx, err := m.pool.Begin(ctx) + if err != nil { + return result, fmt.Errorf("begin audit maintenance: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock($1)`, maintenanceLockID); err != nil { + return result, fmt.Errorf("lock audit maintenance: %w", err) + } + if _, err := tx.Exec(ctx, `LOCK TABLE gateway.audit_events IN ACCESS EXCLUSIVE MODE`); err != nil { + return result, fmt.Errorf("lock audit table: %w", err) + } + partitions, err := listAuditPartitions(ctx, tx) + if err != nil { + return result, err + } + for name := range partitions { + start, ok := auditPartitionMonth(name) + if !ok || start.AddDate(0, 1, 0).After(auditCutoff) { + continue + } + if _, err := tx.Exec(ctx, `DROP TABLE `+pgx.Identifier{"gateway", name}.Sanitize()); err != nil { + return result, fmt.Errorf("drop audit partition %s: %w", name, err) + } + result.DroppedPartitions = append(result.DroppedPartitions, name) + } + if _, err := tx.Exec(ctx, `ALTER TABLE gateway.audit_events DETACH PARTITION gateway.audit_events_default`); err != nil { + return result, fmt.Errorf("detach audit default partition: %w", err) + } + deleted, err := tx.Exec(ctx, `DELETE FROM gateway.audit_events_default WHERE recorded_at < $1`, auditCutoff) + if err != nil { + return result, fmt.Errorf("clean audit default partition: %w", err) + } + result.DeletedAuditRows += deleted.RowsAffected() + from := monthStart(auditCutoff) + through := monthStart(now).AddDate(0, m.monthsAhead+1, 0) + for start := from; start.Before(through); start = start.AddDate(0, 1, 0) { + end := start.AddDate(0, 1, 0) + name := "audit_events_" + start.Format("200601") + if _, exists := partitions[name]; !exists { + statement := fmt.Sprintf(`CREATE TABLE %s PARTITION OF gateway.audit_events FOR VALUES FROM ('%s') TO ('%s')`, pgx.Identifier{"gateway", name}.Sanitize(), start.Format(time.RFC3339), end.Format(time.RFC3339)) + if _, err := tx.Exec(ctx, statement); err != nil { + return result, fmt.Errorf("create audit partition %s: %w", name, err) + } + result.CreatedPartitions = append(result.CreatedPartitions, name) + } + statement := `WITH moved AS (DELETE FROM gateway.audit_events_default WHERE recorded_at >= $1 AND recorded_at < $2 RETURNING *) INSERT INTO gateway.audit_events SELECT * FROM moved` + if _, err := tx.Exec(ctx, statement, start, end); err != nil { + return result, fmt.Errorf("move default audit rows into %s: %w", name, err) + } + } + if _, err := tx.Exec(ctx, `ALTER TABLE gateway.audit_events ATTACH PARTITION gateway.audit_events_default DEFAULT`); err != nil { + return result, fmt.Errorf("reattach audit default partition: %w", err) + } + deleted, err = tx.Exec(ctx, `DELETE FROM gateway.audit_events WHERE recorded_at < $1`, auditCutoff) + if err != nil { + return result, fmt.Errorf("apply exact audit retention: %w", err) + } + result.DeletedAuditRows += deleted.RowsAffected() + deleted, err = tx.Exec(ctx, `DELETE FROM gateway.usage_daily WHERE usage_date < $1::date`, usageCutoff.Format("2006-01-02")) + if err != nil { + return result, fmt.Errorf("apply usage retention: %w", err) + } + result.DeletedUsageRows = deleted.RowsAffected() + if err := tx.Commit(ctx); err != nil { + return result, fmt.Errorf("commit audit maintenance: %w", err) + } + sort.Strings(result.CreatedPartitions) + sort.Strings(result.DroppedPartitions) + return result, nil +} + +func listAuditPartitions(ctx context.Context, tx pgx.Tx) (map[string]struct{}, error) { + rows, err := tx.Query(ctx, `SELECT child.relname FROM pg_inherits i JOIN pg_class parent ON parent.oid=i.inhparent JOIN pg_namespace n ON n.oid=parent.relnamespace JOIN pg_class child ON child.oid=i.inhrelid WHERE n.nspname='gateway' AND parent.relname='audit_events'`) + if err != nil { + return nil, fmt.Errorf("list audit partitions: %w", err) + } + defer rows.Close() + result := make(map[string]struct{}) + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, fmt.Errorf("scan audit partition: %w", err) + } + if name != "audit_events_default" { + result[name] = struct{}{} + } + } + return result, rows.Err() +} + +func auditPartitionMonth(name string) (time.Time, bool) { + match := auditPartitionPattern.FindStringSubmatch(name) + if match == nil { + return time.Time{}, false + } + parsed, err := time.Parse("200601", match[1]+match[2]) + return parsed.UTC(), err == nil +} + +func monthStart(value time.Time) time.Time { + value = value.UTC() + return time.Date(value.Year(), value.Month(), 1, 0, 0, 0, 0, time.UTC) +} diff --git a/internal/audit/maintenance_integration_test.go b/internal/audit/maintenance_integration_test.go new file mode 100644 index 0000000..7381261 --- /dev/null +++ b/internal/audit/maintenance_integration_test.go @@ -0,0 +1,73 @@ +package audit + +import ( + "context" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func TestMaintenancePartitionsRetentionAndIdempotency(t *testing.T) { + databaseURL := os.Getenv("AUDIT_MAINTENANCE_TEST_DATABASE_URL") + if databaseURL == "" { + t.Skip("AUDIT_MAINTENANCE_TEST_DATABASE_URL is not configured") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + defer pool.Close() + const oldID = "44444444-4444-4444-8444-444444444444" + const currentID = "55555555-5555-4555-8555-555555555555" + _, err = pool.Exec(ctx, `INSERT INTO gateway.audit_events(id,request_id,protocol,status_code,recorded_at) VALUES + ($1,'maintenance-old','/v1/test',200,'2026-01-15T00:00:00Z'), + ($2,'maintenance-current','/v1/test',200,'2026-08-10T00:00:00Z')`, oldID, currentID) + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 8, 11, 0, 0, 0, 0, time.UTC) + first, err := NewMaintenance(pool, 300*24*time.Hour, 730*24*time.Hour, 1).Run(ctx, now) + if err != nil { + t.Fatal(err) + } + if !contains(first.CreatedPartitions, "audit_events_202601") || !contains(first.CreatedPartitions, "audit_events_202608") { + t.Fatalf("expected old and current partitions, got %#v", first.CreatedPartitions) + } + second, err := NewMaintenance(pool, 90*24*time.Hour, 730*24*time.Hour, 1).Run(ctx, now) + if err != nil { + t.Fatal(err) + } + if !contains(second.DroppedPartitions, "audit_events_202601") { + t.Fatalf("expected stale partition to be dropped, got %#v", second.DroppedPartitions) + } + var currentTable string + if err := pool.QueryRow(ctx, `SELECT tableoid::regclass::text FROM gateway.audit_events WHERE id=$1`, currentID).Scan(¤tTable); err != nil { + t.Fatal(err) + } + if currentTable != "gateway.audit_events_202608" && currentTable != "audit_events_202608" { + t.Fatalf("current row was not routed to monthly partition: %s", currentTable) + } + var oldCount int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM gateway.audit_events WHERE id=$1`, oldID).Scan(&oldCount); err != nil || oldCount != 0 { + t.Fatalf("expired row still exists: count=%d err=%v", oldCount, err) + } + third, err := NewMaintenance(pool, 90*24*time.Hour, 730*24*time.Hour, 1).Run(ctx, now) + if err != nil { + t.Fatal(err) + } + if len(third.CreatedPartitions) != 0 || len(third.DroppedPartitions) != 0 { + t.Fatalf("maintenance must be idempotent: %#v", third) + } +} + +func contains(values []string, wanted string) bool { + for _, value := range values { + if value == wanted { + return true + } + } + return false +} diff --git a/internal/audit/maintenance_test.go b/internal/audit/maintenance_test.go new file mode 100644 index 0000000..8b9316d --- /dev/null +++ b/internal/audit/maintenance_test.go @@ -0,0 +1,23 @@ +package audit + +import ( + "testing" + "time" +) + +func TestAuditPartitionMonth(t *testing.T) { + month, ok := auditPartitionMonth("audit_events_202608") + if !ok || month.Format("2006-01-02") != "2026-08-01" { + t.Fatalf("unexpected month: %v %v", month, ok) + } + if _, ok := auditPartitionMonth("audit_events_default"); ok { + t.Fatal("default partition must not parse as a monthly partition") + } +} + +func TestMonthStartUsesUTC(t *testing.T) { + value := time.Date(2026, 8, 31, 23, 0, 0, 0, time.FixedZone("UTC-2", -2*3600)) + if got := monthStart(value); got.Format(time.RFC3339) != "2026-09-01T00:00:00Z" { + t.Fatalf("unexpected UTC month start: %s", got) + } +} diff --git a/internal/audit/query.go b/internal/audit/query.go new file mode 100644 index 0000000..7c4e6fe --- /dev/null +++ b/internal/audit/query.go @@ -0,0 +1,151 @@ +package audit + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type QueryService struct{ pool *pgxpool.Pool } + +func NewQueryService(pool *pgxpool.Pool) *QueryService { return &QueryService{pool: pool} } + +type EventView struct { + ID string `json:"id"` + TenantID *string `json:"tenant_id"` + RequestID string `json:"request_id"` + APIKeyID *string `json:"api_key_id"` + APIKeyName *string `json:"api_key_name"` + ProviderCode *string `json:"provider_code"` + Model *string `json:"model"` + Protocol string `json:"protocol"` + StatusCode *int `json:"status_code"` + PromptTokens *int64 `json:"prompt_tokens"` + CompletionTokens *int64 `json:"completion_tokens"` + CostMicrounits *int64 `json:"cost_microunits"` + LatencyMS *int `json:"latency_ms"` + Labels json.RawMessage `json:"labels"` + RecordedAt time.Time `json:"recorded_at"` +} + +type EventFilter struct { + From, To time.Time + Before *time.Time + APIKeyID string + Provider string + Model string + StatusCode *int + Limit int +} + +func (s *QueryService) ListEvents(ctx context.Context, filter EventFilter) ([]EventView, error) { + if s == nil || s.pool == nil { + return nil, fmt.Errorf("audit store unavailable") + } + where := []string{"a.recorded_at >= $1", "a.recorded_at < $2"} + args := []any{filter.From, filter.To} + add := func(condition string, value any) { + args = append(args, value) + where = append(where, fmt.Sprintf(condition, len(args))) + } + if filter.Before != nil { + add("a.recorded_at < $%d", *filter.Before) + } + if filter.APIKeyID != "" { + add("a.api_key_id = $%d", filter.APIKeyID) + } + if filter.Provider != "" { + add("a.provider_code = $%d", filter.Provider) + } + if filter.Model != "" { + add("a.model = $%d", filter.Model) + } + if filter.StatusCode != nil { + add("a.status_code = $%d", *filter.StatusCode) + } + args = append(args, filter.Limit) + query := `SELECT a.id::text,a.tenant_id::text,a.request_id,a.api_key_id::text,k.name, + a.provider_code,a.model,a.protocol,a.status_code,a.prompt_tokens,a.completion_tokens,a.cost_microunits,a.latency_ms,a.labels,a.recorded_at + FROM gateway.audit_events a LEFT JOIN gateway.api_keys k ON k.id=a.api_key_id + WHERE ` + strings.Join(where, " AND ") + ` ORDER BY a.recorded_at DESC,a.id DESC LIMIT $` + strconv.Itoa(len(args)) + rows, err := s.pool.Query(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("query audit events: %w", err) + } + defer rows.Close() + items := make([]EventView, 0) + for rows.Next() { + var item EventView + if err := rows.Scan(&item.ID, &item.TenantID, &item.RequestID, &item.APIKeyID, &item.APIKeyName, + &item.ProviderCode, &item.Model, &item.Protocol, &item.StatusCode, &item.PromptTokens, + &item.CompletionTokens, &item.CostMicrounits, &item.LatencyMS, &item.Labels, &item.RecordedAt); err != nil { + return nil, fmt.Errorf("scan audit event: %w", err) + } + items = append(items, item) + } + return items, rows.Err() +} + +type DailyUsageView struct { + Date time.Time `json:"date"` + APIKeyID string `json:"api_key_id"` + APIKeyName string `json:"api_key_name"` + ProviderCode string `json:"provider_code"` + Model string `json:"model"` + Requests int64 `json:"requests"` + FailedRequests int64 `json:"failed_requests"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + CostMicrounits int64 `json:"cost_microunits"` +} + +type UsageFilter struct { + From, To time.Time + APIKeyID string + Provider string + Model string +} + +func (s *QueryService) ListDailyUsage(ctx context.Context, filter UsageFilter) ([]DailyUsageView, error) { + if s == nil || s.pool == nil { + return nil, fmt.Errorf("usage store unavailable") + } + where := []string{"u.usage_date >= $1", "u.usage_date <= $2"} + args := []any{filter.From, filter.To} + add := func(column string, value any) { + args = append(args, value) + where = append(where, column+" = $"+strconv.Itoa(len(args))) + } + if filter.APIKeyID != "" { + add("u.api_key_id", filter.APIKeyID) + } + if filter.Provider != "" { + add("u.provider_code", filter.Provider) + } + if filter.Model != "" { + add("u.model", filter.Model) + } + rows, err := s.pool.Query(ctx, `SELECT u.usage_date,u.api_key_id::text,k.name,u.provider_code,u.model, + u.requests,u.failed_requests,u.prompt_tokens,u.completion_tokens,u.cost_microunits + FROM gateway.usage_daily u JOIN gateway.api_keys k ON k.id=u.api_key_id WHERE `+ + strings.Join(where, " AND ")+` ORDER BY u.usage_date DESC,k.name,u.provider_code,u.model`, args...) + if err != nil { + return nil, fmt.Errorf("query daily usage: %w", err) + } + defer rows.Close() + items := make([]DailyUsageView, 0) + for rows.Next() { + var item DailyUsageView + if err := rows.Scan(&item.Date, &item.APIKeyID, &item.APIKeyName, &item.ProviderCode, &item.Model, + &item.Requests, &item.FailedRequests, &item.PromptTokens, &item.CompletionTokens, &item.CostMicrounits); err != nil { + return nil, fmt.Errorf("scan daily usage: %w", err) + } + items = append(items, item) + } + return items, rows.Err() +} diff --git a/internal/audit/recorder.go b/internal/audit/recorder.go new file mode 100644 index 0000000..7c32193 --- /dev/null +++ b/internal/audit/recorder.go @@ -0,0 +1,282 @@ +package audit + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "strings" + "sync/atomic" + "time" + + platformid "aigateway.local/core/internal/platform/id" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Event struct { + TenantID *string + RequestID string + APIKeyID *string + ProviderCode string + Model string + Protocol string + StatusCode int + PromptTokens int64 + CompletionTokens int64 + CostMicrounits int64 + PriceID string + Currency string + LatencyMS int + Labels map[string]any + RecordedAt time.Time +} + +type Stats struct { + Accepted uint64 + Dropped uint64 + Written uint64 + Failures uint64 +} + +type Recorder struct { + pool *pgxpool.Pool + logger *slog.Logger + queue chan Event + batchSize int + flushInterval time.Duration + accepted atomic.Uint64 + dropped atomic.Uint64 + written atomic.Uint64 + failures atomic.Uint64 +} + +func NewRecorder(pool *pgxpool.Pool, logger *slog.Logger, queueSize, batchSize int, flushInterval time.Duration) *Recorder { + if queueSize < 1 { + queueSize = 4096 + } + if batchSize < 1 { + batchSize = 200 + } + if flushInterval <= 0 { + flushInterval = time.Second + } + return &Recorder{pool: pool, logger: logger, queue: make(chan Event, queueSize), batchSize: batchSize, flushInterval: flushInterval} +} + +func (r *Recorder) Record(event Event) bool { + if r == nil || r.pool == nil { + return false + } + if event.RecordedAt.IsZero() { + event.RecordedAt = time.Now().UTC() + } + select { + case r.queue <- event: + r.accepted.Add(1) + return true + default: + r.dropped.Add(1) + return false + } +} + +func (r *Recorder) Stats() Stats { + if r == nil { + return Stats{} + } + return Stats{Accepted: r.accepted.Load(), Dropped: r.dropped.Load(), Written: r.written.Load(), Failures: r.failures.Load()} +} + +func (r *Recorder) Prometheus() string { + stats := r.Stats() + return fmt.Sprintf("gateway_audit_events_accepted_total %d\ngateway_audit_events_dropped_total %d\ngateway_audit_events_written_total %d\ngateway_audit_flush_failures_total %d\n", stats.Accepted, stats.Dropped, stats.Written, stats.Failures) +} + +func (r *Recorder) Run(ctx context.Context) { + ticker := time.NewTicker(r.flushInterval) + defer ticker.Stop() + pending := make([]Event, 0, r.batchSize) + flushAllowed := true + for { + var events <-chan Event = r.queue + if len(pending) >= cap(r.queue)+r.batchSize { + events = nil + } + select { + case event := <-events: + pending = append(pending, event) + if len(pending) >= r.batchSize && flushAllowed { + if r.flush(ctx, pending) == nil { + pending = pending[:0] + } else { + flushAllowed = false + } + } + case <-ticker.C: + flushAllowed = true + if len(pending) > 0 { + if r.flush(ctx, pending) == nil { + pending = pending[:0] + } else { + flushAllowed = false + } + } + case <-ctx.Done(): + for { + select { + case event := <-r.queue: + pending = append(pending, event) + default: + flushCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + if len(pending) > 0 { + _ = r.flush(flushCtx, pending) + } + cancel() + return + } + } + } + } +} + +type dailyKey struct { + date, apiKeyID, provider, model string +} + +type dailyValue struct { + requests, failed, prompt, completion, cost int64 +} + +type policyAlert struct { + eventID, requestID string + tenantID *string + payload []byte +} + +func (r *Recorder) flush(ctx context.Context, events []Event) error { + if len(events) == 0 { + return nil + } + tx, err := r.pool.Begin(ctx) + if err != nil { + return r.flushError(err) + } + defer func() { _ = tx.Rollback(ctx) }() + rows := make([][]any, 0, len(events)) + daily := make(map[dailyKey]dailyValue) + alerts := make([]policyAlert, 0) + for _, event := range events { + id, err := platformid.NewUUID() + if err != nil { + return r.flushError(err) + } + labels, _ := json.Marshal(event.Labels) + if policies, matched := event.Labels["content_policies"]; matched { + alertID, idErr := platformid.NewUUID() + if idErr != nil { + return r.flushError(idErr) + } + payload, _ := json.Marshal(map[string]any{ + "request_id": event.RequestID, "protocol": event.Protocol, "status_code": event.StatusCode, + "provider_code": event.ProviderCode, "model": event.Model, "content_policies": policies, + "content_redacted": event.Labels["content_redacted"], "recorded_at": event.RecordedAt, + }) + alerts = append(alerts, policyAlert{eventID: alertID, requestID: event.RequestID, tenantID: event.TenantID, payload: payload}) + } + rows = append(rows, []any{ + uuidValue(id), uuidPointer(event.TenantID), event.RequestID, nil, uuidPointer(event.APIKeyID), + nullString(event.ProviderCode), nullString(event.Model), event.Protocol, event.StatusCode, + nullToken(event.PromptTokens), nullToken(event.CompletionTokens), nullCost(event.CostMicrounits), event.LatencyMS, + nil, nil, labels, event.RecordedAt, + }) + if event.APIKeyID != nil && *event.APIKeyID != "" { + key := dailyKey{date: event.RecordedAt.UTC().Format("2006-01-02"), apiKeyID: *event.APIKeyID, provider: event.ProviderCode, model: event.Model} + value := daily[key] + value.requests++ + if event.StatusCode >= 400 { + value.failed++ + } + value.prompt += max(event.PromptTokens, 0) + value.completion += max(event.CompletionTokens, 0) + value.cost += max(event.CostMicrounits, 0) + daily[key] = value + } + } + _, err = tx.CopyFrom(ctx, pgx.Identifier{"gateway", "audit_events"}, []string{ + "id", "tenant_id", "request_id", "actor_id", "api_key_id", "provider_code", "model", "protocol", + "status_code", "prompt_tokens", "completion_tokens", "cost_microunits", "latency_ms", + "request_preview", "response_preview", "labels", "recorded_at", + }, pgx.CopyFromRows(rows)) + if err != nil { + return r.flushError(err) + } + batch := &pgx.Batch{} + for key, value := range daily { + batch.Queue(`INSERT INTO gateway.usage_daily(usage_date,api_key_id,provider_code,model,requests,failed_requests,prompt_tokens,completion_tokens,cost_microunits) + VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) + ON CONFLICT(usage_date,api_key_id,provider_code,model) DO UPDATE SET + requests=gateway.usage_daily.requests+EXCLUDED.requests, + failed_requests=gateway.usage_daily.failed_requests+EXCLUDED.failed_requests, + prompt_tokens=gateway.usage_daily.prompt_tokens+EXCLUDED.prompt_tokens, + completion_tokens=gateway.usage_daily.completion_tokens+EXCLUDED.completion_tokens, + cost_microunits=gateway.usage_daily.cost_microunits+EXCLUDED.cost_microunits, + updated_at=clock_timestamp()`, key.date, key.apiKeyID, key.provider, key.model, value.requests, value.failed, value.prompt, value.completion, value.cost) + } + for _, alert := range alerts { + batch.Queue(`INSERT INTO gateway.outbox_events(event_id,event_type,event_version,tenant_id,aggregate_type,aggregate_id,payload) + VALUES($1,'content_policy.matched',1,$2,'gateway_request',$3,$4)`, alert.eventID, uuidPointer(alert.tenantID), alert.requestID, alert.payload) + } + results := tx.SendBatch(ctx, batch) + if err := results.Close(); err != nil { + return r.flushError(err) + } + if err := tx.Commit(ctx); err != nil { + return r.flushError(err) + } + r.written.Add(uint64(len(events))) + return nil +} + +func (r *Recorder) flushError(err error) error { + r.failures.Add(1) + if r.logger != nil { + r.logger.Error("audit batch flush failed", "error", err) + } + return err +} + +func uuidValue(value string) pgtype.UUID { + var result pgtype.UUID + _ = result.Scan(value) + return result +} + +func uuidPointer(value *string) any { + if value == nil || strings.TrimSpace(*value) == "" { + return nil + } + return uuidValue(*value) +} + +func nullString(value string) any { + if value == "" { + return nil + } + return value +} + +func nullToken(value int64) any { + if value <= 0 { + return nil + } + return value +} + +func nullCost(value int64) any { + if value <= 0 { + return nil + } + return value +} diff --git a/internal/contentpolicy/admin_http.go b/internal/contentpolicy/admin_http.go new file mode 100644 index 0000000..bd6ea8b --- /dev/null +++ b/internal/contentpolicy/admin_http.go @@ -0,0 +1,119 @@ +package contentpolicy + +import ( + "encoding/json" + "errors" + "net/http" + + "aigateway.local/core/internal/identity" + "aigateway.local/core/internal/platform/apiresponse" +) + +type AdminHTTPHandler struct { + store *Store + engine *Engine + identity *identity.Service + mux *http.ServeMux +} + +func NewAdminHTTPHandler(store *Store, engine *Engine, identityService *identity.Service) *AdminHTTPHandler { + h := &AdminHTTPHandler{store: store, engine: engine, identity: identityService, mux: http.NewServeMux()} + h.mux.HandleFunc("GET /api/v1/admin/content-policies", h.list) + h.mux.HandleFunc("POST /api/v1/admin/content-policies", h.create) + h.mux.HandleFunc("PUT /api/v1/admin/content-policies/{policy_id}", h.update) + h.mux.HandleFunc("DELETE /api/v1/admin/content-policies/{policy_id}", h.delete) + return h +} +func (h *AdminHTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.mux.ServeHTTP(w, r) } +func (h *AdminHTTPHandler) list(w http.ResponseWriter, r *http.Request) { + if _, ok := h.require(w, r, identity.PermissionContentPolicyRead); !ok { + return + } + items, err := h.store.List(r.Context()) + if err != nil { + apiresponse.Error(w, 503, "内容策略服务暂不可用") + return + } + apiresponse.OK(w, items) +} +func (h *AdminHTTPHandler) create(w http.ResponseWriter, r *http.Request) { + actor, ok := h.require(w, r, identity.PermissionContentPolicyManage) + if !ok { + return + } + p, ok := decodePolicy(w, r) + if !ok { + return + } + saved, err := h.store.Save(r.Context(), p, actor.ID, true) + h.finish(w, r, saved, err) +} +func (h *AdminHTTPHandler) update(w http.ResponseWriter, r *http.Request) { + actor, ok := h.require(w, r, identity.PermissionContentPolicyManage) + if !ok { + return + } + p, ok := decodePolicy(w, r) + if !ok { + return + } + p.ID = r.PathValue("policy_id") + saved, err := h.store.Save(r.Context(), p, actor.ID, false) + h.finish(w, r, saved, err) +} +func (h *AdminHTTPHandler) delete(w http.ResponseWriter, r *http.Request) { + actor, ok := h.require(w, r, identity.PermissionContentPolicyManage) + if !ok { + return + } + err := h.store.Delete(r.Context(), r.PathValue("policy_id"), actor.ID) + if errors.Is(err, ErrNotFound) { + apiresponse.Error(w, 404, "内容策略不存在") + return + } + if err != nil { + apiresponse.Error(w, 503, "内容策略删除失败") + return + } + _ = h.engine.Reload(r.Context()) + apiresponse.OK(w, map[string]bool{"deleted": true}) +} +func (h *AdminHTTPHandler) finish(w http.ResponseWriter, r *http.Request, p Policy, err error) { + if errors.Is(err, ErrNotFound) { + apiresponse.Error(w, 404, "内容策略不存在") + return + } + if err != nil { + apiresponse.Error(w, 400, err.Error()) + return + } + _ = h.engine.Reload(r.Context()) + apiresponse.OK(w, p) +} +func decodePolicy(w http.ResponseWriter, r *http.Request) (Policy, bool) { + var p Policy + d := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) + d.DisallowUnknownFields() + if d.Decode(&p) != nil { + apiresponse.Error(w, 400, "请求格式无效") + return p, false + } + Normalize(&p) + if err := ValidateInput(p); err != nil { + apiresponse.Error(w, 400, err.Error()) + return p, false + } + return p, true +} +func (h *AdminHTTPHandler) require(w http.ResponseWriter, r *http.Request, permission string) (identity.Account, bool) { + a, err := h.identity.Authenticate(r.Context(), identity.KindAdmin, r.Header.Get("Authorization")) + if err != nil { + apiresponse.Error(w, 401, "登录状态无效") + return a, false + } + if !identity.HasPermission(a, permission) { + apiresponse.Error(w, 403, "缺少内容策略权限") + return a, false + } + return a, true +} diff --git a/internal/contentpolicy/engine.go b/internal/contentpolicy/engine.go new file mode 100644 index 0000000..46d5a61 --- /dev/null +++ b/internal/contentpolicy/engine.go @@ -0,0 +1,272 @@ +package contentpolicy + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "regexp" + "slices" + "strings" + "sync/atomic" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +var ErrBodyTooLarge = errors.New("request body is too large") + +type Rule struct { + Name string `json:"name"` + Pattern string `json:"pattern"` + Replacement string `json:"replacement"` +} + +type Policy struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Action string `json:"action"` + Priority int `json:"priority"` + Paths []string `json:"paths"` + Models []string `json:"models"` + APIKeyIDs []string `json:"api_key_ids"` + Rules []Rule `json:"rules"` + Enabled bool `json:"enabled"` + Revision int64 `json:"revision"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type compiledRule struct { + name, replacement string + expression *regexp.Regexp +} +type compiledPolicy struct { + Policy + rules []compiledRule +} +type snapshot struct{ policies []compiledPolicy } + +type Engine struct { + pool *pgxpool.Pool + logger *slog.Logger + refresh time.Duration + current atomic.Pointer[snapshot] +} + +type Match struct { + PolicyID string `json:"policy_id"` + PolicyName string `json:"policy_name"` + Action string `json:"action"` + Rules []string `json:"rules"` +} + +type Result struct { + Matches []Match + Blocked, Redacted bool +} + +func NewEngine(pool *pgxpool.Pool, refresh time.Duration, logger *slog.Logger) *Engine { + if refresh <= 0 { + refresh = 30 * time.Second + } + e := &Engine{pool: pool, refresh: refresh, logger: logger} + e.current.Store(&snapshot{}) + return e +} + +func (e *Engine) Run(ctx context.Context) { + _ = e.Reload(ctx) + ticker := time.NewTicker(e.refresh) + defer ticker.Stop() + for { + select { + case <-ticker.C: + if err := e.Reload(ctx); err != nil && e.logger != nil { + e.logger.Warn("content policy refresh failed", "error", err) + } + case <-ctx.Done(): + return + } + } +} + +func (e *Engine) Reload(ctx context.Context) error { + if e == nil || e.pool == nil { + return errors.New("content policy store unavailable") + } + rows, err := e.pool.Query(ctx, `SELECT id::text,name,description,action,priority,paths,models,api_key_ids::text[],rules,enabled,revision,created_at,updated_at FROM gateway.content_policies WHERE enabled ORDER BY priority DESC,name,id`) + if err != nil { + return fmt.Errorf("load content policies: %w", err) + } + defer rows.Close() + loaded := make([]compiledPolicy, 0) + for rows.Next() { + var policy Policy + var raw []byte + if err := rows.Scan(&policy.ID, &policy.Name, &policy.Description, &policy.Action, &policy.Priority, &policy.Paths, &policy.Models, &policy.APIKeyIDs, &raw, &policy.Enabled, &policy.Revision, &policy.CreatedAt, &policy.UpdatedAt); err != nil { + return err + } + if err := json.Unmarshal(raw, &policy.Rules); err != nil { + return fmt.Errorf("decode policy %s: %w", policy.ID, err) + } + compiled, err := compilePolicy(policy) + if err != nil { + return fmt.Errorf("compile policy %s: %w", policy.ID, err) + } + loaded = append(loaded, compiled) + } + if err := rows.Err(); err != nil { + return err + } + e.current.Store(&snapshot{policies: loaded}) + return nil +} + +func compilePolicy(policy Policy) (compiledPolicy, error) { + if policy.Action != "audit" && policy.Action != "block" && policy.Action != "redact" { + return compiledPolicy{}, errors.New("unsupported action") + } + if len(policy.Rules) == 0 || len(policy.Rules) > 20 { + return compiledPolicy{}, errors.New("rules must contain 1 to 20 entries") + } + result := compiledPolicy{Policy: policy, rules: make([]compiledRule, 0, len(policy.Rules))} + for _, rule := range policy.Rules { + if strings.TrimSpace(rule.Name) == "" || len(rule.Pattern) == 0 || len(rule.Pattern) > 512 { + return compiledPolicy{}, errors.New("invalid rule name or pattern length") + } + expression, err := regexp.Compile(rule.Pattern) + if err != nil { + return compiledPolicy{}, err + } + replacement := rule.Replacement + if replacement == "" { + replacement = "[REDACTED]" + } + result.rules = append(result.rules, compiledRule{name: rule.Name, replacement: replacement, expression: expression}) + } + return result, nil +} + +func Validate(policy Policy) error { _, err := compilePolicy(policy); return err } + +func (e *Engine) Apply(request *http.Request, maxBody int64, apiKeyID string) (Result, error) { + if e == nil || request.Body == nil || request.Body == http.NoBody || request.Method == http.MethodGet { + return Result{}, nil + } + body, err := io.ReadAll(io.LimitReader(request.Body, maxBody+1)) + if err != nil { + return Result{}, err + } + _ = request.Body.Close() + if int64(len(body)) > maxBody { + return Result{}, ErrBodyTooLarge + } + restoreBody(request, body) + var document any + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.UseNumber() + if decoder.Decode(&document) != nil { + return Result{}, nil + } + model := findModel(document) + result := Result{} + for _, policy := range e.current.Load().policies { + if !policyApplies(policy, request.URL.Path, model, apiKeyID) { + continue + } + matched := make([]string, 0) + changed := false + walkText(&document, false, func(value string) string { + for _, rule := range policy.rules { + if rule.expression.MatchString(value) { + if !slices.Contains(matched, rule.name) { + matched = append(matched, rule.name) + } + if policy.Action == "redact" { + next := rule.expression.ReplaceAllString(value, rule.replacement) + changed = changed || next != value + value = next + } + } + } + return value + }) + if len(matched) == 0 { + continue + } + result.Matches = append(result.Matches, Match{PolicyID: policy.ID, PolicyName: policy.Name, Action: policy.Action, Rules: matched}) + if policy.Action == "block" { + result.Blocked = true + break + } + result.Redacted = result.Redacted || changed + } + if result.Redacted && !result.Blocked { + encoded, err := json.Marshal(document) + if err != nil { + return Result{}, err + } + restoreBody(request, encoded) + } + return result, nil +} + +func policyApplies(policy compiledPolicy, path, model, apiKeyID string) bool { + return matches(policy.Paths, path) && matches(policy.Models, model) && matches(policy.APIKeyIDs, apiKeyID) +} + +func matches(values []string, candidate string) bool { + if len(values) == 0 { + return true + } + for _, value := range values { + if value == "*" || value == candidate { + return true + } + } + return false +} + +var textualFields = map[string]bool{"content": true, "text": true, "input": true, "prompt": true, "instructions": true} + +func walkText(value *any, selected bool, transform func(string) string) { + switch current := (*value).(type) { + case map[string]any: + for key, child := range current { + local := child + walkText(&local, selected || textualFields[strings.ToLower(key)], transform) + current[key] = local + } + case []any: + for index, child := range current { + local := child + walkText(&local, selected, transform) + current[index] = local + } + case string: + if selected { + *value = transform(current) + } + } +} + +func findModel(document any) string { + if object, ok := document.(map[string]any); ok { + if model, ok := object["model"].(string); ok { + return model + } + } + return "" +} + +func restoreBody(request *http.Request, body []byte) { + request.Body = io.NopCloser(bytes.NewReader(body)) + request.ContentLength = int64(len(body)) + request.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(body)), nil } +} diff --git a/internal/contentpolicy/engine_test.go b/internal/contentpolicy/engine_test.go new file mode 100644 index 0000000..a04efcb --- /dev/null +++ b/internal/contentpolicy/engine_test.go @@ -0,0 +1,44 @@ +package contentpolicy + +import ( + "io" + "net/http" + "strings" + "testing" +) + +func testEngine(t *testing.T, action string) *Engine { + t.Helper() + policy := Policy{ID: "p1", Name: "secret", Action: action, Rules: []Rule{{Name: "token", Pattern: `sk-[A-Za-z0-9]{8,}`, Replacement: "[MASKED]"}}, Enabled: true} + compiled, err := compilePolicy(policy) + if err != nil { + t.Fatal(err) + } + engine := &Engine{} + engine.current.Store(&snapshot{policies: []compiledPolicy{compiled}}) + return engine +} + +func TestApplyRedactsOnlyTextualPromptFields(t *testing.T) { + request, _ := http.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"gpt-5","messages":[{"content":"use sk-12345678"}],"tools":[{"description":"keep sk-abcdefgh"}]}`)) + result, err := testEngine(t, "redact").Apply(request, 1<<20, "key") + if err != nil || !result.Redacted || result.Blocked { + t.Fatalf("unexpected result %#v: %v", result, err) + } + body, _ := io.ReadAll(request.Body) + text := string(body) + if strings.Contains(text, "sk-12345678") || !strings.Contains(text, "keep sk-abcdefgh") { + t.Fatalf("unexpected redaction: %s", text) + } +} + +func TestApplyBlocksWithoutReturningMatchedSecret(t *testing.T) { + request, _ := http.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"input":"sk-12345678"}`)) + result, err := testEngine(t, "block").Apply(request, 1<<20, "") + if err != nil || !result.Blocked || len(result.Matches) != 1 { + t.Fatalf("unexpected result %#v: %v", result, err) + } + if strings.Contains(result.Matches[0].Rules[0], "12345678") { + t.Fatal("match metadata leaked secret") + } +} diff --git a/internal/contentpolicy/store.go b/internal/contentpolicy/store.go new file mode 100644 index 0000000..702906b --- /dev/null +++ b/internal/contentpolicy/store.go @@ -0,0 +1,156 @@ +package contentpolicy + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + platformid "aigateway.local/core/internal/platform/id" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" +) + +var ErrNotFound = errors.New("content policy not found") + +type Store struct{ pool *pgxpool.Pool } + +func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} } + +func (s *Store) List(ctx context.Context) ([]Policy, error) { + rows, err := s.pool.Query(ctx, `SELECT id::text,name,description,action,priority,paths,models,api_key_ids::text[],rules,enabled,revision,created_at,updated_at FROM gateway.content_policies ORDER BY priority DESC,name`) + if err != nil { + return nil, err + } + defer rows.Close() + result := []Policy{} + for rows.Next() { + var p Policy + var raw []byte + if err := rows.Scan(&p.ID, &p.Name, &p.Description, &p.Action, &p.Priority, &p.Paths, &p.Models, &p.APIKeyIDs, &raw, &p.Enabled, &p.Revision, &p.CreatedAt, &p.UpdatedAt); err != nil { + return nil, err + } + if err := json.Unmarshal(raw, &p.Rules); err != nil { + return nil, err + } + result = append(result, p) + } + return result, rows.Err() +} + +func (s *Store) Save(ctx context.Context, p Policy, actorID string, create bool) (Policy, error) { + if err := Validate(p); err != nil { + return Policy{}, err + } + rules, _ := json.Marshal(p.Rules) + eventID, _ := platformid.NewUUID() + tx, err := s.pool.Begin(ctx) + if err != nil { + return Policy{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + if create { + p.ID, _ = platformid.NewUUID() + err = tx.QueryRow(ctx, `INSERT INTO gateway.content_policies(id,name,description,action,priority,paths,models,api_key_ids,rules,enabled,created_by) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING revision,created_at,updated_at`, p.ID, p.Name, p.Description, p.Action, p.Priority, p.Paths, p.Models, p.APIKeyIDs, rules, p.Enabled, actorID).Scan(&p.Revision, &p.CreatedAt, &p.UpdatedAt) + } else { + err = tx.QueryRow(ctx, `UPDATE gateway.content_policies SET name=$2,description=$3,action=$4,priority=$5,paths=$6,models=$7,api_key_ids=$8,rules=$9,enabled=$10,revision=revision+1,updated_at=clock_timestamp() WHERE id=$1 RETURNING revision,created_at,updated_at`, p.ID, p.Name, p.Description, p.Action, p.Priority, p.Paths, p.Models, p.APIKeyIDs, rules, p.Enabled).Scan(&p.Revision, &p.CreatedAt, &p.UpdatedAt) + } + if errors.Is(err, pgx.ErrNoRows) { + return Policy{}, ErrNotFound + } + if err != nil { + return Policy{}, err + } + eventType := "content_policy.updated" + if create { + eventType = "content_policy.created" + } + payload, _ := json.Marshal(map[string]any{"content_policy_id": p.ID, "actor_id": actorID, "revision": p.Revision}) + _, err = tx.Exec(ctx, `INSERT INTO gateway.outbox_events(event_id,event_type,event_version,aggregate_type,aggregate_id,payload) VALUES($1,$2,1,'content_policy',$3,$4)`, eventID, eventType, p.ID, payload) + if err != nil { + return Policy{}, err + } + return p, tx.Commit(ctx) +} + +func (s *Store) Delete(ctx context.Context, id, actorID string) error { + eventID, _ := platformid.NewUUID() + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer func() { _ = tx.Rollback(ctx) }() + tag, err := tx.Exec(ctx, `DELETE FROM gateway.content_policies WHERE id=$1`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + payload, _ := json.Marshal(map[string]string{"content_policy_id": id, "actor_id": actorID}) + if _, err = tx.Exec(ctx, `INSERT INTO gateway.outbox_events(event_id,event_type,event_version,aggregate_type,aggregate_id,payload) VALUES($1,'content_policy.deleted',1,'content_policy',$2,$3)`, eventID, id, payload); err != nil { + return err + } + return tx.Commit(ctx) +} + +func Normalize(p *Policy) { + p.Name = strings.TrimSpace(p.Name) + p.Description = strings.TrimSpace(p.Description) + p.Action = strings.ToLower(strings.TrimSpace(p.Action)) + p.Paths = normalizeStrings(p.Paths) + p.Models = normalizeStrings(p.Models) + p.APIKeyIDs = normalizeStrings(p.APIKeyIDs) +} +func ValidateInput(p Policy) error { + if p.Name == "" || len(p.Name) > 128 { + return fmt.Errorf("name is required and must not exceed 128 characters") + } + if len(p.Description) > 1000 { + return fmt.Errorf("description is too long") + } + if p.Priority < -100000 || p.Priority > 100000 { + return fmt.Errorf("priority 必须在 -100000 到 100000 之间") + } + if len(p.Paths) > 20 || len(p.Models) > 100 || len(p.APIKeyIDs) > 100 { + return fmt.Errorf("策略范围条目过多") + } + allowedPaths := map[string]bool{"*": true, "/v1/chat/completions": true, "/v1/responses": true, "/v1/embeddings": true, "/v1/messages": true} + for _, value := range p.Paths { + if !allowedPaths[value] { + return fmt.Errorf("不支持的端点范围 %s", value) + } + } + for _, value := range p.Models { + if len(value) > 512 { + return fmt.Errorf("模型名过长") + } + } + for _, value := range p.APIKeyIDs { + var id pgtype.UUID + if id.Scan(value) != nil || !id.Valid { + return fmt.Errorf("API Key ID 格式无效") + } + } + for _, rule := range p.Rules { + if len(rule.Name) > 128 || len(rule.Replacement) > 1024 { + return fmt.Errorf("规则名称或替换文本过长") + } + } + return Validate(p) +} + +func normalizeStrings(values []string) []string { + result := make([]string, 0, len(values)) + seen := map[string]bool{} + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" && !seen[value] { + seen[value] = true + result = append(result, value) + } + } + return result +} diff --git a/internal/factcheck/admin_http.go b/internal/factcheck/admin_http.go new file mode 100644 index 0000000..3c3bc61 --- /dev/null +++ b/internal/factcheck/admin_http.go @@ -0,0 +1,154 @@ +package factcheck + +import ( + "aigateway.local/core/internal/identity" + "aigateway.local/core/internal/platform/apiresponse" + "encoding/json" + "errors" + "net/http" + "strconv" + "strings" +) + +type AdminHTTPHandler struct { + service *Service + identity *identity.Service + mux *http.ServeMux +} + +func NewAdminHTTPHandler(service *Service, identityService *identity.Service) *AdminHTTPHandler { + h := &AdminHTTPHandler{service: service, identity: identityService, mux: http.NewServeMux()} + h.mux.HandleFunc("GET /api/v1/admin/fact-check/settings", h.getSettings) + h.mux.HandleFunc("PUT /api/v1/admin/fact-check/settings", h.saveSettings) + h.mux.HandleFunc("GET /api/v1/admin/fact-check/policies", h.policies) + h.mux.HandleFunc("POST /api/v1/admin/fact-check/policies", h.savePolicy) + h.mux.HandleFunc("PUT /api/v1/admin/fact-check/policies/{id}", h.savePolicy) + h.mux.HandleFunc("DELETE /api/v1/admin/fact-check/policies/{id}", h.deletePolicy) + h.mux.HandleFunc("GET /api/v1/admin/fact-check/events", h.events) + h.mux.HandleFunc("GET /api/v1/admin/fact-check/events/{id}", h.event) + return h +} +func (h *AdminHTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.mux.ServeHTTP(w, r) } +func (h *AdminHTTPHandler) account(w http.ResponseWriter, r *http.Request, manage bool) (identity.Account, bool) { + a, err := h.identity.Authenticate(r.Context(), identity.KindAdmin, r.Header.Get("Authorization")) + if err != nil { + apiresponse.Error(w, 401, "登录状态无效") + return a, false + } + permission := identity.PermissionKnowledgeRead + if manage { + permission = identity.PermissionKnowledgeManage + } + if !identity.HasPermission(a, permission) { + apiresponse.Error(w, 403, "缺少事实核验管理权限") + return a, false + } + return a, true +} +func decodeFact(w http.ResponseWriter, r *http.Request, target any) bool { + d := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) + d.DisallowUnknownFields() + if err := d.Decode(target); err != nil { + apiresponse.Error(w, 400, "请求格式无效") + return false + } + return true +} +func factError(w http.ResponseWriter, err error) { + if errors.Is(err, ErrNotFound) { + apiresponse.Error(w, 404, "资源不存在") + return + } + apiresponse.Error(w, 400, err.Error()) +} +func (h *AdminHTTPHandler) getSettings(w http.ResponseWriter, r *http.Request) { + if _, ok := h.account(w, r, false); !ok { + return + } + x, err := h.service.Settings(r.Context()) + if err != nil { + factError(w, err) + return + } + apiresponse.OK(w, x) +} +func (h *AdminHTTPHandler) saveSettings(w http.ResponseWriter, r *http.Request) { + a, ok := h.account(w, r, true) + if !ok { + return + } + var x Settings + if !decodeFact(w, r, &x) { + return + } + x, err := h.service.SaveSettings(r.Context(), x, a.ID) + if err != nil { + factError(w, err) + return + } + apiresponse.OK(w, x) +} +func (h *AdminHTTPHandler) policies(w http.ResponseWriter, r *http.Request) { + if _, ok := h.account(w, r, false); !ok { + return + } + x, err := h.service.Policies(r.Context()) + if err != nil { + factError(w, err) + return + } + apiresponse.OK(w, map[string]any{"items": x}) +} +func (h *AdminHTTPHandler) savePolicy(w http.ResponseWriter, r *http.Request) { + a, ok := h.account(w, r, true) + if !ok { + return + } + var x Policy + if !decodeFact(w, r, &x) { + return + } + if id := r.PathValue("id"); id != "" { + x.ID = id + } + x, err := h.service.SavePolicy(r.Context(), x, a.ID) + if err != nil { + factError(w, err) + return + } + apiresponse.OK(w, x) +} +func (h *AdminHTTPHandler) deletePolicy(w http.ResponseWriter, r *http.Request) { + if _, ok := h.account(w, r, true); !ok { + return + } + if err := h.service.DeletePolicy(r.Context(), r.PathValue("id")); err != nil { + factError(w, err) + return + } + apiresponse.OK(w, map[string]bool{"deleted": true}) +} +func (h *AdminHTTPHandler) events(w http.ResponseWriter, r *http.Request) { + if _, ok := h.account(w, r, false); !ok { + return + } + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + verdict := strings.TrimSpace(r.URL.Query().Get("verdict")) + x, err := h.service.Events(r.Context(), verdict, limit) + if err != nil { + factError(w, err) + return + } + apiresponse.OK(w, map[string]any{"items": x}) +} +func (h *AdminHTTPHandler) event(w http.ResponseWriter, r *http.Request) { + if _, ok := h.account(w, r, false); !ok { + return + } + x, err := h.service.Event(r.Context(), r.PathValue("id")) + if err != nil { + factError(w, err) + return + } + apiresponse.OK(w, x) +} diff --git a/internal/factcheck/engine.go b/internal/factcheck/engine.go new file mode 100644 index 0000000..d7666f2 --- /dev/null +++ b/internal/factcheck/engine.go @@ -0,0 +1,298 @@ +package factcheck + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "strings" + "time" + + platformid "aigateway.local/core/internal/platform/id" + "github.com/jackc/pgx/v5/pgxpool" +) + +// EvidenceHit is a single retrieved knowledge-base excerpt supplied to the +// verifier. The title/content shape mirrors the workbench retriever output. +type EvidenceHit struct { + DocumentTitle string `json:"document_title"` + Content string `json:"content"` +} + +// EvidenceRetriever fetches supporting excerpts from a knowledge base. The +// workbench PostgreSQLRetriever satisfies it (through a small adapter), so +// fact-check reuses the exact retrieval path application prompts already use. +type EvidenceRetriever interface { + Search(context.Context, string, string, int) ([]EvidenceHit, error) +} + +// Verifier performs the model-backed fact-check call and returns the raw model +// output text. The engine is responsible for parsing it into claims and a +// verdict. The workbench runtime implements it by routing a non-streaming chat +// completion through the same governed gateway, reusing the caller's own +// credential headers. +type Verifier interface { + Verify(context.Context, string, string, string, time.Duration) (string, error) +} + +// VerifierFunc adapts a function to the Verifier interface. +type VerifierFunc func(context.Context, string, string, string, time.Duration) (string, error) + +func (f VerifierFunc) Verify(ctx context.Context, model, system, user string, timeout time.Duration) (string, error) { + return f(ctx, model, system, user, timeout) +} + +// Engine executes fact-check policies against assistant answers and records the +// outcome in fact_check_events. It is deliberately side-effect safe: Check +// never returns an error a caller must propagate — callers treat any failure as +// "fact-check skipped" and keep serving the chat. +type Engine struct { + pool *pgxpool.Pool + retriever EvidenceRetriever + logger *slog.Logger +} + +func NewEngine(pool *pgxpool.Pool, retriever EvidenceRetriever, logger *slog.Logger) *Engine { + return &Engine{pool: pool, retriever: retriever, logger: logger} +} + +// Check verifies one assistant answer against the configured knowledge bases +// and persists a fact_check_events row. The returned Event has a zero ID when +// fact-checking is not configured or no policy applies; callers should skip +// quietly in that case. +func (e *Engine) Check(ctx context.Context, requestID, question, answer string, verifier Verifier) (Event, error) { + if e == nil || e.retriever == nil || verifier == nil || strings.TrimSpace(answer) == "" { + return Event{}, nil + } + settings, err := e.checkSettings(ctx) + if err != nil { + return Event{}, fmt.Errorf("read fact-check settings: %w", err) + } + if strings.TrimSpace(settings.Model) == "" { + return Event{}, nil // not configured; skip without noise + } + policy, err := e.enabledPolicy(ctx) + if err != nil { + return Event{}, err + } + if policy.ID == "" { + return Event{}, nil + } + started := time.Now() + evidence := e.gatherEvidence(ctx, policy, question) + if len(evidence) == 0 { + // Nothing to check against; record an uncertain event so the admin can + // see retrieval produced no evidence rather than silently passing. + return e.record(ctx, Event{ + PolicyID: &policy.ID, RequestID: requestID, Model: settings.Model, + Mode: policy.Mode, Action: policy.Action, Verdict: "uncertain", + LatencyMS: int(time.Since(started).Milliseconds()), Question: question, Answer: answer, + Claims: json.RawMessage("[]"), Evidence: evidenceJSON(evidence), + }) + } + system, user := buildVerifyPrompt(question, answer, evidence, policy.MaxClaims) + raw, err := verifier.Verify(ctx, settings.Model, system, user, time.Duration(settings.TimeoutSeconds)*time.Second) + if err != nil { + return e.record(ctx, Event{ + PolicyID: &policy.ID, RequestID: requestID, Model: settings.Model, + Mode: policy.Mode, Action: policy.Action, Verdict: "error", + LatencyMS: int(time.Since(started).Milliseconds()), Question: question, Answer: answer, + Claims: json.RawMessage("[]"), Evidence: evidenceJSON(evidence), Error: err.Error(), + }) + } + verdict, score, claims := parseVerdict(raw, policy.SupportThreshold) + return e.record(ctx, Event{ + PolicyID: &policy.ID, RequestID: requestID, Model: settings.Model, + Mode: policy.Mode, Action: policy.Action, Verdict: verdict, SupportScore: score, + LatencyMS: int(time.Since(started).Milliseconds()), Question: question, Answer: answer, + Claims: claimsJSON(claims), Evidence: evidenceJSON(evidence), + }) +} + +func (e *Engine) checkSettings(ctx context.Context) (Settings, error) { + var x Settings + err := e.pool.QueryRow(ctx, `SELECT f.provider_id::text,coalesce(p.code,''),f.model,f.timeout_seconds,f.updated_at FROM gateway.fact_check_settings f LEFT JOIN gateway.providers p ON p.id=f.provider_id WHERE singleton`).Scan(&x.ProviderID, &x.ProviderCode, &x.Model, &x.TimeoutSeconds, &x.UpdatedAt) + return x, err +} + +func (e *Engine) enabledPolicy(ctx context.Context) (Policy, error) { + policy, err := scanPolicy(e.pool.QueryRow(ctx, policySelect+` WHERE enabled ORDER BY scope LIMIT 1`)) + if errors.Is(err, ErrNotFound) { + return Policy{}, nil + } + return policy, err +} + +// gatherEvidence retrieves up to a bounded number of excerpts across the +// policy's knowledge bases. Retrieval failures never abort the check — a +// broken knowledge base just contributes no evidence. +func (e *Engine) gatherEvidence(ctx context.Context, policy Policy, question string) []EvidenceHit { + maxHits := policy.MaxClaims * 2 + if maxHits < 4 { + maxHits = 4 + } + if maxHits > 20 { + maxHits = 20 + } + evidence := []EvidenceHit{} + for _, kbID := range policy.KnowledgeBaseIDs { + hits, err := e.retriever.Search(ctx, kbID, question, policy.TopK) + if err != nil { + if e.logger != nil { + e.logger.Warn("fact-check evidence retrieval failed", "knowledge_base_id", kbID, "error", err) + } + continue + } + for _, hit := range hits { + if len(evidence) >= maxHits { + break + } + evidence = append(evidence, EvidenceHit{DocumentTitle: hit.DocumentTitle, Content: hit.Content}) + } + if len(evidence) >= maxHits { + break + } + } + return evidence +} + +func (e *Engine) record(ctx context.Context, event Event) (Event, error) { + if event.ID == "" { + id, err := platformid.NewUUID() + if err != nil { + return event, err + } + event.ID = id + } + _, err := e.pool.Exec(ctx, `INSERT INTO gateway.fact_check_events(id,policy_id,request_id,model,mode,action,verdict,support_score,latency_ms,question,answer,claims,evidence,error) VALUES($1,nullif($2,'')::uuid,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::jsonb,$13::jsonb,$14)`, + event.ID, ptrString(event.PolicyID), event.RequestID, event.Model, event.Mode, event.Action, event.Verdict, event.SupportScore, event.LatencyMS, event.Question, event.Answer, event.Claims, event.Evidence, event.Error) + return event, err +} + +func ptrString(value *string) string { + if value == nil { + return "" + } + return *value +} + +type verifyClaim struct { + Claim string `json:"claim"` + Verdict string `json:"verdict"` + EvidenceIndex []int `json:"evidence_index"` +} + +type verifyResult struct { + Verdict string `json:"verdict"` + SupportScore float64 `json:"support_score"` + Claims []verifyClaim `json:"claims"` +} + +func buildVerifyPrompt(question, answer string, evidence []EvidenceHit, maxClaims int) (string, string) { + if maxClaims < 1 { + maxClaims = 8 + } + var buffer bytes.Buffer + buffer.WriteString("问题:\n") + buffer.WriteString(question) + buffer.WriteString("\n\n回答:\n") + buffer.WriteString(answer) + buffer.WriteString("\n\n参考资料:\n") + for i, hit := range evidence { + fmt.Fprintf(&buffer, "[资料%d] %s\n%s\n\n", i, hit.DocumentTitle, hit.Content) + } + system := `你是企业知识库事实核查引擎。你需要逐条判断"回答"中的关键陈述是否能被"参考资料"支持。 +输出必须是严格 JSON,不要输出任何其他内容,格式: +{"verdict":"supported|unsupported|uncertain","support_score":0-100,"claims":[{"claim":"...","verdict":"supported|unsupported|uncertain","evidence_index":[0,1]}]} +- verdict:所有关键陈述均被参考资料支持→supported;存在明确被资料否定或资料完全无法支撑的关键陈述→unsupported;资料不足无法判断→uncertain。 +- support_score:被支持的陈述占比(0-100)。 +- claims:从回答中提取的关键陈述,最多 ` + fmt.Sprint(maxClaims) + ` 条。evidence_index 列出支撑该陈述的资料编号(从0开始);无支撑填[]。 +- 只依据参考资料判断,不要使用你自己的世界知识。 +- 回答为空或不包含可核查陈述时,verdict 输出 uncertain,claims 输出 []。` + return system, buffer.String() +} + +func parseVerdict(raw string, threshold int) (string, *int, []verifyClaim) { + var result verifyResult + if extracted := extractJSON(raw); json.Unmarshal([]byte(extracted), &result) != nil { + return "error", nil, []verifyClaim{} + } + claims := result.Claims + if claims == nil { + claims = []verifyClaim{} + } + verdict := strings.ToLower(strings.TrimSpace(result.Verdict)) + var score *int + if result.SupportScore > 0 { + value := int(result.SupportScore) + if value > 100 { + value = 100 + } + score = &value + } + total := len(claims) + supported := 0 + for _, c := range claims { + switch strings.ToLower(strings.TrimSpace(c.Verdict)) { + case "supported": + supported++ + case "unsupported", "uncertain": + default: + c.Verdict = "uncertain" + } + } + if total > 0 { + percent := supported * 100 / total + if score == nil { + score = &percent + } + if verdict != "supported" && verdict != "unsupported" && verdict != "uncertain" { + switch { + case percent >= threshold: + verdict = "supported" + case supported > 0: + verdict = "uncertain" + default: + verdict = "unsupported" + } + } + } else if verdict != "supported" && verdict != "unsupported" && verdict != "uncertain" { + verdict = "uncertain" + } + return verdict, score, claims +} + +// extractJSON returns the text between the first '{' and the last '}', stripping +// markdown code fences models sometimes wrap around their JSON output. +func extractJSON(raw string) string { + start := strings.Index(raw, "{") + end := strings.LastIndex(raw, "}") + if start < 0 || end < start { + return "" + } + return raw[start : end+1] +} + +func claimsJSON(claims []verifyClaim) json.RawMessage { + if claims == nil { + return json.RawMessage("[]") + } + encoded, err := json.Marshal(claims) + if err != nil { + return json.RawMessage("[]") + } + return encoded +} + +func evidenceJSON(evidence []EvidenceHit) json.RawMessage { + if evidence == nil { + return json.RawMessage("[]") + } + encoded, err := json.Marshal(evidence) + if err != nil { + return json.RawMessage("[]") + } + return encoded +} diff --git a/internal/factcheck/engine_test.go b/internal/factcheck/engine_test.go new file mode 100644 index 0000000..1210884 --- /dev/null +++ b/internal/factcheck/engine_test.go @@ -0,0 +1,56 @@ +package factcheck + +import "testing" + +func TestParseVerdictSupported(t *testing.T) { + raw := "```json\n{\"verdict\":\"supported\",\"support_score\":88,\"claims\":[{\"claim\":\"a\",\"verdict\":\"supported\",\"evidence_index\":[0]}]}\n```" + verdict, score, claims := parseVerdict(raw, 70) + if verdict != "supported" { + t.Fatalf("expected supported, got %q", verdict) + } + if score == nil || *score != 88 { + t.Fatalf("expected score 88, got %v", score) + } + if len(claims) != 1 { + t.Fatalf("expected 1 claim, got %d", len(claims)) + } +} + +func TestParseVerdictDerivesFromClaims(t *testing.T) { + // Model returns no top-level verdict; the engine must derive it from claims. + raw := `{"claims":[{"claim":"x","verdict":"unsupported","evidence_index":[]}]}` + verdict, _, _ := parseVerdict(raw, 70) + if verdict != "unsupported" { + t.Fatalf("expected unsupported, got %q", verdict) + } +} + +func TestParseVerdictEmptyClaimsUncertain(t *testing.T) { + raw := `{"verdict":"","support_score":0,"claims":[]}` + verdict, _, _ := parseVerdict(raw, 70) + if verdict != "uncertain" { + t.Fatalf("expected uncertain, got %q", verdict) + } +} + +func TestParseVerdictGarbageIsError(t *testing.T) { + verdict, _, _ := parseVerdict("this is not json at all", 70) + if verdict != "error" { + t.Fatalf("expected error, got %q", verdict) + } +} + +func TestParseVerdictClampsScore(t *testing.T) { + raw := `{"verdict":"supported","support_score":150,"claims":[{"claim":"a","verdict":"supported","evidence_index":[0]}]}` + _, score, _ := parseVerdict(raw, 70) + if score == nil || *score > 100 { + t.Fatalf("score should be clamped to 100, got %v", score) + } +} + +func TestExtractJSONStripsFences(t *testing.T) { + raw := "以下是结果:\n```json\n{\"a\":1}\n```\n完" + if got := extractJSON(raw); got != `{"a":1}` { + t.Fatalf("expected {\"a\":1}, got %q", got) + } +} diff --git a/internal/factcheck/service.go b/internal/factcheck/service.go new file mode 100644 index 0000000..eb48293 --- /dev/null +++ b/internal/factcheck/service.go @@ -0,0 +1,235 @@ +package factcheck + +import ( + "context" + "encoding/json" + "errors" + "strconv" + "strings" + "time" + + platformid "aigateway.local/core/internal/platform/id" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +var ErrNotFound = errors.New("fact-check resource not found") + +type Service struct{ pool *pgxpool.Pool } + +func NewService(pool *pgxpool.Pool) *Service { return &Service{pool: pool} } + +type Settings struct { + ProviderID *string `json:"provider_id"` + ProviderCode string `json:"provider_code"` + Model string `json:"model"` + TimeoutSeconds int `json:"timeout_seconds"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (s *Service) Settings(ctx context.Context) (Settings, error) { + var x Settings + err := s.pool.QueryRow(ctx, `SELECT f.provider_id::text,coalesce(p.code,''),f.model,f.timeout_seconds,f.updated_at FROM gateway.fact_check_settings f LEFT JOIN gateway.providers p ON p.id=f.provider_id WHERE singleton`).Scan(&x.ProviderID, &x.ProviderCode, &x.Model, &x.TimeoutSeconds, &x.UpdatedAt) + return x, err +} +func (s *Service) SaveSettings(ctx context.Context, x Settings, actor string) (Settings, error) { + x.Model = strings.TrimSpace(x.Model) + if x.TimeoutSeconds < 3 || x.TimeoutSeconds > 60 { + return Settings{}, errors.New("超时必须在 3-60 秒之间") + } + if x.ProviderID != nil && strings.TrimSpace(*x.ProviderID) == "" { + x.ProviderID = nil + } + if x.ProviderID != nil && x.Model == "" { + return Settings{}, errors.New("配置供应商时模型不能为空") + } + _, err := s.pool.Exec(ctx, `UPDATE gateway.fact_check_settings SET provider_id=$1,model=$2,timeout_seconds=$3,updated_by=$4,updated_at=clock_timestamp() WHERE singleton`, x.ProviderID, x.Model, x.TimeoutSeconds, actor) + if err != nil { + return Settings{}, err + } + return s.Settings(ctx) +} + +type Policy struct { + ID string `json:"id"` + Scope string `json:"scope"` + Enabled bool `json:"enabled"` + Mode string `json:"mode"` + Action string `json:"action"` + KnowledgeBaseIDs []string `json:"knowledge_base_ids"` + SupportThreshold int `json:"support_threshold"` + EvidenceThreshold float64 `json:"evidence_threshold"` + TopK int `json:"top_k"` + MaxClaims int `json:"max_claims"` + Revision int64 `json:"revision"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func validatePolicy(x *Policy) error { + x.Scope = strings.ToLower(strings.TrimSpace(x.Scope)) + if x.Scope == "" { + x.Scope = "global" + } + if x.Mode == "" { + x.Mode = "async" + } + if x.Action == "" { + x.Action = "observe" + } + if x.Mode != "async" && x.Mode != "sync" { + return errors.New("模式无效") + } + if x.Action != "observe" && x.Action != "annotate" && x.Action != "block" { + return errors.New("处置动作无效") + } + if x.Mode == "async" && x.Action != "observe" { + return errors.New("异步模式只能观察") + } + if x.Enabled && len(x.KnowledgeBaseIDs) == 0 { + return errors.New("启用策略前至少选择一个知识库") + } + if x.TopK == 0 { + x.TopK = 4 + } + if x.MaxClaims == 0 { + x.MaxClaims = 8 + } + if x.SupportThreshold == 0 { + x.SupportThreshold = 70 + } + if x.EvidenceThreshold == 0 { + x.EvidenceThreshold = .35 + } + if x.TopK < 1 || x.TopK > 10 || x.MaxClaims < 1 || x.MaxClaims > 20 || x.SupportThreshold < 0 || x.SupportThreshold > 100 || x.EvidenceThreshold < 0 || x.EvidenceThreshold > 1 { + return errors.New("事实核验阈值无效") + } + return nil +} + +const policySelect = `SELECT id::text,scope,enabled,mode,action,knowledge_base_ids::text[],support_threshold,evidence_threshold,top_k,max_claims,revision,created_at,updated_at FROM gateway.fact_check_policies` + +func scanPolicy(row pgx.Row) (Policy, error) { + var x Policy + err := row.Scan(&x.ID, &x.Scope, &x.Enabled, &x.Mode, &x.Action, &x.KnowledgeBaseIDs, &x.SupportThreshold, &x.EvidenceThreshold, &x.TopK, &x.MaxClaims, &x.Revision, &x.CreatedAt, &x.UpdatedAt) + if errors.Is(err, pgx.ErrNoRows) { + err = ErrNotFound + } + return x, err +} +func (s *Service) Policies(ctx context.Context) ([]Policy, error) { + rows, err := s.pool.Query(ctx, policySelect+` ORDER BY scope`) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Policy{} + for rows.Next() { + x, err := scanPolicy(rows) + if err != nil { + return nil, err + } + items = append(items, x) + } + return items, rows.Err() +} +func (s *Service) SavePolicy(ctx context.Context, x Policy, actor string) (Policy, error) { + if err := validatePolicy(&x); err != nil { + return Policy{}, err + } + if x.KnowledgeBaseIDs == nil { + x.KnowledgeBaseIDs = []string{} + } + if x.ID == "" { + x.ID, _ = platformid.NewUUID() + _, err := s.pool.Exec(ctx, `INSERT INTO gateway.fact_check_policies(id,scope,enabled,mode,action,knowledge_base_ids,support_threshold,evidence_threshold,top_k,max_claims,created_by) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, x.ID, x.Scope, x.Enabled, x.Mode, x.Action, x.KnowledgeBaseIDs, x.SupportThreshold, x.EvidenceThreshold, x.TopK, x.MaxClaims, actor) + if err != nil { + return Policy{}, err + } + } else { + tag, err := s.pool.Exec(ctx, `UPDATE gateway.fact_check_policies SET scope=$2,enabled=$3,mode=$4,action=$5,knowledge_base_ids=$6,support_threshold=$7,evidence_threshold=$8,top_k=$9,max_claims=$10,revision=revision+1,updated_at=clock_timestamp() WHERE id=$1`, x.ID, x.Scope, x.Enabled, x.Mode, x.Action, x.KnowledgeBaseIDs, x.SupportThreshold, x.EvidenceThreshold, x.TopK, x.MaxClaims) + if err != nil { + return Policy{}, err + } + if tag.RowsAffected() == 0 { + return Policy{}, ErrNotFound + } + } + return scanPolicy(s.pool.QueryRow(ctx, policySelect+` WHERE id=$1`, x.ID)) +} +func (s *Service) DeletePolicy(ctx context.Context, id string) error { + tag, err := s.pool.Exec(ctx, `DELETE FROM gateway.fact_check_policies WHERE id=$1`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +type Event struct { + ID string `json:"id"` + PolicyID *string `json:"policy_id"` + RequestID string `json:"request_id"` + Model string `json:"model"` + Mode string `json:"mode"` + Action string `json:"action"` + Verdict string `json:"verdict"` + SupportScore *int `json:"support_score"` + LatencyMS int `json:"latency_ms"` + Question string `json:"question,omitempty"` + Answer string `json:"answer,omitempty"` + Claims json.RawMessage `json:"claims,omitempty"` + Evidence json.RawMessage `json:"evidence,omitempty"` + Error string `json:"error"` + CreatedAt time.Time `json:"created_at"` +} + +const eventSelect = `SELECT id::text,policy_id::text,request_id,model,mode,action,verdict,support_score,latency_ms,question,answer,claims,evidence,error,created_at FROM gateway.fact_check_events` + +func scanEvent(row pgx.Row) (Event, error) { + var x Event + err := row.Scan(&x.ID, &x.PolicyID, &x.RequestID, &x.Model, &x.Mode, &x.Action, &x.Verdict, &x.SupportScore, &x.LatencyMS, &x.Question, &x.Answer, &x.Claims, &x.Evidence, &x.Error, &x.CreatedAt) + if errors.Is(err, pgx.ErrNoRows) { + err = ErrNotFound + } + return x, err +} +func (s *Service) Events(ctx context.Context, verdict string, limit int) ([]Event, error) { + if limit < 1 { + limit = 50 + } + if limit > 200 { + limit = 200 + } + query := eventSelect + args := []any{} + if verdict != "" { + args = append(args, verdict) + query += ` WHERE verdict=$1` + } + args = append(args, limit) + query += ` ORDER BY created_at DESC LIMIT $` + strconv.Itoa(len(args)) + rows, err := s.pool.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Event{} + for rows.Next() { + x, err := scanEvent(rows) + if err != nil { + return nil, err + } + x.Question = "" + x.Answer = "" + x.Claims = nil + x.Evidence = nil + items = append(items, x) + } + return items, rows.Err() +} +func (s *Service) Event(ctx context.Context, id string) (Event, error) { + return scanEvent(s.pool.QueryRow(ctx, eventSelect+` WHERE id=$1`, id)) +} diff --git a/internal/factcheck/service_integration_test.go b/internal/factcheck/service_integration_test.go new file mode 100644 index 0000000..b80bf23 --- /dev/null +++ b/internal/factcheck/service_integration_test.go @@ -0,0 +1,100 @@ +package factcheck + +import ( + "context" + "encoding/json" + "os" + "testing" + + "aigateway.local/core/internal/platform/config" + "aigateway.local/core/internal/platform/database" +) + +func TestFactCheckPostgreSQLLifecycle(t *testing.T) { + databaseURL := os.Getenv("FACTCHECK_TEST_DATABASE_URL") + if databaseURL == "" { + t.Skip("FACTCHECK_TEST_DATABASE_URL is not set") + } + ctx := context.Background() + pool, err := database.Open(ctx, config.Database{URL: databaseURL, MaxConns: 4}) + if err != nil { + t.Fatal(err) + } + defer pool.Close() + + const actorID = "33333333-3333-4333-8333-333333333333" + const knowledgeBaseID = "44444444-4444-4444-8444-444444444444" + const eventID = "55555555-5555-4555-8555-555555555555" + _, err = pool.Exec(ctx, `INSERT INTO gateway.admin_accounts(id,username,password_hash,role) + VALUES($1,'factcheck-test-admin','test','superadmin') ON CONFLICT(id) DO NOTHING`, actorID) + if err != nil { + t.Fatal(err) + } + cleanup := func() { + _, _ = pool.Exec(ctx, `DELETE FROM gateway.fact_check_events WHERE id=$1`, eventID) + _, _ = pool.Exec(ctx, `DELETE FROM gateway.fact_check_policies WHERE scope='global'`) + _, _ = pool.Exec(ctx, `DELETE FROM gateway.knowledge_bases WHERE id=$1`, knowledgeBaseID) + _, _ = pool.Exec(ctx, `UPDATE gateway.fact_check_settings SET provider_id=NULL,model='',updated_by=NULL`) + _, _ = pool.Exec(ctx, `DELETE FROM gateway.admin_accounts WHERE id=$1`, actorID) + } + cleanup() + _, err = pool.Exec(ctx, `INSERT INTO gateway.admin_accounts(id,username,password_hash,role) + VALUES($1,'factcheck-test-admin','test','superadmin') ON CONFLICT(id) DO NOTHING`, actorID) + if err != nil { + t.Fatal(err) + } + defer cleanup() + _, err = pool.Exec(ctx, `INSERT INTO gateway.knowledge_bases(id,name,retrieval_mode,chunk_size,chunk_overlap,created_by) + VALUES($1,'factcheck-test-kb','postgres_fts',800,100,$2)`, knowledgeBaseID, actorID) + if err != nil { + t.Fatal(err) + } + + service := NewService(pool) + settings, err := service.SaveSettings(ctx, Settings{TimeoutSeconds: 17}, actorID) + if err != nil || settings.TimeoutSeconds != 17 || settings.ProviderID != nil { + t.Fatalf("settings=%#v err=%v", settings, err) + } + policy, err := service.SavePolicy(ctx, Policy{ + Scope: "global", + Enabled: true, + Mode: "sync", + Action: "annotate", + KnowledgeBaseIDs: []string{knowledgeBaseID}, + }, actorID) + if err != nil { + t.Fatal(err) + } + if policy.Revision != 1 || policy.TopK != 4 || policy.SupportThreshold != 70 { + t.Fatalf("unexpected policy defaults: %#v", policy) + } + policy.Action = "block" + updated, err := service.SavePolicy(ctx, policy, actorID) + if err != nil || updated.Revision != 2 || updated.Action != "block" { + t.Fatalf("updated=%#v err=%v", updated, err) + } + + claims := json.RawMessage(`[{"text":"Go gateway"}]`) + evidence := json.RawMessage(`[{"source":"kb"}]`) + _, err = pool.Exec(ctx, `INSERT INTO gateway.fact_check_events + (id,policy_id,request_id,model,mode,action,verdict,support_score,latency_ms,question,answer,claims,evidence) + VALUES($1,$2,'factcheck-request','test-model','sync','block','supported',92,12,'question','answer',$3,$4)`, + eventID, policy.ID, claims, evidence) + if err != nil { + t.Fatal(err) + } + events, err := service.Events(ctx, "supported", 10) + if err != nil || len(events) != 1 { + t.Fatalf("events=%#v err=%v", events, err) + } + if events[0].Question != "" || events[0].Answer != "" || events[0].Claims != nil { + t.Fatalf("event list leaked payload: %#v", events[0]) + } + detail, err := service.Event(ctx, eventID) + if err != nil || detail.Question != "question" || len(detail.Evidence) == 0 { + t.Fatalf("detail=%#v err=%v", detail, err) + } + if err = service.DeletePolicy(ctx, policy.ID); err != nil { + t.Fatal(err) + } +} diff --git a/internal/gateway/admission.go b/internal/gateway/admission.go new file mode 100644 index 0000000..25afa31 --- /dev/null +++ b/internal/gateway/admission.go @@ -0,0 +1,139 @@ +package gateway + +import ( + "context" + "errors" + "fmt" + "strconv" + "time" + + "aigateway.local/core/internal/apikey" + "github.com/redis/go-redis/v9" +) + +var ErrAdmissionUnavailable = errors.New("admission control unavailable") + +type AdmissionReason string + +const ( + AdmissionAllowed AdmissionReason = "" + AdmissionRateLimit AdmissionReason = "rate_limit" + AdmissionMonthlyQuota AdmissionReason = "monthly_quota" +) + +type AdmissionDecision struct { + Allowed bool + Reason AdmissionReason + Limit int64 + Remaining int64 + ResetAt time.Time + RetryAfter time.Duration +} + +type AdmissionController interface { + Allow(context.Context, apikey.Principal, time.Time) (AdmissionDecision, error) +} + +type RedisAdmissionController struct { + client *redis.Client + script *redis.Script +} + +func NewRedisAdmissionController(client *redis.Client) *RedisAdmissionController { + return &RedisAdmissionController{client: client, script: redis.NewScript(admissionScript)} +} + +func (c *RedisAdmissionController) Allow(ctx context.Context, principal apikey.Principal, now time.Time) (AdmissionDecision, error) { + if principal.RequestsPerMinute == 0 && principal.MonthlyRequestQuota == 0 { + return AdmissionDecision{Allowed: true}, nil + } + if c == nil || c.client == nil || principal.APIKeyID == "" { + return AdmissionDecision{}, ErrAdmissionUnavailable + } + now = now.UTC() + minuteReset := now.Truncate(time.Minute).Add(time.Minute) + monthReset := time.Date(now.Year(), now.Month()+1, 1, 0, 0, 0, 0, time.UTC) + minuteKey := fmt.Sprintf("gateway:limit:api-key:%s:minute:%d", principal.APIKeyID, now.Unix()/60) + monthKey := fmt.Sprintf("gateway:limit:api-key:%s:month:%s", principal.APIKeyID, now.Format("200601")) + result, err := c.script.Run(ctx, c.client, []string{minuteKey, monthKey}, + principal.RequestsPerMinute, principal.MonthlyRequestQuota, + int64(minuteReset.Sub(now).Seconds())+2, int64(monthReset.Sub(now).Seconds())+86400, + ).Slice() + if err != nil { + return AdmissionDecision{}, fmt.Errorf("%w: %v", ErrAdmissionUnavailable, err) + } + if len(result) != 3 { + return AdmissionDecision{}, ErrAdmissionUnavailable + } + code, err := redisInteger(result[0]) + if err != nil { + return AdmissionDecision{}, ErrAdmissionUnavailable + } + minuteCount, err := redisInteger(result[1]) + if err != nil { + return AdmissionDecision{}, ErrAdmissionUnavailable + } + monthCount, err := redisInteger(result[2]) + if err != nil { + return AdmissionDecision{}, ErrAdmissionUnavailable + } + switch code { + case 0: + decision := AdmissionDecision{Allowed: true} + if principal.RequestsPerMinute > 0 { + decision.Limit = int64(principal.RequestsPerMinute) + decision.Remaining = max(decision.Limit-minuteCount, 0) + decision.ResetAt = minuteReset + } + return decision, nil + case 1: + return AdmissionDecision{ + Allowed: false, Reason: AdmissionRateLimit, Limit: int64(principal.RequestsPerMinute), + Remaining: 0, ResetAt: minuteReset, RetryAfter: minuteReset.Sub(now), + }, nil + case 2: + return AdmissionDecision{ + Allowed: false, Reason: AdmissionMonthlyQuota, Limit: principal.MonthlyRequestQuota, + Remaining: max(principal.MonthlyRequestQuota-monthCount, 0), ResetAt: monthReset, RetryAfter: monthReset.Sub(now), + }, nil + default: + return AdmissionDecision{}, ErrAdmissionUnavailable + } +} + +func redisInteger(value any) (int64, error) { + switch number := value.(type) { + case int64: + return number, nil + case string: + return strconv.ParseInt(number, 10, 64) + case []byte: + return strconv.ParseInt(string(number), 10, 64) + default: + return 0, fmt.Errorf("unexpected redis integer %T", value) + } +} + +const admissionScript = ` +local rpm = tonumber(ARGV[1]) +local monthly_quota = tonumber(ARGV[2]) +local minute_count = tonumber(redis.call('GET', KEYS[1]) or '0') +local month_count = tonumber(redis.call('GET', KEYS[2]) or '0') + +if monthly_quota > 0 and month_count >= monthly_quota then + return {2, minute_count, month_count} +end +if rpm > 0 and minute_count >= rpm then + return {1, minute_count, month_count} +end + +if rpm > 0 then + minute_count = redis.call('INCR', KEYS[1]) + if minute_count == 1 then redis.call('EXPIRE', KEYS[1], tonumber(ARGV[3])) end +end +if monthly_quota > 0 then + month_count = redis.call('INCR', KEYS[2]) + if month_count == 1 then redis.call('EXPIRE', KEYS[2], tonumber(ARGV[4])) end +end +return {0, minute_count, month_count} +` diff --git a/internal/gateway/admission_test.go b/internal/gateway/admission_test.go new file mode 100644 index 0000000..cbc1b88 --- /dev/null +++ b/internal/gateway/admission_test.go @@ -0,0 +1,30 @@ +package gateway + +import ( + "testing" + "time" +) + +func TestRedisInteger(t *testing.T) { + for _, value := range []any{int64(7), "7", []byte("7")} { + got, err := redisInteger(value) + if err != nil || got != 7 { + t.Fatalf("redisInteger(%T) = %d, %v", value, got, err) + } + } + if _, err := redisInteger(7.0); err == nil { + t.Fatal("unexpected redis number type accepted") + } +} + +func TestAdmissionResetBoundaries(t *testing.T) { + now := time.Date(2026, time.August, 10, 13, 25, 40, 0, time.UTC) + minuteReset := now.Truncate(time.Minute).Add(time.Minute) + monthReset := time.Date(now.Year(), now.Month()+1, 1, 0, 0, 0, 0, time.UTC) + if minuteReset != time.Date(2026, time.August, 10, 13, 26, 0, 0, time.UTC) { + t.Fatal("minute boundary is incorrect") + } + if monthReset != time.Date(2026, time.September, 1, 0, 0, 0, 0, time.UTC) { + t.Fatal("month boundary is incorrect") + } +} diff --git a/internal/gateway/audit.go b/internal/gateway/audit.go new file mode 100644 index 0000000..f1c8a00 --- /dev/null +++ b/internal/gateway/audit.go @@ -0,0 +1,221 @@ +package gateway + +import ( + "bufio" + "encoding/json" + "io" + "net" + "net/http" + "sync" + "time" + + "aigateway.local/core/internal/apikey" + auditpkg "aigateway.local/core/internal/audit" + "aigateway.local/core/internal/contentpolicy" + "aigateway.local/core/internal/pricing" +) + +const auditRequestCaptureBytes = 64 << 10 + +type AuditRecorder interface { + Record(auditpkg.Event) bool +} + +type auditSpan struct { + recorder AuditRecorder + started time.Time + event auditpkg.Event + mu sync.Mutex + capture *captureReadCloser + target string + pricing *pricing.Service + policies []contentpolicy.Match + redacted bool +} + +func newAuditSpan(recorder AuditRecorder, principal apikey.Principal, request *http.Request, started time.Time) *auditSpan { + if recorder == nil { + return nil + } + event := auditpkg.Event{TenantID: principal.TenantID, RequestID: RequestID(request.Context()), Protocol: request.URL.Path, RecordedAt: time.Now().UTC()} + if principal.APIKeyID != "" { + id := principal.APIKeyID + event.APIKeyID = &id + } + return &auditSpan{recorder: recorder, started: started, event: event} +} + +func (s *auditSpan) setModel(model string) { + if s != nil && model != "" { + s.mu.Lock() + s.event.Model = model + s.mu.Unlock() + } +} + +func (s *auditSpan) setRoute(providerCode, targetModel string) { + if s == nil { + return + } + s.mu.Lock() + s.event.ProviderCode = providerCode + s.target = targetModel + s.mu.Unlock() +} + +func (s *auditSpan) setUsage(usage TokenUsage) { + if s == nil { + return + } + s.mu.Lock() + s.event.PromptTokens = usage.Input + s.event.CompletionTokens = usage.Output + s.calculateCostLocked() + s.mu.Unlock() +} + +func (s *auditSpan) setContentPolicy(result contentpolicy.Result) { + if s == nil { + return + } + s.mu.Lock() + s.policies = append(s.policies, result.Matches...) + s.redacted = result.Redacted + s.mu.Unlock() +} + +func (s *auditSpan) captureBody(body io.ReadCloser) io.ReadCloser { + if s == nil || body == nil || body == http.NoBody { + return body + } + capture := &captureReadCloser{ReadCloser: body, limit: auditRequestCaptureBytes} + s.capture = capture + return capture +} + +func (s *auditSpan) finish(status int) { + if s == nil { + return + } + s.mu.Lock() + if s.event.Model == "" && s.capture != nil { + var payload map[string]json.RawMessage + if json.Unmarshal(s.capture.buffer, &payload) == nil { + _ = json.Unmarshal(payload["model"], &s.event.Model) + } + } + s.calculateCostLocked() + s.event.StatusCode = status + s.event.LatencyMS = int(time.Since(s.started).Milliseconds()) + s.event.RecordedAt = time.Now().UTC() + labels := map[string]any{} + if s.target != "" && s.target != s.event.Model { + labels["target_model"] = s.target + labels["routed"] = true + } + if len(s.policies) > 0 { + labels["content_policies"] = s.policies + } + if s.redacted { + labels["content_redacted"] = true + } + if s.event.PriceID != "" { + labels["price_id"] = s.event.PriceID + labels["currency"] = s.event.Currency + } + s.event.Labels = labels + event := s.event + s.mu.Unlock() + s.recorder.Record(event) +} + +func (s *auditSpan) calculateCostLocked() { + if s.pricing == nil || s.event.ProviderCode == "" || s.event.Model == "" { + return + } + model := s.event.Model + if s.target != "" { + model = s.target + } + cost := s.pricing.Calculate(s.event.ProviderCode, model, s.event.PromptTokens, s.event.CompletionTokens, s.started.UTC()) + s.event.CostMicrounits = cost.Microunits + s.event.PriceID = cost.PriceID + s.event.Currency = cost.Currency +} + +type captureReadCloser struct { + io.ReadCloser + buffer []byte + limit int +} + +func (r *captureReadCloser) Read(buffer []byte) (int, error) { + n, err := r.ReadCloser.Read(buffer) + if n > 0 && len(r.buffer) < r.limit { + remaining := r.limit - len(r.buffer) + r.buffer = append(r.buffer, buffer[:min(n, remaining)]...) + } + return n, err +} + +type statusResponseWriter struct { + http.ResponseWriter + status int +} + +func (w *statusResponseWriter) WriteHeader(status int) { + if w.status != 0 { + return + } + w.status = status + w.ResponseWriter.WriteHeader(status) +} + +func (w *statusResponseWriter) Write(buffer []byte) (int, error) { + if w.status == 0 { + w.WriteHeader(http.StatusOK) + } + return w.ResponseWriter.Write(buffer) +} + +func (w *statusResponseWriter) Status() int { + if w.status == 0 { + return http.StatusOK + } + return w.status +} + +func (w *statusResponseWriter) Flush() { + if w.status == 0 { + w.WriteHeader(http.StatusOK) + } + if flusher, ok := w.ResponseWriter.(http.Flusher); ok { + flusher.Flush() + } +} + +func (w *statusResponseWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter } + +func (w *statusResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + if hijacker, ok := w.ResponseWriter.(http.Hijacker); ok { + return hijacker.Hijack() + } + return nil, nil, http.ErrNotSupported +} + +func (w *statusResponseWriter) Push(target string, options *http.PushOptions) error { + if pusher, ok := w.ResponseWriter.(http.Pusher); ok { + return pusher.Push(target, options) + } + return http.ErrNotSupported +} + +func (w *statusResponseWriter) ReadFrom(reader io.Reader) (int64, error) { + if w.status == 0 { + w.WriteHeader(http.StatusOK) + } + if readerFrom, ok := w.ResponseWriter.(io.ReaderFrom); ok { + return readerFrom.ReadFrom(reader) + } + return io.Copy(struct{ io.Writer }{w.ResponseWriter}, reader) +} diff --git a/internal/gateway/content_pricing_integration_test.go b/internal/gateway/content_pricing_integration_test.go new file mode 100644 index 0000000..e2a46f1 --- /dev/null +++ b/internal/gateway/content_pricing_integration_test.go @@ -0,0 +1,141 @@ +package gateway + +import ( + "context" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync/atomic" + "testing" + "time" + + "aigateway.local/core/internal/audit" + "aigateway.local/core/internal/contentpolicy" + "aigateway.local/core/internal/pricing" + provideropenai "aigateway.local/core/internal/provider/openai" + "github.com/jackc/pgx/v5/pgxpool" +) + +func TestContentPolicyAndPricingIntegration(t *testing.T) { + databaseURL := os.Getenv("CONTENT_PRICING_TEST_DATABASE_URL") + if databaseURL == "" { + t.Skip("CONTENT_PRICING_TEST_DATABASE_URL is not set") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + defer pool.Close() + const blockID = "00000000-0000-4000-8000-000000001601" + const priceID = "00000000-0000-4000-8000-000000001602" + _, err = pool.Exec(ctx, `INSERT INTO gateway.content_policies(id,name,action,priority,rules,enabled) VALUES($1,'integration block','block',2000,'[{"name":"forbidden","pattern":"forbidden","replacement":"[BLOCKED]"}]',true) ON CONFLICT(id) DO UPDATE SET enabled=true`, blockID) + if err != nil { + t.Fatal(err) + } + _, err = pool.Exec(ctx, `INSERT INTO gateway.model_prices(id,provider_code,model_pattern,input_microunits_per_million,output_microunits_per_million,currency,effective_from,enabled) VALUES($1,'environment','gpt-test',2000000,8000000,'USD',clock_timestamp()-interval '1 hour',true) ON CONFLICT(id) DO UPDATE SET enabled=true`, priceID) + if err != nil { + t.Fatal(err) + } + defer func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM gateway.outbox_events WHERE event_type='content_policy.matched' AND aggregate_id IN ('m3-content-price-redact','m3-content-price-block')`) + _, _ = pool.Exec(context.Background(), `DELETE FROM gateway.content_policies WHERE id=$1`, blockID) + _, _ = pool.Exec(context.Background(), `DELETE FROM gateway.model_prices WHERE id=$1`, priceID) + }() + + var upstreamCalls atomic.Int64 + bodySeen := make(chan string, 1) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamCalls.Add(1) + body, _ := io.ReadAll(r.Body) + bodySeen <- string(body) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"id":"ok","usage":{"prompt_tokens":1000,"completion_tokens":500,"total_tokens":1500}}`) + })) + defer upstream.Close() + adapter, _ := provideropenai.New(upstream.URL, "upstream-key") + engine := contentpolicy.NewEngine(pool, time.Minute, slog.Default()) + if err := engine.Reload(ctx); err != nil { + t.Fatal(err) + } + prices := pricing.NewService(pool, time.Minute, slog.Default()) + if err := prices.Reload(ctx); err != nil { + t.Fatal(err) + } + recorder := audit.NewRecorder(pool, slog.Default(), 100, 1, 10*time.Millisecond) + recordCtx, cancel := context.WithCancel(ctx) + stopped := make(chan struct{}) + go func() { recorder.Run(recordCtx); close(stopped) }() + defer func() { cancel(); <-stopped }() + proxy := NewProxy(adapter, "test-key", 1<<20, slog.Default()) + proxy.SetAuditRecorder(recorder) + proxy.SetContentPolicyEngine(engine) + proxy.SetPricingService(prices) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + proxy.ServeHTTP(w, r.WithContext(WithRequestID(r.Context(), r.Header.Get("X-Request-ID")))) + })) + defer server.Close() + + req, _ := http.NewRequest(http.MethodPost, server.URL+"/v1/chat/completions", strings.NewReader(`{"model":"gpt-test","messages":[{"role":"user","content":"token sk-1234567890123456"}]}`)) + req.Header.Set("Authorization", "Bearer test-key") + req.Header.Set("X-Request-ID", "m3-content-price-redact") + response, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + _, _ = io.ReadAll(response.Body) + _ = response.Body.Close() + if response.StatusCode != 200 || response.Header.Get("X-Gateway-Content-Redacted") != "true" { + t.Fatalf("unexpected response %d headers=%v", response.StatusCode, response.Header) + } + select { + case body := <-bodySeen: + if strings.Contains(body, "sk-1234567890123456") || !strings.Contains(body, "[REDACTED_API_KEY]") { + t.Fatalf("upstream saw unsafe body %s", body) + } + case <-time.After(time.Second): + t.Fatal("upstream did not receive request") + } + + blocked, _ := http.NewRequest(http.MethodPost, server.URL+"/v1/responses", strings.NewReader(`{"model":"gpt-test","input":"forbidden"}`)) + blocked.Header.Set("Authorization", "Bearer test-key") + blocked.Header.Set("X-Request-ID", "m3-content-price-block") + blockedResponse, err := http.DefaultClient.Do(blocked) + if err != nil { + t.Fatal(err) + } + _, _ = io.ReadAll(blockedResponse.Body) + _ = blockedResponse.Body.Close() + if blockedResponse.StatusCode != http.StatusUnprocessableEntity || upstreamCalls.Load() != 1 { + t.Fatalf("block failed status=%d calls=%d", blockedResponse.StatusCode, upstreamCalls.Load()) + } + + deadline := time.Now().Add(2 * time.Second) + for { + var cost *int64 + var labels string + err = pool.QueryRow(ctx, `SELECT cost_microunits,labels::text FROM gateway.audit_events WHERE request_id='m3-content-price-redact' ORDER BY recorded_at DESC LIMIT 1`).Scan(&cost, &labels) + if err == nil && cost != nil && *cost == 6000 && strings.Contains(labels, "content_redacted") { + break + } + if time.Now().After(deadline) { + t.Fatalf("audit cost/redaction missing cost=%v labels=%s err=%v", cost, labels, err) + } + time.Sleep(20 * time.Millisecond) + } + deadline = time.Now().Add(2 * time.Second) + for { + var count int + err = pool.QueryRow(ctx, `SELECT count(*) FROM gateway.outbox_events WHERE event_type='content_policy.matched' AND aggregate_id IN ('m3-content-price-redact','m3-content-price-block')`).Scan(&count) + if err == nil && count == 2 { + break + } + if time.Now().After(deadline) { + t.Fatalf("content policy notification outbox missing count=%d err=%v", count, err) + } + time.Sleep(20 * time.Millisecond) + } +} diff --git a/internal/gateway/context.go b/internal/gateway/context.go new file mode 100644 index 0000000..7cc7265 --- /dev/null +++ b/internal/gateway/context.go @@ -0,0 +1,14 @@ +package gateway + +import "context" + +type requestIDKey struct{} + +func WithRequestID(ctx context.Context, requestID string) context.Context { + return context.WithValue(ctx, requestIDKey{}, requestID) +} + +func RequestID(ctx context.Context) string { + value, _ := ctx.Value(requestIDKey{}).(string) + return value +} diff --git a/internal/gateway/proxy.go b/internal/gateway/proxy.go new file mode 100644 index 0000000..f4fcfd5 --- /dev/null +++ b/internal/gateway/proxy.go @@ -0,0 +1,430 @@ +package gateway + +import ( + "context" + "crypto/subtle" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "net/http/httputil" + "strconv" + "strings" + "sync" + "time" + + "aigateway.local/core/internal/apikey" + "aigateway.local/core/internal/contentpolicy" + "aigateway.local/core/internal/pricing" + "aigateway.local/core/internal/provider" +) + +type Proxy struct { + resolver AdapterResolver + auth apikey.KeyAuthenticator + maxBody int64 + logger *slog.Logger + transport *http.Transport + proxies sync.Map + circuits sync.Map + admission AdmissionController + tokenQuota TokenQuotaController + resilience ResiliencePolicy + audit AuditRecorder + policies *contentpolicy.Engine + pricing *pricing.Service +} + +type cachedProxy struct { + key string + proxy *httputil.ReverseProxy +} + +var ( + ErrProviderNotFound = errors.New("requested provider is not available") + ErrProviderUnavailable = errors.New("provider configuration is unavailable") +) + +type ResolvedAdapter struct { + Code string + Revision int64 + Adapter provider.Adapter + Capabilities map[provider.Capability]bool +} + +type AdapterResolver interface { + Resolve(providerCode string) (ResolvedAdapter, error) +} + +func NewProxy(adapter provider.Adapter, apiKey string, maxBody int64, logger *slog.Logger) *Proxy { + return NewProxyWithAuthenticator(adapter, staticKeyAuthenticator(apiKey), maxBody, logger) +} + +func NewProxyWithAuthenticator(adapter provider.Adapter, authenticator apikey.KeyAuthenticator, maxBody int64, logger *slog.Logger) *Proxy { + return NewDynamicProxy(staticAdapterResolver{adapter: adapter}, authenticator, maxBody, logger) +} + +func NewDynamicProxy(resolver AdapterResolver, authenticator apikey.KeyAuthenticator, maxBody int64, logger *slog.Logger) *Proxy { + transport := &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second}).DialContext, + ForceAttemptHTTP2: true, + MaxIdleConns: 512, + MaxIdleConnsPerHost: 256, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 5 * time.Second, + ResponseHeaderTimeout: 60 * time.Second, + ExpectContinueTimeout: time.Second, + } + return &Proxy{resolver: resolver, auth: authenticator, maxBody: maxBody, logger: logger, transport: transport, resilience: DefaultResiliencePolicy()} +} + +func (p *Proxy) SetAdmissionController(controller AdmissionController) { + p.admission = controller +} + +func (p *Proxy) SetTokenQuotaController(controller TokenQuotaController) { + p.tokenQuota = controller +} + +func (p *Proxy) SetResiliencePolicy(policy ResiliencePolicy) { + p.resilience = policy + p.transport.ResponseHeaderTimeout = policy.ResponseHeaderTimeout +} + +func (p *Proxy) SetAuditRecorder(recorder AuditRecorder) { p.audit = recorder } +func (p *Proxy) SetContentPolicyEngine(engine *contentpolicy.Engine) { p.policies = engine } +func (p *Proxy) SetPricingService(service *pricing.Service) { p.pricing = service } + +func (p *Proxy) ServeHTTP(writer http.ResponseWriter, request *http.Request) { + started := time.Now() + if !isSupportedPath(request.URL.Path, request.Method) { + writeOpenAIError(writer, http.StatusNotFound, "invalid_request_error", "unsupported gateway endpoint") + return + } + principal, err := p.authorized(request) + if err != nil { + if errors.Is(err, apikey.ErrStore) { + writeOpenAIError(writer, http.StatusServiceUnavailable, "authentication_unavailable", "API key service is unavailable") + return + } + writer.Header().Set("WWW-Authenticate", "Bearer") + writeOpenAIError(writer, http.StatusUnauthorized, "authentication_error", "invalid API key") + return + } + span := newAuditSpan(p.audit, principal, request, started) + if span != nil { + span.pricing = p.pricing + } + if span != nil { + statusWriter := &statusResponseWriter{ResponseWriter: writer} + writer = statusWriter + defer func() { span.finish(statusWriter.Status()) }() + } + if p.admission != nil && principal.APIKeyID != "" { + decision, err := p.admission.Allow(request.Context(), principal, time.Now()) + if err != nil { + writeOpenAIError(writer, http.StatusServiceUnavailable, "rate_limit_unavailable", "rate limit service is unavailable") + return + } + writeAdmissionHeaders(writer.Header(), decision) + if !decision.Allowed { + writer.Header().Set("Retry-After", strconv.FormatInt(max(int64(decision.RetryAfter.Seconds()), 1), 10)) + if decision.Reason == AdmissionMonthlyQuota { + writeOpenAIError(writer, http.StatusTooManyRequests, "insufficient_quota", "monthly request quota exceeded") + } else { + writeOpenAIError(writer, http.StatusTooManyRequests, "rate_limit_error", "request rate limit exceeded") + } + return + } + } + if request.ContentLength > p.maxBody { + writeOpenAIError(writer, http.StatusRequestEntityTooLarge, "invalid_request_error", "request body is too large") + return + } + if p.policies != nil { + policyResult, policyErr := p.policies.Apply(request, p.maxBody, principal.APIKeyID) + if errors.Is(policyErr, contentpolicy.ErrBodyTooLarge) { + writeOpenAIError(writer, http.StatusRequestEntityTooLarge, "invalid_request_error", "request body is too large") + return + } + if policyErr != nil { + writeOpenAIError(writer, http.StatusBadRequest, "invalid_request_error", "request body could not be inspected") + return + } + span.setContentPolicy(policyResult) + if policyResult.Blocked { + writeOpenAIError(writer, http.StatusUnprocessableEntity, "content_policy_violation", "request was blocked by content policy") + return + } + if policyResult.Redacted { + writer.Header().Set("X-Gateway-Content-Redacted", "true") + } + } + routeResolver, routingEnabled := p.resolver.(ModelRouteResolver) + routingEnabled = routingEnabled && routeResolver.ModelRoutingEnabled() + var modelPayload modelRequest + if routingEnabled { + modelPayload, err = readModelRequest(request, p.maxBody) + if errors.Is(err, errRequestBodyTooLarge) { + writeOpenAIError(writer, http.StatusRequestEntityTooLarge, "invalid_request_error", "request body is too large") + return + } + if err != nil { + writeOpenAIError(writer, http.StatusBadRequest, "invalid_request_error", "request body could not be read") + return + } + } + var usage *usageSession + if p.tokenQuota != nil && principal.APIKeyID != "" { + estimate := int64(0) + if principal.MonthlyTokenQuota > 0 { + estimate, err = prepareTokenBudget(request, p.maxBody) + if errors.Is(err, errRequestBodyTooLarge) { + writeOpenAIError(writer, http.StatusRequestEntityTooLarge, "invalid_request_error", "request body is too large") + return + } + if err != nil { + writeOpenAIError(writer, http.StatusBadRequest, "invalid_request_error", "request body could not be read") + return + } + } + reservation, reserveErr := p.tokenQuota.Reserve(request.Context(), principal, estimate, time.Now()) + if reserveErr != nil && principal.MonthlyTokenQuota > 0 { + writeOpenAIError(writer, http.StatusServiceUnavailable, "token_quota_unavailable", "token quota service is unavailable") + return + } + if reserveErr == nil && !reservation.Allowed { + writeTokenQuotaHeaders(writer.Header(), reservation) + writer.Header().Set("Retry-After", strconv.FormatInt(max(int64(time.Until(reservation.ResetAt).Seconds()), 1), 10)) + writeOpenAIError(writer, http.StatusTooManyRequests, "insufficient_quota", "monthly token quota exceeded") + return + } + if reserveErr == nil && reservation.CounterKey != "" { + writeTokenQuotaHeaders(writer.Header(), reservation) + usage = &usageSession{controller: p.tokenQuota, reservation: reservation, log: p.logger} + request = withUsageSession(request, usage) + defer usage.finish(TokenUsage{}) + } + } + if span != nil { + if usage == nil { + usage = &usageSession{log: p.logger} + request = withUsageSession(request, usage) + defer usage.finish(TokenUsage{}) + } + usage.onFinish = span.setUsage + } + if request.Body != nil && request.Method != http.MethodGet { + if request.Header.Get("Idempotency-Key") != "" && request.GetBody == nil { + if _, err := prepareTokenBudget(request, p.maxBody); err != nil { + if errors.Is(err, errRequestBodyTooLarge) { + writeOpenAIError(writer, http.StatusRequestEntityTooLarge, "invalid_request_error", "request body is too large") + } else { + writeOpenAIError(writer, http.StatusBadRequest, "invalid_request_error", "request body could not be read") + } + return + } + } + request.Body = http.MaxBytesReader(writer, request.Body, p.maxBody) + } + providerCode := strings.ToLower(strings.TrimSpace(request.Header.Get("X-Gateway-Provider"))) + var resolved ResolvedAdapter + if routingEnabled && modelPayload.model != "" { + span.setModel(modelPayload.model) + tenantID := "" + if principal.TenantID != nil { + tenantID = *principal.TenantID + } + route, routeErr := routeResolver.ResolveModelRoute(ModelRouteQuery{ + ProviderCode: providerCode, Model: modelPayload.model, Endpoint: request.URL.Path, + APIKeyID: principal.APIKeyID, TenantID: tenantID, Seed: RequestID(request.Context()), + }) + if routeErr != nil { + writeOpenAIError(writer, http.StatusServiceUnavailable, "provider_unavailable", "model routing configuration is unavailable") + return + } + if route.Known && !route.Matched { + writeOpenAIError(writer, http.StatusBadRequest, "invalid_request_error", "model route is not available for this request") + return + } + if route.Matched { + resolved = route.ResolvedAdapter + if err := modelPayload.rewrite(request, route.TargetModel); err != nil { + writeOpenAIError(writer, http.StatusBadRequest, "invalid_request_error", "request model could not be rewritten") + return + } + writer.Header().Set("X-Gateway-Model", route.TargetModel) + } + } + if resolved.Adapter == nil { + resolved, err = p.resolver.Resolve(providerCode) + } + if err != nil { + if errors.Is(err, ErrProviderNotFound) { + writeOpenAIError(writer, http.StatusBadRequest, "invalid_request_error", "requested provider is not available") + return + } + writeOpenAIError(writer, http.StatusServiceUnavailable, "provider_unavailable", "provider configuration is unavailable") + return + } + capability := capabilityForPath(request.URL.Path) + if capability != "" && !resolved.Capabilities[capability] { + writeOpenAIError(writer, http.StatusBadRequest, "invalid_request_error", "provider does not support this endpoint") + return + } + request.Header.Del("X-Gateway-Provider") + writer.Header().Set("X-Gateway-Provider", resolved.Code) + span.setRoute(resolved.Code, writer.Header().Get("X-Gateway-Model")) + request.Body = span.captureBody(request.Body) + p.proxyFor(resolved).ServeHTTP(writer, request) +} + +func (p *Proxy) proxyFor(resolved ResolvedAdapter) *httputil.ReverseProxy { + target := resolved.Adapter.Target() + key := fmt.Sprintf("%s:%d:%s", resolved.Code, resolved.Revision, target.String()) + if cached, ok := p.proxies.Load(resolved.Code); ok { + entry := cached.(cachedProxy) + if entry.key == key { + return entry.proxy + } + } + reverseProxy := httputil.NewSingleHostReverseProxy(target) + originalDirector := reverseProxy.Director + reverseProxy.Director = func(request *http.Request) { + originalDirector(request) + request.Host = target.Host + resolved.Adapter.Prepare(request) + } + reverseProxy.FlushInterval = -1 + circuitValue, _ := p.circuits.LoadOrStore(resolved.Code, newCircuitBreaker(p.resilience)) + reverseProxy.Transport = &resilientTransport{ + base: p.transport, circuit: circuitValue.(*circuitBreaker), maxRetries: p.resilience.MaxRetries, backoff: p.resilience.RetryBackoff, + } + reverseProxy.ModifyResponse = func(response *http.Response) error { + if session := usageSessionFrom(response.Request); session != nil { + if response.StatusCode >= http.StatusOK && response.StatusCode < http.StatusMultipleChoices { + session.fallback = session.reservation.Reserved + response.Body = newUsageReadCloser(response.Body, response.Header.Get("Content-Type"), session) + } else { + session.fallback = 0 + session.finish(TokenUsage{}) + } + } + return nil + } + reverseProxy.ErrorHandler = func(writer http.ResponseWriter, request *http.Request, err error) { + if session := usageSessionFrom(request); session != nil { + session.fallback = 0 + session.finish(TokenUsage{}) + } + p.logger.Error("upstream request failed", "request_id", RequestID(request.Context()), "provider", resolved.Code, "error", err) + if errors.Is(err, ErrCircuitOpen) { + writer.Header().Set("Retry-After", "30") + writeOpenAIError(writer, http.StatusServiceUnavailable, "provider_unavailable", "provider circuit is temporarily open") + return + } + writeOpenAIError(writer, http.StatusBadGateway, "upstream_error", "upstream service is unavailable") + } + p.proxies.Store(resolved.Code, cachedProxy{key: key, proxy: reverseProxy}) + return reverseProxy +} + +func (p *Proxy) authorized(request *http.Request) (apikey.Principal, error) { + presented := strings.TrimSpace(request.Header.Get("X-Gateway-API-Key")) + if presented == "" { + authorization := strings.TrimSpace(request.Header.Get("Authorization")) + if len(authorization) > len("Bearer ") && strings.EqualFold(authorization[:len("Bearer ")], "Bearer ") { + presented = strings.TrimSpace(authorization[len("Bearer "):]) + } + } + if p.auth == nil { + return apikey.Principal{}, apikey.ErrInvalid + } + if authenticator, ok := p.auth.(apikey.PrincipalAuthenticator); ok { + return authenticator.AuthenticatePrincipal(request.Context(), presented) + } + return apikey.Principal{}, p.auth.Authenticate(request.Context(), presented) +} + +func writeAdmissionHeaders(header http.Header, decision AdmissionDecision) { + if decision.Limit <= 0 || decision.ResetAt.IsZero() { + return + } + header.Set("X-RateLimit-Limit", strconv.FormatInt(decision.Limit, 10)) + header.Set("X-RateLimit-Remaining", strconv.FormatInt(max(decision.Remaining, 0), 10)) + header.Set("X-RateLimit-Reset", strconv.FormatInt(decision.ResetAt.Unix(), 10)) +} + +type staticKeyAuthenticator string + +func (a staticKeyAuthenticator) Authenticate(_ context.Context, presented string) error { + key := string(a) + if key == "" { + return nil + } + if len(presented) != len(key) || subtle.ConstantTimeCompare([]byte(presented), []byte(key)) != 1 { + return apikey.ErrInvalid + } + return nil +} + +type staticAdapterResolver struct{ adapter provider.Adapter } + +func (r staticAdapterResolver) Resolve(code string) (ResolvedAdapter, error) { + if r.adapter == nil || code != "" && code != "environment" { + return ResolvedAdapter{}, ErrProviderNotFound + } + capabilities := make(map[provider.Capability]bool) + for _, capability := range r.adapter.Capabilities() { + capabilities[capability] = true + } + return ResolvedAdapter{Code: "environment", Adapter: r.adapter, Capabilities: capabilities}, nil +} + +func capabilityForPath(path string) provider.Capability { + switch path { + case "/v1/models": + return provider.CapabilityModels + case "/v1/chat/completions": + return provider.CapabilityChat + case "/v1/responses": + return provider.CapabilityResponses + case "/v1/embeddings": + return provider.CapabilityEmbeddings + case "/v1/messages": + return provider.CapabilityMessages + default: + return "" + } +} + +func isSupportedPath(path, method string) bool { + switch path { + case "/v1/models": + return method == http.MethodGet + case "/v1/chat/completions", "/v1/responses", "/v1/embeddings", "/v1/messages": + return method == http.MethodPost + default: + return false + } +} + +func writeOpenAIError(writer http.ResponseWriter, status int, errorType, message string) { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(status) + _ = json.NewEncoder(writer).Encode(map[string]any{ + "error": map[string]any{"message": message, "type": errorType, "param": nil, "code": nil}, + }) +} + +var errInvalidAdapter = errors.New("invalid provider adapter") + +func ValidateAdapter(adapter provider.Adapter) error { + if adapter == nil || adapter.Target() == nil || adapter.Name() == "" { + return errInvalidAdapter + } + return nil +} diff --git a/internal/gateway/proxy_test.go b/internal/gateway/proxy_test.go new file mode 100644 index 0000000..2a108ac --- /dev/null +++ b/internal/gateway/proxy_test.go @@ -0,0 +1,252 @@ +package gateway + +import ( + "context" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "aigateway.local/core/internal/apikey" + auditpkg "aigateway.local/core/internal/audit" + "aigateway.local/core/internal/provider" + provideropenai "aigateway.local/core/internal/provider/openai" +) + +type principalAuthenticator struct{ principal apikey.Principal } + +func (a principalAuthenticator) Authenticate(context.Context, string) error { return nil } +func (a principalAuthenticator) AuthenticatePrincipal(context.Context, string) (apikey.Principal, error) { + return a.principal, nil +} + +type fixedAdmission struct{ decision AdmissionDecision } + +func (a fixedAdmission) Allow(context.Context, apikey.Principal, time.Time) (AdmissionDecision, error) { + return a.decision, nil +} + +type recordingTokenQuota struct { + reservation TokenReservation + estimate int64 + actual int64 +} + +type fixedRoutingResolver struct { + adapter ResolvedAdapter +} + +type recordingAudit struct{ event auditpkg.Event } + +func (r *recordingAudit) Record(event auditpkg.Event) bool { r.event = event; return true } + +func (r fixedRoutingResolver) Resolve(string) (ResolvedAdapter, error) { return r.adapter, nil } +func (r fixedRoutingResolver) ModelRoutingEnabled() bool { return true } +func (r fixedRoutingResolver) ResolveModelRoute(query ModelRouteQuery) (ModelRouteResult, error) { + if query.Model == "public-chat" { + return ModelRouteResult{ResolvedAdapter: r.adapter, TargetModel: "upstream-chat", Matched: true, Known: true}, nil + } + return ModelRouteResult{}, nil +} + +func (q *recordingTokenQuota) Reserve(_ context.Context, _ apikey.Principal, estimate int64, _ time.Time) (TokenReservation, error) { + q.estimate = estimate + return q.reservation, nil +} + +func (q *recordingTokenQuota) Commit(_ context.Context, _ TokenReservation, actual int64) error { + q.actual = actual + return nil +} + +func TestProxyRejectsInvalidKey(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("upstream must not be called") + })) + defer upstream.Close() + adapter, err := provideropenai.New(upstream.URL, "upstream-secret") + if err != nil { + t.Fatal(err) + } + proxy := NewProxy(adapter, "gateway-secret", 1024, slog.New(slog.NewTextHandler(io.Discard, nil))) + + request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + request.Header.Set("Authorization", "Bearer wrong") + response := httptest.NewRecorder() + proxy.ServeHTTP(response, request) + if response.Code != http.StatusUnauthorized { + t.Fatalf("got %d, want %d", response.Code, http.StatusUnauthorized) + } +} + +func TestProxyRejectsKnownOversizedBody(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("upstream must not be called") + })) + defer upstream.Close() + adapter, _ := provideropenai.New(upstream.URL, "upstream-secret") + proxy := NewProxy(adapter, "gateway-secret", 4, slog.New(slog.NewTextHandler(io.Discard, nil))) + + request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader("12345")) + request.Header.Set("Authorization", "Bearer gateway-secret") + response := httptest.NewRecorder() + proxy.ServeHTTP(response, request) + if response.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("got %d, want %d", response.Code, http.StatusRequestEntityTooLarge) + } +} + +func TestProxyReplacesClientAuthorization(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if got := request.Header.Get("Authorization"); got != "Bearer upstream-secret" { + t.Fatalf("unexpected upstream authorization: %q", got) + } + writer.Header().Set("Content-Type", "application/json") + _, _ = writer.Write([]byte(`{"ok":true}`)) + })) + defer upstream.Close() + adapter, _ := provideropenai.New(upstream.URL, "upstream-secret") + proxy := NewProxy(adapter, "gateway-secret", 1024, slog.New(slog.NewTextHandler(io.Discard, nil))) + + request := httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + request.Header.Set("Authorization", "Bearer gateway-secret") + response := httptest.NewRecorder() + proxy.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("got %d, want %d", response.Code, http.StatusOK) + } +} + +func TestProxyRejectsRateLimitedPrincipalBeforeUpstream(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("upstream must not be called") + })) + defer upstream.Close() + adapter, _ := provideropenai.New(upstream.URL, "upstream-secret") + proxy := NewProxyWithAuthenticator(adapter, principalAuthenticator{principal: apikey.Principal{ + APIKeyID: "key-1", Scopes: []string{"gateway:invoke"}, RequestsPerMinute: 2, + }}, 1024, slog.New(slog.NewTextHandler(io.Discard, nil))) + proxy.SetAdmissionController(fixedAdmission{decision: AdmissionDecision{ + Allowed: false, Reason: AdmissionRateLimit, Limit: 2, Remaining: 0, + ResetAt: time.Now().Add(time.Minute), RetryAfter: 30 * time.Second, + }}) + + request := httptest.NewRequest(http.MethodGet, "/v1/models", nil) + request.Header.Set("Authorization", "Bearer gateway-secret") + response := httptest.NewRecorder() + proxy.ServeHTTP(response, request) + if response.Code != http.StatusTooManyRequests { + t.Fatalf("got %d, want %d", response.Code, http.StatusTooManyRequests) + } + if response.Header().Get("X-RateLimit-Limit") != "2" || response.Header().Get("Retry-After") == "" { + t.Fatalf("missing rate limit headers: %#v", response.Header()) + } + if !strings.Contains(response.Body.String(), `"type":"rate_limit_error"`) { + t.Fatalf("unexpected body: %s", response.Body.String()) + } +} + +func TestProxyReconcilesReservedTokensWithUpstreamUsage(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.Header().Set("Content-Type", "application/json") + _, _ = writer.Write([]byte(`{"choices":[],"usage":{"prompt_tokens":7,"completion_tokens":5,"total_tokens":12}}`)) + })) + defer upstream.Close() + adapter, _ := provideropenai.New(upstream.URL, "upstream-secret") + quota := &recordingTokenQuota{reservation: TokenReservation{ + Allowed: true, CounterKey: "tokens", Reserved: 24, Limit: 1000, Remaining: 976, ResetAt: time.Now().Add(time.Hour), + }} + proxy := NewProxyWithAuthenticator(adapter, principalAuthenticator{principal: apikey.Principal{ + APIKeyID: "key-1", Scopes: []string{"gateway:invoke"}, MonthlyTokenQuota: 1000, + }}, 1024, slog.New(slog.NewTextHandler(io.Discard, nil))) + proxy.SetTokenQuotaController(quota) + + request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"test","max_tokens":20}`)) + request.Header.Set("Authorization", "Bearer gateway-secret") + response := httptest.NewRecorder() + proxy.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("got %d, want %d: %s", response.Code, http.StatusOK, response.Body.String()) + } + if quota.estimate <= 20 { + t.Fatalf("request budget did not include input tokens: %d", quota.estimate) + } + if quota.actual != 12 { + t.Fatalf("committed %d tokens, want 12", quota.actual) + } +} + +func TestProxyRejectsTokenReservationBeforeUpstream(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("upstream must not be called") + })) + defer upstream.Close() + adapter, _ := provideropenai.New(upstream.URL, "upstream-secret") + quota := &recordingTokenQuota{reservation: TokenReservation{ + Allowed: false, Limit: 100, Remaining: 4, ResetAt: time.Now().Add(time.Hour), + }} + proxy := NewProxyWithAuthenticator(adapter, principalAuthenticator{principal: apikey.Principal{ + APIKeyID: "key-1", Scopes: []string{"gateway:invoke"}, MonthlyTokenQuota: 100, + }}, 1024, slog.New(slog.NewTextHandler(io.Discard, nil))) + proxy.SetTokenQuotaController(quota) + + request := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"test","max_output_tokens":20}`)) + request.Header.Set("Authorization", "Bearer gateway-secret") + response := httptest.NewRecorder() + proxy.ServeHTTP(response, request) + if response.Code != http.StatusTooManyRequests || !strings.Contains(response.Body.String(), `"type":"insufficient_quota"`) { + t.Fatalf("unexpected response %d: %s", response.Code, response.Body.String()) + } + if response.Header().Get("X-TokenLimit-Limit") != "100" { + t.Fatalf("missing token quota headers: %#v", response.Header()) + } +} + +func TestProxyRewritesModelAliasBeforeUpstream(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + body, _ := io.ReadAll(request.Body) + if !strings.Contains(string(body), `"model":"upstream-chat"`) { + t.Fatalf("model alias was not rewritten: %s", body) + } + writer.Header().Set("Content-Type", "application/json") + _, _ = writer.Write([]byte(`{"choices":[]}`)) + })) + defer upstream.Close() + adapter, _ := provideropenai.New(upstream.URL, "upstream-secret") + resolved := ResolvedAdapter{Code: "routed", Adapter: adapter, Capabilities: map[provider.Capability]bool{provider.CapabilityChat: true}} + proxy := NewDynamicProxy(fixedRoutingResolver{adapter: resolved}, principalAuthenticator{principal: apikey.Principal{Scopes: []string{"gateway:invoke"}}}, 1024, slog.New(slog.NewTextHandler(io.Discard, nil))) + request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"public-chat","messages":[]}`)) + request.Header.Set("Authorization", "Bearer gateway-secret") + response := httptest.NewRecorder() + proxy.ServeHTTP(response, request) + if response.Code != http.StatusOK || response.Header().Get("X-Gateway-Model") != "upstream-chat" || response.Header().Get("X-Gateway-Provider") != "routed" { + t.Fatalf("unexpected routed response %d %#v: %s", response.Code, response.Header(), response.Body.String()) + } +} + +func TestProxyRecordsAuditWithoutBufferingWholeResponse(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.Header().Set("Content-Type", "application/json") + _, _ = writer.Write([]byte(`{"choices":[],"usage":{"prompt_tokens":9,"completion_tokens":4,"total_tokens":13}}`)) + })) + defer upstream.Close() + adapter, _ := provideropenai.New(upstream.URL, "upstream-secret") + recorder := &recordingAudit{} + proxy := NewProxyWithAuthenticator(adapter, principalAuthenticator{principal: apikey.Principal{ + APIKeyID: "11111111-1111-4111-8111-111111111111", Scopes: []string{"gateway:invoke"}, + }}, 1024, slog.New(slog.NewTextHandler(io.Discard, nil))) + proxy.SetAuditRecorder(recorder) + request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"audit-model","messages":[]}`)) + request.Header.Set("Authorization", "Bearer gateway-secret") + response := httptest.NewRecorder() + proxy.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("unexpected response %d: %s", response.Code, response.Body.String()) + } + if recorder.event.Model != "audit-model" || recorder.event.ProviderCode != "environment" || recorder.event.StatusCode != 200 || recorder.event.PromptTokens != 9 || recorder.event.CompletionTokens != 4 { + t.Fatalf("unexpected audit event: %#v", recorder.event) + } +} diff --git a/internal/gateway/request_budget.go b/internal/gateway/request_budget.go new file mode 100644 index 0000000..d5f77d9 --- /dev/null +++ b/internal/gateway/request_budget.go @@ -0,0 +1,46 @@ +package gateway + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "net/http" +) + +var errRequestBodyTooLarge = errors.New("request body is too large") + +func prepareTokenBudget(request *http.Request, maxBody int64) (int64, error) { + if request.Body == nil || request.Method == http.MethodGet { + return 0, nil + } + limited := io.LimitReader(request.Body, maxBody+1) + body, err := io.ReadAll(limited) + if err != nil { + return 0, err + } + _ = request.Body.Close() + if int64(len(body)) > maxBody { + return 0, errRequestBodyTooLarge + } + restoreRequestBody(request, body) + + inputEstimate := max(int64((len(body)+3)/4), 1) + if request.URL.Path == "/v1/embeddings" { + return inputEstimate, nil + } + outputBudget := int64(1024) + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.UseNumber() + var payload map[string]any + if decoder.Decode(&payload) == nil { + for _, field := range []string{"max_output_tokens", "max_completion_tokens", "max_tokens"} { + if value := jsonInt64(payload[field]); value > 0 { + outputBudget = value + break + } + } + } + // A per-request ceiling prevents malicious payloads from reserving unbounded Redis counters. + return min(inputEstimate+outputBudget, int64(100_000_000)), nil +} diff --git a/internal/gateway/resilience.go b/internal/gateway/resilience.go new file mode 100644 index 0000000..8b83160 --- /dev/null +++ b/internal/gateway/resilience.go @@ -0,0 +1,149 @@ +package gateway + +import ( + "context" + "errors" + "io" + "net/http" + "sync" + "time" +) + +var ErrCircuitOpen = errors.New("provider circuit is open") + +type ResiliencePolicy struct { + ResponseHeaderTimeout time.Duration + MaxRetries int + RetryBackoff time.Duration + CircuitThreshold int + CircuitOpenDuration time.Duration +} + +func DefaultResiliencePolicy() ResiliencePolicy { + return ResiliencePolicy{ + ResponseHeaderTimeout: 60 * time.Second, MaxRetries: 2, RetryBackoff: 50 * time.Millisecond, + CircuitThreshold: 5, CircuitOpenDuration: 30 * time.Second, + } +} + +type circuitBreaker struct { + mu sync.Mutex + failures int + threshold int + openFor time.Duration + openUntil time.Time + halfOpenRun bool +} + +func newCircuitBreaker(policy ...ResiliencePolicy) *circuitBreaker { + settings := DefaultResiliencePolicy() + if len(policy) > 0 { + settings = policy[0] + } + return &circuitBreaker{threshold: settings.CircuitThreshold, openFor: settings.CircuitOpenDuration} +} + +func (c *circuitBreaker) allow(now time.Time) bool { + c.mu.Lock() + defer c.mu.Unlock() + if c.openUntil.IsZero() { + return true + } + if now.Before(c.openUntil) || c.halfOpenRun { + return false + } + c.halfOpenRun = true + return true +} + +func (c *circuitBreaker) success() { + c.mu.Lock() + c.failures = 0 + c.openUntil = time.Time{} + c.halfOpenRun = false + c.mu.Unlock() +} + +func (c *circuitBreaker) failure(now time.Time) { + c.mu.Lock() + defer c.mu.Unlock() + c.halfOpenRun = false + c.failures++ + if c.failures >= c.threshold || !c.openUntil.IsZero() { + c.openUntil = now.Add(c.openFor) + } +} + +type resilientTransport struct { + base http.RoundTripper + circuit *circuitBreaker + maxRetries int + backoff time.Duration +} + +func (t *resilientTransport) RoundTrip(request *http.Request) (*http.Response, error) { + if !t.circuit.allow(time.Now()) { + return nil, ErrCircuitOpen + } + replayable := request.Method == http.MethodGet || request.Method == http.MethodHead || + request.Header.Get("Idempotency-Key") != "" && request.GetBody != nil + attempts := 1 + if replayable { + attempts += t.maxRetries + } + var response *http.Response + var err error + for attempt := 0; attempt < attempts; attempt++ { + current := request + if attempt > 0 { + if waitErr := waitBackoff(request.Context(), t.backoff*time.Duration(attempt)); waitErr != nil { + err = waitErr + break + } + current = request.Clone(request.Context()) + if request.Body != nil && request.Body != http.NoBody { + body, bodyErr := request.GetBody() + if bodyErr != nil { + err = bodyErr + break + } + current.Body = body + } + } + response, err = t.base.RoundTrip(current) + if !retryableResult(response, err) || attempt == attempts-1 { + break + } + if response != nil { + _, _ = io.CopyN(io.Discard, response.Body, 4096) + _ = response.Body.Close() + } + } + if retryableResult(response, err) { + t.circuit.failure(time.Now()) + } else { + t.circuit.success() + } + return response, err +} + +func retryableResult(response *http.Response, err error) bool { + if err != nil { + return !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) + } + return response != nil && (response.StatusCode == http.StatusBadGateway || response.StatusCode == http.StatusServiceUnavailable || response.StatusCode == http.StatusGatewayTimeout) +} + +func waitBackoff(ctx context.Context, duration time.Duration) error { + if duration <= 0 { + return nil + } + timer := time.NewTimer(duration) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} diff --git a/internal/gateway/resilience_test.go b/internal/gateway/resilience_test.go new file mode 100644 index 0000000..2e4ef16 --- /dev/null +++ b/internal/gateway/resilience_test.go @@ -0,0 +1,61 @@ +package gateway + +import ( + "errors" + "io" + "net/http" + "strings" + "testing" + "time" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) } + +func TestResilientTransportRetriesReplayableRequest(t *testing.T) { + attempts := 0 + transport := &resilientTransport{base: roundTripFunc(func(*http.Request) (*http.Response, error) { + attempts++ + if attempts < 3 { + return &http.Response{StatusCode: http.StatusServiceUnavailable, Body: io.NopCloser(strings.NewReader("busy")), Header: make(http.Header)}, nil + } + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("ok")), Header: make(http.Header)}, nil + }), circuit: newCircuitBreaker(), maxRetries: 2} + request, _ := http.NewRequest(http.MethodGet, "https://provider.example/v1/models", nil) + response, err := transport.RoundTrip(request) + if err != nil || response.StatusCode != http.StatusOK || attempts != 3 { + t.Fatalf("response=%v err=%v attempts=%d", response, err, attempts) + } +} + +func TestResilientTransportDoesNotRetryUnsafePost(t *testing.T) { + attempts := 0 + transport := &resilientTransport{base: roundTripFunc(func(*http.Request) (*http.Response, error) { + attempts++ + return nil, errors.New("network failure") + }), circuit: newCircuitBreaker(), maxRetries: 2} + request, _ := http.NewRequest(http.MethodPost, "https://provider.example/v1/chat/completions", strings.NewReader("{}")) + _, _ = transport.RoundTrip(request) + if attempts != 1 { + t.Fatalf("unsafe POST attempted %d times", attempts) + } +} + +func TestCircuitOpensAndAllowsSingleProbe(t *testing.T) { + circuit := newCircuitBreaker() + circuit.threshold = 2 + circuit.openFor = time.Millisecond + now := time.Now() + circuit.failure(now) + circuit.failure(now) + if circuit.allow(now) { + t.Fatal("open circuit allowed request") + } + if !circuit.allow(now.Add(2 * time.Millisecond)) { + t.Fatal("circuit did not allow half-open probe") + } + if circuit.allow(now.Add(2 * time.Millisecond)) { + t.Fatal("circuit allowed concurrent half-open probe") + } +} diff --git a/internal/gateway/routing.go b/internal/gateway/routing.go new file mode 100644 index 0000000..8d288b0 --- /dev/null +++ b/internal/gateway/routing.go @@ -0,0 +1,76 @@ +package gateway + +import ( + "bytes" + "encoding/json" + "io" + "net/http" +) + +type ModelRouteQuery struct { + ProviderCode string + Model string + Endpoint string + APIKeyID string + TenantID string + Seed string +} + +type ModelRouteResult struct { + ResolvedAdapter + TargetModel string + Matched bool + Known bool +} + +type ModelRouteResolver interface { + ModelRoutingEnabled() bool + ResolveModelRoute(ModelRouteQuery) (ModelRouteResult, error) +} + +type modelRequest struct { + body map[string]json.RawMessage + model string +} + +func readModelRequest(request *http.Request, maxBody int64) (modelRequest, error) { + if request.Body == nil || request.Method == http.MethodGet { + return modelRequest{}, nil + } + body, err := io.ReadAll(io.LimitReader(request.Body, maxBody+1)) + if err != nil { + return modelRequest{}, err + } + _ = request.Body.Close() + if int64(len(body)) > maxBody { + return modelRequest{}, errRequestBodyTooLarge + } + restoreRequestBody(request, body) + var payload map[string]json.RawMessage + if json.Unmarshal(body, &payload) != nil { + return modelRequest{}, nil + } + var model string + _ = json.Unmarshal(payload["model"], &model) + return modelRequest{body: payload, model: model}, nil +} + +func (m modelRequest) rewrite(request *http.Request, target string) error { + if len(m.body) == 0 || target == "" || target == m.model { + return nil + } + encodedModel, _ := json.Marshal(target) + m.body["model"] = encodedModel + body, err := json.Marshal(m.body) + if err != nil { + return err + } + restoreRequestBody(request, body) + return nil +} + +func restoreRequestBody(request *http.Request, body []byte) { + request.Body = io.NopCloser(bytes.NewReader(body)) + request.ContentLength = int64(len(body)) + request.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(body)), nil } +} diff --git a/internal/gateway/token_quota.go b/internal/gateway/token_quota.go new file mode 100644 index 0000000..3468f5d --- /dev/null +++ b/internal/gateway/token_quota.go @@ -0,0 +1,129 @@ +package gateway + +import ( + "context" + "errors" + "fmt" + "strconv" + "time" + + "aigateway.local/core/internal/apikey" + "github.com/redis/go-redis/v9" +) + +var ErrTokenQuotaUnavailable = errors.New("token quota unavailable") + +type TokenReservation struct { + Allowed bool + APIKeyID string + CounterKey string + Reserved int64 + Limit int64 + Remaining int64 + ResetAt time.Time +} + +type TokenQuotaController interface { + Reserve(context.Context, apikey.Principal, int64, time.Time) (TokenReservation, error) + Commit(context.Context, TokenReservation, int64) error +} + +type RedisTokenQuotaController struct { + client *redis.Client + reserveScript *redis.Script + commitScript *redis.Script +} + +func NewRedisTokenQuotaController(client *redis.Client) *RedisTokenQuotaController { + return &RedisTokenQuotaController{ + client: client, reserveScript: redis.NewScript(tokenReserveScript), commitScript: redis.NewScript(tokenCommitScript), + } +} + +func (c *RedisTokenQuotaController) Reserve(ctx context.Context, principal apikey.Principal, estimate int64, now time.Time) (TokenReservation, error) { + if principal.APIKeyID == "" { + return TokenReservation{Allowed: true}, nil + } + if estimate < 0 { + estimate = 0 + } + // Accounts without a monthly token quota never need a reservation and must + // not create a pointless monthly counter key in Redis. + if principal.MonthlyTokenQuota == 0 { + return TokenReservation{Allowed: true}, nil + } + if c == nil || c.client == nil { + return TokenReservation{}, ErrTokenQuotaUnavailable + } + now = now.UTC() + reset := time.Date(now.Year(), now.Month()+1, 1, 0, 0, 0, 0, time.UTC) + key := apikey.MonthlyTokenUsageKey(principal.APIKeyID, now) + result, err := c.reserveScript.Run(ctx, c.client, []string{key}, estimate, principal.MonthlyTokenQuota, int64(reset.Sub(now).Seconds())+86400).Slice() + if err != nil || len(result) != 2 { + if principal.MonthlyTokenQuota == 0 { + return TokenReservation{Allowed: true}, nil + } + return TokenReservation{}, fmt.Errorf("%w: %v", ErrTokenQuotaUnavailable, err) + } + allowed, err := redisInteger(result[0]) + if err != nil { + return TokenReservation{}, ErrTokenQuotaUnavailable + } + current, err := redisInteger(result[1]) + if err != nil { + return TokenReservation{}, ErrTokenQuotaUnavailable + } + return TokenReservation{ + Allowed: allowed == 1, APIKeyID: principal.APIKeyID, CounterKey: key, Reserved: estimate, + Limit: principal.MonthlyTokenQuota, Remaining: max(principal.MonthlyTokenQuota-current, 0), ResetAt: reset, + }, nil +} + +func (c *RedisTokenQuotaController) Commit(ctx context.Context, reservation TokenReservation, actual int64) error { + if c == nil || c.client == nil || reservation.CounterKey == "" || !reservation.Allowed { + return nil + } + if actual < 0 { + actual = 0 + } + if _, err := c.commitScript.Run(ctx, c.client, []string{reservation.CounterKey}, actual-reservation.Reserved).Result(); err != nil { + return fmt.Errorf("%w: %v", ErrTokenQuotaUnavailable, err) + } + return nil +} + +const tokenReserveScript = ` +local estimate = tonumber(ARGV[1]) +local quota = tonumber(ARGV[2]) +local current = tonumber(redis.call('GET', KEYS[1]) or '0') +if quota > 0 and current + estimate > quota then + return {0, current} +end +if estimate > 0 then + current = redis.call('INCRBY', KEYS[1], estimate) + if current == estimate then redis.call('EXPIRE', KEYS[1], tonumber(ARGV[3])) end +elseif redis.call('EXISTS', KEYS[1]) == 0 then + redis.call('SET', KEYS[1], 0, 'EX', tonumber(ARGV[3])) +end +return {1, current} +` + +const tokenCommitScript = ` +local delta = tonumber(ARGV[1]) +local current = tonumber(redis.call('GET', KEYS[1]) or '0') +local updated = current + delta +if updated < 0 then updated = 0 end +redis.call('SET', KEYS[1], updated, 'KEEPTTL') +return updated +` + +func writeTokenQuotaHeaders(header mapHeader, reservation TokenReservation) { + if reservation.Limit <= 0 || reservation.ResetAt.IsZero() { + return + } + header.Set("X-TokenLimit-Limit", strconv.FormatInt(reservation.Limit, 10)) + header.Set("X-TokenLimit-Remaining", strconv.FormatInt(max(reservation.Remaining, 0), 10)) + header.Set("X-TokenLimit-Reset", strconv.FormatInt(reservation.ResetAt.Unix(), 10)) +} + +type mapHeader interface{ Set(string, string) } diff --git a/internal/gateway/usage.go b/internal/gateway/usage.go new file mode 100644 index 0000000..fec04cf --- /dev/null +++ b/internal/gateway/usage.go @@ -0,0 +1,261 @@ +package gateway + +import ( + "bytes" + "context" + "encoding/json" + "io" + "log/slog" + "mime" + "net/http" + "strings" + "sync" + "time" +) + +// maxUsageDocumentBytes bounds the buffered tail of a non-streaming response. +// The window is kept from the end of the body (where providers place the +// "usage" object), so token accounting stays correct for responses far larger +// than this bound while memory use stays bounded per in-flight response. +const maxUsageDocumentBytes = 2 << 20 + +type usageSession struct { + controller TokenQuotaController + reservation TokenReservation + fallback int64 + onFinish func(TokenUsage) + once sync.Once + log *slog.Logger +} + +type TokenUsage struct { + Input int64 + Output int64 + Total int64 +} + +type usageSessionContextKey struct{} + +func withUsageSession(request *http.Request, session *usageSession) *http.Request { + return request.WithContext(context.WithValue(request.Context(), usageSessionContextKey{}, session)) +} + +func usageSessionFrom(request *http.Request) *usageSession { + session, _ := request.Context().Value(usageSessionContextKey{}).(*usageSession) + return session +} + +func (s *usageSession) finish(usage TokenUsage) { + if s == nil { + return + } + s.once.Do(func() { + usage.Total = max(usage.Total, usage.Input+usage.Output) + if usage.Total <= 0 { + usage.Total = s.fallback + } + if s.controller != nil { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + if err := s.controller.Commit(ctx, s.reservation, usage.Total); err != nil && s.log != nil { + // The reservation already counted towards the quota, so a + // failed reconciliation silently leaves the counter slightly + // off. Surface it instead of dropping it. + s.log.Warn("token quota commit failed", "error", err) + } + cancel() + } + if s.onFinish != nil { + s.onFinish(usage) + } + }) +} + +type usageReadCloser struct { + io.ReadCloser + collector *usageCollector + session *usageSession +} + +func newUsageReadCloser(body io.ReadCloser, contentType string, session *usageSession) io.ReadCloser { + mediaType, _, _ := mime.ParseMediaType(contentType) + return &usageReadCloser{ReadCloser: body, collector: &usageCollector{sse: mediaType == "text/event-stream"}, session: session} +} + +func (r *usageReadCloser) Read(buffer []byte) (int, error) { + n, err := r.ReadCloser.Read(buffer) + if n > 0 { + r.collector.feed(buffer[:n]) + } + if err == io.EOF { + r.session.finish(r.collector.usage()) + } + return n, err +} + +func (r *usageReadCloser) Close() error { + r.session.finish(r.collector.usage()) + return r.ReadCloser.Close() +} + +type usageCollector struct { + sse bool + pending []byte + doc []byte + input int64 + output int64 + total int64 +} + +func (c *usageCollector) feed(chunk []byte) { + if !c.sse { + c.doc = append(c.doc, chunk...) + // Keep only a bounded tail window. The "usage" member lives at the end + // of a non-streaming response, so dropping the head (never the tail) + // preserves accounting for arbitrarily large bodies at a fixed memory + // cost instead of truncating usage away. + if len(c.doc) > maxUsageDocumentBytes { + c.doc = append([]byte(nil), c.doc[len(c.doc)-maxUsageDocumentBytes:]...) + } + return + } + c.pending = append(c.pending, chunk...) + for { + index := bytes.IndexByte(c.pending, '\n') + if index < 0 { + if len(c.pending) > maxUsageDocumentBytes { + c.pending = c.pending[:0] + } + return + } + line := strings.TrimSpace(string(c.pending[:index])) + c.pending = c.pending[index+1:] + if strings.HasPrefix(line, "data:") { + payload := strings.TrimSpace(strings.TrimPrefix(line, "data:")) + if payload != "" && payload != "[DONE]" { + c.consumeJSON([]byte(payload)) + } + } + } +} + +func (c *usageCollector) tokens() int64 { + return c.usage().Total +} + +func (c *usageCollector) usage() TokenUsage { + if c.sse && len(c.pending) > 0 { + line := strings.TrimSpace(string(c.pending)) + if strings.HasPrefix(line, "data:") { + c.consumeJSON([]byte(strings.TrimSpace(strings.TrimPrefix(line, "data:")))) + } + c.pending = nil + } + if !c.sse && len(c.doc) > 0 { + c.consumeJSON(c.doc) // fast path: whole valid JSON object + c.consumeUsageObject(c.doc) // tail extraction: covers truncated bodies + } + return TokenUsage{Input: c.input, Output: c.output, Total: max(c.total, c.input+c.output)} +} + +// consumeUsageObject extracts the trailing `"usage"` object from a possibly +// truncated response document. It scans for the final `"usage"` member and +// decodes the JSON object that immediately follows — where OpenAI-compatible +// providers place token accounting in non-streaming responses — so usage is +// still counted when the buffered window starts mid-object. +func (c *usageCollector) consumeUsageObject(doc []byte) { + const key = `"usage"` + index := bytes.LastIndex(doc, []byte(key)) + if index < 0 { + return + } + rest := doc[index+len(key):] + colon := bytes.IndexByte(rest, ':') + if colon < 0 { + return + } + rest = bytes.TrimSpace(rest[colon+1:]) + if len(rest) == 0 || rest[0] != '{' { + return + } + depth := 0 + end := -1 + inString := false + escaped := false + for i := 0; i < len(rest); i++ { + ch := rest[i] + if inString { + if escaped { + escaped = false + } else if ch == '\\' { + escaped = true + } else if ch == '"' { + inString = false + } + continue + } + switch ch { + case '"': + inString = true + case '{': + depth++ + case '}': + depth-- + if depth == 0 { + end = i + 1 + } + } + if end > 0 { + break + } + } + if end > 0 { + c.consumeJSON(rest[:end]) + } +} + +func (c *usageCollector) consumeJSON(payload []byte) { + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.UseNumber() + var value any + if decoder.Decode(&value) == nil { + c.walk(value, false) + } +} + +func (c *usageCollector) walk(value any, inUsage bool) { + switch typed := value.(type) { + case map[string]any: + for key, child := range typed { + usage := inUsage || key == "usage" + if usage { + switch key { + case "input_tokens", "prompt_tokens": + c.input = max(c.input, jsonInt64(child)) + case "output_tokens", "completion_tokens": + c.output = max(c.output, jsonInt64(child)) + case "total_tokens": + c.total = max(c.total, jsonInt64(child)) + } + } + c.walk(child, usage) + } + case []any: + for _, child := range typed { + c.walk(child, inUsage) + } + } +} + +func jsonInt64(value any) int64 { + switch number := value.(type) { + case json.Number: + parsed, _ := number.Int64() + return max(parsed, 0) + case float64: + return max(int64(number), 0) + case int64: + return max(number, 0) + default: + return 0 + } +} diff --git a/internal/gateway/usage_test.go b/internal/gateway/usage_test.go new file mode 100644 index 0000000..b11364f --- /dev/null +++ b/internal/gateway/usage_test.go @@ -0,0 +1,21 @@ +package gateway + +import "testing" + +func TestUsageCollectorReadsOpenAIJSON(t *testing.T) { + collector := &usageCollector{} + collector.feed([]byte(`{"usage":{"prompt_tokens":11,"completion_tokens":7,"total_tokens":18}}`)) + if got := collector.tokens(); got != 18 { + t.Fatalf("got %d, want 18", got) + } +} + +func TestUsageCollectorReadsChunkedSSEAndAnthropicUsage(t *testing.T) { + collector := &usageCollector{sse: true} + collector.feed([]byte("event: message_start\ndata: {\"message\":{\"usage\":{\"input_tokens\":13}}}\n\n")) + collector.feed([]byte("event: message_delta\ndata: {\"usage\":{\"output_")) + collector.feed([]byte("tokens\":9}}\n\ndata: [DONE]\n\n")) + if got := collector.tokens(); got != 22 { + t.Fatalf("got %d, want 22", got) + } +} diff --git a/internal/identity/account.go b/internal/identity/account.go new file mode 100644 index 0000000..52e850b --- /dev/null +++ b/internal/identity/account.go @@ -0,0 +1,128 @@ +package identity + +import ( + "errors" + "sort" + "time" +) + +var ( + ErrNotFound = errors.New("identity not found") + ErrUnavailable = errors.New("identity service unavailable") +) + +type Kind string + +const ( + KindAdmin Kind = "admin" + KindPortal Kind = "portal" +) + +type Account struct { + ID string + Kind Kind + Login string + DisplayName string + Role string + Permissions []string + PasswordHash string + AuthSource string + Active bool + FailedLogins int + LockedUntil *time.Time + TOTPEnabled bool + EncryptedTOTPSecret []byte + TOTPKekVersion *int + TOTPLastStep *int64 + TOTPBackupCodes []byte + DepartmentID *string + DepartmentName string + CreatedAt time.Time + UpdatedAt time.Time +} + +const ( + PermissionIdentityManage = "identity:manage" + PermissionProviderRead = "provider:read" + PermissionProviderManage = "provider:manage" + PermissionAPIKeyRead = "api_key:read" + PermissionAPIKeyManage = "api_key:manage" + PermissionAuditRead = "audit:read" + PermissionUsageRead = "usage:read" + PermissionOutboxRead = "outbox:read" + PermissionOutboxManage = "outbox:manage" + PermissionContentPolicyRead = "content_policy:read" + PermissionContentPolicyManage = "content_policy:manage" + PermissionPricingRead = "pricing:read" + PermissionPricingManage = "pricing:manage" + PermissionPromptRead = "prompt:read" + PermissionPromptManage = "prompt:manage" + PermissionKnowledgeRead = "knowledge:read" + PermissionKnowledgeManage = "knowledge:manage" + PermissionToolRead = "tool:read" + PermissionToolManage = "tool:manage" + PermissionApplicationRead = "application:read" + PermissionApplicationManage = "application:manage" + PermissionNotificationRead = "notification:read" + PermissionNotificationManage = "notification:manage" + PermissionMCPServerRead = "mcp_server:read" + PermissionMCPServerManage = "mcp_server:manage" + PermissionSkillRead = "skill:read" + PermissionSkillManage = "skill:manage" + PermissionDigitalEmployeeRead = "digital_employee:read" + PermissionDigitalEmployeeManage = "digital_employee:manage" + PermissionMarketplaceRead = "marketplace:read" + PermissionMarketplaceManage = "marketplace:manage" +) + +var rolePermissions = map[string][]string{ + "superadmin": {"*"}, + "operator": { + PermissionProviderRead, PermissionProviderManage, + PermissionAPIKeyRead, PermissionAPIKeyManage, + PermissionUsageRead, + PermissionOutboxRead, PermissionOutboxManage, + PermissionContentPolicyRead, PermissionContentPolicyManage, + PermissionPricingRead, PermissionPricingManage, + PermissionPromptRead, PermissionPromptManage, + PermissionKnowledgeRead, PermissionKnowledgeManage, + PermissionToolRead, PermissionToolManage, + PermissionApplicationRead, PermissionApplicationManage, + PermissionNotificationRead, PermissionNotificationManage, + PermissionMCPServerRead, PermissionMCPServerManage, + PermissionSkillRead, PermissionSkillManage, + PermissionDigitalEmployeeRead, PermissionDigitalEmployeeManage, + PermissionMarketplaceRead, PermissionMarketplaceManage, + }, + "auditor": {PermissionProviderRead, PermissionAPIKeyRead, PermissionAuditRead, PermissionUsageRead, PermissionOutboxRead, PermissionContentPolicyRead, PermissionPricingRead, PermissionPromptRead, PermissionKnowledgeRead, PermissionToolRead, PermissionApplicationRead, PermissionNotificationRead, PermissionMCPServerRead, PermissionSkillRead, PermissionDigitalEmployeeRead, PermissionMarketplaceRead}, + "member": {}, +} + +func EffectivePermissions(account Account) []string { + seen := make(map[string]struct{}) + result := make([]string, 0, len(account.Permissions)+4) + for _, permissions := range [][]string{rolePermissions[account.Role], account.Permissions} { + for _, permission := range permissions { + if _, exists := seen[permission]; exists { + continue + } + seen[permission] = struct{}{} + result = append(result, permission) + } + } + sort.Strings(result) + return result +} + +func HasPermission(account Account, required string) bool { + for _, permission := range EffectivePermissions(account) { + if permission == "*" || permission == required { + return true + } + } + return false +} + +func (a Account) Locked(now time.Time) bool { + return a.LockedUntil != nil && a.LockedUntil.After(now) +} diff --git a/internal/identity/departments.go b/internal/identity/departments.go new file mode 100644 index 0000000..7933a0e --- /dev/null +++ b/internal/identity/departments.go @@ -0,0 +1,297 @@ +package identity + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "regexp" + "strings" + "time" + + "aigateway.local/core/internal/platform/apiresponse" + platformid "aigateway.local/core/internal/platform/id" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +var ( + ErrDepartmentConflict = errors.New("department already exists") + ErrDepartmentInUse = errors.New("department is in use") + ErrDepartmentCycle = errors.New("department hierarchy cycle") + departmentCodePattern = regexp.MustCompile(`^[a-z][a-z0-9_-]{1,63}$`) +) + +type Department struct { + ID string `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + Description string `json:"description"` + ParentID *string `json:"parent_id"` + ParentName string `json:"parent_name,omitempty"` + Active bool `json:"active"` + UserCount int `json:"user_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type departmentInput struct { + Code string `json:"code"` + Name string `json:"name"` + Description string `json:"description"` + ParentID *string `json:"parent_id"` + Active *bool `json:"active"` +} + +func (h *ManagementHTTPHandler) listDepartments(writer http.ResponseWriter, request *http.Request) { + if _, ok := h.requirePermission(writer, request); !ok { + return + } + departments, err := h.service.repository.ListDepartments(request.Context()) + if err != nil { + h.writeDepartmentError(writer, err) + return + } + apiresponse.OK(writer, departments) +} + +func (h *ManagementHTTPHandler) createDepartment(writer http.ResponseWriter, request *http.Request) { + actor, ok := h.requirePermission(writer, request) + if !ok { + return + } + input, department, ok := decodeDepartment(writer, request) + if !ok { + return + } + _ = input + created, err := h.service.repository.CreateDepartment(request.Context(), department, actor.ID) + if err != nil { + h.writeDepartmentError(writer, err) + return + } + apiresponse.OK(writer, created) +} + +func (h *ManagementHTTPHandler) updateDepartment(writer http.ResponseWriter, request *http.Request) { + actor, ok := h.requirePermission(writer, request) + if !ok { + return + } + _, department, ok := decodeDepartment(writer, request) + if !ok { + return + } + department.ID = request.PathValue("department_id") + updated, err := h.service.repository.UpdateDepartment(request.Context(), department, actor.ID) + if err != nil { + h.writeDepartmentError(writer, err) + return + } + apiresponse.OK(writer, updated) +} + +func decodeDepartment(writer http.ResponseWriter, request *http.Request) (departmentInput, Department, bool) { + var input departmentInput + decoder := json.NewDecoder(http.MaxBytesReader(writer, request.Body, 1<<20)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&input); err != nil { + apiresponse.Error(writer, http.StatusBadRequest, "请求格式无效") + return input, Department{}, false + } + input.Code = strings.ToLower(strings.TrimSpace(input.Code)) + input.Name = strings.TrimSpace(input.Name) + input.Description = strings.TrimSpace(input.Description) + if !departmentCodePattern.MatchString(input.Code) || input.Name == "" || len(input.Name) > 128 || len(input.Description) > 1024 { + apiresponse.Error(writer, http.StatusBadRequest, "部门代码、名称或描述格式无效") + return input, Department{}, false + } + var parentID *string + if input.ParentID != nil && strings.TrimSpace(*input.ParentID) != "" { + value := strings.TrimSpace(*input.ParentID) + parentID = &value + } + active := true + if input.Active != nil { + active = *input.Active + } + return input, Department{Code: input.Code, Name: input.Name, Description: input.Description, ParentID: parentID, Active: active}, true +} + +func (h *ManagementHTTPHandler) writeDepartmentError(writer http.ResponseWriter, err error) { + switch { + case errors.Is(err, ErrNotFound): + apiresponse.Error(writer, http.StatusNotFound, "部门不存在") + case errors.Is(err, ErrDepartmentConflict): + apiresponse.Error(writer, http.StatusConflict, "部门代码已存在") + case errors.Is(err, ErrDepartmentCycle): + apiresponse.Error(writer, http.StatusConflict, "部门层级不能形成循环") + case errors.Is(err, ErrDepartmentInUse): + apiresponse.Error(writer, http.StatusConflict, "部门仍包含启用用户或启用子部门,不能停用") + case errors.Is(err, ErrUnavailable): + apiresponse.Error(writer, http.StatusServiceUnavailable, "部门服务暂不可用") + default: + apiresponse.Error(writer, http.StatusBadRequest, "部门操作失败") + } +} + +func (r *Repository) ListDepartments(ctx context.Context) ([]Department, error) { + if r.pool == nil { + return nil, ErrUnavailable + } + rows, err := r.pool.Query(ctx, ` + SELECT d.id::text, d.code, d.name, d.description, d.parent_id::text, + COALESCE(p.name, ''), d.active, + count(u.id) FILTER (WHERE u.active), d.created_at, d.updated_at + FROM gateway.departments d + LEFT JOIN gateway.departments p ON p.id = d.parent_id + LEFT JOIN gateway.portal_users u ON u.department_id = d.id + GROUP BY d.id, p.name + ORDER BY d.code`) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrUnavailable, err) + } + defer rows.Close() + departments := make([]Department, 0) + for rows.Next() { + var department Department + if err := rows.Scan(&department.ID, &department.Code, &department.Name, &department.Description, + &department.ParentID, &department.ParentName, &department.Active, &department.UserCount, + &department.CreatedAt, &department.UpdatedAt); err != nil { + return nil, fmt.Errorf("%w: %v", ErrUnavailable, err) + } + departments = append(departments, department) + } + return departments, mapRepositoryError(rows.Err()) +} + +func (r *Repository) GetDepartment(ctx context.Context, id string) (Department, error) { + if r.pool == nil { + return Department{}, ErrUnavailable + } + var department Department + err := r.pool.QueryRow(ctx, ` + SELECT id::text, code, name, description, parent_id::text, active, created_at, updated_at + FROM gateway.departments WHERE id = $1`, id).Scan( + &department.ID, &department.Code, &department.Name, &department.Description, + &department.ParentID, &department.Active, &department.CreatedAt, &department.UpdatedAt, + ) + return department, mapRepositoryError(err) +} + +func (r *Repository) CreateDepartment(ctx context.Context, department Department, actorID string) (Department, error) { + id, err := platformid.NewUUID() + if err != nil { + return Department{}, err + } + department.ID = id + return r.storeDepartment(ctx, department, actorID, true) +} + +func (r *Repository) UpdateDepartment(ctx context.Context, department Department, actorID string) (Department, error) { + return r.storeDepartment(ctx, department, actorID, false) +} + +func (r *Repository) storeDepartment(ctx context.Context, department Department, actorID string, creating bool) (Department, error) { + if r.pool == nil { + return Department{}, ErrUnavailable + } + tx, err := r.pool.Begin(ctx) + if err != nil { + return Department{}, fmt.Errorf("%w: %v", ErrUnavailable, err) + } + defer func() { _ = tx.Rollback(ctx) }() + if department.ParentID != nil { + var parentActive bool + if err := tx.QueryRow(ctx, `SELECT active FROM gateway.departments WHERE id = $1`, *department.ParentID).Scan(&parentActive); err != nil { + return Department{}, mapRepositoryError(err) + } + if !parentActive { + return Department{}, ErrDepartmentInUse + } + } + if !creating && department.ParentID != nil { + var cycle bool + if err := tx.QueryRow(ctx, ` + WITH RECURSIVE descendants AS ( + SELECT id FROM gateway.departments WHERE parent_id = $1 + UNION ALL + SELECT d.id FROM gateway.departments d JOIN descendants x ON d.parent_id = x.id + ) + SELECT $2::uuid = $1::uuid OR EXISTS (SELECT 1 FROM descendants WHERE id = $2)`, + department.ID, *department.ParentID).Scan(&cycle); err != nil { + return Department{}, fmt.Errorf("%w: %v", ErrUnavailable, err) + } + if cycle { + return Department{}, ErrDepartmentCycle + } + } + if !creating && !department.Active { + var inUse bool + if err := tx.QueryRow(ctx, ` + SELECT EXISTS (SELECT 1 FROM gateway.portal_users WHERE department_id = $1 AND active) + OR EXISTS (SELECT 1 FROM gateway.departments WHERE parent_id = $1 AND active)`, department.ID).Scan(&inUse); err != nil { + return Department{}, fmt.Errorf("%w: %v", ErrUnavailable, err) + } + if inUse { + return Department{}, ErrDepartmentInUse + } + } + if creating { + err = tx.QueryRow(ctx, ` + INSERT INTO gateway.departments (id, code, name, description, parent_id, active) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING created_at, updated_at`, department.ID, department.Code, department.Name, + department.Description, department.ParentID, department.Active).Scan(&department.CreatedAt, &department.UpdatedAt) + } else { + err = tx.QueryRow(ctx, ` + UPDATE gateway.departments + SET code = $2, name = $3, description = $4, parent_id = $5, + active = $6, updated_at = clock_timestamp() + WHERE id = $1 + RETURNING created_at, updated_at`, department.ID, department.Code, department.Name, + department.Description, department.ParentID, department.Active).Scan(&department.CreatedAt, &department.UpdatedAt) + } + if err != nil { + return Department{}, mapDepartmentError(err) + } + eventID, err := platformid.NewUUID() + if err != nil { + return Department{}, err + } + eventType := "department.updated" + if creating { + eventType = "department.created" + } + payload, _ := json.Marshal(map[string]any{"department_id": department.ID, "code": department.Code, "actor_id": actorID}) + if _, err := tx.Exec(ctx, ` + INSERT INTO gateway.outbox_events + (event_id, event_type, event_version, aggregate_type, aggregate_id, payload) + VALUES ($1, $2, 1, 'department', $3, $4)`, eventID, eventType, department.ID, payload); err != nil { + return Department{}, fmt.Errorf("%w: %v", ErrUnavailable, err) + } + if err := tx.Commit(ctx); err != nil { + return Department{}, fmt.Errorf("%w: %v", ErrUnavailable, err) + } + return department, nil +} + +func mapDepartmentError(err error) error { + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + var pgError *pgconn.PgError + if errors.As(err, &pgError) { + switch pgError.Code { + case "23505": + return ErrDepartmentConflict + case "23503", "23514": + return ErrDepartmentCycle + } + } + if err != nil { + return fmt.Errorf("%w: %v", ErrUnavailable, err) + } + return nil +} diff --git a/internal/identity/http.go b/internal/identity/http.go new file mode 100644 index 0000000..0c56dd8 --- /dev/null +++ b/internal/identity/http.go @@ -0,0 +1,429 @@ +package identity + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + "time" + + "aigateway.local/core/internal/platform/apiresponse" + "aigateway.local/core/internal/platform/cryptox" +) + +type HTTPHandler struct { + service *Service + mux *http.ServeMux +} + +type loginRequest struct { + UserName string `json:"userName"` + Username string `json:"username"` + Account string `json:"account"` + Password string `json:"password"` +} + +type totpLoginRequest struct { + TempToken string `json:"temp_token"` + Code string `json:"code"` + BackupCode string `json:"backup_code"` +} + +type passwordRequest struct { + Password string `json:"password"` +} + +type factorRequest struct { + Password string `json:"password"` + Code string `json:"code"` + BackupCode string `json:"backup_code"` +} + +func NewHTTPHandler(service *Service) *HTTPHandler { + handler := &HTTPHandler{service: service, mux: http.NewServeMux()} + handler.mux.HandleFunc("POST /api/v1/admin/login", handler.login(KindAdmin)) + handler.registerTOTP(KindAdmin, "/api/v1/admin") + handler.mux.HandleFunc("GET /api/v1/admin/whoami", handler.whoami(KindAdmin)) + handler.mux.HandleFunc("POST /api/v1/admin/password", handler.changePassword(KindAdmin)) + handler.mux.HandleFunc("POST /api/v1/admin/logout", handler.logout) + handler.mux.HandleFunc("GET /api/v1/admin/menus", handler.menus(KindAdmin)) + handler.mux.HandleFunc("POST /api/v1/portal/login", handler.login(KindPortal)) + handler.registerTOTP(KindPortal, "/api/v1/portal") + handler.mux.HandleFunc("GET /api/v1/portal/me", handler.whoami(KindPortal)) + handler.mux.HandleFunc("POST /api/v1/portal/logout", handler.logout) + handler.mux.HandleFunc("GET /api/v1/portal/menus", handler.menus(KindPortal)) + handler.registerOIDC() + return handler +} + +func (h *HTTPHandler) changePassword(kind Kind) http.HandlerFunc { + return func(writer http.ResponseWriter, request *http.Request) { + account, ok := h.requireAccount(writer, request, kind) + if !ok { + return + } + var input struct { + OldPassword string `json:"old_password"` + NewPassword string `json:"new_password"` + } + if !decodeJSON(writer, request, &input) { + apiresponse.Error(writer, http.StatusBadRequest, "请求格式无效") + return + } + if err := h.service.ChangePassword(request.Context(), account, input.OldPassword, input.NewPassword); err != nil { + h.writeIdentityError(writer, err) + return + } + apiresponse.OK(writer, map[string]bool{"changed": true}) + } +} + +func (h *HTTPHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) { + h.mux.ServeHTTP(writer, request) +} + +func (h *HTTPHandler) login(kind Kind) http.HandlerFunc { + return func(writer http.ResponseWriter, request *http.Request) { + // 防爆破:按 IP 的滑动窗口限流,超限返回 429(与账号锁定叠加)。 + if !h.service.AllowLogin(request.Context(), ClientIP(request)) { + apiresponse.Error(writer, http.StatusTooManyRequests, "登录尝试过于频繁,请稍后再试") + return + } + var input loginRequest + decoder := json.NewDecoder(http.MaxBytesReader(writer, request.Body, 1<<20)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&input); err != nil { + apiresponse.Error(writer, http.StatusBadRequest, "请求格式无效") + return + } + login := strings.TrimSpace(input.UserName) + if login == "" { + login = strings.TrimSpace(input.Username) + } + if login == "" { + login = strings.TrimSpace(input.Account) + } + if login == "" || len(login) > 128 || len(input.Password) < 1 || len(input.Password) > 1024 { + apiresponse.Error(writer, http.StatusBadRequest, "账号或口令格式无效") + return + } + result, err := h.service.Login(request.Context(), kind, login, input.Password) + if err != nil { + h.writeIdentityError(writer, err) + return + } + apiresponse.OK(writer, map[string]any{ + "token": result.Token, "refreshToken": "", "require_totp": result.RequireTOTP, + "temp_token": result.TempToken, + }) + } +} + +func (h *HTTPHandler) registerTOTP(kind Kind, prefix string) { + h.mux.HandleFunc("POST "+prefix+"/login/totp", h.completeTOTPLogin(kind)) + h.mux.HandleFunc("GET "+prefix+"/totp/status", h.totpStatus(kind)) + h.mux.HandleFunc("POST "+prefix+"/totp/setup", h.setupTOTP(kind)) + h.mux.HandleFunc("POST "+prefix+"/totp/confirm", h.confirmTOTP(kind)) + h.mux.HandleFunc("POST "+prefix+"/totp/disable", h.disableTOTP(kind)) + h.mux.HandleFunc("POST "+prefix+"/totp/backup-codes/regenerate", h.regenerateBackupCodes(kind)) +} + +func (h *HTTPHandler) completeTOTPLogin(kind Kind) http.HandlerFunc { + return func(writer http.ResponseWriter, request *http.Request) { + // 防爆破:TOTP 完成端点同样按 IP 限流。 + if !h.service.AllowLogin(request.Context(), ClientIP(request)) { + apiresponse.Error(writer, http.StatusTooManyRequests, "登录尝试过于频繁,请稍后再试") + return + } + var input totpLoginRequest + if !decodeJSON(writer, request, &input) || strings.TrimSpace(input.TempToken) == "" || (strings.TrimSpace(input.Code) == "" && strings.TrimSpace(input.BackupCode) == "") { + apiresponse.Error(writer, http.StatusBadRequest, "请输入动态验证码或备用码") + return + } + result, err := h.service.CompleteTOTPLogin(request.Context(), kind, input.TempToken, input.Code, input.BackupCode) + if err != nil { + h.writeIdentityError(writer, err) + return + } + apiresponse.OK(writer, map[string]any{"token": result.Token, "refreshToken": "", "require_totp": false}) + } +} + +func (h *HTTPHandler) totpStatus(kind Kind) http.HandlerFunc { + return func(writer http.ResponseWriter, request *http.Request) { + account, ok := h.requireAccount(writer, request, kind) + if !ok { + return + } + apiresponse.OK(writer, map[string]bool{"enabled": account.TOTPEnabled, "setup_pending": !account.TOTPEnabled && len(account.EncryptedTOTPSecret) > 0}) + } +} + +func (h *HTTPHandler) setupTOTP(kind Kind) http.HandlerFunc { + return func(writer http.ResponseWriter, request *http.Request) { + account, ok := h.requireAccount(writer, request, kind) + if !ok { + return + } + var input passwordRequest + if !decodeJSON(writer, request, &input) || input.Password == "" { + apiresponse.Error(writer, http.StatusBadRequest, "请输入当前口令") + return + } + result, err := h.service.SetupTOTP(request.Context(), account, input.Password) + if err != nil { + h.writeIdentityError(writer, err) + return + } + apiresponse.OK(writer, map[string]string{"secret": result.Secret, "provisioning_uri": result.ProvisioningURI}) + } +} + +func (h *HTTPHandler) confirmTOTP(kind Kind) http.HandlerFunc { + return func(writer http.ResponseWriter, request *http.Request) { + account, ok := h.requireAccount(writer, request, kind) + if !ok { + return + } + var input factorRequest + if !decodeJSON(writer, request, &input) || strings.TrimSpace(input.Code) == "" { + apiresponse.Error(writer, http.StatusBadRequest, "请输入动态验证码") + return + } + codes, err := h.service.ConfirmTOTP(request.Context(), account, input.Code) + if err != nil { + h.writeIdentityError(writer, err) + return + } + apiresponse.OK(writer, map[string]any{"enabled": true, "backup_codes": codes}) + } +} + +func (h *HTTPHandler) disableTOTP(kind Kind) http.HandlerFunc { + return func(writer http.ResponseWriter, request *http.Request) { + account, ok := h.requireAccount(writer, request, kind) + if !ok { + return + } + var input factorRequest + if !decodeJSON(writer, request, &input) || input.Password == "" { + apiresponse.Error(writer, http.StatusBadRequest, "当前口令和验证因子不能为空") + return + } + if err := h.service.DisableTOTP(request.Context(), account, input.Password, input.Code, input.BackupCode); err != nil { + h.writeIdentityError(writer, err) + return + } + apiresponse.OK(writer, map[string]bool{"enabled": false}) + } +} + +func (h *HTTPHandler) regenerateBackupCodes(kind Kind) http.HandlerFunc { + return func(writer http.ResponseWriter, request *http.Request) { + account, ok := h.requireAccount(writer, request, kind) + if !ok { + return + } + var input factorRequest + if !decodeJSON(writer, request, &input) || input.Password == "" { + apiresponse.Error(writer, http.StatusBadRequest, "当前口令和验证因子不能为空") + return + } + codes, err := h.service.RegenerateBackupCodes(request.Context(), account, input.Password, input.Code, input.BackupCode) + if err != nil { + h.writeIdentityError(writer, err) + return + } + apiresponse.OK(writer, map[string]any{"backup_codes": codes}) + } +} + +func (h *HTTPHandler) requireAccount(writer http.ResponseWriter, request *http.Request, kind Kind) (Account, bool) { + account, err := h.service.Authenticate(request.Context(), kind, request.Header.Get("Authorization")) + if err != nil { + h.writeIdentityError(writer, err) + return Account{}, false + } + return account, true +} + +func decodeJSON(writer http.ResponseWriter, request *http.Request, target any) bool { + decoder := json.NewDecoder(http.MaxBytesReader(writer, request.Body, 1<<20)) + decoder.DisallowUnknownFields() + return decoder.Decode(target) == nil +} + +func (h *HTTPHandler) whoami(kind Kind) http.HandlerFunc { + return func(writer http.ResponseWriter, request *http.Request) { + account, err := h.service.Authenticate(request.Context(), kind, request.Header.Get("Authorization")) + if err != nil { + h.writeIdentityError(writer, err) + return + } + roles := []string{"R_USER"} + if kind == KindAdmin { + roles = []string{"R_ADMIN"} + if account.Role == "superadmin" { + roles = []string{"R_SUPER"} + } + } + permissions := EffectivePermissions(account) + apiresponse.OK(writer, map[string]any{ + "userId": account.ID, "userName": account.Login, + "displayName": account.DisplayName, "email": "", + "roles": roles, "buttons": permissions, "permissions": permissions, "role": account.Role, + }) + } +} + +func (h *HTTPHandler) logout(writer http.ResponseWriter, request *http.Request) { + if err := h.service.Logout(request.Context(), request.Header.Get("Authorization")); err != nil && !errors.Is(err, ErrInvalidSession) { + h.writeIdentityError(writer, err) + return + } + apiresponse.OK(writer, map[string]bool{"ok": true}) +} + +func (h *HTTPHandler) menus(kind Kind) http.HandlerFunc { + return func(writer http.ResponseWriter, request *http.Request) { + account, err := h.service.Authenticate(request.Context(), kind, request.Header.Get("Authorization")) + if err != nil { + h.writeIdentityError(writer, err) + return + } + if kind == KindAdmin { + apiresponse.OK(writer, adminMenus(account)) + return + } + apiresponse.OK(writer, portalMenus()) + } +} + +func (h *HTTPHandler) writeIdentityError(writer http.ResponseWriter, err error) { + var locked LockedError + switch { + case errors.As(err, &locked): + minutes := int(time.Until(locked.Until).Minutes()) + 1 + apiresponse.Error(writer, http.StatusTooManyRequests, "账号已锁定,请在 "+(time.Duration(minutes)*time.Minute).String()+" 后重试") + case errors.Is(err, ErrInvalidCredentials): + apiresponse.Error(writer, http.StatusUnauthorized, "账号或口令错误") + case errors.Is(err, ErrInvalidSession), errors.Is(err, ErrNotFound): + apiresponse.Error(writer, http.StatusUnauthorized, "登录状态无效或已过期") + case errors.Is(err, ErrAccountDisabled): + apiresponse.Error(writer, http.StatusForbidden, "账号已被停用") + case errors.Is(err, ErrInvalidTOTP): + apiresponse.Error(writer, http.StatusUnauthorized, "动态验证码无效、已使用或备用码无效") + case errors.Is(err, ErrTOTPAlreadyEnabled): + apiresponse.Error(writer, http.StatusConflict, "两步验证已经启用") + case errors.Is(err, ErrTOTPNotEnabled), errors.Is(err, ErrTOTPSetupRequired): + apiresponse.Error(writer, http.StatusConflict, "两步验证尚未完成配置") + case errors.Is(err, cryptox.ErrKeyUnavailable): + apiresponse.Error(writer, http.StatusServiceUnavailable, "两步验证加密密钥不可用") + case errors.Is(err, ErrUnavailable): + apiresponse.Error(writer, http.StatusServiceUnavailable, "身份服务暂不可用") + default: + apiresponse.Error(writer, http.StatusInternalServerError, "身份服务处理失败") + } +} + +func adminMenus(account Account) []map[string]any { + // 运行概览:首页仪表盘(叶子菜单,避免出现「运行概览>运行概览」同级冗余)。 + menus := []map[string]any{ + {"name": "Dashboard", "path": "/dashboard/console", "component": "/dashboard/console", "meta": map[string]any{"title": "运行概览", "icon": "ri:pie-chart-line", "fixedTab": true}}, + } + + // 网关接入:上游供应商、路由与凭据。 + gatewayChildren := make([]map[string]any, 0, 4) + if HasPermission(account, PermissionProviderRead) || HasPermission(account, PermissionProviderManage) { + gatewayChildren = append(gatewayChildren, map[string]any{"name": "Providers", "path": "providers", "component": "/gateway/providers", "meta": map[string]any{"title": "模型供应商"}}) + gatewayChildren = append(gatewayChildren, map[string]any{"name": "ModelRoutes", "path": "model-routes", "component": "/gateway/model-routes", "meta": map[string]any{"title": "模型路由"}}) + } + if HasPermission(account, PermissionAPIKeyRead) || HasPermission(account, PermissionAPIKeyManage) { + gatewayChildren = append(gatewayChildren, map[string]any{"name": "APIKeys", "path": "api-keys", "component": "/gateway/api-keys", "meta": map[string]any{"title": "API Key"}}) + } + if HasPermission(account, PermissionPricingRead) || HasPermission(account, PermissionPricingManage) { + gatewayChildren = append(gatewayChildren, map[string]any{"name": "ModelPrices", "path": "model-prices", "component": "/gateway/model-prices", "meta": map[string]any{"title": "模型价格"}}) + } + if len(gatewayChildren) > 0 { + menus = append(menus, map[string]any{"name": "Gateway", "path": "/gateway", "component": "/index/index", "meta": map[string]any{"title": "网关接入", "icon": "ri:router-line"}, "children": gatewayChildren}) + } + + // 安全与审计:审计用量、内容策略与模型治理。 + securityChildren := make([]map[string]any, 0, 3) + if HasPermission(account, PermissionAuditRead) || HasPermission(account, PermissionUsageRead) { + securityChildren = append(securityChildren, map[string]any{"name": "AuditUsage", "path": "audit-usage", "component": "/gateway/audit-usage", "meta": map[string]any{"title": "审计与用量"}}) + } + if HasPermission(account, PermissionContentPolicyRead) || HasPermission(account, PermissionContentPolicyManage) { + securityChildren = append(securityChildren, map[string]any{"name": "ContentPolicies", "path": "content-policies", "component": "/gateway/content-policies", "meta": map[string]any{"title": "内容策略"}}) + } + if HasPermission(account, PermissionKnowledgeRead) || HasPermission(account, PermissionKnowledgeManage) { + securityChildren = append(securityChildren, map[string]any{"name": "Governance", "path": "governance", "component": "/gateway/governance", "meta": map[string]any{"title": "模型治理"}}) + } + if len(securityChildren) > 0 { + menus = append(menus, map[string]any{"name": "Security", "path": "/security", "component": "/index/index", "meta": map[string]any{"title": "安全与审计", "icon": "ri:shield-check-line"}, "children": securityChildren}) + } + + // AI 资产:Prompt、知识库、工具与 AI 应用。 + assetsChildren := make([]map[string]any, 0, 4) + if HasPermission(account, PermissionPromptRead) || HasPermission(account, PermissionPromptManage) { + assetsChildren = append(assetsChildren, map[string]any{"name": "Prompts", "path": "prompts", "component": "/gateway/prompts", "meta": map[string]any{"title": "Prompt 资产"}}) + } + if HasPermission(account, PermissionKnowledgeRead) || HasPermission(account, PermissionKnowledgeManage) { + assetsChildren = append(assetsChildren, map[string]any{"name": "Knowledge", "path": "knowledge", "component": "/gateway/knowledge", "meta": map[string]any{"title": "知识库"}}) + } + if HasPermission(account, PermissionToolRead) || HasPermission(account, PermissionToolManage) { + assetsChildren = append(assetsChildren, map[string]any{"name": "Tools", "path": "tools", "component": "/gateway/tools", "meta": map[string]any{"title": "工具中心"}}) + } + if HasPermission(account, PermissionApplicationRead) || HasPermission(account, PermissionApplicationManage) { + assetsChildren = append(assetsChildren, map[string]any{"name": "Applications", "path": "applications", "component": "/gateway/applications", "meta": map[string]any{"title": "AI 应用"}}) + } + if len(assetsChildren) > 0 { + menus = append(menus, map[string]any{"name": "Assets", "path": "/assets", "component": "/index/index", "meta": map[string]any{"title": "AI 资产", "icon": "ri:box-3-line"}, "children": assetsChildren}) + } + + // 资源市场:MCP 服务器、Skills 与数字员工(旗舰版资源市场)。 + marketChildren := make([]map[string]any, 0, 4) + if HasPermission(account, PermissionMarketplaceRead) || HasPermission(account, PermissionMarketplaceManage) { + marketChildren = append(marketChildren, map[string]any{"name": "Marketplace", "path": "overview", "component": "/gateway/marketplace", "meta": map[string]any{"title": "市场总览"}}) + } + if HasPermission(account, PermissionMCPServerRead) || HasPermission(account, PermissionMCPServerManage) { + marketChildren = append(marketChildren, map[string]any{"name": "MCPServers", "path": "mcp-servers", "component": "/gateway/mcp-servers", "meta": map[string]any{"title": "MCP 服务器"}}) + } + if HasPermission(account, PermissionSkillRead) || HasPermission(account, PermissionSkillManage) { + marketChildren = append(marketChildren, map[string]any{"name": "Skills", "path": "skills", "component": "/gateway/skills", "meta": map[string]any{"title": "Skills 技能"}}) + } + if HasPermission(account, PermissionDigitalEmployeeRead) || HasPermission(account, PermissionDigitalEmployeeManage) { + marketChildren = append(marketChildren, map[string]any{"name": "DigitalEmployees", "path": "digital-employees", "component": "/gateway/digital-employees", "meta": map[string]any{"title": "数字员工"}}) + } + if len(marketChildren) > 0 { + menus = append(menus, map[string]any{"name": "ResourceMarket", "path": "/resource-market", "component": "/index/index", "meta": map[string]any{"title": "资源市场", "icon": "ri:store-3-line"}, "children": marketChildren}) + } + + // 系统管理:账号权限、事件投递与通知。 + systemChildren := make([]map[string]any, 0, 3) + if HasPermission(account, PermissionIdentityManage) { + systemChildren = append(systemChildren, map[string]any{"name": "User", "path": "user", "component": "/system/user", "meta": map[string]any{"title": "账号与权限"}}) + } + if HasPermission(account, PermissionOutboxRead) || HasPermission(account, PermissionOutboxManage) { + systemChildren = append(systemChildren, map[string]any{"name": "Outbox", "path": "outbox", "component": "/gateway/outbox", "meta": map[string]any{"title": "事件投递"}}) + } + if HasPermission(account, PermissionNotificationRead) || HasPermission(account, PermissionNotificationManage) { + systemChildren = append(systemChildren, map[string]any{"name": "Notifications", "path": "notifications", "component": "/gateway/notifications", "meta": map[string]any{"title": "通知中心"}}) + } + if len(systemChildren) > 0 { + menus = append(menus, map[string]any{"name": "System", "path": "/system", "component": "/index/index", "meta": map[string]any{"title": "系统管理", "icon": "ri:user-3-line"}, "children": systemChildren}) + } + return menus +} + +func portalMenus() []map[string]any { + return []map[string]any{ + {"name": "Portal", "path": "/portal", "component": "/index/index", "meta": map[string]any{"title": "AI 工作台", "icon": "ri:sparkling-line"}, "children": []map[string]any{ + {"name": "PortalCatalog", "path": "catalog", "component": "/portal/catalog", "meta": map[string]any{"title": "资产目录", "fixedTab": true}}, + {"name": "PortalMarketplace", "path": "marketplace", "component": "/portal/marketplace", "meta": map[string]any{"title": "资源市场"}}, + {"name": "PortalPrompts", "path": "prompts", "component": "/portal/prompts", "meta": map[string]any{"title": "Prompt 广场"}}, + {"name": "PortalUsage", "path": "usage", "component": "/portal/usage", "meta": map[string]any{"title": "我的用量"}}, + {"name": "PortalAccess", "path": "access", "component": "/portal/access", "meta": map[string]any{"title": "模型权限"}}, + }}, + } +} diff --git a/internal/identity/management.go b/internal/identity/management.go new file mode 100644 index 0000000..ad67150 --- /dev/null +++ b/internal/identity/management.go @@ -0,0 +1,415 @@ +package identity + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "regexp" + "sort" + "strings" + + "aigateway.local/core/internal/platform/apiresponse" + platformid "aigateway.local/core/internal/platform/id" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +var ( + ErrIdentityConflict = errors.New("identity already exists") + permissionPattern = regexp.MustCompile(`^[a-z][a-z0-9_.:-]{2,127}$`) +) + +type ManagementHTTPHandler struct { + service *Service + mux *http.ServeMux +} + +type identityInput struct { + Login string `json:"login"` + DisplayName string `json:"display_name"` + Role string `json:"role"` + Password *string `json:"password"` + Permissions []string `json:"permissions"` + Active *bool `json:"active"` + DepartmentID *string `json:"department_id"` +} + +func NewManagementHTTPHandler(service *Service) *ManagementHTTPHandler { + h := &ManagementHTTPHandler{service: service, mux: http.NewServeMux()} + h.mux.HandleFunc("GET /api/v1/admin/identities/admins", h.list(KindAdmin)) + h.mux.HandleFunc("POST /api/v1/admin/identities/admins", h.create(KindAdmin)) + h.mux.HandleFunc("PUT /api/v1/admin/identities/admins/{identity_id}", h.update(KindAdmin)) + h.mux.HandleFunc("GET /api/v1/admin/identities/portal-users", h.list(KindPortal)) + h.mux.HandleFunc("POST /api/v1/admin/identities/portal-users", h.create(KindPortal)) + h.mux.HandleFunc("PUT /api/v1/admin/identities/portal-users/{identity_id}", h.update(KindPortal)) + h.mux.HandleFunc("GET /api/v1/admin/departments", h.listDepartments) + h.mux.HandleFunc("POST /api/v1/admin/departments", h.createDepartment) + h.mux.HandleFunc("PUT /api/v1/admin/departments/{department_id}", h.updateDepartment) + h.registerOIDC() + return h +} + +func (h *ManagementHTTPHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) { + h.mux.ServeHTTP(writer, request) +} + +func (h *ManagementHTTPHandler) list(kind Kind) http.HandlerFunc { + return func(writer http.ResponseWriter, request *http.Request) { + if _, ok := h.requirePermission(writer, request); !ok { + return + } + accounts, err := h.service.repository.ListIdentities(request.Context(), kind) + if err != nil { + h.writeError(writer, err) + return + } + items := make([]map[string]any, 0, len(accounts)) + for _, account := range accounts { + items = append(items, managementView(account)) + } + apiresponse.OK(writer, items) + } +} + +func (h *ManagementHTTPHandler) create(kind Kind) http.HandlerFunc { + return func(writer http.ResponseWriter, request *http.Request) { + actor, ok := h.requirePermission(writer, request) + if !ok { + return + } + input, account, password, ok := h.decode(writer, request, kind, true) + if !ok { + return + } + _ = input + passwordHash, err := h.service.hasher.Hash(password) + if err != nil { + h.writeError(writer, err) + return + } + account.PasswordHash = passwordHash + created, err := h.service.repository.CreateIdentity(request.Context(), account, actor.ID) + if err != nil { + h.writeError(writer, err) + return + } + apiresponse.OK(writer, managementView(created)) + } +} + +func (h *ManagementHTTPHandler) update(kind Kind) http.HandlerFunc { + return func(writer http.ResponseWriter, request *http.Request) { + actor, ok := h.requirePermission(writer, request) + if !ok { + return + } + _, account, password, ok := h.decode(writer, request, kind, false) + if !ok { + return + } + account.ID = request.PathValue("identity_id") + current, err := h.service.findByID(request.Context(), kind, account.ID) + if err != nil { + h.writeError(writer, err) + return + } + if kind == KindAdmin && actor.ID == current.ID && (account.Role != current.Role || !account.Active) { + apiresponse.Error(writer, http.StatusConflict, "不能停用自身账号或修改自身角色") + return + } + var passwordHash *string + if password != "" { + hash, err := h.service.hasher.Hash(password) + if err != nil { + h.writeError(writer, err) + return + } + passwordHash = &hash + } + updated, err := h.service.repository.UpdateIdentity(request.Context(), account, passwordHash, actor.ID) + if err != nil { + h.writeError(writer, err) + return + } + apiresponse.OK(writer, managementView(updated)) + } +} + +func (h *ManagementHTTPHandler) decode(writer http.ResponseWriter, request *http.Request, kind Kind, creating bool) (identityInput, Account, string, bool) { + var input identityInput + decoder := json.NewDecoder(http.MaxBytesReader(writer, request.Body, 1<<20)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&input); err != nil { + apiresponse.Error(writer, http.StatusBadRequest, "请求格式无效") + return input, Account{}, "", false + } + input.Login = strings.ToLower(strings.TrimSpace(input.Login)) + input.DisplayName = strings.TrimSpace(input.DisplayName) + input.Role = strings.ToLower(strings.TrimSpace(input.Role)) + if input.Role == "" { + if kind == KindPortal { + input.Role = "member" + } else { + input.Role = "operator" + } + } + if len(input.Login) < 2 || len(input.Login) > 128 || len(input.DisplayName) > 64 { + apiresponse.Error(writer, http.StatusBadRequest, "账号或显示名称格式无效") + return input, Account{}, "", false + } + if kind == KindAdmin && input.Role != "superadmin" && input.Role != "operator" && input.Role != "auditor" { + apiresponse.Error(writer, http.StatusBadRequest, "管理员角色无效") + return input, Account{}, "", false + } + if kind == KindPortal && input.Role != "member" { + apiresponse.Error(writer, http.StatusBadRequest, "门户角色无效") + return input, Account{}, "", false + } + permissions, err := normalizePermissions(input.Permissions) + if err != nil { + apiresponse.Error(writer, http.StatusBadRequest, err.Error()) + return input, Account{}, "", false + } + password := "" + if input.Password != nil { + password = *input.Password + } + if creating && len(password) < 12 || password != "" && len(password) < 12 || len(password) > 1024 { + apiresponse.Error(writer, http.StatusBadRequest, "口令长度必须为 12 至 1024 个字符") + return input, Account{}, "", false + } + active := true + if input.Active != nil { + active = *input.Active + } + var departmentID *string + if input.DepartmentID != nil && strings.TrimSpace(*input.DepartmentID) != "" { + value := strings.TrimSpace(*input.DepartmentID) + department, err := h.service.repository.GetDepartment(request.Context(), value) + if err != nil || !department.Active { + apiresponse.Error(writer, http.StatusBadRequest, "所选部门不存在或已停用") + return input, Account{}, "", false + } + departmentID = &value + } + return input, Account{ + Kind: kind, Login: input.Login, DisplayName: input.DisplayName, + Role: input.Role, Permissions: permissions, Active: active, DepartmentID: departmentID, + }, password, true +} + +func (h *ManagementHTTPHandler) requirePermission(writer http.ResponseWriter, request *http.Request) (Account, bool) { + account, err := h.service.Authenticate(request.Context(), KindAdmin, request.Header.Get("Authorization")) + if err != nil { + apiresponse.Error(writer, http.StatusUnauthorized, "登录状态无效或已过期") + return Account{}, false + } + if !HasPermission(account, PermissionIdentityManage) { + apiresponse.Error(writer, http.StatusForbidden, "缺少身份管理权限") + return Account{}, false + } + return account, true +} + +func (h *ManagementHTTPHandler) writeError(writer http.ResponseWriter, err error) { + switch { + case errors.Is(err, ErrNotFound): + apiresponse.Error(writer, http.StatusNotFound, "账号不存在") + case errors.Is(err, ErrIdentityConflict): + apiresponse.Error(writer, http.StatusConflict, "账号已存在") + case errors.Is(err, ErrUnavailable): + apiresponse.Error(writer, http.StatusServiceUnavailable, "身份管理服务暂不可用") + default: + apiresponse.Error(writer, http.StatusInternalServerError, "身份管理操作失败") + } +} + +func normalizePermissions(values []string) ([]string, error) { + seen := make(map[string]struct{}) + result := make([]string, 0, len(values)) + for _, value := range values { + value = strings.ToLower(strings.TrimSpace(value)) + if value != "*" && !permissionPattern.MatchString(value) { + return nil, fmt.Errorf("权限字符串 %q 格式无效", value) + } + if _, exists := seen[value]; exists { + continue + } + seen[value] = struct{}{} + result = append(result, value) + } + sort.Strings(result) + return result, nil +} + +func managementView(account Account) map[string]any { + return map[string]any{ + "id": account.ID, "kind": account.Kind, "login": account.Login, + "display_name": account.DisplayName, "role": account.Role, + "permissions": account.Permissions, "effective_permissions": EffectivePermissions(account), + "active": account.Active, "auth_source": account.AuthSource, + "totp_enabled": account.TOTPEnabled, "locked_until": account.LockedUntil, + "created_at": account.CreatedAt, "updated_at": account.UpdatedAt, + "department_id": account.DepartmentID, "department_name": account.DepartmentName, + } +} + +func (r *Repository) ListIdentities(ctx context.Context, kind Kind) ([]Account, error) { + if r.pool == nil { + return nil, ErrUnavailable + } + query := ` + SELECT id::text, username, display_name, role, permissions, active, + totp_enabled, locked_until, 'local', created_at, updated_at + FROM gateway.admin_accounts ORDER BY lower(username)` + if kind == KindPortal { + query = ` + SELECT u.id::text, u.account, u.name, u.role, u.permissions, u.active, + u.totp_enabled, u.locked_until, u.auth_source, u.created_at, u.updated_at, + u.department_id::text, COALESCE(d.name, '') + FROM gateway.portal_users u + LEFT JOIN gateway.departments d ON d.id = u.department_id + ORDER BY lower(u.account)` + } + rows, err := r.pool.Query(ctx, query) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrUnavailable, err) + } + defer rows.Close() + accounts := make([]Account, 0) + for rows.Next() { + account := Account{Kind: kind} + arguments := []any{ + &account.ID, &account.Login, &account.DisplayName, &account.Role, + &account.Permissions, &account.Active, &account.TOTPEnabled, + &account.LockedUntil, &account.AuthSource, &account.CreatedAt, &account.UpdatedAt, + } + if kind == KindPortal { + arguments = append(arguments, &account.DepartmentID, &account.DepartmentName) + } + if err := rows.Scan(arguments...); err != nil { + return nil, fmt.Errorf("%w: %v", ErrUnavailable, err) + } + accounts = append(accounts, account) + } + return accounts, mapRepositoryError(rows.Err()) +} + +func (r *Repository) CreateIdentity(ctx context.Context, account Account, actorID string) (Account, error) { + if r.pool == nil { + return Account{}, ErrUnavailable + } + id, err := platformid.NewUUID() + if err != nil { + return Account{}, err + } + eventID, err := platformid.NewUUID() + if err != nil { + return Account{}, err + } + account.ID = id + tx, err := r.pool.Begin(ctx) + if err != nil { + return Account{}, fmt.Errorf("%w: %v", ErrUnavailable, err) + } + defer func() { _ = tx.Rollback(ctx) }() + if account.Kind == KindAdmin { + err = tx.QueryRow(ctx, ` + INSERT INTO gateway.admin_accounts + (id, username, display_name, role, permissions, password_hash, active) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING created_at, updated_at`, account.ID, account.Login, account.DisplayName, + account.Role, account.Permissions, account.PasswordHash, account.Active, + ).Scan(&account.CreatedAt, &account.UpdatedAt) + } else { + account.AuthSource = "local" + err = tx.QueryRow(ctx, ` + INSERT INTO gateway.portal_users + (id, account, name, role, permissions, password_hash, auth_source, active, department_id) + VALUES ($1, $2, $3, $4, $5, $6, 'local', $7, $8) + RETURNING created_at, updated_at`, account.ID, account.Login, account.DisplayName, + account.Role, account.Permissions, account.PasswordHash, account.Active, account.DepartmentID, + ).Scan(&account.CreatedAt, &account.UpdatedAt) + } + if err != nil { + return Account{}, mapManagementError(err) + } + payload, _ := json.Marshal(map[string]any{"identity_id": account.ID, "kind": account.Kind, "actor_id": actorID}) + if _, err := tx.Exec(ctx, ` + INSERT INTO gateway.outbox_events + (event_id, event_type, event_version, aggregate_type, aggregate_id, payload) + VALUES ($1, 'identity.created', 1, 'identity', $2, $3)`, eventID, account.ID, payload); err != nil { + return Account{}, fmt.Errorf("%w: %v", ErrUnavailable, err) + } + if err := tx.Commit(ctx); err != nil { + return Account{}, fmt.Errorf("%w: %v", ErrUnavailable, err) + } + return account, nil +} + +func (r *Repository) UpdateIdentity(ctx context.Context, account Account, passwordHash *string, actorID string) (Account, error) { + if r.pool == nil { + return Account{}, ErrUnavailable + } + eventID, err := platformid.NewUUID() + if err != nil { + return Account{}, err + } + tx, err := r.pool.Begin(ctx) + if err != nil { + return Account{}, fmt.Errorf("%w: %v", ErrUnavailable, err) + } + defer func() { _ = tx.Rollback(ctx) }() + if account.Kind == KindAdmin { + err = tx.QueryRow(ctx, ` + UPDATE gateway.admin_accounts + SET username = $2, display_name = $3, role = $4, permissions = $5, + active = $6, password_hash = COALESCE($7, password_hash), + updated_at = clock_timestamp() + WHERE id = $1 + RETURNING totp_enabled, locked_until, created_at, updated_at`, account.ID, account.Login, + account.DisplayName, account.Role, account.Permissions, account.Active, passwordHash, + ).Scan(&account.TOTPEnabled, &account.LockedUntil, &account.CreatedAt, &account.UpdatedAt) + } else { + account.AuthSource = "local" + err = tx.QueryRow(ctx, ` + UPDATE gateway.portal_users + SET account = $2, name = $3, role = $4, permissions = $5, + active = $6, password_hash = COALESCE($7, password_hash), department_id = $8, + updated_at = clock_timestamp() + WHERE id = $1 + RETURNING auth_source, totp_enabled, locked_until, created_at, updated_at`, account.ID, account.Login, + account.DisplayName, account.Role, account.Permissions, account.Active, passwordHash, account.DepartmentID, + ).Scan(&account.AuthSource, &account.TOTPEnabled, &account.LockedUntil, &account.CreatedAt, &account.UpdatedAt) + } + if err != nil { + return Account{}, mapManagementError(err) + } + payload, _ := json.Marshal(map[string]any{"identity_id": account.ID, "kind": account.Kind, "actor_id": actorID}) + if _, err := tx.Exec(ctx, ` + INSERT INTO gateway.outbox_events + (event_id, event_type, event_version, aggregate_type, aggregate_id, payload) + VALUES ($1, 'identity.updated', 1, 'identity', $2, $3)`, eventID, account.ID, payload); err != nil { + return Account{}, fmt.Errorf("%w: %v", ErrUnavailable, err) + } + if err := tx.Commit(ctx); err != nil { + return Account{}, fmt.Errorf("%w: %v", ErrUnavailable, err) + } + return account, nil +} + +func mapManagementError(err error) error { + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + var pgError *pgconn.PgError + if errors.As(err, &pgError) && pgError.Code == "23505" { + return ErrIdentityConflict + } + if err != nil { + return fmt.Errorf("%w: %v", ErrUnavailable, err) + } + return nil +} diff --git a/internal/identity/oidc.go b/internal/identity/oidc.go new file mode 100644 index 0000000..17015ac --- /dev/null +++ b/internal/identity/oidc.go @@ -0,0 +1,818 @@ +package identity + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "math/big" + "net" + "net/http" + "net/url" + "regexp" + "strings" + "time" + + "aigateway.local/core/internal/platform/apiresponse" + platformid "aigateway.local/core/internal/platform/id" + "github.com/jackc/pgx/v5" +) + +const oidcMaxResponse = 2 << 20 + +// oidcMaxTokenLifetimeSeconds bounds how far an ID token's exp may sit past +// its iat, preventing long-lived or replayed tokens from being accepted. +const oidcMaxTokenLifetimeSeconds = 24 * 3600 + +var oidcCodePattern = regexp.MustCompile(`^[a-z][a-z0-9_-]{1,63}$`) + +type OIDCProvider struct { + ID string `json:"id"` + Code string `json:"code"` + DisplayName string `json:"display_name"` + IssuerURL string `json:"issuer_url"` + ClientID string `json:"client_id"` + EncryptedCredentials []byte `json:"-"` + CredentialKEKVersion int `json:"-"` + RedirectURI string `json:"redirect_uri"` + PortalReturnURL string `json:"portal_return_url"` + Scopes []string `json:"scopes"` + AutoProvision bool `json:"auto_provision"` + DefaultDepartmentID *string `json:"default_department_id,omitempty"` + Enabled bool `json:"enabled"` + Revision int64 `json:"revision"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type oidcProviderInput struct { + Code string `json:"code"` + DisplayName string `json:"display_name"` + IssuerURL string `json:"issuer_url"` + ClientID string `json:"client_id"` + ClientSecret *string `json:"client_secret"` + RedirectURI string `json:"redirect_uri"` + PortalReturnURL string `json:"portal_return_url"` + Scopes []string `json:"scopes"` + AutoProvision bool `json:"auto_provision"` + DefaultDepartmentID *string `json:"default_department_id"` + Enabled bool `json:"enabled"` +} + +type oidcCredentials struct { + ClientSecret string `json:"client_secret"` +} +type oidcChallenge struct{ ProviderID, Verifier, Nonce string } +type oidcExchange struct { + Token string `json:"token"` +} +type oidcDiscovery struct{ Issuer, AuthorizationEndpoint, TokenEndpoint, JWKSURI string } + +func (h *ManagementHTTPHandler) registerOIDC() { + h.mux.HandleFunc("GET /api/v1/admin/identity-providers", h.listOIDCProviders) + h.mux.HandleFunc("POST /api/v1/admin/identity-providers", h.createOIDCProvider) + h.mux.HandleFunc("PUT /api/v1/admin/identity-providers/{provider_id}", h.updateOIDCProvider) + h.registerSAML() +} + +func (h *HTTPHandler) registerOIDC() { + h.mux.HandleFunc("GET /api/v1/portal/sso/providers", h.listPublicOIDCProviders) + h.mux.HandleFunc("GET /api/v1/portal/sso/{provider_code}/start", h.startSSO) + h.mux.HandleFunc("GET /api/v1/portal/sso/{provider_code}/callback", h.callbackOIDC) + h.mux.HandleFunc("POST /api/v1/portal/sso/{provider_code}/callback", h.callbackSAML) + h.mux.HandleFunc("GET /api/v1/portal/sso/{provider_code}/metadata", h.samlMetadata) + h.mux.HandleFunc("POST /api/v1/portal/sso/exchange", h.exchangeOIDC) +} + +func (h *ManagementHTTPHandler) listOIDCProviders(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requirePermission(w, r); !ok { + return + } + records, err := h.service.repository.ListOIDCProviders(r.Context(), false) + if err != nil { + h.writeError(w, err) + return + } + items := make([]map[string]any, 0, len(records)) + for _, record := range records { + items = append(items, h.oidcView(record)) + } + apiresponse.OK(w, items) +} + +func (h *ManagementHTTPHandler) createOIDCProvider(w http.ResponseWriter, r *http.Request) { + actor, ok := h.requirePermission(w, r) + if !ok { + return + } + input, record, ok := h.decodeOIDC(w, r, true) + if !ok { + return + } + if err := h.setOIDCCredentials(&record, strings.TrimSpace(*input.ClientSecret)); err != nil { + h.writeError(w, err) + return + } + created, err := h.service.repository.CreateOIDCProvider(r.Context(), record, actor.ID) + if err != nil { + h.writeError(w, err) + return + } + apiresponse.OK(w, h.oidcView(created)) +} + +func (h *ManagementHTTPHandler) updateOIDCProvider(w http.ResponseWriter, r *http.Request) { + actor, ok := h.requirePermission(w, r) + if !ok { + return + } + input, record, ok := h.decodeOIDC(w, r, false) + if !ok { + return + } + record.ID = r.PathValue("provider_id") + replace := input.ClientSecret != nil + if replace { + if err := h.setOIDCCredentials(&record, strings.TrimSpace(*input.ClientSecret)); err != nil { + h.writeError(w, err) + return + } + } + updated, err := h.service.repository.UpdateOIDCProvider(r.Context(), record, actor.ID, replace) + if err != nil { + h.writeError(w, err) + return + } + apiresponse.OK(w, h.oidcView(updated)) +} + +func (h *ManagementHTTPHandler) decodeOIDC(w http.ResponseWriter, r *http.Request, creating bool) (oidcProviderInput, OIDCProvider, bool) { + var input oidcProviderInput + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) + decoder.DisallowUnknownFields() + if decoder.Decode(&input) != nil { + apiresponse.Error(w, 400, "请求格式无效") + return input, OIDCProvider{}, false + } + input.Code = strings.ToLower(strings.TrimSpace(input.Code)) + input.DisplayName = strings.TrimSpace(input.DisplayName) + input.ClientID = strings.TrimSpace(input.ClientID) + if !oidcCodePattern.MatchString(input.Code) || input.DisplayName == "" || input.ClientID == "" || + (creating && input.ClientSecret == nil) || (input.ClientSecret != nil && strings.TrimSpace(*input.ClientSecret) == "") { + apiresponse.Error(w, 400, "身份源代码、名称、Client ID 或 Client Secret 无效") + return input, OIDCProvider{}, false + } + issuer, err := validateOIDCURL(r.Context(), input.IssuerURL, h.service.allowPrivateIdentityProvider) + if err != nil { + apiresponse.Error(w, 400, "Issuer URL 无效") + return input, OIDCProvider{}, false + } + redirectURI, err := validateAbsoluteURL(input.RedirectURI) + redirectURL, _ := url.Parse(redirectURI) + if err != nil || redirectURL.Fragment != "" { + apiresponse.Error(w, 400, "回调 URL 无效") + return input, OIDCProvider{}, false + } + returnURL, err := validateAbsoluteURL(input.PortalReturnURL) + if err != nil { + apiresponse.Error(w, 400, "门户返回 URL 无效") + return input, OIDCProvider{}, false + } + if len(input.Scopes) == 0 { + input.Scopes = []string{"openid", "profile", "email"} + } + input.Scopes, err = normalizeOIDCScopes(input.Scopes) + if err != nil { + apiresponse.Error(w, 400, "OIDC scopes 无效") + return input, OIDCProvider{}, false + } + if !contains(input.Scopes, "openid") { + apiresponse.Error(w, 400, "OIDC scopes 必须包含 openid") + return input, OIDCProvider{}, false + } + if input.DefaultDepartmentID != nil && *input.DefaultDepartmentID != "" { + department, err := h.service.repository.GetDepartment(r.Context(), *input.DefaultDepartmentID) + if err != nil || !department.Active { + apiresponse.Error(w, 400, "默认部门不存在或已停用") + return input, OIDCProvider{}, false + } + } + return input, OIDCProvider{Code: input.Code, DisplayName: input.DisplayName, IssuerURL: issuer, ClientID: input.ClientID, + RedirectURI: redirectURI, PortalReturnURL: returnURL, Scopes: input.Scopes, AutoProvision: input.AutoProvision, + DefaultDepartmentID: input.DefaultDepartmentID, Enabled: input.Enabled}, true +} + +func (h *ManagementHTTPHandler) setOIDCCredentials(record *OIDCProvider, secret string) error { + payload, _ := json.Marshal(oidcCredentials{ClientSecret: secret}) + encrypted, version, err := h.service.idpCipher.Encrypt(payload) + if err != nil { + return err + } + record.EncryptedCredentials, record.CredentialKEKVersion = encrypted, version + return nil +} + +func (h *ManagementHTTPHandler) oidcView(record OIDCProvider) map[string]any { + configured := false + if plaintext, err := h.service.idpCipher.Decrypt(record.EncryptedCredentials, record.CredentialKEKVersion); err == nil { + var credentials oidcCredentials + configured = json.Unmarshal(plaintext, &credentials) == nil && credentials.ClientSecret != "" + } + return map[string]any{"id": record.ID, "code": record.Code, "display_name": record.DisplayName, "issuer_url": record.IssuerURL, + "client_id": record.ClientID, "secret_configured": configured, "redirect_uri": record.RedirectURI, "portal_return_url": record.PortalReturnURL, + "scopes": record.Scopes, "auto_provision": record.AutoProvision, "default_department_id": record.DefaultDepartmentID, + "enabled": record.Enabled, "revision": record.Revision, "credential_kek_version": record.CredentialKEKVersion} +} + +func (h *HTTPHandler) listPublicOIDCProviders(w http.ResponseWriter, r *http.Request) { + records, err := h.service.repository.ListPublicIdentityProviders(r.Context()) + if err != nil { + h.writeIdentityError(w, err) + return + } + apiresponse.OK(w, records) +} + +func (h *HTTPHandler) startOIDC(w http.ResponseWriter, r *http.Request) { + p, err := h.service.repository.GetOIDCProviderByCode(r.Context(), r.PathValue("provider_code")) + if err != nil || !p.Enabled { + http.NotFound(w, r) + return + } + discovery, err := h.service.discoverOIDC(r.Context(), p) + if err != nil { + apiresponse.Error(w, 502, "身份源发现失败") + return + } + verifier, err := randomURLToken(48) + if err != nil { + h.writeIdentityError(w, ErrUnavailable) + return + } + nonce, err := randomURLToken(32) + if err != nil { + h.writeIdentityError(w, ErrUnavailable) + return + } + state, err := h.service.sessions.StoreOneTime(r.Context(), "oidc-state", oidcChallenge{ProviderID: p.ID, Verifier: verifier, Nonce: nonce}, 5*time.Minute) + if err != nil { + h.writeIdentityError(w, err) + return + } + challenge := sha256.Sum256([]byte(verifier)) + target, _ := url.Parse(discovery.AuthorizationEndpoint) + query := target.Query() + query.Set("response_type", "code") + query.Set("client_id", p.ClientID) + query.Set("redirect_uri", p.RedirectURI) + query.Set("scope", strings.Join(p.Scopes, " ")) + query.Set("state", state) + query.Set("nonce", nonce) + query.Set("code_challenge", base64.RawURLEncoding.EncodeToString(challenge[:])) + query.Set("code_challenge_method", "S256") + target.RawQuery = query.Encode() + http.Redirect(w, r, target.String(), http.StatusFound) +} + +func (h *HTTPHandler) callbackOIDC(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("error") != "" { + apiresponse.Error(w, 401, "OIDC 登录被拒绝") + return + } + if r.URL.Query().Get("state") == "" || r.URL.Query().Get("code") == "" { + apiresponse.Error(w, 401, "OIDC 回调参数无效") + return + } + var challenge oidcChallenge + if err := h.service.sessions.ConsumeOneTime(r.Context(), "oidc-state", r.URL.Query().Get("state"), &challenge); err != nil { + apiresponse.Error(w, 401, "OIDC state 无效或已使用") + return + } + p, err := h.service.repository.GetOIDCProviderByCode(r.Context(), r.PathValue("provider_code")) + if err != nil || p.ID != challenge.ProviderID || !p.Enabled { + apiresponse.Error(w, 401, "OIDC 身份源无效") + return + } + discovery, err := h.service.discoverOIDC(r.Context(), p) + if err != nil { + apiresponse.Error(w, 502, "身份源发现失败") + return + } + credentials, err := h.service.oidcCredentials(p) + if err != nil { + apiresponse.Error(w, 503, "身份源凭据不可用") + return + } + form := url.Values{"grant_type": {"authorization_code"}, "code": {r.URL.Query().Get("code")}, "redirect_uri": {p.RedirectURI}, "code_verifier": {challenge.Verifier}} + req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, discovery.TokenEndpoint, strings.NewReader(form.Encode())) + if err != nil { + apiresponse.Error(w, 502, "OIDC token 交换失败") + return + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(p.ClientID, credentials.ClientSecret) + response, err := h.service.oidcHTTPClient().Do(req) + if err != nil { + apiresponse.Error(w, 502, "OIDC token 交换失败") + return + } + defer response.Body.Close() + payload, _ := io.ReadAll(io.LimitReader(response.Body, oidcMaxResponse+1)) + if response.StatusCode/100 != 2 || len(payload) > oidcMaxResponse { + apiresponse.Error(w, 502, "OIDC token 交换失败") + return + } + var tokens struct { + IDToken string `json:"id_token"` + } + if json.Unmarshal(payload, &tokens) != nil || tokens.IDToken == "" { + apiresponse.Error(w, 502, "OIDC 响应缺少 ID Token") + return + } + claims, err := h.service.verifyIDToken(r.Context(), discovery, p.ClientID, challenge.Nonce, tokens.IDToken) + if err != nil { + apiresponse.Error(w, 401, "OIDC ID Token 校验失败") + return + } + account, err := h.service.repository.ResolveExternalAccount(r.Context(), p, claims) + if err != nil { + h.writeIdentityError(w, err) + return + } + if !account.Active { + h.writeIdentityError(w, ErrAccountDisabled) + return + } + token, err := h.service.sessions.Create(r.Context(), principalFor(account)) + if err != nil { + h.writeIdentityError(w, err) + return + } + exchange, err := h.service.sessions.StoreOneTime(r.Context(), "oidc-exchange", oidcExchange{Token: token}, time.Minute) + if err != nil { + h.writeIdentityError(w, err) + return + } + returnURL, _ := url.Parse(p.PortalReturnURL) + query := returnURL.Query() + query.Set("sso_code", exchange) + returnURL.RawQuery = query.Encode() + http.Redirect(w, r, returnURL.String(), http.StatusFound) +} + +func (h *HTTPHandler) exchangeOIDC(w http.ResponseWriter, r *http.Request) { + var input struct { + Code string `json:"code"` + } + if !decodeJSON(w, r, &input) { + apiresponse.Error(w, 400, "请求格式无效") + return + } + var exchange oidcExchange + if h.service.sessions.ConsumeOneTime(r.Context(), "oidc-exchange", input.Code, &exchange) != nil { + apiresponse.Error(w, 401, "SSO 交换码无效或已使用") + return + } + apiresponse.OK(w, map[string]string{"token": exchange.Token, "refreshToken": ""}) +} + +type oidcClaims struct { + Issuer string `json:"iss"` + Subject string `json:"sub"` + Audience json.RawMessage `json:"aud"` + AuthorizedParty string `json:"azp"` + ExpiresAt int64 `json:"exp"` + IssuedAt int64 `json:"iat"` + NotBefore int64 `json:"nbf"` + Nonce string `json:"nonce"` + Email string `json:"email"` + PreferredUsername string `json:"preferred_username"` + Name string `json:"name"` +} + +type externalProvider struct { + ID string + Code string + AuthSource string + AutoProvision bool + DefaultDepartmentID *string +} + +type externalClaims struct { + Subject string + Email string + PreferredUsername string + Name string +} + +func (s *Service) discoverOIDC(ctx context.Context, p OIDCProvider) (oidcDiscovery, error) { + target := strings.TrimRight(p.IssuerURL, "/") + "/.well-known/openid-configuration" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) + if err != nil { + return oidcDiscovery{}, err + } + response, err := s.oidcHTTPClient().Do(req) + if err != nil { + return oidcDiscovery{}, err + } + defer response.Body.Close() + payload, _ := io.ReadAll(io.LimitReader(response.Body, oidcMaxResponse+1)) + var raw struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + JWKSURI string `json:"jwks_uri"` + } + if response.StatusCode/100 != 2 || len(payload) > oidcMaxResponse || json.Unmarshal(payload, &raw) != nil || raw.Issuer != p.IssuerURL { + return oidcDiscovery{}, errors.New("invalid discovery") + } + for _, value := range []string{raw.AuthorizationEndpoint, raw.TokenEndpoint, raw.JWKSURI} { + if _, err := validateOIDCURL(ctx, value, s.allowPrivateIdentityProvider); err != nil { + return oidcDiscovery{}, err + } + } + return oidcDiscovery{Issuer: raw.Issuer, AuthorizationEndpoint: raw.AuthorizationEndpoint, TokenEndpoint: raw.TokenEndpoint, JWKSURI: raw.JWKSURI}, nil +} + +func (s *Service) oidcCredentials(p OIDCProvider) (oidcCredentials, error) { + plaintext, err := s.idpCipher.Decrypt(p.EncryptedCredentials, p.CredentialKEKVersion) + var c oidcCredentials + if err == nil { + err = json.Unmarshal(plaintext, &c) + } + return c, err +} + +func (s *Service) oidcHTTPClient() *http.Client { + if s.oidcClient == nil { + s.oidcClient = newOIDCHTTPClient(s.allowPrivateIdentityProvider) + } + return s.oidcClient +} + +func newOIDCHTTPClient(allowPrivate bool) *http.Client { + return &http.Client{ + Timeout: 10 * time.Second, + Transport: &http.Transport{ + DialContext: safeOIDCDial(allowPrivate), + TLSHandshakeTimeout: 5 * time.Second, + ResponseHeaderTimeout: 8 * time.Second, + MaxIdleConns: 32, + MaxIdleConnsPerHost: 8, + IdleConnTimeout: 90 * time.Second, + }, + CheckRedirect: func(*http.Request, []*http.Request) error { return errors.New("redirect rejected") }, + } +} + +func (s *Service) verifyIDToken(ctx context.Context, d oidcDiscovery, clientID, nonce, token string) (oidcClaims, error) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return oidcClaims{}, errors.New("jwt format") + } + headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return oidcClaims{}, err + } + var header struct{ Alg, Kid string } + if json.Unmarshal(headerBytes, &header) != nil || header.Alg != "RS256" || header.Kid == "" { + return oidcClaims{}, errors.New("jwt header") + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, d.JWKSURI, nil) + if err != nil { + return oidcClaims{}, err + } + response, err := s.oidcHTTPClient().Do(req) + if err != nil { + return oidcClaims{}, err + } + defer response.Body.Close() + payload, _ := io.ReadAll(io.LimitReader(response.Body, oidcMaxResponse+1)) + var keys struct { + Keys []struct{ Kid, Kty, N, E string } + } + if response.StatusCode/100 != 2 || len(payload) > oidcMaxResponse || json.Unmarshal(payload, &keys) != nil { + return oidcClaims{}, errors.New("jwks") + } + var key *rsa.PublicKey + for _, j := range keys.Keys { + if j.Kid == header.Kid && j.Kty == "RSA" { + nBytes, nErr := base64.RawURLEncoding.DecodeString(j.N) + eBytes, eErr := base64.RawURLEncoding.DecodeString(j.E) + e := 0 + for _, b := range eBytes { + e = e<<8 + int(b) + } + if nErr == nil && eErr == nil && len(nBytes) >= 256 && e >= 3 && e%2 == 1 { + key = &rsa.PublicKey{N: new(big.Int).SetBytes(nBytes), E: e} + } + } + } + if key == nil { + return oidcClaims{}, errors.New("key") + } + signature, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + return oidcClaims{}, err + } + digest := sha256.Sum256([]byte(parts[0] + "." + parts[1])) + if rsa.VerifyPKCS1v15(key, crypto.SHA256, digest[:], signature) != nil { + return oidcClaims{}, errors.New("signature") + } + claimsBytes, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return oidcClaims{}, err + } + var claims oidcClaims + now := time.Now().Unix() + if s.now != nil { + now = s.now().Unix() + } + if json.Unmarshal(claimsBytes, &claims) != nil || claims.Issuer != d.Issuer || claims.Subject == "" || claims.Nonce != nonce || + claims.ExpiresAt <= now-30 || claims.IssuedAt == 0 || claims.IssuedAt > now+30 || claims.NotBefore > now+30 || + claims.ExpiresAt-claims.IssuedAt > oidcMaxTokenLifetimeSeconds { + return oidcClaims{}, errors.New("claims") + } + audiences, ok := parseAudience(claims.Audience) + if !ok || !contains(audiences, clientID) || (len(audiences) > 1 && claims.AuthorizedParty != clientID) || + (claims.AuthorizedParty != "" && claims.AuthorizedParty != clientID) { + return oidcClaims{}, errors.New("audience") + } + return claims, nil +} + +func (r *Repository) ListOIDCProviders(ctx context.Context, enabledOnly bool) ([]OIDCProvider, error) { + query := `SELECT id::text,code,display_name,issuer_url,client_id,encrypted_credentials,credential_kek_version,redirect_uri,portal_return_url,scopes,auto_provision,default_department_id::text,enabled,revision,created_at,updated_at FROM gateway.identity_providers WHERE kind='oidc'` + if enabledOnly { + query += " AND enabled" + } + query += " ORDER BY code" + rows, err := r.pool.Query(ctx, query) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrUnavailable, err) + } + defer rows.Close() + items := []OIDCProvider{} + for rows.Next() { + var p OIDCProvider + if err := rows.Scan(&p.ID, &p.Code, &p.DisplayName, &p.IssuerURL, &p.ClientID, &p.EncryptedCredentials, &p.CredentialKEKVersion, &p.RedirectURI, &p.PortalReturnURL, &p.Scopes, &p.AutoProvision, &p.DefaultDepartmentID, &p.Enabled, &p.Revision, &p.CreatedAt, &p.UpdatedAt); err != nil { + return nil, err + } + items = append(items, p) + } + return items, mapRepositoryError(rows.Err()) +} +func (r *Repository) GetOIDCProviderByCode(ctx context.Context, code string) (OIDCProvider, error) { + var p OIDCProvider + err := r.pool.QueryRow(ctx, `SELECT id::text,code,display_name,issuer_url,client_id,encrypted_credentials,credential_kek_version,redirect_uri,portal_return_url,scopes,auto_provision,default_department_id::text,enabled,revision,created_at,updated_at FROM gateway.identity_providers WHERE code=$1 AND kind='oidc'`, strings.ToLower(code)).Scan(&p.ID, &p.Code, &p.DisplayName, &p.IssuerURL, &p.ClientID, &p.EncryptedCredentials, &p.CredentialKEKVersion, &p.RedirectURI, &p.PortalReturnURL, &p.Scopes, &p.AutoProvision, &p.DefaultDepartmentID, &p.Enabled, &p.Revision, &p.CreatedAt, &p.UpdatedAt) + return p, mapRepositoryError(err) +} + +func (r *Repository) CreateOIDCProvider(ctx context.Context, p OIDCProvider, actor string) (OIDCProvider, error) { + id, _ := platformid.NewUUID() + p.ID = id + return r.storeOIDCProvider(ctx, p, actor, true, true) +} +func (r *Repository) UpdateOIDCProvider(ctx context.Context, p OIDCProvider, actor string, replace bool) (OIDCProvider, error) { + return r.storeOIDCProvider(ctx, p, actor, false, replace) +} +func (r *Repository) storeOIDCProvider(ctx context.Context, p OIDCProvider, actor string, creating, replace bool) (OIDCProvider, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return p, ErrUnavailable + } + defer tx.Rollback(ctx) + if creating { + err = tx.QueryRow(ctx, `INSERT INTO gateway.identity_providers(id,code,kind,display_name,issuer_url,client_id,encrypted_credentials,credential_kek_version,redirect_uri,portal_return_url,scopes,auto_provision,default_department_id,enabled) VALUES($1,$2,'oidc',$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING revision,created_at,updated_at`, p.ID, p.Code, p.DisplayName, p.IssuerURL, p.ClientID, p.EncryptedCredentials, p.CredentialKEKVersion, p.RedirectURI, p.PortalReturnURL, p.Scopes, p.AutoProvision, p.DefaultDepartmentID, p.Enabled).Scan(&p.Revision, &p.CreatedAt, &p.UpdatedAt) + } else { + err = tx.QueryRow(ctx, `UPDATE gateway.identity_providers SET code=$2,display_name=$3,issuer_url=$4,client_id=$5,encrypted_credentials=CASE WHEN $14 THEN $6 ELSE encrypted_credentials END,credential_kek_version=CASE WHEN $14 THEN $7 ELSE credential_kek_version END,redirect_uri=$8,portal_return_url=$9,scopes=$10,auto_provision=$11,default_department_id=$12,enabled=$13,revision=revision+1,updated_at=clock_timestamp() WHERE id=$1 AND kind='oidc' RETURNING encrypted_credentials,credential_kek_version,revision,created_at,updated_at`, p.ID, p.Code, p.DisplayName, p.IssuerURL, p.ClientID, p.EncryptedCredentials, p.CredentialKEKVersion, p.RedirectURI, p.PortalReturnURL, p.Scopes, p.AutoProvision, p.DefaultDepartmentID, p.Enabled, replace).Scan(&p.EncryptedCredentials, &p.CredentialKEKVersion, &p.Revision, &p.CreatedAt, &p.UpdatedAt) + } + if err != nil { + return p, mapManagementError(err) + } + eventID, _ := platformid.NewUUID() + eventType := "identity_provider.updated" + if creating { + eventType = "identity_provider.created" + } + payload, _ := json.Marshal(map[string]any{"identity_provider_id": p.ID, "actor_id": actor}) + if _, err = tx.Exec(ctx, `INSERT INTO gateway.outbox_events(event_id,event_type,event_version,aggregate_type,aggregate_id,payload) VALUES($1,$2,1,'identity_provider',$3,$4)`, eventID, eventType, p.ID, payload); err != nil { + return p, ErrUnavailable + } + if tx.Commit(ctx) != nil { + return p, ErrUnavailable + } + return p, nil +} + +func (r *Repository) ResolveExternalAccount(ctx context.Context, p OIDCProvider, c oidcClaims) (Account, error) { + return r.resolveExternalAccount(ctx, externalProvider{ + ID: p.ID, Code: p.Code, AuthSource: "oidc", AutoProvision: p.AutoProvision, DefaultDepartmentID: p.DefaultDepartmentID, + }, externalClaims{Subject: c.Subject, Email: c.Email, PreferredUsername: c.PreferredUsername, Name: c.Name}) +} + +func (r *Repository) resolveExternalAccount(ctx context.Context, p externalProvider, c externalClaims) (Account, error) { + var id string + err := r.pool.QueryRow(ctx, `SELECT id::text FROM gateway.portal_users WHERE identity_provider_id=$1 AND external_subject=$2`, p.ID, c.Subject).Scan(&id) + if err == nil { + return r.FindPortalByID(ctx, id) + } + if !errors.Is(err, pgx.ErrNoRows) { + return Account{}, ErrUnavailable + } + if !p.AutoProvision { + return Account{}, ErrNotFound + } + if p.DefaultDepartmentID != nil { + var active bool + if err := r.pool.QueryRow(ctx, `SELECT active FROM gateway.departments WHERE id=$1`, *p.DefaultDepartmentID).Scan(&active); err != nil || !active { + return Account{}, ErrUnavailable + } + } + login := strings.ToLower(strings.TrimSpace(c.Email)) + if login == "" { + login = strings.ToLower(strings.TrimSpace(c.PreferredUsername)) + } + if login == "" { + sum := sha256.Sum256([]byte(c.Subject)) + login = p.Code + "_" + fmt.Sprintf("%x", sum[:6]) + } + login = truncate(login, 128) + id, _ = platformid.NewUUID() + eventID, _ := platformid.NewUUID() + tx, err := r.pool.Begin(ctx) + if err != nil { + return Account{}, ErrUnavailable + } + defer tx.Rollback(ctx) + name := truncate(firstNonEmpty(c.Name, c.PreferredUsername, login), 64) + insert := func(candidate string) (bool, error) { + var insertedID string + err := tx.QueryRow(ctx, `INSERT INTO gateway.portal_users(id,account,name,role,permissions,password_hash,auth_source,external_subject,identity_provider_id,department_id,active) VALUES($1,$2,$3,'member','{}',NULL,$4,$5,$6,$7,true) ON CONFLICT DO NOTHING RETURNING id::text`, id, candidate, name, p.AuthSource, c.Subject, p.ID, p.DefaultDepartmentID).Scan(&insertedID) + if errors.Is(err, pgx.ErrNoRows) { + return false, nil + } + return err == nil, err + } + inserted, err := insert(login) + if err != nil { + return Account{}, ErrUnavailable + } + if !inserted { + var existingID string + lookupErr := tx.QueryRow(ctx, `SELECT id::text FROM gateway.portal_users WHERE identity_provider_id=$1 AND external_subject=$2`, p.ID, c.Subject).Scan(&existingID) + if lookupErr == nil { + _ = tx.Rollback(ctx) + return r.FindPortalByID(ctx, existingID) + } + if !errors.Is(lookupErr, pgx.ErrNoRows) { + return Account{}, ErrUnavailable + } + sum := sha256.Sum256([]byte(p.Code + "|" + c.Subject)) + login = truncate(login, 110) + "-" + fmt.Sprintf("%x", sum[:8]) + inserted, err = insert(login) + } + if err != nil || !inserted { + return Account{}, ErrUnavailable + } + payload, _ := json.Marshal(map[string]any{"identity_id": id, "identity_provider_id": p.ID}) + if _, err = tx.Exec(ctx, `INSERT INTO gateway.outbox_events(event_id,event_type,event_version,aggregate_type,aggregate_id,payload) VALUES($1,'identity.external_provisioned',1,'identity',$2,$3)`, eventID, id, payload); err != nil { + return Account{}, ErrUnavailable + } + if tx.Commit(ctx) != nil { + return Account{}, ErrUnavailable + } + return r.FindPortalByID(ctx, id) +} + +func validateAbsoluteURL(raw string) (string, error) { + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" || u.User != nil { + return "", errors.New("invalid url") + } + return u.String(), nil +} +func validateOIDCURL(ctx context.Context, raw string, allowPrivate bool) (string, error) { + value, err := validateAbsoluteURL(raw) + if err != nil { + return "", err + } + u, _ := url.Parse(value) + if u.RawQuery != "" || u.Fragment != "" || (!allowPrivate && u.Scheme != "https") { + return "", errors.New("invalid issuer url") + } + if !allowPrivate { + addresses, err := net.DefaultResolver.LookupIPAddr(ctx, u.Hostname()) + if err != nil || len(addresses) == 0 { + return "", errors.New("resolve") + } + for _, a := range addresses { + if !publicIP(a.IP) { + return "", errors.New("blocked address") + } + } + } + u.Path = strings.TrimRight(u.Path, "/") + return u.String(), nil +} +func safeOIDCDial(allowPrivate bool) func(context.Context, string, string) (net.Conn, error) { + d := &net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second} + if allowPrivate { + return d.DialContext + } + return func(ctx context.Context, network, address string) (net.Conn, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, err + } + addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil { + return nil, err + } + for _, a := range addresses { + if !publicIP(a.IP) { + return nil, errors.New("blocked address") + } + } + if len(addresses) == 0 { + return nil, errors.New("resolve") + } + return d.DialContext(ctx, network, net.JoinHostPort(addresses[0].IP.String(), port)) + } +} +func publicIP(ip net.IP) bool { + return ip != nil && !ip.IsPrivate() && !ip.IsLoopback() && !ip.IsLinkLocalUnicast() && !ip.IsLinkLocalMulticast() && !ip.IsMulticast() && !ip.IsUnspecified() +} +func randomURLToken(size int) (string, error) { + b := make([]byte, size) + _, err := rand.Read(b) + return base64.RawURLEncoding.EncodeToString(b), err +} +func contains(values []string, target string) bool { + for _, v := range values { + if v == target { + return true + } + } + return false +} +func audienceContains(raw json.RawMessage, target string) bool { + values, ok := parseAudience(raw) + return ok && contains(values, target) +} +func parseAudience(raw json.RawMessage) ([]string, bool) { + var one string + if json.Unmarshal(raw, &one) == nil { + return []string{one}, one != "" + } + var many []string + if json.Unmarshal(raw, &many) != nil || len(many) == 0 { + return nil, false + } + for _, value := range many { + if value == "" { + return nil, false + } + } + return many, true +} +func normalizeOIDCScopes(values []string) ([]string, error) { + if len(values) > 16 { + return nil, errors.New("too many scopes") + } + seen := make(map[string]struct{}, len(values)) + result := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" || len(value) > 64 || strings.ContainsAny(value, " \t\r\n\"") { + return nil, errors.New("invalid scope") + } + if _, exists := seen[value]; exists { + continue + } + seen[value] = struct{}{} + result = append(result, value) + } + return result, nil +} +func firstNonEmpty(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + return "用户" +} +func truncate(value string, max int) string { + runes := []rune(value) + if len(runes) > max { + return string(runes[:max]) + } + return value +} diff --git a/internal/identity/oidc_test.go b/internal/identity/oidc_test.go new file mode 100644 index 0000000..a4be2fd --- /dev/null +++ b/internal/identity/oidc_test.go @@ -0,0 +1,140 @@ +package identity + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestVerifyIDTokenValidatesOIDCSecurityClaims(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + kid := "test-key" + jwks := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"keys": []map[string]string{{ + "kid": kid, + "kty": "RSA", + "n": base64.RawURLEncoding.EncodeToString(key.N.Bytes()), + "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.E)).Bytes()), + }}}) + })) + defer jwks.Close() + + now := time.Unix(1_800_000_000, 0) + service := &Service{ + allowPrivateIdentityProvider: true, + oidcClient: newOIDCHTTPClient(true), + now: func() time.Time { return now }, + } + discovery := oidcDiscovery{Issuer: "https://issuer.example", JWKSURI: jwks.URL} + baseClaims := map[string]any{ + "iss": discovery.Issuer, + "sub": "subject-1", + "aud": "client-1", + "exp": now.Add(time.Minute).Unix(), + "iat": now.Unix(), + "nonce": "nonce-1", + } + + if _, err := service.verifyIDToken(context.Background(), discovery, "client-1", "nonce-1", signTestIDToken(t, key, kid, baseClaims)); err != nil { + t.Fatalf("valid ID token rejected: %v", err) + } + + tests := []struct { + name string + change func(map[string]any) + }{ + {name: "wrong issuer", change: func(c map[string]any) { c["iss"] = "https://attacker.example" }}, + {name: "wrong nonce", change: func(c map[string]any) { c["nonce"] = "other" }}, + {name: "expired", change: func(c map[string]any) { c["exp"] = now.Add(-time.Minute).Unix() }}, + {name: "issued in future", change: func(c map[string]any) { c["iat"] = now.Add(time.Minute).Unix() }}, + {name: "missing subject", change: func(c map[string]any) { delete(c, "sub") }}, + {name: "wrong audience", change: func(c map[string]any) { c["aud"] = "other-client" }}, + {name: "multiple audiences without azp", change: func(c map[string]any) { c["aud"] = []string{"client-1", "other-client"} }}, + {name: "wrong authorized party", change: func(c map[string]any) { c["aud"] = []string{"client-1", "other-client"}; c["azp"] = "other-client" }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + claims := cloneClaims(baseClaims) + test.change(claims) + if _, err := service.verifyIDToken(context.Background(), discovery, "client-1", "nonce-1", signTestIDToken(t, key, kid, claims)); err == nil { + t.Fatal("expected ID token to be rejected") + } + }) + } + + multiple := cloneClaims(baseClaims) + multiple["aud"] = []string{"client-1", "other-client"} + multiple["azp"] = "client-1" + if _, err := service.verifyIDToken(context.Background(), discovery, "client-1", "nonce-1", signTestIDToken(t, key, kid, multiple)); err != nil { + t.Fatalf("valid multi-audience ID token rejected: %v", err) + } +} + +func TestOIDCURLAndScopeValidation(t *testing.T) { + got, err := validateOIDCURL(context.Background(), "http://127.0.0.1:9090/issuer/", true) + if err != nil || got != "http://127.0.0.1:9090/issuer" { + t.Fatalf("private development issuer rejected: %q %v", got, err) + } + for _, raw := range []string{ + "http://127.0.0.1:9090/issuer?tenant=1", + "http://127.0.0.1:9090/issuer#fragment", + "http://8.8.8.8/issuer", + } { + allowPrivate := raw != "http://8.8.8.8/issuer" + if _, err := validateOIDCURL(context.Background(), raw, allowPrivate); err == nil { + t.Fatalf("unsafe issuer accepted: %s", raw) + } + } + if _, err := validateAbsoluteURL("https://user:secret@example.com/callback"); err == nil { + t.Fatal("URL containing credentials was accepted") + } + + scopes, err := normalizeOIDCScopes([]string{"openid", " profile ", "openid", "email"}) + if err != nil || len(scopes) != 3 || scopes[0] != "openid" || scopes[1] != "profile" || scopes[2] != "email" { + t.Fatalf("unexpected normalized scopes: %#v %v", scopes, err) + } + for _, scopes := range [][]string{{"openid email"}, {"openid", ""}} { + if _, err := normalizeOIDCScopes(scopes); err == nil { + t.Fatalf("invalid scopes accepted: %#v", scopes) + } + } +} + +func signTestIDToken(t *testing.T, key *rsa.PrivateKey, kid string, claims map[string]any) string { + t.Helper() + header, err := json.Marshal(map[string]string{"alg": "RS256", "kid": kid, "typ": "JWT"}) + if err != nil { + t.Fatal(err) + } + payload, err := json.Marshal(claims) + if err != nil { + t.Fatal(err) + } + signingInput := base64.RawURLEncoding.EncodeToString(header) + "." + base64.RawURLEncoding.EncodeToString(payload) + digest := sha256.Sum256([]byte(signingInput)) + signature, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:]) + if err != nil { + t.Fatal(err) + } + return signingInput + "." + base64.RawURLEncoding.EncodeToString(signature) +} + +func cloneClaims(source map[string]any) map[string]any { + result := make(map[string]any, len(source)) + for key, value := range source { + result[key] = value + } + return result +} diff --git a/internal/identity/password.go b/internal/identity/password.go new file mode 100644 index 0000000..c7ad35e --- /dev/null +++ b/internal/identity/password.go @@ -0,0 +1,103 @@ +package identity + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "strconv" + "strings" +) + +const ( + currentPBKDF2Iterations = 600_000 + legacyPBKDF2Iterations = 120_000 + maximumPBKDF2Iterations = 2_000_000 + passwordDigestBytes = 32 +) + +var ErrInvalidPasswordHash = errors.New("invalid password hash") + +type PasswordHasher struct{} + +func (PasswordHasher) Hash(password string) (string, error) { + saltBytes := make([]byte, 16) + if _, err := rand.Read(saltBytes); err != nil { + return "", fmt.Errorf("generate password salt: %w", err) + } + return hashWithSalt(password, hex.EncodeToString(saltBytes), currentPBKDF2Iterations), nil +} + +func (PasswordHasher) Verify(password, stored string) bool { + iterations, salt, expected, err := parsePasswordHash(stored) + if err != nil { + return false + } + actual := pbkdf2SHA256([]byte(password), []byte(salt), iterations, len(expected)) + return subtle.ConstantTimeCompare(actual, expected) == 1 +} + +func (PasswordHasher) NeedsUpgrade(stored string) bool { + iterations, _, _, err := parsePasswordHash(stored) + return err == nil && iterations < currentPBKDF2Iterations +} + +func hashWithSalt(password, salt string, iterations int) string { + digest := pbkdf2SHA256([]byte(password), []byte(salt), iterations, passwordDigestBytes) + return fmt.Sprintf("pbkdf2_sha256$%d$%s$%s", iterations, salt, hex.EncodeToString(digest)) +} + +func parsePasswordHash(stored string) (int, string, []byte, error) { + parts := strings.Split(stored, "$") + iterations := legacyPBKDF2Iterations + var salt, encodedDigest string + switch { + case len(parts) == 4 && parts[0] == "pbkdf2_sha256": + parsed, err := strconv.Atoi(parts[1]) + if err != nil { + return 0, "", nil, ErrInvalidPasswordHash + } + iterations, salt, encodedDigest = parsed, parts[2], parts[3] + case len(parts) == 2: + salt, encodedDigest = parts[0], parts[1] + default: + return 0, "", nil, ErrInvalidPasswordHash + } + if iterations < 1 || iterations > maximumPBKDF2Iterations || salt == "" { + return 0, "", nil, ErrInvalidPasswordHash + } + digest, err := hex.DecodeString(encodedDigest) + if err != nil || len(digest) != passwordDigestBytes { + return 0, "", nil, ErrInvalidPasswordHash + } + return iterations, salt, digest, nil +} + +func pbkdf2SHA256(password, salt []byte, iterations, keyLength int) []byte { + const hashLength = sha256.Size + blocks := (keyLength + hashLength - 1) / hashLength + result := make([]byte, 0, blocks*hashLength) + buffer := make([]byte, len(salt)+4) + copy(buffer, salt) + for block := 1; block <= blocks; block++ { + binary.BigEndian.PutUint32(buffer[len(salt):], uint32(block)) + mac := hmac.New(sha256.New, password) + _, _ = mac.Write(buffer) + u := mac.Sum(nil) + t := append([]byte(nil), u...) + for round := 1; round < iterations; round++ { + mac.Reset() + _, _ = mac.Write(u) + u = mac.Sum(nil) + for index := range t { + t[index] ^= u[index] + } + } + result = append(result, t...) + } + return result[:keyLength] +} diff --git a/internal/identity/password_test.go b/internal/identity/password_test.go new file mode 100644 index 0000000..2da41a4 --- /dev/null +++ b/internal/identity/password_test.go @@ -0,0 +1,36 @@ +package identity + +import "testing" + +func TestPasswordHasherMatchesPythonGatewayFormat(t *testing.T) { + stored := "pbkdf2_sha256$600000$00112233445566778899aabbccddeeff$afca0887b188255f525e15e30f5aa5a0b210a3e253bfaf9630411f0782bb6573" + hasher := PasswordHasher{} + if !hasher.Verify("correct horse battery staple", stored) { + t.Fatal("expected Python-compatible password to verify") + } + if hasher.Verify("wrong", stored) { + t.Fatal("wrong password must not verify") + } +} + +func TestPasswordHasherAcceptsLegacyFormat(t *testing.T) { + stored := "abcd1234$a377be9840f0f48e6a0f3577f08a9e56e095561e1e313517e3d589739f8d6907" + hasher := PasswordHasher{} + if !hasher.Verify("legacy", stored) { + t.Fatal("expected legacy password to verify") + } + if !hasher.NeedsUpgrade(stored) { + t.Fatal("legacy password should require upgrade") + } +} + +func TestHashRoundTrip(t *testing.T) { + hasher := PasswordHasher{} + stored, err := hasher.Hash("long-enough-password") + if err != nil { + t.Fatal(err) + } + if !hasher.Verify("long-enough-password", stored) { + t.Fatal("new hash did not verify") + } +} diff --git a/internal/identity/permissions_test.go b/internal/identity/permissions_test.go new file mode 100644 index 0000000..b4aa5f9 --- /dev/null +++ b/internal/identity/permissions_test.go @@ -0,0 +1,29 @@ +package identity + +import "testing" + +func TestRoleAndDirectPermissionsAreMerged(t *testing.T) { + account := Account{Role: "auditor", Permissions: []string{PermissionProviderManage}} + if !HasPermission(account, PermissionProviderRead) || !HasPermission(account, PermissionProviderManage) { + t.Fatal("expected role and direct permissions to be merged") + } + if HasPermission(account, PermissionIdentityManage) { + t.Fatal("unexpected identity management permission") + } +} + +func TestSuperadminWildcard(t *testing.T) { + if !HasPermission(Account{Role: "superadmin"}, "future_resource:future_action") { + t.Fatal("superadmin wildcard must cover future permissions") + } +} + +func TestNormalizePermissions(t *testing.T) { + permissions, err := normalizePermissions([]string{"provider:read", " provider:read ", "future.feature:execute"}) + if err != nil || len(permissions) != 2 { + t.Fatalf("unexpected normalized permissions: %#v, %v", permissions, err) + } + if _, err := normalizePermissions([]string{"INVALID PERMISSION"}); err == nil { + t.Fatal("expected invalid permission to be rejected") + } +} diff --git a/internal/identity/ratelimit.go b/internal/identity/ratelimit.go new file mode 100644 index 0000000..a412053 --- /dev/null +++ b/internal/identity/ratelimit.go @@ -0,0 +1,104 @@ +package identity + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "net" + "net/http" + "strings" + "time" + + "github.com/redis/go-redis/v9" +) + +// LoginLimiter bounds login attempts per client IP with a Redis-backed +// sliding-window counter. It is defense-in-depth layered on top of the +// per-account lockout (repository.RecordFailure): +// +// - IP limiter: throttles credential-stuffing spread across many accounts +// from one source (returns HTTP 429). +// - Account lockout: stops repeated attempts on a single account. +// +// If Redis is unavailable or not configured the limiter fails OPEN (login +// proceeds, account lockout still applies) rather than locking every user out. +type LoginLimiter struct { + client *redis.Client + max int + window time.Duration +} + +func NewLoginLimiter(client *redis.Client, max int, window time.Duration) *LoginLimiter { + return &LoginLimiter{client: client, max: max, window: window} +} + +// Allow reports whether a login attempt from ip may proceed. +func (l *LoginLimiter) Allow(ctx context.Context, ip string) bool { + if l == nil || l.client == nil || l.max <= 0 || l.window <= 0 || ip == "" { + return true // not configured -> fail open + } + now := time.Now().UnixMilli() + // Member must be unique per attempt so ZADD appends instead of overwriting + // the score of an identical timestamp. + res, err := allowLoginScript.Run(ctx, l.client, + []string{loginLimitKey(ip)}, + now, l.window.Milliseconds(), l.max, uniqueMember(now), + int(l.window.Seconds())+60, + ).Int64Slice() + if err != nil { + return true // Redis hiccup -> fail open + } + // Script returns {1, count} when limited, {0, count+1} when admitted. + return len(res) == 2 && res[0] == 0 +} + +func loginLimitKey(ip string) string { + return "gateway:login-limit:" + ip +} + +func uniqueMember(now int64) string { + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + return fmt.Sprintf("%d:%d", now, time.Now().UnixNano()) + } + return fmt.Sprintf("%d:%s", now, hex.EncodeToString(b[:])) +} + +// allowLoginScript atomically trims the window, counts entries, and (when +// under the limit) records the attempt and refreshes the key TTL. +// +// KEYS[1] = key +// ARGV[1] = now (ms) ARGV[2] = window (ms) ARGV[3] = max +// ARGV[4] = unique member ARGV[5] = TTL (s) +var allowLoginScript = redis.NewScript(` +local key = KEYS[1] +local now = tonumber(ARGV[1]) +local window = tonumber(ARGV[2]) +local max = tonumber(ARGV[3]) +redis.call('ZREMRANGEBYSCORE', key, '-inf', now - window) +local count = redis.call('ZCARD', key) +if count >= max then + return {1, count} +end +redis.call('ZADD', key, now, ARGV[4]) +redis.call('EXPIRE', key, ARGV[5]) +return {0, count + 1} +`) + +// ClientIP extracts the caller's IP for login rate limiting. X-Forwarded-For +// is trusted here because nginx is the only ingress and overwrites the header +// on every proxy hop; the first value is the client address. Falls back to +// RemoteAddr for direct connections. +func ClientIP(r *http.Request) string { + if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" { + if first := strings.TrimSpace(strings.Split(fwd, ",")[0]); first != "" { + return first + } + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + return host +} diff --git a/internal/identity/ratelimit_test.go b/internal/identity/ratelimit_test.go new file mode 100644 index 0000000..5df0d13 --- /dev/null +++ b/internal/identity/ratelimit_test.go @@ -0,0 +1,34 @@ +package identity + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestClientIP(t *testing.T) { + cases := []struct { + name string + remoteAddr string + xfwd string + want string + }{ + {"xfwd first value", "10.0.0.1:52341", "203.0.113.9, 10.0.0.2", "203.0.113.9"}, + {"xfwd single", "10.0.0.1:52341", "198.51.100.7", "198.51.100.7"}, + {"xfwd with spaces", "10.0.0.1:52341", " 192.0.2.5 ", "192.0.2.5"}, + {"no xfwd falls back to remote", "203.0.113.9:8080", "", "203.0.113.9"}, + {"no xfwd and no port", "[2001:db8::1]:443", "", "2001:db8::1"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/", nil) + req.RemoteAddr = tc.remoteAddr + if tc.xfwd != "" { + req.Header.Set("X-Forwarded-For", tc.xfwd) + } + if got := ClientIP(req); got != tc.want { + t.Fatalf("ClientIP() = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/internal/identity/repository.go b/internal/identity/repository.go new file mode 100644 index 0000000..746c629 --- /dev/null +++ b/internal/identity/repository.go @@ -0,0 +1,335 @@ +package identity + +import ( + "context" + "crypto/subtle" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + platformid "aigateway.local/core/internal/platform/id" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Repository struct { + pool *pgxpool.Pool +} + +func NewRepository(pool *pgxpool.Pool) *Repository { + return &Repository{pool: pool} +} + +func (r *Repository) FindAdminByLogin(ctx context.Context, login string) (Account, error) { + if r.pool == nil { + return Account{}, ErrUnavailable + } + var account Account + account.Kind = KindAdmin + err := r.pool.QueryRow(ctx, ` + SELECT id::text, username, display_name, role, permissions, password_hash, active, + failed_logins, locked_until, totp_enabled, encrypted_totp_secret, + totp_kek_version, totp_last_step, totp_backup_codes + FROM gateway.admin_accounts + WHERE lower(username) = lower($1)`, strings.TrimSpace(login)).Scan( + &account.ID, &account.Login, &account.DisplayName, &account.Role, &account.Permissions, + &account.PasswordHash, &account.Active, &account.FailedLogins, + &account.LockedUntil, &account.TOTPEnabled, &account.EncryptedTOTPSecret, + &account.TOTPKekVersion, &account.TOTPLastStep, &account.TOTPBackupCodes, + ) + return account, mapRepositoryError(err) +} + +func (r *Repository) FindAdminByID(ctx context.Context, id string) (Account, error) { + if r.pool == nil { + return Account{}, ErrUnavailable + } + var account Account + account.Kind = KindAdmin + err := r.pool.QueryRow(ctx, ` + SELECT id::text, username, display_name, role, permissions, password_hash, active, + failed_logins, locked_until, totp_enabled, encrypted_totp_secret, + totp_kek_version, totp_last_step, totp_backup_codes + FROM gateway.admin_accounts + WHERE id = $1`, id).Scan( + &account.ID, &account.Login, &account.DisplayName, &account.Role, &account.Permissions, + &account.PasswordHash, &account.Active, &account.FailedLogins, + &account.LockedUntil, &account.TOTPEnabled, &account.EncryptedTOTPSecret, + &account.TOTPKekVersion, &account.TOTPLastStep, &account.TOTPBackupCodes, + ) + return account, mapRepositoryError(err) +} + +func (r *Repository) FindPortalByLogin(ctx context.Context, login string) (Account, error) { + if r.pool == nil { + return Account{}, ErrUnavailable + } + var account Account + account.Kind = KindPortal + err := r.pool.QueryRow(ctx, ` + SELECT id::text, account, name, role, permissions, COALESCE(password_hash, ''), auth_source, active, + failed_logins, locked_until, totp_enabled, encrypted_totp_secret, + totp_kek_version, totp_last_step, totp_backup_codes, department_id::text + FROM gateway.portal_users + WHERE lower(account) = lower($1)`, strings.TrimSpace(login)).Scan( + &account.ID, &account.Login, &account.DisplayName, &account.Role, &account.Permissions, &account.PasswordHash, + &account.AuthSource, &account.Active, &account.FailedLogins, + &account.LockedUntil, &account.TOTPEnabled, &account.EncryptedTOTPSecret, + &account.TOTPKekVersion, &account.TOTPLastStep, &account.TOTPBackupCodes, &account.DepartmentID, + ) + return account, mapRepositoryError(err) +} + +func (r *Repository) FindPortalByID(ctx context.Context, id string) (Account, error) { + if r.pool == nil { + return Account{}, ErrUnavailable + } + var account Account + account.Kind = KindPortal + err := r.pool.QueryRow(ctx, ` + SELECT id::text, account, name, role, permissions, COALESCE(password_hash, ''), auth_source, active, + failed_logins, locked_until, totp_enabled, encrypted_totp_secret, + totp_kek_version, totp_last_step, totp_backup_codes, department_id::text + FROM gateway.portal_users + WHERE id = $1`, id).Scan( + &account.ID, &account.Login, &account.DisplayName, &account.Role, &account.Permissions, &account.PasswordHash, + &account.AuthSource, &account.Active, &account.FailedLogins, + &account.LockedUntil, &account.TOTPEnabled, &account.EncryptedTOTPSecret, + &account.TOTPKekVersion, &account.TOTPLastStep, &account.TOTPBackupCodes, &account.DepartmentID, + ) + return account, mapRepositoryError(err) +} + +func (r *Repository) RecordFailure(ctx context.Context, account Account, maximum int, lockDuration time.Duration) (*time.Time, error) { + if r.pool == nil { + return nil, ErrUnavailable + } + table := "gateway.admin_accounts" + if account.Kind == KindPortal { + table = "gateway.portal_users" + } + query := fmt.Sprintf(` + UPDATE %s + SET locked_until = CASE + WHEN failed_logins + 1 >= $2 + THEN clock_timestamp() + make_interval(secs => $3) + ELSE locked_until + END, + failed_logins = CASE WHEN failed_logins + 1 >= $2 THEN 0 ELSE failed_logins + 1 END, + updated_at = clock_timestamp() + WHERE id = $1 + RETURNING locked_until`, table) + var lockedUntil *time.Time + err := r.pool.QueryRow(ctx, query, account.ID, maximum, int64(lockDuration.Seconds())).Scan(&lockedUntil) + return lockedUntil, mapRepositoryError(err) +} + +func (r *Repository) CompleteLogin(ctx context.Context, account Account, upgradedHash *string) error { + if r.pool == nil { + return ErrUnavailable + } + table := "gateway.admin_accounts" + if account.Kind == KindPortal { + table = "gateway.portal_users" + } + query := fmt.Sprintf(` + UPDATE %s + SET failed_logins = 0, + locked_until = NULL, + last_login = clock_timestamp(), + password_hash = COALESCE($2::text, password_hash), + updated_at = clock_timestamp() + WHERE id = $1`, table) + result, err := r.pool.Exec(ctx, query, account.ID, upgradedHash) + if err != nil { + return fmt.Errorf("%w: %v", ErrUnavailable, err) + } + if result.RowsAffected() != 1 { + return ErrNotFound + } + return nil +} + +func (r *Repository) SetPassword(ctx context.Context, account Account, passwordHash string) error { + query := fmt.Sprintf(`UPDATE %s SET password_hash=$2, failed_logins=0, locked_until=NULL, updated_at=clock_timestamp() WHERE id=$1`, identityTable(account.Kind)) + return expectOne(r.pool, ctx, query, account.ID, passwordHash) +} + +func (r *Repository) SetTOTPSecret(ctx context.Context, account Account, encrypted []byte, version int) error { + query := fmt.Sprintf(` + UPDATE %s + SET encrypted_totp_secret = $2, totp_kek_version = $3, + totp_enabled = false, totp_last_step = NULL, + totp_backup_codes = '[]'::jsonb, totp_confirmed_at = NULL, + updated_at = clock_timestamp() + WHERE id = $1`, identityTable(account.Kind)) + return expectOne(r.pool, ctx, query, account.ID, encrypted, version) +} + +func (r *Repository) EnableTOTP(ctx context.Context, account Account, step int64, records []BackupCodeRecord) error { + payload, err := json.Marshal(records) + if err != nil { + return err + } + query := fmt.Sprintf(` + UPDATE %s + SET totp_enabled = true, totp_last_step = $2, + totp_backup_codes = $3::jsonb, totp_confirmed_at = clock_timestamp(), + updated_at = clock_timestamp() + WHERE id = $1 AND encrypted_totp_secret IS NOT NULL AND NOT totp_enabled + AND (totp_last_step IS NULL OR totp_last_step < $2)`, identityTable(account.Kind)) + return expectOne(r.pool, ctx, query, account.ID, step, payload) +} + +func (r *Repository) ConsumeTOTPStep(ctx context.Context, account Account, step int64) (bool, error) { + if r.pool == nil { + return false, ErrUnavailable + } + query := fmt.Sprintf(` + UPDATE %s SET totp_last_step = $2, updated_at = clock_timestamp() + WHERE id = $1 AND totp_enabled + AND (totp_last_step IS NULL OR totp_last_step < $2)`, identityTable(account.Kind)) + result, err := r.pool.Exec(ctx, query, account.ID, step) + if err != nil { + return false, fmt.Errorf("%w: %v", ErrUnavailable, err) + } + return result.RowsAffected() == 1, nil +} + +func (r *Repository) ConsumeBackupCode(ctx context.Context, account Account, hash string) (bool, error) { + if r.pool == nil { + return false, ErrUnavailable + } + tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{}) + if err != nil { + return false, fmt.Errorf("%w: %v", ErrUnavailable, err) + } + defer func() { _ = tx.Rollback(ctx) }() + query := fmt.Sprintf(`SELECT totp_backup_codes FROM %s WHERE id = $1 AND totp_enabled FOR UPDATE`, identityTable(account.Kind)) + var payload []byte + if err := tx.QueryRow(ctx, query, account.ID).Scan(&payload); err != nil { + return false, mapRepositoryError(err) + } + var records []BackupCodeRecord + if err := json.Unmarshal(payload, &records); err != nil { + return false, fmt.Errorf("invalid stored TOTP backup codes: %w", err) + } + found := -1 + for index := range records { + if records[index].UsedAt == nil && subtle.ConstantTimeCompare([]byte(records[index].Hash), []byte(hash)) == 1 { + found = index + } + } + if found < 0 { + return false, nil + } + now := time.Now().UTC() + records[found].UsedAt = &now + payload, err = json.Marshal(records) + if err != nil { + return false, err + } + update := fmt.Sprintf(`UPDATE %s SET totp_backup_codes = $2::jsonb, updated_at = clock_timestamp() WHERE id = $1`, identityTable(account.Kind)) + if _, err := tx.Exec(ctx, update, account.ID, payload); err != nil { + return false, fmt.Errorf("%w: %v", ErrUnavailable, err) + } + if err := tx.Commit(ctx); err != nil { + return false, fmt.Errorf("%w: %v", ErrUnavailable, err) + } + return true, nil +} + +func (r *Repository) DisableTOTP(ctx context.Context, account Account) error { + query := fmt.Sprintf(` + UPDATE %s SET encrypted_totp_secret = NULL, totp_kek_version = NULL, + totp_enabled = false, totp_last_step = NULL, + totp_backup_codes = '[]'::jsonb, totp_confirmed_at = NULL, + updated_at = clock_timestamp() + WHERE id = $1 AND totp_enabled`, identityTable(account.Kind)) + return expectOne(r.pool, ctx, query, account.ID) +} + +func (r *Repository) ReplaceBackupCodes(ctx context.Context, account Account, records []BackupCodeRecord) error { + payload, err := json.Marshal(records) + if err != nil { + return err + } + query := fmt.Sprintf(`UPDATE %s SET totp_backup_codes = $2::jsonb, updated_at = clock_timestamp() WHERE id = $1 AND totp_enabled`, identityTable(account.Kind)) + return expectOne(r.pool, ctx, query, account.ID, payload) +} + +func identityTable(kind Kind) string { + if kind == KindPortal { + return "gateway.portal_users" + } + return "gateway.admin_accounts" +} + +func expectOne(pool *pgxpool.Pool, ctx context.Context, query string, arguments ...any) error { + if pool == nil { + return ErrUnavailable + } + result, err := pool.Exec(ctx, query, arguments...) + if err != nil { + return fmt.Errorf("%w: %v", ErrUnavailable, err) + } + if result.RowsAffected() != 1 { + return ErrNotFound + } + return nil +} + +func (r *Repository) CreateAdmin(ctx context.Context, login, displayName, role, passwordHash string) (string, error) { + if r.pool == nil { + return "", ErrUnavailable + } + id, err := platformid.NewUUID() + if err != nil { + return "", err + } + result, err := r.pool.Exec(ctx, ` + INSERT INTO gateway.admin_accounts + (id, username, display_name, role, password_hash) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT DO NOTHING`, id, strings.ToLower(strings.TrimSpace(login)), displayName, role, passwordHash) + if err != nil { + return "", err + } + if result.RowsAffected() != 1 { + return "", errors.New("administrator already exists") + } + return id, nil +} + +func (r *Repository) CreatePortalUser(ctx context.Context, login, displayName, passwordHash string) (string, error) { + if r.pool == nil { + return "", ErrUnavailable + } + id, err := platformid.NewUUID() + if err != nil { + return "", err + } + result, err := r.pool.Exec(ctx, ` + INSERT INTO gateway.portal_users (id, account, name, password_hash, auth_source) + VALUES ($1, $2, $3, $4, 'local') + ON CONFLICT DO NOTHING`, id, strings.ToLower(strings.TrimSpace(login)), displayName, passwordHash) + if err != nil { + return "", err + } + if result.RowsAffected() != 1 { + return "", errors.New("portal user already exists") + } + return id, nil +} + +func mapRepositoryError(err error) error { + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return fmt.Errorf("%w: %v", ErrUnavailable, err) + } + return nil +} diff --git a/internal/identity/saml.go b/internal/identity/saml.go new file mode 100644 index 0000000..a910d63 --- /dev/null +++ b/internal/identity/saml.go @@ -0,0 +1,636 @@ +package identity + +import ( + "context" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/xml" + "errors" + "io" + "net/http" + "net/url" + "strings" + "time" + + "aigateway.local/core/internal/platform/apiresponse" + platformid "aigateway.local/core/internal/platform/id" + "github.com/beevik/etree" + "github.com/crewjam/saml" + "github.com/crewjam/saml/samlsp" + dsig "github.com/russellhaering/goxmldsig" +) + +const samlMaxResponse = 4 << 20 + +type SAMLConfig struct { + MetadataURL string `json:"metadata_url"` + SPEntityID string `json:"sp_entity_id"` + ACSURL string `json:"acs_url"` + EmailAttribute string `json:"email_attribute"` + NameAttribute string `json:"name_attribute"` +} + +type SAMLProvider struct { + ID string + Code string + DisplayName string + PortalReturnURL string + AutoProvision bool + DefaultDepartmentID *string + Enabled bool + Revision int64 + Config SAMLConfig + CreatedAt time.Time + UpdatedAt time.Time +} + +type samlProviderInput struct { + Code string `json:"code"` + DisplayName string `json:"display_name"` + MetadataURL string `json:"metadata_url"` + SPEntityID string `json:"sp_entity_id"` + ACSURL string `json:"acs_url"` + PortalReturnURL string `json:"portal_return_url"` + EmailAttribute string `json:"email_attribute"` + NameAttribute string `json:"name_attribute"` + AutoProvision bool `json:"auto_provision"` + DefaultDepartmentID *string `json:"default_department_id"` + Enabled bool `json:"enabled"` +} + +type samlChallenge struct { + ProviderID string `json:"provider_id"` + RequestID string `json:"request_id"` +} + +type samlMetadataCacheEntry struct { + Metadata *saml.EntityDescriptor + ExpiresAt time.Time +} + +type PublicIdentityProvider struct { + Code string `json:"code"` + DisplayName string `json:"display_name"` + Kind string `json:"kind"` +} + +func (h *ManagementHTTPHandler) registerSAML() { + h.mux.HandleFunc("GET /api/v1/admin/saml-providers", h.listSAMLProviders) + h.mux.HandleFunc("POST /api/v1/admin/saml-providers", h.createSAMLProvider) + h.mux.HandleFunc("PUT /api/v1/admin/saml-providers/{provider_id}", h.updateSAMLProvider) +} + +func (h *ManagementHTTPHandler) listSAMLProviders(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requirePermission(w, r); !ok { + return + } + providers, err := h.service.repository.ListSAMLProviders(r.Context()) + if err != nil { + h.writeError(w, err) + return + } + items := make([]map[string]any, 0, len(providers)) + for _, provider := range providers { + items = append(items, samlView(provider)) + } + apiresponse.OK(w, items) +} + +func (h *ManagementHTTPHandler) createSAMLProvider(w http.ResponseWriter, r *http.Request) { + actor, ok := h.requirePermission(w, r) + if !ok { + return + } + provider, ok := h.decodeSAMLProvider(w, r) + if !ok { + return + } + created, err := h.service.repository.CreateSAMLProvider(r.Context(), provider, actor.ID) + if err != nil { + h.writeError(w, err) + return + } + apiresponse.OK(w, samlView(created)) +} + +func (h *ManagementHTTPHandler) updateSAMLProvider(w http.ResponseWriter, r *http.Request) { + actor, ok := h.requirePermission(w, r) + if !ok { + return + } + provider, ok := h.decodeSAMLProvider(w, r) + if !ok { + return + } + provider.ID = r.PathValue("provider_id") + updated, err := h.service.repository.UpdateSAMLProvider(r.Context(), provider, actor.ID) + if err != nil { + h.writeError(w, err) + return + } + apiresponse.OK(w, samlView(updated)) +} + +func (h *ManagementHTTPHandler) decodeSAMLProvider(w http.ResponseWriter, r *http.Request) (SAMLProvider, bool) { + var input samlProviderInput + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) + decoder.DisallowUnknownFields() + if decoder.Decode(&input) != nil { + apiresponse.Error(w, http.StatusBadRequest, "请求格式无效") + return SAMLProvider{}, false + } + input.Code = strings.ToLower(strings.TrimSpace(input.Code)) + input.DisplayName = strings.TrimSpace(input.DisplayName) + input.EmailAttribute = strings.TrimSpace(input.EmailAttribute) + input.NameAttribute = strings.TrimSpace(input.NameAttribute) + if !oidcCodePattern.MatchString(input.Code) || input.DisplayName == "" || len(input.DisplayName) > 128 { + apiresponse.Error(w, http.StatusBadRequest, "身份源代码或名称无效") + return SAMLProvider{}, false + } + metadataURL, err := validateOIDCURL(r.Context(), input.MetadataURL, h.service.allowPrivateIdentityProvider) + if err != nil { + apiresponse.Error(w, http.StatusBadRequest, "SAML metadata URL 无效") + return SAMLProvider{}, false + } + entityID, err := validateSAMLEntityID(input.SPEntityID) + if err != nil { + apiresponse.Error(w, http.StatusBadRequest, "SAML SP Entity ID 无效") + return SAMLProvider{}, false + } + acsURL, err := validateAbsoluteURL(input.ACSURL) + acs, _ := url.Parse(acsURL) + expectedACSSuffix := "/api/v1/portal/sso/" + input.Code + "/callback" + if err != nil || acs.Fragment != "" || !strings.HasSuffix(strings.TrimRight(acs.Path, "/"), expectedACSSuffix) { + apiresponse.Error(w, http.StatusBadRequest, "SAML ACS URL 无效") + return SAMLProvider{}, false + } + returnURL, err := validateAbsoluteURL(input.PortalReturnURL) + if err != nil { + apiresponse.Error(w, http.StatusBadRequest, "门户返回 URL 无效") + return SAMLProvider{}, false + } + if input.EmailAttribute == "" { + input.EmailAttribute = "email" + } + if input.NameAttribute == "" { + input.NameAttribute = "displayName" + } + if len(input.EmailAttribute) > 256 || len(input.NameAttribute) > 256 { + apiresponse.Error(w, http.StatusBadRequest, "SAML 属性名过长") + return SAMLProvider{}, false + } + if input.DefaultDepartmentID != nil && *input.DefaultDepartmentID != "" { + department, err := h.service.repository.GetDepartment(r.Context(), *input.DefaultDepartmentID) + if err != nil || !department.Active { + apiresponse.Error(w, http.StatusBadRequest, "默认部门不存在或已停用") + return SAMLProvider{}, false + } + } + provider := SAMLProvider{ + Code: input.Code, DisplayName: input.DisplayName, PortalReturnURL: returnURL, + AutoProvision: input.AutoProvision, DefaultDepartmentID: input.DefaultDepartmentID, Enabled: input.Enabled, + Config: SAMLConfig{MetadataURL: metadataURL, SPEntityID: entityID, ACSURL: acsURL, EmailAttribute: input.EmailAttribute, NameAttribute: input.NameAttribute}, + } + if input.Enabled { + if _, err := h.service.samlServiceProvider(r.Context(), provider); err != nil { + apiresponse.Error(w, http.StatusBadRequest, "SAML metadata 无法验证或缺少有效签名证书") + return SAMLProvider{}, false + } + } + return provider, true +} + +func samlView(provider SAMLProvider) map[string]any { + return map[string]any{ + "id": provider.ID, "kind": "saml", "code": provider.Code, "display_name": provider.DisplayName, + "metadata_url": provider.Config.MetadataURL, "sp_entity_id": provider.Config.SPEntityID, + "acs_url": provider.Config.ACSURL, "portal_return_url": provider.PortalReturnURL, + "email_attribute": provider.Config.EmailAttribute, "name_attribute": provider.Config.NameAttribute, + "auto_provision": provider.AutoProvision, "default_department_id": provider.DefaultDepartmentID, + "enabled": provider.Enabled, "revision": provider.Revision, + } +} + +func (h *HTTPHandler) startSSO(w http.ResponseWriter, r *http.Request) { + kind, err := h.service.repository.GetIdentityProviderKind(r.Context(), r.PathValue("provider_code")) + if err != nil { + http.NotFound(w, r) + return + } + if kind == "saml" { + h.startSAML(w, r) + return + } + h.startOIDC(w, r) +} + +func (h *HTTPHandler) startSAML(w http.ResponseWriter, r *http.Request) { + provider, err := h.service.repository.GetSAMLProviderByCode(r.Context(), r.PathValue("provider_code")) + if err != nil || !provider.Enabled { + http.NotFound(w, r) + return + } + sp, err := h.service.samlServiceProvider(r.Context(), provider) + if err != nil { + apiresponse.Error(w, http.StatusBadGateway, "SAML metadata 获取失败") + return + } + idpURL := sp.GetSSOBindingLocation(saml.HTTPRedirectBinding) + if idpURL == "" { + apiresponse.Error(w, http.StatusBadGateway, "SAML 身份源不支持 Redirect 登录") + return + } + authnRequest, err := sp.MakeAuthenticationRequest(idpURL, saml.HTTPRedirectBinding, saml.HTTPPostBinding) + if err != nil { + apiresponse.Error(w, http.StatusBadGateway, "SAML 登录请求生成失败") + return + } + relayState, err := h.service.sessions.StoreOneTime(r.Context(), "saml-state", samlChallenge{ProviderID: provider.ID, RequestID: authnRequest.ID}, 5*time.Minute) + if err != nil { + h.writeIdentityError(w, err) + return + } + target, err := authnRequest.Redirect(relayState, sp) + if err != nil { + apiresponse.Error(w, http.StatusBadGateway, "SAML 登录请求生成失败") + return + } + http.Redirect(w, r, target.String(), http.StatusFound) +} + +func (h *HTTPHandler) callbackSAML(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, samlMaxResponse) + if err := r.ParseForm(); err != nil || r.PostForm.Get("SAMLResponse") == "" || r.PostForm.Get("RelayState") == "" || r.PostForm.Get("SAMLart") != "" { + apiresponse.Error(w, http.StatusBadRequest, "SAML 回调格式无效") + return + } + var challenge samlChallenge + if err := h.service.sessions.ConsumeOneTime(r.Context(), "saml-state", r.PostForm.Get("RelayState"), &challenge); err != nil { + apiresponse.Error(w, http.StatusUnauthorized, "SAML RelayState 无效或已使用") + return + } + provider, err := h.service.repository.GetSAMLProviderByCode(r.Context(), r.PathValue("provider_code")) + if err != nil || !provider.Enabled || provider.ID != challenge.ProviderID { + apiresponse.Error(w, http.StatusUnauthorized, "SAML 身份源无效") + return + } + sp, err := h.service.samlServiceProvider(r.Context(), provider) + if err != nil { + apiresponse.Error(w, http.StatusBadGateway, "SAML metadata 获取失败") + return + } + assertion, err := sp.ParseResponse(r, []string{challenge.RequestID}) + if err != nil || assertion == nil || assertion.Subject == nil || assertion.Subject.NameID == nil || strings.TrimSpace(assertion.Subject.NameID.Value) == "" { + apiresponse.Error(w, http.StatusUnauthorized, "SAML 断言校验失败") + return + } + claimed, err := h.service.sessions.ClaimIdentifier(r.Context(), "saml-assertion", assertion.ID, 10*time.Minute) + if err != nil { + h.writeIdentityError(w, err) + return + } + if !claimed { + apiresponse.Error(w, http.StatusUnauthorized, "SAML 断言已使用") + return + } + subject := strings.TrimSpace(assertion.Subject.NameID.Value) + email := samlAttribute(assertion, provider.Config.EmailAttribute) + name := samlAttribute(assertion, provider.Config.NameAttribute) + account, err := h.service.repository.resolveExternalAccount(r.Context(), externalProvider{ + ID: provider.ID, Code: provider.Code, AuthSource: "saml", AutoProvision: provider.AutoProvision, DefaultDepartmentID: provider.DefaultDepartmentID, + }, externalClaims{Subject: subject, Email: email, PreferredUsername: subject, Name: name}) + if err != nil { + h.writeIdentityError(w, err) + return + } + if !account.Active { + h.writeIdentityError(w, ErrAccountDisabled) + return + } + token, err := h.service.sessions.Create(r.Context(), principalFor(account)) + if err != nil { + h.writeIdentityError(w, err) + return + } + exchange, err := h.service.sessions.StoreOneTime(r.Context(), "oidc-exchange", oidcExchange{Token: token}, time.Minute) + if err != nil { + h.writeIdentityError(w, err) + return + } + returnURL, _ := url.Parse(provider.PortalReturnURL) + query := returnURL.Query() + query.Set("sso_code", exchange) + returnURL.RawQuery = query.Encode() + http.Redirect(w, r, returnURL.String(), http.StatusFound) +} + +func (h *HTTPHandler) samlMetadata(w http.ResponseWriter, r *http.Request) { + provider, err := h.service.repository.GetSAMLProviderByCode(r.Context(), r.PathValue("provider_code")) + if err != nil { + http.NotFound(w, r) + return + } + sp, err := newSAMLServiceProvider(provider, nil, h.service.oidcHTTPClient()) + if err != nil { + apiresponse.Error(w, http.StatusBadGateway, "SAML metadata 获取失败") + return + } + payload, err := xml.MarshalIndent(sp.Metadata(), "", " ") + if err != nil { + apiresponse.Error(w, http.StatusInternalServerError, "SAML SP metadata 生成失败") + return + } + w.Header().Set("Content-Type", "application/samlmetadata+xml; charset=utf-8") + w.Header().Set("X-Content-Type-Options", "nosniff") + _, _ = w.Write(append([]byte(xml.Header), payload...)) +} + +func (s *Service) samlServiceProvider(ctx context.Context, provider SAMLProvider) (*saml.ServiceProvider, error) { + metadata, err := s.fetchSAMLMetadata(ctx, provider.Config.MetadataURL) + if err != nil { + return nil, err + } + return newSAMLServiceProvider(provider, metadata, s.oidcHTTPClient()) +} + +func newSAMLServiceProvider(provider SAMLProvider, metadata *saml.EntityDescriptor, client *http.Client) (*saml.ServiceProvider, error) { + acsURL, err := url.Parse(provider.Config.ACSURL) + if err != nil { + return nil, err + } + metadataURL := *acsURL + metadataURL.Path = strings.TrimSuffix(metadataURL.Path, "/callback") + "/metadata" + metadataURL.RawQuery = "" + metadataURL.Fragment = "" + return &saml.ServiceProvider{ + EntityID: provider.Config.SPEntityID, MetadataURL: metadataURL, AcsURL: *acsURL, + IDPMetadata: metadata, HTTPClient: client, AllowIDPInitiated: false, + SignatureVerifier: modernSAMLSignatureVerifier{}, + }, nil +} + +type modernSAMLSignatureVerifier struct{} + +func (modernSAMLSignatureVerifier) VerifySignature(validationContext *dsig.ValidationContext, element *etree.Element) error { + if err := validateSAMLSignatureAlgorithms(element); err != nil { + return err + } + _, err := validationContext.Validate(element) + return err +} + +func validateSAMLSignatureAlgorithms(element *etree.Element) error { + method := element.FindElement("./Signature/SignedInfo/SignatureMethod") + if method == nil { + return errors.New("SAML signature method missing") + } + switch method.SelectAttrValue("Algorithm", "") { + case dsig.RSASHA256SignatureMethod, dsig.RSASHA384SignatureMethod, dsig.RSASHA512SignatureMethod, + dsig.ECDSASHA256SignatureMethod, dsig.ECDSASHA384SignatureMethod, dsig.ECDSASHA512SignatureMethod: + default: + return errors.New("legacy or unsupported SAML signature algorithm") + } + allowedDigests := map[string]bool{ + "http://www.w3.org/2001/04/xmlenc#sha256": true, + "http://www.w3.org/2001/04/xmldsig-more#sha384": true, + "http://www.w3.org/2001/04/xmlenc#sha512": true, + } + digests := element.FindElements("./Signature/SignedInfo/Reference/DigestMethod") + if len(digests) == 0 { + return errors.New("SAML digest method missing") + } + for _, digest := range digests { + if !allowedDigests[digest.SelectAttrValue("Algorithm", "")] { + return errors.New("legacy or unsupported SAML digest algorithm") + } + } + return nil +} + +func (s *Service) fetchSAMLMetadata(ctx context.Context, rawURL string) (*saml.EntityDescriptor, error) { + target, err := validateOIDCURL(ctx, rawURL, s.allowPrivateIdentityProvider) + if err != nil { + return nil, err + } + now := time.Now() + s.samlMetadataMu.RLock() + cached, found := s.samlMetadata[target] + s.samlMetadataMu.RUnlock() + if found && now.Before(cached.ExpiresAt) { + return cached.Metadata, nil + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) + if err != nil { + return nil, err + } + response, err := s.oidcHTTPClient().Do(req) + if err != nil { + return nil, err + } + defer response.Body.Close() + payload, err := io.ReadAll(io.LimitReader(response.Body, oidcMaxResponse+1)) + if err != nil || response.StatusCode/100 != 2 || len(payload) > oidcMaxResponse { + return nil, errors.New("invalid SAML metadata response") + } + metadata, err := samlsp.ParseMetadata(payload) + if err != nil { + return nil, err + } + if err := validateSAMLMetadata(ctx, metadata, s.allowPrivateIdentityProvider, now); err != nil { + return nil, err + } + expiresAt := now.Add(time.Minute) + if !metadata.ValidUntil.IsZero() && metadata.ValidUntil.Before(expiresAt) { + expiresAt = metadata.ValidUntil + } + s.samlMetadataMu.Lock() + if s.samlMetadata == nil { + s.samlMetadata = make(map[string]samlMetadataCacheEntry) + } + s.samlMetadata[target] = samlMetadataCacheEntry{Metadata: metadata, ExpiresAt: expiresAt} + s.samlMetadataMu.Unlock() + return metadata, nil +} + +func validateSAMLMetadata(ctx context.Context, metadata *saml.EntityDescriptor, allowPrivate bool, now time.Time) error { + if metadata == nil || strings.TrimSpace(metadata.EntityID) == "" || len(metadata.IDPSSODescriptors) == 0 { + return errors.New("metadata has no IDP descriptor") + } + if !metadata.ValidUntil.IsZero() && !metadata.ValidUntil.After(now) { + return errors.New("metadata expired") + } + descriptor := metadata.IDPSSODescriptors[0] + redirectFound := false + for _, endpoint := range descriptor.SingleSignOnServices { + if endpoint.Binding == saml.HTTPRedirectBinding { + if _, err := validateOIDCURL(ctx, endpoint.Location, allowPrivate); err != nil { + return err + } + redirectFound = true + } + } + if !redirectFound { + return errors.New("metadata has no redirect SSO endpoint") + } + validSigningCertificate := false + for _, key := range descriptor.KeyDescriptors { + if key.Use != "" && key.Use != "signing" { + continue + } + for _, encoded := range key.KeyInfo.X509Data.X509Certificates { + der, err := base64.StdEncoding.DecodeString(strings.Join(strings.Fields(encoded.Data), "")) + if err != nil { + continue + } + certificate, err := x509.ParseCertificate(der) + if err == nil && !now.Before(certificate.NotBefore) && now.Before(certificate.NotAfter) { + validSigningCertificate = true + } + } + } + if !validSigningCertificate { + return errors.New("metadata has no currently valid signing certificate") + } + return nil +} + +func validateSAMLEntityID(raw string) (string, error) { + value := strings.TrimSpace(raw) + if value == "" || len(value) > 512 { + return "", errors.New("invalid entity ID") + } + parsed, err := url.Parse(value) + if err != nil || parsed.Scheme == "" || parsed.Fragment != "" { + return "", errors.New("invalid entity ID") + } + if (parsed.Scheme == "http" || parsed.Scheme == "https") && (parsed.Hostname() == "" || parsed.User != nil) { + return "", errors.New("invalid entity ID") + } + return value, nil +} + +func samlAttribute(assertion *saml.Assertion, name string) string { + for _, statement := range assertion.AttributeStatements { + for _, attribute := range statement.Attributes { + if attribute.Name != name && attribute.FriendlyName != name { + continue + } + for _, value := range attribute.Values { + if strings.TrimSpace(value.Value) != "" { + return strings.TrimSpace(value.Value) + } + } + } + } + return "" +} + +func (r *Repository) GetIdentityProviderKind(ctx context.Context, code string) (string, error) { + var kind string + err := r.pool.QueryRow(ctx, `SELECT kind FROM gateway.identity_providers WHERE code=$1 AND enabled`, strings.ToLower(strings.TrimSpace(code))).Scan(&kind) + return kind, mapRepositoryError(err) +} + +func (r *Repository) ListPublicIdentityProviders(ctx context.Context) ([]PublicIdentityProvider, error) { + rows, err := r.pool.Query(ctx, `SELECT code,display_name,kind FROM gateway.identity_providers WHERE enabled ORDER BY code`) + if err != nil { + return nil, ErrUnavailable + } + defer rows.Close() + providers := []PublicIdentityProvider{} + for rows.Next() { + var provider PublicIdentityProvider + if err := rows.Scan(&provider.Code, &provider.DisplayName, &provider.Kind); err != nil { + return nil, ErrUnavailable + } + providers = append(providers, provider) + } + return providers, mapRepositoryError(rows.Err()) +} + +func (r *Repository) ListSAMLProviders(ctx context.Context) ([]SAMLProvider, error) { + rows, err := r.pool.Query(ctx, `SELECT id::text,code,display_name,portal_return_url,auto_provision,default_department_id::text,enabled,revision,config,created_at,updated_at FROM gateway.identity_providers WHERE kind='saml' ORDER BY code`) + if err != nil { + return nil, ErrUnavailable + } + defer rows.Close() + providers := []SAMLProvider{} + for rows.Next() { + provider, err := scanSAMLProvider(rows) + if err != nil { + return nil, err + } + providers = append(providers, provider) + } + return providers, mapRepositoryError(rows.Err()) +} + +func (r *Repository) GetSAMLProviderByCode(ctx context.Context, code string) (SAMLProvider, error) { + return scanSAMLProvider(r.pool.QueryRow(ctx, `SELECT id::text,code,display_name,portal_return_url,auto_provision,default_department_id::text,enabled,revision,config,created_at,updated_at FROM gateway.identity_providers WHERE kind='saml' AND code=$1`, strings.ToLower(strings.TrimSpace(code)))) +} + +type rowScanner interface { + Scan(dest ...any) error +} + +func scanSAMLProvider(row rowScanner) (SAMLProvider, error) { + var provider SAMLProvider + var configJSON []byte + if err := row.Scan(&provider.ID, &provider.Code, &provider.DisplayName, &provider.PortalReturnURL, &provider.AutoProvision, &provider.DefaultDepartmentID, &provider.Enabled, &provider.Revision, &configJSON, &provider.CreatedAt, &provider.UpdatedAt); err != nil { + return provider, mapRepositoryError(err) + } + if json.Unmarshal(configJSON, &provider.Config) != nil { + return provider, ErrUnavailable + } + return provider, nil +} + +func (r *Repository) CreateSAMLProvider(ctx context.Context, provider SAMLProvider, actor string) (SAMLProvider, error) { + id, err := platformid.NewUUID() + if err != nil { + return provider, ErrUnavailable + } + provider.ID = id + return r.storeSAMLProvider(ctx, provider, actor, true) +} + +func (r *Repository) UpdateSAMLProvider(ctx context.Context, provider SAMLProvider, actor string) (SAMLProvider, error) { + return r.storeSAMLProvider(ctx, provider, actor, false) +} + +func (r *Repository) storeSAMLProvider(ctx context.Context, provider SAMLProvider, actor string, creating bool) (SAMLProvider, error) { + configJSON, err := json.Marshal(provider.Config) + if err != nil { + return provider, ErrUnavailable + } + tx, err := r.pool.Begin(ctx) + if err != nil { + return provider, ErrUnavailable + } + defer tx.Rollback(ctx) + if creating { + err = tx.QueryRow(ctx, `INSERT INTO gateway.identity_providers(id,code,kind,display_name,portal_return_url,auto_provision,default_department_id,enabled,config) VALUES($1,$2,'saml',$3,$4,$5,$6,$7,$8) RETURNING revision,created_at,updated_at`, provider.ID, provider.Code, provider.DisplayName, provider.PortalReturnURL, provider.AutoProvision, provider.DefaultDepartmentID, provider.Enabled, configJSON).Scan(&provider.Revision, &provider.CreatedAt, &provider.UpdatedAt) + } else { + err = tx.QueryRow(ctx, `UPDATE gateway.identity_providers SET code=$2,display_name=$3,portal_return_url=$4,auto_provision=$5,default_department_id=$6,enabled=$7,config=$8,revision=revision+1,updated_at=clock_timestamp() WHERE id=$1 AND kind='saml' RETURNING revision,created_at,updated_at`, provider.ID, provider.Code, provider.DisplayName, provider.PortalReturnURL, provider.AutoProvision, provider.DefaultDepartmentID, provider.Enabled, configJSON).Scan(&provider.Revision, &provider.CreatedAt, &provider.UpdatedAt) + } + if err != nil { + return provider, mapManagementError(err) + } + eventID, err := platformid.NewUUID() + if err != nil { + return provider, ErrUnavailable + } + eventType := "identity_provider.updated" + if creating { + eventType = "identity_provider.created" + } + payload, _ := json.Marshal(map[string]any{"identity_provider_id": provider.ID, "kind": "saml", "actor_id": actor}) + if _, err = tx.Exec(ctx, `INSERT INTO gateway.outbox_events(event_id,event_type,event_version,aggregate_type,aggregate_id,payload) VALUES($1,$2,1,'identity_provider',$3,$4)`, eventID, eventType, provider.ID, payload); err != nil { + return provider, ErrUnavailable + } + if err := tx.Commit(ctx); err != nil { + return provider, ErrUnavailable + } + return provider, nil +} diff --git a/internal/identity/saml_test.go b/internal/identity/saml_test.go new file mode 100644 index 0000000..51880d0 --- /dev/null +++ b/internal/identity/saml_test.go @@ -0,0 +1,115 @@ +package identity + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "math/big" + "testing" + "time" + + "github.com/beevik/etree" + "github.com/crewjam/saml" + dsig "github.com/russellhaering/goxmldsig" +) + +func TestValidateSAMLMetadataRequiresLiveSigningCertificate(t *testing.T) { + now := time.Unix(1_800_000_000, 0) + certificate := makeTestCertificate(t, now.Add(-time.Hour), now.Add(time.Hour)) + metadata := testIDPMetadata(certificate) + if err := validateSAMLMetadata(context.Background(), metadata, true, now); err != nil { + t.Fatalf("valid metadata rejected: %v", err) + } + + metadata.IDPSSODescriptors[0].KeyDescriptors = nil + if err := validateSAMLMetadata(context.Background(), metadata, true, now); err == nil { + t.Fatal("metadata without signing certificate was accepted") + } + + metadata = testIDPMetadata(makeTestCertificate(t, now.Add(-2*time.Hour), now.Add(-time.Hour))) + if err := validateSAMLMetadata(context.Background(), metadata, true, now); err == nil { + t.Fatal("expired signing certificate was accepted") + } + + metadata = testIDPMetadata(certificate) + metadata.IDPSSODescriptors[0].SingleSignOnServices = nil + if err := validateSAMLMetadata(context.Background(), metadata, true, now); err == nil { + t.Fatal("metadata without redirect SSO endpoint was accepted") + } +} + +func TestSAMLEntityIDAndAttributes(t *testing.T) { + if got, err := validateSAMLEntityID(" urn:example:gateway "); err != nil || got != "urn:example:gateway" { + t.Fatalf("valid URN entity ID rejected: %q %v", got, err) + } + for _, raw := range []string{"", "relative", "https://user:secret@example.com/sp", "urn:example:sp#fragment"} { + if _, err := validateSAMLEntityID(raw); err == nil { + t.Fatalf("invalid entity ID accepted: %q", raw) + } + } + assertion := &saml.Assertion{AttributeStatements: []saml.AttributeStatement{{Attributes: []saml.Attribute{{ + FriendlyName: "mail", Name: "urn:oid:0.9.2342.19200300.100.1.3", + Values: []saml.AttributeValue{{Value: " user@example.com "}}, + }}}}} + if got := samlAttribute(assertion, "mail"); got != "user@example.com" { + t.Fatalf("unexpected friendly-name attribute %q", got) + } + if got := samlAttribute(assertion, "urn:oid:0.9.2342.19200300.100.1.3"); got != "user@example.com" { + t.Fatalf("unexpected named attribute %q", got) + } +} + +func TestSAMLSignatureAlgorithmPolicyRejectsSHA1(t *testing.T) { + document := etree.NewDocument() + if err := document.ReadFromString(``); err != nil { + t.Fatal(err) + } + if err := validateSAMLSignatureAlgorithms(document.Root()); err != nil { + t.Fatalf("SHA-256 signature policy rejected: %v", err) + } + document.Root().FindElement("./Signature/SignedInfo/SignatureMethod").SelectAttr("Algorithm").Value = dsig.RSASHA1SignatureMethod + if err := validateSAMLSignatureAlgorithms(document.Root()); err == nil { + t.Fatal("SHA-1 signature algorithm was accepted") + } + document.Root().FindElement("./Signature/SignedInfo/SignatureMethod").SelectAttr("Algorithm").Value = dsig.RSASHA256SignatureMethod + document.Root().FindElement("./Signature/SignedInfo/Reference/DigestMethod").SelectAttr("Algorithm").Value = "http://www.w3.org/2000/09/xmldsig#sha1" + if err := validateSAMLSignatureAlgorithms(document.Root()); err == nil { + t.Fatal("SHA-1 digest algorithm was accepted") + } +} + +func testIDPMetadata(certificate *x509.Certificate) *saml.EntityDescriptor { + return &saml.EntityDescriptor{ + EntityID: "https://idp.example/metadata", + IDPSSODescriptors: []saml.IDPSSODescriptor{{ + SSODescriptor: saml.SSODescriptor{RoleDescriptor: saml.RoleDescriptor{KeyDescriptors: []saml.KeyDescriptor{{ + Use: "signing", KeyInfo: saml.KeyInfo{X509Data: saml.X509Data{X509Certificates: []saml.X509Certificate{{Data: base64.StdEncoding.EncodeToString(certificate.Raw)}}}}, + }}}}, + SingleSignOnServices: []saml.Endpoint{{Binding: saml.HTTPRedirectBinding, Location: "http://127.0.0.1:9091/sso"}}, + }}, + } +} + +func makeTestCertificate(t *testing.T, notBefore, notAfter time.Time) *x509.Certificate { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "SAML test"}, + NotBefore: notBefore, NotAfter: notAfter, KeyUsage: x509.KeyUsageDigitalSignature, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + certificate, err := x509.ParseCertificate(der) + if err != nil { + t.Fatal(err) + } + return certificate +} diff --git a/internal/identity/service.go b/internal/identity/service.go new file mode 100644 index 0000000..cb1e851 --- /dev/null +++ b/internal/identity/service.go @@ -0,0 +1,362 @@ +package identity + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "aigateway.local/core/internal/platform/config" + "aigateway.local/core/internal/platform/cryptox" +) + +var ( + ErrInvalidCredentials = errors.New("invalid credentials") + ErrAccountDisabled = errors.New("account disabled") + ErrInvalidTOTP = errors.New("invalid or reused TOTP code") + ErrTOTPAlreadyEnabled = errors.New("TOTP is already enabled") + ErrTOTPNotEnabled = errors.New("TOTP is not enabled") + ErrTOTPSetupRequired = errors.New("TOTP setup is required") +) + +const dummyPasswordHash = "pbkdf2_sha256$600000$00112233445566778899aabbccddeeff$afca0887b188255f525e15e30f5aa5a0b210a3e253bfaf9630411f0782bb6573" + +type LockedError struct { + Until time.Time +} + +func (e LockedError) Error() string { + return fmt.Sprintf("account locked until %s", e.Until.Format(time.RFC3339)) +} + +type LoginResult struct { + Token string + TempToken string + RequireTOTP bool + Account Account +} + +type TOTPSetupResult struct { + Secret string + ProvisioningURI string +} + +type Service struct { + repository *Repository + sessions *SessionStore + limiter *LoginLimiter + hasher PasswordHasher + config config.Auth + totpCipher cryptox.Cipher + idpCipher cryptox.Cipher + allowPrivateIdentityProvider bool + oidcClient *http.Client + samlMetadataMu sync.RWMutex + samlMetadata map[string]samlMetadataCacheEntry + now func() time.Time +} + +func (s *Service) SetIdentityProviderCipher(cipher cryptox.Cipher, allowPrivate bool) { + s.idpCipher = cipher + s.allowPrivateIdentityProvider = allowPrivate + s.oidcClient = newOIDCHTTPClient(allowPrivate) +} + +func NewService(repository *Repository, sessions *SessionStore, limiter *LoginLimiter, cfg config.Auth, totpCipher cryptox.Cipher) *Service { + return &Service{repository: repository, sessions: sessions, limiter: limiter, hasher: PasswordHasher{}, config: cfg, totpCipher: totpCipher, oidcClient: newOIDCHTTPClient(false), samlMetadata: make(map[string]samlMetadataCacheEntry), now: time.Now} +} + +// AllowLogin reports whether a login attempt from ip may proceed. When the +// per-IP sliding-window limit is exceeded it returns false (caller responds 429). +func (s *Service) AllowLogin(ctx context.Context, ip string) bool { + return s.limiter == nil || s.limiter.Allow(ctx, ip) +} + +func (s *Service) Login(ctx context.Context, kind Kind, login, password string) (LoginResult, error) { + account, err := s.findByLogin(ctx, kind, login) + if errors.Is(err, ErrNotFound) { + _ = s.hasher.Verify(password, dummyPasswordHash) + return LoginResult{}, ErrInvalidCredentials + } + if err != nil { + return LoginResult{}, err + } + if !account.Active { + return LoginResult{}, ErrAccountDisabled + } + if account.Locked(s.now()) { + return LoginResult{}, LockedError{Until: *account.LockedUntil} + } + if account.PasswordHash == "" || !s.hasher.Verify(password, account.PasswordHash) { + lockedUntil, recordErr := s.repository.RecordFailure(ctx, account, s.config.MaxFailures, s.config.LockDuration) + if recordErr != nil { + return LoginResult{}, recordErr + } + if lockedUntil != nil && lockedUntil.After(s.now()) { + return LoginResult{}, LockedError{Until: *lockedUntil} + } + return LoginResult{}, ErrInvalidCredentials + } + if account.TOTPEnabled { + principal := principalFor(account) + token, tokenErr := s.sessions.CreatePending(ctx, principal, s.config.TOTPChallengeTTL) + if tokenErr != nil { + return LoginResult{}, tokenErr + } + return LoginResult{TempToken: token, RequireTOTP: true, Account: account}, nil + } + + var upgradedHash *string + if s.hasher.NeedsUpgrade(account.PasswordHash) { + hash, hashErr := s.hasher.Hash(password) + if hashErr != nil { + return LoginResult{}, hashErr + } + upgradedHash = &hash + } + principal := principalFor(account) + token, err := s.sessions.Create(ctx, principal) + if err != nil { + return LoginResult{}, err + } + if err := s.repository.CompleteLogin(ctx, account, upgradedHash); err != nil { + _ = s.sessions.Delete(ctx, "Bearer "+token) + return LoginResult{}, err + } + return LoginResult{Token: token, Account: account}, nil +} + +func (s *Service) CompleteTOTPLogin(ctx context.Context, kind Kind, tempToken, code, backupCode string) (LoginResult, error) { + principal, err := s.sessions.AuthenticatePending(ctx, tempToken, kind) + if err != nil { + return LoginResult{}, err + } + account, err := s.findByID(ctx, kind, principal.SubjectID) + if err != nil { + return LoginResult{}, err + } + if !account.Active { + return LoginResult{}, ErrAccountDisabled + } + if account.Locked(s.now()) { + return LoginResult{}, LockedError{Until: *account.LockedUntil} + } + if !account.TOTPEnabled { + return LoginResult{}, ErrTOTPNotEnabled + } + valid, err := s.verifyAndConsumeFactor(ctx, account, code, backupCode) + if err != nil { + return LoginResult{}, err + } + if !valid { + lockedUntil, recordErr := s.repository.RecordFailure(ctx, account, s.config.MaxFailures, s.config.LockDuration) + if recordErr != nil { + return LoginResult{}, recordErr + } + if lockedUntil != nil && lockedUntil.After(s.now()) { + return LoginResult{}, LockedError{Until: *lockedUntil} + } + return LoginResult{}, ErrInvalidTOTP + } + token, err := s.sessions.Create(ctx, principalFor(account)) + if err != nil { + return LoginResult{}, err + } + if err := s.repository.CompleteLogin(ctx, account, nil); err != nil { + _ = s.sessions.Delete(ctx, "Bearer "+token) + return LoginResult{}, err + } + _ = s.sessions.DeleteToken(ctx, tempToken) + return LoginResult{Token: token, Account: account}, nil +} + +func (s *Service) SetupTOTP(ctx context.Context, account Account, password string) (TOTPSetupResult, error) { + if account.TOTPEnabled { + return TOTPSetupResult{}, ErrTOTPAlreadyEnabled + } + if !s.hasher.Verify(password, account.PasswordHash) { + return TOTPSetupResult{}, ErrInvalidCredentials + } + secret, err := GenerateTOTPSecret() + if err != nil { + return TOTPSetupResult{}, err + } + encrypted, version, err := s.totpCipher.Encrypt([]byte(secret)) + if err != nil { + return TOTPSetupResult{}, err + } + if err := s.repository.SetTOTPSecret(ctx, account, encrypted, version); err != nil { + return TOTPSetupResult{}, err + } + return TOTPSetupResult{Secret: secret, ProvisioningURI: TOTPProvisioningURI(secret, account.Kind, account.Login)}, nil +} + +func (s *Service) ConfirmTOTP(ctx context.Context, account Account, code string) ([]string, error) { + account, err := s.findByID(ctx, account.Kind, account.ID) + if err != nil { + return nil, err + } + if account.TOTPEnabled { + return nil, ErrTOTPAlreadyEnabled + } + secret, err := s.decryptTOTPSecret(account) + if err != nil { + return nil, err + } + step, valid := VerifyTOTP(secret, code, s.now()) + if !valid { + return nil, ErrInvalidTOTP + } + codes, records, err := GenerateBackupCodes() + if err != nil { + return nil, err + } + if err := s.repository.EnableTOTP(ctx, account, step, records); err != nil { + return nil, err + } + return codes, nil +} + +func (s *Service) DisableTOTP(ctx context.Context, account Account, password, code, backupCode string) error { + account, err := s.findByID(ctx, account.Kind, account.ID) + if err != nil { + return err + } + if !account.TOTPEnabled { + return ErrTOTPNotEnabled + } + if !s.hasher.Verify(password, account.PasswordHash) { + return ErrInvalidCredentials + } + valid, err := s.verifyAndConsumeFactor(ctx, account, code, backupCode) + if err != nil { + return err + } + if !valid { + return ErrInvalidTOTP + } + return s.repository.DisableTOTP(ctx, account) +} + +func (s *Service) RegenerateBackupCodes(ctx context.Context, account Account, password, code, backupCode string) ([]string, error) { + account, err := s.findByID(ctx, account.Kind, account.ID) + if err != nil { + return nil, err + } + if !account.TOTPEnabled { + return nil, ErrTOTPNotEnabled + } + if !s.hasher.Verify(password, account.PasswordHash) { + return nil, ErrInvalidCredentials + } + valid, err := s.verifyAndConsumeFactor(ctx, account, code, backupCode) + if err != nil { + return nil, err + } + if !valid { + return nil, ErrInvalidTOTP + } + codes, records, err := GenerateBackupCodes() + if err != nil { + return nil, err + } + if err := s.repository.ReplaceBackupCodes(ctx, account, records); err != nil { + return nil, err + } + return codes, nil +} + +func (s *Service) verifyAndConsumeFactor(ctx context.Context, account Account, code, backupCode string) (bool, error) { + if strings.TrimSpace(backupCode) != "" { + return s.repository.ConsumeBackupCode(ctx, account, HashBackupCode(backupCode)) + } + secret, err := s.decryptTOTPSecret(account) + if err != nil { + return false, err + } + step, valid := VerifyTOTP(secret, code, s.now()) + if !valid { + return false, nil + } + return s.repository.ConsumeTOTPStep(ctx, account, step) +} + +func (s *Service) decryptTOTPSecret(account Account) (string, error) { + if len(account.EncryptedTOTPSecret) == 0 || account.TOTPKekVersion == nil { + return "", ErrTOTPSetupRequired + } + plaintext, err := s.totpCipher.Decrypt(account.EncryptedTOTPSecret, *account.TOTPKekVersion) + if err != nil { + return "", err + } + return string(plaintext), nil +} + +func (s *Service) Authenticate(ctx context.Context, kind Kind, authorization string) (Account, error) { + principal, err := s.sessions.Authenticate(ctx, authorization, kind) + if err != nil { + return Account{}, err + } + var account Account + if kind == KindAdmin { + account, err = s.repository.FindAdminByID(ctx, principal.SubjectID) + } else { + account, err = s.repository.FindPortalByID(ctx, principal.SubjectID) + } + if errors.Is(err, ErrNotFound) { + return Account{}, ErrInvalidSession + } + if err != nil { + return Account{}, err + } + if !account.Active { + return Account{}, ErrAccountDisabled + } + return account, nil +} + +func (s *Service) Logout(ctx context.Context, authorization string) error { + return s.sessions.Delete(ctx, authorization) +} + +// ChangePassword updates the authenticated account password after verifying the +// current password. Externally provisioned portal accounts without a local +// password may set their first password without an old-password check. +func (s *Service) ChangePassword(ctx context.Context, account Account, oldPassword, newPassword string) error { + account, err := s.findByID(ctx, account.Kind, account.ID) + if err != nil { + return err + } + if account.PasswordHash != "" && !s.hasher.Verify(oldPassword, account.PasswordHash) { + return ErrInvalidCredentials + } + if len(newPassword) < 12 || len(newPassword) > 1024 { + return errors.New("new password must contain 12 to 1024 characters") + } + hash, err := s.hasher.Hash(newPassword) + if err != nil { + return err + } + return s.repository.SetPassword(ctx, account, hash) +} + +func (s *Service) findByLogin(ctx context.Context, kind Kind, login string) (Account, error) { + if kind == KindAdmin { + return s.repository.FindAdminByLogin(ctx, login) + } + return s.repository.FindPortalByLogin(ctx, login) +} + +func (s *Service) findByID(ctx context.Context, kind Kind, id string) (Account, error) { + if kind == KindAdmin { + return s.repository.FindAdminByID(ctx, id) + } + return s.repository.FindPortalByID(ctx, id) +} + +func principalFor(account Account) Principal { + return Principal{Kind: account.Kind, SubjectID: account.ID, Login: account.Login, DisplayName: account.DisplayName, Role: account.Role} +} diff --git a/internal/identity/session.go b/internal/identity/session.go new file mode 100644 index 0000000..837b21f --- /dev/null +++ b/internal/identity/session.go @@ -0,0 +1,211 @@ +package identity + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/redis/go-redis/v9" +) + +var ErrInvalidSession = errors.New("invalid or expired session") + +type Principal struct { + Kind Kind `json:"kind"` + SubjectID string `json:"subject_id"` + Login string `json:"login"` + DisplayName string `json:"display_name"` + Role string `json:"role,omitempty"` + Purpose string `json:"purpose"` + IssuedAt int64 `json:"issued_at"` +} + +type SessionStore struct { + client *redis.Client + ttl time.Duration +} + +func NewSessionStore(client *redis.Client, ttl time.Duration) *SessionStore { + return &SessionStore{client: client, ttl: ttl} +} + +func (s *SessionStore) Create(ctx context.Context, principal Principal) (string, error) { + principal.Purpose = "session" + return s.create(ctx, principal, s.ttl) +} + +func (s *SessionStore) CreatePending(ctx context.Context, principal Principal, ttl time.Duration) (string, error) { + principal.Purpose = "totp_pending" + return s.create(ctx, principal, ttl) +} + +func (s *SessionStore) create(ctx context.Context, principal Principal, ttl time.Duration) (string, error) { + if s.client == nil { + return "", ErrUnavailable + } + random := make([]byte, 32) + if _, err := rand.Read(random); err != nil { + return "", err + } + token := base64.RawURLEncoding.EncodeToString(random) + principal.IssuedAt = time.Now().Unix() + payload, err := json.Marshal(principal) + if err != nil { + return "", err + } + if err := s.client.Set(ctx, sessionKey(token), payload, ttl).Err(); err != nil { + return "", fmt.Errorf("%w: %v", ErrUnavailable, err) + } + return token, nil +} + +func (s *SessionStore) Authenticate(ctx context.Context, authorization string, expected Kind) (Principal, error) { + if s.client == nil { + return Principal{}, ErrUnavailable + } + token, ok := bearerToken(authorization) + if !ok { + return Principal{}, ErrInvalidSession + } + payload, err := s.client.Get(ctx, sessionKey(token)).Bytes() + if errors.Is(err, redis.Nil) { + return Principal{}, ErrInvalidSession + } + if err != nil { + return Principal{}, fmt.Errorf("%w: %v", ErrUnavailable, err) + } + var principal Principal + if err := json.Unmarshal(payload, &principal); err != nil || principal.Kind != expected || principal.SubjectID == "" || principal.Purpose != "session" { + return Principal{}, ErrInvalidSession + } + return principal, nil +} + +func (s *SessionStore) AuthenticatePending(ctx context.Context, token string, expected Kind) (Principal, error) { + principal, err := s.authenticateToken(ctx, token) + if err != nil || principal.Kind != expected || principal.SubjectID == "" || principal.Purpose != "totp_pending" { + return Principal{}, ErrInvalidSession + } + return principal, nil +} + +func (s *SessionStore) authenticateToken(ctx context.Context, token string) (Principal, error) { + if s.client == nil { + return Principal{}, ErrUnavailable + } + payload, err := s.client.Get(ctx, sessionKey(strings.TrimSpace(token))).Bytes() + if errors.Is(err, redis.Nil) { + return Principal{}, ErrInvalidSession + } + if err != nil { + return Principal{}, fmt.Errorf("%w: %v", ErrUnavailable, err) + } + var principal Principal + if err := json.Unmarshal(payload, &principal); err != nil { + return Principal{}, ErrInvalidSession + } + return principal, nil +} + +func (s *SessionStore) DeleteToken(ctx context.Context, token string) error { + if s.client == nil { + return ErrUnavailable + } + if err := s.client.Del(ctx, sessionKey(strings.TrimSpace(token))).Err(); err != nil { + return fmt.Errorf("%w: %v", ErrUnavailable, err) + } + return nil +} + +func (s *SessionStore) Delete(ctx context.Context, authorization string) error { + if s.client == nil { + return ErrUnavailable + } + token, ok := bearerToken(authorization) + if !ok { + return nil + } + if err := s.client.Del(ctx, sessionKey(token)).Err(); err != nil { + return fmt.Errorf("%w: %v", ErrUnavailable, err) + } + return nil +} + +func (s *SessionStore) StoreOneTime(ctx context.Context, namespace string, value any, ttl time.Duration) (string, error) { + if s.client == nil { + return "", ErrUnavailable + } + random := make([]byte, 32) + if _, err := rand.Read(random); err != nil { + return "", err + } + token := base64.RawURLEncoding.EncodeToString(random) + payload, err := json.Marshal(value) + if err != nil { + return "", err + } + if err := s.client.Set(ctx, oneTimeKey(namespace, token), payload, ttl).Err(); err != nil { + return "", fmt.Errorf("%w: %v", ErrUnavailable, err) + } + return token, nil +} + +func (s *SessionStore) ConsumeOneTime(ctx context.Context, namespace, token string, target any) error { + if s.client == nil { + return ErrUnavailable + } + payload, err := s.client.GetDel(ctx, oneTimeKey(namespace, strings.TrimSpace(token))).Bytes() + if errors.Is(err, redis.Nil) { + return ErrInvalidSession + } + if err != nil { + return fmt.Errorf("%w: %v", ErrUnavailable, err) + } + if err := json.Unmarshal(payload, target); err != nil { + return ErrInvalidSession + } + return nil +} + +// ClaimIdentifier atomically claims a caller-provided replay identifier for +// the TTL. It is used for signed protocol message IDs, not bearer secrets. +func (s *SessionStore) ClaimIdentifier(ctx context.Context, namespace, identifier string, ttl time.Duration) (bool, error) { + if s.client == nil { + return false, ErrUnavailable + } + identifier = strings.TrimSpace(identifier) + if identifier == "" || len(identifier) > 512 { + return false, ErrInvalidSession + } + claimed, err := s.client.SetNX(ctx, oneTimeKey(namespace, identifier), "1", ttl).Result() + if err != nil { + return false, fmt.Errorf("%w: %v", ErrUnavailable, err) + } + return claimed, nil +} + +func bearerToken(authorization string) (string, bool) { + authorization = strings.TrimSpace(authorization) + if len(authorization) <= len("Bearer ") || !strings.EqualFold(authorization[:len("Bearer ")], "Bearer ") { + return "", false + } + token := strings.TrimSpace(authorization[len("Bearer "):]) + return token, token != "" +} + +func sessionKey(token string) string { + digest := sha256.Sum256([]byte(token)) + return "gateway:session:v1:" + hex.EncodeToString(digest[:]) +} + +func oneTimeKey(namespace, token string) string { + digest := sha256.Sum256([]byte(token)) + return "gateway:one-time:" + namespace + ":" + hex.EncodeToString(digest[:]) +} diff --git a/internal/identity/totp.go b/internal/identity/totp.go new file mode 100644 index 0000000..e7f4cb4 --- /dev/null +++ b/internal/identity/totp.go @@ -0,0 +1,118 @@ +package identity + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha1" + "crypto/sha256" + "crypto/subtle" + "encoding/base32" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "net/url" + "strings" + "time" +) + +const ( + totpPeriod = int64(30) + totpDigits = 6 + totpWindow = int64(1) + backupCodeCount = 10 + backupCodeLength = 8 +) + +var backupAlphabet = []byte("23456789ABCDEFGHJKLMNPQRSTUVWXYZ") + +type BackupCodeRecord struct { + Hash string `json:"hash"` + UsedAt *time.Time `json:"used_at,omitempty"` +} + +func GenerateTOTPSecret() (string, error) { + secret := make([]byte, 20) + if _, err := rand.Read(secret); err != nil { + return "", err + } + return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(secret), nil +} + +func TOTPProvisioningURI(secret string, kind Kind, login string) string { + issuer := "AI Gateway" + label := issuer + ":" + string(kind) + ":" + login + query := url.Values{ + "secret": []string{secret}, + "issuer": []string{issuer}, + "algorithm": []string{"SHA1"}, + "digits": []string{fmt.Sprint(totpDigits)}, + "period": []string{fmt.Sprint(totpPeriod)}, + } + return "otpauth://totp/" + url.PathEscape(label) + "?" + query.Encode() +} + +func VerifyTOTP(secret, code string, now time.Time) (int64, bool) { + code = strings.TrimSpace(code) + if len(code) != totpDigits { + return 0, false + } + step := now.Unix() / totpPeriod + for offset := -totpWindow; offset <= totpWindow; offset++ { + candidate, err := hotp(secret, step+offset, totpDigits) + if err == nil && subtle.ConstantTimeCompare([]byte(candidate), []byte(code)) == 1 { + return step + offset, true + } + } + return 0, false +} + +func hotp(secret string, counter int64, digits int) (string, error) { + if counter < 0 || digits < 6 || digits > 8 { + return "", errors.New("invalid HOTP parameters") + } + decoded, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(strings.TrimSpace(secret))) + if err != nil { + return "", err + } + message := make([]byte, 8) + binary.BigEndian.PutUint64(message, uint64(counter)) + mac := hmac.New(sha1.New, decoded) + _, _ = mac.Write(message) + digest := mac.Sum(nil) + offset := digest[len(digest)-1] & 0x0f + value := (uint32(digest[offset])&0x7f)<<24 | + uint32(digest[offset+1])<<16 | + uint32(digest[offset+2])<<8 | + uint32(digest[offset+3]) + modulus := uint32(1) + for i := 0; i < digits; i++ { + modulus *= 10 + } + return fmt.Sprintf("%0*d", digits, value%modulus), nil +} + +func GenerateBackupCodes() ([]string, []BackupCodeRecord, error) { + codes := make([]string, 0, backupCodeCount) + records := make([]BackupCodeRecord, 0, backupCodeCount) + for range backupCodeCount { + random := make([]byte, backupCodeLength) + if _, err := rand.Read(random); err != nil { + return nil, nil, err + } + for index := range random { + random[index] = backupAlphabet[int(random[index])%len(backupAlphabet)] + } + raw := string(random) + code := raw[:4] + "-" + raw[4:] + codes = append(codes, code) + records = append(records, BackupCodeRecord{Hash: HashBackupCode(code)}) + } + return codes, records, nil +} + +func HashBackupCode(code string) string { + normalized := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(code), "-", "")) + digest := sha256.Sum256([]byte(normalized)) + return hex.EncodeToString(digest[:]) +} diff --git a/internal/identity/totp_test.go b/internal/identity/totp_test.go new file mode 100644 index 0000000..425d3da --- /dev/null +++ b/internal/identity/totp_test.go @@ -0,0 +1,43 @@ +package identity + +import ( + "testing" + "time" +) + +func TestHOTPUsesRFC6238Vector(t *testing.T) { + // RFC 6238 SHA-1 shared secret, time 59 seconds, 8-digit expected value. + code, err := hotp("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", 59/30, 8) + if err != nil { + t.Fatal(err) + } + if code != "94287082" { + t.Fatalf("got %s", code) + } +} + +func TestVerifyTOTPAcceptsWindow(t *testing.T) { + now := time.Unix(1_700_000_000, 0) + secret := "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" + code, err := hotp(secret, now.Unix()/30-1, 6) + if err != nil { + t.Fatal(err) + } + step, ok := VerifyTOTP(secret, code, now) + if !ok || step != now.Unix()/30-1 { + t.Fatalf("step=%d ok=%v", step, ok) + } +} + +func TestBackupCodeNormalization(t *testing.T) { + if HashBackupCode("abcd-2345") != HashBackupCode(" ABCD2345 ") { + t.Fatal("backup code normalization differs") + } + codes, records, err := GenerateBackupCodes() + if err != nil { + t.Fatal(err) + } + if len(codes) != 10 || len(records) != 10 || records[0].Hash != HashBackupCode(codes[0]) { + t.Fatal("invalid backup code generation") + } +} diff --git a/internal/operations/admin_http.go b/internal/operations/admin_http.go new file mode 100644 index 0000000..9f62e92 --- /dev/null +++ b/internal/operations/admin_http.go @@ -0,0 +1,84 @@ +package operations + +import ( + "context" + "net/http" + "runtime" + "time" + + "aigateway.local/core/internal/identity" + "aigateway.local/core/internal/platform/apiresponse" + "github.com/jackc/pgx/v5/pgxpool" +) + +type AdminHTTPHandler struct { + pool *pgxpool.Pool + identity *identity.Service + version string + startedAt time.Time + reload func(context.Context) error + mux *http.ServeMux +} + +func NewAdminHTTPHandler(pool *pgxpool.Pool, identityService *identity.Service, version string, startedAt time.Time, reload func(context.Context) error) *AdminHTTPHandler { + h := &AdminHTTPHandler{pool: pool, identity: identityService, version: version, startedAt: startedAt, reload: reload, mux: http.NewServeMux()} + h.mux.HandleFunc("GET /api/v1/admin/system-info", h.systemInfo) + h.mux.HandleFunc("GET /api/v1/admin/monitoring/overview", h.overview) + h.mux.HandleFunc("POST /api/v1/admin/reload", h.reloadSnapshots) + return h +} + +func (h *AdminHTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.mux.ServeHTTP(w, r) } + +func (h *AdminHTTPHandler) account(w http.ResponseWriter, r *http.Request) (identity.Account, bool) { + account, err := h.identity.Authenticate(r.Context(), identity.KindAdmin, r.Header.Get("Authorization")) + if err != nil { + apiresponse.Error(w, http.StatusUnauthorized, "登录状态无效") + return identity.Account{}, false + } + return account, true +} + +func (h *AdminHTTPHandler) systemInfo(w http.ResponseWriter, r *http.Request) { + if _, ok := h.account(w, r); !ok { + return + } + var providers, models, keys int64 + if err := h.pool.QueryRow(r.Context(), `SELECT (SELECT count(*) FROM gateway.providers WHERE enabled),(SELECT count(*) FROM gateway.provider_models WHERE enabled),(SELECT count(*) FROM gateway.api_keys WHERE enabled)`).Scan(&providers, &models, &keys); err != nil { + apiresponse.Error(w, http.StatusServiceUnavailable, "系统信息查询失败") + return + } + apiresponse.OK(w, map[string]any{"version": h.version, "go_version": runtime.Version(), "uptime_seconds": int64(time.Since(h.startedAt).Seconds()), "database": "postgresql", "object_storage": false, "clickhouse": false, "enabled_providers": providers, "enabled_models": models, "enabled_api_keys": keys}) +} + +func (h *AdminHTTPHandler) overview(w http.ResponseWriter, r *http.Request) { + if _, ok := h.account(w, r); !ok { + return + } + var requests, failures, promptTokens, completionTokens, cost int64 + var latency *float64 + err := h.pool.QueryRow(r.Context(), `SELECT count(*),count(*) FILTER(WHERE coalesce(status_code,500)>=400),coalesce(sum(prompt_tokens),0),coalesce(sum(completion_tokens),0),coalesce(sum(cost_microunits),0),avg(latency_ms) FROM gateway.audit_events WHERE recorded_at>=clock_timestamp()-interval '24 hours'`).Scan(&requests, &failures, &promptTokens, &completionTokens, &cost, &latency) + if err != nil { + apiresponse.Error(w, http.StatusServiceUnavailable, "监控汇总查询失败") + return + } + apiresponse.OK(w, map[string]any{"window": "24h", "requests": requests, "failed_requests": failures, "prompt_tokens": promptTokens, "completion_tokens": completionTokens, "cost_microunits": cost, "avg_latency_ms": latency}) +} + +func (h *AdminHTTPHandler) reloadSnapshots(w http.ResponseWriter, r *http.Request) { + account, ok := h.account(w, r) + if !ok { + return + } + if !identity.HasPermission(account, identity.PermissionProviderManage) { + apiresponse.Error(w, http.StatusForbidden, "缺少运行时配置管理权限") + return + } + if h.reload != nil { + if err := h.reload(r.Context()); err != nil { + apiresponse.Error(w, http.StatusServiceUnavailable, "运行时快照刷新不完整: "+err.Error()) + return + } + } + apiresponse.OK(w, map[string]bool{"reloaded": true}) +} diff --git a/internal/outbox/admin_http.go b/internal/outbox/admin_http.go new file mode 100644 index 0000000..719489e --- /dev/null +++ b/internal/outbox/admin_http.go @@ -0,0 +1,95 @@ +package outbox + +import ( + "errors" + "net/http" + "strconv" + "strings" + + "aigateway.local/core/internal/identity" + "aigateway.local/core/internal/platform/apiresponse" + "github.com/jackc/pgx/v5/pgtype" +) + +type AdminHTTPHandler struct { + store *Store + identity *identity.Service + mux *http.ServeMux +} + +func NewAdminHTTPHandler(store *Store, identityService *identity.Service) *AdminHTTPHandler { + h := &AdminHTTPHandler{store: store, identity: identityService, mux: http.NewServeMux()} + h.mux.HandleFunc("GET /api/v1/admin/outbox-events", h.list) + h.mux.HandleFunc("POST /api/v1/admin/outbox-events/{event_id}/retry", h.retry) + return h +} + +func (h *AdminHTTPHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) { + h.mux.ServeHTTP(writer, request) +} + +func (h *AdminHTTPHandler) list(writer http.ResponseWriter, request *http.Request) { + if !h.requirePermission(writer, request, identity.PermissionOutboxRead) { + return + } + status := strings.TrimSpace(request.URL.Query().Get("status")) + if status != "" && status != "pending" && status != "dead" && status != "processed" { + apiresponse.Error(writer, http.StatusBadRequest, "status 必须是 pending、dead 或 processed") + return + } + limit := 100 + if value := request.URL.Query().Get("limit"); value != "" { + parsed, err := strconv.Atoi(value) + if err != nil || parsed < 1 || parsed > 500 { + apiresponse.Error(writer, http.StatusBadRequest, "limit 必须在 1 到 500 之间") + return + } + limit = parsed + } + items, err := h.store.List(request.Context(), status, strings.TrimSpace(request.URL.Query().Get("type")), limit) + if err != nil { + apiresponse.Error(writer, http.StatusServiceUnavailable, "事件投递查询服务暂不可用") + return + } + apiresponse.OK(writer, items) +} + +func (h *AdminHTTPHandler) retry(writer http.ResponseWriter, request *http.Request) { + if !h.requirePermission(writer, request, identity.PermissionOutboxManage) { + return + } + eventID := strings.TrimSpace(request.PathValue("event_id")) + var parsedID pgtype.UUID + if err := parsedID.Scan(eventID); err != nil || !parsedID.Valid { + apiresponse.Error(writer, http.StatusBadRequest, "event_id 必须是有效 UUID") + return + } + if err := h.store.Retry(request.Context(), eventID); err != nil { + if errors.Is(err, ErrEventNotFound) { + apiresponse.Error(writer, http.StatusNotFound, "待处理或死信事件不存在") + return + } + apiresponse.Error(writer, http.StatusServiceUnavailable, "事件重试服务暂不可用") + return + } + apiresponse.OK(writer, map[string]bool{"retried": true}) +} + +func (h *AdminHTTPHandler) requirePermission(writer http.ResponseWriter, request *http.Request, permission string) bool { + account, err := h.identity.Authenticate(request.Context(), identity.KindAdmin, request.Header.Get("Authorization")) + if err != nil { + if errors.Is(err, identity.ErrInvalidSession) || errors.Is(err, identity.ErrNotFound) { + apiresponse.Error(writer, http.StatusUnauthorized, "登录状态无效或已过期") + } else if errors.Is(err, identity.ErrAccountDisabled) { + apiresponse.Error(writer, http.StatusForbidden, "管理员账号已被停用") + } else { + apiresponse.Error(writer, http.StatusServiceUnavailable, "身份服务暂不可用") + } + return false + } + if !identity.HasPermission(account, permission) { + apiresponse.Error(writer, http.StatusForbidden, "缺少事件投递操作权限") + return false + } + return true +} diff --git a/internal/outbox/consumer_integration_test.go b/internal/outbox/consumer_integration_test.go new file mode 100644 index 0000000..7d18d77 --- /dev/null +++ b/internal/outbox/consumer_integration_test.go @@ -0,0 +1,55 @@ +package outbox + +import ( + "context" + "errors" + "os" + "testing" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +func TestConsumeTransactionRollbackAndIdempotency(t *testing.T) { + databaseURL := os.Getenv("OUTBOX_TEST_DATABASE_URL") + if databaseURL == "" { + t.Skip("OUTBOX_TEST_DATABASE_URL is not configured") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + defer pool.Close() + store := NewStore(pool) + const subscriber = "outbox-integration-test" + const eventID = "33333333-3333-4333-8333-333333333333" + _, _ = pool.Exec(ctx, `DELETE FROM gateway.event_consumptions WHERE subscriber=$1 AND event_id=$2`, subscriber, eventID) + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM gateway.event_consumptions WHERE subscriber=$1 AND event_id=$2`, subscriber, eventID) + }) + + calls := 0 + wantErr := errors.New("rollback handler") + consumed, err := store.Consume(ctx, subscriber, eventID, func(context.Context, pgx.Tx) error { + calls++ + return wantErr + }) + if consumed || !errors.Is(err, wantErr) { + t.Fatalf("failed handler must roll back: consumed=%v err=%v", consumed, err) + } + consumed, err = store.Consume(ctx, subscriber, eventID, func(context.Context, pgx.Tx) error { + calls++ + return nil + }) + if !consumed || err != nil { + t.Fatalf("second attempt must consume: consumed=%v err=%v", consumed, err) + } + consumed, err = store.Consume(ctx, subscriber, eventID, func(context.Context, pgx.Tx) error { + calls++ + return nil + }) + if consumed || err != nil || calls != 2 { + t.Fatalf("duplicate must skip handler: consumed=%v calls=%d err=%v", consumed, calls, err) + } +} diff --git a/internal/outbox/publisher.go b/internal/outbox/publisher.go new file mode 100644 index 0000000..b5da8a8 --- /dev/null +++ b/internal/outbox/publisher.go @@ -0,0 +1,98 @@ +package outbox + +import ( + "context" + "errors" + "fmt" + "strconv" + "time" + + "github.com/redis/go-redis/v9" +) + +var ErrPublisherUnavailable = errors.New("outbox publisher unavailable") + +type PublishResult struct { + Published bool + StreamID string +} + +type RedisPublisher struct { + client *redis.Client + stream string + maxLength int64 + markerTTL time.Duration + script *redis.Script +} + +func NewRedisPublisher(client *redis.Client, stream string, maxLength int64, markerTTL time.Duration) *RedisPublisher { + return &RedisPublisher{client: client, stream: stream, maxLength: maxLength, markerTTL: markerTTL, script: redis.NewScript(publishScript)} +} + +// Ping reports whether the downstream Redis stream is reachable. The outbox +// worker calls it before claiming events so a Redis outage never consumes the +// events' delivery budget (claiming increments attempts; dead-lettering then +// burns the whole queue for a fault that was never the events'). +func (p *RedisPublisher) Ping(ctx context.Context) error { + if p == nil || p.client == nil { + return ErrPublisherUnavailable + } + if err := p.client.Ping(ctx).Err(); err != nil { + return fmt.Errorf("%w: %v", ErrPublisherUnavailable, err) + } + return nil +} + +func (p *RedisPublisher) Publish(ctx context.Context, event Event) (PublishResult, error) { + if p == nil || p.client == nil { + return PublishResult{}, ErrPublisherUnavailable + } + marker := "gateway:{outbox}:published:" + event.EventID + result, err := p.script.Run(ctx, p.client, []string{marker, p.stream}, + int64(p.markerTTL.Seconds()), p.maxLength, event.EventID, event.EventType, event.EventVersion, + valueOrEmpty(event.TenantID), event.AggregateType, event.AggregateID, string(event.Payload), string(event.TraceContext), event.OccurredAt.UTC().Format(time.RFC3339Nano), + ).Slice() + if err != nil || len(result) != 2 { + return PublishResult{}, fmt.Errorf("%w: %v", ErrPublisherUnavailable, err) + } + published, err := redisInt(result[0]) + if err != nil { + return PublishResult{}, fmt.Errorf("%w: %v", ErrPublisherUnavailable, err) + } + streamID := "duplicate" + if result[1] != nil && fmt.Sprint(result[1]) != "" { + streamID = fmt.Sprint(result[1]) + } + return PublishResult{Published: published == 1, StreamID: streamID}, nil +} + +func valueOrEmpty(value *string) string { + if value == nil { + return "" + } + return *value +} + +func redisInt(value any) (int64, error) { + switch typed := value.(type) { + case int64: + return typed, nil + case string: + return strconv.ParseInt(typed, 10, 64) + case []byte: + return strconv.ParseInt(string(typed), 10, 64) + default: + return 0, fmt.Errorf("unexpected redis integer %T", value) + } +} + +const publishScript = ` +if redis.call('SET', KEYS[1], '1', 'NX', 'EX', tonumber(ARGV[1])) then + local id = redis.call('XADD', KEYS[2], 'MAXLEN', '~', tonumber(ARGV[2]), '*', + 'event_id', ARGV[3], 'event_type', ARGV[4], 'event_version', ARGV[5], + 'tenant_id', ARGV[6], 'aggregate_type', ARGV[7], 'aggregate_id', ARGV[8], + 'payload', ARGV[9], 'trace_context', ARGV[10], 'occurred_at', ARGV[11]) + return {1, id} +end +return {0, ''} +` diff --git a/internal/outbox/store.go b/internal/outbox/store.go new file mode 100644 index 0000000..563e4fc --- /dev/null +++ b/internal/outbox/store.go @@ -0,0 +1,201 @@ +package outbox + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +var ( + ErrStoreUnavailable = errors.New("outbox store unavailable") + ErrEventNotFound = errors.New("outbox event not found") +) + +type Event struct { + EventID string `json:"event_id"` + EventType string `json:"event_type"` + EventVersion int `json:"event_version"` + TenantID *string `json:"tenant_id"` + AggregateType string `json:"aggregate_type"` + AggregateID string `json:"aggregate_id"` + Payload json.RawMessage `json:"payload"` + TraceContext json.RawMessage `json:"trace_context"` + OccurredAt time.Time `json:"occurred_at"` + Attempts int `json:"attempts"` +} + +type Store struct{ pool *pgxpool.Pool } + +func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} } + +func (s *Store) Claim(ctx context.Context, workerID string, limit int, lease time.Duration) ([]Event, error) { + if s == nil || s.pool == nil { + return nil, ErrStoreUnavailable + } + rows, err := s.pool.Query(ctx, ` + WITH candidates AS ( + SELECT event_id FROM gateway.outbox_events + WHERE processed_at IS NULL AND dead_lettered_at IS NULL AND available_at <= clock_timestamp() + AND (locked_at IS NULL OR locked_at < clock_timestamp()-($1 * interval '1 second')) + ORDER BY available_at,occurred_at + FOR UPDATE SKIP LOCKED LIMIT $2 + ) + UPDATE gateway.outbox_events e + SET locked_at=clock_timestamp(),locked_by=$3,attempts=e.attempts+1 + FROM candidates c WHERE e.event_id=c.event_id + RETURNING e.event_id::text,e.event_type,e.event_version,e.tenant_id::text,e.aggregate_type, + e.aggregate_id,e.payload,e.trace_context,e.occurred_at,e.attempts`, lease.Seconds(), limit, workerID) + if err != nil { + return nil, fmt.Errorf("%w: claim: %v", ErrStoreUnavailable, err) + } + defer rows.Close() + events := make([]Event, 0, limit) + for rows.Next() { + var event Event + if err := rows.Scan(&event.EventID, &event.EventType, &event.EventVersion, &event.TenantID, + &event.AggregateType, &event.AggregateID, &event.Payload, &event.TraceContext, &event.OccurredAt, &event.Attempts); err != nil { + return nil, fmt.Errorf("%w: scan claim: %v", ErrStoreUnavailable, err) + } + events = append(events, event) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("%w: claim rows: %v", ErrStoreUnavailable, err) + } + return events, nil +} + +func (s *Store) MarkProcessed(ctx context.Context, eventID, workerID, streamID string) error { + if s == nil || s.pool == nil { + return ErrStoreUnavailable + } + result, err := s.pool.Exec(ctx, `UPDATE gateway.outbox_events SET processed_at=clock_timestamp(),locked_at=NULL,locked_by=NULL,last_error=NULL,published_stream_id=$3 WHERE event_id=$1 AND locked_by=$2 AND processed_at IS NULL`, eventID, workerID, streamID) + if err != nil { + return fmt.Errorf("%w: mark processed: %v", ErrStoreUnavailable, err) + } + if result.RowsAffected() != 1 { + return ErrEventNotFound + } + return nil +} + +func (s *Store) MarkFailed(ctx context.Context, event Event, workerID string, deliveryErr error, maxAttempts int, delay time.Duration) error { + if s == nil || s.pool == nil { + return ErrStoreUnavailable + } + message := deliveryErr.Error() + if len(message) > 2048 { + message = message[:2048] + } + dead := event.Attempts >= maxAttempts + result, err := s.pool.Exec(ctx, ` + UPDATE gateway.outbox_events SET locked_at=NULL,locked_by=NULL,last_error=$3, + available_at=CASE WHEN $4 THEN available_at ELSE clock_timestamp()+($5 * interval '1 second') END, + dead_lettered_at=CASE WHEN $4 THEN clock_timestamp() ELSE NULL END + WHERE event_id=$1 AND locked_by=$2 AND processed_at IS NULL`, event.EventID, workerID, message, dead, delay.Seconds()) + if err != nil { + return fmt.Errorf("%w: mark failed: %v", ErrStoreUnavailable, err) + } + if result.RowsAffected() != 1 { + return ErrEventNotFound + } + return nil +} + +type EventView struct { + Event + AvailableAt time.Time `json:"available_at"` + LockedAt *time.Time `json:"locked_at"` + LockedBy *string `json:"locked_by"` + ProcessedAt *time.Time `json:"processed_at"` + DeadLetteredAt *time.Time `json:"dead_lettered_at"` + LastError *string `json:"last_error"` + PublishedStream *string `json:"published_stream_id"` +} + +func (s *Store) List(ctx context.Context, status, eventType string, limit int) ([]EventView, error) { + if s == nil || s.pool == nil { + return nil, ErrStoreUnavailable + } + where := "TRUE" + switch status { + case "pending": + where = "processed_at IS NULL AND dead_lettered_at IS NULL" + case "dead": + where = "dead_lettered_at IS NOT NULL" + case "processed": + where = "processed_at IS NOT NULL" + } + args := []any{limit} + if eventType != "" { + args = append(args, eventType) + where += fmt.Sprintf(" AND event_type=$%d", len(args)) + } + rows, err := s.pool.Query(ctx, `SELECT event_id::text,event_type,event_version,tenant_id::text,aggregate_type,aggregate_id,payload,trace_context,occurred_at,attempts,available_at,locked_at,locked_by,processed_at,dead_lettered_at,last_error,published_stream_id FROM gateway.outbox_events WHERE `+where+` ORDER BY occurred_at DESC LIMIT $1`, args...) + if err != nil { + return nil, fmt.Errorf("%w: list: %v", ErrStoreUnavailable, err) + } + defer rows.Close() + items := make([]EventView, 0) + for rows.Next() { + var item EventView + if err := rows.Scan(&item.EventID, &item.EventType, &item.EventVersion, &item.TenantID, &item.AggregateType, + &item.AggregateID, &item.Payload, &item.TraceContext, &item.OccurredAt, &item.Attempts, &item.AvailableAt, + &item.LockedAt, &item.LockedBy, &item.ProcessedAt, &item.DeadLetteredAt, &item.LastError, &item.PublishedStream); err != nil { + return nil, fmt.Errorf("%w: scan list: %v", ErrStoreUnavailable, err) + } + items = append(items, item) + } + return items, rows.Err() +} + +func (s *Store) Retry(ctx context.Context, eventID string) error { + if s == nil || s.pool == nil { + return ErrStoreUnavailable + } + result, err := s.pool.Exec(ctx, `UPDATE gateway.outbox_events SET attempts=0,available_at=clock_timestamp(),locked_at=NULL,locked_by=NULL,dead_lettered_at=NULL,last_error=NULL WHERE event_id=$1 AND processed_at IS NULL`, eventID) + if err != nil { + return fmt.Errorf("%w: retry: %v", ErrStoreUnavailable, err) + } + if result.RowsAffected() != 1 { + return ErrEventNotFound + } + return nil +} + +type ConsumerHandler func(context.Context, pgx.Tx) error + +func (s *Store) Consume(ctx context.Context, subscriber, eventID string, handler ConsumerHandler) (bool, error) { + if s == nil || s.pool == nil { + return false, ErrStoreUnavailable + } + subscriber = strings.TrimSpace(subscriber) + if subscriber == "" || eventID == "" || handler == nil { + return false, errors.New("subscriber, event ID, and handler are required") + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return false, fmt.Errorf("%w: begin consumption: %v", ErrStoreUnavailable, err) + } + defer func() { _ = tx.Rollback(ctx) }() + var inserted bool + err = tx.QueryRow(ctx, `WITH inserted AS (INSERT INTO gateway.event_consumptions(subscriber,event_id) VALUES($1,$2) ON CONFLICT DO NOTHING RETURNING 1) SELECT EXISTS(SELECT 1 FROM inserted)`, subscriber, eventID).Scan(&inserted) + if err != nil { + return false, fmt.Errorf("%w: reserve consumption: %v", ErrStoreUnavailable, err) + } + if !inserted { + return false, nil + } + if err := handler(ctx, tx); err != nil { + return false, err + } + if err := tx.Commit(ctx); err != nil { + return false, fmt.Errorf("%w: commit consumption: %v", ErrStoreUnavailable, err) + } + return true, nil +} diff --git a/internal/outbox/worker.go b/internal/outbox/worker.go new file mode 100644 index 0000000..01610a3 --- /dev/null +++ b/internal/outbox/worker.go @@ -0,0 +1,91 @@ +package outbox + +import ( + "context" + "errors" + "log/slog" + "math" + "time" +) + +type WorkerConfig struct { + WorkerID string + BatchSize int + PollInterval time.Duration + Lease time.Duration + MaxAttempts int + MaxBackoff time.Duration +} + +type Worker struct { + store *Store + publisher *RedisPublisher + config WorkerConfig + logger *slog.Logger +} + +func NewWorker(store *Store, publisher *RedisPublisher, config WorkerConfig, logger *slog.Logger) *Worker { + return &Worker{store: store, publisher: publisher, config: config, logger: logger} +} + +func (w *Worker) Run(ctx context.Context) error { + for { + processed, err := w.runBatch(ctx) + if err != nil && ctx.Err() == nil && w.logger != nil { + w.logger.Error("outbox batch failed", "error", err) + } + if ctx.Err() != nil { + return nil + } + if err == nil && processed > 0 { + continue + } + timer := time.NewTimer(w.config.PollInterval) + select { + case <-ctx.Done(): + timer.Stop() + return nil + case <-timer.C: + } + } +} + +func (w *Worker) runBatch(ctx context.Context) (int, error) { + // Gate on the downstream publisher before claiming anything. Claiming + // increments each event's attempt counter, so claiming during a Redis + // outage would burn every queued event's delivery budget and dead-letter + // the whole queue the moment the budget ran out — even though the events + // themselves were never at fault. When Redis is unreachable we back off + // instead, leaving events untouched in PostgreSQL until it recovers. + if err := w.publisher.Ping(ctx); err != nil { + return 0, err + } + events, err := w.store.Claim(ctx, w.config.WorkerID, w.config.BatchSize, w.config.Lease) + if err != nil { + return 0, err + } + var batchErr error + for _, event := range events { + result, publishErr := w.publisher.Publish(ctx, event) + if publishErr == nil { + if err := w.store.MarkProcessed(ctx, event.EventID, w.config.WorkerID, result.StreamID); err != nil { + batchErr = errors.Join(batchErr, err) + } + continue + } + delay := retryDelay(event.Attempts, w.config.MaxBackoff) + if err := w.store.MarkFailed(ctx, event, w.config.WorkerID, publishErr, w.config.MaxAttempts, delay); err != nil { + batchErr = errors.Join(batchErr, err) + } + } + return len(events), batchErr +} + +func retryDelay(attempt int, maximum time.Duration) time.Duration { + seconds := math.Pow(2, float64(max(attempt-1, 0))) + delay := time.Duration(seconds * float64(time.Second)) + if delay > maximum { + return maximum + } + return delay +} diff --git a/internal/outbox/worker_test.go b/internal/outbox/worker_test.go new file mode 100644 index 0000000..77c8ac0 --- /dev/null +++ b/internal/outbox/worker_test.go @@ -0,0 +1,18 @@ +package outbox + +import ( + "testing" + "time" +) + +func TestRetryDelayIsExponentialAndCapped(t *testing.T) { + tests := []struct { + attempt int + want time.Duration + }{{1, time.Second}, {2, 2 * time.Second}, {3, 4 * time.Second}, {20, 5 * time.Second}} + for _, test := range tests { + if got := retryDelay(test.attempt, 5*time.Second); got != test.want { + t.Fatalf("attempt %d: got %s, want %s", test.attempt, got, test.want) + } + } +} diff --git a/internal/platform/apiresponse/response.go b/internal/platform/apiresponse/response.go new file mode 100644 index 0000000..c918185 --- /dev/null +++ b/internal/platform/apiresponse/response.go @@ -0,0 +1,26 @@ +package apiresponse + +import ( + "encoding/json" + "net/http" +) + +type Envelope struct { + Code int `json:"code"` + Message string `json:"msg"` + Data any `json:"data,omitempty"` +} + +func OK(writer http.ResponseWriter, data any) { + Write(writer, http.StatusOK, Envelope{Code: http.StatusOK, Message: "success", Data: data}) +} + +func Error(writer http.ResponseWriter, status int, message string) { + Write(writer, status, Envelope{Code: status, Message: message}) +} + +func Write(writer http.ResponseWriter, status int, value any) { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(status) + _ = json.NewEncoder(writer).Encode(value) +} diff --git a/internal/platform/cache/redis.go b/internal/platform/cache/redis.go new file mode 100644 index 0000000..2ae2960 --- /dev/null +++ b/internal/platform/cache/redis.go @@ -0,0 +1,23 @@ +package cache + +import ( + "fmt" + "time" + + "github.com/redis/go-redis/v9" +) + +func Open(rawURL string) (*redis.Client, error) { + if rawURL == "" { + return nil, nil + } + options, err := redis.ParseURL(rawURL) + if err != nil { + return nil, fmt.Errorf("parse redis URL: %w", err) + } + options.DialTimeout = 2 * time.Second + options.ReadTimeout = time.Second + options.WriteTimeout = time.Second + options.PoolTimeout = 2 * time.Second + return redis.NewClient(options), nil +} diff --git a/internal/platform/config/config.go b/internal/platform/config/config.go new file mode 100644 index 0000000..9ba3a7c --- /dev/null +++ b/internal/platform/config/config.go @@ -0,0 +1,348 @@ +package config + +import ( + "errors" + "fmt" + "net/url" + "os" + "strconv" + "strings" + "time" +) + +type Config struct { + Environment string + Server Server + Database Database + Redis Redis + Security Security + Auth Auth + Credentials Credentials + Upstream Upstream + Audit Audit + Outbox Outbox + RuntimeData RuntimeData + Shadow Shadow +} + +type Server struct { + Address string + ReadHeaderTimeout time.Duration + IdleTimeout time.Duration + ShutdownTimeout time.Duration + MaxBodyBytes int64 +} + +type Database struct { + URL string + MaxConns int32 + MinConns int32 +} + +type Redis struct { + CriticalURL string + CacheURL string +} + +type Security struct { + BootstrapAPIKey string + BootstrapAPIKeyEnabled bool +} + +type Auth struct { + SessionTTL time.Duration + TOTPChallengeTTL time.Duration + MaxFailures int + LockDuration time.Duration + LoginRateLimitMax int // 单 IP 滑动窗口内的最大登录尝试次数 + LoginRateLimitWindow time.Duration // 登录限流滑动窗口 +} + +type Credentials struct { + MasterKey string + KEKVersion int + KEKKeyring string + AllowPrivateProviderURL bool + AllowPrivateToolURL bool + AllowPrivateWebhookURL bool + ProviderRefreshInterval time.Duration +} + +type Upstream struct { + BaseURL string + APIKey string + FallbackEnabled bool + ResponseHeaderTimeout time.Duration + MaxRetries int + RetryBackoff time.Duration + CircuitThreshold int + CircuitOpenDuration time.Duration +} + +type Audit struct { + QueueSize int + BatchSize int + FlushInterval time.Duration + Retention time.Duration + UsageRetention time.Duration + PartitionMonthsAhead int + MaintenanceInterval time.Duration +} + +type Outbox struct { + Stream string + BatchSize int + PollInterval time.Duration + Lease time.Duration + MaxAttempts int + MaxBackoff time.Duration + StreamMaxLength int64 + MarkerTTL time.Duration +} + +type RuntimeData struct { + ContentPolicyRefreshInterval time.Duration + PricingRefreshInterval time.Duration +} + +type Shadow struct { + BaseURL string + APIKey string + SampleRate float64 + Timeout time.Duration + MaxBodyBytes int64 + MaxConcurrent int +} + +func Load() (Config, error) { + cfg := Config{ + Environment: env("APP_ENV", "local"), + Server: Server{ + Address: env("HTTP_ADDR", "127.0.0.1:8080"), + ReadHeaderTimeout: duration("HTTP_READ_HEADER_TIMEOUT", 5*time.Second), + IdleTimeout: duration("HTTP_IDLE_TIMEOUT", 120*time.Second), + ShutdownTimeout: duration("HTTP_SHUTDOWN_TIMEOUT", 20*time.Second), + MaxBodyBytes: int64Value("HTTP_MAX_BODY_BYTES", 32<<20), + }, + Database: Database{ + URL: strings.TrimSpace(os.Getenv("DATABASE_URL")), + MaxConns: int32(intValue("DATABASE_MAX_CONNS", 40)), + MinConns: int32(intValue("DATABASE_MIN_CONNS", 4)), + }, + Redis: Redis{ + CriticalURL: strings.TrimSpace(os.Getenv("REDIS_CRITICAL_URL")), + CacheURL: strings.TrimSpace(os.Getenv("REDIS_CACHE_URL")), + }, + Security: Security{ + BootstrapAPIKey: strings.TrimSpace(os.Getenv("GATEWAY_BOOTSTRAP_API_KEY")), + BootstrapAPIKeyEnabled: boolValue("GATEWAY_BOOTSTRAP_API_KEY_ENABLED", false), + }, + Auth: Auth{ + SessionTTL: duration("AUTH_SESSION_TTL", 12*time.Hour), + TOTPChallengeTTL: duration("AUTH_TOTP_CHALLENGE_TTL", 5*time.Minute), + MaxFailures: intValue("LOGIN_MAX_FAILURES", 5), + LockDuration: duration("LOGIN_LOCK_DURATION", 15*time.Minute), + LoginRateLimitMax: intValue("LOGIN_RATE_LIMIT_MAX", 30), + LoginRateLimitWindow: duration("LOGIN_RATE_LIMIT_WINDOW", 5*time.Minute), + }, + Credentials: Credentials{ + MasterKey: strings.TrimSpace(os.Getenv("CREDENTIAL_MASTER_KEY")), + KEKVersion: intValue("CREDENTIAL_KEK_VERSION", 1), + KEKKeyring: strings.TrimSpace(os.Getenv("CREDENTIAL_KEK_KEYRING")), + AllowPrivateProviderURL: boolValue("ALLOW_PRIVATE_PROVIDER_URLS", false), + AllowPrivateToolURL: boolValue("ALLOW_PRIVATE_TOOL_URLS", false), + AllowPrivateWebhookURL: boolValue("ALLOW_PRIVATE_WEBHOOK_URLS", false), + ProviderRefreshInterval: duration("PROVIDER_REFRESH_INTERVAL", 5*time.Second), + }, + Upstream: Upstream{ + BaseURL: strings.TrimRight(env("UPSTREAM_BASE_URL", "https://api.openai.com"), "/"), APIKey: strings.TrimSpace(os.Getenv("UPSTREAM_API_KEY")), + FallbackEnabled: boolValue("UPSTREAM_FALLBACK_ENABLED", true), ResponseHeaderTimeout: duration("UPSTREAM_RESPONSE_HEADER_TIMEOUT", 60*time.Second), + MaxRetries: intValue("UPSTREAM_MAX_RETRIES", 2), RetryBackoff: duration("UPSTREAM_RETRY_BACKOFF", 50*time.Millisecond), + CircuitThreshold: intValue("UPSTREAM_CIRCUIT_THRESHOLD", 5), CircuitOpenDuration: duration("UPSTREAM_CIRCUIT_OPEN_DURATION", 30*time.Second), + }, + Audit: Audit{ + QueueSize: intValue("AUDIT_QUEUE_SIZE", 4096), BatchSize: intValue("AUDIT_BATCH_SIZE", 200), + FlushInterval: duration("AUDIT_FLUSH_INTERVAL", time.Second), Retention: duration("AUDIT_RETENTION", 90*24*time.Hour), + UsageRetention: duration("USAGE_RETENTION", 730*24*time.Hour), PartitionMonthsAhead: intValue("AUDIT_PARTITION_MONTHS_AHEAD", 3), + MaintenanceInterval: duration("AUDIT_MAINTENANCE_INTERVAL", 6*time.Hour), + }, + Outbox: Outbox{ + Stream: env("OUTBOX_STREAM", "gateway:{outbox}:events"), BatchSize: intValue("OUTBOX_BATCH_SIZE", 100), + PollInterval: duration("OUTBOX_POLL_INTERVAL", 500*time.Millisecond), Lease: duration("OUTBOX_LEASE", 30*time.Second), + MaxAttempts: intValue("OUTBOX_MAX_ATTEMPTS", 10), MaxBackoff: duration("OUTBOX_MAX_BACKOFF", 5*time.Minute), + StreamMaxLength: int64Value("OUTBOX_STREAM_MAX_LENGTH", 100_000), MarkerTTL: duration("OUTBOX_MARKER_TTL", 30*24*time.Hour), + }, + RuntimeData: RuntimeData{ + ContentPolicyRefreshInterval: duration("CONTENT_POLICY_REFRESH_INTERVAL", 30*time.Second), + PricingRefreshInterval: duration("PRICING_REFRESH_INTERVAL", 30*time.Second), + }, + Shadow: Shadow{ + BaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("SHADOW_BASE_URL")), "/"), + APIKey: strings.TrimSpace(os.Getenv("SHADOW_API_KEY")), SampleRate: floatValue("SHADOW_SAMPLE_RATE", 0), + Timeout: duration("SHADOW_TIMEOUT", 20*time.Second), MaxBodyBytes: int64Value("SHADOW_MAX_BODY_BYTES", 2<<20), + MaxConcurrent: intValue("SHADOW_MAX_CONCURRENT", 16), + }, + } + + return cfg, cfg.Validate() +} + +func (c Config) Validate() error { + var errs []error + if c.Server.MaxBodyBytes <= 0 { + errs = append(errs, errors.New("HTTP_MAX_BODY_BYTES must be positive")) + } + if c.Database.MinConns < 0 || c.Database.MaxConns < 1 || c.Database.MinConns > c.Database.MaxConns { + errs = append(errs, errors.New("database pool sizes are invalid")) + } + if c.Auth.SessionTTL < 5*time.Minute || c.Auth.TOTPChallengeTTL < time.Minute || c.Auth.TOTPChallengeTTL > 15*time.Minute || c.Auth.MaxFailures < 1 || c.Auth.LockDuration < time.Minute { + errs = append(errs, errors.New("authentication limits are invalid")) + } + if c.Auth.LoginRateLimitMax < 1 || c.Auth.LoginRateLimitWindow < time.Second { + errs = append(errs, errors.New("login rate limit is invalid")) + } + if c.Credentials.KEKVersion < 1 { + errs = append(errs, errors.New("CREDENTIAL_KEK_VERSION must be positive")) + } + if c.Credentials.ProviderRefreshInterval < time.Second || c.Credentials.ProviderRefreshInterval > time.Minute { + errs = append(errs, errors.New("PROVIDER_REFRESH_INTERVAL must be between 1s and 1m")) + } + if err := validateHTTPURL(c.Upstream.BaseURL); err != nil { + errs = append(errs, fmt.Errorf("UPSTREAM_BASE_URL: %w", err)) + } + if c.Upstream.ResponseHeaderTimeout < time.Second || c.Upstream.ResponseHeaderTimeout > 10*time.Minute || c.Upstream.MaxRetries < 0 || c.Upstream.MaxRetries > 5 || c.Upstream.RetryBackoff < 0 || c.Upstream.RetryBackoff > 5*time.Second || c.Upstream.CircuitThreshold < 1 || c.Upstream.CircuitThreshold > 100 || c.Upstream.CircuitOpenDuration < time.Second || c.Upstream.CircuitOpenDuration > 10*time.Minute { + errs = append(errs, errors.New("upstream resilience settings are invalid")) + } + if c.Audit.QueueSize < 100 || c.Audit.QueueSize > 1_000_000 || c.Audit.BatchSize < 1 || c.Audit.BatchSize > c.Audit.QueueSize || c.Audit.FlushInterval < 100*time.Millisecond || c.Audit.FlushInterval > time.Minute { + errs = append(errs, errors.New("audit buffering settings are invalid")) + } + if c.Audit.Retention < 24*time.Hour || c.Audit.Retention > 10*365*24*time.Hour || c.Audit.UsageRetention < c.Audit.Retention || c.Audit.UsageRetention > 10*365*24*time.Hour || c.Audit.PartitionMonthsAhead < 1 || c.Audit.PartitionMonthsAhead > 24 || c.Audit.MaintenanceInterval < time.Hour || c.Audit.MaintenanceInterval > 7*24*time.Hour { + errs = append(errs, errors.New("audit retention settings are invalid")) + } + if !strings.Contains(c.Outbox.Stream, "{outbox}") || c.Outbox.BatchSize < 1 || c.Outbox.BatchSize > 1000 || c.Outbox.PollInterval < 50*time.Millisecond || c.Outbox.PollInterval > time.Minute || c.Outbox.Lease < 5*time.Second || c.Outbox.Lease > 10*time.Minute || c.Outbox.MaxAttempts < 1 || c.Outbox.MaxAttempts > 100 || c.Outbox.MaxBackoff < time.Second || c.Outbox.MaxBackoff > time.Hour || c.Outbox.StreamMaxLength < 1000 || c.Outbox.StreamMaxLength > 100_000_000 || c.Outbox.MarkerTTL < 24*time.Hour || c.Outbox.MarkerTTL > 365*24*time.Hour { + errs = append(errs, errors.New("outbox delivery settings are invalid")) + } + if c.RuntimeData.ContentPolicyRefreshInterval < time.Second || c.RuntimeData.ContentPolicyRefreshInterval > 10*time.Minute || c.RuntimeData.PricingRefreshInterval < time.Second || c.RuntimeData.PricingRefreshInterval > 10*time.Minute { + errs = append(errs, errors.New("runtime data refresh settings must be between 1s and 10m")) + } + if c.Shadow.SampleRate < 0 || c.Shadow.SampleRate > 1 || c.Shadow.Timeout < time.Second || c.Shadow.Timeout > 2*time.Minute || c.Shadow.MaxBodyBytes < 1024 || c.Shadow.MaxBodyBytes > 32<<20 || c.Shadow.MaxConcurrent < 1 || c.Shadow.MaxConcurrent > 1000 { + errs = append(errs, errors.New("shadow traffic settings are invalid")) + } + if c.Shadow.BaseURL != "" { + if err := validateHTTPURL(c.Shadow.BaseURL); err != nil { + errs = append(errs, fmt.Errorf("SHADOW_BASE_URL: %w", err)) + } + if c.Shadow.APIKey == "" { + errs = append(errs, errors.New("SHADOW_API_KEY is required when SHADOW_BASE_URL is set")) + } + } + return errors.Join(errs...) +} + +func (c Config) ValidateRuntime() error { + var errs []error + if strings.EqualFold(c.Environment, "production") { + if c.Database.URL == "" { + errs = append(errs, errors.New("DATABASE_URL is required in production")) + } + if c.Redis.CriticalURL == "" { + errs = append(errs, errors.New("REDIS_CRITICAL_URL is required in production")) + } + if c.Security.BootstrapAPIKeyEnabled { + if len(c.Security.BootstrapAPIKey) < 32 { + errs = append(errs, errors.New("GATEWAY_BOOTSTRAP_API_KEY must contain at least 32 characters in production")) + } + // Reject the documented placeholder and other obviously weak values + // so an operator cannot accidentally deploy with the example secret. + switch strings.ToLower(c.Security.BootstrapAPIKey) { + case "change-me-in-production", "local-development-key", "changeme", "change-me", "password", "secret": + errs = append(errs, errors.New("GATEWAY_BOOTSTRAP_API_KEY is set to a known weak default; generate a strong random value")) + } + } + if c.Upstream.FallbackEnabled && c.Upstream.APIKey == "" { + errs = append(errs, errors.New("UPSTREAM_API_KEY is required in production")) + } + if c.Credentials.MasterKey == "" { + errs = append(errs, errors.New("CREDENTIAL_MASTER_KEY is required in production")) + } + } + return errors.Join(errs...) +} + +func validateHTTPURL(raw string) error { + u, err := url.Parse(raw) + if err != nil { + return err + } + if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil { + return errors.New("must be an absolute http(s) URL without user info") + } + return nil +} + +func env(key, fallback string) string { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + return fallback +} + +func duration(key string, fallback time.Duration) time.Duration { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback + } + parsed, err := time.ParseDuration(value) + if err != nil { + return fallback + } + return parsed +} + +func intValue(key string, fallback int) int { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback + } + parsed, err := strconv.Atoi(value) + if err != nil { + return fallback + } + return parsed +} + +func int64Value(key string, fallback int64) int64 { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback + } + parsed, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return fallback + } + return parsed +} + +func floatValue(key string, fallback float64) float64 { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback + } + parsed, err := strconv.ParseFloat(value, 64) + if err != nil { + return fallback + } + return parsed +} + +func boolValue(key string, fallback bool) bool { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback + } + parsed, err := strconv.ParseBool(value) + if err != nil { + return fallback + } + return parsed +} diff --git a/internal/platform/config/config_test.go b/internal/platform/config/config_test.go new file mode 100644 index 0000000..944149f --- /dev/null +++ b/internal/platform/config/config_test.go @@ -0,0 +1,45 @@ +package config + +import "testing" + +func TestProductionRequiresCoreDependencies(t *testing.T) { + t.Setenv("APP_ENV", "production") + t.Setenv("DATABASE_URL", "") + t.Setenv("REDIS_CRITICAL_URL", "") + t.Setenv("GATEWAY_BOOTSTRAP_API_KEY", "short") + t.Setenv("UPSTREAM_API_KEY", "") + + cfg, err := Load() + if err != nil { + t.Fatalf("unexpected structural configuration error: %v", err) + } + if err := cfg.ValidateRuntime(); err == nil { + t.Fatal("expected production validation error") + } +} + +func TestLocalDefaultsAreValid(t *testing.T) { + t.Setenv("APP_ENV", "local") + t.Setenv("UPSTREAM_BASE_URL", "https://example.com") + if _, err := Load(); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestProductionCanDisableBootstrapCompatibility(t *testing.T) { + t.Setenv("APP_ENV", "production") + t.Setenv("DATABASE_URL", "postgres://gateway:secret@db.example/gateway?sslmode=require") + t.Setenv("REDIS_CRITICAL_URL", "rediss://redis.example/0") + t.Setenv("GATEWAY_BOOTSTRAP_API_KEY", "") + t.Setenv("GATEWAY_BOOTSTRAP_API_KEY_ENABLED", "false") + t.Setenv("UPSTREAM_FALLBACK_ENABLED", "false") + t.Setenv("CREDENTIAL_MASTER_KEY", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + t.Setenv("UPSTREAM_BASE_URL", "https://example.com") + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if err := cfg.ValidateRuntime(); err != nil { + t.Fatalf("disabled bootstrap compatibility should not require a key: %v", err) + } +} diff --git a/internal/platform/cryptox/aesgcm.go b/internal/platform/cryptox/aesgcm.go new file mode 100644 index 0000000..2b4fb6f --- /dev/null +++ b/internal/platform/cryptox/aesgcm.go @@ -0,0 +1,77 @@ +package cryptox + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" +) + +var ErrKeyUnavailable = errors.New("encryption key unavailable") + +type AESGCM struct { + aead cipher.AEAD + version int + purpose string +} + +func NewAESGCM(encodedKey string, version int, purpose string) (*AESGCM, error) { + if encodedKey == "" { + return nil, nil + } + key, err := base64.StdEncoding.DecodeString(encodedKey) + if err != nil { + key, err = base64.RawStdEncoding.DecodeString(encodedKey) + } + if err != nil || len(key) != 32 { + return nil, errors.New("encryption key must be base64-encoded 32 bytes") + } + if version < 1 || purpose == "" { + return nil, errors.New("encryption version and purpose are required") + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + aead, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + return &AESGCM{aead: aead, version: version, purpose: purpose}, nil +} + +func (c *AESGCM) Encrypt(plaintext []byte) ([]byte, int, error) { + if c == nil { + return nil, 0, ErrKeyUnavailable + } + nonce := make([]byte, c.aead.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return nil, 0, err + } + ciphertext := c.aead.Seal(nil, nonce, plaintext, c.additionalData()) + return append(nonce, ciphertext...), c.version, nil +} + +func (c *AESGCM) Decrypt(encrypted []byte, version int) ([]byte, error) { + if c == nil { + return nil, ErrKeyUnavailable + } + if version != c.version { + return nil, fmt.Errorf("encryption key version %d is not loaded", version) + } + nonceSize := c.aead.NonceSize() + if len(encrypted) <= nonceSize { + return nil, errors.New("encrypted value is truncated") + } + plaintext, err := c.aead.Open(nil, encrypted[:nonceSize], encrypted[nonceSize:], c.additionalData()) + if err != nil { + return nil, errors.New("encrypted value authentication failed") + } + return plaintext, nil +} + +func (c *AESGCM) additionalData() []byte { + return []byte(fmt.Sprintf("ai-gateway/%s/v%d", c.purpose, c.version)) +} diff --git a/internal/platform/cryptox/keyring.go b/internal/platform/cryptox/keyring.go new file mode 100644 index 0000000..4541da9 --- /dev/null +++ b/internal/platform/cryptox/keyring.go @@ -0,0 +1,100 @@ +package cryptox + +import ( + "encoding/json" + "errors" + "fmt" + "sort" + "strconv" + "strings" +) + +type Cipher interface { + Encrypt([]byte) ([]byte, int, error) + Decrypt([]byte, int) ([]byte, error) +} + +type Keyring struct { + activeVersion int + ciphers map[int]*AESGCM +} + +func NewKeyring(activeKey string, activeVersion int, encodedKeyring, purpose string) (*Keyring, error) { + if activeVersion < 1 || purpose == "" { + return nil, errors.New("encryption version and purpose are required") + } + encoded := make(map[int]string) + if strings.TrimSpace(encodedKeyring) != "" { + var values map[string]string + if err := json.Unmarshal([]byte(encodedKeyring), &values); err != nil { + return nil, fmt.Errorf("encryption keyring must be a JSON object: %w", err) + } + for rawVersion, key := range values { + version, err := strconv.Atoi(rawVersion) + if err != nil || version < 1 || strings.TrimSpace(key) == "" { + return nil, fmt.Errorf("invalid encryption keyring version %q", rawVersion) + } + encoded[version] = strings.TrimSpace(key) + } + } + if strings.TrimSpace(activeKey) != "" { + if existing, ok := encoded[activeVersion]; ok && existing != strings.TrimSpace(activeKey) { + return nil, fmt.Errorf("active encryption key version %d is configured twice with different values", activeVersion) + } + encoded[activeVersion] = strings.TrimSpace(activeKey) + } + if len(encoded) == 0 { + return nil, nil + } + if _, ok := encoded[activeVersion]; !ok { + return nil, fmt.Errorf("active encryption key version %d is not loaded", activeVersion) + } + keyring := &Keyring{activeVersion: activeVersion, ciphers: make(map[int]*AESGCM, len(encoded))} + for version, key := range encoded { + cipher, err := NewAESGCM(key, version, purpose) + if err != nil { + return nil, fmt.Errorf("encryption key version %d: %w", version, err) + } + keyring.ciphers[version] = cipher + } + return keyring, nil +} + +func (k *Keyring) Encrypt(plaintext []byte) ([]byte, int, error) { + if k == nil { + return nil, 0, ErrKeyUnavailable + } + return k.ciphers[k.activeVersion].Encrypt(plaintext) +} + +func (k *Keyring) Decrypt(encrypted []byte, version int) ([]byte, error) { + if k == nil { + return nil, ErrKeyUnavailable + } + cipher, ok := k.ciphers[version] + if !ok { + return nil, fmt.Errorf("encryption key version %d is not loaded", version) + } + return cipher.Decrypt(encrypted, version) +} + +func (k *Keyring) ActiveVersion() int { + if k == nil { + return 0 + } + return k.activeVersion +} + +func (k *Keyring) Versions() []int { + if k == nil { + return nil + } + versions := make([]int, 0, len(k.ciphers)) + for version := range k.ciphers { + versions = append(versions, version) + } + sort.Ints(versions) + return versions +} + +var _ Cipher = (*Keyring)(nil) diff --git a/internal/platform/cryptox/keyring_test.go b/internal/platform/cryptox/keyring_test.go new file mode 100644 index 0000000..74ab2ed --- /dev/null +++ b/internal/platform/cryptox/keyring_test.go @@ -0,0 +1,37 @@ +package cryptox + +import ( + "encoding/base64" + "fmt" + "testing" +) + +func TestKeyringDecryptsOldVersionAndEncryptsActiveVersion(t *testing.T) { + oldKey := base64.StdEncoding.EncodeToString(make([]byte, 32)) + newBytes := make([]byte, 32) + newBytes[0] = 1 + newKey := base64.StdEncoding.EncodeToString(newBytes) + oldCipher, err := NewAESGCM(oldKey, 1, "test") + if err != nil { + t.Fatal(err) + } + oldEncrypted, _, err := oldCipher.Encrypt([]byte("secret")) + if err != nil { + t.Fatal(err) + } + keyring, err := NewKeyring(newKey, 2, fmt.Sprintf(`{"1":%q}`, oldKey), "test") + if err != nil { + t.Fatal(err) + } + plaintext, err := keyring.Decrypt(oldEncrypted, 1) + if err != nil || string(plaintext) != "secret" { + t.Fatalf("old key was not usable: %q, %v", plaintext, err) + } + newEncrypted, version, err := keyring.Encrypt([]byte("new-secret")) + if err != nil || version != 2 { + t.Fatalf("active version was not used: version=%d error=%v", version, err) + } + if _, err := oldCipher.Decrypt(newEncrypted, version); err == nil { + t.Fatal("old cipher must not decrypt the new version") + } +} diff --git a/internal/platform/database/postgres.go b/internal/platform/database/postgres.go new file mode 100644 index 0000000..a502d69 --- /dev/null +++ b/internal/platform/database/postgres.go @@ -0,0 +1,31 @@ +package database + +import ( + "context" + "fmt" + "time" + + "aigateway.local/core/internal/platform/config" + "github.com/jackc/pgx/v5/pgxpool" +) + +func Open(ctx context.Context, cfg config.Database) (*pgxpool.Pool, error) { + if cfg.URL == "" { + return nil, nil + } + poolConfig, err := pgxpool.ParseConfig(cfg.URL) + if err != nil { + return nil, fmt.Errorf("parse database configuration: %w", err) + } + poolConfig.MaxConns = cfg.MaxConns + poolConfig.MinConns = cfg.MinConns + poolConfig.MaxConnLifetime = 30 * time.Minute + poolConfig.MaxConnIdleTime = 5 * time.Minute + poolConfig.HealthCheckPeriod = 30 * time.Second + + pool, err := pgxpool.NewWithConfig(ctx, poolConfig) + if err != nil { + return nil, fmt.Errorf("create database pool: %w", err) + } + return pool, nil +} diff --git a/internal/platform/health/checker.go b/internal/platform/health/checker.go new file mode 100644 index 0000000..3c63f44 --- /dev/null +++ b/internal/platform/health/checker.go @@ -0,0 +1,85 @@ +package health + +import ( + "context" + "sync" + "time" +) + +type Probe func(context.Context) error + +type Dependency struct { + Name string + Required bool + Probe Probe +} + +type Result struct { + Status string `json:"status"` + Required bool `json:"required"` + LatencyMS float64 `json:"latency_ms"` + Error string `json:"error,omitempty"` +} + +type Report struct { + Ready bool `json:"ready"` + Status string `json:"status"` + Components map[string]Result `json:"components"` +} + +type Checker struct { + Timeout time.Duration + Dependencies []Dependency +} + +func (c Checker) Readiness(ctx context.Context) Report { + report := Report{Ready: true, Status: "ok", Components: make(map[string]Result, len(c.Dependencies))} + type namedResult struct { + name string + result Result + } + results := make(chan namedResult, len(c.Dependencies)) + var wg sync.WaitGroup + + for _, dependency := range c.Dependencies { + dependency := dependency + wg.Add(1) + go func() { + defer wg.Done() + result := Result{Status: "ok", Required: dependency.Required} + if dependency.Probe == nil { + result.Status = "not_configured" + results <- namedResult{dependency.Name, result} + return + } + probeCtx, cancel := context.WithTimeout(ctx, c.Timeout) + defer cancel() + started := time.Now() + err := dependency.Probe(probeCtx) + result.LatencyMS = float64(time.Since(started).Microseconds()) / 1000 + if err != nil { + result.Status = "unavailable" + result.Error = err.Error() + } + results <- namedResult{dependency.Name, result} + }() + } + + go func() { + wg.Wait() + close(results) + }() + for item := range results { + report.Components[item.name] = item.result + if item.result.Required && item.result.Status != "ok" { + report.Ready = false + } + if !item.result.Required && item.result.Status == "unavailable" && report.Status == "ok" { + report.Status = "degraded" + } + } + if !report.Ready { + report.Status = "unavailable" + } + return report +} diff --git a/internal/platform/health/checker_test.go b/internal/platform/health/checker_test.go new file mode 100644 index 0000000..9f58881 --- /dev/null +++ b/internal/platform/health/checker_test.go @@ -0,0 +1,35 @@ +package health + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestOptionalFailureDegradesWithoutBlockingReadiness(t *testing.T) { + checker := Checker{ + Timeout: time.Second, + Dependencies: []Dependency{ + {Name: "postgres", Required: true, Probe: func(context.Context) error { return nil }}, + {Name: "redis_cache", Required: false, Probe: func(context.Context) error { return errors.New("offline") }}, + }, + } + report := checker.Readiness(context.Background()) + if !report.Ready || report.Status != "degraded" { + t.Fatalf("unexpected report: %+v", report) + } +} + +func TestRequiredFailureBlocksReadiness(t *testing.T) { + checker := Checker{ + Timeout: time.Second, + Dependencies: []Dependency{ + {Name: "postgres", Required: true, Probe: func(context.Context) error { return errors.New("offline") }}, + }, + } + report := checker.Readiness(context.Background()) + if report.Ready || report.Status != "unavailable" { + t.Fatalf("unexpected report: %+v", report) + } +} diff --git a/internal/platform/httpserver/server.go b/internal/platform/httpserver/server.go new file mode 100644 index 0000000..f13d395 --- /dev/null +++ b/internal/platform/httpserver/server.go @@ -0,0 +1,113 @@ +package httpserver + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "runtime/debug" + "sync/atomic" + "time" + + "aigateway.local/core/internal/gateway" + "aigateway.local/core/internal/platform/config" + "aigateway.local/core/internal/platform/health" +) + +type Dependencies struct { + Config config.Config + Logger *slog.Logger + Checker health.Checker + Gateway http.Handler + Control http.Handler + Version string + StartedAt time.Time + BootstrapUses func() uint64 + ExtraMetrics func() string +} + +type metrics struct { + requests atomic.Uint64 + panics atomic.Uint64 +} + +func New(dependencies Dependencies) *http.Server { + mux := http.NewServeMux() + stats := &metrics{} + mux.HandleFunc("GET /healthz", func(writer http.ResponseWriter, _ *http.Request) { + writeJSON(writer, http.StatusOK, map[string]any{"status": "ok", "version": dependencies.Version}) + }) + mux.HandleFunc("GET /readyz", func(writer http.ResponseWriter, request *http.Request) { + report := dependencies.Checker.Readiness(request.Context()) + status := http.StatusOK + if !report.Ready { + status = http.StatusServiceUnavailable + } + writeJSON(writer, status, report) + }) + mux.HandleFunc("GET /metrics", func(writer http.ResponseWriter, _ *http.Request) { + writer.Header().Set("Content-Type", "text/plain; version=0.0.4") + bootstrapUses := uint64(0) + if dependencies.BootstrapUses != nil { + bootstrapUses = dependencies.BootstrapUses() + } + _, _ = fmt.Fprintf(writer, "gateway_http_requests_total %d\ngateway_http_panics_total %d\ngateway_bootstrap_api_key_uses_total %d\ngateway_uptime_seconds %.0f\n", stats.requests.Load(), stats.panics.Load(), bootstrapUses, time.Since(dependencies.StartedAt).Seconds()) + if dependencies.ExtraMetrics != nil { + _, _ = fmt.Fprint(writer, dependencies.ExtraMetrics()) + } + }) + mux.Handle("/v1/", dependencies.Gateway) + if dependencies.Control != nil { + mux.Handle("/api/", dependencies.Control) + } + + handler := recoverMiddleware(dependencies.Logger, stats, requestIDMiddleware(stats, mux)) + return &http.Server{ + Addr: dependencies.Config.Server.Address, + Handler: handler, + ReadHeaderTimeout: dependencies.Config.Server.ReadHeaderTimeout, + IdleTimeout: dependencies.Config.Server.IdleTimeout, + // WriteTimeout intentionally remains zero: SSE responses may be long lived. + } +} + +func requestIDMiddleware(stats *metrics, next http.Handler) http.Handler { + return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + stats.requests.Add(1) + requestID := request.Header.Get("X-Request-ID") + if requestID == "" || len(requestID) > 128 { + requestID = newRequestID() + } + writer.Header().Set("X-Request-ID", requestID) + next.ServeHTTP(writer, request.WithContext(gateway.WithRequestID(request.Context(), requestID))) + }) +} + +func recoverMiddleware(logger *slog.Logger, stats *metrics, next http.Handler) http.Handler { + return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + defer func() { + if recovered := recover(); recovered != nil { + stats.panics.Add(1) + logger.Error("http handler panic", "request_id", gateway.RequestID(request.Context()), "panic", recovered, "stack", string(debug.Stack())) + writeJSON(writer, http.StatusInternalServerError, map[string]string{"error": "internal server error"}) + } + }() + next.ServeHTTP(writer, request) + }) +} + +func writeJSON(writer http.ResponseWriter, status int, value any) { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(status) + _ = json.NewEncoder(writer).Encode(value) +} + +func newRequestID() string { + buffer := make([]byte, 12) + if _, err := rand.Read(buffer); err != nil { + return fmt.Sprintf("req_%x", time.Now().UnixNano()) + } + return "req_" + hex.EncodeToString(buffer) +} diff --git a/internal/platform/id/uuid.go b/internal/platform/id/uuid.go new file mode 100644 index 0000000..53f3af2 --- /dev/null +++ b/internal/platform/id/uuid.go @@ -0,0 +1,17 @@ +package id + +import ( + "crypto/rand" + "fmt" +) + +func NewUUID() (string, error) { + value := make([]byte, 16) + if _, err := rand.Read(value); err != nil { + return "", err + } + value[6] = (value[6] & 0x0f) | 0x40 + value[8] = (value[8] & 0x3f) | 0x80 + return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", + value[0:4], value[4:6], value[6:8], value[8:10], value[10:16]), nil +} diff --git a/internal/platform/legacyid/legacyid.go b/internal/platform/legacyid/legacyid.go new file mode 100644 index 0000000..ad6a31a --- /dev/null +++ b/internal/platform/legacyid/legacyid.go @@ -0,0 +1,36 @@ +package legacyid + +import ( + "crypto/sha1" + "encoding/hex" + "errors" + "fmt" + "strings" +) + +// Namespace is stable for the lifetime of this migration lineage. Changing it +// would generate different UUIDs for the same legacy records. +const Namespace = "e2464f95-0c8d-5a9c-9c1d-d5bca69bb09f" + +// UUID returns an RFC 4122 UUIDv5 for a legacy record. SHA-1 is required by the +// UUIDv5 standard here and is not used for credentials or signatures. +func UUID(sourceSystem, entityType, legacyID string) (string, error) { + sourceSystem = strings.TrimSpace(sourceSystem) + entityType = strings.TrimSpace(entityType) + legacyID = strings.TrimSpace(legacyID) + if sourceSystem == "" || entityType == "" || legacyID == "" { + return "", errors.New("legacy ID components must not be empty") + } + if len(sourceSystem) > 64 || len(entityType) > 128 || len(legacyID) > 256 { + return "", errors.New("legacy ID component is too long") + } + namespace, err := hex.DecodeString(strings.ReplaceAll(Namespace, "-", "")) + if err != nil || len(namespace) != 16 { + return "", errors.New("invalid UUID namespace") + } + name := sourceSystem + "/" + entityType + "/" + legacyID + digest := sha1.Sum(append(namespace, []byte(name)...)) + digest[6] = digest[6]&0x0f | 0x50 + digest[8] = digest[8]&0x3f | 0x80 + return fmt.Sprintf("%x-%x-%x-%x-%x", digest[0:4], digest[4:6], digest[6:8], digest[8:10], digest[10:16]), nil +} diff --git a/internal/platform/legacyid/legacyid_test.go b/internal/platform/legacyid/legacyid_test.go new file mode 100644 index 0000000..48747a3 --- /dev/null +++ b/internal/platform/legacyid/legacyid_test.go @@ -0,0 +1,26 @@ +package legacyid + +import "testing" + +func TestUUIDIsStableAndEntityScoped(t *testing.T) { + department, err := UUID("python-v1", "departments", "42") + if err != nil { + t.Fatal(err) + } + if department != "028ac0aa-75d4-5ce6-b20d-15a343df445e" { + t.Fatalf("unexpected deterministic UUID %q", department) + } + user, err := UUID("python-v1", "users", "42") + if err != nil { + t.Fatal(err) + } + if user != "de8453de-93b5-5ab9-8284-ed099894296b" || user == department { + t.Fatalf("entity namespace was not applied: %q", user) + } +} + +func TestUUIDRejectsIncompleteIdentity(t *testing.T) { + if _, err := UUID("python-v1", "users", " "); err == nil { + t.Fatal("expected empty legacy ID to be rejected") + } +} diff --git a/internal/platform/migrate/migrate.go b/internal/platform/migrate/migrate.go new file mode 100644 index 0000000..5d3a992 --- /dev/null +++ b/internal/platform/migrate/migrate.go @@ -0,0 +1,113 @@ +package migrate + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +const advisoryLockID int64 = 6720240805 + +type Migration struct { + Version string + Filename string + SQL string + Checksum string +} + +func Load(directory string) ([]Migration, error) { + entries, err := os.ReadDir(directory) + if err != nil { + return nil, fmt.Errorf("read migration directory: %w", err) + } + var filenames []string + for _, entry := range entries { + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") { + filenames = append(filenames, entry.Name()) + } + } + sort.Strings(filenames) + migrations := make([]Migration, 0, len(filenames)) + for _, filename := range filenames { + body, err := os.ReadFile(filepath.Join(directory, filename)) + if err != nil { + return nil, fmt.Errorf("read migration %s: %w", filename, err) + } + version, _, ok := strings.Cut(filename, "_") + if !ok || version == "" { + return nil, fmt.Errorf("migration %s must start with a version and underscore", filename) + } + digest := sha256.Sum256(body) + migrations = append(migrations, Migration{ + Version: version, Filename: filename, SQL: string(body), Checksum: hex.EncodeToString(digest[:]), + }) + } + return migrations, nil +} + +func Apply(ctx context.Context, pool *pgxpool.Pool, migrations []Migration) error { + connection, err := pool.Acquire(ctx) + if err != nil { + return fmt.Errorf("acquire migration connection: %w", err) + } + defer connection.Release() + if _, err := connection.Exec(ctx, "SELECT pg_advisory_lock($1)", advisoryLockID); err != nil { + return fmt.Errorf("lock migrations: %w", err) + } + defer func() { _, _ = connection.Exec(context.Background(), "SELECT pg_advisory_unlock($1)", advisoryLockID) }() + + if _, err := connection.Exec(ctx, ` + CREATE SCHEMA IF NOT EXISTS gateway; + CREATE TABLE IF NOT EXISTS gateway.schema_migrations ( + version text PRIMARY KEY, + filename text NOT NULL, + checksum text NOT NULL, + applied_at timestamptz NOT NULL DEFAULT clock_timestamp() + )`); err != nil { + return fmt.Errorf("initialize migration table: %w", err) + } + + for _, migration := range migrations { + var existingChecksum string + err := connection.QueryRow(ctx, + "SELECT checksum FROM gateway.schema_migrations WHERE version = $1", migration.Version, + ).Scan(&existingChecksum) + if err == nil { + if existingChecksum != migration.Checksum { + return fmt.Errorf("migration %s checksum changed after application", migration.Filename) + } + continue + } + if !errors.Is(err, pgx.ErrNoRows) { + return fmt.Errorf("check migration %s: %w", migration.Filename, err) + } + transaction, err := connection.Begin(ctx) + if err != nil { + return fmt.Errorf("begin migration %s: %w", migration.Filename, err) + } + if _, err := transaction.Exec(ctx, migration.SQL); err != nil { + _ = transaction.Rollback(ctx) + return fmt.Errorf("apply migration %s: %w", migration.Filename, err) + } + if _, err := transaction.Exec(ctx, + "INSERT INTO gateway.schema_migrations (version, filename, checksum) VALUES ($1, $2, $3)", + migration.Version, migration.Filename, migration.Checksum, + ); err != nil { + _ = transaction.Rollback(ctx) + return fmt.Errorf("record migration %s: %w", migration.Filename, err) + } + if err := transaction.Commit(ctx); err != nil { + return fmt.Errorf("commit migration %s: %w", migration.Filename, err) + } + } + return nil +} diff --git a/internal/portal/admin_http.go b/internal/portal/admin_http.go new file mode 100644 index 0000000..26ff272 --- /dev/null +++ b/internal/portal/admin_http.go @@ -0,0 +1,92 @@ +package portal + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + + "aigateway.local/core/internal/identity" + "aigateway.local/core/internal/platform/apiresponse" +) + +type AdminHTTPHandler struct { + service *Service + identity *identity.Service + mux *http.ServeMux +} + +func NewAdminHTTPHandler(service *Service, identityService *identity.Service) *AdminHTTPHandler { + h := &AdminHTTPHandler{service: service, identity: identityService, mux: http.NewServeMux()} + h.mux.HandleFunc("GET /api/v1/admin/models", h.models) + h.mux.HandleFunc("GET /api/v1/admin/model-requests", h.requests) + h.mux.HandleFunc("POST /api/v1/admin/model-requests/{id}/approve", h.decide("approved")) + h.mux.HandleFunc("POST /api/v1/admin/model-requests/{id}/reject", h.decide("rejected")) + return h +} +func (h *AdminHTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.mux.ServeHTTP(w, r) } +func (h *AdminHTTPHandler) admin(w http.ResponseWriter, r *http.Request) (identity.Account, bool) { + a, err := h.identity.Authenticate(r.Context(), identity.KindAdmin, r.Header.Get("Authorization")) + if err != nil { + apiresponse.Error(w, 401, "登录状态无效") + return a, false + } + if !identity.HasPermission(a, identity.PermissionProviderRead) { + apiresponse.Error(w, 403, "缺少模型查看权限") + return a, false + } + return a, true +} +func (h *AdminHTTPHandler) models(w http.ResponseWriter, r *http.Request) { + if _, ok := h.admin(w, r); !ok { + return + } + items, err := h.service.Models(r.Context()) + if err != nil { + portalError(w, err) + return + } + apiresponse.OK(w, items) +} +func (h *AdminHTTPHandler) requests(w http.ResponseWriter, r *http.Request) { + if _, ok := h.admin(w, r); !ok { + return + } + status := strings.TrimSpace(r.URL.Query().Get("status")) + if status != "" && status != "pending" && status != "approved" && status != "rejected" && status != "cancelled" { + apiresponse.Error(w, 400, "状态无效") + return + } + items, err := h.service.ModelRequests(r.Context(), "", status) + if err != nil { + portalError(w, err) + return + } + apiresponse.OK(w, items) +} +func (h *AdminHTTPHandler) decide(status string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + a, ok := h.admin(w, r) + if !ok { + return + } + if !identity.HasPermission(a, identity.PermissionProviderManage) { + apiresponse.Error(w, 403, "缺少模型审批权限") + return + } + var input struct { + Note string `json:"note"` + } + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) + if err := decoder.Decode(&input); err != nil && !errors.Is(err, http.ErrBodyReadAfterClose) { + apiresponse.Error(w, 400, "请求格式无效") + return + } + item, err := h.service.DecideModelRequest(r.Context(), r.PathValue("id"), status, input.Note, a.ID) + if err != nil { + portalError(w, err) + return + } + apiresponse.OK(w, item) + } +} diff --git a/internal/portal/conversations.go b/internal/portal/conversations.go new file mode 100644 index 0000000..cf73f98 --- /dev/null +++ b/internal/portal/conversations.go @@ -0,0 +1,239 @@ +package portal + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "time" + + "aigateway.local/core/internal/gateway" + "aigateway.local/core/internal/identity" + platformid "aigateway.local/core/internal/platform/id" + "github.com/jackc/pgx/v5" +) + +type ConversationMessage struct { + Sequence int `json:"sequence"` + Role string `json:"role"` + Content string `json:"content"` + CreatedAt time.Time `json:"created_at"` +} + +type Conversation struct { + ID string `json:"id"` + ApplicationCode string `json:"application_code"` + Title string `json:"title"` + Status string `json:"status"` + Messages []ConversationMessage `json:"messages"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (s *Service) CreateConversation(ctx context.Context, account identity.Account, code string) (Conversation, error) { + app, err := s.assets.GetPublishedApplicationByCode(ctx, strings.ToLower(strings.TrimSpace(code))) + if err != nil || !visible(app.DepartmentIDs, account.DepartmentID) { + return Conversation{}, ErrNotFound + } + if s.credentials == nil || s.runtime == nil { + return Conversation{}, errors.New("应用运行服务未配置") + } + if _, _, err = s.credentials.Ensure(ctx, app.ID, account.DepartmentID); err != nil { + return Conversation{}, err + } + id, err := platformid.NewUUID() + if err != nil { + return Conversation{}, err + } + var item Conversation + err = s.pool.QueryRow(ctx, `INSERT INTO gateway.portal_conversations(id,portal_user_id,application_id) VALUES($1,$2,$3) RETURNING id::text,'',title,status,created_at,updated_at`, id, account.ID, app.ID).Scan(&item.ID, &item.ApplicationCode, &item.Title, &item.Status, &item.CreatedAt, &item.UpdatedAt) + item.ApplicationCode = app.Code + item.Messages = []ConversationMessage{} + return item, err +} + +func messageDigest(previous string, sequence int, role, content string) string { + digest := sha256.Sum256([]byte(fmt.Sprintf("%s\n%d\n%s\n%s", previous, sequence, role, content))) + return hex.EncodeToString(digest[:]) +} + +func (s *Service) conversation(ctx context.Context, account identity.Account, code, id string) (Conversation, error) { + var item Conversation + err := s.pool.QueryRow(ctx, `SELECT c.id::text,a.code,c.title,c.status,c.created_at,c.updated_at FROM gateway.portal_conversations c JOIN gateway.applications a ON a.id=c.application_id WHERE c.id=$1 AND c.portal_user_id=$2 AND a.code=$3`, id, account.ID, code).Scan(&item.ID, &item.ApplicationCode, &item.Title, &item.Status, &item.CreatedAt, &item.UpdatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return Conversation{}, ErrNotFound + } + if err != nil { + return Conversation{}, err + } + rows, err := s.pool.Query(ctx, `SELECT sequence,role,content,previous_hash,message_hash,created_at FROM gateway.portal_conversation_messages WHERE conversation_id=$1 ORDER BY sequence`, id) + if err != nil { + return Conversation{}, err + } + defer rows.Close() + previous := strings.Repeat("0", 64) + item.Messages = []ConversationMessage{} + for rows.Next() { + var message ConversationMessage + var storedPrevious, storedHash string + if err = rows.Scan(&message.Sequence, &message.Role, &message.Content, &storedPrevious, &storedHash, &message.CreatedAt); err != nil { + return Conversation{}, err + } + if storedPrevious != previous || storedHash != messageDigest(previous, message.Sequence, message.Role, message.Content) { + return Conversation{}, errors.New("会话历史完整性校验失败") + } + previous = storedHash + item.Messages = append(item.Messages, message) + } + return item, rows.Err() +} + +func (s *Service) Conversation(ctx context.Context, account identity.Account, code, id string) (Conversation, error) { + return s.conversation(ctx, account, strings.ToLower(strings.TrimSpace(code)), id) +} + +func (s *Service) appendMessage(ctx context.Context, conversationID, role, content string) (ConversationMessage, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return ConversationMessage{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + var sequence int + if err = tx.QueryRow(ctx, `SELECT next_sequence FROM gateway.portal_conversations WHERE id=$1 FOR UPDATE`, conversationID).Scan(&sequence); err != nil { + return ConversationMessage{}, err + } + if sequence > 200 { + return ConversationMessage{}, errors.New("本会话已达到 200 条消息上限") + } + previous := strings.Repeat("0", 64) + if sequence > 1 { + if err = tx.QueryRow(ctx, `SELECT message_hash FROM gateway.portal_conversation_messages WHERE conversation_id=$1 AND sequence=$2`, conversationID, sequence-1).Scan(&previous); err != nil { + return ConversationMessage{}, err + } + } + id, err := platformid.NewUUID() + if err != nil { + return ConversationMessage{}, err + } + hash := messageDigest(previous, sequence, role, content) + var created time.Time + if err = tx.QueryRow(ctx, `INSERT INTO gateway.portal_conversation_messages(id,conversation_id,sequence,role,content,previous_hash,message_hash) VALUES($1,$2,$3,$4,$5,$6,$7) RETURNING created_at`, id, conversationID, sequence, role, content, previous, hash).Scan(&created); err != nil { + return ConversationMessage{}, err + } + _, err = tx.Exec(ctx, `UPDATE gateway.portal_conversations SET next_sequence=next_sequence+1,title=CASE WHEN next_sequence=1 THEN left($2,160) ELSE title END,updated_at=clock_timestamp() WHERE id=$1`, conversationID, content) + if err != nil { + return ConversationMessage{}, err + } + if err = tx.Commit(ctx); err != nil { + return ConversationMessage{}, err + } + return ConversationMessage{Sequence: sequence, Role: role, Content: content, CreatedAt: created}, nil +} + +func (s *Service) callApplication(ctx context.Context, appCode, secret string, messages []ConversationMessage, variables map[string]any) (map[string]any, string, error) { + payloadMessages := make([]map[string]any, 0, len(messages)) + for _, m := range messages { + payloadMessages = append(payloadMessages, map[string]any{"role": m.Role, "content": m.Content}) + } + payload, _ := json.Marshal(map[string]any{"messages": payloadMessages, "variables": variables}) + request := httptest.NewRequest(http.MethodPost, "/v1/applications/"+appCode+"/chat/completions", bytes.NewReader(payload)).WithContext(gateway.WithRequestID(ctx, "portal-"+time.Now().UTC().Format("20060102150405.000000000"))) + request.Header.Set("Authorization", "Bearer "+secret) + request.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + s.runtime.ServeHTTP(recorder, request) + var response map[string]any + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + return nil, "", errors.New("应用响应无法解析") + } + if recorder.Code < 200 || recorder.Code >= 300 { + return response, "", fmt.Errorf("应用调用失败(HTTP %d)", recorder.Code) + } + choices, _ := response["choices"].([]any) + if len(choices) == 0 { + return response, "", errors.New("应用未返回回答") + } + choice, _ := choices[0].(map[string]any) + message, _ := choice["message"].(map[string]any) + answer, _ := message["content"].(string) + if strings.TrimSpace(answer) == "" { + return response, "", errors.New("应用未返回文本回答") + } + return response, answer, nil +} + +func (s *Service) Chat(ctx context.Context, account identity.Account, code, message string, variables map[string]any) (map[string]any, error) { + message = strings.TrimSpace(message) + if message == "" || len(message) > 100000 { + return nil, errors.New("消息为空或过长") + } + app, err := s.assets.GetPublishedApplicationByCode(ctx, strings.ToLower(strings.TrimSpace(code))) + if err != nil || !visible(app.DepartmentIDs, account.DepartmentID) { + return nil, ErrNotFound + } + secret, _, err := s.credentials.Ensure(ctx, app.ID, account.DepartmentID) + if err != nil { + return nil, err + } + response, _, err := s.callApplication(ctx, app.Code, secret, []ConversationMessage{{Role: "user", Content: message}}, variables) + return response, err +} + +func (s *Service) AppendConversationMessage(ctx context.Context, account identity.Account, code, id, message string, variables map[string]any) (map[string]any, error) { + message = strings.TrimSpace(message) + if message == "" || len(message) > 100000 { + return nil, errors.New("消息为空或过长") + } + lease, _ := platformid.NewUUID() + var appID string + tag, err := s.pool.Exec(ctx, `UPDATE gateway.portal_conversations c SET busy=true,busy_token=$4,busy_since=clock_timestamp() FROM gateway.applications a WHERE c.application_id=a.id AND c.id=$1 AND c.portal_user_id=$2 AND a.code=$3 AND c.status='active' AND (NOT c.busy OR c.busy_since 200 { + value = 200 + } + return value +} +func daysParam(r *http.Request) int { + value, _ := strconv.Atoi(r.URL.Query().Get("days")) + if value != 1 && value != 7 && value != 30 { + value = 7 + } + return value +} +func (h *HTTPHandler) logs(w http.ResponseWriter, r *http.Request) { + a, ok := h.account(w, r) + if !ok { + return + } + days, limit := daysParam(r), limitParam(r) + rows, err := h.service.pool.Query(r.Context(), `SELECT e.id::text,e.request_id,e.provider_code,e.model,e.protocol,e.status_code,e.prompt_tokens,e.completion_tokens,e.cost_microunits,e.latency_ms,e.labels,e.recorded_at FROM gateway.audit_events e JOIN gateway.api_keys k ON k.id=e.api_key_id WHERE k.portal_user_id=$1 AND e.recorded_at >= clock_timestamp()-make_interval(days=>$2) ORDER BY e.recorded_at DESC LIMIT $3`, a.ID, days, limit) + if err != nil { + portalError(w, err) + return + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var id, requestID, protocol string + var provider, model *string + var status, latency *int + var pt, ct, cost *int64 + var labels json.RawMessage + var at time.Time + if err = rows.Scan(&id, &requestID, &provider, &model, &protocol, &status, &pt, &ct, &cost, &latency, &labels, &at); err != nil { + portalError(w, err) + return + } + items = append(items, map[string]any{"id": id, "request_id": requestID, "provider_code": provider, "model": model, "protocol": protocol, "status_code": status, "prompt_tokens": pt, "completion_tokens": ct, "cost_microunits": cost, "latency_ms": latency, "labels": labels, "recorded_at": at}) + } + apiresponse.OK(w, map[string]any{"items": items}) +} +func (h *HTTPHandler) logDetail(w http.ResponseWriter, r *http.Request) { + a, ok := h.account(w, r) + if !ok { + return + } + var id, requestID, protocol string + var provider, model, requestPreview, responsePreview *string + var status, latency *int + var pt, ct, cost *int64 + var labels json.RawMessage + var at time.Time + err := h.service.pool.QueryRow(r.Context(), `SELECT e.id::text,e.request_id,e.provider_code,e.model,e.protocol,e.status_code,e.prompt_tokens,e.completion_tokens,e.cost_microunits,e.latency_ms,e.labels,e.request_preview,e.response_preview,e.recorded_at FROM gateway.audit_events e JOIN gateway.api_keys k ON k.id=e.api_key_id WHERE k.portal_user_id=$1 AND e.id=$2 ORDER BY e.recorded_at DESC LIMIT 1`, a.ID, r.PathValue("id")).Scan(&id, &requestID, &provider, &model, &protocol, &status, &pt, &ct, &cost, &latency, &labels, &requestPreview, &responsePreview, &at) + if err != nil { + if errors.Is(err, ErrNotFound) { + portalError(w, err) + } else { + apiresponse.Error(w, http.StatusNotFound, "日志不存在或无权访问") + } + return + } + apiresponse.OK(w, map[string]any{"id": id, "request_id": requestID, "provider_code": provider, "model": model, "protocol": protocol, "status_code": status, "prompt_tokens": pt, "completion_tokens": ct, "cost_microunits": cost, "latency_ms": latency, "labels": labels, "request_preview": requestPreview, "response_preview": responsePreview, "recorded_at": at}) +} +func (h *HTTPHandler) stats(w http.ResponseWriter, r *http.Request) { + a, ok := h.account(w, r) + if !ok { + return + } + days := daysParam(r) + var requests, failed, pt, ct, cost int64 + var latency *float64 + err := h.service.pool.QueryRow(r.Context(), `SELECT count(*),count(*) FILTER (WHERE coalesce(e.status_code,500)>=400),coalesce(sum(e.prompt_tokens),0),coalesce(sum(e.completion_tokens),0),coalesce(sum(e.cost_microunits),0),avg(e.latency_ms) FROM gateway.audit_events e JOIN gateway.api_keys k ON k.id=e.api_key_id WHERE k.portal_user_id=$1 AND e.recorded_at>=clock_timestamp()-make_interval(days=>$2)`, a.ID, days).Scan(&requests, &failed, &pt, &ct, &cost, &latency) + if err != nil { + portalError(w, err) + return + } + apiresponse.OK(w, map[string]any{"days": days, "requests": requests, "failed_requests": failed, "prompt_tokens": pt, "completion_tokens": ct, "total_tokens": pt + ct, "cost_microunits": cost, "avg_latency_ms": latency}) +} +func (h *HTTPHandler) cost(w http.ResponseWriter, r *http.Request) { + a, ok := h.account(w, r) + if !ok { + return + } + var cost int64 + err := h.service.pool.QueryRow(r.Context(), `SELECT coalesce(sum(e.cost_microunits),0) FROM gateway.audit_events e JOIN gateway.api_keys k ON k.id=e.api_key_id WHERE k.portal_user_id=$1 AND e.recorded_at>=date_trunc('month',clock_timestamp())`, a.ID).Scan(&cost) + if err != nil { + portalError(w, err) + return + } + apiresponse.OK(w, map[string]any{"cost_microunits_this_month": cost, "period": time.Now().UTC().Format("2006-01")}) +} +func (h *HTTPHandler) docsInfo(w http.ResponseWriter, r *http.Request) { + if _, ok := h.account(w, r); !ok { + return + } + apiresponse.OK(w, map[string]any{"base_url": "/v1", "protocols": []string{"OpenAI chat/completions", "OpenAI responses", "Anthropic messages", "embeddings"}, "authentication": "Authorization: Bearer ", "note": "密钥只在创建时显示一次,请由管理员审批后通过安全渠道交付。"}) +} + +type chatInput struct { + Message string `json:"message"` + Messages []map[string]any `json:"messages"` + Variables map[string]any `json:"variables"` +} + +func messageFromInput(input chatInput) (string, bool) { + if strings.TrimSpace(input.Message) != "" { + return input.Message, true + } + if len(input.Messages) != 1 { + return "", false + } + role, _ := input.Messages[0]["role"].(string) + content, _ := input.Messages[0]["content"].(string) + return content, role == "user" && strings.TrimSpace(content) != "" +} + +func writeApplicationResponse(w http.ResponseWriter, response map[string]any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(response) +} + +func (h *HTTPHandler) chat(w http.ResponseWriter, r *http.Request) { + a, ok := h.account(w, r) + if !ok { + return + } + var input chatInput + if !decode(w, r, &input) { + return + } + message, ok := messageFromInput(input) + if !ok { + apiresponse.Error(w, 400, "兼容入口只接受一条 user 文本消息") + return + } + response, err := h.service.Chat(r.Context(), a, r.PathValue("code"), message, input.Variables) + if err != nil { + portalError(w, err) + return + } + writeApplicationResponse(w, response) +} +func (h *HTTPHandler) createConversation(w http.ResponseWriter, r *http.Request) { + a, ok := h.account(w, r) + if !ok { + return + } + item, err := h.service.CreateConversation(r.Context(), a, r.PathValue("code")) + if err != nil { + portalError(w, err) + return + } + apiresponse.OK(w, item) +} +func (h *HTTPHandler) getConversation(w http.ResponseWriter, r *http.Request) { + a, ok := h.account(w, r) + if !ok { + return + } + item, err := h.service.Conversation(r.Context(), a, r.PathValue("code"), r.PathValue("id")) + if err != nil { + portalError(w, err) + return + } + apiresponse.OK(w, item) +} +func (h *HTTPHandler) appendConversationMessage(w http.ResponseWriter, r *http.Request) { + a, ok := h.account(w, r) + if !ok { + return + } + var input chatInput + if !decode(w, r, &input) { + return + } + message, ok := messageFromInput(input) + if !ok { + apiresponse.Error(w, 400, "消息不能为空") + return + } + response, err := h.service.AppendConversationMessage(r.Context(), a, r.PathValue("code"), r.PathValue("id"), message, input.Variables) + if err != nil { + portalError(w, err) + return + } + writeApplicationResponse(w, response) +} diff --git a/internal/portal/portal_integration_test.go b/internal/portal/portal_integration_test.go new file mode 100644 index 0000000..0fabbe2 --- /dev/null +++ b/internal/portal/portal_integration_test.go @@ -0,0 +1,117 @@ +package portal + +import ( + "bytes" + "context" + "encoding/base64" + "net/http" + "os" + "testing" + + "aigateway.local/core/internal/apikey" + "aigateway.local/core/internal/identity" + "aigateway.local/core/internal/platform/config" + "aigateway.local/core/internal/platform/cryptox" + "aigateway.local/core/internal/platform/database" + "aigateway.local/core/internal/workbench" +) + +func TestPortalWorkspacePostgreSQLLifecycle(t *testing.T) { + databaseURL := os.Getenv("WORKBENCH_TEST_DATABASE_URL") + if databaseURL == "" { + t.Skip("WORKBENCH_TEST_DATABASE_URL is not set") + } + ctx := context.Background() + pool, err := database.Open(ctx, config.Database{URL: databaseURL, MaxConns: 8}) + if err != nil { + t.Fatal(err) + } + defer pool.Close() + const actorID = "11111111-1111-4111-8111-111111111111" + const userID = "22222222-2222-4222-8222-222222222222" + _, err = pool.Exec(ctx, `INSERT INTO gateway.admin_accounts(id,username,password_hash,role) VALUES($1,'portal-test-admin','test','superadmin') ON CONFLICT(id) DO NOTHING`, actorID) + if err != nil { + t.Fatal(err) + } + _, err = pool.Exec(ctx, `INSERT INTO gateway.portal_users(id,account,name,role,permissions,password_hash,auth_source,active) VALUES($1,'portal-test-user','Portal Test','member','{}','test','local',true) ON CONFLICT(id) DO NOTHING`, userID) + if err != nil { + t.Fatal(err) + } + cleanup := func() { + _, _ = pool.Exec(ctx, `DELETE FROM gateway.api_keys WHERE id IN (SELECT api_key_id FROM gateway.application_runtime_credentials c JOIN gateway.applications a ON a.id=c.application_id WHERE a.code='portal_test_app')`) + _, _ = pool.Exec(ctx, `DELETE FROM gateway.model_access_requests WHERE portal_user_id=$1`, userID) + _, _ = pool.Exec(ctx, `DELETE FROM gateway.prompt_favorites WHERE portal_user_id=$1`, userID) + _, _ = pool.Exec(ctx, `DELETE FROM gateway.applications WHERE code='portal_test_app'`) + _, _ = pool.Exec(ctx, `DELETE FROM gateway.prompt_templates WHERE name='portal-test-prompt'`) + _, _ = pool.Exec(ctx, `DELETE FROM gateway.portal_users WHERE id=$1`, userID) + } + cleanup() + _, err = pool.Exec(ctx, `INSERT INTO gateway.portal_users(id,account,name,role,permissions,password_hash,auth_source,active) VALUES($1,'portal-test-user','Portal Test','member','{}','test','local',true)`, userID) + if err != nil { + t.Fatal(err) + } + defer cleanup() + + assets := workbench.NewService(pool) + prompt, err := assets.CreatePrompt(ctx, workbench.PromptInput{Name: "portal-test-prompt", Enabled: true, Content: "hello"}, actorID) + if err != nil { + t.Fatal(err) + } + app, err := assets.SaveApplication(ctx, workbench.Application{Code: "portal_test_app", Name: "Portal Test", Status: "draft", DraftConfig: workbench.ApplicationConfig{Model: "test-model", PromptTemplateID: prompt.ID}}, actorID, true) + if err != nil { + t.Fatal(err) + } + if _, err = assets.PublishApplication(ctx, app.ID, "portal test", actorID); err != nil { + t.Fatal(err) + } + key := base64.StdEncoding.EncodeToString(make([]byte, 32)) + cipher, err := cryptox.NewKeyring(key, 1, "", "portal-test-tool") + if err != nil { + t.Fatal(err) + } + service := NewService(pool, assets, workbench.NewToolService(assets, cipher, true), nil) + runtime := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !bytes.HasPrefix([]byte(r.Header.Get("Authorization")), []byte("Bearer gw_")) { + t.Errorf("runtime credential was not supplied") + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"portal answer"}}]}`)) + }) + service.SetApplicationRuntime(NewRuntimeCredentials(pool, apikey.NewRepository(pool), cipher), runtime) + account := identity.Account{ID: userID, Kind: identity.KindPortal} + apps, err := service.Applications(ctx, account) + if err != nil || len(apps) != 1 { + t.Fatalf("apps=%#v err=%v", apps, err) + } + prompts, err := service.Prompts(ctx, account) + if err != nil || len(prompts) != 1 || prompts[0].Favorite { + t.Fatalf("prompts=%#v err=%v", prompts, err) + } + if err = service.SetFavorite(ctx, account, prompt.ID, true); err != nil { + t.Fatal(err) + } + promptView, err := service.Prompt(ctx, account, prompt.ID) + if err != nil || !promptView.Favorite { + t.Fatalf("prompt=%#v err=%v", promptView, err) + } + request, err := service.CreateModelRequest(ctx, account, ModelRequest{Model: "test-model", Reason: "project", RequestedRPM: 10, RequestedMonthlyTokens: 1000}) + if err != nil || request.Status != "pending" { + t.Fatalf("request=%#v err=%v", request, err) + } + request, err = service.DecideModelRequest(ctx, request.ID, "approved", "approved", actorID) + if err != nil || request.Status != "approved" || request.DecidedAt == nil { + t.Fatalf("decision=%#v err=%v", request, err) + } + conversation, err := service.CreateConversation(ctx, account, "portal_test_app") + if err != nil { + t.Fatal(err) + } + response, err := service.AppendConversationMessage(ctx, account, "portal_test_app", conversation.ID, "hello", nil) + if err != nil || response["history_integrity"] != "verified" { + t.Fatalf("response=%#v err=%v", response, err) + } + conversation, err = service.Conversation(ctx, account, "portal_test_app", conversation.ID) + if err != nil || len(conversation.Messages) != 2 || conversation.Messages[1].Content != "portal answer" { + t.Fatalf("conversation=%#v err=%v", conversation, err) + } +} diff --git a/internal/portal/service.go b/internal/portal/service.go new file mode 100644 index 0000000..8c721af --- /dev/null +++ b/internal/portal/service.go @@ -0,0 +1,368 @@ +package portal + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "aigateway.local/core/internal/identity" + platformid "aigateway.local/core/internal/platform/id" + "aigateway.local/core/internal/workbench" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +var ErrNotFound = errors.New("portal resource not found") + +type Service struct { + pool *pgxpool.Pool + assets *workbench.Service + tools *workbench.ToolService + identity *identity.Service + credentials *RuntimeCredentials + runtime http.Handler + market *workbench.MarketplaceService +} + +func NewService(pool *pgxpool.Pool, assets *workbench.Service, tools *workbench.ToolService, identityService *identity.Service) *Service { + return &Service{pool: pool, assets: assets, tools: tools, identity: identityService} +} + +func (s *Service) SetApplicationRuntime(credentials *RuntimeCredentials, runtime http.Handler) { + s.credentials = credentials + s.runtime = runtime +} + +// SetMarketplace wires the resource-marketplace service into the portal so the +// marketplace pages can browse, install and manage resources. +func (s *Service) SetMarketplace(market *workbench.MarketplaceService) { + s.market = market +} + +func visible(departmentIDs []string, departmentID *string) bool { + if len(departmentIDs) == 0 { + return true + } + if departmentID == nil { + return false + } + for _, id := range departmentIDs { + if id == *departmentID { + return true + } + } + return false +} + +func (s *Service) Applications(ctx context.Context, account identity.Account) ([]workbench.Application, error) { + items, err := s.assets.ListApplications(ctx) + if err != nil { + return nil, err + } + result := make([]workbench.Application, 0, len(items)) + for _, item := range items { + if item.Status == "active" && item.PublishedVersion != nil && visible(item.DepartmentIDs, account.DepartmentID) { + result = append(result, item) + } + } + return result, nil +} + +func (s *Service) Knowledge(ctx context.Context, account identity.Account) ([]workbench.KnowledgeBase, error) { + items, err := s.assets.ListKnowledgeBases(ctx) + if err != nil { + return nil, err + } + result := make([]workbench.KnowledgeBase, 0, len(items)) + for _, item := range items { + if item.Enabled && visible(item.DepartmentIDs, account.DepartmentID) { + result = append(result, item) + } + } + return result, nil +} + +func (s *Service) Tools(ctx context.Context, account identity.Account) ([]workbench.Tool, error) { + items, err := s.tools.List(ctx) + if err != nil { + return nil, err + } + result := make([]workbench.Tool, 0, len(items)) + for _, item := range items { + if item.Enabled && visible(item.DepartmentIDs, account.DepartmentID) { + item.EndpointURL = "" + result = append(result, item) + } + } + return result, nil +} + +type PromptView struct { + workbench.PromptTemplate + Favorite bool `json:"favorite"` +} + +func (s *Service) Prompts(ctx context.Context, account identity.Account) ([]PromptView, error) { + items, err := s.assets.ListPrompts(ctx) + if err != nil { + return nil, err + } + rows, err := s.pool.Query(ctx, `SELECT prompt_id::text FROM gateway.prompt_favorites WHERE portal_user_id=$1`, account.ID) + if err != nil { + return nil, err + } + defer rows.Close() + favorites := map[string]bool{} + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + favorites[id] = true + } + result := make([]PromptView, 0, len(items)) + for _, item := range items { + if item.Enabled && item.Current != nil && visible(item.DepartmentIDs, account.DepartmentID) { + result = append(result, PromptView{PromptTemplate: item, Favorite: favorites[item.ID]}) + } + } + return result, rows.Err() +} + +func (s *Service) Prompt(ctx context.Context, account identity.Account, id string) (PromptView, error) { + item, err := s.assets.GetPrompt(ctx, id) + if err != nil || !item.Enabled || item.Current == nil || !visible(item.DepartmentIDs, account.DepartmentID) { + return PromptView{}, ErrNotFound + } + var favorite bool + err = s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gateway.prompt_favorites WHERE portal_user_id=$1 AND prompt_id=$2)`, account.ID, id).Scan(&favorite) + return PromptView{PromptTemplate: item, Favorite: favorite}, err +} + +func (s *Service) SetFavorite(ctx context.Context, account identity.Account, id string, value bool) error { + if _, err := s.Prompt(ctx, account, id); err != nil { + return err + } + if value { + _, err := s.pool.Exec(ctx, `INSERT INTO gateway.prompt_favorites(portal_user_id,prompt_id) VALUES($1,$2) ON CONFLICT DO NOTHING`, account.ID, id) + return err + } + _, err := s.pool.Exec(ctx, `DELETE FROM gateway.prompt_favorites WHERE portal_user_id=$1 AND prompt_id=$2`, account.ID, id) + return err +} + +type Model struct { + ProviderCode string `json:"provider_code"` + ID string `json:"id"` + OwnedBy string `json:"owned_by"` +} + +func (s *Service) Models(ctx context.Context) ([]Model, error) { + rows, err := s.pool.Query(ctx, `SELECT p.code,m.provider_model_id,m.owned_by FROM gateway.provider_models m JOIN gateway.providers p ON p.id=m.provider_id WHERE p.enabled AND m.enabled ORDER BY p.code,m.provider_model_id`) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Model{} + for rows.Next() { + var item Model + if err := rows.Scan(&item.ProviderCode, &item.ID, &item.OwnedBy); err != nil { + return nil, err + } + items = append(items, item) + } + return items, rows.Err() +} + +type ModelRequest struct { + ID string `json:"id"` + PortalUserID string `json:"portal_user_id"` + UserLogin string `json:"user_login,omitempty"` + ProviderCode string `json:"provider_code"` + Model string `json:"model"` + Reason string `json:"reason"` + RequestedRPM int `json:"requested_rpm"` + RequestedMonthlyTokens int64 `json:"requested_monthly_tokens"` + Status string `json:"status"` + DecisionNote string `json:"decision_note"` + DecidedAt *time.Time `json:"decided_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +const modelRequestSelect = `SELECT r.id::text,r.portal_user_id::text,u.account,r.provider_code,r.model,r.reason,r.requested_rpm,r.requested_monthly_tokens,r.status,r.decision_note,r.decided_at,r.created_at,r.updated_at FROM gateway.model_access_requests r JOIN gateway.portal_users u ON u.id=r.portal_user_id` + +func scanModelRequest(row pgx.Row) (ModelRequest, error) { + var item ModelRequest + err := row.Scan(&item.ID, &item.PortalUserID, &item.UserLogin, &item.ProviderCode, &item.Model, &item.Reason, &item.RequestedRPM, &item.RequestedMonthlyTokens, &item.Status, &item.DecisionNote, &item.DecidedAt, &item.CreatedAt, &item.UpdatedAt) + return item, err +} + +func (s *Service) CreateModelRequest(ctx context.Context, account identity.Account, item ModelRequest) (ModelRequest, error) { + item.ProviderCode = strings.ToLower(strings.TrimSpace(item.ProviderCode)) + item.Model = strings.TrimSpace(item.Model) + item.Reason = strings.TrimSpace(item.Reason) + if item.Model == "" || len(item.Model) > 512 || len(item.Reason) > 4000 { + return ModelRequest{}, errors.New("模型或申请理由格式无效") + } + if item.RequestedRPM == 0 { + item.RequestedRPM = 60 + } + if item.RequestedRPM < 1 || item.RequestedRPM > 100000 || item.RequestedMonthlyTokens < 0 { + return ModelRequest{}, errors.New("申请限额无效") + } + item.ID, _ = platformid.NewUUID() + eventID, _ := platformid.NewUUID() + tx, err := s.pool.Begin(ctx) + if err != nil { + return ModelRequest{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + _, err = tx.Exec(ctx, `INSERT INTO gateway.model_access_requests(id,portal_user_id,provider_code,model,reason,requested_rpm,requested_monthly_tokens) VALUES($1,$2,$3,$4,$5,$6,$7)`, item.ID, account.ID, item.ProviderCode, item.Model, item.Reason, item.RequestedRPM, item.RequestedMonthlyTokens) + if err != nil { + return ModelRequest{}, err + } + payload, _ := json.Marshal(map[string]any{"request_id": item.ID, "portal_user_id": account.ID, "model": item.Model}) + if _, err = tx.Exec(ctx, `INSERT INTO gateway.outbox_events(event_id,event_type,event_version,aggregate_type,aggregate_id,payload) VALUES($1,'model_access.requested',1,'model_access_request',$2,$3)`, eventID, item.ID, payload); err != nil { + return ModelRequest{}, err + } + if err = tx.Commit(ctx); err != nil { + return ModelRequest{}, err + } + return scanModelRequest(s.pool.QueryRow(ctx, modelRequestSelect+` WHERE r.id=$1`, item.ID)) +} + +func (s *Service) ModelRequests(ctx context.Context, portalUserID, status string) ([]ModelRequest, error) { + where, args := " WHERE true", []any{} + if portalUserID != "" { + args = append(args, portalUserID) + where += fmt.Sprintf(" AND r.portal_user_id=$%d", len(args)) + } + if status != "" { + args = append(args, status) + where += fmt.Sprintf(" AND r.status=$%d", len(args)) + } + rows, err := s.pool.Query(ctx, modelRequestSelect+where+` ORDER BY r.created_at DESC LIMIT 500`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ModelRequest{} + for rows.Next() { + item, err := scanModelRequest(rows) + if err != nil { + return nil, err + } + items = append(items, item) + } + return items, rows.Err() +} + +func (s *Service) DecideModelRequest(ctx context.Context, id, status, note, actorID string) (ModelRequest, error) { + if status != "approved" && status != "rejected" { + return ModelRequest{}, errors.New("审批状态无效") + } + if len(note) > 4000 { + return ModelRequest{}, errors.New("审批备注过长") + } + eventID, _ := platformid.NewUUID() + tx, err := s.pool.Begin(ctx) + if err != nil { + return ModelRequest{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + tag, err := tx.Exec(ctx, `UPDATE gateway.model_access_requests SET status=$2,decision_note=$3,decided_by=$4,decided_at=clock_timestamp(),updated_at=clock_timestamp() WHERE id=$1 AND status='pending'`, id, status, strings.TrimSpace(note), actorID) + if err != nil { + return ModelRequest{}, err + } + if tag.RowsAffected() == 0 { + return ModelRequest{}, ErrNotFound + } + payload, _ := json.Marshal(map[string]any{"request_id": id, "status": status, "actor_id": actorID}) + if _, err = tx.Exec(ctx, `INSERT INTO gateway.outbox_events(event_id,event_type,event_version,aggregate_type,aggregate_id,payload) VALUES($1,'model_access.decided',1,'model_access_request',$2,$3)`, eventID, id, payload); err != nil { + return ModelRequest{}, err + } + if err = tx.Commit(ctx); err != nil { + return ModelRequest{}, err + } + return scanModelRequest(s.pool.QueryRow(ctx, modelRequestSelect+` WHERE r.id=$1`, id)) +} + +// --- 资源市场 --- + +// MarketplaceCatalog returns the published resources visible to the portal +// user. Department-scoped resources only appear when the user belongs to the +// department; cross-department resources require an install and are managed +// from the "my installs" surface. +func (s *Service) MarketplaceCatalog(ctx context.Context, account identity.Account, resourceType, categoryID, tag, query string) ([]workbench.MarketItem, error) { + if s.market == nil { + return []workbench.MarketItem{}, nil + } + items, err := s.market.Catalog(ctx, resourceType, categoryID, tag, query, 100) + if err != nil { + return nil, err + } + result := make([]workbench.MarketItem, 0, len(items)) + for _, item := range items { + if visible(item.DepartmentIDs, account.DepartmentID) { + result = append(result, item) + } + } + return result, nil +} + +// MarketplaceDetail returns a published resource the user is allowed to see. +func (s *Service) MarketplaceDetail(ctx context.Context, account identity.Account, resourceType, code string) (workbench.MarketItem, json.RawMessage, error) { + if s.market == nil { + return workbench.MarketItem{}, nil, ErrNotFound + } + item, raw, err := s.market.Detail(ctx, resourceType, code) + if err != nil { + return item, nil, err + } + if !visible(item.DepartmentIDs, account.DepartmentID) { + return item, nil, ErrNotFound + } + return item, raw, nil +} + +// MarketplaceInstall binds a published, visible resource to the portal user's +// workspace. Cross-department resources the user cannot see cannot be installed. +func (s *Service) MarketplaceInstall(ctx context.Context, account identity.Account, resourceType, code string) (bool, error) { + if s.market == nil { + return false, ErrNotFound + } + item, _, err := s.market.Detail(ctx, resourceType, code) + if err != nil { + return false, err + } + if !visible(item.DepartmentIDs, account.DepartmentID) { + return false, ErrNotFound + } + return s.market.Install(ctx, resourceType, code, account.ID) +} + +func (s *Service) MarketplaceUninstall(ctx context.Context, account identity.Account, resourceType, code string) error { + if s.market == nil { + return ErrNotFound + } + return s.market.Uninstall(ctx, resourceType, code, account.ID) +} + +func (s *Service) MarketplaceInstalled(ctx context.Context, account identity.Account) ([]workbench.MarketItem, error) { + if s.market == nil { + return []workbench.MarketItem{}, nil + } + return s.market.ListInstalled(ctx, account.ID) +} + +func (s *Service) MarketplaceCategories(ctx context.Context) ([]workbench.Category, error) { + if s.market == nil { + return []workbench.Category{}, nil + } + return s.market.ListCategories(ctx, "") +} diff --git a/internal/pricing/admin_http.go b/internal/pricing/admin_http.go new file mode 100644 index 0000000..e7dbcce --- /dev/null +++ b/internal/pricing/admin_http.go @@ -0,0 +1,114 @@ +package pricing + +import ( + "aigateway.local/core/internal/identity" + "aigateway.local/core/internal/platform/apiresponse" + "encoding/json" + "errors" + "net/http" +) + +type AdminHTTPHandler struct { + service *Service + identity *identity.Service + mux *http.ServeMux +} + +func NewAdminHTTPHandler(service *Service, identityService *identity.Service) *AdminHTTPHandler { + h := &AdminHTTPHandler{service: service, identity: identityService, mux: http.NewServeMux()} + h.mux.HandleFunc("GET /api/v1/admin/model-prices", h.list) + h.mux.HandleFunc("POST /api/v1/admin/model-prices", h.create) + h.mux.HandleFunc("PUT /api/v1/admin/model-prices/{price_id}", h.update) + h.mux.HandleFunc("DELETE /api/v1/admin/model-prices/{price_id}", h.delete) + return h +} +func (h *AdminHTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.mux.ServeHTTP(w, r) } +func (h *AdminHTTPHandler) list(w http.ResponseWriter, r *http.Request) { + if _, ok := h.require(w, r, identity.PermissionPricingRead); !ok { + return + } + items, err := h.service.List(r.Context()) + if err != nil { + apiresponse.Error(w, 503, "模型价格服务暂不可用") + return + } + apiresponse.OK(w, items) +} +func (h *AdminHTTPHandler) create(w http.ResponseWriter, r *http.Request) { + a, ok := h.require(w, r, identity.PermissionPricingManage) + if !ok { + return + } + p, ok := decode(w, r) + if !ok { + return + } + saved, err := h.service.Save(r.Context(), p, a.ID, true) + finish(w, saved, err) +} +func (h *AdminHTTPHandler) update(w http.ResponseWriter, r *http.Request) { + a, ok := h.require(w, r, identity.PermissionPricingManage) + if !ok { + return + } + p, ok := decode(w, r) + if !ok { + return + } + p.ID = r.PathValue("price_id") + saved, err := h.service.Save(r.Context(), p, a.ID, false) + finish(w, saved, err) +} +func (h *AdminHTTPHandler) delete(w http.ResponseWriter, r *http.Request) { + a, ok := h.require(w, r, identity.PermissionPricingManage) + if !ok { + return + } + err := h.service.Delete(r.Context(), r.PathValue("price_id"), a.ID) + if errors.Is(err, ErrNotFound) { + apiresponse.Error(w, 404, "模型价格不存在") + return + } + if err != nil { + apiresponse.Error(w, 503, "模型价格删除失败") + return + } + apiresponse.OK(w, map[string]bool{"deleted": true}) +} +func decode(w http.ResponseWriter, r *http.Request) (Price, bool) { + var p Price + d := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) + d.DisallowUnknownFields() + if d.Decode(&p) != nil { + apiresponse.Error(w, 400, "请求格式无效") + return p, false + } + if err := Validate(&p); err != nil { + apiresponse.Error(w, 400, err.Error()) + return p, false + } + return p, true +} +func finish(w http.ResponseWriter, p Price, err error) { + if errors.Is(err, ErrNotFound) { + apiresponse.Error(w, 404, "模型价格不存在") + return + } + if err != nil { + apiresponse.Error(w, 400, err.Error()) + return + } + apiresponse.OK(w, p) +} +func (h *AdminHTTPHandler) require(w http.ResponseWriter, r *http.Request, permission string) (identity.Account, bool) { + a, err := h.identity.Authenticate(r.Context(), identity.KindAdmin, r.Header.Get("Authorization")) + if err != nil { + apiresponse.Error(w, 401, "登录状态无效") + return a, false + } + if !identity.HasPermission(a, permission) { + apiresponse.Error(w, 403, "缺少模型价格权限") + return a, false + } + return a, true +} diff --git a/internal/pricing/service.go b/internal/pricing/service.go new file mode 100644 index 0000000..a272003 --- /dev/null +++ b/internal/pricing/service.go @@ -0,0 +1,252 @@ +package pricing + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "math" + "regexp" + "sort" + "strings" + "sync/atomic" + "time" + + platformid "aigateway.local/core/internal/platform/id" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +var ErrNotFound = errors.New("model price not found") +var codePattern = regexp.MustCompile(`^[a-z][a-z0-9_-]{1,63}$`) + +type Price struct { + ID string `json:"id"` + ProviderCode string `json:"provider_code"` + ModelPattern string `json:"model_pattern"` + InputMicrounitsPerMillion int64 `json:"input_microunits_per_million"` + OutputMicrounitsPerMillion int64 `json:"output_microunits_per_million"` + Currency string `json:"currency"` + EffectiveFrom time.Time `json:"effective_from"` + EffectiveTo *time.Time `json:"effective_to"` + Enabled bool `json:"enabled"` + Revision int64 `json:"revision"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Cost struct { + Microunits int64 + PriceID, Currency string +} +type priceSnapshot struct{ prices []Price } +type Service struct { + pool *pgxpool.Pool + refresh time.Duration + logger *slog.Logger + current atomic.Pointer[priceSnapshot] +} + +func NewService(pool *pgxpool.Pool, refresh time.Duration, logger *slog.Logger) *Service { + if refresh <= 0 { + refresh = 30 * time.Second + } + s := &Service{pool: pool, refresh: refresh, logger: logger} + s.current.Store(&priceSnapshot{}) + return s +} +func (s *Service) Run(ctx context.Context) { + _ = s.Reload(ctx) + ticker := time.NewTicker(s.refresh) + defer ticker.Stop() + for { + select { + case <-ticker.C: + if err := s.Reload(ctx); err != nil && s.logger != nil { + s.logger.Warn("model price refresh failed", "error", err) + } + case <-ctx.Done(): + return + } + } +} +func (s *Service) Reload(ctx context.Context) error { + prices, err := s.List(ctx) + if err != nil { + return err + } + active := prices[:0] + for _, p := range prices { + if p.Enabled { + active = append(active, p) + } + } + sort.SliceStable(active, func(i, j int) bool { + exactI := !strings.HasSuffix(active[i].ModelPattern, "*") + exactJ := !strings.HasSuffix(active[j].ModelPattern, "*") + if exactI != exactJ { + return exactI + } + return active[i].EffectiveFrom.After(active[j].EffectiveFrom) + }) + s.current.Store(&priceSnapshot{prices: active}) + return nil +} + +func (s *Service) Calculate(providerCode, model string, input, output int64, at time.Time) Cost { + if s == nil { + return Cost{} + } + providerCode = strings.ToLower(providerCode) + for _, p := range s.current.Load().prices { + if p.ProviderCode != providerCode || at.Before(p.EffectiveFrom) || (p.EffectiveTo != nil && !at.Before(*p.EffectiveTo)) || !modelMatches(p.ModelPattern, model) { + continue + } + inputCost := ceilMillion(max(input, 0), p.InputMicrounitsPerMillion) + outputCost := ceilMillion(max(output, 0), p.OutputMicrounitsPerMillion) + cost := inputCost + outputCost + if inputCost > math.MaxInt64-outputCost { + cost = math.MaxInt64 + } + return Cost{Microunits: cost, PriceID: p.ID, Currency: p.Currency} + } + return Cost{} +} +func ceilMillion(tokens, rate int64) int64 { + if tokens <= 0 || rate <= 0 { + return 0 + } + if tokens > (math.MaxInt64-999999)/rate { + return math.MaxInt64 + } + return (tokens*rate + 999999) / 1000000 +} +func modelMatches(pattern, model string) bool { + if strings.HasSuffix(pattern, "*") { + return strings.HasPrefix(model, strings.TrimSuffix(pattern, "*")) + } + return pattern == model +} + +func (s *Service) List(ctx context.Context) ([]Price, error) { + rows, err := s.pool.Query(ctx, `SELECT id::text,provider_code,model_pattern,input_microunits_per_million,output_microunits_per_million,currency,effective_from,effective_to,enabled,revision,created_at,updated_at FROM gateway.model_prices ORDER BY provider_code,model_pattern,effective_from DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + result := []Price{} + for rows.Next() { + var p Price + if err := rows.Scan(&p.ID, &p.ProviderCode, &p.ModelPattern, &p.InputMicrounitsPerMillion, &p.OutputMicrounitsPerMillion, &p.Currency, &p.EffectiveFrom, &p.EffectiveTo, &p.Enabled, &p.Revision, &p.CreatedAt, &p.UpdatedAt); err != nil { + return nil, err + } + result = append(result, p) + } + return result, rows.Err() +} + +func Validate(p *Price) error { + p.ProviderCode = strings.ToLower(strings.TrimSpace(p.ProviderCode)) + p.ModelPattern = strings.TrimSpace(p.ModelPattern) + p.Currency = strings.ToUpper(strings.TrimSpace(p.Currency)) + if !codePattern.MatchString(p.ProviderCode) { + return errors.New("provider_code 格式无效") + } + if p.ModelPattern == "" || len(p.ModelPattern) > 255 || strings.Count(p.ModelPattern, "*") > 1 || (strings.Contains(p.ModelPattern, "*") && !strings.HasSuffix(p.ModelPattern, "*")) { + return errors.New("model_pattern 只能是精确模型名或末尾带 * 的前缀") + } + if p.InputMicrounitsPerMillion < 0 || p.OutputMicrounitsPerMillion < 0 { + return errors.New("价格不能为负数") + } + if p.InputMicrounitsPerMillion > 9_000_000_000_000 || p.OutputMicrounitsPerMillion > 9_000_000_000_000 { + return errors.New("价格超过安全上限") + } + if p.Currency == "" { + p.Currency = "USD" + } + if len(p.Currency) != 3 { + return errors.New("currency 必须是 3 位代码") + } + if p.EffectiveFrom.IsZero() { + return errors.New("effective_from 不能为空") + } + if p.EffectiveTo != nil && !p.EffectiveTo.After(p.EffectiveFrom) { + return errors.New("effective_to 必须晚于 effective_from") + } + return nil +} + +func (s *Service) Save(ctx context.Context, p Price, actorID string, create bool) (Price, error) { + if err := Validate(&p); err != nil { + return Price{}, err + } + eventID, err := platformid.NewUUID() + if err != nil { + return Price{}, err + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return Price{}, err + } + defer func() { _ = tx.Rollback(ctx) }() + if create { + p.ID, err = platformid.NewUUID() + if err != nil { + return Price{}, err + } + err = tx.QueryRow(ctx, `INSERT INTO gateway.model_prices(id,provider_code,model_pattern,input_microunits_per_million,output_microunits_per_million,currency,effective_from,effective_to,enabled,created_by) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING revision,created_at,updated_at`, p.ID, p.ProviderCode, p.ModelPattern, p.InputMicrounitsPerMillion, p.OutputMicrounitsPerMillion, p.Currency, p.EffectiveFrom, p.EffectiveTo, p.Enabled, actorID).Scan(&p.Revision, &p.CreatedAt, &p.UpdatedAt) + } else { + err = tx.QueryRow(ctx, `UPDATE gateway.model_prices SET provider_code=$2,model_pattern=$3,input_microunits_per_million=$4,output_microunits_per_million=$5,currency=$6,effective_from=$7,effective_to=$8,enabled=$9,revision=revision+1,updated_at=clock_timestamp() WHERE id=$1 RETURNING revision,created_at,updated_at`, p.ID, p.ProviderCode, p.ModelPattern, p.InputMicrounitsPerMillion, p.OutputMicrounitsPerMillion, p.Currency, p.EffectiveFrom, p.EffectiveTo, p.Enabled).Scan(&p.Revision, &p.CreatedAt, &p.UpdatedAt) + } + if errors.Is(err, pgx.ErrNoRows) { + return Price{}, ErrNotFound + } + if err != nil { + return Price{}, fmt.Errorf("save model price: %w", err) + } + eventType := "model_price.updated" + if create { + eventType = "model_price.created" + } + payload, _ := json.Marshal(map[string]any{"model_price_id": p.ID, "actor_id": actorID, "revision": p.Revision}) + if _, err = tx.Exec(ctx, `INSERT INTO gateway.outbox_events(event_id,event_type,event_version,aggregate_type,aggregate_id,payload) VALUES($1,$2,1,'model_price',$3,$4)`, eventID, eventType, p.ID, payload); err != nil { + return Price{}, err + } + if err = tx.Commit(ctx); err != nil { + return Price{}, err + } + if err = s.Reload(ctx); err != nil && s.logger != nil { + s.logger.Warn("price reload after save failed; snapshot may be stale", "error", err) + } + return p, nil +} + +func (s *Service) Delete(ctx context.Context, id, actorID string) error { + eventID, err := platformid.NewUUID() + if err != nil { + return err + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer func() { _ = tx.Rollback(ctx) }() + tag, err := tx.Exec(ctx, `DELETE FROM gateway.model_prices WHERE id=$1`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + payload, _ := json.Marshal(map[string]string{"model_price_id": id, "actor_id": actorID}) + if _, err = tx.Exec(ctx, `INSERT INTO gateway.outbox_events(event_id,event_type,event_version,aggregate_type,aggregate_id,payload) VALUES($1,'model_price.deleted',1,'model_price',$2,$3)`, eventID, id, payload); err != nil { + return err + } + if err = tx.Commit(ctx); err == nil { + if reloadErr := s.Reload(ctx); reloadErr != nil && s.logger != nil { + s.logger.Warn("price reload after delete failed; snapshot may be stale", "error", reloadErr) + } + } + return err +} diff --git a/internal/pricing/service_test.go b/internal/pricing/service_test.go new file mode 100644 index 0000000..0dc349e --- /dev/null +++ b/internal/pricing/service_test.go @@ -0,0 +1,29 @@ +package pricing + +import ( + "testing" + "time" +) + +func TestCalculateSelectsExactEffectivePrice(t *testing.T) { + now := time.Date(2026, 8, 11, 0, 0, 0, 0, time.UTC) + service := &Service{} + service.current.Store(&priceSnapshot{prices: []Price{ + {ID: "exact", ProviderCode: "openai", ModelPattern: "gpt-5", InputMicrounitsPerMillion: 2_000_000, OutputMicrounitsPerMillion: 8_000_000, Currency: "USD", EffectiveFrom: now.Add(-time.Hour), Enabled: true}, + {ID: "wild", ProviderCode: "openai", ModelPattern: "gpt-*", InputMicrounitsPerMillion: 1, Currency: "USD", EffectiveFrom: now.Add(-time.Hour), Enabled: true}, + }}) + cost := service.Calculate("OPENAI", "gpt-5", 1_000, 500, now) + if cost.PriceID != "exact" || cost.Microunits != 6_000 { + t.Fatalf("unexpected cost %#v", cost) + } +} + +func TestCalculateHonorsEffectiveWindow(t *testing.T) { + now := time.Now().UTC() + past := now.Add(-time.Hour) + service := &Service{} + service.current.Store(&priceSnapshot{prices: []Price{{ID: "expired", ProviderCode: "p1", ModelPattern: "m", InputMicrounitsPerMillion: 1_000_000, Currency: "USD", EffectiveFrom: now.Add(-2 * time.Hour), EffectiveTo: &past, Enabled: true}}}) + if cost := service.Calculate("p1", "m", 100, 0, now); cost.Microunits != 0 { + t.Fatalf("expired price selected: %#v", cost) + } +} diff --git a/internal/provider/admin_http.go b/internal/provider/admin_http.go new file mode 100644 index 0000000..8fe2471 --- /dev/null +++ b/internal/provider/admin_http.go @@ -0,0 +1,498 @@ +package provider + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "regexp" + "strings" + "time" + + "aigateway.local/core/internal/identity" + "aigateway.local/core/internal/platform/apiresponse" +) + +var providerCodePattern = regexp.MustCompile(`^[a-z][a-z0-9_-]{1,63}$`) + +type AdminHTTPHandler struct { + repository *Repository + cipher *CredentialCipher + identity *identity.Service + allowPrivate bool + changeHook func(context.Context) error + operations AdminOperations + mux *http.ServeMux +} + +func (h *AdminHTTPHandler) SetChangeHook(hook func(context.Context) error) { + h.changeHook = hook +} + +func (h *AdminHTTPHandler) SetOperations(operations AdminOperations) { + h.operations = operations +} + +type providerInput struct { + Code string `json:"code"` + Adapter string `json:"adapter"` + BaseURL string `json:"base_url"` + APIKey *string `json:"api_key"` + Capabilities []string `json:"capabilities"` + Config json.RawMessage `json:"config"` + Enabled *bool `json:"enabled"` +} + +type modelRouteInput struct { + Name string `json:"name"` + SourceModel string `json:"source_model"` + TargetModel string `json:"target_model"` + ProviderID string `json:"provider_id"` + Weight int `json:"weight"` + Priority int `json:"priority"` + Conditions json.RawMessage `json:"conditions"` + Enabled *bool `json:"enabled"` +} + +type modelRouteConditions struct { + Endpoints []string `json:"endpoints"` + APIKeyIDs []string `json:"api_key_ids"` + TenantIDs []string `json:"tenant_ids"` +} + +func NewAdminHTTPHandler(repository *Repository, cipher *CredentialCipher, identityService *identity.Service, allowPrivate bool) *AdminHTTPHandler { + handler := &AdminHTTPHandler{ + repository: repository, cipher: cipher, identity: identityService, + allowPrivate: allowPrivate, mux: http.NewServeMux(), + } + handler.mux.HandleFunc("GET /api/v1/admin/providers", handler.list) + handler.mux.HandleFunc("POST /api/v1/admin/providers", handler.create) + handler.mux.HandleFunc("PUT /api/v1/admin/providers/{provider_id}", handler.update) + handler.mux.HandleFunc("POST /api/v1/admin/providers/{provider_id}/test", handler.testConnection) + handler.mux.HandleFunc("GET /api/v1/admin/providers/{provider_id}/models", handler.listModels) + handler.mux.HandleFunc("POST /api/v1/admin/providers/{provider_id}/models/sync", handler.syncModels) + handler.mux.HandleFunc("POST /api/v1/admin/providers/credentials/rotate", handler.rotateCredentials) + handler.mux.HandleFunc("GET /api/v1/admin/model-routes", handler.listModelRoutes) + handler.mux.HandleFunc("POST /api/v1/admin/model-routes", handler.createModelRoute) + handler.mux.HandleFunc("PUT /api/v1/admin/model-routes/{model_route_id}", handler.updateModelRoute) + handler.mux.HandleFunc("DELETE /api/v1/admin/model-routes/{model_route_id}", handler.deleteModelRoute) + return handler +} + +func (h *AdminHTTPHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) { + h.mux.ServeHTTP(writer, request) +} + +func (h *AdminHTTPHandler) list(writer http.ResponseWriter, request *http.Request) { + if _, ok := h.requirePermission(writer, request, identity.PermissionProviderRead); !ok { + return + } + records, err := h.repository.List(request.Context()) + if err != nil { + h.writeError(writer, err) + return + } + items := make([]map[string]any, 0, len(records)) + for _, record := range records { + item, err := h.view(record) + if err != nil { + h.writeError(writer, err) + return + } + items = append(items, item) + } + apiresponse.OK(writer, items) +} + +func (h *AdminHTTPHandler) create(writer http.ResponseWriter, request *http.Request) { + actor, ok := h.requirePermission(writer, request, identity.PermissionProviderManage) + if !ok { + return + } + input, record, err := h.decodeRecord(writer, request) + if err != nil { + h.writeError(writer, err) + return + } + apiKey := "" + if input.APIKey != nil { + apiKey = strings.TrimSpace(*input.APIKey) + } + if err := h.setCredentials(&record, apiKey); err != nil { + h.writeError(writer, err) + return + } + created, err := h.repository.Create(request.Context(), record, actor.ID) + if err != nil { + h.writeError(writer, err) + return + } + if h.changeHook != nil { + _ = h.changeHook(request.Context()) + } + view, err := h.view(created) + if err != nil { + h.writeError(writer, err) + return + } + apiresponse.OK(writer, view) +} + +func (h *AdminHTTPHandler) update(writer http.ResponseWriter, request *http.Request) { + actor, ok := h.requirePermission(writer, request, identity.PermissionProviderManage) + if !ok { + return + } + input, record, err := h.decodeRecord(writer, request) + if err != nil { + h.writeError(writer, err) + return + } + record.ID = request.PathValue("provider_id") + if record.ID == "" { + apiresponse.Error(writer, http.StatusBadRequest, "provider_id 不能为空") + return + } + replaceCredentials := input.APIKey != nil + if replaceCredentials { + if err := h.setCredentials(&record, strings.TrimSpace(*input.APIKey)); err != nil { + h.writeError(writer, err) + return + } + } + if _, err := h.repository.Update(request.Context(), record, actor.ID, replaceCredentials); err != nil { + h.writeError(writer, err) + return + } + updated, err := h.repository.Get(request.Context(), record.ID) + if err != nil { + h.writeError(writer, err) + return + } + if h.changeHook != nil { + _ = h.changeHook(request.Context()) + } + view, err := h.view(updated) + if err != nil { + h.writeError(writer, err) + return + } + apiresponse.OK(writer, view) +} + +func (h *AdminHTTPHandler) decodeRecord(writer http.ResponseWriter, request *http.Request) (providerInput, Record, error) { + var input providerInput + decoder := json.NewDecoder(http.MaxBytesReader(writer, request.Body, 1<<20)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&input); err != nil { + return input, Record{}, errors.New("请求格式无效") + } + input.Code = strings.ToLower(strings.TrimSpace(input.Code)) + if !providerCodePattern.MatchString(input.Code) { + return input, Record{}, errors.New("code 必须以小写字母开头,且只能包含小写字母、数字、下划线和连字符") + } + input.Adapter = strings.TrimSpace(input.Adapter) + if input.Adapter == "" { + input.Adapter = "openai-compatible" + } + if input.Adapter != "openai-compatible" { + return input, Record{}, errors.New("当前只支持 openai-compatible adapter") + } + validationCtx, cancel := context.WithTimeout(request.Context(), 3*time.Second) + defer cancel() + baseURL, err := ValidateBaseURL(validationCtx, input.BaseURL, h.allowPrivate) + if err != nil { + return input, Record{}, err + } + if len(input.Capabilities) == 0 { + input.Capabilities = []string{"chat", "responses", "embeddings", "models"} + } + if len(input.Config) == 0 { + input.Config = json.RawMessage(`{}`) + } + var configObject map[string]any + if err := json.Unmarshal(input.Config, &configObject); err != nil || configObject == nil { + return input, Record{}, errors.New("config 必须是 JSON 对象") + } + if value, exists := configObject["default"]; exists { + if _, ok := value.(bool); !ok { + return input, Record{}, errors.New("config.default 必须是布尔值") + } + } + enabled := true + if input.Enabled != nil { + enabled = *input.Enabled + } + return input, Record{ + Code: input.Code, Adapter: input.Adapter, BaseURL: baseURL, + Capabilities: input.Capabilities, Config: input.Config, Enabled: enabled, + }, nil +} + +func (h *AdminHTTPHandler) setCredentials(record *Record, apiKey string) error { + payload, err := json.Marshal(Credentials{APIKey: apiKey}) + if err != nil { + return err + } + encrypted, version, err := h.cipher.Encrypt(payload) + if err != nil { + return err + } + record.EncryptedCredentials = encrypted + record.CredentialKEKVersion = version + return nil +} + +func (h *AdminHTTPHandler) view(record Record) (map[string]any, error) { + plaintext, err := h.cipher.Decrypt(record.EncryptedCredentials, record.CredentialKEKVersion) + if err != nil { + return nil, err + } + var credentials Credentials + if err := json.Unmarshal(plaintext, &credentials); err != nil { + return nil, err + } + return map[string]any{ + "id": record.ID, "code": record.Code, "adapter": record.Adapter, + "base_url": record.BaseURL, "capabilities": record.Capabilities, + "config": record.Config, "enabled": record.Enabled, "revision": record.Revision, + "credential_kek_version": record.CredentialKEKVersion, + "key_configured": credentials.APIKey != "", "api_key_masked": maskSecret(credentials.APIKey), + }, nil +} + +func (h *AdminHTTPHandler) testConnection(writer http.ResponseWriter, request *http.Request) { + if _, ok := h.requirePermission(writer, request, identity.PermissionProviderManage); !ok { + return + } + if !h.requireOperations(writer) { + return + } + result, err := h.operations.TestConnection(request.Context(), request.PathValue("provider_id")) + if err != nil { + h.writeError(writer, err) + return + } + apiresponse.OK(writer, result) +} + +func (h *AdminHTTPHandler) listModels(writer http.ResponseWriter, request *http.Request) { + if _, ok := h.requirePermission(writer, request, identity.PermissionProviderRead); !ok { + return + } + if !h.requireOperations(writer) { + return + } + models, err := h.operations.ListModels(request.Context(), request.PathValue("provider_id")) + if err != nil { + h.writeError(writer, err) + return + } + if models == nil { + models = []Model{} + } + apiresponse.OK(writer, models) +} + +func (h *AdminHTTPHandler) syncModels(writer http.ResponseWriter, request *http.Request) { + actor, ok := h.requirePermission(writer, request, identity.PermissionProviderManage) + if !ok { + return + } + if !h.requireOperations(writer) { + return + } + result, err := h.operations.SyncModels(request.Context(), request.PathValue("provider_id"), actor.ID) + if err != nil { + h.writeError(writer, err) + return + } + apiresponse.OK(writer, result) +} + +func (h *AdminHTTPHandler) rotateCredentials(writer http.ResponseWriter, request *http.Request) { + actor, ok := h.requirePermission(writer, request, identity.PermissionProviderManage) + if !ok { + return + } + if !h.requireOperations(writer) { + return + } + result, err := h.operations.RotateCredentials(request.Context(), actor.ID) + if err != nil { + h.writeError(writer, err) + return + } + if h.changeHook != nil && result.Rotated > 0 { + _ = h.changeHook(request.Context()) + } + apiresponse.OK(writer, result) +} + +func (h *AdminHTTPHandler) listModelRoutes(writer http.ResponseWriter, request *http.Request) { + if _, ok := h.requirePermission(writer, request, identity.PermissionProviderRead); !ok { + return + } + routes, err := h.repository.ListModelRoutes(request.Context()) + if err != nil { + h.writeError(writer, err) + return + } + apiresponse.OK(writer, routes) +} + +func (h *AdminHTTPHandler) createModelRoute(writer http.ResponseWriter, request *http.Request) { + actor, ok := h.requirePermission(writer, request, identity.PermissionProviderManage) + if !ok { + return + } + route, err := h.decodeModelRoute(writer, request) + if err != nil { + h.writeError(writer, err) + return + } + created, err := h.repository.CreateModelRoute(request.Context(), route, actor.ID) + if err != nil { + h.writeError(writer, err) + return + } + h.propagateChange(request.Context()) + apiresponse.OK(writer, created) +} + +func (h *AdminHTTPHandler) updateModelRoute(writer http.ResponseWriter, request *http.Request) { + actor, ok := h.requirePermission(writer, request, identity.PermissionProviderManage) + if !ok { + return + } + route, err := h.decodeModelRoute(writer, request) + if err != nil { + h.writeError(writer, err) + return + } + route.ID = request.PathValue("model_route_id") + updated, err := h.repository.UpdateModelRoute(request.Context(), route, actor.ID) + if err != nil { + h.writeError(writer, err) + return + } + h.propagateChange(request.Context()) + apiresponse.OK(writer, updated) +} + +func (h *AdminHTTPHandler) deleteModelRoute(writer http.ResponseWriter, request *http.Request) { + actor, ok := h.requirePermission(writer, request, identity.PermissionProviderManage) + if !ok { + return + } + if err := h.repository.DeleteModelRoute(request.Context(), request.PathValue("model_route_id"), actor.ID); err != nil { + h.writeError(writer, err) + return + } + h.propagateChange(request.Context()) + apiresponse.OK(writer, map[string]bool{"deleted": true}) +} + +func (h *AdminHTTPHandler) decodeModelRoute(writer http.ResponseWriter, request *http.Request) (ModelRoute, error) { + var input modelRouteInput + decoder := json.NewDecoder(http.MaxBytesReader(writer, request.Body, 1<<20)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&input); err != nil { + return ModelRoute{}, errors.New("请求格式无效") + } + input.Name = strings.TrimSpace(input.Name) + input.SourceModel = strings.TrimSpace(input.SourceModel) + input.TargetModel = strings.TrimSpace(input.TargetModel) + input.ProviderID = strings.TrimSpace(input.ProviderID) + if input.Name == "" || len(input.Name) > 128 || input.SourceModel == "" || len(input.SourceModel) > 512 || input.TargetModel == "" || len(input.TargetModel) > 512 || input.ProviderID == "" { + return ModelRoute{}, errors.New("名称、模型别名、上游模型或供应商无效") + } + if input.Weight == 0 { + input.Weight = 100 + } + if input.Weight < 1 || input.Weight > 10000 || input.Priority < -100000 || input.Priority > 100000 { + return ModelRoute{}, errors.New("权重或优先级超出允许范围") + } + if len(input.Conditions) == 0 || string(input.Conditions) == "null" { + input.Conditions = json.RawMessage(`{}`) + } + var conditions modelRouteConditions + if err := json.Unmarshal(input.Conditions, &conditions); err != nil { + return ModelRoute{}, errors.New("conditions 必须是 JSON 对象") + } + allowedEndpoints := map[string]bool{"/v1/chat/completions": true, "/v1/responses": true, "/v1/embeddings": true, "/v1/messages": true} + for _, endpoint := range conditions.Endpoints { + if !allowedEndpoints[endpoint] { + return ModelRoute{}, errors.New("conditions.endpoints 包含不支持的网关端点") + } + } + enabled := true + if input.Enabled != nil { + enabled = *input.Enabled + } + return ModelRoute{Name: input.Name, SourceModel: input.SourceModel, TargetModel: input.TargetModel, ProviderID: input.ProviderID, Weight: input.Weight, Priority: input.Priority, Conditions: input.Conditions, Enabled: enabled}, nil +} + +func (h *AdminHTTPHandler) propagateChange(ctx context.Context) { + if h.changeHook != nil { + _ = h.changeHook(ctx) + } +} + +func (h *AdminHTTPHandler) requireOperations(writer http.ResponseWriter) bool { + if h.operations == nil { + apiresponse.Error(writer, http.StatusServiceUnavailable, "供应商控制面服务暂不可用") + return false + } + return true +} + +func (h *AdminHTTPHandler) requirePermission(writer http.ResponseWriter, request *http.Request, permission string) (identity.Account, bool) { + account, err := h.identity.Authenticate(request.Context(), identity.KindAdmin, request.Header.Get("Authorization")) + if err != nil { + if errors.Is(err, identity.ErrInvalidSession) || errors.Is(err, identity.ErrNotFound) { + apiresponse.Error(writer, http.StatusUnauthorized, "登录状态无效或已过期") + } else if errors.Is(err, identity.ErrAccountDisabled) { + apiresponse.Error(writer, http.StatusForbidden, "管理员账号已被停用") + } else { + apiresponse.Error(writer, http.StatusServiceUnavailable, "身份服务暂不可用") + } + return identity.Account{}, false + } + if !identity.HasPermission(account, permission) { + apiresponse.Error(writer, http.StatusForbidden, "缺少模型供应商操作权限") + return identity.Account{}, false + } + return account, true +} + +func (h *AdminHTTPHandler) writeError(writer http.ResponseWriter, err error) { + switch { + case errors.Is(err, ErrProviderNotFound): + apiresponse.Error(writer, http.StatusNotFound, "供应商不存在") + case errors.Is(err, ErrProviderExists): + apiresponse.Error(writer, http.StatusConflict, "供应商 code 已存在") + case errors.Is(err, ErrMultipleDefaults): + apiresponse.Error(writer, http.StatusConflict, "只能启用一个默认供应商") + case errors.Is(err, ErrModelRouteNotFound): + apiresponse.Error(writer, http.StatusNotFound, "模型路由不存在") + case errors.Is(err, ErrModelRouteExists): + apiresponse.Error(writer, http.StatusConflict, "相同模型、供应商和上游模型的路由已存在") + case errors.Is(err, ErrProviderStore), errors.Is(err, ErrCredentialKeyUnavailable): + apiresponse.Error(writer, http.StatusServiceUnavailable, "供应商配置服务暂不可用") + case errors.Is(err, ErrProviderUpstream): + apiresponse.Error(writer, http.StatusBadGateway, "无法从上游供应商获取模型信息") + default: + apiresponse.Error(writer, http.StatusBadRequest, err.Error()) + } +} + +func maskSecret(secret string) string { + if secret == "" { + return "" + } + if len(secret) <= 10 { + return "••••••••" + } + return secret[:4] + "••••••" + secret[len(secret)-4:] +} diff --git a/internal/provider/controlplane/service.go b/internal/provider/controlplane/service.go new file mode 100644 index 0000000..6a5e307 --- /dev/null +++ b/internal/provider/controlplane/service.go @@ -0,0 +1,262 @@ +package controlplane + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "strconv" + "strings" + "time" + + "aigateway.local/core/internal/provider" + provideropenai "aigateway.local/core/internal/provider/openai" +) + +const maxModelsResponseBytes int64 = 4 << 20 + +type Service struct { + repository *provider.Repository + cipher *provider.CredentialCipher + allowPrivate bool + client *http.Client +} + +func NewService(repository *provider.Repository, cipher *provider.CredentialCipher, allowPrivate bool) *Service { + return &Service{ + repository: repository, + cipher: cipher, + allowPrivate: allowPrivate, + client: &http.Client{ + Timeout: 10 * time.Second, + Transport: &http.Transport{ + DialContext: safeDialContext(allowPrivate), + ForceAttemptHTTP2: true, + MaxIdleConns: 32, + MaxIdleConnsPerHost: 4, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 5 * time.Second, + ResponseHeaderTimeout: 8 * time.Second, + }, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return errors.New("upstream redirects are not allowed") + }, + }, + } +} + +func (s *Service) TestConnection(ctx context.Context, providerID string) (provider.ConnectionTestResult, error) { + record, adapter, err := s.load(ctx, providerID) + if err != nil { + return provider.ConnectionTestResult{}, err + } + started := time.Now() + response, err := s.doModelsRequest(ctx, adapter) + latency := time.Since(started).Milliseconds() + if err != nil { + return provider.ConnectionTestResult{}, fmt.Errorf("%w: request provider %s: %v", provider.ErrProviderUpstream, record.Code, err) + } + defer response.Body.Close() + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 32<<10)) + result := provider.ConnectionTestResult{ + Connected: response.StatusCode >= 200 && response.StatusCode < 300, + StatusCode: response.StatusCode, + LatencyMS: latency, + } + if result.Connected { + result.Message = "连接成功" + } else { + result.Message = "上游返回 HTTP " + strconv.Itoa(response.StatusCode) + } + return result, nil +} + +func (s *Service) SyncModels(ctx context.Context, providerID, actorID string) (provider.ModelSyncResult, error) { + record, adapter, err := s.load(ctx, providerID) + if err != nil { + return provider.ModelSyncResult{}, err + } + if !hasCapability(record.Capabilities, string(provider.CapabilityModels)) { + return provider.ModelSyncResult{}, errors.New("供应商未启用 models 能力") + } + response, err := s.doModelsRequest(ctx, adapter) + if err != nil { + return provider.ModelSyncResult{}, fmt.Errorf("%w: request provider %s: %v", provider.ErrProviderUpstream, record.Code, err) + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 32<<10)) + return provider.ModelSyncResult{}, fmt.Errorf("%w: provider %s returned HTTP %d", provider.ErrProviderUpstream, record.Code, response.StatusCode) + } + payload, err := io.ReadAll(io.LimitReader(response.Body, maxModelsResponseBytes+1)) + if err != nil { + return provider.ModelSyncResult{}, fmt.Errorf("%w: read provider %s response: %v", provider.ErrProviderUpstream, record.Code, err) + } + if int64(len(payload)) > maxModelsResponseBytes { + return provider.ModelSyncResult{}, fmt.Errorf("%w: provider %s model response exceeds 4 MiB", provider.ErrProviderUpstream, record.Code) + } + models, err := decodeModels(payload) + if err != nil { + return provider.ModelSyncResult{}, fmt.Errorf("%w: provider %s returned invalid model data: %v", provider.ErrProviderUpstream, record.Code, err) + } + return s.repository.SyncModels(ctx, providerID, actorID, models) +} + +func (s *Service) ListModels(ctx context.Context, providerID string) ([]provider.Model, error) { + if _, err := s.repository.Get(ctx, providerID); err != nil { + return nil, err + } + return s.repository.ListModels(ctx, providerID) +} + +func (s *Service) RotateCredentials(ctx context.Context, actorID string) (provider.CredentialRotationResult, error) { + result := provider.CredentialRotationResult{ + ActiveVersion: s.cipher.ActiveVersion(), LoadedVersions: s.cipher.Versions(), + } + records, err := s.repository.List(ctx) + if err != nil { + return provider.CredentialRotationResult{}, err + } + rotations := make([]provider.CredentialRotation, 0, len(records)) + for _, record := range records { + if record.CredentialKEKVersion == result.ActiveVersion { + result.Skipped++ + continue + } + plaintext, err := s.cipher.Decrypt(record.EncryptedCredentials, record.CredentialKEKVersion) + if err != nil { + return provider.CredentialRotationResult{}, fmt.Errorf("provider %s credentials cannot be decrypted: %w", record.Code, err) + } + ciphertext, version, err := s.cipher.Encrypt(plaintext) + if err != nil { + return provider.CredentialRotationResult{}, err + } + rotations = append(rotations, provider.CredentialRotation{ + ProviderID: record.ID, FromVersion: record.CredentialKEKVersion, + ToVersion: version, Ciphertext: ciphertext, + }) + } + if err := s.repository.RotateCredentials(ctx, actorID, rotations); err != nil { + return provider.CredentialRotationResult{}, err + } + result.Rotated = len(rotations) + return result, nil +} + +func (s *Service) load(ctx context.Context, providerID string) (provider.Record, provider.Adapter, error) { + record, err := s.repository.Get(ctx, providerID) + if err != nil { + return provider.Record{}, nil, err + } + validatedURL, err := provider.ValidateBaseURL(ctx, record.BaseURL, s.allowPrivate) + if err != nil { + return provider.Record{}, nil, fmt.Errorf("供应商地址校验失败: %w", err) + } + plaintext, err := s.cipher.Decrypt(record.EncryptedCredentials, record.CredentialKEKVersion) + if err != nil { + return provider.Record{}, nil, err + } + var credentials provider.Credentials + if err := json.Unmarshal(plaintext, &credentials); err != nil { + return provider.Record{}, nil, errors.New("供应商凭据格式无效") + } + switch record.Adapter { + case "openai-compatible": + adapter, err := provideropenai.New(validatedURL, credentials.APIKey) + return record, adapter, err + default: + return provider.Record{}, nil, fmt.Errorf("不支持的供应商适配器 %q", record.Adapter) + } +} + +func (s *Service) doModelsRequest(ctx context.Context, adapter provider.Adapter) (*http.Response, error) { + target := adapter.Target() + target.Path = strings.TrimRight(target.Path, "/") + "/v1/models" + target.RawPath = "" + request, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil) + if err != nil { + return nil, err + } + request.Header.Set("Accept", "application/json") + adapter.Prepare(request) + return s.client.Do(request) +} + +func decodeModels(payload []byte) ([]provider.DiscoveredModel, error) { + var response struct { + Data []json.RawMessage `json:"data"` + } + if err := json.Unmarshal(payload, &response); err != nil { + return nil, err + } + seen := make(map[string]struct{}, len(response.Data)) + models := make([]provider.DiscoveredModel, 0, len(response.Data)) + for _, raw := range response.Data { + var item struct { + ID string `json:"id"` + OwnedBy string `json:"owned_by"` + } + if err := json.Unmarshal(raw, &item); err != nil { + return nil, err + } + item.ID = strings.TrimSpace(item.ID) + if item.ID == "" || len(item.ID) > 512 { + return nil, errors.New("model id must contain 1 to 512 characters") + } + if _, exists := seen[item.ID]; exists { + continue + } + seen[item.ID] = struct{}{} + models = append(models, provider.DiscoveredModel{ + ProviderModelID: item.ID, + OwnedBy: strings.TrimSpace(item.OwnedBy), + Metadata: append(json.RawMessage(nil), raw...), + }) + } + return models, nil +} + +func hasCapability(capabilities []string, expected string) bool { + for _, capability := range capabilities { + if capability == expected { + return true + } + } + return false +} + +func safeDialContext(allowPrivate bool) func(context.Context, string, string) (net.Conn, error) { + dialer := &net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second} + if allowPrivate { + return dialer.DialContext + } + return func(ctx context.Context, network, address string) (net.Conn, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, err + } + addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil { + return nil, err + } + if len(addresses) == 0 { + return nil, errors.New("upstream host did not resolve") + } + for _, address := range addresses { + if !isPublicAddress(address.IP) { + return nil, fmt.Errorf("upstream resolved to blocked address %s", address.IP) + } + } + return dialer.DialContext(ctx, network, net.JoinHostPort(addresses[0].IP.String(), port)) + } +} + +func isPublicAddress(ip net.IP) bool { + return ip != nil && !ip.IsPrivate() && !ip.IsLoopback() && !ip.IsLinkLocalUnicast() && + !ip.IsLinkLocalMulticast() && !ip.IsMulticast() && !ip.IsUnspecified() +} + +var _ provider.AdminOperations = (*Service)(nil) diff --git a/internal/provider/controlplane/service_test.go b/internal/provider/controlplane/service_test.go new file mode 100644 index 0000000..4de4123 --- /dev/null +++ b/internal/provider/controlplane/service_test.go @@ -0,0 +1,36 @@ +package controlplane + +import ( + "encoding/json" + "testing" +) + +func TestDecodeModelsDeduplicatesAndRetainsMetadata(t *testing.T) { + models, err := decodeModels([]byte(`{ + "object":"list", + "data":[ + {"id":"gpt-5","owned_by":"openai","context_window":400000}, + {"id":"gpt-5","owned_by":"duplicate"}, + {"id":"text-embedding-3-small","owned_by":"openai"} + ] + }`)) + if err != nil { + t.Fatal(err) + } + if len(models) != 2 || models[0].ProviderModelID != "gpt-5" || models[1].ProviderModelID != "text-embedding-3-small" { + t.Fatalf("unexpected models: %#v", models) + } + var metadata map[string]any + if err := json.Unmarshal(models[0].Metadata, &metadata); err != nil { + t.Fatal(err) + } + if metadata["context_window"] != float64(400000) { + t.Fatalf("metadata was not retained: %#v", metadata) + } +} + +func TestDecodeModelsRejectsMissingID(t *testing.T) { + if _, err := decodeModels([]byte(`{"data":[{"owned_by":"openai"}]}`)); err == nil { + t.Fatal("expected missing model id to be rejected") + } +} diff --git a/internal/provider/credentials.go b/internal/provider/credentials.go new file mode 100644 index 0000000..7a8db2b --- /dev/null +++ b/internal/provider/credentials.go @@ -0,0 +1,15 @@ +package provider + +import "aigateway.local/core/internal/platform/cryptox" + +var ErrCredentialKeyUnavailable = cryptox.ErrKeyUnavailable + +type CredentialCipher = cryptox.Keyring + +func NewCredentialCipher(encodedKey string, version int, encodedKeyring ...string) (*CredentialCipher, error) { + keyring := "" + if len(encodedKeyring) > 0 { + keyring = encodedKeyring[0] + } + return cryptox.NewKeyring(encodedKey, version, keyring, "provider-credentials") +} diff --git a/internal/provider/credentials_test.go b/internal/provider/credentials_test.go new file mode 100644 index 0000000..cf38368 --- /dev/null +++ b/internal/provider/credentials_test.go @@ -0,0 +1,26 @@ +package provider + +import ( + "encoding/base64" + "testing" +) + +func TestCredentialCipherRoundTripAndAuthentication(t *testing.T) { + key := base64.StdEncoding.EncodeToString(make([]byte, 32)) + cipher, err := NewCredentialCipher(key, 7) + if err != nil { + t.Fatal(err) + } + encrypted, version, err := cipher.Encrypt([]byte(`{"api_key":"secret"}`)) + if err != nil { + t.Fatal(err) + } + plaintext, err := cipher.Decrypt(encrypted, version) + if err != nil || string(plaintext) != `{"api_key":"secret"}` { + t.Fatalf("unexpected decrypt result %q: %v", plaintext, err) + } + encrypted[len(encrypted)-1] ^= 1 + if _, err := cipher.Decrypt(encrypted, version); err == nil { + t.Fatal("tampered ciphertext must fail authentication") + } +} diff --git a/internal/provider/model_routes.go b/internal/provider/model_routes.go new file mode 100644 index 0000000..2403d4f --- /dev/null +++ b/internal/provider/model_routes.go @@ -0,0 +1,177 @@ +package provider + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + platformid "aigateway.local/core/internal/platform/id" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +var ( + ErrModelRouteNotFound = errors.New("model route not found") + ErrModelRouteExists = errors.New("model route already exists") +) + +type ModelRoute struct { + ID string `json:"id"` + Name string `json:"name"` + SourceModel string `json:"source_model"` + TargetModel string `json:"target_model"` + ProviderID string `json:"provider_id"` + ProviderCode string `json:"provider_code"` + Weight int `json:"weight"` + Priority int `json:"priority"` + Conditions json.RawMessage `json:"conditions"` + Enabled bool `json:"enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (r *Repository) ListModelRoutes(ctx context.Context) ([]ModelRoute, error) { + if r.pool == nil { + return nil, ErrProviderStore + } + rows, err := r.pool.Query(ctx, ` + SELECT mr.id::text, mr.name, mr.source_model, mr.target_model, mr.provider_id::text, + p.code, mr.weight, mr.priority, mr.conditions, mr.enabled, mr.created_at, mr.updated_at + FROM gateway.model_routes mr JOIN gateway.providers p ON p.id = mr.provider_id + ORDER BY mr.source_model, mr.priority DESC, mr.name, p.code`) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrProviderStore, err) + } + defer rows.Close() + routes := make([]ModelRoute, 0) + for rows.Next() { + var route ModelRoute + if err := rows.Scan(&route.ID, &route.Name, &route.SourceModel, &route.TargetModel, &route.ProviderID, + &route.ProviderCode, &route.Weight, &route.Priority, &route.Conditions, &route.Enabled, &route.CreatedAt, &route.UpdatedAt); err != nil { + return nil, fmt.Errorf("%w: %v", ErrProviderStore, err) + } + routes = append(routes, route) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("%w: %v", ErrProviderStore, err) + } + return routes, nil +} + +func (r *Repository) CreateModelRoute(ctx context.Context, route ModelRoute, actorID string) (ModelRoute, error) { + id, err := platformid.NewUUID() + if err != nil { + return ModelRoute{}, err + } + route.ID = id + return r.saveModelRoute(ctx, route, actorID, true) +} + +func (r *Repository) UpdateModelRoute(ctx context.Context, route ModelRoute, actorID string) (ModelRoute, error) { + return r.saveModelRoute(ctx, route, actorID, false) +} + +func (r *Repository) saveModelRoute(ctx context.Context, route ModelRoute, actorID string, create bool) (ModelRoute, error) { + if r.pool == nil { + return ModelRoute{}, ErrProviderStore + } + eventID, err := platformid.NewUUID() + if err != nil { + return ModelRoute{}, err + } + tx, err := r.pool.Begin(ctx) + if err != nil { + return ModelRoute{}, fmt.Errorf("%w: %v", ErrProviderStore, err) + } + defer func() { _ = tx.Rollback(ctx) }() + var providerExists bool + if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gateway.providers WHERE id=$1 AND tenant_id IS NULL)`, route.ProviderID).Scan(&providerExists); err != nil { + return ModelRoute{}, fmt.Errorf("%w: %v", ErrProviderStore, err) + } + if !providerExists { + return ModelRoute{}, ErrProviderNotFound + } + if create { + err = tx.QueryRow(ctx, ` + INSERT INTO gateway.model_routes + (id,name,source_model,target_model,provider_id,weight,priority,conditions,enabled,created_by) + SELECT $1,$2,$3,$4,p.id,$6,$7,$8,$9,$10 FROM gateway.providers p WHERE p.id=$5 AND p.tenant_id IS NULL + RETURNING created_at,updated_at`, route.ID, route.Name, route.SourceModel, route.TargetModel, + route.ProviderID, route.Weight, route.Priority, route.Conditions, route.Enabled, actorID, + ).Scan(&route.CreatedAt, &route.UpdatedAt) + } else { + err = tx.QueryRow(ctx, ` + UPDATE gateway.model_routes + SET name=$2,source_model=$3,target_model=$4,provider_id=$5,weight=$6,priority=$7, + conditions=$8,enabled=$9,updated_at=clock_timestamp() + WHERE id=$1 + RETURNING created_at,updated_at`, route.ID, route.Name, route.SourceModel, route.TargetModel, + route.ProviderID, route.Weight, route.Priority, route.Conditions, route.Enabled, + ).Scan(&route.CreatedAt, &route.UpdatedAt) + } + if errors.Is(err, pgx.ErrNoRows) { + if create { + return ModelRoute{}, ErrProviderNotFound + } + return ModelRoute{}, ErrModelRouteNotFound + } + if err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + if pgErr.Code == "23505" { + return ModelRoute{}, ErrModelRouteExists + } + if pgErr.Code == "23503" { + return ModelRoute{}, ErrProviderNotFound + } + } + return ModelRoute{}, fmt.Errorf("%w: %v", ErrProviderStore, err) + } + if err := tx.QueryRow(ctx, `SELECT code FROM gateway.providers WHERE id=$1`, route.ProviderID).Scan(&route.ProviderCode); err != nil { + return ModelRoute{}, ErrProviderNotFound + } + eventType := "model_route.updated" + if create { + eventType = "model_route.created" + } + payload, _ := json.Marshal(map[string]any{"model_route_id": route.ID, "source_model": route.SourceModel, "target_model": route.TargetModel, "provider_id": route.ProviderID, "actor_id": actorID}) + if _, err := tx.Exec(ctx, `INSERT INTO gateway.outbox_events(event_id,event_type,event_version,aggregate_type,aggregate_id,payload) VALUES($1,$2,1,'model_route',$3,$4)`, eventID, eventType, route.ID, payload); err != nil { + return ModelRoute{}, fmt.Errorf("%w: %v", ErrProviderStore, err) + } + if err := tx.Commit(ctx); err != nil { + return ModelRoute{}, fmt.Errorf("%w: %v", ErrProviderStore, err) + } + return route, nil +} + +func (r *Repository) DeleteModelRoute(ctx context.Context, id, actorID string) error { + if r.pool == nil { + return ErrProviderStore + } + eventID, err := platformid.NewUUID() + if err != nil { + return err + } + tx, err := r.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("%w: %v", ErrProviderStore, err) + } + defer func() { _ = tx.Rollback(ctx) }() + result, err := tx.Exec(ctx, `DELETE FROM gateway.model_routes WHERE id=$1`, id) + if err != nil { + return fmt.Errorf("%w: %v", ErrProviderStore, err) + } + if result.RowsAffected() == 0 { + return ErrModelRouteNotFound + } + payload, _ := json.Marshal(map[string]any{"model_route_id": id, "actor_id": actorID}) + if _, err := tx.Exec(ctx, `INSERT INTO gateway.outbox_events(event_id,event_type,event_version,aggregate_type,aggregate_id,payload) VALUES($1,'model_route.deleted',1,'model_route',$2,$3)`, eventID, id, payload); err != nil { + return fmt.Errorf("%w: %v", ErrProviderStore, err) + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("%w: %v", ErrProviderStore, err) + } + return nil +} diff --git a/internal/provider/openai/adapter.go b/internal/provider/openai/adapter.go new file mode 100644 index 0000000..798243d --- /dev/null +++ b/internal/provider/openai/adapter.go @@ -0,0 +1,55 @@ +package openai + +import ( + "net/http" + "net/url" + "strings" + + "aigateway.local/core/internal/provider" +) + +type Adapter struct { + target *url.URL + apiKey string +} + +func New(baseURL, apiKey string) (*Adapter, error) { + target, err := url.Parse(baseURL) + if err != nil { + return nil, err + } + return &Adapter{target: target, apiKey: apiKey}, nil +} + +func (a *Adapter) Name() string { return "openai-compatible" } + +func (a *Adapter) Target() *url.URL { + copy := *a.target + return © +} + +func (a *Adapter) Capabilities() []provider.Capability { + return []provider.Capability{ + provider.CapabilityChat, + provider.CapabilityResponses, + provider.CapabilityEmbeddings, + provider.CapabilityMessages, + provider.CapabilityModels, + } +} + +func (a *Adapter) Prepare(request *http.Request) { + request.Header.Del("X-Gateway-API-Key") + request.Header.Del("X-Gateway-Provider") + basePath := strings.TrimRight(a.target.Path, "/") + duplicatedVersionPrefix := basePath + "/v1/" + if strings.HasSuffix(basePath, "/v1") && strings.HasPrefix(request.URL.Path, duplicatedVersionPrefix) { + request.URL.Path = basePath + "/" + strings.TrimPrefix(request.URL.Path, duplicatedVersionPrefix) + request.URL.RawPath = "" + } + if a.apiKey != "" { + request.Header.Set("Authorization", "Bearer "+a.apiKey) + } +} + +var _ provider.Adapter = (*Adapter)(nil) diff --git a/internal/provider/openai/adapter_test.go b/internal/provider/openai/adapter_test.go new file mode 100644 index 0000000..c7380cf --- /dev/null +++ b/internal/provider/openai/adapter_test.go @@ -0,0 +1,21 @@ +package openai + +import ( + "net/http/httptest" + "testing" +) + +func TestPrepareAvoidsDuplicatedV1BasePath(t *testing.T) { + adapter, err := New("https://example.com/proxy/v1", "secret") + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest("POST", "https://example.com/proxy/v1/v1/chat/completions", nil) + adapter.Prepare(request) + if request.URL.Path != "/proxy/v1/chat/completions" { + t.Fatalf("unexpected path %q", request.URL.Path) + } + if request.Header.Get("Authorization") != "Bearer secret" { + t.Fatal("upstream credential was not applied") + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go new file mode 100644 index 0000000..fdb73c7 --- /dev/null +++ b/internal/provider/provider.go @@ -0,0 +1,82 @@ +package provider + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + "time" +) + +type Capability string + +const ( + CapabilityChat Capability = "chat" + CapabilityResponses Capability = "responses" + CapabilityEmbeddings Capability = "embeddings" + CapabilityMessages Capability = "messages" + CapabilityModels Capability = "models" +) + +type Adapter interface { + Name() string + Target() *url.URL + Capabilities() []Capability + Prepare(*http.Request) +} + +type Credentials struct { + APIKey string `json:"api_key"` +} + +type ConnectionTestResult struct { + Connected bool `json:"connected"` + StatusCode int `json:"status_code,omitempty"` + LatencyMS int64 `json:"latency_ms"` + Message string `json:"message"` +} + +type Model struct { + ID string `json:"id"` + ProviderID string `json:"provider_id"` + ProviderModelID string `json:"provider_model_id"` + OwnedBy string `json:"owned_by,omitempty"` + Metadata json.RawMessage `json:"metadata"` + Enabled bool `json:"enabled"` + DiscoveredAt time.Time `json:"discovered_at"` + LastSeenAt time.Time `json:"last_seen_at"` +} + +type DiscoveredModel struct { + ProviderModelID string + OwnedBy string + Metadata json.RawMessage +} + +type ModelSyncResult struct { + Discovered int `json:"discovered"` + Active int `json:"active"` + Disabled int `json:"disabled"` + SyncedAt time.Time `json:"synced_at"` +} + +type CredentialRotation struct { + ProviderID string + FromVersion int + ToVersion int + Ciphertext []byte +} + +type CredentialRotationResult struct { + ActiveVersion int `json:"active_version"` + LoadedVersions []int `json:"loaded_versions"` + Rotated int `json:"rotated"` + Skipped int `json:"skipped"` +} + +type AdminOperations interface { + TestConnection(context.Context, string) (ConnectionTestResult, error) + SyncModels(context.Context, string, string) (ModelSyncResult, error) + ListModels(context.Context, string) ([]Model, error) + RotateCredentials(context.Context, string) (CredentialRotationResult, error) +} diff --git a/internal/provider/repository.go b/internal/provider/repository.go new file mode 100644 index 0000000..f4c5bc6 --- /dev/null +++ b/internal/provider/repository.go @@ -0,0 +1,357 @@ +package provider + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + platformid "aigateway.local/core/internal/platform/id" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" +) + +var ( + ErrProviderNotFound = errors.New("provider not found") + ErrProviderExists = errors.New("provider already exists") + ErrMultipleDefaults = errors.New("multiple default providers") + ErrProviderStore = errors.New("provider store unavailable") + ErrProviderUpstream = errors.New("provider upstream unavailable") +) + +type Record struct { + ID string + TenantID *string + Code string + Adapter string + BaseURL string + EncryptedCredentials []byte + CredentialKEKVersion int + Capabilities []string + Config json.RawMessage + Enabled bool + Revision int64 +} + +func (r *Repository) ListModels(ctx context.Context, providerID string) ([]Model, error) { + if r.pool == nil { + return nil, ErrProviderStore + } + rows, err := r.pool.Query(ctx, ` + SELECT id::text, provider_id::text, provider_model_id, owned_by, metadata, + enabled, discovered_at, last_seen_at + FROM gateway.provider_models + WHERE provider_id = $1 + ORDER BY enabled DESC, provider_model_id`, providerID) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrProviderStore, err) + } + defer rows.Close() + models := make([]Model, 0) + for rows.Next() { + var model Model + if err := rows.Scan( + &model.ID, &model.ProviderID, &model.ProviderModelID, &model.OwnedBy, + &model.Metadata, &model.Enabled, &model.DiscoveredAt, &model.LastSeenAt, + ); err != nil { + return nil, fmt.Errorf("%w: %v", ErrProviderStore, err) + } + models = append(models, model) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("%w: %v", ErrProviderStore, err) + } + return models, nil +} + +func (r *Repository) SyncModels(ctx context.Context, providerID, actorID string, models []DiscoveredModel) (ModelSyncResult, error) { + if r.pool == nil { + return ModelSyncResult{}, ErrProviderStore + } + eventID, err := platformid.NewUUID() + if err != nil { + return ModelSyncResult{}, err + } + transaction, err := r.pool.Begin(ctx) + if err != nil { + return ModelSyncResult{}, fmt.Errorf("%w: %v", ErrProviderStore, err) + } + defer func() { _ = transaction.Rollback(ctx) }() + var exists bool + if err := transaction.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM gateway.providers WHERE id = $1 AND tenant_id IS NULL + )`, providerID).Scan(&exists); err != nil { + return ModelSyncResult{}, fmt.Errorf("%w: %v", ErrProviderStore, err) + } + if !exists { + return ModelSyncResult{}, ErrProviderNotFound + } + if _, err := transaction.Exec(ctx, ` + UPDATE gateway.provider_models + SET enabled = false, updated_at = clock_timestamp() + WHERE provider_id = $1 AND enabled = true`, providerID); err != nil { + return ModelSyncResult{}, fmt.Errorf("%w: %v", ErrProviderStore, err) + } + for _, model := range models { + id, err := platformid.NewUUID() + if err != nil { + return ModelSyncResult{}, err + } + if _, err := transaction.Exec(ctx, ` + INSERT INTO gateway.provider_models + (id, provider_id, provider_model_id, owned_by, metadata, enabled) + VALUES ($1, $2, $3, $4, $5, true) + ON CONFLICT (provider_id, provider_model_id) DO UPDATE + SET owned_by = EXCLUDED.owned_by, + metadata = EXCLUDED.metadata, + enabled = true, + last_seen_at = clock_timestamp(), + updated_at = clock_timestamp()`, + id, providerID, model.ProviderModelID, model.OwnedBy, model.Metadata, + ); err != nil { + return ModelSyncResult{}, fmt.Errorf("%w: %v", ErrProviderStore, err) + } + } + result := ModelSyncResult{Discovered: len(models), SyncedAt: time.Now().UTC()} + if err := transaction.QueryRow(ctx, ` + SELECT count(*) FILTER (WHERE enabled), count(*) FILTER (WHERE NOT enabled) + FROM gateway.provider_models + WHERE provider_id = $1`, providerID).Scan(&result.Active, &result.Disabled); err != nil { + return ModelSyncResult{}, fmt.Errorf("%w: %v", ErrProviderStore, err) + } + payload, _ := json.Marshal(map[string]any{ + "provider_id": providerID, "discovered": result.Discovered, + "active": result.Active, "disabled": result.Disabled, "actor_id": actorID, + }) + if _, err := transaction.Exec(ctx, ` + INSERT INTO gateway.outbox_events + (event_id, event_type, event_version, aggregate_type, aggregate_id, payload) + VALUES ($1, 'provider.models_synced', 1, 'provider', $2, $3)`, eventID, providerID, payload); err != nil { + return ModelSyncResult{}, fmt.Errorf("%w: %v", ErrProviderStore, err) + } + if err := transaction.Commit(ctx); err != nil { + return ModelSyncResult{}, fmt.Errorf("%w: %v", ErrProviderStore, err) + } + return result, nil +} + +func (r *Repository) RotateCredentials(ctx context.Context, actorID string, rotations []CredentialRotation) error { + if r.pool == nil { + return ErrProviderStore + } + if len(rotations) == 0 { + return nil + } + transaction, err := r.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("%w: %v", ErrProviderStore, err) + } + defer func() { _ = transaction.Rollback(ctx) }() + for _, rotation := range rotations { + result, err := transaction.Exec(ctx, ` + UPDATE gateway.providers + SET encrypted_credentials = $2, + credential_kek_version = $3, + revision = revision + 1, + updated_at = clock_timestamp() + WHERE id = $1 + AND tenant_id IS NULL + AND credential_kek_version = $4`, + rotation.ProviderID, rotation.Ciphertext, rotation.ToVersion, rotation.FromVersion, + ) + if err != nil { + return fmt.Errorf("%w: %v", ErrProviderStore, err) + } + if result.RowsAffected() != 1 { + return fmt.Errorf("%w: provider %s changed during credential rotation", ErrProviderStore, rotation.ProviderID) + } + eventID, err := platformid.NewUUID() + if err != nil { + return err + } + payload, _ := json.Marshal(map[string]any{ + "provider_id": rotation.ProviderID, "from_version": rotation.FromVersion, + "to_version": rotation.ToVersion, "actor_id": actorID, + }) + if _, err := transaction.Exec(ctx, ` + INSERT INTO gateway.outbox_events + (event_id, event_type, event_version, aggregate_type, aggregate_id, payload) + VALUES ($1, 'provider.credentials_rotated', 1, 'provider', $2, $3)`, + eventID, rotation.ProviderID, payload, + ); err != nil { + return fmt.Errorf("%w: %v", ErrProviderStore, err) + } + } + if err := transaction.Commit(ctx); err != nil { + return fmt.Errorf("%w: %v", ErrProviderStore, err) + } + return nil +} + +type Repository struct { + pool *pgxpool.Pool +} + +func NewRepository(pool *pgxpool.Pool) *Repository { + return &Repository{pool: pool} +} + +func (r *Repository) List(ctx context.Context) ([]Record, error) { + if r.pool == nil { + return nil, ErrProviderStore + } + rows, err := r.pool.Query(ctx, ` + SELECT id::text, tenant_id::text, code, adapter, base_url, + encrypted_credentials, credential_kek_version, capabilities, + config, enabled, revision + FROM gateway.providers + WHERE tenant_id IS NULL + ORDER BY code`) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrProviderStore, err) + } + defer rows.Close() + var records []Record + for rows.Next() { + var record Record + if err := rows.Scan( + &record.ID, &record.TenantID, &record.Code, &record.Adapter, &record.BaseURL, + &record.EncryptedCredentials, &record.CredentialKEKVersion, &record.Capabilities, + &record.Config, &record.Enabled, &record.Revision, + ); err != nil { + return nil, fmt.Errorf("%w: %v", ErrProviderStore, err) + } + records = append(records, record) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("%w: %v", ErrProviderStore, err) + } + return records, nil +} + +func (r *Repository) Get(ctx context.Context, id string) (Record, error) { + if r.pool == nil { + return Record{}, ErrProviderStore + } + var record Record + err := r.pool.QueryRow(ctx, ` + SELECT id::text, tenant_id::text, code, adapter, base_url, + encrypted_credentials, credential_kek_version, capabilities, + config, enabled, revision + FROM gateway.providers + WHERE id = $1 AND tenant_id IS NULL`, id).Scan( + &record.ID, &record.TenantID, &record.Code, &record.Adapter, &record.BaseURL, + &record.EncryptedCredentials, &record.CredentialKEKVersion, &record.Capabilities, + &record.Config, &record.Enabled, &record.Revision, + ) + return record, mapProviderError(err) +} + +func (r *Repository) Create(ctx context.Context, record Record, actorID string) (Record, error) { + if r.pool == nil { + return Record{}, ErrProviderStore + } + id, err := platformid.NewUUID() + if err != nil { + return Record{}, err + } + eventID, err := platformid.NewUUID() + if err != nil { + return Record{}, err + } + record.ID = id + transaction, err := r.pool.Begin(ctx) + if err != nil { + return Record{}, fmt.Errorf("%w: %v", ErrProviderStore, err) + } + defer func() { _ = transaction.Rollback(ctx) }() + err = transaction.QueryRow(ctx, ` + INSERT INTO gateway.providers + (id, code, adapter, base_url, encrypted_credentials, + credential_kek_version, capabilities, config, enabled, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING revision`, record.ID, record.Code, record.Adapter, record.BaseURL, + record.EncryptedCredentials, record.CredentialKEKVersion, record.Capabilities, + record.Config, record.Enabled, actorID).Scan(&record.Revision) + if err != nil { + return Record{}, mapProviderError(err) + } + payload, _ := json.Marshal(map[string]any{"provider_id": record.ID, "code": record.Code, "revision": record.Revision}) + if _, err := transaction.Exec(ctx, ` + INSERT INTO gateway.outbox_events + (event_id, event_type, event_version, aggregate_type, aggregate_id, payload) + VALUES ($1, 'provider.created', 1, 'provider', $2, $3)`, eventID, record.ID, payload); err != nil { + return Record{}, fmt.Errorf("%w: %v", ErrProviderStore, err) + } + if err := transaction.Commit(ctx); err != nil { + return Record{}, fmt.Errorf("%w: %v", ErrProviderStore, err) + } + return record, nil +} + +func (r *Repository) Update(ctx context.Context, record Record, actorID string, replaceCredentials bool) (Record, error) { + if r.pool == nil { + return Record{}, ErrProviderStore + } + eventID, err := platformid.NewUUID() + if err != nil { + return Record{}, err + } + transaction, err := r.pool.Begin(ctx) + if err != nil { + return Record{}, fmt.Errorf("%w: %v", ErrProviderStore, err) + } + defer func() { _ = transaction.Rollback(ctx) }() + err = transaction.QueryRow(ctx, ` + UPDATE gateway.providers + SET code = $2, + adapter = $3, + base_url = $4, + encrypted_credentials = CASE WHEN $10 THEN $5 ELSE encrypted_credentials END, + credential_kek_version = CASE WHEN $10 THEN $6 ELSE credential_kek_version END, + capabilities = $7, + config = $8, + enabled = $9, + revision = revision + 1, + updated_at = clock_timestamp() + WHERE id = $1 AND tenant_id IS NULL + RETURNING revision`, record.ID, record.Code, record.Adapter, record.BaseURL, + record.EncryptedCredentials, record.CredentialKEKVersion, record.Capabilities, + record.Config, record.Enabled, replaceCredentials).Scan(&record.Revision) + if err != nil { + return Record{}, mapProviderError(err) + } + payload, _ := json.Marshal(map[string]any{ + "provider_id": record.ID, "code": record.Code, "revision": record.Revision, "actor_id": actorID, + }) + if _, err := transaction.Exec(ctx, ` + INSERT INTO gateway.outbox_events + (event_id, event_type, event_version, aggregate_type, aggregate_id, payload) + VALUES ($1, 'provider.updated', 1, 'provider', $2, $3)`, eventID, record.ID, payload); err != nil { + return Record{}, fmt.Errorf("%w: %v", ErrProviderStore, err) + } + if err := transaction.Commit(ctx); err != nil { + return Record{}, fmt.Errorf("%w: %v", ErrProviderStore, err) + } + return record, nil +} + +func mapProviderError(err error) error { + if errors.Is(err, pgx.ErrNoRows) { + return ErrProviderNotFound + } + var pgError *pgconn.PgError + if errors.As(err, &pgError) && pgError.Code == "23505" { + if pgError.ConstraintName == "providers_single_global_default_idx" { + return ErrMultipleDefaults + } + return ErrProviderExists + } + if err != nil { + return fmt.Errorf("%w: %v", ErrProviderStore, err) + } + return nil +} diff --git a/internal/provider/runtime/resolver.go b/internal/provider/runtime/resolver.go new file mode 100644 index 0000000..7fd40d9 --- /dev/null +++ b/internal/provider/runtime/resolver.go @@ -0,0 +1,330 @@ +package runtime + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "hash/fnv" + "log/slog" + "strings" + "sync/atomic" + "time" + + "aigateway.local/core/internal/gateway" + "aigateway.local/core/internal/provider" + provideropenai "aigateway.local/core/internal/provider/openai" + "github.com/redis/go-redis/v9" +) + +const changeChannel = "gateway:providers:changed" + +type snapshot struct { + adapters map[string]gateway.ResolvedAdapter + defaultCode string + routes map[string][]compiledRoute + knownModels map[string]bool +} + +type routeConditions struct { + Endpoints []string `json:"endpoints"` + APIKeyIDs []string `json:"api_key_ids"` + TenantIDs []string `json:"tenant_ids"` +} + +type compiledRoute struct { + providerCode string + targetModel string + weight int + priority int + conditions routeConditions +} + +type Resolver struct { + repository *provider.Repository + cipher *provider.CredentialCipher + fallback gateway.ResolvedAdapter + interval time.Duration + logger *slog.Logger + redis *redis.Client + snapshot atomic.Pointer[snapshot] +} + +func (r *Resolver) SetNotificationClient(client *redis.Client) { + r.redis = client +} + +func (r *Resolver) Notify(ctx context.Context) error { + if r.redis == nil { + return nil + } + return r.redis.Publish(ctx, changeChannel, time.Now().UTC().Format(time.RFC3339Nano)).Err() +} + +func NewResolver(repository *provider.Repository, cipher *provider.CredentialCipher, fallback provider.Adapter, interval time.Duration, logger *slog.Logger) *Resolver { + capabilities := make(map[provider.Capability]bool) + if fallback != nil { + for _, capability := range fallback.Capabilities() { + capabilities[capability] = true + } + } + resolver := &Resolver{ + repository: repository, cipher: cipher, interval: interval, logger: logger, + fallback: gateway.ResolvedAdapter{Code: "environment", Adapter: fallback, Capabilities: capabilities}, + } + initial := &snapshot{adapters: make(map[string]gateway.ResolvedAdapter), routes: make(map[string][]compiledRoute), knownModels: make(map[string]bool)} + if fallback != nil { + initial.adapters["environment"] = resolver.fallback + initial.defaultCode = "environment" + } + resolver.snapshot.Store(initial) + return resolver +} + +func (r *Resolver) ResolveModelRoute(query gateway.ModelRouteQuery) (gateway.ModelRouteResult, error) { + current := r.snapshot.Load() + if current == nil { + return gateway.ModelRouteResult{}, gateway.ErrProviderUnavailable + } + candidates := current.routes[query.Model] + known := current.knownModels[query.Model] + matched := make([]compiledRoute, 0, len(candidates)) + highestPriority := -100001 + for _, route := range candidates { + if query.ProviderCode != "" && route.providerCode != query.ProviderCode || !route.conditions.matches(query) { + continue + } + if route.priority > highestPriority { + highestPriority = route.priority + matched = matched[:0] + } + if route.priority == highestPriority { + matched = append(matched, route) + } + } + if len(matched) == 0 { + return gateway.ModelRouteResult{Known: known}, nil + } + total := 0 + for _, route := range matched { + total += route.weight + } + hasher := fnv.New64a() + _, _ = hasher.Write([]byte(query.Seed + "\x00" + query.Model)) + selected := int(hasher.Sum64() % uint64(total)) + chosen := matched[len(matched)-1] + for _, route := range matched { + if selected < route.weight { + chosen = route + break + } + selected -= route.weight + } + resolved, ok := current.adapters[chosen.providerCode] + if !ok { + return gateway.ModelRouteResult{}, gateway.ErrProviderUnavailable + } + return gateway.ModelRouteResult{ResolvedAdapter: resolved, TargetModel: chosen.targetModel, Matched: true, Known: true}, nil +} + +func (r *Resolver) ModelRoutingEnabled() bool { + current := r.snapshot.Load() + return current != nil && len(current.knownModels) > 0 +} + +func (c routeConditions) matches(query gateway.ModelRouteQuery) bool { + return containsOrEmpty(c.Endpoints, query.Endpoint) && containsOrEmpty(c.APIKeyIDs, query.APIKeyID) && containsOrEmpty(c.TenantIDs, query.TenantID) +} + +func containsOrEmpty(values []string, target string) bool { + if len(values) == 0 { + return true + } + for _, value := range values { + if value == target { + return true + } + } + return false +} + +func (r *Resolver) Resolve(code string) (gateway.ResolvedAdapter, error) { + current := r.snapshot.Load() + if current == nil { + return gateway.ResolvedAdapter{}, gateway.ErrProviderUnavailable + } + code = strings.ToLower(strings.TrimSpace(code)) + if code == "" { + if current.defaultCode == "" { + return gateway.ResolvedAdapter{}, gateway.ErrProviderUnavailable + } + code = current.defaultCode + } + resolved, ok := current.adapters[code] + if !ok { + return gateway.ResolvedAdapter{}, gateway.ErrProviderNotFound + } + return resolved, nil +} + +func (r *Resolver) Reload(ctx context.Context) error { + records, err := r.repository.List(ctx) + if err != nil { + return err + } + routeRecords, err := r.repository.ListModelRoutes(ctx) + if err != nil { + return err + } + next := &snapshot{adapters: make(map[string]gateway.ResolvedAdapter), routes: make(map[string][]compiledRoute), knownModels: make(map[string]bool)} + previous := r.snapshot.Load() + enabledCount := 0 + explicitDefault := false + for _, record := range records { + if !record.Enabled { + continue + } + enabledCount++ + resolved, defaultProvider, err := r.build(record) + if err != nil { + r.logger.Error("provider snapshot entry rejected", "provider", record.Code, "revision", record.Revision, "error", err) + if previous != nil { + if lastValid, ok := previous.adapters[record.Code]; ok { + next.adapters[record.Code] = lastValid + if next.defaultCode == "" || previous.defaultCode == record.Code { + next.defaultCode = record.Code + } + } + } + continue + } + next.adapters[record.Code] = resolved + if defaultProvider { + if explicitDefault { + return errors.New("multiple enabled providers declare config.default=true; retaining previous snapshot") + } + explicitDefault = true + next.defaultCode = record.Code + } else if next.defaultCode == "" { + next.defaultCode = record.Code + } + } + if enabledCount > 0 && len(next.adapters) == 0 { + return errors.New("all enabled provider records are invalid; retaining previous snapshot") + } + if len(next.adapters) == 0 { + if r.fallback.Adapter != nil { + next.adapters["environment"] = r.fallback + next.defaultCode = "environment" + } + } + for _, route := range routeRecords { + if !route.Enabled { + continue + } + if _, ok := next.adapters[route.ProviderCode]; !ok { + continue + } + // Only routes that can actually be served make the model "known". + // Marking it before the Enabled check above meant a disabled route + // still advertised the model as known, and the proxy hard-blocks any + // known-but-unmatched model with a 400 — so disabling the only route + // for a model made that model unusable on every other provider. + next.knownModels[route.SourceModel] = true + var conditions routeConditions + if err := json.Unmarshal(route.Conditions, &conditions); err != nil { + r.logger.Error("model route snapshot entry rejected", "route", route.ID, "error", err) + continue + } + next.routes[route.SourceModel] = append(next.routes[route.SourceModel], compiledRoute{ + providerCode: route.ProviderCode, targetModel: route.TargetModel, weight: route.Weight, + priority: route.Priority, conditions: conditions, + }) + } + r.snapshot.Store(next) + r.logger.Info("provider snapshot refreshed", "providers", len(next.adapters), "model_routes", len(routeRecords), "default", next.defaultCode) + return nil +} + +func (r *Resolver) Run(ctx context.Context) { + if err := r.Reload(ctx); err != nil { + r.logger.Warn("initial provider snapshot refresh failed; using last valid snapshot", "error", err) + } + interval := r.interval + if interval <= 0 { + interval = 5 * time.Second + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + var changes <-chan *redis.Message + var subscription *redis.PubSub + if r.redis != nil { + subscription = r.redis.Subscribe(ctx, changeChannel) + defer subscription.Close() + changes = subscription.Channel(redis.WithChannelSize(32)) + } + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + refreshCtx, cancel := context.WithTimeout(ctx, interval) + err := r.Reload(refreshCtx) + cancel() + if err != nil && ctx.Err() == nil { + r.logger.Warn("provider snapshot refresh failed; retaining last valid snapshot", "error", err) + } + case _, ok := <-changes: + if !ok { + changes = nil + continue + } + refreshCtx, cancel := context.WithTimeout(ctx, interval) + err := r.Reload(refreshCtx) + cancel() + if err != nil && ctx.Err() == nil { + r.logger.Warn("provider notification refresh failed; retaining last valid snapshot", "error", err) + } + } + } +} + +func (r *Resolver) build(record provider.Record) (gateway.ResolvedAdapter, bool, error) { + plaintext, err := r.cipher.Decrypt(record.EncryptedCredentials, record.CredentialKEKVersion) + if err != nil { + return gateway.ResolvedAdapter{}, false, fmt.Errorf("decrypt credentials: %w", err) + } + var credentials provider.Credentials + if err := json.Unmarshal(plaintext, &credentials); err != nil { + return gateway.ResolvedAdapter{}, false, fmt.Errorf("decode credentials: %w", err) + } + var adapter provider.Adapter + switch record.Adapter { + case "openai-compatible": + adapter, err = provideropenai.New(record.BaseURL, credentials.APIKey) + default: + err = fmt.Errorf("unsupported adapter %q", record.Adapter) + } + if err != nil { + return gateway.ResolvedAdapter{}, false, err + } + capabilities := make(map[provider.Capability]bool, len(record.Capabilities)) + for _, value := range record.Capabilities { + capability := provider.Capability(value) + switch capability { + case provider.CapabilityChat, provider.CapabilityResponses, provider.CapabilityEmbeddings, provider.CapabilityMessages, provider.CapabilityModels: + capabilities[capability] = true + default: + return gateway.ResolvedAdapter{}, false, fmt.Errorf("unsupported capability %q", value) + } + } + var configuration struct { + Default bool `json:"default"` + } + if len(record.Config) > 0 { + if err := json.Unmarshal(record.Config, &configuration); err != nil { + return gateway.ResolvedAdapter{}, false, fmt.Errorf("decode config: %w", err) + } + } + return gateway.ResolvedAdapter{Code: record.Code, Revision: record.Revision, Adapter: adapter, Capabilities: capabilities}, configuration.Default, nil +} diff --git a/internal/provider/runtime/resolver_test.go b/internal/provider/runtime/resolver_test.go new file mode 100644 index 0000000..a801dd9 --- /dev/null +++ b/internal/provider/runtime/resolver_test.go @@ -0,0 +1,79 @@ +package runtime + +import ( + "encoding/base64" + "encoding/json" + "io" + "log/slog" + "testing" + "time" + + "aigateway.local/core/internal/gateway" + "aigateway.local/core/internal/provider" + provideropenai "aigateway.local/core/internal/provider/openai" +) + +func TestBuildRuntimeAdapter(t *testing.T) { + key := base64.StdEncoding.EncodeToString(make([]byte, 32)) + cipher, err := provider.NewCredentialCipher(key, 1) + if err != nil { + t.Fatal(err) + } + encrypted, version, err := cipher.Encrypt([]byte(`{"api_key":"upstream-secret"}`)) + if err != nil { + t.Fatal(err) + } + fallback, _ := provideropenai.New("https://fallback.example", "") + resolver := NewResolver(nil, cipher, fallback, time.Second, slog.New(slog.NewTextHandler(io.Discard, nil))) + resolved, defaultProvider, err := resolver.build(provider.Record{ + Code: "primary", Adapter: "openai-compatible", BaseURL: "https://upstream.example/v1", + EncryptedCredentials: encrypted, CredentialKEKVersion: version, + Capabilities: []string{"chat", "models"}, Config: json.RawMessage(`{"default":true}`), Revision: 3, + }) + if err != nil { + t.Fatal(err) + } + if resolved.Code != "primary" || resolved.Revision != 3 || !resolved.Capabilities[provider.CapabilityChat] || !defaultProvider { + t.Fatalf("unexpected resolved adapter: %#v, default=%v", resolved, defaultProvider) + } +} + +func TestModelRouteUsesConditionsPriorityAndExplicitProvider(t *testing.T) { + first, _ := provideropenai.New("https://first.example", "") + second, _ := provideropenai.New("https://second.example", "") + resolver := &Resolver{} + resolver.snapshot.Store(&snapshot{ + adapters: map[string]gateway.ResolvedAdapter{ + "first": {Code: "first", Adapter: first}, "second": {Code: "second", Adapter: second}, + }, + routes: map[string][]compiledRoute{"public-chat": { + {providerCode: "first", targetModel: "model-a", weight: 100, priority: 10}, + {providerCode: "second", targetModel: "model-b", weight: 100, priority: 20, conditions: routeConditions{Endpoints: []string{"/v1/chat/completions"}}}, + }}, + knownModels: map[string]bool{"public-chat": true}, + }) + result, err := resolver.ResolveModelRoute(gateway.ModelRouteQuery{Model: "public-chat", Endpoint: "/v1/chat/completions", Seed: "request-1"}) + if err != nil || !result.Matched || result.Code != "second" || result.TargetModel != "model-b" { + t.Fatalf("unexpected priority route: %#v %v", result, err) + } + result, err = resolver.ResolveModelRoute(gateway.ModelRouteQuery{ProviderCode: "first", Model: "public-chat", Endpoint: "/v1/chat/completions", Seed: "request-1"}) + if err != nil || !result.Matched || result.Code != "first" { + t.Fatalf("explicit provider was not respected: %#v %v", result, err) + } + result, err = resolver.ResolveModelRoute(gateway.ModelRouteQuery{ProviderCode: "second", Model: "public-chat", Endpoint: "/v1/embeddings", Seed: "request-1"}) + if err != nil || result.Matched || !result.Known { + t.Fatalf("known alias with unmatched conditions must fail closed: %#v %v", result, err) + } +} + +func TestResolverStartsWithEnvironmentFallback(t *testing.T) { + fallback, _ := provideropenai.New("https://fallback.example", "") + resolver := NewResolver(nil, nil, fallback, time.Second, slog.New(slog.NewTextHandler(io.Discard, nil))) + resolved, err := resolver.Resolve("") + if err != nil || resolved.Code != "environment" { + t.Fatalf("fallback resolve: %#v %v", resolved, err) + } + if _, err := resolver.Resolve("missing"); err == nil { + t.Fatal("unknown provider must be rejected") + } +} diff --git a/internal/provider/url_validator.go b/internal/provider/url_validator.go new file mode 100644 index 0000000..26f29e5 --- /dev/null +++ b/internal/provider/url_validator.go @@ -0,0 +1,44 @@ +package provider + +import ( + "context" + "errors" + "fmt" + "net" + "net/url" + "strings" +) + +func ValidateBaseURL(ctx context.Context, raw string, allowPrivate bool) (string, error) { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil { + return "", err + } + if (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" { + return "", errors.New("base_url must be an absolute http(s) URL") + } + if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return "", errors.New("base_url must not contain user info, query, or fragment") + } + if !allowPrivate { + addresses, err := net.DefaultResolver.LookupIPAddr(ctx, parsed.Hostname()) + if err != nil { + return "", fmt.Errorf("resolve base_url host: %w", err) + } + if len(addresses) == 0 { + return "", errors.New("base_url host did not resolve") + } + for _, address := range addresses { + if !isPublicAddress(address.IP) { + return "", fmt.Errorf("base_url resolves to blocked address %s", address.IP) + } + } + } + parsed.Path = strings.TrimRight(parsed.Path, "/") + return parsed.String(), nil +} + +func isPublicAddress(ip net.IP) bool { + return ip != nil && !ip.IsPrivate() && !ip.IsLoopback() && !ip.IsLinkLocalUnicast() && + !ip.IsLinkLocalMulticast() && !ip.IsMulticast() && !ip.IsUnspecified() +} diff --git a/internal/provider/url_validator_test.go b/internal/provider/url_validator_test.go new file mode 100644 index 0000000..677490a --- /dev/null +++ b/internal/provider/url_validator_test.go @@ -0,0 +1,16 @@ +package provider + +import ( + "context" + "testing" +) + +func TestValidateBaseURLBlocksPrivateLiteral(t *testing.T) { + if _, err := ValidateBaseURL(context.Background(), "http://127.0.0.1:8080/v1", false); err == nil { + t.Fatal("expected loopback URL to be blocked") + } + got, err := ValidateBaseURL(context.Background(), "http://127.0.0.1:8080/v1/", true) + if err != nil || got != "http://127.0.0.1:8080/v1" { + t.Fatalf("unexpected private URL result %q: %v", got, err) + } +} diff --git a/internal/shadow/middleware.go b/internal/shadow/middleware.go new file mode 100644 index 0000000..60bfe82 --- /dev/null +++ b/internal/shadow/middleware.go @@ -0,0 +1,235 @@ +package shadow + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "hash/fnv" + "io" + "log/slog" + "net/http" + "net/url" + "sync/atomic" + + "aigateway.local/core/internal/gateway" + "aigateway.local/core/internal/platform/config" +) + +type Metrics struct{ eligible, sent, matched, mismatched, errors, skipped atomic.Uint64 } + +type Middleware struct { + config config.Shadow + logger *slog.Logger + client *http.Client + limit chan struct{} + metrics Metrics +} + +func New(cfg config.Shadow, logger *slog.Logger) *Middleware { + return &Middleware{config: cfg, logger: logger, client: &http.Client{Timeout: cfg.Timeout, CheckRedirect: func(*http.Request, []*http.Request) error { return fmt.Errorf("shadow redirects are disabled") }}, limit: make(chan struct{}, cfg.MaxConcurrent)} +} + +func (m *Middleware) Wrap(next http.Handler) http.Handler { + if m == nil || m.config.BaseURL == "" || m.config.SampleRate <= 0 { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !m.sample(gateway.RequestID(r.Context())) { + next.ServeHTTP(w, r) + return + } + body, eligible := m.captureRequest(r) + if !eligible { + next.ServeHTTP(w, r) + return + } + m.metrics.eligible.Add(1) + select { + case m.limit <- struct{}{}: + default: + m.metrics.skipped.Add(1) + next.ServeHTTP(w, r) + return + } + capture := &captureWriter{ResponseWriter: w, limit: m.config.MaxBodyBytes} + next.ServeHTTP(capture, r) + primaryStatus := capture.Status() + primaryBody := append([]byte(nil), capture.body...) + headers := shadowHeaders(r.Header) + path := r.URL.RequestURI() + go func() { + defer func() { <-m.limit }() + m.compare(context.WithoutCancel(r.Context()), path, headers, body, primaryStatus, primaryBody) + }() + }) +} + +func (m *Middleware) captureRequest(r *http.Request) ([]byte, bool) { + if r.Method != http.MethodPost || r.Body == nil || r.Body == http.NoBody { + return nil, false + } + original := r.Body + raw, err := io.ReadAll(io.LimitReader(original, m.config.MaxBodyBytes+1)) + if err != nil || int64(len(raw)) > m.config.MaxBodyBytes { + r.Body = &multiReadCloser{Reader: io.MultiReader(bytes.NewReader(raw), original), Closer: original} + return nil, false + } + _ = original.Close() + r.Body = io.NopCloser(bytes.NewReader(raw)) + r.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(raw)), nil } + var envelope struct { + Stream bool `json:"stream"` + } + if json.Unmarshal(raw, &envelope) != nil || envelope.Stream { + return nil, false + } + return raw, true +} + +type multiReadCloser struct { + io.Reader + io.Closer +} + +func (m *Middleware) sample(requestID string) bool { + hash := fnv.New64a() + _, _ = hash.Write([]byte(requestID)) + return float64(hash.Sum64()%1_000_000)/1_000_000 < m.config.SampleRate +} + +func (m *Middleware) compare(ctx context.Context, path string, headers http.Header, body []byte, primaryStatus int, primaryBody []byte) { + ctx, cancel := context.WithTimeout(ctx, m.config.Timeout) + defer cancel() + target, err := url.Parse(m.config.BaseURL + path) + if err != nil { + m.fail("invalid shadow URL", err) + return + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(body)) + if err != nil { + m.fail("create shadow request", err) + return + } + request.Header = headers + request.Header.Set("Authorization", "Bearer "+m.config.APIKey) + request.Header.Del("X-Gateway-API-Key") + request.Header.Set("X-Gateway-Shadow", "true") + response, err := m.client.Do(request) + if err != nil { + m.fail("shadow request failed", err) + return + } + defer response.Body.Close() + raw, err := io.ReadAll(io.LimitReader(response.Body, m.config.MaxBodyBytes+1)) + if err != nil || int64(len(raw)) > m.config.MaxBodyBytes { + m.fail("shadow response invalid", err) + return + } + m.metrics.sent.Add(1) + primarySignature := contractSignature(primaryStatus, primaryBody) + shadowSignature := contractSignature(response.StatusCode, raw) + if primarySignature == shadowSignature { + m.metrics.matched.Add(1) + return + } + m.metrics.mismatched.Add(1) + if m.logger != nil { + m.logger.Warn("shadow contract mismatch", "path", path, "primary_status", primaryStatus, "shadow_status", response.StatusCode, "primary_signature", primarySignature, "shadow_signature", shadowSignature) + } +} +func (m *Middleware) fail(message string, err error) { + m.metrics.errors.Add(1) + if m.logger != nil { + m.logger.Warn(message, "error", err) + } +} + +func contractSignature(status int, raw []byte) string { + var value any + if json.Unmarshal(raw, &value) != nil { + return fmt.Sprintf("%d:non-json", status) + } + shape, _ := json.Marshal(jsonShape(value)) + return fmt.Sprintf("%d:%s", status, shape) +} +func jsonShape(value any) any { + switch item := value.(type) { + case map[string]any: + result := make(map[string]any, len(item)) + for key, child := range item { + switch key { + case "id", "created", "created_at", "system_fingerprint": + continue + } + result[key] = jsonShape(child) + } + return result + case []any: + if len(item) == 0 { + return []any{} + } + return []any{jsonShape(item[0])} + case string: + return "string" + case float64: + return "number" + case bool: + return "boolean" + case nil: + return nil + default: + return fmt.Sprintf("%T", item) + } +} +func shadowHeaders(source http.Header) http.Header { + result := make(http.Header) + for _, key := range []string{"Content-Type", "Accept", "Idempotency-Key", "X-Gateway-Provider"} { + if value := source.Values(key); len(value) > 0 { + result[key] = append([]string(nil), value...) + } + } + return result +} +func (m *Middleware) Prometheus() string { + if m == nil { + return "" + } + return fmt.Sprintf("gateway_shadow_eligible_total %d\ngateway_shadow_sent_total %d\ngateway_shadow_contract_matches_total %d\ngateway_shadow_contract_mismatches_total %d\ngateway_shadow_errors_total %d\ngateway_shadow_skipped_total %d\n", m.metrics.eligible.Load(), m.metrics.sent.Load(), m.metrics.matched.Load(), m.metrics.mismatched.Load(), m.metrics.errors.Load(), m.metrics.skipped.Load()) +} + +type captureWriter struct { + http.ResponseWriter + status int + body []byte + limit int64 +} + +func (w *captureWriter) WriteHeader(status int) { + if w.status == 0 { + w.status = status + } + w.ResponseWriter.WriteHeader(status) +} +func (w *captureWriter) Write(value []byte) (int, error) { + if w.status == 0 { + w.status = http.StatusOK + } + if int64(len(w.body)) < w.limit { + remaining := int(w.limit - int64(len(w.body))) + w.body = append(w.body, value[:min(len(value), remaining)]...) + } + return w.ResponseWriter.Write(value) +} +func (w *captureWriter) Status() int { + if w.status == 0 { + return http.StatusOK + } + return w.status +} +func (w *captureWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter } +func (w *captureWriter) Flush() { + if flusher, ok := w.ResponseWriter.(http.Flusher); ok { + flusher.Flush() + } +} diff --git a/internal/shadow/middleware_test.go b/internal/shadow/middleware_test.go new file mode 100644 index 0000000..cdbc3c1 --- /dev/null +++ b/internal/shadow/middleware_test.go @@ -0,0 +1,83 @@ +package shadow + +import ( + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "aigateway.local/core/internal/gateway" + "aigateway.local/core/internal/platform/config" +) + +func TestContractSignatureComparesShapeNotGeneratedValues(t *testing.T) { + left := contractSignature(200, []byte(`{"id":"one","choices":[{"message":{"content":"answer one"}}]}`)) + right := contractSignature(200, []byte(`{"id":"two","choices":[{"message":{"content":"answer two"}}]}`)) + if left != right { + t.Fatalf("expected equal shapes: %s != %s", left, right) + } + if left == contractSignature(500, []byte(`{"choices":[]}`)) { + t.Fatal("status and schema mismatch must differ") + } +} + +func TestMiddlewareUsesDedicatedCredentialAndDoesNotDelayPrimary(t *testing.T) { + delivered := make(chan string, 1) + legacy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + delivered <- r.Header.Get("Authorization") + ":" + string(raw) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"legacy","choices":[{"message":{"content":"different"}}]}`)) + })) + defer legacy.Close() + middleware := New(config.Shadow{BaseURL: legacy.URL, APIKey: "shadow-secret", SampleRate: 1, Timeout: time.Second, MaxBodyBytes: 1 << 20, MaxConcurrent: 2}, slog.Default()) + primary := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"go","choices":[{"message":{"content":"primary"}}]}`)) + }) + request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"test","stream":false}`)) + request.Header.Set("Authorization", "Bearer production-secret") + request = request.WithContext(gateway.WithRequestID(request.Context(), "shadow-test")) + response := httptest.NewRecorder() + middleware.Wrap(primary).ServeHTTP(response, request) + if response.Code != 200 { + t.Fatalf("primary status %d", response.Code) + } + select { + case value := <-delivered: + if !strings.HasPrefix(value, "Bearer shadow-secret:") { + t.Fatalf("shadow credential not replaced: %s", value) + } + case <-time.After(2 * time.Second): + t.Fatal("shadow request was not delivered") + } + deadline := time.Now().Add(time.Second) + for middleware.metrics.matched.Load()+middleware.metrics.mismatched.Load() == 0 && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if middleware.metrics.matched.Load() != 1 { + t.Fatalf("expected shape match, metrics=%s", middleware.Prometheus()) + } +} + +func TestOversizedShadowBodyIsPreservedForPrimary(t *testing.T) { + middleware := New(config.Shadow{BaseURL: "http://127.0.0.1:1", APIKey: "shadow", SampleRate: 1, Timeout: time.Second, MaxBodyBytes: 16, MaxConcurrent: 1}, slog.Default()) + payload := `{"stream":false,"prompt":"this body is larger than the shadow limit"}` + primary := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + if string(raw) != payload { + t.Fatalf("primary body changed: %q", raw) + } + w.WriteHeader(http.StatusNoContent) + }) + request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(payload)) + request = request.WithContext(gateway.WithRequestID(request.Context(), "large-shadow")) + response := httptest.NewRecorder() + middleware.Wrap(primary).ServeHTTP(response, request) + if response.Code != http.StatusNoContent { + t.Fatalf("status %d", response.Code) + } +} diff --git a/internal/workbench/admin_http.go b/internal/workbench/admin_http.go new file mode 100644 index 0000000..fe7c780 --- /dev/null +++ b/internal/workbench/admin_http.go @@ -0,0 +1,783 @@ +package workbench + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + "strings" + + "aigateway.local/core/internal/identity" + "aigateway.local/core/internal/platform/apiresponse" +) + +type AdminHTTPHandler struct { + service *Service + tools *ToolService + notifications *NotificationService + identity *identity.Service + mux *http.ServeMux +} + +func NewAdminHTTPHandler(service *Service, tools *ToolService, notifications *NotificationService, identityService *identity.Service) *AdminHTTPHandler { + h := &AdminHTTPHandler{service: service, tools: tools, notifications: notifications, identity: identityService, mux: http.NewServeMux()} + h.mux.HandleFunc("GET /api/v1/admin/prompt-categories", h.listPromptCategories) + h.mux.HandleFunc("POST /api/v1/admin/prompt-categories", h.createPromptCategory) + h.mux.HandleFunc("DELETE /api/v1/admin/prompt-categories/{id}", h.deletePromptCategory) + h.mux.HandleFunc("GET /api/v1/admin/prompts", h.listPrompts) + h.mux.HandleFunc("POST /api/v1/admin/prompts", h.createPrompt) + h.mux.HandleFunc("GET /api/v1/admin/prompts/{id}", h.getPrompt) + h.mux.HandleFunc("PUT /api/v1/admin/prompts/{id}", h.updatePrompt) + h.mux.HandleFunc("DELETE /api/v1/admin/prompts/{id}", h.deletePrompt) + h.mux.HandleFunc("GET /api/v1/admin/prompts/{id}/versions", h.listPromptVersions) + h.mux.HandleFunc("POST /api/v1/admin/prompts/{id}/versions", h.addPromptVersion) + h.mux.HandleFunc("POST /api/v1/admin/prompts/{id}/versions/{version}/activate", h.activatePromptVersion) + h.mux.HandleFunc("POST /api/v1/admin/prompts/{id}/render", h.previewPrompt) + h.mux.HandleFunc("GET /api/v1/admin/knowledge-bases", h.listKnowledgeBases) + h.mux.HandleFunc("POST /api/v1/admin/knowledge-bases", h.createKnowledgeBase) + h.mux.HandleFunc("PUT /api/v1/admin/knowledge-bases/{id}", h.updateKnowledgeBase) + h.mux.HandleFunc("DELETE /api/v1/admin/knowledge-bases/{id}", h.deleteKnowledgeBase) + h.mux.HandleFunc("GET /api/v1/admin/knowledge-bases/{id}/documents", h.listDocuments) + h.mux.HandleFunc("POST /api/v1/admin/knowledge-bases/{id}/documents", h.addDocument) + h.mux.HandleFunc("DELETE /api/v1/admin/knowledge-bases/{id}/documents/{document_id}", h.deleteDocument) + h.mux.HandleFunc("POST /api/v1/admin/knowledge-bases/{id}/documents/{document_id}/reprocess", h.reprocessDocument) + h.mux.HandleFunc("POST /api/v1/admin/knowledge-bases/{id}/search", h.searchKnowledge) + h.mux.HandleFunc("GET /api/v1/admin/tools", h.listTools) + h.mux.HandleFunc("POST /api/v1/admin/tools", h.createTool) + h.mux.HandleFunc("PUT /api/v1/admin/tools/{id}", h.updateTool) + h.mux.HandleFunc("DELETE /api/v1/admin/tools/{id}", h.deleteTool) + h.mux.HandleFunc("POST /api/v1/admin/tools/{id}/test", h.testTool) + h.mux.HandleFunc("GET /api/v1/admin/applications", h.listApplications) + h.mux.HandleFunc("POST /api/v1/admin/applications", h.createApplication) + h.mux.HandleFunc("GET /api/v1/admin/applications/catalog", h.applicationCatalog) + h.mux.HandleFunc("GET /api/v1/admin/applications/asset-dependencies", h.applicationDependencies) + h.mux.HandleFunc("GET /api/v1/admin/applications/{id}", h.getApplication) + h.mux.HandleFunc("PUT /api/v1/admin/applications/{id}", h.updateApplication) + h.mux.HandleFunc("DELETE /api/v1/admin/applications/{id}", h.deleteApplication) + h.mux.HandleFunc("POST /api/v1/admin/applications/{id}/publish", h.publishApplication) + h.mux.HandleFunc("POST /api/v1/admin/applications/{id}/rollback/{version}", h.rollbackApplication) + h.mux.HandleFunc("GET /api/v1/admin/applications/{id}/versions", h.listApplicationVersions) + h.mux.HandleFunc("GET /api/v1/admin/applications/{id}/runs", h.listApplicationRuns) + h.mux.HandleFunc("GET /api/v1/admin/notification-channels", h.listChannels) + h.mux.HandleFunc("POST /api/v1/admin/notification-channels", h.createChannel) + h.mux.HandleFunc("PUT /api/v1/admin/notification-channels/{id}", h.updateChannel) + h.mux.HandleFunc("DELETE /api/v1/admin/notification-channels/{id}", h.deleteChannel) + h.mux.HandleFunc("GET /api/v1/admin/notification-deliveries", h.listDeliveries) + h.mux.HandleFunc("POST /api/v1/admin/notification-deliveries/{id}/retry", h.retryDelivery) + return h +} +func (h *AdminHTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.mux.ServeHTTP(w, r) } +func (h *AdminHTTPHandler) require(w http.ResponseWriter, r *http.Request, permission string) (identity.Account, bool) { + account, err := h.identity.Authenticate(r.Context(), identity.KindAdmin, r.Header.Get("Authorization")) + if err != nil { + apiresponse.Error(w, 401, "登录状态无效") + return account, false + } + if !identity.HasPermission(account, permission) { + apiresponse.Error(w, 403, "缺少 AI 资产管理权限") + return account, false + } + return account, true +} +func decodeAsset(w http.ResponseWriter, r *http.Request, target any) bool { + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 3<<20)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + apiresponse.Error(w, 400, "请求格式无效") + return false + } + return true +} +func assetError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, ErrNotFound): + apiresponse.Error(w, 404, "资源不存在") + case errors.Is(err, ErrConflict): + apiresponse.Error(w, 409, "资源仍被已发布或草稿应用引用") + default: + apiresponse.Error(w, 400, err.Error()) + } +} + +func (h *AdminHTTPHandler) listPromptCategories(w http.ResponseWriter, r *http.Request) { + if _, ok := h.require(w, r, identity.PermissionPromptRead); !ok { + return + } + items, err := h.service.ListPromptCategories(r.Context()) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, items) +} +func (h *AdminHTTPHandler) createPromptCategory(w http.ResponseWriter, r *http.Request) { + a, ok := h.require(w, r, identity.PermissionPromptManage) + if !ok { + return + } + var input struct { + Name string `json:"name"` + Description string `json:"description"` + } + if !decodeAsset(w, r, &input) { + return + } + item, err := h.service.CreatePromptCategory(r.Context(), input.Name, input.Description, a.ID) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, item) +} +func (h *AdminHTTPHandler) deletePromptCategory(w http.ResponseWriter, r *http.Request) { + a, ok := h.require(w, r, identity.PermissionPromptManage) + if !ok { + return + } + if err := h.service.DeletePromptCategory(r.Context(), r.PathValue("id"), a.ID); err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, map[string]bool{"deleted": true}) +} + +type promptPayload struct { + Name string `json:"name"` + Description string `json:"description"` + CategoryID *string `json:"category_id"` + Tags []string `json:"tags"` + DepartmentIDs []string `json:"department_ids"` + Enabled bool `json:"enabled"` + Content string `json:"content"` + Variables []Variable `json:"variables"` + ChangeNote string `json:"change_note"` +} + +func promptInput(p promptPayload) PromptInput { + return PromptInput{Name: p.Name, Description: p.Description, CategoryID: p.CategoryID, Tags: p.Tags, DepartmentIDs: p.DepartmentIDs, Enabled: p.Enabled, Content: p.Content, Variables: p.Variables, ChangeNote: p.ChangeNote} +} +func (h *AdminHTTPHandler) listPrompts(w http.ResponseWriter, r *http.Request) { + if _, ok := h.require(w, r, identity.PermissionPromptRead); !ok { + return + } + items, err := h.service.ListPrompts(r.Context()) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, items) +} +func (h *AdminHTTPHandler) getPrompt(w http.ResponseWriter, r *http.Request) { + if _, ok := h.require(w, r, identity.PermissionPromptRead); !ok { + return + } + item, err := h.service.GetPrompt(r.Context(), r.PathValue("id")) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, item) +} +func (h *AdminHTTPHandler) createPrompt(w http.ResponseWriter, r *http.Request) { + a, ok := h.require(w, r, identity.PermissionPromptManage) + if !ok { + return + } + var p promptPayload + if !decodeAsset(w, r, &p) { + return + } + item, err := h.service.CreatePrompt(r.Context(), promptInput(p), a.ID) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, item) +} +func (h *AdminHTTPHandler) updatePrompt(w http.ResponseWriter, r *http.Request) { + a, ok := h.require(w, r, identity.PermissionPromptManage) + if !ok { + return + } + var p promptPayload + if !decodeAsset(w, r, &p) { + return + } + item, err := h.service.UpdatePrompt(r.Context(), r.PathValue("id"), promptInput(p), a.ID) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, item) +} +func (h *AdminHTTPHandler) deletePrompt(w http.ResponseWriter, r *http.Request) { + a, ok := h.require(w, r, identity.PermissionPromptManage) + if !ok { + return + } + if err := h.service.DeletePrompt(r.Context(), r.PathValue("id"), a.ID); err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, map[string]bool{"deleted": true}) +} +func (h *AdminHTTPHandler) listPromptVersions(w http.ResponseWriter, r *http.Request) { + if _, ok := h.require(w, r, identity.PermissionPromptRead); !ok { + return + } + items, err := h.service.ListPromptVersions(r.Context(), r.PathValue("id")) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, items) +} +func (h *AdminHTTPHandler) addPromptVersion(w http.ResponseWriter, r *http.Request) { + a, ok := h.require(w, r, identity.PermissionPromptManage) + if !ok { + return + } + var p struct { + Content string `json:"content"` + Variables []Variable `json:"variables"` + ChangeNote string `json:"change_note"` + Activate bool `json:"activate"` + } + if !decodeAsset(w, r, &p) { + return + } + item, err := h.service.AddPromptVersion(r.Context(), r.PathValue("id"), p.Content, p.Variables, p.ChangeNote, a.ID, p.Activate) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, item) +} +func (h *AdminHTTPHandler) activatePromptVersion(w http.ResponseWriter, r *http.Request) { + a, ok := h.require(w, r, identity.PermissionPromptManage) + if !ok { + return + } + version, err := strconv.Atoi(r.PathValue("version")) + if err != nil { + apiresponse.Error(w, 400, "版本号无效") + return + } + if err = h.service.ActivatePromptVersion(r.Context(), r.PathValue("id"), version, a.ID); err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, map[string]bool{"activated": true}) +} +func (h *AdminHTTPHandler) previewPrompt(w http.ResponseWriter, r *http.Request) { + if _, ok := h.require(w, r, identity.PermissionPromptRead); !ok { + return + } + var p struct { + Variables map[string]any `json:"variables"` + } + if !decodeAsset(w, r, &p) { + return + } + prompt, err := h.service.GetPrompt(r.Context(), r.PathValue("id")) + if err != nil { + assetError(w, err) + return + } + if prompt.Current == nil { + apiresponse.Error(w, 409, "Prompt 没有生效版本") + return + } + rendered, err := RenderPrompt(prompt.Current.Content, prompt.Current.Variables, p.Variables) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, map[string]any{"rendered": rendered, "version": prompt.Current.Version}) +} + +func (h *AdminHTTPHandler) listKnowledgeBases(w http.ResponseWriter, r *http.Request) { + if _, ok := h.require(w, r, identity.PermissionKnowledgeRead); !ok { + return + } + items, err := h.service.ListKnowledgeBases(r.Context()) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, items) +} +func (h *AdminHTTPHandler) createKnowledgeBase(w http.ResponseWriter, r *http.Request) { + a, ok := h.require(w, r, identity.PermissionKnowledgeManage) + if !ok { + return + } + var k KnowledgeBase + if !decodeAsset(w, r, &k) { + return + } + item, err := h.service.SaveKnowledgeBase(r.Context(), k, a.ID, true) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, item) +} +func (h *AdminHTTPHandler) updateKnowledgeBase(w http.ResponseWriter, r *http.Request) { + a, ok := h.require(w, r, identity.PermissionKnowledgeManage) + if !ok { + return + } + var k KnowledgeBase + if !decodeAsset(w, r, &k) { + return + } + k.ID = r.PathValue("id") + item, err := h.service.SaveKnowledgeBase(r.Context(), k, a.ID, false) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, item) +} +func (h *AdminHTTPHandler) deleteKnowledgeBase(w http.ResponseWriter, r *http.Request) { + a, ok := h.require(w, r, identity.PermissionKnowledgeManage) + if !ok { + return + } + if err := h.service.DeleteKnowledgeBase(r.Context(), r.PathValue("id"), a.ID); err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, map[string]bool{"deleted": true}) +} +func (h *AdminHTTPHandler) listDocuments(w http.ResponseWriter, r *http.Request) { + if _, ok := h.require(w, r, identity.PermissionKnowledgeRead); !ok { + return + } + items, err := h.service.ListKnowledgeDocuments(r.Context(), r.PathValue("id")) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, items) +} +func (h *AdminHTTPHandler) addDocument(w http.ResponseWriter, r *http.Request) { + a, ok := h.require(w, r, identity.PermissionKnowledgeManage) + if !ok { + return + } + var p struct { + Title string `json:"title"` + SourceType string `json:"source_type"` + SourceURI string `json:"source_uri"` + Content string `json:"content"` + } + if !decodeAsset(w, r, &p) { + return + } + item, err := h.service.AddKnowledgeDocument(r.Context(), r.PathValue("id"), p.Title, p.SourceType, p.SourceURI, p.Content, a.ID) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, item) +} +func (h *AdminHTTPHandler) deleteDocument(w http.ResponseWriter, r *http.Request) { + a, ok := h.require(w, r, identity.PermissionKnowledgeManage) + if !ok { + return + } + if err := h.service.DeleteKnowledgeDocument(r.Context(), r.PathValue("id"), r.PathValue("document_id"), a.ID); err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, map[string]bool{"deleted": true}) +} +func (h *AdminHTTPHandler) reprocessDocument(w http.ResponseWriter, r *http.Request) { + a, ok := h.require(w, r, identity.PermissionKnowledgeManage) + if !ok { + return + } + item, err := h.service.ReprocessKnowledgeDocument(r.Context(), r.PathValue("id"), r.PathValue("document_id"), a.ID) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, item) +} +func (h *AdminHTTPHandler) searchKnowledge(w http.ResponseWriter, r *http.Request) { + if _, ok := h.require(w, r, identity.PermissionKnowledgeRead); !ok { + return + } + var p struct { + Query string `json:"query"` + TopK int `json:"top_k"` + } + if !decodeAsset(w, r, &p) { + return + } + items, err := NewPostgreSQLRetriever(h.service).Search(r.Context(), r.PathValue("id"), p.Query, p.TopK) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, items) +} + +type toolPayload struct { + Code string `json:"code"` + Name string `json:"name"` + Description string `json:"description"` + EndpointURL string `json:"endpoint_url"` + HTTPMethod string `json:"http_method"` + Headers *map[string]string `json:"headers"` + InputSchema json.RawMessage `json:"input_schema"` + TimeoutSeconds int `json:"timeout_seconds"` + DepartmentIDs []string `json:"department_ids"` + Enabled bool `json:"enabled"` +} + +func toolInput(p toolPayload, create bool) ToolInput { + var headers map[string]string + if p.Headers != nil { + headers = *p.Headers + } else if create { + headers = map[string]string{} + } + return ToolInput{Code: p.Code, Name: p.Name, Description: p.Description, EndpointURL: p.EndpointURL, HTTPMethod: p.HTTPMethod, Headers: headers, InputSchema: p.InputSchema, TimeoutSeconds: p.TimeoutSeconds, DepartmentIDs: p.DepartmentIDs, Enabled: p.Enabled} +} +func (h *AdminHTTPHandler) listTools(w http.ResponseWriter, r *http.Request) { + if _, ok := h.require(w, r, identity.PermissionToolRead); !ok { + return + } + items, err := h.tools.List(r.Context()) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, items) +} +func (h *AdminHTTPHandler) createTool(w http.ResponseWriter, r *http.Request) { + a, ok := h.require(w, r, identity.PermissionToolManage) + if !ok { + return + } + var p toolPayload + if !decodeAsset(w, r, &p) { + return + } + item, err := h.tools.Save(r.Context(), "", toolInput(p, true), a.ID, true) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, item) +} +func (h *AdminHTTPHandler) updateTool(w http.ResponseWriter, r *http.Request) { + a, ok := h.require(w, r, identity.PermissionToolManage) + if !ok { + return + } + var p toolPayload + if !decodeAsset(w, r, &p) { + return + } + item, err := h.tools.Save(r.Context(), r.PathValue("id"), toolInput(p, false), a.ID, false) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, item) +} +func (h *AdminHTTPHandler) deleteTool(w http.ResponseWriter, r *http.Request) { + a, ok := h.require(w, r, identity.PermissionToolManage) + if !ok { + return + } + if err := h.tools.Delete(r.Context(), r.PathValue("id"), a.ID); err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, map[string]bool{"deleted": true}) +} +func (h *AdminHTTPHandler) testTool(w http.ResponseWriter, r *http.Request) { + if _, ok := h.require(w, r, identity.PermissionToolManage); !ok { + return + } + var p struct { + Input map[string]any `json:"input"` + } + if !decodeAsset(w, r, &p) { + return + } + tool, err := h.tools.Get(r.Context(), r.PathValue("id")) + if err == nil { + result, executeErr := h.tools.Execute(r.Context(), tool, p.Input, "", "admin-test") + err = executeErr + if err == nil { + apiresponse.OK(w, result) + return + } + } + assetError(w, err) +} + +func (h *AdminHTTPHandler) listApplications(w http.ResponseWriter, r *http.Request) { + if _, ok := h.require(w, r, identity.PermissionApplicationRead); !ok { + return + } + items, err := h.service.ListApplications(r.Context()) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, items) +} +func (h *AdminHTTPHandler) getApplication(w http.ResponseWriter, r *http.Request) { + if _, ok := h.require(w, r, identity.PermissionApplicationRead); !ok { + return + } + item, err := h.service.GetApplication(r.Context(), r.PathValue("id")) + if err != nil { + assetError(w, err) + return + } + versions, err := h.service.ListApplicationVersions(r.Context(), item.ID) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, map[string]any{"application": item, "versions": versions}) +} +func (h *AdminHTTPHandler) applicationCatalog(w http.ResponseWriter, r *http.Request) { + if _, ok := h.require(w, r, identity.PermissionApplicationRead); !ok { + return + } + prompts, err := h.service.ListPrompts(r.Context()) + if err != nil { + assetError(w, err) + return + } + knowledge, err := h.service.ListKnowledgeBases(r.Context()) + if err != nil { + assetError(w, err) + return + } + tools, err := h.tools.List(r.Context()) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, map[string]any{"prompts": prompts, "knowledge_bases": knowledge, "tools": tools}) +} +func (h *AdminHTTPHandler) applicationDependencies(w http.ResponseWriter, r *http.Request) { + if _, ok := h.require(w, r, identity.PermissionApplicationRead); !ok { + return + } + assetType := strings.TrimSpace(r.URL.Query().Get("asset_type")) + assetID := strings.TrimSpace(r.URL.Query().Get("asset_id")) + if assetID == "" || (assetType != "prompt" && assetType != "knowledge" && assetType != "tool") { + apiresponse.Error(w, 400, "asset_type 或 asset_id 无效") + return + } + items, err := h.service.ApplicationDependencies(r.Context(), assetType, assetID) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, items) +} +func (h *AdminHTTPHandler) createApplication(w http.ResponseWriter, r *http.Request) { + a, ok := h.require(w, r, identity.PermissionApplicationManage) + if !ok { + return + } + var app Application + if !decodeAsset(w, r, &app) { + return + } + item, err := h.service.SaveApplication(r.Context(), app, a.ID, true) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, item) +} +func (h *AdminHTTPHandler) updateApplication(w http.ResponseWriter, r *http.Request) { + a, ok := h.require(w, r, identity.PermissionApplicationManage) + if !ok { + return + } + var app Application + if !decodeAsset(w, r, &app) { + return + } + app.ID = r.PathValue("id") + item, err := h.service.SaveApplication(r.Context(), app, a.ID, false) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, item) +} +func (h *AdminHTTPHandler) deleteApplication(w http.ResponseWriter, r *http.Request) { + a, ok := h.require(w, r, identity.PermissionApplicationManage) + if !ok { + return + } + if err := h.service.DeleteApplication(r.Context(), r.PathValue("id"), a.ID); err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, map[string]bool{"deleted": true}) +} +func (h *AdminHTTPHandler) publishApplication(w http.ResponseWriter, r *http.Request) { + a, ok := h.require(w, r, identity.PermissionApplicationManage) + if !ok { + return + } + var p struct { + ChangeNote string `json:"change_note"` + } + if !decodeAsset(w, r, &p) { + return + } + app, err := h.service.PublishApplication(r.Context(), r.PathValue("id"), p.ChangeNote, a.ID) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, app) +} +func (h *AdminHTTPHandler) rollbackApplication(w http.ResponseWriter, r *http.Request) { + a, ok := h.require(w, r, identity.PermissionApplicationManage) + if !ok { + return + } + version, err := strconv.Atoi(r.PathValue("version")) + if err != nil || version < 1 { + apiresponse.Error(w, 400, "版本号无效") + return + } + var input struct { + ChangeNote string `json:"change_note"` + } + if !decodeAsset(w, r, &input) { + return + } + item, err := h.service.RollbackApplication(r.Context(), r.PathValue("id"), version, input.ChangeNote, a.ID) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, item) +} +func (h *AdminHTTPHandler) listApplicationVersions(w http.ResponseWriter, r *http.Request) { + if _, ok := h.require(w, r, identity.PermissionApplicationRead); !ok { + return + } + items, err := h.service.ListApplicationVersions(r.Context(), r.PathValue("id")) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, items) +} +func (h *AdminHTTPHandler) listApplicationRuns(w http.ResponseWriter, r *http.Request) { + if _, ok := h.require(w, r, identity.PermissionApplicationRead); !ok { + return + } + items, err := h.service.ListApplicationRuns(r.Context(), r.PathValue("id"), 100) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, items) +} + +type channelPayload struct { + Name string `json:"name"` + WebhookURL string `json:"webhook_url"` + SigningSecret *string `json:"signing_secret"` + EventPatterns []string `json:"event_patterns"` + Enabled bool `json:"enabled"` +} + +func (h *AdminHTTPHandler) listChannels(w http.ResponseWriter, r *http.Request) { + if _, ok := h.require(w, r, identity.PermissionNotificationRead); !ok { + return + } + items, err := h.notifications.ListChannels(r.Context()) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, items) +} +func (h *AdminHTTPHandler) createChannel(w http.ResponseWriter, r *http.Request) { + a, ok := h.require(w, r, identity.PermissionNotificationManage) + if !ok { + return + } + var p channelPayload + if !decodeAsset(w, r, &p) { + return + } + item, err := h.notifications.SaveChannel(r.Context(), "", NotificationInput{Name: p.Name, WebhookURL: p.WebhookURL, SigningSecret: p.SigningSecret, EventPatterns: p.EventPatterns, Enabled: p.Enabled}, a.ID, true) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, item) +} +func (h *AdminHTTPHandler) updateChannel(w http.ResponseWriter, r *http.Request) { + a, ok := h.require(w, r, identity.PermissionNotificationManage) + if !ok { + return + } + var p channelPayload + if !decodeAsset(w, r, &p) { + return + } + item, err := h.notifications.SaveChannel(r.Context(), r.PathValue("id"), NotificationInput{Name: p.Name, WebhookURL: p.WebhookURL, SigningSecret: p.SigningSecret, EventPatterns: p.EventPatterns, Enabled: p.Enabled}, a.ID, false) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, item) +} +func (h *AdminHTTPHandler) deleteChannel(w http.ResponseWriter, r *http.Request) { + a, ok := h.require(w, r, identity.PermissionNotificationManage) + if !ok { + return + } + if err := h.notifications.DeleteChannel(r.Context(), r.PathValue("id"), a.ID); err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, map[string]bool{"deleted": true}) +} +func (h *AdminHTTPHandler) listDeliveries(w http.ResponseWriter, r *http.Request) { + if _, ok := h.require(w, r, identity.PermissionNotificationRead); !ok { + return + } + items, err := h.notifications.ListDeliveries(r.Context(), 100) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, items) +} +func (h *AdminHTTPHandler) retryDelivery(w http.ResponseWriter, r *http.Request) { + if _, ok := h.require(w, r, identity.PermissionNotificationManage); !ok { + return + } + if err := h.notifications.RetryDelivery(r.Context(), r.PathValue("id")); err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, map[string]bool{"retried": true}) +} + +var _ = strings.TrimSpace diff --git a/internal/workbench/admin_marketplace.go b/internal/workbench/admin_marketplace.go new file mode 100644 index 0000000..4bb2e9c --- /dev/null +++ b/internal/workbench/admin_marketplace.go @@ -0,0 +1,435 @@ +package workbench + +import ( + "net/http" + "strconv" + + "aigateway.local/core/internal/identity" + "aigateway.local/core/internal/platform/apiresponse" +) + +// MarketplaceAdminHTTPHandler exposes the resource-marketplace administration +// surface: shared categories, MCP servers (with connectivity test), skills, +// digital employees, and the unified catalog. +type MarketplaceAdminHTTPHandler struct { + market *MarketplaceService + mcpServers *MCPServerService + skills *SkillService + employees *DigitalEmployeeService + mcpClient *MCPClient + identity *identity.Service + mux *http.ServeMux +} + +func NewMarketplaceAdminHTTPHandler(market *MarketplaceService, mcpServers *MCPServerService, skills *SkillService, employees *DigitalEmployeeService, mcpClient *MCPClient, identityService *identity.Service) *MarketplaceAdminHTTPHandler { + h := &MarketplaceAdminHTTPHandler{market: market, mcpServers: mcpServers, skills: skills, employees: employees, mcpClient: mcpClient, identity: identityService, mux: http.NewServeMux()} + h.mux.HandleFunc("GET /api/v1/admin/marketplace-categories", h.listMarketplaceCategories) + h.mux.HandleFunc("POST /api/v1/admin/marketplace-categories", h.createMarketplaceCategory) + h.mux.HandleFunc("DELETE /api/v1/admin/marketplace-categories/{id}", h.deleteMarketplaceCategory) + h.mux.HandleFunc("GET /api/v1/admin/mcp-servers", h.listMCPServers) + h.mux.HandleFunc("POST /api/v1/admin/mcp-servers", h.createMCPServer) + h.mux.HandleFunc("GET /api/v1/admin/mcp-servers/{id}", h.getMCPServer) + h.mux.HandleFunc("PUT /api/v1/admin/mcp-servers/{id}", h.updateMCPServer) + h.mux.HandleFunc("DELETE /api/v1/admin/mcp-servers/{id}", h.deleteMCPServer) + h.mux.HandleFunc("POST /api/v1/admin/mcp-servers/{id}/test", h.testMCPServer) + h.mux.HandleFunc("GET /api/v1/admin/skills", h.listSkills) + h.mux.HandleFunc("POST /api/v1/admin/skills", h.createSkill) + h.mux.HandleFunc("GET /api/v1/admin/skills/{id}", h.getSkill) + h.mux.HandleFunc("PUT /api/v1/admin/skills/{id}", h.updateSkill) + h.mux.HandleFunc("DELETE /api/v1/admin/skills/{id}", h.deleteSkill) + h.mux.HandleFunc("POST /api/v1/admin/skills/{id}/render", h.renderSkillPreview) + h.mux.HandleFunc("GET /api/v1/admin/digital-employees", h.listDigitalEmployees) + h.mux.HandleFunc("POST /api/v1/admin/digital-employees", h.createDigitalEmployee) + h.mux.HandleFunc("GET /api/v1/admin/digital-employees/{id}", h.getDigitalEmployee) + h.mux.HandleFunc("PUT /api/v1/admin/digital-employees/{id}", h.updateDigitalEmployee) + h.mux.HandleFunc("DELETE /api/v1/admin/digital-employees/{id}", h.deleteDigitalEmployee) + h.mux.HandleFunc("POST /api/v1/admin/digital-employees/{id}/publish", h.publishDigitalEmployee) + h.mux.HandleFunc("POST /api/v1/admin/digital-employees/{id}/archive", h.archiveDigitalEmployee) + h.mux.HandleFunc("GET /api/v1/admin/digital-employees/{id}/runs", h.listDigitalEmployeeRuns) + h.mux.HandleFunc("GET /api/v1/admin/marketplace/catalog", h.marketplaceCatalog) + return h +} +func (h *MarketplaceAdminHTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + h.mux.ServeHTTP(w, r) +} +func (h *MarketplaceAdminHTTPHandler) require(w http.ResponseWriter, r *http.Request, permission string) (identity.Account, bool) { + account, err := h.identity.Authenticate(r.Context(), identity.KindAdmin, r.Header.Get("Authorization")) + if err != nil { + apiresponse.Error(w, 401, "登录状态无效") + return account, false + } + if !identity.HasPermission(account, permission) { + apiresponse.Error(w, 403, "缺少资源市场管理权限") + return account, false + } + return account, true +} + +// --- 分类 --- + +func (h *MarketplaceAdminHTTPHandler) listMarketplaceCategories(w http.ResponseWriter, r *http.Request) { + if _, ok := h.require(w, r, identity.PermissionMarketplaceRead); !ok { + return + } + items, err := h.market.ListCategories(r.Context(), r.URL.Query().Get("type")) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, items) +} + +func (h *MarketplaceAdminHTTPHandler) createMarketplaceCategory(w http.ResponseWriter, r *http.Request) { + a, ok := h.require(w, r, identity.PermissionMarketplaceManage) + if !ok { + return + } + var input struct { + Name string `json:"name"` + Description string `json:"description"` + ResourceType string `json:"resource_type"` + SortOrder int `json:"sort_order"` + } + if !decodeAsset(w, r, &input) { + return + } + item, err := h.market.CreateCategory(r.Context(), Category{Name: input.Name, Description: input.Description, ResourceType: input.ResourceType, SortOrder: input.SortOrder}, a.ID) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, item) +} + +func (h *MarketplaceAdminHTTPHandler) deleteMarketplaceCategory(w http.ResponseWriter, r *http.Request) { + a, ok := h.require(w, r, identity.PermissionMarketplaceManage) + if !ok { + return + } + if err := h.market.DeleteCategory(r.Context(), r.PathValue("id"), a.ID); err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, map[string]bool{"deleted": true}) +} + +// --- MCP 服务器 --- + +func (h *MarketplaceAdminHTTPHandler) listMCPServers(w http.ResponseWriter, r *http.Request) { + if _, ok := h.require(w, r, identity.PermissionMCPServerRead); !ok { + return + } + items, err := h.mcpServers.List(r.Context()) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, items) +} + +func (h *MarketplaceAdminHTTPHandler) createMCPServer(w http.ResponseWriter, r *http.Request) { + a, ok := h.require(w, r, identity.PermissionMCPServerManage) + if !ok { + return + } + var input MCPServerInput + if !decodeAsset(w, r, &input) { + return + } + item, err := h.mcpServers.Save(r.Context(), "", input, a.ID, true) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, item) +} + +func (h *MarketplaceAdminHTTPHandler) getMCPServer(w http.ResponseWriter, r *http.Request) { + if _, ok := h.require(w, r, identity.PermissionMCPServerRead); !ok { + return + } + item, err := h.mcpServers.Get(r.Context(), r.PathValue("id")) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, item) +} + +func (h *MarketplaceAdminHTTPHandler) updateMCPServer(w http.ResponseWriter, r *http.Request) { + a, ok := h.require(w, r, identity.PermissionMCPServerManage) + if !ok { + return + } + var input MCPServerInput + if !decodeAsset(w, r, &input) { + return + } + item, err := h.mcpServers.Save(r.Context(), r.PathValue("id"), input, a.ID, false) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, item) +} + +func (h *MarketplaceAdminHTTPHandler) deleteMCPServer(w http.ResponseWriter, r *http.Request) { + a, ok := h.require(w, r, identity.PermissionMCPServerManage) + if !ok { + return + } + if err := h.mcpServers.Delete(r.Context(), r.PathValue("id"), a.ID); err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, map[string]bool{"deleted": true}) +} + +// testMCPServer verifies connectivity and lists the tools an MCP server +// advertises so an operator can confirm it before publishing. +func (h *MarketplaceAdminHTTPHandler) testMCPServer(w http.ResponseWriter, r *http.Request) { + if _, ok := h.require(w, r, identity.PermissionMCPServerManage); !ok { + return + } + server, err := h.mcpServers.Get(r.Context(), r.PathValue("id")) + if err != nil { + assetError(w, err) + return + } + headers, err := h.mcpServers.Headers(server) + if err != nil { + assetError(w, err) + return + } + tools, err := h.mcpClient.DiscoverTools(r.Context(), server, headers) + if err != nil { + apiresponse.Error(w, 502, "MCP 服务器连通性测试失败: "+err.Error()) + return + } + apiresponse.OK(w, tools) +} + +// --- Skills --- + +func (h *MarketplaceAdminHTTPHandler) listSkills(w http.ResponseWriter, r *http.Request) { + if _, ok := h.require(w, r, identity.PermissionSkillRead); !ok { + return + } + items, err := h.skills.List(r.Context()) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, items) +} + +func (h *MarketplaceAdminHTTPHandler) createSkill(w http.ResponseWriter, r *http.Request) { + a, ok := h.require(w, r, identity.PermissionSkillManage) + if !ok { + return + } + var input SkillInput + if !decodeAsset(w, r, &input) { + return + } + item, err := h.skills.Save(r.Context(), "", input, a.ID, true) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, item) +} + +func (h *MarketplaceAdminHTTPHandler) getSkill(w http.ResponseWriter, r *http.Request) { + if _, ok := h.require(w, r, identity.PermissionSkillRead); !ok { + return + } + item, err := h.skills.Get(r.Context(), r.PathValue("id")) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, item) +} + +func (h *MarketplaceAdminHTTPHandler) updateSkill(w http.ResponseWriter, r *http.Request) { + a, ok := h.require(w, r, identity.PermissionSkillManage) + if !ok { + return + } + var input SkillInput + if !decodeAsset(w, r, &input) { + return + } + item, err := h.skills.Save(r.Context(), r.PathValue("id"), input, a.ID, false) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, item) +} + +func (h *MarketplaceAdminHTTPHandler) deleteSkill(w http.ResponseWriter, r *http.Request) { + a, ok := h.require(w, r, identity.PermissionSkillManage) + if !ok { + return + } + if err := h.skills.Delete(r.Context(), r.PathValue("id"), a.ID); err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, map[string]bool{"deleted": true}) +} + +func (h *MarketplaceAdminHTTPHandler) renderSkillPreview(w http.ResponseWriter, r *http.Request) { + if _, ok := h.require(w, r, identity.PermissionSkillRead); !ok { + return + } + var input struct { + Variables map[string]any `json:"variables"` + } + if !decodeAsset(w, r, &input) { + return + } + skill, err := h.skills.Get(r.Context(), r.PathValue("id")) + if err != nil { + assetError(w, err) + return + } + rendered, err := h.skills.Render(skill, input.Variables) + if err != nil { + apiresponse.Error(w, 400, err.Error()) + return + } + apiresponse.OK(w, map[string]any{"code": skill.Code, "name": skill.Name, "rendered": rendered}) +} + +// --- 数字员工 --- + +func (h *MarketplaceAdminHTTPHandler) listDigitalEmployees(w http.ResponseWriter, r *http.Request) { + if _, ok := h.require(w, r, identity.PermissionDigitalEmployeeRead); !ok { + return + } + items, err := h.employees.List(r.Context()) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, items) +} + +func (h *MarketplaceAdminHTTPHandler) createDigitalEmployee(w http.ResponseWriter, r *http.Request) { + a, ok := h.require(w, r, identity.PermissionDigitalEmployeeManage) + if !ok { + return + } + var input DigitalEmployeeInput + if !decodeAsset(w, r, &input) { + return + } + item, err := h.employees.Save(r.Context(), "", input, a.ID, true) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, item) +} + +func (h *MarketplaceAdminHTTPHandler) getDigitalEmployee(w http.ResponseWriter, r *http.Request) { + if _, ok := h.require(w, r, identity.PermissionDigitalEmployeeRead); !ok { + return + } + item, err := h.employees.Get(r.Context(), r.PathValue("id")) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, item) +} + +func (h *MarketplaceAdminHTTPHandler) updateDigitalEmployee(w http.ResponseWriter, r *http.Request) { + a, ok := h.require(w, r, identity.PermissionDigitalEmployeeManage) + if !ok { + return + } + var input DigitalEmployeeInput + if !decodeAsset(w, r, &input) { + return + } + item, err := h.employees.Save(r.Context(), r.PathValue("id"), input, a.ID, false) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, item) +} + +func (h *MarketplaceAdminHTTPHandler) deleteDigitalEmployee(w http.ResponseWriter, r *http.Request) { + a, ok := h.require(w, r, identity.PermissionDigitalEmployeeManage) + if !ok { + return + } + if err := h.employees.Delete(r.Context(), r.PathValue("id"), a.ID); err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, map[string]bool{"deleted": true}) +} + +func (h *MarketplaceAdminHTTPHandler) publishDigitalEmployee(w http.ResponseWriter, r *http.Request) { + a, ok := h.require(w, r, identity.PermissionDigitalEmployeeManage) + if !ok { + return + } + item, err := h.employees.Publish(r.Context(), r.PathValue("id"), a.ID) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, item) +} + +func (h *MarketplaceAdminHTTPHandler) archiveDigitalEmployee(w http.ResponseWriter, r *http.Request) { + a, ok := h.require(w, r, identity.PermissionDigitalEmployeeManage) + if !ok { + return + } + item, err := h.employees.Archive(r.Context(), r.PathValue("id"), a.ID) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, item) +} + +func (h *MarketplaceAdminHTTPHandler) listDigitalEmployeeRuns(w http.ResponseWriter, r *http.Request) { + if _, ok := h.require(w, r, identity.PermissionDigitalEmployeeRead); !ok { + return + } + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + items, err := h.employees.ListRuns(r.Context(), r.PathValue("id"), limit) + if err != nil { + assetError(w, err) + return + } + apiresponse.OK(w, items) +} + +// --- 统一目录 --- + +func (h *MarketplaceAdminHTTPHandler) marketplaceCatalog(w http.ResponseWriter, r *http.Request) { + if _, ok := h.require(w, r, identity.PermissionMarketplaceRead); !ok { + return + } + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + query := r.URL.Query() + items, err := h.market.Catalog(r.Context(), query.Get("type"), query.Get("category_id"), query.Get("tag"), query.Get("q"), limit) + if err != nil { + assetError(w, err) + return + } + if items == nil { + items = []MarketItem{} + } + apiresponse.OK(w, items) +} diff --git a/internal/workbench/applications.go b/internal/workbench/applications.go new file mode 100644 index 0000000..ae1ad6e --- /dev/null +++ b/internal/workbench/applications.go @@ -0,0 +1,380 @@ +package workbench + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5" +) + +func validateApplication(app *Application) error { + app.Code = strings.ToLower(strings.TrimSpace(app.Code)) + app.Name = strings.TrimSpace(app.Name) + app.Description = strings.TrimSpace(app.Description) + app.Status = strings.TrimSpace(app.Status) + if !codeRE.MatchString(app.Code) || len(app.Code) < 3 || app.Name == "" || len(app.Name) > 128 || len(app.Description) > 4000 { + return errors.New("应用编码、名称或描述格式无效") + } + switch app.Status { + case "draft", "active", "suspended", "retired": + case "": + app.Status = "draft" + default: + return errors.New("应用状态无效") + } + var err error + app.DepartmentIDs, err = normalizeStrings(app.DepartmentIDs, 100) + return err +} + +func normalizeApplicationConfig(config *ApplicationConfig, requireModel bool) error { + config.Model = strings.TrimSpace(config.Model) + config.PromptTemplateID = strings.TrimSpace(config.PromptTemplateID) + if requireModel && config.Model == "" { + return errors.New("发布配置必须指定模型") + } + if len(config.Model) > 255 { + return errors.New("模型名称过长") + } + var err error + if config.KnowledgeBaseIDs, err = normalizeStrings(config.KnowledgeBaseIDs, 20); err != nil { + return err + } + if config.ToolIDs, err = normalizeStrings(config.ToolIDs, 20); err != nil { + return err + } + if config.RetrievalTopK == 0 { + config.RetrievalTopK = 4 + } + if config.RetrievalTopK < 1 || config.RetrievalTopK > 20 { + return errors.New("retrieval_top_k 应在 1-20 之间") + } + if config.Temperature < 0 || config.Temperature > 2 { + return errors.New("temperature 应在 0-2 之间") + } + if config.MaxToolRounds < 0 || config.MaxToolRounds > 8 { + return errors.New("max_tool_rounds 应在 0-8 之间") + } + return nil +} + +func (s *Service) validateApplicationRefs(ctx context.Context, config ApplicationConfig) error { + if config.PromptTemplateID != "" { + var ok bool + if err := s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gateway.prompt_templates WHERE id=$1 AND enabled AND current_version IS NOT NULL)`, config.PromptTemplateID).Scan(&ok); err != nil { + return err + } + if !ok { + return errors.New("绑定的 Prompt 不存在、未启用或没有生效版本") + } + } + for _, id := range config.KnowledgeBaseIDs { + var ok bool + if err := s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gateway.knowledge_bases WHERE id=$1 AND enabled)`, id).Scan(&ok); err != nil { + return err + } + if !ok { + return fmt.Errorf("知识库 %s 不存在或未启用", id) + } + } + for _, id := range config.ToolIDs { + var ok bool + if err := s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gateway.tool_definitions WHERE id=$1 AND enabled)`, id).Scan(&ok); err != nil { + return err + } + if !ok { + return fmt.Errorf("工具 %s 不存在或未启用", id) + } + } + return nil +} + +const applicationSelect = `SELECT a.id::text,a.code,a.name,a.description,a.department_ids::text[],a.status,a.draft_config,a.published_version,p.config,a.revision,a.created_at,a.updated_at FROM gateway.applications a LEFT JOIN gateway.application_versions p ON p.application_id=a.id AND p.version=a.published_version` + +func scanApplication(row pgx.Row) (Application, error) { + var app Application + var draft []byte + var published []byte + err := row.Scan(&app.ID, &app.Code, &app.Name, &app.Description, &app.DepartmentIDs, &app.Status, &draft, &app.PublishedVersion, &published, &app.Revision, &app.CreatedAt, &app.UpdatedAt) + if err != nil { + return app, mapNotFound(err) + } + _ = json.Unmarshal(draft, &app.DraftConfig) + if len(published) > 0 { + var config ApplicationConfig + if json.Unmarshal(published, &config) == nil { + app.PublishedConfig = &config + } + } + return app, nil +} +func (s *Service) ListApplications(ctx context.Context) ([]Application, error) { + rows, err := s.pool.Query(ctx, applicationSelect+` ORDER BY a.updated_at DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Application{} + for rows.Next() { + app, err := scanApplication(rows) + if err != nil { + return nil, err + } + items = append(items, app) + } + return items, rows.Err() +} +func (s *Service) GetApplication(ctx context.Context, id string) (Application, error) { + return scanApplication(s.pool.QueryRow(ctx, applicationSelect+` WHERE a.id=$1`, id)) +} +func (s *Service) GetPublishedApplicationByCode(ctx context.Context, code string) (Application, error) { + return scanApplication(s.pool.QueryRow(ctx, applicationSelect+` WHERE a.code=$1 AND a.status='active' AND a.published_version IS NOT NULL`, code)) +} + +func (s *Service) SaveApplication(ctx context.Context, app Application, actorID string, create bool) (Application, error) { + if err := validateApplication(&app); err != nil { + return Application{}, err + } + if err := normalizeApplicationConfig(&app.DraftConfig, false); err != nil { + return Application{}, err + } + if err := s.validateApplicationRefs(ctx, app.DraftConfig); err != nil { + return Application{}, err + } + raw, _ := json.Marshal(app.DraftConfig) + tx, err := s.pool.Begin(ctx) + if err != nil { + return Application{}, err + } + defer rollback(ctx, tx) + if create { + app.ID, err = newUUID() + if err != nil { + return Application{}, err + } + _, err = tx.Exec(ctx, `INSERT INTO gateway.applications(id,code,name,description,department_ids,status,draft_config,created_by) VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, app.ID, app.Code, app.Name, app.Description, app.DepartmentIDs, app.Status, raw, actorID) + } else { + tag, updateErr := tx.Exec(ctx, `UPDATE gateway.applications SET code=$2,name=$3,description=$4,department_ids=$5,status=$6,draft_config=$7,revision=revision+1,updated_at=clock_timestamp() WHERE id=$1`, app.ID, app.Code, app.Name, app.Description, app.DepartmentIDs, app.Status, raw) + err = updateErr + if err == nil && tag.RowsAffected() == 0 { + return Application{}, ErrNotFound + } + } + if err != nil { + return Application{}, err + } + event := "application.updated" + if create { + event = "application.created" + } + if err = emit(ctx, tx, event, "application", app.ID, actorID, nil); err != nil { + return Application{}, err + } + if err = tx.Commit(ctx); err != nil { + return Application{}, err + } + return s.GetApplication(ctx, app.ID) +} + +func (s *Service) PublishApplication(ctx context.Context, id, changeNote, actorID string) (Application, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return Application{}, err + } + defer rollback(ctx, tx) + var raw []byte + if err = tx.QueryRow(ctx, `SELECT draft_config FROM gateway.applications WHERE id=$1 FOR UPDATE`, id).Scan(&raw); errors.Is(err, pgx.ErrNoRows) { + return Application{}, ErrNotFound + } else if err != nil { + return Application{}, err + } + var config ApplicationConfig + if err = json.Unmarshal(raw, &config); err != nil { + return Application{}, errors.New("应用草稿配置无效") + } + if err = normalizeApplicationConfig(&config, true); err != nil { + return Application{}, err + } + if err = s.validateApplicationRefs(ctx, config); err != nil { + return Application{}, err + } + raw, _ = json.Marshal(config) + var version int + if err = tx.QueryRow(ctx, `SELECT coalesce(max(version),0)+1 FROM gateway.application_versions WHERE application_id=$1`, id).Scan(&version); err != nil { + return Application{}, err + } + versionID, idErr := newUUID() + if idErr != nil { + return Application{}, idErr + } + if _, err = tx.Exec(ctx, `INSERT INTO gateway.application_versions(id,application_id,version,config,change_note,published_by) VALUES($1,$2,$3,$4,$5,$6)`, versionID, id, version, raw, strings.TrimSpace(changeNote), actorID); err != nil { + return Application{}, err + } + if _, err = tx.Exec(ctx, `UPDATE gateway.applications SET published_version=$2,status='active',revision=revision+1,updated_at=clock_timestamp() WHERE id=$1`, id, version); err != nil { + return Application{}, err + } + if err = emit(ctx, tx, "application.published", "application", id, actorID, map[string]any{"version": version}); err != nil { + return Application{}, err + } + if err = tx.Commit(ctx); err != nil { + return Application{}, err + } + return s.GetApplication(ctx, id) +} + +func (s *Service) DeleteApplication(ctx context.Context, id, actorID string) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer rollback(ctx, tx) + tag, err := tx.Exec(ctx, `DELETE FROM gateway.applications WHERE id=$1`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + if err = emit(ctx, tx, "application.deleted", "application", id, actorID, nil); err != nil { + return err + } + return tx.Commit(ctx) +} + +func (s *Service) ListApplicationVersions(ctx context.Context, id string) ([]map[string]any, error) { + rows, err := s.pool.Query(ctx, `SELECT id::text,version,config,change_note,created_at FROM gateway.application_versions WHERE application_id=$1 ORDER BY version DESC`, id) + if err != nil { + return nil, err + } + defer rows.Close() + items := []map[string]any{} + for rows.Next() { + var versionID, note string + var version int + var raw []byte + var created time.Time + if err = rows.Scan(&versionID, &version, &raw, ¬e, &created); err != nil { + return nil, err + } + var config ApplicationConfig + _ = json.Unmarshal(raw, &config) + items = append(items, map[string]any{"id": versionID, "version": version, "config": config, "change_note": note, "created_at": created}) + } + return items, rows.Err() +} + +func (s *Service) ListApplicationRuns(ctx context.Context, id string, limit int) ([]ApplicationRun, error) { + if limit < 1 { + limit = 50 + } + if limit > 200 { + limit = 200 + } + rows, err := s.pool.Query(ctx, `SELECT id::text,application_id::text,request_id,status,error,version,latency_ms,retrieval_count,tool_count,created_at FROM gateway.application_runs WHERE application_id=$1 ORDER BY created_at DESC LIMIT $2`, id, limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ApplicationRun{} + for rows.Next() { + var run ApplicationRun + if err = rows.Scan(&run.ID, &run.ApplicationID, &run.RequestID, &run.Status, &run.Error, &run.Version, &run.LatencyMS, &run.RetrievalCount, &run.ToolCount, &run.CreatedAt); err != nil { + return nil, err + } + items = append(items, run) + } + return items, rows.Err() +} + +func (s *Service) RollbackApplication(ctx context.Context, id string, version int, changeNote, actorID string) (Application, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return Application{}, err + } + defer rollback(ctx, tx) + var raw []byte + if err = tx.QueryRow(ctx, `SELECT config FROM gateway.application_versions WHERE application_id=$1 AND version=$2`, id, version).Scan(&raw); errors.Is(err, pgx.ErrNoRows) { + return Application{}, ErrNotFound + } else if err != nil { + return Application{}, err + } + var config ApplicationConfig + if err = json.Unmarshal(raw, &config); err != nil { + return Application{}, errors.New("目标版本配置无效") + } + if err = normalizeApplicationConfig(&config, true); err != nil { + return Application{}, err + } + if err = s.validateApplicationRefs(ctx, config); err != nil { + return Application{}, err + } + var next int + if err = tx.QueryRow(ctx, `SELECT coalesce(max(version),0)+1 FROM gateway.application_versions WHERE application_id=$1`, id).Scan(&next); err != nil { + return Application{}, err + } + versionID, err := newUUID() + if err != nil { + return Application{}, err + } + note := strings.TrimSpace(changeNote) + if note == "" { + note = fmt.Sprintf("回滚到 v%d", version) + } + if _, err = tx.Exec(ctx, `INSERT INTO gateway.application_versions(id,application_id,version,config,change_note,published_by) VALUES($1,$2,$3,$4,$5,$6)`, versionID, id, next, raw, note, actorID); err != nil { + return Application{}, err + } + if _, err = tx.Exec(ctx, `UPDATE gateway.applications SET draft_config=$2,published_version=$3,status='active',revision=revision+1,updated_at=clock_timestamp() WHERE id=$1`, id, raw, next); err != nil { + return Application{}, err + } + if err = emit(ctx, tx, "application.rolled_back", "application", id, actorID, map[string]any{"source_version": version, "published_version": next}); err != nil { + return Application{}, err + } + if err = tx.Commit(ctx); err != nil { + return Application{}, err + } + return s.GetApplication(ctx, id) +} + +func (s *Service) ApplicationDependencies(ctx context.Context, assetType, assetID string) ([]map[string]any, error) { + apps, err := s.ListApplications(ctx) + if err != nil { + return nil, err + } + items := []map[string]any{} + for _, app := range apps { + stages := []struct { + name string + config *ApplicationConfig + }{{"draft", &app.DraftConfig}, {"published", app.PublishedConfig}} + for _, stage := range stages { + if stage.config == nil { + continue + } + used := assetType == "prompt" && stage.config.PromptTemplateID == assetID + if assetType == "knowledge" { + for _, id := range stage.config.KnowledgeBaseIDs { + if id == assetID { + used = true + break + } + } + } + if assetType == "tool" { + for _, id := range stage.config.ToolIDs { + if id == assetID { + used = true + break + } + } + } + if used { + items = append(items, map[string]any{"application_id": app.ID, "code": app.Code, "name": app.Name, "stage": stage.name, "version": app.PublishedVersion}) + } + } + } + return items, nil +} diff --git a/internal/workbench/digital_employees.go b/internal/workbench/digital_employees.go new file mode 100644 index 0000000..1e64aee --- /dev/null +++ b/internal/workbench/digital_employees.go @@ -0,0 +1,378 @@ +package workbench + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5" +) + +type DigitalEmployee struct { + ID string `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + Description string `json:"description"` + Persona string `json:"persona"` + Model string `json:"model"` + SkillIDs []string `json:"skill_ids"` + ToolIDs []string `json:"tool_ids"` + MCPServerIDs []string `json:"mcp_server_ids"` + KnowledgeBaseIDs []string `json:"knowledge_base_ids"` + Temperature float64 `json:"temperature"` + RetrievalTopK int `json:"retrieval_top_k"` + MaxToolRounds int `json:"max_tool_rounds"` + Status string `json:"status"` + CategoryID *string `json:"category_id,omitempty"` + CategoryName string `json:"category_name"` + Tags []string `json:"tags"` + DepartmentIDs []string `json:"department_ids"` + Enabled bool `json:"enabled"` + Revision int64 `json:"revision"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type DigitalEmployeeInput struct { + Code string `json:"code"` + Name string `json:"name"` + Description string `json:"description"` + Persona string `json:"persona"` + Model string `json:"model"` + SkillIDs []string `json:"skill_ids"` + ToolIDs []string `json:"tool_ids"` + MCPServerIDs []string `json:"mcp_server_ids"` + KnowledgeBaseIDs []string `json:"knowledge_base_ids"` + Temperature float64 `json:"temperature"` + RetrievalTopK int `json:"retrieval_top_k"` + MaxToolRounds int `json:"max_tool_rounds"` + Status string `json:"status"` + CategoryID *string `json:"category_id,omitempty"` + Tags []string `json:"tags"` + DepartmentIDs []string `json:"department_ids"` + Enabled bool `json:"enabled"` +} + +type DigitalEmployeeRun struct { + ID string `json:"id"` + DigitalEmployee string `json:"digital_employee"` + RequestID string `json:"request_id"` + Status string `json:"status"` + Error string `json:"error"` + LatencyMS int64 `json:"latency_ms"` + RetrievalCount int `json:"retrieval_count"` + ToolCount int `json:"tool_count"` + CreatedAt time.Time `json:"created_at"` +} + +// DigitalEmployeeService manages composite digital employees. A digital +// employee bundles a persona, model, and bound skills / tools / MCP servers / +// knowledge bases; it is the end-user consumption point of the marketplace. +type DigitalEmployeeService struct { + assets *Service + skills *SkillService + tools *ToolService + mcpServers *MCPServerService +} + +func NewDigitalEmployeeService(assets *Service, skills *SkillService, tools *ToolService, mcpServers *MCPServerService) *DigitalEmployeeService { + return &DigitalEmployeeService{assets: assets, skills: skills, tools: tools, mcpServers: mcpServers} +} + +func (s *DigitalEmployeeService) validate(ctx context.Context, input *DigitalEmployeeInput, create bool) error { + input.Code = strings.ToLower(strings.TrimSpace(input.Code)) + input.Name = strings.TrimSpace(input.Name) + input.Description = strings.TrimSpace(input.Description) + input.Persona = strings.TrimSpace(input.Persona) + input.Model = strings.TrimSpace(input.Model) + if !codeRE.MatchString(input.Code) || input.Name == "" || len(input.Name) > 128 || len(input.Description) > 4000 || len(input.Persona) > 100000 { + return errors.New("数字员工编码、名称、描述或人设格式无效") + } + if input.Model == "" { + return errors.New("必须指定模型") + } + if input.Temperature == 0 { + input.Temperature = 0.7 + } + if input.Temperature < 0 || input.Temperature > 2 { + return errors.New("temperature 应在 0-2 之间") + } + if input.RetrievalTopK == 0 { + input.RetrievalTopK = 5 + } + if input.RetrievalTopK < 1 || input.RetrievalTopK > 50 { + return errors.New("retrieval_top_k 应在 1-50 之间") + } + if input.MaxToolRounds == 0 { + input.MaxToolRounds = 5 + } + if input.MaxToolRounds < 1 || input.MaxToolRounds > 20 { + return errors.New("max_tool_rounds 应在 1-20 之间") + } + switch input.Status { + case "", "draft": + input.Status = "draft" + case "published", "archived": + default: + return errors.New("无效的资源状态") + } + var err error + if input.SkillIDs, err = normalizeStrings(input.SkillIDs, 100); err != nil { + return err + } + if input.ToolIDs, err = normalizeStrings(input.ToolIDs, 100); err != nil { + return err + } + if input.MCPServerIDs, err = normalizeStrings(input.MCPServerIDs, 100); err != nil { + return err + } + if input.KnowledgeBaseIDs, err = normalizeStrings(input.KnowledgeBaseIDs, 100); err != nil { + return err + } + if input.CategoryID != nil && strings.TrimSpace(*input.CategoryID) == "" { + input.CategoryID = nil + } + var categoryErr error + input.CategoryID, categoryErr = validCategoryID(ctx, s.assets.pool, input.CategoryID, "digital_employee") + if categoryErr != nil { + return categoryErr + } + if input.Tags, err = normalizeStrings(input.Tags, 30); err != nil { + return err + } + if input.DepartmentIDs, err = normalizeStrings(input.DepartmentIDs, 100); err != nil { + return err + } + return s.validateBindings(ctx, input, false) +} + +// validateBindings ensures every bound asset exists (and, when strict, is +// enabled) so a published digital employee never references a missing resource. +func (s *DigitalEmployeeService) validateBindings(ctx context.Context, input *DigitalEmployeeInput, strict bool) error { + for _, id := range input.SkillIDs { + skill, err := s.skills.Get(ctx, id) + if err != nil { + return fmt.Errorf("绑定的 Skill %s 不存在", id) + } + if strict && !skill.Enabled { + return fmt.Errorf("绑定的 Skill %s 未启用", skill.Code) + } + } + for _, id := range input.ToolIDs { + tool, err := s.tools.Get(ctx, id) + if err != nil { + return fmt.Errorf("绑定的工具 %s 不存在", id) + } + if strict && !tool.Enabled { + return fmt.Errorf("绑定的工具 %s 未启用", tool.Code) + } + } + for _, id := range input.MCPServerIDs { + server, err := s.mcpServers.Get(ctx, id) + if err != nil { + return fmt.Errorf("绑定的 MCP 服务器 %s 不存在", id) + } + if strict && !server.Enabled { + return fmt.Errorf("绑定的 MCP 服务器 %s 未启用", server.Code) + } + } + for _, id := range input.KnowledgeBaseIDs { + var exists bool + if err := s.assets.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gateway.knowledge_bases WHERE id=$1)`, id).Scan(&exists); err != nil { + return err + } + if !exists { + return fmt.Errorf("绑定的知识库 %s 不存在", id) + } + if strict { + var enabled bool + if err := s.assets.pool.QueryRow(ctx, `SELECT enabled FROM gateway.knowledge_bases WHERE id=$1`, id).Scan(&enabled); err != nil { + return err + } + if !enabled { + return fmt.Errorf("绑定的知识库 %s 未启用", id) + } + } + } + return nil +} + +const digitalEmployeeSelect = `SELECT d.id::text,d.code,d.name,d.description,d.persona,d.model,d.skill_ids,d.tool_ids,d.mcp_server_ids,d.knowledge_base_ids,d.temperature,d.retrieval_top_k,d.max_tool_rounds,d.status,d.category_id::text,coalesce(c.name,''),d.tags,d.department_ids::text[],d.enabled,d.revision,d.created_at,d.updated_at FROM gateway.digital_employees d LEFT JOIN gateway.marketplace_categories c ON c.id=d.category_id` + +func scanDigitalEmployee(row pgx.Row) (DigitalEmployee, error) { + var d DigitalEmployee + err := row.Scan(&d.ID, &d.Code, &d.Name, &d.Description, &d.Persona, &d.Model, &d.SkillIDs, &d.ToolIDs, &d.MCPServerIDs, &d.KnowledgeBaseIDs, &d.Temperature, &d.RetrievalTopK, &d.MaxToolRounds, &d.Status, &d.CategoryID, &d.CategoryName, &d.Tags, &d.DepartmentIDs, &d.Enabled, &d.Revision, &d.CreatedAt, &d.UpdatedAt) + return d, mapNotFound(err) +} + +func (s *DigitalEmployeeService) List(ctx context.Context) ([]DigitalEmployee, error) { + rows, err := s.assets.pool.Query(ctx, digitalEmployeeSelect+` ORDER BY d.updated_at DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + items := []DigitalEmployee{} + for rows.Next() { + item, err := scanDigitalEmployee(rows) + if err != nil { + return nil, err + } + items = append(items, item) + } + return items, rows.Err() +} + +func (s *DigitalEmployeeService) Get(ctx context.Context, id string) (DigitalEmployee, error) { + return scanDigitalEmployee(s.assets.pool.QueryRow(ctx, digitalEmployeeSelect+` WHERE d.id=$1`, id)) +} + +func (s *DigitalEmployeeService) GetByCode(ctx context.Context, code string) (DigitalEmployee, error) { + return scanDigitalEmployee(s.assets.pool.QueryRow(ctx, digitalEmployeeSelect+` WHERE d.code=$1`, code)) +} + +// GetPublishedByCode returns a published, enabled digital employee by code. +func (s *DigitalEmployeeService) GetPublishedByCode(ctx context.Context, code string) (DigitalEmployee, error) { + return scanDigitalEmployee(s.assets.pool.QueryRow(ctx, digitalEmployeeSelect+` WHERE d.code=$1 AND d.status='published' AND d.enabled`, code)) +} + +func (s *DigitalEmployeeService) Save(ctx context.Context, id string, input DigitalEmployeeInput, actorID string, create bool) (DigitalEmployee, error) { + if err := s.validate(ctx, &input, create); err != nil { + return DigitalEmployee{}, err + } + tx, err := s.assets.pool.Begin(ctx) + if err != nil { + return DigitalEmployee{}, err + } + defer rollback(ctx, tx) + if create { + id, err = newUUID() + if err != nil { + return DigitalEmployee{}, err + } + _, err = tx.Exec(ctx, `INSERT INTO gateway.digital_employees(id,code,name,description,persona,model,skill_ids,tool_ids,mcp_server_ids,knowledge_base_ids,temperature,retrieval_top_k,max_tool_rounds,status,category_id,tags,department_ids,enabled,created_by) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19)`, id, input.Code, input.Name, input.Description, input.Persona, input.Model, input.SkillIDs, input.ToolIDs, input.MCPServerIDs, input.KnowledgeBaseIDs, input.Temperature, input.RetrievalTopK, input.MaxToolRounds, input.Status, input.CategoryID, input.Tags, input.DepartmentIDs, input.Enabled, actorID) + } else { + tag, updateErr := tx.Exec(ctx, `UPDATE gateway.digital_employees SET code=$2,name=$3,description=$4,persona=$5,model=$6,skill_ids=$7,tool_ids=$8,mcp_server_ids=$9,knowledge_base_ids=$10,temperature=$11,retrieval_top_k=$12,max_tool_rounds=$13,status=$14,category_id=$15,tags=$16,department_ids=$17,enabled=$18,revision=revision+1,updated_at=clock_timestamp() WHERE id=$1`, id, input.Code, input.Name, input.Description, input.Persona, input.Model, input.SkillIDs, input.ToolIDs, input.MCPServerIDs, input.KnowledgeBaseIDs, input.Temperature, input.RetrievalTopK, input.MaxToolRounds, input.Status, input.CategoryID, input.Tags, input.DepartmentIDs, input.Enabled) + err = updateErr + if err == nil && tag.RowsAffected() == 0 { + return DigitalEmployee{}, ErrNotFound + } + } + if err != nil { + return DigitalEmployee{}, err + } + event := "digital_employee.updated" + if create { + event = "digital_employee.created" + } + if err = emit(ctx, tx, event, "digital_employee", id, actorID, nil); err != nil { + return DigitalEmployee{}, err + } + if err = tx.Commit(ctx); err != nil { + return DigitalEmployee{}, err + } + return s.Get(ctx, id) +} + +// Publish revalidates all bindings (must exist and be enabled) and flips the +// status to published so the resource appears in the marketplace. +func (s *DigitalEmployeeService) Publish(ctx context.Context, id, actorID string) (DigitalEmployee, error) { + current, err := s.Get(ctx, id) + if err != nil { + return DigitalEmployee{}, err + } + input := DigitalEmployeeInput{ + Code: current.Code, Name: current.Name, Description: current.Description, + Persona: current.Persona, Model: current.Model, + SkillIDs: current.SkillIDs, ToolIDs: current.ToolIDs, MCPServerIDs: current.MCPServerIDs, + KnowledgeBaseIDs: current.KnowledgeBaseIDs, Temperature: current.Temperature, + RetrievalTopK: current.RetrievalTopK, MaxToolRounds: current.MaxToolRounds, + Status: "published", CategoryID: current.CategoryID, Tags: current.Tags, + DepartmentIDs: current.DepartmentIDs, Enabled: current.Enabled, + } + if err = s.validateBindings(ctx, &input, true); err != nil { + return DigitalEmployee{}, err + } + if err = s.setStatus(ctx, id, actorID, "published"); err != nil { + return DigitalEmployee{}, err + } + return s.Get(ctx, id) +} + +// Archive unpublishes a digital employee from the marketplace without deleting +// its configuration. +func (s *DigitalEmployeeService) Archive(ctx context.Context, id, actorID string) (DigitalEmployee, error) { + if err := s.setStatus(ctx, id, actorID, "archived"); err != nil { + return DigitalEmployee{}, err + } + return s.Get(ctx, id) +} + +func (s *DigitalEmployeeService) setStatus(ctx context.Context, id, actorID, status string) error { + tx, err := s.assets.pool.Begin(ctx) + if err != nil { + return err + } + defer rollback(ctx, tx) + tag, err := tx.Exec(ctx, `UPDATE gateway.digital_employees SET status=$2,revision=revision+1,updated_at=clock_timestamp() WHERE id=$1`, id, status) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + if err = emit(ctx, tx, "digital_employee."+status, "digital_employee", id, actorID, nil); err != nil { + return err + } + return tx.Commit(ctx) +} + +func (s *DigitalEmployeeService) Delete(ctx context.Context, id, actorID string) error { + tx, err := s.assets.pool.Begin(ctx) + if err != nil { + return err + } + defer rollback(ctx, tx) + var used bool + if err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gateway.marketplace_installations WHERE resource_type='digital_employee' AND resource_id=$1)`, id).Scan(&used); err != nil { + return err + } + if used { + return ErrConflict + } + tag, err := tx.Exec(ctx, `DELETE FROM gateway.digital_employees WHERE id=$1`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + if err = emit(ctx, tx, "digital_employee.deleted", "digital_employee", id, actorID, nil); err != nil { + return err + } + return tx.Commit(ctx) +} + +func (s *DigitalEmployeeService) ListRuns(ctx context.Context, id string, limit int) ([]DigitalEmployeeRun, error) { + if limit < 1 { + limit = 50 + } + if limit > 200 { + limit = 200 + } + rows, err := s.assets.pool.Query(ctx, `SELECT id::text,(SELECT code FROM gateway.digital_employees WHERE id=r.digital_employee_id),request_id,status,error,latency_ms,retrieval_count,tool_count,created_at FROM gateway.digital_employee_runs r WHERE digital_employee_id=$1 ORDER BY created_at DESC LIMIT $2`, id, limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []DigitalEmployeeRun{} + for rows.Next() { + var run DigitalEmployeeRun + if err = rows.Scan(&run.ID, &run.DigitalEmployee, &run.RequestID, &run.Status, &run.Error, &run.LatencyMS, &run.RetrievalCount, &run.ToolCount, &run.CreatedAt); err != nil { + return nil, err + } + items = append(items, run) + } + return items, rows.Err() +} diff --git a/internal/workbench/knowledge.go b/internal/workbench/knowledge.go new file mode 100644 index 0000000..e566e32 --- /dev/null +++ b/internal/workbench/knowledge.go @@ -0,0 +1,448 @@ +package workbench + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "regexp" + "sort" + "strings" + "unicode" + "unicode/utf8" + + "github.com/jackc/pgx/v5" +) + +type Retriever interface { + Search(context.Context, string, string, int) ([]SearchHit, error) +} + +type PostgreSQLRetriever struct { + pool interface { + Query(context.Context, string, ...any) (pgx.Rows, error) + } +} + +func NewPostgreSQLRetriever(service *Service) *PostgreSQLRetriever { + return &PostgreSQLRetriever{pool: service.pool} +} + +func (r *PostgreSQLRetriever) Search(ctx context.Context, knowledgeBaseID, query string, topK int) ([]SearchHit, error) { + query = strings.TrimSpace(query) + if query == "" { + return nil, errors.New("检索词不能为空") + } + if len(query) > 6000 { + return nil, errors.New("检索词过长") + } + if topK < 1 { + topK = 4 + } + if topK > 20 { + topK = 20 + } + tokens := searchTokens(query) + rows, err := r.pool.Query(ctx, `WITH q AS (SELECT plainto_tsquery('simple',$2) AS tsq,lower($2) AS raw), tokens AS (SELECT unnest($4::text[]) AS token) +SELECT c.id::text,c.document_id::text,d.title,c.chunk_index,c.content, +greatest(ts_rank_cd(c.search_vector,q.tsq),CASE WHEN strpos(lower(c.content),q.raw)>0 THEN 1.0 ELSE 0.0 END, + (SELECT count(*)::float8/greatest(array_length($4::text[],1),1) FROM tokens WHERE strpos(lower(c.content),token)>0))::float8 AS score +FROM gateway.knowledge_chunks c JOIN gateway.knowledge_documents d ON d.id=c.document_id JOIN gateway.knowledge_bases k ON k.id=c.knowledge_base_id CROSS JOIN q +WHERE c.knowledge_base_id=$1 AND k.enabled AND d.status='ready' AND (c.search_vector @@ q.tsq OR strpos(lower(c.content),q.raw)>0 OR EXISTS(SELECT 1 FROM tokens WHERE strpos(lower(c.content),token)>0)) +ORDER BY score DESC,c.document_id,c.chunk_index LIMIT $3`, knowledgeBaseID, query, topK, tokens) + if err != nil { + return nil, err + } + defer rows.Close() + hits := []SearchHit{} + for rows.Next() { + var h SearchHit + if err = rows.Scan(&h.ChunkID, &h.DocumentID, &h.DocumentTitle, &h.ChunkIndex, &h.Content, &h.Score); err != nil { + return nil, err + } + hits = append(hits, h) + } + return hits, rows.Err() +} + +func searchTokens(query string) []string { + query = strings.ToLower(strings.TrimSpace(query)) + seen := map[string]struct{}{} + tokens := make([]string, 0, 32) + add := func(value string) { + value = strings.TrimSpace(value) + if utf8.RuneCountInString(value) < 2 { + return + } + if _, ok := seen[value]; ok { + return + } + seen[value] = struct{}{} + tokens = append(tokens, value) + } + words := strings.FieldsFunc(query, func(value rune) bool { return !unicode.IsLetter(value) && !unicode.IsNumber(value) }) + for _, word := range words { + runes := []rune(word) + hasCJK := false + for _, value := range runes { + if unicode.In(value, unicode.Han) { + hasCJK = true + break + } + } + if !hasCJK { + add(word) + continue + } + for index := 0; index+1 < len(runes); index++ { + add(string(runes[index : index+2])) + } + } + if len(tokens) > 64 { + tokens = tokens[:64] + } + sort.Strings(tokens) + return tokens +} + +func validateKnowledgeBase(k *KnowledgeBase) error { + k.Name = strings.TrimSpace(k.Name) + k.Description = strings.TrimSpace(k.Description) + if k.Name == "" || len(k.Name) > 128 || len(k.Description) > 4000 { + return errors.New("知识库名称或描述格式无效") + } + if k.RetrievalMode == "" { + k.RetrievalMode = "postgres_fts" + } + if k.RetrievalMode != "postgres_fts" { + return errors.New("基线仅支持 postgres_fts 检索器") + } + if k.ChunkSize == 0 { + k.ChunkSize = 800 + } + if k.ChunkSize < 200 || k.ChunkSize > 8000 { + return errors.New("chunk_size 应在 200-8000 之间") + } + if k.ChunkOverlap < 0 || k.ChunkOverlap > k.ChunkSize/2 { + return errors.New("chunk_overlap 应在 0 到 chunk_size 一半之间") + } + var err error + k.DepartmentIDs, err = normalizeStrings(k.DepartmentIDs, 100) + return err +} + +const knowledgeBaseSelect = `SELECT k.id::text,k.name,k.description,k.retrieval_mode,k.chunk_size,k.chunk_overlap,k.department_ids::text[],k.enabled,k.revision, +count(DISTINCT d.id)::int,count(c.id)::int,k.created_at,k.updated_at +FROM gateway.knowledge_bases k LEFT JOIN gateway.knowledge_documents d ON d.knowledge_base_id=k.id LEFT JOIN gateway.knowledge_chunks c ON c.document_id=d.id` + +func scanKnowledgeBase(row pgx.Row) (KnowledgeBase, error) { + var k KnowledgeBase + err := row.Scan(&k.ID, &k.Name, &k.Description, &k.RetrievalMode, &k.ChunkSize, &k.ChunkOverlap, &k.DepartmentIDs, &k.Enabled, &k.Revision, &k.DocumentCount, &k.ChunkCount, &k.CreatedAt, &k.UpdatedAt) + return k, mapNotFound(err) +} + +func (s *Service) ListKnowledgeBases(ctx context.Context) ([]KnowledgeBase, error) { + rows, err := s.pool.Query(ctx, knowledgeBaseSelect+` GROUP BY k.id ORDER BY k.updated_at DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + items := []KnowledgeBase{} + for rows.Next() { + k, err := scanKnowledgeBase(rows) + if err != nil { + return nil, err + } + items = append(items, k) + } + return items, rows.Err() +} +func (s *Service) GetKnowledgeBase(ctx context.Context, id string) (KnowledgeBase, error) { + return scanKnowledgeBase(s.pool.QueryRow(ctx, knowledgeBaseSelect+` WHERE k.id=$1 GROUP BY k.id`, id)) +} + +func (s *Service) SaveKnowledgeBase(ctx context.Context, k KnowledgeBase, actorID string, create bool) (KnowledgeBase, error) { + if err := validateKnowledgeBase(&k); err != nil { + return KnowledgeBase{}, err + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return KnowledgeBase{}, err + } + defer rollback(ctx, tx) + if create { + k.ID, err = newUUID() + if err != nil { + return KnowledgeBase{}, err + } + _, err = tx.Exec(ctx, `INSERT INTO gateway.knowledge_bases(id,name,description,retrieval_mode,chunk_size,chunk_overlap,department_ids,enabled,created_by) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)`, k.ID, k.Name, k.Description, k.RetrievalMode, k.ChunkSize, k.ChunkOverlap, k.DepartmentIDs, k.Enabled, actorID) + } else { + tag, updateErr := tx.Exec(ctx, `UPDATE gateway.knowledge_bases SET name=$2,description=$3,retrieval_mode=$4,chunk_size=$5,chunk_overlap=$6,department_ids=$7,enabled=$8,revision=revision+1,updated_at=clock_timestamp() WHERE id=$1`, k.ID, k.Name, k.Description, k.RetrievalMode, k.ChunkSize, k.ChunkOverlap, k.DepartmentIDs, k.Enabled) + err = updateErr + if err == nil && tag.RowsAffected() == 0 { + return KnowledgeBase{}, ErrNotFound + } + } + if err != nil { + return KnowledgeBase{}, err + } + event := "knowledge_base.updated" + if create { + event = "knowledge_base.created" + } + if err = emit(ctx, tx, event, "knowledge_base", k.ID, actorID, nil); err != nil { + return KnowledgeBase{}, err + } + if err = tx.Commit(ctx); err != nil { + return KnowledgeBase{}, err + } + return s.GetKnowledgeBase(ctx, k.ID) +} + +func (s *Service) DeleteKnowledgeBase(ctx context.Context, id, actorID string) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer rollback(ctx, tx) + var used bool + if err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gateway.applications WHERE draft_config->'knowledge_base_ids' ? $1 UNION ALL SELECT 1 FROM gateway.application_versions WHERE config->'knowledge_base_ids' ? $1)`, id).Scan(&used); err != nil { + return err + } + if used { + return ErrConflict + } + tag, err := tx.Exec(ctx, `DELETE FROM gateway.knowledge_bases WHERE id=$1`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + if err = emit(ctx, tx, "knowledge_base.deleted", "knowledge_base", id, actorID, nil); err != nil { + return err + } + return tx.Commit(ctx) +} + +func ChunkText(text string, size, overlap int) []string { + if size < 200 { + size = 800 + } + if overlap < 0 { + overlap = 0 + } + if overlap > size/2 { + overlap = size / 2 + } + paragraphs := regexp.MustCompile(`\n\s*\n`).Split(strings.TrimSpace(text), -1) + chunks := []string{} + current := []rune{} + appendCurrent := func() { + value := strings.TrimSpace(string(current)) + if value != "" { + chunks = append(chunks, value) + } + current = nil + } + for _, paragraph := range paragraphs { + runes := []rune(strings.TrimSpace(paragraph)) + if len(runes) == 0 { + continue + } + if len(runes) > size { + appendCurrent() + step := size - overlap + if step < 1 { + step = 1 + } + for start := 0; start < len(runes); start += step { + end := start + size + if end > len(runes) { + end = len(runes) + } + chunks = append(chunks, string(runes[start:end])) + if end == len(runes) { + break + } + } + continue + } + separator := 0 + if len(current) > 0 { + separator = 2 + } + if len(current)+separator+len(runes) <= size { + if separator > 0 { + current = append(current, '\n', '\n') + } + current = append(current, runes...) + continue + } + previous := append([]rune(nil), current...) + appendCurrent() + if overlap > 0 && len(previous) > 0 { + start := len(previous) - overlap + if start < 0 { + start = 0 + } + current = append(current, previous[start:]...) + current = append(current, '\n', '\n') + } + current = append(current, runes...) + } + appendCurrent() + return chunks +} + +func (s *Service) AddKnowledgeDocument(ctx context.Context, kbID, title, sourceType, sourceURI, content, actorID string) (KnowledgeDocument, error) { + title = strings.TrimSpace(title) + sourceType = strings.TrimSpace(sourceType) + sourceURI = strings.TrimSpace(sourceURI) + content = strings.TrimSpace(content) + if title == "" || len(title) > 256 { + return KnowledgeDocument{}, errors.New("文档标题格式无效") + } + if sourceType == "" { + sourceType = "text" + } + if sourceType != "text" && sourceType != "url" && sourceType != "import" { + return KnowledgeDocument{}, errors.New("source_type 无效") + } + if content == "" || len([]byte(content)) > 2<<20 { + return KnowledgeDocument{}, errors.New("文档正文不能为空且最多 2 MiB") + } + kb, err := s.GetKnowledgeBase(ctx, kbID) + if err != nil { + return KnowledgeDocument{}, err + } + chunks := ChunkText(content, kb.ChunkSize, kb.ChunkOverlap) + if len(chunks) == 0 { + return KnowledgeDocument{}, errors.New("文档没有可入库内容") + } + digest := sha256.Sum256([]byte(content)) + hash := hex.EncodeToString(digest[:]) + docID, err := newUUID() + if err != nil { + return KnowledgeDocument{}, err + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return KnowledgeDocument{}, err + } + defer rollback(ctx, tx) + var doc KnowledgeDocument + err = tx.QueryRow(ctx, `INSERT INTO gateway.knowledge_documents(id,knowledge_base_id,title,source_type,source_uri,content,content_sha256,chunk_count,created_by) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id::text,knowledge_base_id::text,title,source_type,source_uri,content_sha256,status,status_message,char_length(content),chunk_count,created_at,updated_at`, docID, kbID, title, sourceType, sourceURI, content, hash, len(chunks), actorID).Scan(&doc.ID, &doc.KnowledgeBaseID, &doc.Title, &doc.SourceType, &doc.SourceURI, &doc.ContentSHA256, &doc.Status, &doc.StatusMessage, &doc.CharCount, &doc.ChunkCount, &doc.CreatedAt, &doc.UpdatedAt) + if err != nil { + return KnowledgeDocument{}, err + } + chunkRows := make([][]any, 0, len(chunks)) + for index, chunk := range chunks { + chunkID, idErr := newUUID() + if idErr != nil { + return KnowledgeDocument{}, idErr + } + chunkRows = append(chunkRows, []any{chunkID, kbID, docID, index, chunk}) + } + // Bulk-copy all chunks in one statement instead of one INSERT per chunk; + // a 2 MiB document can split into thousands of chunks. + if _, err = tx.CopyFrom(ctx, pgx.Identifier{"gateway", "knowledge_chunks"}, []string{"id", "knowledge_base_id", "document_id", "chunk_index", "content"}, pgx.CopyFromRows(chunkRows)); err != nil { + return KnowledgeDocument{}, err + } + if err = emit(ctx, tx, "knowledge_document.ready", "knowledge_document", docID, actorID, map[string]any{"knowledge_base_id": kbID, "chunk_count": len(chunks)}); err != nil { + return KnowledgeDocument{}, err + } + if err = tx.Commit(ctx); err != nil { + return KnowledgeDocument{}, err + } + return doc, nil +} + +func (s *Service) ListKnowledgeDocuments(ctx context.Context, kbID string) ([]KnowledgeDocument, error) { + rows, err := s.pool.Query(ctx, `SELECT id::text,knowledge_base_id::text,title,source_type,source_uri,content_sha256,status,status_message,char_length(content),chunk_count,created_at,updated_at FROM gateway.knowledge_documents WHERE knowledge_base_id=$1 ORDER BY created_at DESC`, kbID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []KnowledgeDocument{} + for rows.Next() { + var d KnowledgeDocument + if err = rows.Scan(&d.ID, &d.KnowledgeBaseID, &d.Title, &d.SourceType, &d.SourceURI, &d.ContentSHA256, &d.Status, &d.StatusMessage, &d.CharCount, &d.ChunkCount, &d.CreatedAt, &d.UpdatedAt); err != nil { + return nil, err + } + items = append(items, d) + } + return items, rows.Err() +} + +func (s *Service) DeleteKnowledgeDocument(ctx context.Context, kbID, id, actorID string) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer rollback(ctx, tx) + tag, err := tx.Exec(ctx, `DELETE FROM gateway.knowledge_documents WHERE id=$1 AND knowledge_base_id=$2`, id, kbID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + if err = emit(ctx, tx, "knowledge_document.deleted", "knowledge_document", id, actorID, map[string]any{"knowledge_base_id": kbID}); err != nil { + return err + } + return tx.Commit(ctx) +} + +func (s *Service) ReprocessKnowledgeDocument(ctx context.Context, kbID, id, actorID string) (KnowledgeDocument, error) { + kb, err := s.GetKnowledgeBase(ctx, kbID) + if err != nil { + return KnowledgeDocument{}, err + } + var content string + var doc KnowledgeDocument + err = s.pool.QueryRow(ctx, `SELECT id::text,knowledge_base_id::text,title,source_type,source_uri,content,content_sha256,status,status_message,char_length(content),chunk_count,created_at,updated_at FROM gateway.knowledge_documents WHERE id=$1 AND knowledge_base_id=$2`, id, kbID).Scan(&doc.ID, &doc.KnowledgeBaseID, &doc.Title, &doc.SourceType, &doc.SourceURI, &content, &doc.ContentSHA256, &doc.Status, &doc.StatusMessage, &doc.CharCount, &doc.ChunkCount, &doc.CreatedAt, &doc.UpdatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return KnowledgeDocument{}, ErrNotFound + } + if err != nil { + return KnowledgeDocument{}, err + } + chunks := ChunkText(content, kb.ChunkSize, kb.ChunkOverlap) + if len(chunks) == 0 { + return KnowledgeDocument{}, errors.New("文档没有可入库内容") + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return KnowledgeDocument{}, err + } + defer rollback(ctx, tx) + if _, err = tx.Exec(ctx, `DELETE FROM gateway.knowledge_chunks WHERE document_id=$1`, id); err != nil { + return KnowledgeDocument{}, err + } + for index, chunk := range chunks { + chunkID, idErr := newUUID() + if idErr != nil { + return KnowledgeDocument{}, idErr + } + if _, err = tx.Exec(ctx, `INSERT INTO gateway.knowledge_chunks(id,knowledge_base_id,document_id,chunk_index,content) VALUES($1,$2,$3,$4,$5)`, chunkID, kbID, id, index, chunk); err != nil { + return KnowledgeDocument{}, err + } + } + err = tx.QueryRow(ctx, `UPDATE gateway.knowledge_documents SET status='ready',status_message='',chunk_count=$3,updated_at=clock_timestamp() WHERE id=$1 AND knowledge_base_id=$2 RETURNING updated_at`, id, kbID, len(chunks)).Scan(&doc.UpdatedAt) + if err != nil { + return KnowledgeDocument{}, err + } + doc.Status, doc.StatusMessage, doc.ChunkCount = "ready", "", len(chunks) + if err = emit(ctx, tx, "knowledge_document.reprocessed", "knowledge_document", id, actorID, map[string]any{"knowledge_base_id": kbID, "chunk_count": len(chunks)}); err != nil { + return KnowledgeDocument{}, err + } + if err = tx.Commit(ctx); err != nil { + return KnowledgeDocument{}, err + } + return doc, nil +} + +func RuneCount(value string) int { return utf8.RuneCountInString(value) } + +var _ Retriever = (*PostgreSQLRetriever)(nil) diff --git a/internal/workbench/marketplace.go b/internal/workbench/marketplace.go new file mode 100644 index 0000000..3375ee0 --- /dev/null +++ b/internal/workbench/marketplace.go @@ -0,0 +1,444 @@ +package workbench + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// MarketItem is the lightweight unified catalog row for a published resource, +// regardless of which of the three resource tables it lives in. +type MarketItem struct { + Type string `json:"type"` + Code string `json:"code"` + Name string `json:"name"` + Description string `json:"description"` + CategoryID *string `json:"category_id,omitempty"` + CategoryName string `json:"category_name"` + Tags []string `json:"tags"` + DepartmentIDs []string `json:"department_ids"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Category is a shared marketplace category, optionally scoped to one resource +// type (empty resource_type = global). +type Category struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + ResourceType string `json:"resource_type"` + SortOrder int `json:"sort_order"` + CreatedAt time.Time `json:"created_at"` +} + +// MarketplaceService provides the shared cross-resource surface: categories, +// the unified catalog, and portal workspace installations. +type MarketplaceService struct { + assets *Service + mcpServers *MCPServerService + skills *SkillService + employees *DigitalEmployeeService +} + +func NewMarketplaceService(assets *Service, mcpServers *MCPServerService, skills *SkillService, employees *DigitalEmployeeService) *MarketplaceService { + return &MarketplaceService{assets: assets, mcpServers: mcpServers, skills: skills, employees: employees} +} + +// --- 分类 --- + +func (s *MarketplaceService) ListCategories(ctx context.Context, resourceType string) ([]Category, error) { + rows, err := s.assets.pool.Query(ctx, `SELECT id::text,name,description,resource_type,sort_order,created_at FROM gateway.marketplace_categories WHERE resource_type=$1 OR resource_type='' ORDER BY sort_order,created_at`, resourceType) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Category{} + for rows.Next() { + var c Category + if err = rows.Scan(&c.ID, &c.Name, &c.Description, &c.ResourceType, &c.SortOrder, &c.CreatedAt); err != nil { + return nil, err + } + items = append(items, c) + } + return items, rows.Err() +} + +func (s *MarketplaceService) CreateCategory(ctx context.Context, input Category, actorID string) (Category, error) { + input.Name = strings.TrimSpace(input.Name) + input.Description = strings.TrimSpace(input.Description) + if input.Name == "" || len(input.Name) > 64 || len(input.Description) > 1000 { + return Category{}, errors.New("分类名称或描述格式无效") + } + switch input.ResourceType { + case "", "mcp_server", "skill", "digital_employee": + default: + return Category{}, errors.New("无效的分类资源类型") + } + if input.SortOrder < 0 { + input.SortOrder = 0 + } + id, err := newUUID() + if err != nil { + return Category{}, err + } + tx, err := s.assets.pool.Begin(ctx) + if err != nil { + return Category{}, err + } + defer rollback(ctx, tx) + _, err = tx.Exec(ctx, `INSERT INTO gateway.marketplace_categories(id,name,description,resource_type,sort_order,created_by) VALUES($1,$2,$3,$4,$5,$6)`, id, input.Name, input.Description, input.ResourceType, input.SortOrder, actorID) + if err != nil { + if strings.Contains(err.Error(), "duplicate") { + return Category{}, ErrConflict + } + return Category{}, err + } + if err = emit(ctx, tx, "marketplace_category.created", "marketplace_category", id, actorID, nil); err != nil { + return Category{}, err + } + if err = tx.Commit(ctx); err != nil { + return Category{}, err + } + input.ID = id + input.CreatedAt = time.Now() + return input, nil +} + +func (s *MarketplaceService) DeleteCategory(ctx context.Context, id, actorID string) error { + tx, err := s.assets.pool.Begin(ctx) + if err != nil { + return err + } + defer rollback(ctx, tx) + tag, err := tx.Exec(ctx, `DELETE FROM gateway.marketplace_categories WHERE id=$1`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + if err = emit(ctx, tx, "marketplace_category.deleted", "marketplace_category", id, actorID, nil); err != nil { + return err + } + return tx.Commit(ctx) +} + +// validCategoryID resolves and verifies an optional category reference. A nil +// or blank id yields a nil category; a non-empty id must exist and match the +// resource type scope (global categories apply to every type). +func validCategoryID(ctx context.Context, pool *pgxpool.Pool, id *string, resourceType string) (*string, error) { + if id == nil || strings.TrimSpace(*id) == "" { + return nil, nil + } + var exists bool + err := pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gateway.marketplace_categories WHERE id=$1 AND (resource_type='' OR resource_type=$2))`, *id, resourceType).Scan(&exists) + if err != nil { + return nil, err + } + if !exists { + return nil, errors.New("分类不存在或与资源类型不匹配") + } + return id, nil +} + +// --- 统一目录 --- + +// Catalog returns published, enabled resources across all three types, merged +// and sorted by update time. Filters are optional and compose with AND. +func (s *MarketplaceService) Catalog(ctx context.Context, resourceType, categoryID, tag, query string, limit int) ([]MarketItem, error) { + if limit < 1 { + limit = 100 + } + if limit > 200 { + limit = 200 + } + items := make([]MarketItem, 0, limit) + types := []string{"mcp_server", "skill", "digital_employee"} + if resourceType != "" { + types = []string{resourceType} + } + for _, typ := range types { + // Filters are built per table alias so name/description don't clash + // with the LEFT JOINed category columns. + where, args := marketFilters(aliasFor(typ), categoryID, tag, query) + if err := s.catalogTable(ctx, typ, where, args, limit, &items); err != nil { + return nil, err + } + } + sort.SliceStable(items, func(i, j int) bool { return items[i].UpdatedAt.After(items[j].UpdatedAt) }) + if len(items) > limit { + items = items[:limit] + } + return items, nil +} + +func aliasFor(resourceType string) string { + switch resourceType { + case "mcp_server": + return "m" + case "skill": + return "s" + default: + return "d" + } +} + +func marketFilters(alias, categoryID, tag, query string) (string, []any) { + clauses := []string{} + args := []any{} + if categoryID != "" { + args = append(args, categoryID) + clauses = append(clauses, fmt.Sprintf("%s.category_id=$%d", alias, len(args))) + } + if tag != "" { + args = append(args, tag) + clauses = append(clauses, fmt.Sprintf("$%d = ANY(%s.tags)", len(args), alias)) + } + if query != "" { + args = append(args, "%"+query+"%") + clauses = append(clauses, fmt.Sprintf("(%s.name ILIKE $%d OR %s.code ILIKE $%d OR %s.description ILIKE $%d)", alias, len(args), alias, len(args), alias, len(args))) + } + where := "status='published' AND enabled" + if len(clauses) > 0 { + where += " AND " + strings.Join(clauses, " AND ") + } + return where, args +} + +func (s *MarketplaceService) catalogTable(ctx context.Context, typ, where string, args []any, limit int, out *[]MarketItem) error { + // Filters occupy $1..$N in the WHERE clause; the LIMIT placeholder must be + // the next position (N+1), not a hardcoded $1 which would collide with a + // category filter arg and land a uuid in LIMIT. + limitParam := fmt.Sprintf("$%d", len(args)+1) + var query string + switch typ { + case "mcp_server": + query = `SELECT 'mcp_server',m.code,m.name,m.description,m.category_id::text,coalesce(c.name,''),m.tags,m.department_ids::text[],m.updated_at FROM gateway.mcp_servers m LEFT JOIN gateway.marketplace_categories c ON c.id=m.category_id WHERE ` + where + ` ORDER BY m.updated_at DESC LIMIT ` + limitParam + case "skill": + query = `SELECT 'skill',s.code,s.name,s.description,s.category_id::text,coalesce(c.name,''),s.tags,s.department_ids::text[],s.updated_at FROM gateway.skills s LEFT JOIN gateway.marketplace_categories c ON c.id=s.category_id WHERE ` + where + ` ORDER BY s.updated_at DESC LIMIT ` + limitParam + case "digital_employee": + query = `SELECT 'digital_employee',d.code,d.name,d.description,d.category_id::text,coalesce(c.name,''),d.tags,d.department_ids::text[],d.updated_at FROM gateway.digital_employees d LEFT JOIN gateway.marketplace_categories c ON c.id=d.category_id WHERE ` + where + ` ORDER BY d.updated_at DESC LIMIT ` + limitParam + default: + return errors.New("未知的资源类型") + } + queryArgs := append(append([]any{}, args...), limit) + rows, err := s.assets.pool.Query(ctx, query, queryArgs...) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var item MarketItem + if err = rows.Scan(&item.Type, &item.Code, &item.Name, &item.Description, &item.CategoryID, &item.CategoryName, &item.Tags, &item.DepartmentIDs, &item.UpdatedAt); err != nil { + return err + } + *out = append(*out, item) + } + return rows.Err() +} + +// Detail returns the full resource payload for a published item by type+code. +// MCP servers are returned without their encrypted headers; the consumer calls +// a dedicated endpoint to test connectivity. +func (s *MarketplaceService) Detail(ctx context.Context, resourceType, code string) (MarketItem, json.RawMessage, error) { + var item MarketItem + var detail any + var err error + switch resourceType { + case "mcp_server": + server, getErr := s.mcpServers.GetPublishedByCode(ctx, code) + if getErr != nil { + return item, nil, getErr + } + item = MarketItem{Type: "mcp_server", Code: server.Code, Name: server.Name, Description: server.Description, CategoryID: server.CategoryID, CategoryName: server.CategoryName, Tags: server.Tags, DepartmentIDs: server.DepartmentIDs, UpdatedAt: server.UpdatedAt} + detail = server + case "skill": + skill, getErr := s.skills.GetPublishedByCode(ctx, code) + if getErr != nil { + return item, nil, getErr + } + item = MarketItem{Type: "skill", Code: skill.Code, Name: skill.Name, Description: skill.Description, CategoryID: skill.CategoryID, CategoryName: skill.CategoryName, Tags: skill.Tags, DepartmentIDs: skill.DepartmentIDs, UpdatedAt: skill.UpdatedAt} + detail = skill + case "digital_employee": + employee, getErr := s.employees.GetPublishedByCode(ctx, code) + if getErr != nil { + return item, nil, getErr + } + item = MarketItem{Type: "digital_employee", Code: employee.Code, Name: employee.Name, Description: employee.Description, CategoryID: employee.CategoryID, CategoryName: employee.CategoryName, Tags: employee.Tags, DepartmentIDs: employee.DepartmentIDs, UpdatedAt: employee.UpdatedAt} + detail = employee + default: + return item, nil, errors.New("未知的资源类型") + } + raw, err := json.Marshal(detail) + if err != nil { + return item, nil, err + } + return item, raw, nil +} + +// --- 安装(工作区绑定 + 权限) --- + +// Install records a portal user's workspace binding to a published resource. +// It is the permission grant that lets a cross-department user invoke a +// resource that would otherwise be invisible to them. +func (s *MarketplaceService) Install(ctx context.Context, resourceType, code, portalUserID string) (bool, error) { + resourceID, err := s.publishedResourceID(ctx, resourceType, code) + if err != nil { + return false, err + } + id, err := newUUID() + if err != nil { + return false, err + } + tx, err := s.assets.pool.Begin(ctx) + if err != nil { + return false, err + } + defer rollback(ctx, tx) + tag, err := tx.Exec(ctx, `INSERT INTO gateway.marketplace_installations(id,resource_type,resource_id,portal_user_id) VALUES($1,$2,$3,$4) ON CONFLICT(resource_type,resource_id,portal_user_id) DO NOTHING`, id, resourceType, resourceID, portalUserID) + if err != nil { + return false, err + } + created := tag.RowsAffected() == 1 + // Idempotent re-install: only emit the audit event when a row was created. + if created { + if err = emit(ctx, tx, "marketplace.installed", resourceType, resourceID, "", map[string]any{"code": code, "portal_user_id": portalUserID}); err != nil { + return false, err + } + } + if err = tx.Commit(ctx); err != nil { + return false, err + } + return created, nil +} + +func (s *MarketplaceService) Uninstall(ctx context.Context, resourceType, code, portalUserID string) error { + resourceID, err := s.publishedResourceID(ctx, resourceType, code) + if err != nil { + return err + } + tx, err := s.assets.pool.Begin(ctx) + if err != nil { + return err + } + defer rollback(ctx, tx) + tag, err := tx.Exec(ctx, `DELETE FROM gateway.marketplace_installations WHERE resource_type=$1 AND resource_id=$2 AND portal_user_id=$3`, resourceType, resourceID, portalUserID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + if err = emit(ctx, tx, "marketplace.uninstalled", resourceType, resourceID, "", map[string]any{"code": code, "portal_user_id": portalUserID}); err != nil { + return err + } + return tx.Commit(ctx) +} + +// ListInstalled returns the codes the portal user has installed, grouped by +// resource type, joined with the live resource name for display. +func (s *MarketplaceService) ListInstalled(ctx context.Context, portalUserID string) ([]MarketItem, error) { + rows, err := s.assets.pool.Query(ctx, `SELECT resource_type,resource_id::text FROM gateway.marketplace_installations WHERE portal_user_id=$1 ORDER BY created_at DESC`, portalUserID) + if err != nil { + return nil, err + } + type bound struct{ typ, id string } + bounds := []bound{} + for rows.Next() { + var b bound + if err = rows.Scan(&b.typ, &b.id); err != nil { + rows.Close() + return nil, err + } + bounds = append(bounds, b) + } + rows.Close() + if err = rows.Err(); err != nil { + return nil, err + } + items := make([]MarketItem, 0, len(bounds)) + for _, b := range bounds { + item, ok, err := s.resourceByID(ctx, b.typ, b.id) + if err != nil { + return nil, err + } + if ok { + items = append(items, item) + } + } + return items, nil +} + +// Installed reports whether a portal user has an installation for the resource. +func (s *MarketplaceService) Installed(ctx context.Context, resourceType, resourceID, portalUserID string) (bool, error) { + var exists bool + err := s.assets.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gateway.marketplace_installations WHERE resource_type=$1 AND resource_id=$2 AND portal_user_id=$3)`, resourceType, resourceID, portalUserID).Scan(&exists) + return exists, err +} + +// PortalUserForAPIKey resolves the portal user that owns an API key, used to +// evaluate installation-based visibility at runtime. +func (s *MarketplaceService) PortalUserForAPIKey(ctx context.Context, apiKeyID string) (string, bool, error) { + var userID *string + err := s.assets.pool.QueryRow(ctx, `SELECT portal_user_id::text FROM gateway.api_keys WHERE id=$1`, apiKeyID).Scan(&userID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return "", false, nil + } + return "", false, err + } + if userID == nil || *userID == "" { + return "", false, nil + } + return *userID, true, nil +} + +func (s *MarketplaceService) publishedResourceID(ctx context.Context, resourceType, code string) (string, error) { + var id string + var err error + switch resourceType { + case "mcp_server": + err = s.assets.pool.QueryRow(ctx, `SELECT id::text FROM gateway.mcp_servers WHERE code=$1 AND status='published' AND enabled`, code).Scan(&id) + case "skill": + err = s.assets.pool.QueryRow(ctx, `SELECT id::text FROM gateway.skills WHERE code=$1 AND status='published' AND enabled`, code).Scan(&id) + case "digital_employee": + err = s.assets.pool.QueryRow(ctx, `SELECT id::text FROM gateway.digital_employees WHERE code=$1 AND status='published' AND enabled`, code).Scan(&id) + default: + return "", errors.New("未知的资源类型") + } + return id, mapNotFound(err) +} + +func (s *MarketplaceService) resourceByID(ctx context.Context, resourceType, id string) (MarketItem, bool, error) { + var item MarketItem + switch resourceType { + case "mcp_server": + server, err := s.mcpServers.Get(ctx, id) + if err != nil { + return item, false, nil + } + item = MarketItem{Type: "mcp_server", Code: server.Code, Name: server.Name, Description: server.Description, CategoryID: server.CategoryID, CategoryName: server.CategoryName, Tags: server.Tags, UpdatedAt: server.UpdatedAt} + case "skill": + skill, err := s.skills.Get(ctx, id) + if err != nil { + return item, false, nil + } + item = MarketItem{Type: "skill", Code: skill.Code, Name: skill.Name, Description: skill.Description, CategoryID: skill.CategoryID, CategoryName: skill.CategoryName, Tags: skill.Tags, UpdatedAt: skill.UpdatedAt} + case "digital_employee": + employee, err := s.employees.Get(ctx, id) + if err != nil { + return item, false, nil + } + item = MarketItem{Type: "digital_employee", Code: employee.Code, Name: employee.Name, Description: employee.Description, CategoryID: employee.CategoryID, CategoryName: employee.CategoryName, Tags: employee.Tags, UpdatedAt: employee.UpdatedAt} + default: + return item, false, nil + } + return item, true, nil +} diff --git a/internal/workbench/marketplace_integration_test.go b/internal/workbench/marketplace_integration_test.go new file mode 100644 index 0000000..62d9237 --- /dev/null +++ b/internal/workbench/marketplace_integration_test.go @@ -0,0 +1,179 @@ +package workbench + +import ( + "context" + "encoding/base64" + "os" + "testing" + + "aigateway.local/core/internal/platform/config" + "aigateway.local/core/internal/platform/cryptox" + "aigateway.local/core/internal/platform/database" +) + +func TestMarketplaceLifecycle(t *testing.T) { + databaseURL := os.Getenv("WORKBENCH_TEST_DATABASE_URL") + if databaseURL == "" { + t.Skip("WORKBENCH_TEST_DATABASE_URL is not set") + } + ctx := context.Background() + pool, err := database.Open(ctx, config.Database{URL: databaseURL, MaxConns: 8, MinConns: 0}) + if err != nil { + t.Fatal(err) + } + defer pool.Close() + actorID := "22222222-2222-4222-8222-222222222222" + portalUserID := "33333333-3333-4333-8333-333333333333" + cleanup := func() { + // pgx v5 Exec uses the extended protocol, which rejects multi-command + // strings, so each statement must run in its own Exec. + for _, q := range []string{ + `DELETE FROM gateway.marketplace_installations WHERE portal_user_id='` + portalUserID + `'`, + `DELETE FROM gateway.digital_employees WHERE code='mkt_employee'`, + `DELETE FROM gateway.skills WHERE code='mkt_skill'`, + `DELETE FROM gateway.mcp_servers WHERE code='mkt_mcp'`, + `DELETE FROM gateway.marketplace_categories WHERE name IN ('mkt-分类','mkt-技能分类')`, + `DELETE FROM gateway.portal_users WHERE id='` + portalUserID + `'`, + } { + if _, err := pool.Exec(ctx, q); err != nil { + t.Fatalf("cleanup: %v", err) + } + } + } + cleanup() + defer cleanup() + // Inserts must come after the initial cleanup, which would otherwise + // delete the portal user we just created (it is referenced by the + // marketplace_installations FK). + if _, err := pool.Exec(ctx, `INSERT INTO gateway.admin_accounts(id,username,password_hash,role) VALUES($1,'market-test','test','superadmin') ON CONFLICT(id) DO NOTHING`, actorID); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `INSERT INTO gateway.portal_users(id,account,name) VALUES($1,'market-user','市场测试') ON CONFLICT(id) DO NOTHING`, portalUserID); err != nil { + t.Fatal(err) + } + + assets := NewService(pool) + key := base64.StdEncoding.EncodeToString(make([]byte, 32)) + cipher, err := cryptox.NewKeyring(key, 1, "", "mkt-test-headers") + if err != nil { + t.Fatal(err) + } + mcpSvc := NewMCPServerService(assets, cipher, true) + toolSvc := NewToolService(assets, cipher, true) + skillSvc := NewSkillService(assets) + deSvc := NewDigitalEmployeeService(assets, skillSvc, toolSvc, mcpSvc) + market := NewMarketplaceService(assets, mcpSvc, skillSvc, deSvc) + + globalCat, err := market.CreateCategory(ctx, Category{Name: "mkt-分类", Description: "全局", ResourceType: ""}, actorID) + if err != nil { + t.Fatal(err) + } + skillCat, err := market.CreateCategory(ctx, Category{Name: "mkt-技能分类", Description: "技能专用", ResourceType: "skill"}, actorID) + if err != nil { + t.Fatal(err) + } + // A skill category must reject binding to an MCP server. + if _, err := market.CreateCategory(ctx, Category{Name: "mkt-冲突分类", Description: "", ResourceType: "mcp_server"}, actorID); err != nil { + t.Fatalf("mcp_server category should be creatable: %v", err) + } + cleanupConflict := func() { + _, _ = pool.Exec(ctx, `DELETE FROM gateway.marketplace_categories WHERE name='mkt-冲突分类'`) + } + cleanupConflict() + defer cleanupConflict() + + mcp, err := mcpSvc.Save(ctx, "", MCPServerInput{ + Code: "mkt_mcp", Name: "测试 MCP", Transport: "streamable-http", + EndpointURL: "https://mcp.example.com/mcp", Status: "published", + CategoryID: &globalCat.ID, Enabled: true, Headers: map[string]string{"Authorization": "Bearer test"}, + }, actorID, true) + if err != nil { + t.Fatal(err) + } + skill, err := skillSvc.Save(ctx, "", SkillInput{ + Code: "mkt_skill", Name: "测试技能", Content: "你是客服,请回答 {{question}}", + Variables: []Variable{{Name: "question", Required: true}}, Status: "published", + CategoryID: &skillCat.ID, Enabled: true, MCPServerIDs: []string{mcp.ID}, + }, actorID, true) + if err != nil { + t.Fatal(err) + } + rendered, err := skillSvc.Render(skill, map[string]any{"question": "你好"}) + if err != nil || rendered != "你是客服,请回答 你好" { + t.Fatalf("render=%q err=%v", rendered, err) + } + + employee, err := deSvc.Save(ctx, "", DigitalEmployeeInput{ + Code: "mkt_employee", Name: "客服机器人", Persona: "你是一位耐心的客服", Model: "deepseek-chat", + SkillIDs: []string{skill.ID}, Status: "draft", Enabled: true, + }, actorID, true) + if err != nil { + t.Fatal(err) + } + if employee.Status != "draft" { + t.Fatalf("expected draft, got %s", employee.Status) + } + published, err := deSvc.Publish(ctx, employee.ID, actorID) + if err != nil { + t.Fatalf("publish failed: %v", err) + } + if published.Status != "published" { + t.Fatalf("expected published, got %s", published.Status) + } + + // Unified catalog merges all three published resource types. + items, err := market.Catalog(ctx, "", "", "", "", 50) + if err != nil { + t.Fatal(err) + } + found := map[string]bool{} + for _, item := range items { + found[item.Code] = true + } + for _, code := range []string{"mkt_mcp", "mkt_skill", "mkt_employee"} { + if !found[code] { + t.Fatalf("catalog missing %s (got %#v)", code, found) + } + } + // Category filter narrows to the skill category. + skillItems, err := market.Catalog(ctx, "skill", skillCat.ID, "", "", 50) + if err != nil || len(skillItems) != 1 || skillItems[0].Code != "mkt_skill" { + t.Fatalf("category filter failed: %#v err=%v", skillItems, err) + } + + detailItem, raw, err := market.Detail(ctx, "digital_employee", "mkt_employee") + if err != nil || detailItem.Code != "mkt_employee" || len(raw) == 0 { + t.Fatalf("detail=%#v err=%v", detailItem, err) + } + + // Install/uninstall is idempotent and gated by published status. + created, err := market.Install(ctx, "skill", "mkt_skill", portalUserID) + if err != nil || !created { + t.Fatalf("install created=%v err=%v", created, err) + } + installed, err := market.Installed(ctx, "skill", skill.ID, portalUserID) + if err != nil || !installed { + t.Fatalf("installed=%v err=%v", installed, err) + } + createdAgain, err := market.Install(ctx, "skill", "mkt_skill", portalUserID) + if err != nil || createdAgain { + t.Fatalf("re-install should be a no-op: created=%v err=%v", createdAgain, err) + } + installedList, err := market.ListInstalled(ctx, portalUserID) + if err != nil || len(installedList) != 1 || installedList[0].Code != "mkt_skill" { + t.Fatalf("installed list=%#v err=%v", installedList, err) + } + if err := market.Uninstall(ctx, "skill", "mkt_skill", portalUserID); err != nil { + t.Fatal(err) + } + installed, err = market.Installed(ctx, "skill", skill.ID, portalUserID) + if err != nil || installed { + t.Fatalf("expected uninstalled, got installed=%v err=%v", installed, err) + } + + // Runs mirroring: publishing a digital employee whose skill is bound to an + // MCP server must still revalidate cleanly (strict bindings). + if _, err := deSvc.Publish(ctx, employee.ID, actorID); err != nil { + t.Fatalf("re-publish after install should pass: %v", err) + } +} diff --git a/internal/workbench/mcp.go b/internal/workbench/mcp.go new file mode 100644 index 0000000..0f693e0 --- /dev/null +++ b/internal/workbench/mcp.go @@ -0,0 +1,253 @@ +package workbench + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "aigateway.local/core/internal/platform/cryptox" + "aigateway.local/core/internal/provider" + "github.com/jackc/pgx/v5" +) + +type MCPServer struct { + ID string `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + Description string `json:"description"` + Transport string `json:"transport"` + EndpointURL string `json:"endpoint_url"` + Status string `json:"status"` + CategoryID *string `json:"category_id,omitempty"` + CategoryName string `json:"category_name"` + Tags []string `json:"tags"` + DepartmentIDs []string `json:"department_ids"` + Enabled bool `json:"enabled"` + HasSecretHeaders bool `json:"has_secret_headers"` + Revision int64 `json:"revision"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + EncryptedHeaders []byte `json:"-"` + HeadersKEKVersion int `json:"-"` +} + +type MCPServerInput struct { + Code string `json:"code"` + Name string `json:"name"` + Description string `json:"description"` + Transport string `json:"transport"` + EndpointURL string `json:"endpoint_url"` + Headers map[string]string `json:"headers,omitempty"` + Status string `json:"status"` + CategoryID *string `json:"category_id,omitempty"` + Tags []string `json:"tags"` + DepartmentIDs []string `json:"department_ids"` + Enabled bool `json:"enabled"` +} + +// MCPServerService manages registered MCP servers: lifecycle CRUD plus header +// decryption for the MCP client. +type MCPServerService struct { + assets *Service + cipher cryptox.Cipher + allowPrivate bool +} + +func NewMCPServerService(assets *Service, cipher cryptox.Cipher, allowPrivate bool) *MCPServerService { + return &MCPServerService{assets: assets, cipher: cipher, allowPrivate: allowPrivate} +} + +func (s *MCPServerService) validate(ctx context.Context, input *MCPServerInput, create bool) error { + input.Code = strings.ToLower(strings.TrimSpace(input.Code)) + input.Name = strings.TrimSpace(input.Name) + input.Description = strings.TrimSpace(input.Description) + input.Transport = strings.ToLower(strings.TrimSpace(input.Transport)) + if !codeRE.MatchString(input.Code) || input.Name == "" || len(input.Name) > 128 || len(input.Description) > 4000 { + return errors.New("MCP 服务器编码、名称或描述格式无效") + } + switch input.Transport { + case "", "streamable-http": + input.Transport = "streamable-http" + case "sse": + default: + return errors.New("不支持的 MCP 传输方式") + } + validated, err := provider.ValidateBaseURL(ctx, input.EndpointURL, s.allowPrivate) + if err != nil { + return fmt.Errorf("MCP 端点校验失败: %w", err) + } + input.EndpointURL = validated + switch input.Status { + case "", "draft": + input.Status = "draft" + case "published", "archived": + default: + return errors.New("无效的资源状态") + } + if input.CategoryID != nil && strings.TrimSpace(*input.CategoryID) == "" { + input.CategoryID = nil + } + var categoryErr error + input.CategoryID, categoryErr = validCategoryID(ctx, s.assets.pool, input.CategoryID, "") + if categoryErr != nil { + return categoryErr + } + input.Tags, err = normalizeStrings(input.Tags, 30) + if err != nil { + return err + } + input.DepartmentIDs, err = normalizeStrings(input.DepartmentIDs, 100) + if err != nil { + return err + } + if create && input.Headers == nil { + input.Headers = map[string]string{} + } + for key, value := range input.Headers { + if strings.TrimSpace(key) == "" || len(key) > 128 || strings.ContainsAny(key, "\r\n") || len(value) > 8192 || strings.ContainsAny(value, "\r\n") { + return errors.New("MCP 请求头格式无效") + } + } + return nil +} + +const mcpServerSelect = `SELECT m.id::text,m.code,m.name,m.description,m.transport,m.endpoint_url,m.status,m.category_id::text,coalesce(c.name,''),m.tags,m.department_ids::text[],m.enabled,octet_length(m.encrypted_headers)>0,m.revision,m.created_at,m.updated_at,m.encrypted_headers,m.headers_kek_version FROM gateway.mcp_servers m LEFT JOIN gateway.marketplace_categories c ON c.id=m.category_id` + +func scanMCPServer(row pgx.Row) (MCPServer, error) { + var s MCPServer + err := row.Scan(&s.ID, &s.Code, &s.Name, &s.Description, &s.Transport, &s.EndpointURL, &s.Status, &s.CategoryID, &s.CategoryName, &s.Tags, &s.DepartmentIDs, &s.Enabled, &s.HasSecretHeaders, &s.Revision, &s.CreatedAt, &s.UpdatedAt, &s.EncryptedHeaders, &s.HeadersKEKVersion) + return s, mapNotFound(err) +} + +func (s *MCPServerService) List(ctx context.Context) ([]MCPServer, error) { + rows, err := s.assets.pool.Query(ctx, mcpServerSelect+` ORDER BY m.updated_at DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + items := []MCPServer{} + for rows.Next() { + server, err := scanMCPServer(rows) + if err != nil { + return nil, err + } + items = append(items, server) + } + return items, rows.Err() +} + +func (s *MCPServerService) Get(ctx context.Context, id string) (MCPServer, error) { + return scanMCPServer(s.assets.pool.QueryRow(ctx, mcpServerSelect+` WHERE m.id=$1`, id)) +} + +func (s *MCPServerService) GetByCode(ctx context.Context, code string) (MCPServer, error) { + return scanMCPServer(s.assets.pool.QueryRow(ctx, mcpServerSelect+` WHERE m.code=$1`, code)) +} + +// GetPublishedByCode returns a published, enabled server by code. +func (s *MCPServerService) GetPublishedByCode(ctx context.Context, code string) (MCPServer, error) { + return scanMCPServer(s.assets.pool.QueryRow(ctx, mcpServerSelect+` WHERE m.code=$1 AND m.status='published' AND m.enabled`, code)) +} + +func (s *MCPServerService) Save(ctx context.Context, id string, input MCPServerInput, actorID string, create bool) (MCPServer, error) { + if err := s.validate(ctx, &input, create); err != nil { + return MCPServer{}, err + } + tx, err := s.assets.pool.Begin(ctx) + if err != nil { + return MCPServer{}, err + } + defer rollback(ctx, tx) + var encrypted []byte + var version int + if input.Headers != nil { + raw, _ := json.Marshal(input.Headers) + encrypted, version, err = s.cipher.Encrypt(raw) + if err != nil { + return MCPServer{}, err + } + } + if create { + id, err = newUUID() + if err != nil { + return MCPServer{}, err + } + _, err = tx.Exec(ctx, `INSERT INTO gateway.mcp_servers(id,code,name,description,transport,endpoint_url,encrypted_headers,headers_kek_version,status,category_id,tags,department_ids,enabled,created_by) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, id, input.Code, input.Name, input.Description, input.Transport, input.EndpointURL, encrypted, version, input.Status, input.CategoryID, input.Tags, input.DepartmentIDs, input.Enabled, actorID) + } else if input.Headers == nil { + tag, updateErr := tx.Exec(ctx, `UPDATE gateway.mcp_servers SET code=$2,name=$3,description=$4,transport=$5,endpoint_url=$6,status=$7,category_id=$8,tags=$9,department_ids=$10,enabled=$11,revision=revision+1,updated_at=clock_timestamp() WHERE id=$1`, id, input.Code, input.Name, input.Description, input.Transport, input.EndpointURL, input.Status, input.CategoryID, input.Tags, input.DepartmentIDs, input.Enabled) + err = updateErr + if err == nil && tag.RowsAffected() == 0 { + return MCPServer{}, ErrNotFound + } + } else { + tag, updateErr := tx.Exec(ctx, `UPDATE gateway.mcp_servers SET code=$2,name=$3,description=$4,transport=$5,endpoint_url=$6,encrypted_headers=$7,headers_kek_version=$8,status=$9,category_id=$10,tags=$11,department_ids=$12,enabled=$13,revision=revision+1,updated_at=clock_timestamp() WHERE id=$1`, id, input.Code, input.Name, input.Description, input.Transport, input.EndpointURL, encrypted, version, input.Status, input.CategoryID, input.Tags, input.DepartmentIDs, input.Enabled) + err = updateErr + if err == nil && tag.RowsAffected() == 0 { + return MCPServer{}, ErrNotFound + } + } + if err != nil { + return MCPServer{}, err + } + event := "mcp_server.updated" + if create { + event = "mcp_server.created" + } + if err = emit(ctx, tx, event, "mcp_server", id, actorID, nil); err != nil { + return MCPServer{}, err + } + if err = tx.Commit(ctx); err != nil { + return MCPServer{}, err + } + return s.Get(ctx, id) +} + +func (s *MCPServerService) Delete(ctx context.Context, id, actorID string) error { + tx, err := s.assets.pool.Begin(ctx) + if err != nil { + return err + } + defer rollback(ctx, tx) + var used bool + if err = tx.QueryRow(ctx, `SELECT EXISTS( + SELECT 1 FROM gateway.skills WHERE $1 = ANY(mcp_server_ids) + UNION ALL SELECT 1 FROM gateway.digital_employees WHERE $1 = ANY(mcp_server_ids) + UNION ALL SELECT 1 FROM gateway.marketplace_installations WHERE resource_type='mcp_server' AND resource_id=$1 + )`, id).Scan(&used); err != nil { + return err + } + if used { + return ErrConflict + } + tag, err := tx.Exec(ctx, `DELETE FROM gateway.mcp_servers WHERE id=$1`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + if err = emit(ctx, tx, "mcp_server.deleted", "mcp_server", id, actorID, nil); err != nil { + return err + } + return tx.Commit(ctx) +} + +// Headers decrypts the stored request headers for an MCP server so the client +// can authenticate to it. +func (s *MCPServerService) Headers(server MCPServer) (map[string]string, error) { + if len(server.EncryptedHeaders) == 0 { + return map[string]string{}, nil + } + plain, err := s.cipher.Decrypt(server.EncryptedHeaders, server.HeadersKEKVersion) + if err != nil { + return nil, err + } + headers := map[string]string{} + if err = json.Unmarshal(plain, &headers); err != nil { + return nil, errors.New("MCP 请求头密文内容无效") + } + return headers, nil +} diff --git a/internal/workbench/mcp_client.go b/internal/workbench/mcp_client.go new file mode 100644 index 0000000..e1c3294 --- /dev/null +++ b/internal/workbench/mcp_client.go @@ -0,0 +1,369 @@ +package workbench + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" +) + +// mcpProtocolVersion is the Model Context Protocol version this client speaks. +// Streamable HTTP (2025-06-18) is the current stable transport; the server may +// negotiate an older one and we accept whatever it replies with. +const mcpProtocolVersion = "2025-06-18" + +// MCPTool is one tool discovered from an MCP server via tools/list. +type MCPTool struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema json.RawMessage `json:"inputSchema"` +} + +// MCPToolResult is the outcome of a tools/call. +type MCPToolResult struct { + Content string + IsError bool +} + +// mcpServerState caches the negotiated session and discovered tool list for one +// server, keyed by server ID. initialize is expensive (two round trips) and +// stateless HTTP servers expect it per session, so we keep the session warm for +// cacheTTL and only re-handshake once it lapses. +type mcpServerState struct { + initAt time.Time + sessionID string + tools []MCPTool + toolsAt time.Time +} + +// MCPClient is a minimal Model Context Protocol client over the streamable HTTP +// (and legacy SSE) transports. It speaks just enough of the protocol for the +// resource marketplace: initialize + notifications/initialized, tools/list for +// discovery, and tools/call for execution. +type MCPClient struct { + client *http.Client + cacheTTL time.Duration + mu sync.Mutex + states map[string]*mcpServerState +} + +func NewMCPClient(allowPrivate bool, cacheTTL time.Duration) *MCPClient { + if cacheTTL <= 0 { + cacheTTL = 60 * time.Second + } + return &MCPClient{ + client: &http.Client{ + Timeout: 15 * time.Second, + Transport: &http.Transport{ + DialContext: safeToolDial(allowPrivate), + ForceAttemptHTTP2: true, + TLSHandshakeTimeout: 5 * time.Second, + ResponseHeaderTimeout: 10 * time.Second, + MaxIdleConns: 64, + MaxIdleConnsPerHost: 16, + IdleConnTimeout: 90 * time.Second, + }, + CheckRedirect: func(*http.Request, []*http.Request) error { return errors.New("MCP 服务器不允许重定向") }, + }, + cacheTTL: cacheTTL, + states: make(map[string]*mcpServerState), + } +} + +// DiscoverTools returns the tools an MCP server advertises, reusing a cached +// list for cacheTTL. headers are the already-decrypted request headers (e.g. +// Authorization) for this server. +func (c *MCPClient) DiscoverTools(ctx context.Context, server MCPServer, headers map[string]string) ([]MCPTool, error) { + state, err := c.ensureInitialized(ctx, server, headers) + if err != nil { + return nil, err + } + if state.tools != nil && time.Since(state.toolsAt) < c.cacheTTL { + return state.tools, nil + } + result, err := c.call(ctx, server, headers, "tools/list", map[string]any{}) + if err != nil { + return nil, err + } + var list struct { + Tools []MCPTool `json:"tools"` + } + if err = json.Unmarshal(result, &list); err != nil { + return nil, fmt.Errorf("MCP tools/list 响应无效: %w", err) + } + for i := range list.Tools { + if list.Tools[i].Name == "" { + return nil, errors.New("MCP 服务器返回了没有名称的工具") + } + if len(list.Tools[i].InputSchema) == 0 { + list.Tools[i].InputSchema = json.RawMessage(`{}`) + } + } + c.mu.Lock() + state.tools = list.Tools + state.toolsAt = time.Now() + c.mu.Unlock() + return list.Tools, nil +} + +// CallTool invokes one tool on an MCP server and returns the concatenated text +// content. An isError result is surfaced as an error so callers treat it as a +// failed tool round rather than a successful empty answer. +func (c *MCPClient) CallTool(ctx context.Context, server MCPServer, headers map[string]string, name string, args map[string]any) (MCPToolResult, error) { + state, err := c.ensureInitialized(ctx, server, headers) + if err != nil { + return MCPToolResult{}, err + } + // The runtime exposes tools under the collision-proof prefix + // (mcp__{serverCode}__{toolName}); strip it before the wire call since the + // remote server only knows the unprefixed tool name. + if _, resolved, ok := resolveMCPTool(name); ok { + name = resolved + } + params := map[string]any{"name": name, "arguments": args} + result, err := c.call(ctx, server, headers, "tools/call", params) + if err != nil { + return MCPToolResult{}, err + } + var called struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + IsError bool `json:"isError"` + } + if err = json.Unmarshal(result, &called); err != nil { + return MCPToolResult{}, fmt.Errorf("MCP tools/call 响应无效: %w", err) + } + text := strings.Builder{} + for _, block := range called.Content { + if block.Type == "text" { + if text.Len() > 0 { + text.WriteString("\n") + } + text.WriteString(block.Text) + } + } + _ = state // keep state warm check semantics obvious + if called.IsError { + return MCPToolResult{}, errors.New("MCP 工具执行失败: " + text.String()) + } + return MCPToolResult{Content: text.String()}, nil +} + +// ensureInitialized performs the MCP initialize handshake for a server if its +// session has lapsed (or no cached tools exist yet), then acknowledges with +// notifications/initialized. The handshake is guarded by the per-server cache +// so a burst of calls does not re-initialize every request. +func (c *MCPClient) ensureInitialized(ctx context.Context, server MCPServer, headers map[string]string) (*mcpServerState, error) { + c.mu.Lock() + state, ok := c.states[server.ID] + if ok && time.Since(state.initAt) < c.cacheTTL { + c.mu.Unlock() + return state, nil + } + c.mu.Unlock() + + result, headersOut, err := c.handshake(ctx, server, headers) + if err != nil { + return nil, err + } + sessionID := headersOut.Get("Mcp-Session-Id") + if server.Transport != "" && server.Transport != "streamable-http" && server.Transport != "sse" { + return nil, fmt.Errorf("不支持的 MCP 传输方式 %s", server.Transport) + } + _ = result // negotiated protocol version is accepted as-is + + c.mu.Lock() + state = &mcpServerState{initAt: time.Now(), sessionID: sessionID} + c.states[server.ID] = state + c.mu.Unlock() + + // Best-effort acknowledgment; servers that require it will reject later + // calls and we will surface that error naturally. + c.sendNotification(ctx, server, headers, sessionID) + return state, nil +} + +func (c *MCPClient) handshake(ctx context.Context, server MCPServer, headers map[string]string) (json.RawMessage, http.Header, error) { + params := map[string]any{ + "protocolVersion": mcpProtocolVersion, + "capabilities": map[string]any{}, + "clientInfo": map[string]any{"name": "LLMGuardX语枢", "version": "0.10.0"}, + } + payload, _ := json.Marshal(mcpRequest{JSONRPC: "2.0", ID: 1, Method: "initialize", Params: params}) + request, err := http.NewRequestWithContext(ctx, http.MethodPost, server.EndpointURL, bytes.NewReader(payload)) + if err != nil { + return nil, nil, err + } + c.prepare(request, headers, "") + response, err := c.client.Do(request) + if err != nil { + return nil, nil, fmt.Errorf("MCP 连接失败: %w", err) + } + defer response.Body.Close() + result, err := readMCPBody(response) + if err != nil { + return nil, nil, err + } + return result, response.Header, nil +} + +func (c *MCPClient) sendNotification(ctx context.Context, server MCPServer, headers map[string]string, sessionID string) { + payload, _ := json.Marshal(mcpRequest{JSONRPC: "2.0", ID: nil, Method: "notifications/initialized"}) + request, err := http.NewRequestWithContext(ctx, http.MethodPost, server.EndpointURL, bytes.NewReader(payload)) + if err != nil { + return + } + c.prepare(request, headers, sessionID) + response, err := c.client.Do(request) + if err != nil { + return + } + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 1<<10)) + response.Body.Close() +} + +func (c *MCPClient) call(ctx context.Context, server MCPServer, headers map[string]string, method string, params any) (json.RawMessage, error) { + c.mu.Lock() + sessionID := "" + if state, ok := c.states[server.ID]; ok { + sessionID = state.sessionID + } + c.mu.Unlock() + payload, _ := json.Marshal(mcpRequest{JSONRPC: "2.0", ID: 1, Method: method, Params: params}) + request, err := http.NewRequestWithContext(ctx, http.MethodPost, server.EndpointURL, bytes.NewReader(payload)) + if err != nil { + return nil, err + } + c.prepare(request, headers, sessionID) + response, err := c.client.Do(request) + if err != nil { + return nil, fmt.Errorf("MCP 调用失败: %w", err) + } + defer response.Body.Close() + return readMCPBody(response) +} + +func (c *MCPClient) prepare(request *http.Request, headers map[string]string, sessionID string) { + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Accept", "application/json, text/event-stream") + for key, value := range headers { + request.Header.Set(key, value) + } + if sessionID != "" { + request.Header.Set("Mcp-Session-Id", sessionID) + } +} + +type mcpRequest struct { + JSONRPC string `json:"jsonrpc"` + ID any `json:"id"` + Method string `json:"method"` + Params any `json:"params,omitempty"` +} + +type mcpError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +type mcpResponse struct { + JSONRPC string `json:"jsonrpc"` + ID any `json:"id"` + Result json.RawMessage `json:"result"` + Error *mcpError `json:"error"` +} + +// readMCPBody reads and parses a single JSON-RPC response. Streamable HTTP +// servers return application/json; legacy SSE servers stream data frames, from +// which the first complete JSON object is extracted. +func readMCPBody(response *http.Response) (json.RawMessage, error) { + code := response.StatusCode + raw, err := io.ReadAll(io.LimitReader(response.Body, (1<<20)+1)) + if err != nil { + return nil, err + } + if len(raw) > 1<<20 { + return nil, errors.New("MCP 响应超过 1 MiB") + } + if code < 200 || code >= 300 { + return nil, fmt.Errorf("MCP 服务器返回 HTTP %d", code) + } + var body []byte + if strings.HasPrefix(response.Header.Get("Content-Type"), "text/event-stream") { + body, err = extractSSEJSON(raw) + if err != nil { + return nil, err + } + } else { + body = raw + } + var resp mcpResponse + if err = json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("MCP 响应不是有效 JSON-RPC: %w", err) + } + if resp.Error != nil { + return nil, fmt.Errorf("MCP 服务器错误 (%d): %s", resp.Error.Code, resp.Error.Message) + } + if len(resp.Result) == 0 { + return nil, errors.New("MCP 服务器返回空结果") + } + return resp.Result, nil +} + +// extractSSEJSON concatenates the first data frame's payload into one JSON +// document. MCP streamable-HTTP servers emit a single frame per request, so +// only the first frame boundary is consumed and the rest is ignored. +func extractSSEJSON(raw []byte) ([]byte, error) { + var data strings.Builder + for _, line := range strings.Split(string(raw), "\n") { + trimmed := strings.TrimSpace(line) + switch { + case strings.HasPrefix(trimmed, "data:"): + value := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:")) + if value == "" { + continue + } + if data.Len() > 0 { + data.WriteString("\n") + } + data.WriteString(value) + case trimmed == "" && data.Len() > 0: + if json.Valid([]byte(data.String())) { + return []byte(data.String()), nil + } + data.Reset() + } + } + if data.Len() > 0 && json.Valid([]byte(data.String())) { + return []byte(data.String()), nil + } + return nil, errors.New("MCP 服务器未返回有效的 SSE 数据帧") +} + +// toolCallPrefix namespaces MCP tools inside a shared tool list so different +// servers cannot collide. Format: mcp__{serverCode}__{toolName}. +const toolCallPrefix = "mcp__" + +func mcpToolName(serverCode, tool string) string { return toolCallPrefix + serverCode + "__" + tool } + +// resolveMCPTool splits a prefixed tool name back into its MCP server code and +// tool name. Returns ok=false for names that are not MCP-prefixed. +func resolveMCPTool(prefixed string) (serverCode, tool string, ok bool) { + if !strings.HasPrefix(prefixed, toolCallPrefix) { + return "", "", false + } + rest := strings.TrimPrefix(prefixed, toolCallPrefix) + parts := strings.SplitN(rest, "__", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", false + } + return parts[0], parts[1], true +} diff --git a/internal/workbench/mcp_client_test.go b/internal/workbench/mcp_client_test.go new file mode 100644 index 0000000..5996779 --- /dev/null +++ b/internal/workbench/mcp_client_test.go @@ -0,0 +1,237 @@ +package workbench + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +// minimalMCP is a scriptable JSON-RPC MCP server for exercising the client. +type minimalMCP struct { + t *testing.T + server *httptest.Server + tools []MCPTool + initCount int32 + listCount int32 + callCount int32 + lastArgs map[string]any + lastTool string + versioned bool +} + +func newMinimalMCP(t *testing.T) *minimalMCP { + m := &minimalMCP{t: t, tools: []MCPTool{ + {Name: "lookup", Description: "look something up", InputSchema: json.RawMessage(`{"type":"object","properties":{"q":{"type":"string"}},"required":["q"]}`)}, + {Name: "add", Description: "add two numbers", InputSchema: json.RawMessage(`{"type":"object","properties":{"a":{"type":"number"},"b":{"type":"number"}},"required":["a","b"]}`)}, + }} + m.server = httptest.NewServer(http.HandlerFunc(m.serve)) + t.Cleanup(m.server.Close) + return m +} + +func (m *minimalMCP) endpoint() string { return m.server.URL + "/mcp" } + +func (m *minimalMCP) serve(w http.ResponseWriter, r *http.Request) { + var req mcpRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + switch req.Method { + case "initialize": + atomic.AddInt32(&m.initCount, 1) + params, _ := req.Params.(map[string]any) + clientInfo, _ := params["clientInfo"].(map[string]any) + if clientInfo["name"] == nil || clientInfo["name"] == "" { + m.t.Error("initialize missing clientInfo.name") + } + result := map[string]any{ + "protocolVersion": "2025-06-18", + "capabilities": map[string]any{"tools": map[string]any{}}, + "serverInfo": map[string]any{"name": "test-mcp", "version": "1.0.0"}, + } + m.write(w, req.ID, result, false) + case "notifications/initialized": + w.WriteHeader(http.StatusAccepted) + case "tools/list": + atomic.AddInt32(&m.listCount, 1) + m.write(w, req.ID, map[string]any{"tools": m.tools}, false) + case "tools/call": + atomic.AddInt32(&m.callCount, 1) + params, _ := req.Params.(map[string]any) + name, _ := params["name"].(string) + args, _ := params["arguments"].(map[string]any) + m.lastArgs = args + m.lastTool = name + if name == "boom" { + m.write(w, req.ID, map[string]any{"content": []map[string]any{{"type": "text", "text": "failed intentionally"}}, "isError": true}, false) + return + } + if name == "nope" { + m.write(w, req.ID, nil, true) + return + } + m.write(w, req.ID, map[string]any{"content": []map[string]any{{"type": "text", "text": "result for " + name}}, "isError": false}, false) + case "tools/fail": + m.write(w, req.ID, nil, true) + default: + http.Error(w, "unknown method", http.StatusBadRequest) + } +} + +func (m *minimalMCP) write(w http.ResponseWriter, id any, result any, isError bool) { + w.Header().Set("Content-Type", "application/json") + if isError { + json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": id, "error": map[string]any{"code": -32601, "message": "method not found"}}) + return + } + json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": id, "result": result}) +} + +func testServer(code string) MCPServer { + return MCPServer{ID: "00000000-0000-0000-0000-000000000001", Code: code} +} + +func TestMCPDiscoverTools(t *testing.T) { + mcp := newMinimalMCP(t) + client := NewMCPClient(true, time.Minute) + server := testServer("demo") + server.EndpointURL = mcp.endpoint() + + tools, err := client.DiscoverTools(context.Background(), server, nil) + if err != nil { + t.Fatalf("discover failed: %v", err) + } + if len(tools) != 2 || tools[0].Name != "lookup" { + t.Fatalf("expected 2 tools starting with lookup, got %+v", tools) + } + if atomic.LoadInt32(&mcp.initCount) != 1 { + t.Fatalf("expected exactly one initialize handshake, got %d", mcp.initCount) + } + // Second call hits the tool cache, no new tools/list. + if _, err = client.DiscoverTools(context.Background(), server, nil); err != nil { + t.Fatalf("cached discover failed: %v", err) + } + if atomic.LoadInt32(&mcp.listCount) != 1 { + t.Fatalf("expected tools/list to run once, got %d", mcp.listCount) + } +} + +func TestMCPCacheExpiryRediscover(t *testing.T) { + mcp := newMinimalMCP(t) + client := NewMCPClient(true, 50*time.Millisecond) + server := testServer("expire") + server.EndpointURL = mcp.endpoint() + + if _, err := client.DiscoverTools(context.Background(), server, nil); err != nil { + t.Fatalf("discover failed: %v", err) + } + time.Sleep(80 * time.Millisecond) + if _, err := client.DiscoverTools(context.Background(), server, nil); err != nil { + t.Fatalf("rediscover failed: %v", err) + } + if atomic.LoadInt32(&mcp.listCount) != 2 { + t.Fatalf("expected tools/list to run twice after expiry, got %d", mcp.listCount) + } +} + +func TestMCPCallTool(t *testing.T) { + mcp := newMinimalMCP(t) + client := NewMCPClient(true, time.Minute) + server := testServer("call") + server.EndpointURL = mcp.endpoint() + + result, err := client.CallTool(context.Background(), server, nil, "lookup", map[string]any{"q": "hello"}) + if err != nil { + t.Fatalf("call failed: %v", err) + } + if result.Content != "result for lookup" { + t.Fatalf("unexpected content %q", result.Content) + } + if mcp.lastArgs["q"] != "hello" { + t.Fatalf("arguments not forwarded: %v", mcp.lastArgs) + } + // The runtime exposes tools under the mcp__{code}__{tool} prefix; CallTool + // must strip it so the remote server sees the real tool name. + if mcp.lastTool != "lookup" { + t.Fatalf("tool name not sent as-is: %q", mcp.lastTool) + } + if _, err := client.CallTool(context.Background(), server, nil, "mcp__call__lookup", map[string]any{"q": "x"}); err != nil { + t.Fatalf("prefixed call failed: %v", err) + } + if mcp.lastTool != "lookup" { + t.Fatalf("prefixed tool name was not stripped: %q", mcp.lastTool) + } +} + +func TestMCPCallToolIsError(t *testing.T) { + mcp := newMinimalMCP(t) + client := NewMCPClient(true, time.Minute) + server := testServer("err") + server.EndpointURL = mcp.endpoint() + + if _, err := client.CallTool(context.Background(), server, nil, "boom", nil); err == nil { + t.Fatal("expected error for isError tool result") + } +} + +func TestMCPJSONRPCError(t *testing.T) { + mcp := newMinimalMCP(t) + client := NewMCPClient(true, time.Minute) + server := testServer("rpc") + server.EndpointURL = mcp.endpoint() + + if _, err := client.CallTool(context.Background(), server, nil, "nope", nil); err == nil || !strings.Contains(err.Error(), "method not found") { + t.Fatalf("expected JSON-RPC error surfaced, got %v", err) + } +} + +func TestMCPToolNamePrefixRoundTrip(t *testing.T) { + prefixed := mcpToolName("github", "create-issue") + if prefixed != "mcp__github__create-issue" { + t.Fatalf("unexpected prefix %q", prefixed) + } + code, tool, ok := resolveMCPTool(prefixed) + if !ok || code != "github" || tool != "create-issue" { + t.Fatalf("resolve failed: %q %q %v", code, tool, ok) + } + if _, _, ok := resolveMCPTool("plain-name"); ok { + t.Fatal("non-prefixed name should not resolve as MCP tool") + } +} + +func TestMCPSSESingleFrame(t *testing.T) { + handler := func(w http.ResponseWriter, r *http.Request) { + var req mcpRequest + _ = json.NewDecoder(r.Body).Decode(&req) + w.Header().Set("Content-Type", "text/event-stream") + switch req.Method { + case "initialize": + payload, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": req.ID, "result": map[string]any{"protocolVersion": "2025-06-18", "capabilities": map[string]any{}, "serverInfo": map[string]any{"name": "sse", "version": "1"}}}) + w.Write([]byte("event: message\ndata: " + string(payload) + "\n\n")) + case "notifications/initialized": + w.WriteHeader(http.StatusAccepted) + case "tools/list": + payload, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": req.ID, "result": map[string]any{"tools": []MCPTool{{Name: "sse-tool", Description: "", InputSchema: json.RawMessage(`{}`)}}}}) + w.Write([]byte("event: message\ndata: " + string(payload) + "\n\n")) + } + } + server := httptest.NewServer(http.HandlerFunc(handler)) + defer server.Close() + + client := NewMCPClient(true, time.Minute) + svr := testServer("sse") + svr.EndpointURL = server.URL + tools, err := client.DiscoverTools(context.Background(), svr, nil) + if err != nil { + t.Fatalf("SSE discover failed: %v", err) + } + if len(tools) != 1 || tools[0].Name != "sse-tool" { + t.Fatalf("unexpected SSE tools: %+v", tools) + } +} diff --git a/internal/workbench/notifications.go b/internal/workbench/notifications.go new file mode 100644 index 0000000..3e7c3fc --- /dev/null +++ b/internal/workbench/notifications.go @@ -0,0 +1,437 @@ +package workbench + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "strings" + "time" + + "aigateway.local/core/internal/platform/cryptox" + "aigateway.local/core/internal/provider" + "github.com/jackc/pgx/v5" + "github.com/redis/go-redis/v9" +) + +type NotificationInput struct { + Name, WebhookURL string + SigningSecret *string + EventPatterns []string + Enabled bool +} + +type NotificationService struct { + assets *Service + cipher cryptox.Cipher + allowPrivate bool + client *http.Client +} + +func NewNotificationService(assets *Service, cipher cryptox.Cipher, allowPrivate bool) *NotificationService { + // One shared client for all deliveries: http.Client is safe for concurrent + // use, and reusing the transport keeps TLS sessions and connections + // warm instead of re-handshaking for every webhook. + client := &http.Client{ + Timeout: 15 * time.Second, + Transport: &http.Transport{ + DialContext: safeToolDial(allowPrivate), + TLSHandshakeTimeout: 5 * time.Second, + ResponseHeaderTimeout: 10 * time.Second, + MaxIdleConns: 64, + MaxIdleConnsPerHost: 16, + IdleConnTimeout: 90 * time.Second, + }, + CheckRedirect: func(*http.Request, []*http.Request) error { return errors.New("Webhook 不允许重定向") }, + } + return &NotificationService{assets: assets, cipher: cipher, allowPrivate: allowPrivate, client: client} +} + +const channelSelect = `SELECT id::text,name,webhook_url,event_patterns,enabled,octet_length(encrypted_signing_secret)>0,revision,created_at,updated_at,encrypted_signing_secret,signing_secret_kek_version FROM gateway.notification_channels` + +func scanChannel(row pgx.Row) (NotificationChannel, error) { + var c NotificationChannel + err := row.Scan(&c.ID, &c.Name, &c.WebhookURL, &c.EventPatterns, &c.Enabled, &c.HasSigningSecret, &c.Revision, &c.CreatedAt, &c.UpdatedAt, &c.EncryptedSigningSecret, &c.SigningSecretKEKVersion) + return c, mapNotFound(err) +} +func (s *NotificationService) ListChannels(ctx context.Context) ([]NotificationChannel, error) { + rows, err := s.assets.pool.Query(ctx, channelSelect+` ORDER BY updated_at DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + items := []NotificationChannel{} + for rows.Next() { + c, err := scanChannel(rows) + if err != nil { + return nil, err + } + items = append(items, c) + } + return items, rows.Err() +} +func (s *NotificationService) GetChannel(ctx context.Context, id string) (NotificationChannel, error) { + return scanChannel(s.assets.pool.QueryRow(ctx, channelSelect+` WHERE id=$1`, id)) +} + +func (s *NotificationService) SaveChannel(ctx context.Context, id string, input NotificationInput, actorID string, create bool) (NotificationChannel, error) { + input.Name = strings.TrimSpace(input.Name) + input.WebhookURL = strings.TrimSpace(input.WebhookURL) + if input.Name == "" || len(input.Name) > 128 { + return NotificationChannel{}, errors.New("通知通道名称格式无效") + } + validated, err := provider.ValidateBaseURL(ctx, input.WebhookURL, s.allowPrivate) + if err != nil { + return NotificationChannel{}, fmt.Errorf("Webhook 地址校验失败: %w", err) + } + input.WebhookURL = validated + input.EventPatterns, err = normalizeStrings(input.EventPatterns, 100) + if err != nil { + return NotificationChannel{}, err + } + if len(input.EventPatterns) == 0 { + return NotificationChannel{}, errors.New("至少配置一个事件模式") + } + for _, pattern := range input.EventPatterns { + if strings.Count(pattern, "*") > 1 || (strings.Contains(pattern, "*") && !strings.HasSuffix(pattern, "*")) { + return NotificationChannel{}, errors.New("事件模式仅允许末尾 * 通配") + } + } + tx, err := s.assets.pool.Begin(ctx) + if err != nil { + return NotificationChannel{}, err + } + defer rollback(ctx, tx) + var encrypted []byte + var version int + if input.SigningSecret != nil && *input.SigningSecret != "" { + if len(*input.SigningSecret) > 4096 { + return NotificationChannel{}, errors.New("签名密钥过长") + } + encrypted, version, err = s.cipher.Encrypt([]byte(*input.SigningSecret)) + if err != nil { + return NotificationChannel{}, err + } + } + if create { + id, err = newUUID() + if err != nil { + return NotificationChannel{}, err + } + _, err = tx.Exec(ctx, `INSERT INTO gateway.notification_channels(id,name,webhook_url,encrypted_signing_secret,signing_secret_kek_version,event_patterns,enabled,created_by) VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, id, input.Name, input.WebhookURL, encrypted, version, input.EventPatterns, input.Enabled, actorID) + } else if input.SigningSecret == nil { + tag, updateErr := tx.Exec(ctx, `UPDATE gateway.notification_channels SET name=$2,webhook_url=$3,event_patterns=$4,enabled=$5,revision=revision+1,updated_at=clock_timestamp() WHERE id=$1`, id, input.Name, input.WebhookURL, input.EventPatterns, input.Enabled) + err = updateErr + if err == nil && tag.RowsAffected() == 0 { + return NotificationChannel{}, ErrNotFound + } + } else { + tag, updateErr := tx.Exec(ctx, `UPDATE gateway.notification_channels SET name=$2,webhook_url=$3,encrypted_signing_secret=$4,signing_secret_kek_version=$5,event_patterns=$6,enabled=$7,revision=revision+1,updated_at=clock_timestamp() WHERE id=$1`, id, input.Name, input.WebhookURL, encrypted, version, input.EventPatterns, input.Enabled) + err = updateErr + if err == nil && tag.RowsAffected() == 0 { + return NotificationChannel{}, ErrNotFound + } + } + if err != nil { + return NotificationChannel{}, err + } + event := "notification_channel.updated" + if create { + event = "notification_channel.created" + } + if err = emit(ctx, tx, event, "notification_channel", id, actorID, nil); err != nil { + return NotificationChannel{}, err + } + if err = tx.Commit(ctx); err != nil { + return NotificationChannel{}, err + } + return s.GetChannel(ctx, id) +} + +func (s *NotificationService) DeleteChannel(ctx context.Context, id, actorID string) error { + tx, err := s.assets.pool.Begin(ctx) + if err != nil { + return err + } + defer rollback(ctx, tx) + tag, err := tx.Exec(ctx, `DELETE FROM gateway.notification_channels WHERE id=$1`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + if err = emit(ctx, tx, "notification_channel.deleted", "notification_channel", id, actorID, nil); err != nil { + return err + } + return tx.Commit(ctx) +} + +const deliverySelect = `SELECT d.id::text,d.channel_id::text,c.name,d.event_id::text,d.event_type,d.status,d.last_error,d.payload,d.attempts,d.response_status,d.delivered_at,d.created_at,d.updated_at FROM gateway.notification_deliveries d JOIN gateway.notification_channels c ON c.id=d.channel_id` + +func (s *NotificationService) ListDeliveries(ctx context.Context, limit int) ([]NotificationDelivery, error) { + if limit < 1 { + limit = 100 + } + if limit > 500 { + limit = 500 + } + rows, err := s.assets.pool.Query(ctx, deliverySelect+` ORDER BY d.created_at DESC LIMIT $1`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []NotificationDelivery{} + for rows.Next() { + var d NotificationDelivery + if err = rows.Scan(&d.ID, &d.ChannelID, &d.ChannelName, &d.EventID, &d.EventType, &d.Status, &d.LastError, &d.Payload, &d.Attempts, &d.ResponseStatus, &d.DeliveredAt, &d.CreatedAt, &d.UpdatedAt); err != nil { + return nil, err + } + items = append(items, d) + } + return items, rows.Err() +} + +func matchesEvent(patterns []string, eventType string) bool { + for _, pattern := range patterns { + if pattern == "*" || pattern == eventType { + return true + } + if strings.HasSuffix(pattern, "*") && strings.HasPrefix(eventType, strings.TrimSuffix(pattern, "*")) { + return true + } + } + return false +} + +func (s *NotificationService) ensureDelivery(ctx context.Context, channel NotificationChannel, eventID, eventType string, payload json.RawMessage) (NotificationDelivery, error) { + id, err := newUUID() + if err != nil { + return NotificationDelivery{}, err + } + var d NotificationDelivery + err = s.assets.pool.QueryRow(ctx, `INSERT INTO gateway.notification_deliveries(id,channel_id,event_id,event_type,payload,status) VALUES($1,$2,$3,$4,$5,'pending') ON CONFLICT(channel_id,event_id) DO UPDATE SET updated_at=gateway.notification_deliveries.updated_at RETURNING id::text,channel_id::text,$6,event_id::text,event_type,status,last_error,payload,attempts,response_status,delivered_at,created_at,updated_at`, id, channel.ID, eventID, eventType, payload, channel.Name).Scan(&d.ID, &d.ChannelID, &d.ChannelName, &d.EventID, &d.EventType, &d.Status, &d.LastError, &d.Payload, &d.Attempts, &d.ResponseStatus, &d.DeliveredAt, &d.CreatedAt, &d.UpdatedAt) + return d, err +} + +func (s *NotificationService) deliver(ctx context.Context, channel NotificationChannel, delivery NotificationDelivery) error { + body, _ := json.Marshal(map[string]any{"event_id": delivery.EventID, "event_type": delivery.EventType, "occurred_at": delivery.CreatedAt.UTC().Format(time.RFC3339Nano), "payload": json.RawMessage(delivery.Payload)}) + request, err := http.NewRequestWithContext(ctx, http.MethodPost, channel.WebhookURL, bytes.NewReader(body)) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("X-Gateway-Event-ID", delivery.EventID) + if len(channel.EncryptedSigningSecret) > 0 { + secret, decryptErr := s.cipher.Decrypt(channel.EncryptedSigningSecret, channel.SigningSecretKEKVersion) + if decryptErr != nil { + return decryptErr + } + mac := hmac.New(sha256.New, secret) + _, _ = mac.Write(body) + request.Header.Set("X-Gateway-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil))) + } + response, requestErr := s.client.Do(request) + status := 0 + if response != nil { + status = response.StatusCode + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64<<10)) + response.Body.Close() + } + success := requestErr == nil && status >= 200 && status < 300 + message := "" + if requestErr != nil { + message = requestErr.Error() + } else if !success { + message = fmt.Sprintf("Webhook 返回 HTTP %d", status) + } + if len(message) > 1000 { + message = message[:1000] + } + _, dbErr := s.assets.pool.Exec(context.WithoutCancel(ctx), `UPDATE gateway.notification_deliveries SET status=$2,attempts=attempts+1,response_status=nullif($3,0),last_error=$4,delivered_at=CASE WHEN $2='delivered' THEN clock_timestamp() ELSE delivered_at END,updated_at=clock_timestamp() WHERE id=$1`, delivery.ID, map[bool]string{true: "delivered", false: "failed"}[success], status, message) + if dbErr != nil { + return dbErr + } + if !success { + return errors.New(message) + } + return nil +} + +func (s *NotificationService) RetryDelivery(ctx context.Context, id string) error { + var d NotificationDelivery + err := s.assets.pool.QueryRow(ctx, deliverySelect+` WHERE d.id=$1`, id).Scan(&d.ID, &d.ChannelID, &d.ChannelName, &d.EventID, &d.EventType, &d.Status, &d.LastError, &d.Payload, &d.Attempts, &d.ResponseStatus, &d.DeliveredAt, &d.CreatedAt, &d.UpdatedAt) + if err != nil { + return mapNotFound(err) + } + channel, err := s.GetChannel(ctx, d.ChannelID) + if err != nil { + return err + } + return s.deliver(ctx, channel, d) +} + +type NotificationDispatcher struct { + service *NotificationService + redis *redis.Client + stream, group, consumer string + logger *slog.Logger +} + +func NewNotificationDispatcher(service *NotificationService, client *redis.Client, stream, consumer string, logger *slog.Logger) *NotificationDispatcher { + return &NotificationDispatcher{service: service, redis: client, stream: stream, group: "gateway-notifications-v1", consumer: consumer, logger: logger} +} +func (d *NotificationDispatcher) Run(ctx context.Context) error { + if err := d.redis.XGroupCreateMkStream(ctx, d.stream, d.group, "$").Err(); err != nil && !strings.Contains(err.Error(), "BUSYGROUP") { + return err + } + delay := time.Duration(0) + for { + // Compensate for events claimed by a previous iteration (or a crashed + // worker) that were never acknowledged. XReadGroup ">" never re-reads + // the pending list, so without this pass those events would be silently + // dropped for good. + if err := d.reclaim(ctx); err != nil { + if ctx.Err() != nil { + return nil + } + if d.logger != nil { + d.logger.Error("notification pending reclaim failed; will retry", "error", err) + } + } + streams, err := d.redis.XReadGroup(ctx, &redis.XReadGroupArgs{Group: d.group, Consumer: d.consumer, Streams: []string{d.stream, ">"}, Count: 20, Block: 5 * time.Second}).Result() + if errors.Is(err, redis.Nil) { + delay = 0 + continue + } + if err != nil { + if ctx.Err() != nil { + return nil + } + if d.logger != nil { + d.logger.Error("notification stream read failed; backing off", "error", err) + } + // Reconnect with backoff instead of killing the worker: a transient + // Redis blip must not take the notification worker down and let the + // stream trim every queued event. + if !d.wait(ctx, &delay) { + return nil + } + continue + } + delay = 0 + for _, stream := range streams { + for _, message := range stream.Messages { + if err = d.handle(ctx, message); err != nil { + // Do not acknowledge: the event stays in the pending list and + // is retried by the reclaim pass above. + if d.logger != nil { + d.logger.Error("notification event failed; will retry", "stream_id", message.ID, "error", err) + } + continue + } + if err = d.redis.XAck(ctx, d.stream, d.group, message.ID).Err(); err != nil { + if ctx.Err() != nil { + return nil + } + // An ack failure must not drop a successfully handled event; + // it is reclaimed and re-acked on the next pass (handle is + // idempotent via the delivery table). + if d.logger != nil { + d.logger.Error("notification ack failed; will retry via reclaim", "stream_id", message.ID, "error", err) + } + continue + } + } + } + } +} + +// reclaim re-processes pending stream entries that have been idle longer than +// the threshold. handle is idempotent (deliveries are keyed by channel+event), +// so re-running it only creates the delivery rows that were never created or +// updates attempts on ones already recorded. +func (d *NotificationDispatcher) reclaim(ctx context.Context) error { + for { + messages, cursor, err := d.redis.XAutoClaim(ctx, &redis.XAutoClaimArgs{ + Stream: d.stream, + Group: d.group, + Consumer: d.consumer, + MinIdle: 30 * time.Second, + Start: "0", + Count: 20, + }).Result() + if err != nil { + return err + } + for _, message := range messages { + if err = d.handle(ctx, message); err != nil { + if d.logger != nil { + d.logger.Warn("notification pending retry failed", "stream_id", message.ID, "error", err) + } + continue + } + if err = d.redis.XAck(ctx, d.stream, d.group, message.ID).Err(); err != nil { + return err + } + } + if cursor == "0-0" { + break + } + } + return nil +} + +// wait sleeps with exponential backoff, capping at 30s. It returns false when +// the context is cancelled. +func (d *NotificationDispatcher) wait(ctx context.Context, delay *time.Duration) bool { + const max = 30 * time.Second + if *delay == 0 { + *delay = 200 * time.Millisecond + } else { + *delay *= 2 + if *delay > max { + *delay = max + } + } + timer := time.NewTimer(*delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return false + case <-timer.C: + return true + } +} +func (d *NotificationDispatcher) handle(ctx context.Context, message redis.XMessage) error { + eventID := fmt.Sprint(message.Values["event_id"]) + eventType := fmt.Sprint(message.Values["event_type"]) + payload := json.RawMessage(fmt.Sprint(message.Values["payload"])) + channels, err := d.service.ListChannels(ctx) + if err != nil { + return err + } + for _, channel := range channels { + if !channel.Enabled || !matchesEvent(channel.EventPatterns, eventType) { + continue + } + delivery, deliveryErr := d.service.ensureDelivery(ctx, channel, eventID, eventType, payload) + if deliveryErr != nil { + return deliveryErr + } + if delivery.Status == "delivered" { + continue + } + if deliveryErr = d.service.deliver(ctx, channel, delivery); deliveryErr != nil && d.logger != nil { + d.logger.Warn("webhook delivery failed", "channel", channel.Name, "event_id", eventID, "error", deliveryErr) + } + } + return nil +} diff --git a/internal/workbench/prompts.go b/internal/workbench/prompts.go new file mode 100644 index 0000000..98e61d0 --- /dev/null +++ b/internal/workbench/prompts.go @@ -0,0 +1,377 @@ +package workbench + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5" +) + +type PromptCategory struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + TemplateCount int `json:"template_count"` + CreatedAt time.Time `json:"created_at"` +} + +func (s *Service) ListPromptCategories(ctx context.Context) ([]PromptCategory, error) { + rows, err := s.pool.Query(ctx, `SELECT c.id::text,c.name,c.description,count(t.id)::int,c.created_at FROM gateway.prompt_categories c LEFT JOIN gateway.prompt_templates t ON t.category_id=c.id GROUP BY c.id ORDER BY c.name`) + if err != nil { + return nil, err + } + defer rows.Close() + items := []PromptCategory{} + for rows.Next() { + var item PromptCategory + if err := rows.Scan(&item.ID, &item.Name, &item.Description, &item.TemplateCount, &item.CreatedAt); err != nil { + return nil, err + } + items = append(items, item) + } + return items, rows.Err() +} + +func (s *Service) CreatePromptCategory(ctx context.Context, name, description, actorID string) (PromptCategory, error) { + name, description = strings.TrimSpace(name), strings.TrimSpace(description) + if name == "" || len(name) > 64 || len(description) > 512 { + return PromptCategory{}, errors.New("分类名称或描述格式无效") + } + id, err := newUUID() + if err != nil { + return PromptCategory{}, err + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return PromptCategory{}, err + } + defer rollback(ctx, tx) + var item PromptCategory + err = tx.QueryRow(ctx, `INSERT INTO gateway.prompt_categories(id,name,description) VALUES($1,$2,$3) RETURNING id::text,name,description,created_at`, id, name, description).Scan(&item.ID, &item.Name, &item.Description, &item.CreatedAt) + if err != nil { + return PromptCategory{}, err + } + if err = emit(ctx, tx, "prompt_category.created", "prompt_category", id, actorID, nil); err != nil { + return PromptCategory{}, err + } + if err = tx.Commit(ctx); err != nil { + return PromptCategory{}, err + } + return item, nil +} + +func (s *Service) DeletePromptCategory(ctx context.Context, id, actorID string) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer rollback(ctx, tx) + if _, err = tx.Exec(ctx, `UPDATE gateway.prompt_templates SET category_id=NULL,revision=revision+1,updated_at=clock_timestamp() WHERE category_id=$1`, id); err != nil { + return err + } + tag, err := tx.Exec(ctx, `DELETE FROM gateway.prompt_categories WHERE id=$1`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + if err = emit(ctx, tx, "prompt_category.deleted", "prompt_category", id, actorID, nil); err != nil { + return err + } + return tx.Commit(ctx) +} + +func validateVariables(values []Variable) ([]Variable, error) { + seen := map[string]struct{}{} + for i := range values { + values[i].Name = strings.TrimSpace(values[i].Name) + values[i].Label = strings.TrimSpace(values[i].Label) + if !regexpVariableName(values[i].Name) { + return nil, fmt.Errorf("变量名 %q 格式无效", values[i].Name) + } + if _, ok := seen[values[i].Name]; ok { + return nil, fmt.Errorf("变量 %q 重复", values[i].Name) + } + seen[values[i].Name] = struct{}{} + if len(values[i].Default) > 10000 || len(values[i].Label) > 128 { + return nil, errors.New("变量定义过长") + } + } + if len(values) > 100 { + return nil, errors.New("变量最多 100 个") + } + return values, nil +} + +func regexpVariableName(value string) bool { + if value == "" { + return false + } + matched := variableRE.FindStringSubmatch("{{" + value + "}}") + return len(matched) == 2 && matched[1] == value +} + +func validatePromptInput(input *PromptInput, create bool) error { + input.Name = strings.TrimSpace(input.Name) + input.Description = strings.TrimSpace(input.Description) + input.Content = strings.TrimSpace(input.Content) + input.ChangeNote = strings.TrimSpace(input.ChangeNote) + if input.Name == "" || len(input.Name) > 128 || len(input.Description) > 4000 { + return errors.New("Prompt 名称或描述格式无效") + } + if create && (input.Content == "" || len(input.Content) > 100000) { + return errors.New("Prompt 正文不能为空且最多 100000 字符") + } + var err error + if input.Tags, err = normalizeStrings(input.Tags, 30); err != nil { + return err + } + if input.DepartmentIDs, err = normalizeStrings(input.DepartmentIDs, 100); err != nil { + return err + } + if input.Variables, err = validateVariables(input.Variables); err != nil { + return err + } + return nil +} + +const promptSelect = `SELECT t.id::text,t.name,t.description,t.category_id::text,coalesce(c.name,''),t.tags,t.department_ids::text[],t.enabled,t.current_version,t.revision,t.created_at,t.updated_at, +v.id::text,v.template_id::text,v.version,v.content,v.variables,v.change_note,v.created_at +FROM gateway.prompt_templates t LEFT JOIN gateway.prompt_categories c ON c.id=t.category_id LEFT JOIN gateway.prompt_versions v ON v.template_id=t.id AND v.version=t.current_version` + +func scanPrompt(row pgx.Row) (PromptTemplate, error) { + var p PromptTemplate + var vID, vTemplate *string + var vVersion *int + var content, change *string + var variables []byte + var versionCreated *time.Time + err := row.Scan(&p.ID, &p.Name, &p.Description, &p.CategoryID, &p.CategoryName, &p.Tags, &p.DepartmentIDs, &p.Enabled, &p.CurrentVersion, &p.Revision, &p.CreatedAt, &p.UpdatedAt, &vID, &vTemplate, &vVersion, &content, &variables, &change, &versionCreated) + if err != nil { + return p, mapNotFound(err) + } + if vID != nil { + v := PromptVersion{ID: *vID, TemplateID: *vTemplate, Version: *vVersion, Content: *content, ChangeNote: *change, CreatedAt: *versionCreated} + _ = json.Unmarshal(variables, &v.Variables) + p.Current = &v + } + return p, nil +} + +func (s *Service) ListPrompts(ctx context.Context) ([]PromptTemplate, error) { + rows, err := s.pool.Query(ctx, promptSelect+` ORDER BY t.updated_at DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + items := []PromptTemplate{} + for rows.Next() { + p, err := scanPrompt(rows) + if err != nil { + return nil, err + } + items = append(items, p) + } + return items, rows.Err() +} + +func (s *Service) GetPrompt(ctx context.Context, id string) (PromptTemplate, error) { + return scanPrompt(s.pool.QueryRow(ctx, promptSelect+` WHERE t.id=$1`, id)) +} + +func (s *Service) CreatePrompt(ctx context.Context, input PromptInput, actorID string) (PromptTemplate, error) { + if err := validatePromptInput(&input, true); err != nil { + return PromptTemplate{}, err + } + if input.Variables == nil { + input.Variables = []Variable{} + } + id, err := newUUID() + if err != nil { + return PromptTemplate{}, err + } + versionID, err := newUUID() + if err != nil { + return PromptTemplate{}, err + } + vars, _ := json.Marshal(input.Variables) + tx, err := s.pool.Begin(ctx) + if err != nil { + return PromptTemplate{}, err + } + defer rollback(ctx, tx) + _, err = tx.Exec(ctx, `INSERT INTO gateway.prompt_templates(id,name,description,category_id,tags,department_ids,enabled,created_by) VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, id, input.Name, input.Description, input.CategoryID, input.Tags, input.DepartmentIDs, input.Enabled, actorID) + if err != nil { + return PromptTemplate{}, err + } + _, err = tx.Exec(ctx, `INSERT INTO gateway.prompt_versions(id,template_id,version,content,variables,change_note,created_by) VALUES($1,$2,1,$3,$4,$5,$6)`, versionID, id, input.Content, vars, input.ChangeNote, actorID) + if err != nil { + return PromptTemplate{}, err + } + _, err = tx.Exec(ctx, `UPDATE gateway.prompt_templates SET current_version=1 WHERE id=$1`, id) + if err != nil { + return PromptTemplate{}, err + } + if err = emit(ctx, tx, "prompt.created", "prompt", id, actorID, map[string]any{"version": 1}); err != nil { + return PromptTemplate{}, err + } + if err = tx.Commit(ctx); err != nil { + return PromptTemplate{}, err + } + return s.GetPrompt(ctx, id) +} + +func (s *Service) UpdatePrompt(ctx context.Context, id string, input PromptInput, actorID string) (PromptTemplate, error) { + if err := validatePromptInput(&input, false); err != nil { + return PromptTemplate{}, err + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return PromptTemplate{}, err + } + defer rollback(ctx, tx) + tag, err := tx.Exec(ctx, `UPDATE gateway.prompt_templates SET name=$2,description=$3,category_id=$4,tags=$5,department_ids=$6,enabled=$7,revision=revision+1,updated_at=clock_timestamp() WHERE id=$1`, id, input.Name, input.Description, input.CategoryID, input.Tags, input.DepartmentIDs, input.Enabled) + if err != nil { + return PromptTemplate{}, err + } + if tag.RowsAffected() == 0 { + return PromptTemplate{}, ErrNotFound + } + if err = emit(ctx, tx, "prompt.updated", "prompt", id, actorID, nil); err != nil { + return PromptTemplate{}, err + } + if err = tx.Commit(ctx); err != nil { + return PromptTemplate{}, err + } + return s.GetPrompt(ctx, id) +} + +func (s *Service) AddPromptVersion(ctx context.Context, id, content string, variables []Variable, changeNote, actorID string, activate bool) (PromptVersion, error) { + content = strings.TrimSpace(content) + changeNote = strings.TrimSpace(changeNote) + if content == "" || len(content) > 100000 { + return PromptVersion{}, errors.New("Prompt 正文不能为空且最多 100000 字符") + } + variables, err := validateVariables(variables) + if err != nil { + return PromptVersion{}, err + } + if variables == nil { + variables = []Variable{} + } + raw, _ := json.Marshal(variables) + versionID, err := newUUID() + if err != nil { + return PromptVersion{}, err + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return PromptVersion{}, err + } + defer rollback(ctx, tx) + var lockedID string + if err = tx.QueryRow(ctx, `SELECT id::text FROM gateway.prompt_templates WHERE id=$1 FOR UPDATE`, id).Scan(&lockedID); errors.Is(err, pgx.ErrNoRows) { + return PromptVersion{}, ErrNotFound + } else if err != nil { + return PromptVersion{}, err + } + var version int + err = tx.QueryRow(ctx, `SELECT coalesce(max(version),0)+1 FROM gateway.prompt_versions WHERE template_id=$1`, id).Scan(&version) + if err != nil { + return PromptVersion{}, err + } + var created time.Time + err = tx.QueryRow(ctx, `INSERT INTO gateway.prompt_versions(id,template_id,version,content,variables,change_note,created_by) SELECT $1,id,$3,$4,$5,$6,$7 FROM gateway.prompt_templates WHERE id=$2 RETURNING created_at`, versionID, id, version, content, raw, changeNote, actorID).Scan(&created) + if errors.Is(err, pgx.ErrNoRows) { + return PromptVersion{}, ErrNotFound + } + if err != nil { + return PromptVersion{}, err + } + if activate { + if _, err = tx.Exec(ctx, `UPDATE gateway.prompt_templates SET current_version=$2,revision=revision+1,updated_at=clock_timestamp() WHERE id=$1`, id, version); err != nil { + return PromptVersion{}, err + } + } + if err = emit(ctx, tx, "prompt.version_created", "prompt", id, actorID, map[string]any{"version": version, "active": activate}); err != nil { + return PromptVersion{}, err + } + if err = tx.Commit(ctx); err != nil { + return PromptVersion{}, err + } + return PromptVersion{ID: versionID, TemplateID: id, Version: version, Content: content, Variables: variables, ChangeNote: changeNote, CreatedAt: created}, nil +} + +func (s *Service) ListPromptVersions(ctx context.Context, id string) ([]PromptVersion, error) { + rows, err := s.pool.Query(ctx, `SELECT id::text,template_id::text,version,content,variables,change_note,created_at FROM gateway.prompt_versions WHERE template_id=$1 ORDER BY version DESC`, id) + if err != nil { + return nil, err + } + defer rows.Close() + items := []PromptVersion{} + for rows.Next() { + var v PromptVersion + var raw []byte + if err = rows.Scan(&v.ID, &v.TemplateID, &v.Version, &v.Content, &raw, &v.ChangeNote, &v.CreatedAt); err != nil { + return nil, err + } + _ = json.Unmarshal(raw, &v.Variables) + items = append(items, v) + } + return items, rows.Err() +} + +func (s *Service) ActivatePromptVersion(ctx context.Context, id string, version int, actorID string) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer rollback(ctx, tx) + tag, err := tx.Exec(ctx, `UPDATE gateway.prompt_templates t SET current_version=$2,revision=revision+1,updated_at=clock_timestamp() WHERE id=$1 AND EXISTS(SELECT 1 FROM gateway.prompt_versions v WHERE v.template_id=t.id AND v.version=$2)`, id, version) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + if err = emit(ctx, tx, "prompt.version_activated", "prompt", id, actorID, map[string]any{"version": version}); err != nil { + return err + } + return tx.Commit(ctx) +} + +func (s *Service) DeletePrompt(ctx context.Context, id, actorID string) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer rollback(ctx, tx) + tag, err := tx.Exec(ctx, `DELETE FROM gateway.prompt_templates WHERE id=$1`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + if err = emit(ctx, tx, "prompt.deleted", "prompt", id, actorID, nil); err != nil { + return err + } + return tx.Commit(ctx) +} + +func (s *Service) RenderPromptByName(ctx context.Context, name string, provided map[string]any) (PromptVersion, string, error) { + var v PromptVersion + var raw []byte + err := s.pool.QueryRow(ctx, `SELECT v.id::text,v.template_id::text,v.version,v.content,v.variables,v.change_note,v.created_at FROM gateway.prompt_templates t JOIN gateway.prompt_versions v ON v.template_id=t.id AND v.version=t.current_version WHERE t.name=$1 AND t.enabled`, name).Scan(&v.ID, &v.TemplateID, &v.Version, &v.Content, &raw, &v.ChangeNote, &v.CreatedAt) + if err != nil { + return v, "", mapNotFound(err) + } + _ = json.Unmarshal(raw, &v.Variables) + rendered, err := RenderPrompt(v.Content, v.Variables, provided) + return v, rendered, err +} diff --git a/internal/workbench/runtime_http.go b/internal/workbench/runtime_http.go new file mode 100644 index 0000000..422b1c5 --- /dev/null +++ b/internal/workbench/runtime_http.go @@ -0,0 +1,688 @@ +package workbench + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "strings" + "time" + + "aigateway.local/core/internal/apikey" + "aigateway.local/core/internal/factcheck" + "aigateway.local/core/internal/gateway" + "aigateway.local/core/internal/platform/apiresponse" +) + +type RuntimeHTTPHandler struct { + service *Service + tools *ToolService + retriever Retriever + auth apikey.PrincipalAuthenticator + gateway http.Handler + factCheck *factcheck.Engine + logger *slog.Logger + mux *http.ServeMux + market MarketplaceDeps +} + +// MarketplaceDeps carries the resource-marketplace services into the runtime +// handler (MCP servers, skills, digital employees, installations). +type MarketplaceDeps struct { + MCPServers *MCPServerService + Skills *SkillService + Employees *DigitalEmployeeService + Market *MarketplaceService + MCPClient *MCPClient +} + +func NewRuntimeHTTPHandler(service *Service, tools *ToolService, retriever Retriever, auth apikey.PrincipalAuthenticator, gatewayHandler http.Handler, market MarketplaceDeps) *RuntimeHTTPHandler { + h := &RuntimeHTTPHandler{service: service, tools: tools, retriever: retriever, auth: auth, gateway: gatewayHandler, mux: http.NewServeMux(), market: market} + h.logger = slog.Default() + h.mux.HandleFunc("GET /v1/prompts", h.listPrompts) + h.mux.HandleFunc("POST /v1/prompts/{name}/render", h.renderPrompt) + h.mux.HandleFunc("POST /v1/knowledge/search", h.searchKnowledge) + h.mux.HandleFunc("POST /v1/knowledge/{id}/search", h.searchKnowledge) + h.mux.HandleFunc("GET /v1/tools", h.listTools) + h.mux.HandleFunc("POST /v1/tools/{code}/invoke", h.invokeTool) + h.mux.HandleFunc("POST /v1/applications/{code}/chat/completions", h.runApplication) + h.mux.HandleFunc("POST /v1/skills/{code}/render", h.renderSkill) + h.mux.HandleFunc("GET /v1/mcp-servers", h.listMCPServers) + h.mux.HandleFunc("GET /v1/mcp-servers/{code}/tools", h.mcpServerTools) + h.mux.HandleFunc("POST /v1/mcp-servers/{code}/tools/{tool}/invoke", h.invokeMCPTool) + h.mux.HandleFunc("POST /v1/digital-employees/{code}/chat/completions", h.runDigitalEmployee) + return h +} +func (h *RuntimeHTTPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.mux.ServeHTTP(w, r) } + +// SetLogger wires a logger for best-effort diagnostics (fact-check skips etc.). +func (h *RuntimeHTTPHandler) SetLogger(logger *slog.Logger) { + if logger != nil { + h.logger = logger + } +} + +// SetFactCheckEngine enables post-answer fact-checking on application +// conversations. When nil (the default) fact-checking is skipped entirely. +func (h *RuntimeHTTPHandler) SetFactCheckEngine(engine *factcheck.Engine) { h.factCheck = engine } + +// factCheckRetriever adapts the workbench Retriever to the fact-check engine's +// EvidenceRetriever interface, reusing the same knowledge-base search path that +// application prompts already use. +type factCheckRetriever struct{ inner Retriever } + +func NewFactCheckRetriever(inner Retriever) *factCheckRetriever { + if inner == nil { + return nil + } + return &factCheckRetriever{inner: inner} +} + +func (a *factCheckRetriever) Search(ctx context.Context, knowledgeBaseID, query string, topK int) ([]factcheck.EvidenceHit, error) { + hits, err := a.inner.Search(ctx, knowledgeBaseID, query, topK) + if err != nil { + return nil, err + } + out := make([]factcheck.EvidenceHit, 0, len(hits)) + for _, hit := range hits { + out = append(out, factcheck.EvidenceHit{DocumentTitle: hit.DocumentTitle, Content: hit.Content}) + } + return out, nil +} + +// VerifyFactCheck satisfies factcheck.Verifier. It routes a non-streaming chat +// completion through the same governed gateway, reusing the original request's +// credential headers so the fact-check call is authenticated and rate-limited +// exactly like the application call that produced the answer. +func (h *RuntimeHTTPHandler) VerifyFactCheck(ctx context.Context, original *http.Request, model, system, user string, timeout time.Duration) (string, error) { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + payload := map[string]any{ + "model": model, + "temperature": 0, + "stream": false, + "messages": []map[string]any{ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + }, + } + raw, _ := json.Marshal(payload) + request := original.Clone(ctx) + request.Method = http.MethodPost + request.URL.Path = "/v1/chat/completions" + request.URL.RawPath = "" + request.Body = ioNopCloser{bytes.NewReader(raw)} + request.ContentLength = int64(len(raw)) + request.Header = request.Header.Clone() + request.Header.Set("Content-Type", "application/json") + recorder := newBoundedRecorder() + h.gateway.ServeHTTP(recorder, request) + if recorder.overrun > 0 { + return "", errors.New("事实核查响应超过 2MB 上限") + } + result := recorder.Result() + defer result.Body.Close() + var decoded map[string]any + if json.NewDecoder(result.Body).Decode(&decoded) != nil { + return "", errors.New("事实核查响应无法解析") + } + if result.StatusCode < 200 || result.StatusCode >= 300 { + return "", fmt.Errorf("事实核查模型调用失败(HTTP %d)", result.StatusCode) + } + content, _ := firstChoiceMessage(decoded)["content"].(string) + if strings.TrimSpace(content) == "" { + return "", errors.New("事实核查模型未返回文本") + } + return content, nil +} +func (h *RuntimeHTTPHandler) principal(w http.ResponseWriter, r *http.Request) (apikey.Principal, bool) { + secret := strings.TrimSpace(r.Header.Get("X-Gateway-API-Key")) + if secret == "" { + value := strings.TrimSpace(r.Header.Get("Authorization")) + if strings.HasPrefix(strings.ToLower(value), "bearer ") { + secret = strings.TrimSpace(value[7:]) + } + } + principal, err := h.auth.AuthenticatePrincipal(r.Context(), secret) + if err != nil { + apiresponse.Error(w, 401, "API Key 无效或已过期") + return principal, false + } + return principal, true +} +func visible(departments []string, principal apikey.Principal, secure bool) bool { + if principal.APIKeyID == "" { + return true + } + if len(departments) == 0 { + return !secure + } + if principal.TenantID == nil { + return false + } + for _, id := range departments { + if id == *principal.TenantID { + return true + } + } + return false +} + +func (h *RuntimeHTTPHandler) listPrompts(w http.ResponseWriter, r *http.Request) { + principal, ok := h.principal(w, r) + if !ok { + return + } + items, err := h.service.ListPrompts(r.Context()) + if err != nil { + apiresponse.Error(w, 503, "Prompt 服务暂不可用") + return + } + result := []map[string]any{} + for _, item := range items { + if item.Enabled && item.Current != nil && visible(item.DepartmentIDs, principal, false) { + result = append(result, map[string]any{"name": item.Name, "description": item.Description, "tags": item.Tags, "version": item.Current.Version, "variables": item.Current.Variables}) + } + } + writeRuntime(w, 200, map[string]any{"object": "list", "data": result}) +} +func (h *RuntimeHTTPHandler) renderPrompt(w http.ResponseWriter, r *http.Request) { + principal, ok := h.principal(w, r) + if !ok { + return + } + var input struct { + Variables map[string]any `json:"variables"` + } + if !decodeRuntime(w, r, &input) { + return + } + items, err := h.service.ListPrompts(r.Context()) + if err != nil { + runtimeError(w, 503, "Prompt 服务暂不可用") + return + } + var selected *PromptTemplate + for i := range items { + if items[i].Name == r.PathValue("name") && items[i].Enabled && visible(items[i].DepartmentIDs, principal, false) { + selected = &items[i] + break + } + } + if selected == nil || selected.Current == nil { + runtimeError(w, 404, "Prompt 不存在或不可见") + return + } + rendered, err := RenderPrompt(selected.Current.Content, selected.Current.Variables, input.Variables) + if err != nil { + runtimeError(w, 400, err.Error()) + return + } + writeRuntime(w, 200, map[string]any{"name": selected.Name, "version": selected.Current.Version, "rendered": rendered}) +} + +func (h *RuntimeHTTPHandler) searchKnowledge(w http.ResponseWriter, r *http.Request) { + principal, ok := h.principal(w, r) + if !ok { + return + } + var input struct { + KnowledgeBaseID string `json:"knowledge_base_id"` + Query string `json:"query"` + TopK int `json:"top_k"` + } + if !decodeRuntime(w, r, &input) { + return + } + id := r.PathValue("id") + if id == "" { + id = input.KnowledgeBaseID + } + kb, err := h.service.GetKnowledgeBase(r.Context(), id) + if err != nil || !kb.Enabled || !visible(kb.DepartmentIDs, principal, false) { + runtimeError(w, 404, "知识库不存在或不可见") + return + } + hits, err := h.retriever.Search(r.Context(), id, input.Query, input.TopK) + if err != nil { + runtimeError(w, 400, err.Error()) + return + } + writeRuntime(w, 200, map[string]any{"knowledge_base_id": id, "results": hits}) +} + +func (h *RuntimeHTTPHandler) listTools(w http.ResponseWriter, r *http.Request) { + principal, ok := h.principal(w, r) + if !ok { + return + } + items, err := h.tools.List(r.Context()) + if err != nil { + runtimeError(w, 503, "工具服务暂不可用") + return + } + result := []map[string]any{} + for _, tool := range items { + if tool.Enabled && visible(tool.DepartmentIDs, principal, true) { + result = append(result, map[string]any{"code": tool.Code, "name": tool.Name, "description": tool.Description, "input_schema": tool.InputSchema}) + } + } + writeRuntime(w, 200, map[string]any{"object": "list", "data": result}) +} +func (h *RuntimeHTTPHandler) invokeTool(w http.ResponseWriter, r *http.Request) { + principal, ok := h.principal(w, r) + if !ok { + return + } + var input struct { + Input map[string]any `json:"input"` + } + if !decodeRuntime(w, r, &input) { + return + } + tool, err := h.tools.GetByCode(r.Context(), r.PathValue("code")) + if err != nil || !visible(tool.DepartmentIDs, principal, true) { + runtimeError(w, 404, "工具不存在或不可调用") + return + } + result, err := h.tools.Execute(r.Context(), tool, input.Input, principal.APIKeyID, gateway.RequestID(r.Context())) + if err != nil { + runtimeError(w, 502, err.Error()) + return + } + writeRuntime(w, 200, result) +} + +type applicationRequest struct { + Messages []map[string]any `json:"messages"` + Variables map[string]any `json:"variables"` +} + +func (h *RuntimeHTTPHandler) runApplication(w http.ResponseWriter, r *http.Request) { + principal, ok := h.principal(w, r) + if !ok { + return + } + var input applicationRequest + if !decodeRuntime(w, r, &input) { + return + } + app, err := h.service.GetPublishedApplicationByCode(r.Context(), r.PathValue("code")) + if err != nil || app.PublishedConfig == nil || !visible(app.DepartmentIDs, principal, false) { + runtimeError(w, 404, "应用不存在、未发布或不可见") + return + } + started := time.Now() + status := "error" + runError := "" + retrievalCount := 0 + toolCount := 0 + defer func() { + runID, idErr := newUUID() + if idErr == nil { + _, _ = h.service.pool.Exec(context.WithoutCancel(r.Context()), `INSERT INTO gateway.application_runs(id,application_id,version,api_key_id,request_id,status,latency_ms,retrieval_count,tool_count,error) VALUES($1,$2,nullif($3,0),nullif($4,'')::uuid,$5,$6,$7,$8,$9,$10)`, runID, app.ID, valueOrZero(app.PublishedVersion), principal.APIKeyID, gateway.RequestID(r.Context()), status, time.Since(started).Milliseconds(), retrievalCount, toolCount, runError) + } + }() + payload, toolsByCode, prepareErr := h.prepareApplication(r.Context(), app, input, principal, &retrievalCount) + if prepareErr != nil { + runError = prepareErr.Error() + runtimeError(w, 400, runError) + return + } + config := *app.PublishedConfig + var response map[string]any + var responseHeaders http.Header + var statusCode int + for round := 0; ; round++ { + statusCode, responseHeaders, response, err = h.callGateway(r, payload) + if err != nil { + runError = err.Error() + copyHeaders(w.Header(), responseHeaders) + runtimeError(w, statusCode, runError) + return + } + calls := extractToolCalls(response) + if len(calls) == 0 { + break + } + if round >= config.MaxToolRounds { + runError = "工具调用轮次已达上限" + runtimeError(w, 502, runError) + return + } + choice := firstChoiceMessage(response) + payload["messages"] = append(payload["messages"].([]map[string]any), choice) + for _, call := range calls { + tool, exists := toolsByCode[call.Name] + if !exists { + runError = "模型请求了未授权工具 " + call.Name + runtimeError(w, 400, runError) + return + } + var args map[string]any + if json.Unmarshal([]byte(call.Arguments), &args) != nil { + args = map[string]any{} + } + result, executeErr := h.tools.Execute(r.Context(), tool, args, principal.APIKeyID, gateway.RequestID(r.Context())) + if executeErr != nil { + runError = executeErr.Error() + runtimeError(w, 502, runError) + return + } + encoded, _ := json.Marshal(result["body"]) + payload["messages"] = append(payload["messages"].([]map[string]any), map[string]any{"role": "tool", "tool_call_id": call.ID, "name": call.Name, "content": string(encoded)}) + toolCount++ + } + } + if h.factCheck != nil { + h.applyFactCheck(r, input, response) + } + response["application"] = map[string]any{"code": app.Code, "name": app.Name, "version": valueOrZero(app.PublishedVersion), "retrieval_count": retrievalCount, "tool_calls": toolCount} + status = "success" + copyHeaders(w.Header(), responseHeaders) + writeRuntime(w, statusCode, response) +} + +// applyFactCheck verifies the assistant answer against configured knowledge +// bases and applies the policy action. It must never fail the chat: any error +// is logged and the answer is returned unchanged. +func (h *RuntimeHTTPHandler) applyFactCheck(r *http.Request, input applicationRequest, response map[string]any) { + answer, _ := assistantAnswer(response) + lastQuestion := lastUserMessage(input.Messages) + if strings.TrimSpace(answer) == "" || strings.TrimSpace(lastQuestion) == "" { + return + } + verifier := func(ctx context.Context, model, system, user string, timeout time.Duration) (string, error) { + return h.VerifyFactCheck(ctx, r, model, system, user, timeout) + } + event, err := h.factCheck.Check(r.Context(), gateway.RequestID(r.Context()), lastQuestion, answer, factcheck.VerifierFunc(verifier)) + if err != nil { + h.logger.Warn("fact-check skipped", "request_id", gateway.RequestID(r.Context()), "error", err) + return + } + if event.ID == "" { + return + } + switch { + case event.Action == "block" && event.Verdict == "unsupported": + overrideAnswer(response, "无法回答:该回复与知识库事实不符,已被事实核查拦截。") + response["fact_check"] = map[string]any{"event_id": event.ID, "verdict": event.Verdict, "support_score": event.SupportScore, "blocked": true} + case event.Action == "annotate": + response["fact_check"] = map[string]any{"event_id": event.ID, "verdict": event.Verdict, "support_score": event.SupportScore, "blocked": false} + } +} + +// lastUserMessage returns the content of the last user message in the request. +func lastUserMessage(messages []map[string]any) string { + last := "" + for _, message := range messages { + role, _ := message["role"].(string) + if role != "user" { + continue + } + if content, ok := message["content"].(string); ok { + last = content + } + } + return last +} + +// assistantAnswer extracts the final assistant text from a gateway response. +func assistantAnswer(response map[string]any) (string, bool) { + content, _ := firstChoiceMessage(response)["content"].(string) + return content, strings.TrimSpace(content) != "" +} + +// overrideAnswer rewrites the assistant message content in place so the portal +// and runtime consumers of response["choices"][0]["message"]["content"] all see +// the fact-checked text. +func overrideAnswer(response map[string]any, content string) { + if message := firstChoiceMessage(response); message != nil { + message["content"] = content + } +} + +func (h *RuntimeHTTPHandler) prepareApplication(ctx context.Context, app Application, input applicationRequest, principal apikey.Principal, retrievalCount *int) (map[string]any, map[string]Tool, error) { + config := *app.PublishedConfig + messages := make([]map[string]any, 0, len(input.Messages)+2) + total := 0 + lastQuestion := "" + for _, message := range input.Messages { + role, _ := message["role"].(string) + content, contentOK := message["content"].(string) + if (role != "user" && role != "assistant") || !contentOK { + return nil, nil, errors.New("应用对话只接受 user/assistant 文本消息") + } + total += len(content) + if total > 100000 { + return nil, nil, errors.New("对话历史超过 100000 字符") + } + messages = append(messages, map[string]any{"role": role, "content": content}) + if role == "user" { + lastQuestion = content + } + } + if lastQuestion == "" { + return nil, nil, errors.New("至少需要一条用户消息") + } + system := []string{} + if config.PromptTemplateID != "" { + prompt, err := h.service.GetPrompt(ctx, config.PromptTemplateID) + // The prompt must be visible to this principal, mirroring the direct + // render/list entry points, or a shared application could leak a + // department-scoped prompt across departments. + if err != nil || prompt.Current == nil || !prompt.Enabled || !visible(prompt.DepartmentIDs, principal, false) { + return nil, nil, errors.New("应用绑定的 Prompt 当前不可用") + } + rendered, err := RenderPrompt(prompt.Current.Content, prompt.Current.Variables, input.Variables) + if err != nil { + return nil, nil, err + } + system = append(system, rendered) + } + evidence := []string{} + for _, kbID := range config.KnowledgeBaseIDs { + kb, err := h.service.GetKnowledgeBase(ctx, kbID) + if err != nil || !kb.Enabled || !visible(kb.DepartmentIDs, principal, false) { + return nil, nil, fmt.Errorf("应用绑定的知识库 %s 当前不可用", kbID) + } + hits, searchErr := h.retriever.Search(ctx, kbID, lastQuestion, config.RetrievalTopK) + if searchErr != nil { + continue + } + for _, hit := range hits { + *retrievalCount++ + evidence = append(evidence, fmt.Sprintf("[资料%d|%s]\n%s", len(evidence)+1, hit.DocumentTitle, hit.Content)) + } + } + if len(evidence) > 0 { + system = append(system, "请优先依据以下企业资料回答;资料不足时明确说明不确定,不得编造。引用时使用[资料N]。\n\n"+strings.Join(evidence, "\n\n")) + } + if len(system) > 0 { + messages = append([]map[string]any{{"role": "system", "content": strings.Join(system, "\n\n")}}, messages...) + } + toolsByCode := map[string]Tool{} + schemas := []map[string]any{} + for _, toolID := range config.ToolIDs { + tool, err := h.tools.Get(ctx, toolID) + // Enforce the same department visibility as the direct tool invoke + // entry point (secure=true because tools carry embedded request + // headers); otherwise an app shared across departments could trigger a + // department-only tool and borrow its stored credentials. + if err != nil || !tool.Enabled || !visible(tool.DepartmentIDs, principal, true) { + return nil, nil, fmt.Errorf("应用绑定的工具 %s 当前不可用", toolID) + } + toolsByCode[tool.Code] = tool + var schema any + _ = json.Unmarshal(tool.InputSchema, &schema) + schemas = append(schemas, map[string]any{"type": "function", "function": map[string]any{"name": tool.Code, "description": tool.Description, "parameters": schema}}) + } + payload := map[string]any{"model": config.Model, "messages": messages, "stream": false, "temperature": config.Temperature} + if len(schemas) > 0 { + payload["tools"] = schemas + payload["tool_choice"] = "auto" + } + return payload, toolsByCode, nil +} + +func (h *RuntimeHTTPHandler) callGateway(original *http.Request, payload map[string]any) (int, http.Header, map[string]any, error) { + raw, _ := json.Marshal(payload) + request := original.Clone(original.Context()) + request.Method = http.MethodPost + request.URL.Path = "/v1/chat/completions" + request.URL.RawPath = "" + request.Body = http.NoBody + if len(raw) > 0 { + request.Body = ioNopCloser{bytes.NewReader(raw)} + } + request.ContentLength = int64(len(raw)) + request.Header = request.Header.Clone() + request.Header.Set("Content-Type", "application/json") + recorder := newBoundedRecorder() + h.gateway.ServeHTTP(recorder, request) + if recorder.overrun > 0 { + return http.StatusBadGateway, recorder.Header(), nil, + errors.New("模型响应超过 2MB 上限,已截断") + } + result := recorder.Result() + defer result.Body.Close() + var decoded map[string]any + if json.NewDecoder(result.Body).Decode(&decoded) != nil { + return result.StatusCode, result.Header, nil, errors.New("模型返回无法解析") + } + if result.StatusCode < 200 || result.StatusCode >= 300 { + message := "应用模型调用失败" + if value, ok := decoded["error"].(map[string]any); ok { + if text, ok := value["message"].(string); ok { + message = text + } + } + return result.StatusCode, result.Header, decoded, errors.New(message) + } + return result.StatusCode, result.Header, decoded, nil +} + +type ioNopCloser struct{ *bytes.Reader } + +func (ioNopCloser) Close() error { return nil } + +// maxGatewayResponseBytes caps how much of a model response a buffered app +// call may hold in memory. Non-streaming conversations go through a recorder +// that buffers the full upstream response; without a cap a long completion +// could exhaust process memory under concurrent app conversations. +const maxGatewayResponseBytes = 2 << 20 // 2 MiB + +// boundedRecorder is a minimal http.ResponseWriter that buffers the response +// up to maxGatewayResponseBytes. Anything beyond the cap is discarded (but +// counted) so a runaway upstream completion can never exhaust memory; callGateway +// turns an overrun into an explicit error instead of decoding truncated JSON. +type boundedRecorder struct { + code int + header http.Header + body bytes.Buffer + overrun int64 +} + +func newBoundedRecorder() *boundedRecorder { + return &boundedRecorder{code: http.StatusOK, header: make(http.Header)} +} + +func (r *boundedRecorder) Header() http.Header { return r.header } + +func (r *boundedRecorder) WriteHeader(code int) { + if r.code != 0 { + return + } + r.code = code +} + +func (r *boundedRecorder) Write(data []byte) (int, error) { + if r.code == 0 { + r.code = http.StatusOK + } + remaining := int64(maxGatewayResponseBytes) - int64(r.body.Len()) + if remaining > 0 { + written := data + if int64(len(written)) > remaining { + written = written[:remaining] + } + _, _ = r.body.Write(written) + } + r.overrun += int64(len(data)) - min(remaining, int64(len(data))) + return len(data), nil +} + +func (r *boundedRecorder) Flush() {} + +func (r *boundedRecorder) Result() *http.Response { + return &http.Response{ + StatusCode: r.code, + Header: r.header, + Body: io.NopCloser(bytes.NewReader(r.body.Bytes())), + } +} + +type toolCall struct{ ID, Name, Arguments string } + +func extractToolCalls(response map[string]any) []toolCall { + message := firstChoiceMessage(response) + rawCalls, ok := message["tool_calls"].([]any) + if !ok { + return nil + } + calls := []toolCall{} + for _, raw := range rawCalls { + item, ok := raw.(map[string]any) + if !ok { + continue + } + fn, _ := item["function"].(map[string]any) + calls = append(calls, toolCall{ID: toString(item["id"]), Name: toString(fn["name"]), Arguments: toString(fn["arguments"])}) + } + return calls +} +func firstChoiceMessage(response map[string]any) map[string]any { + choices, ok := response["choices"].([]any) + if !ok || len(choices) == 0 { + return map[string]any{} + } + choice, _ := choices[0].(map[string]any) + message, _ := choice["message"].(map[string]any) + return message +} +func valueOrZero(value *int) int { + if value == nil { + return 0 + } + return *value +} +func decodeRuntime(w http.ResponseWriter, r *http.Request, target any) bool { + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<20)) + decoder.UseNumber() + if err := decoder.Decode(target); err != nil { + runtimeError(w, 400, "请求格式无效") + return false + } + return true +} +func runtimeError(w http.ResponseWriter, status int, message string) { + writeRuntime(w, status, map[string]any{"error": map[string]any{"message": message, "type": "application_error"}}) +} +func writeRuntime(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} +func copyHeaders(target, source http.Header) { + for key, values := range source { + lower := strings.ToLower(key) + if lower == "connection" || lower == "content-length" || lower == "transfer-encoding" { + continue + } + target[key] = append([]string(nil), values...) + } +} diff --git a/internal/workbench/runtime_marketplace.go b/internal/workbench/runtime_marketplace.go new file mode 100644 index 0000000..29a391e --- /dev/null +++ b/internal/workbench/runtime_marketplace.go @@ -0,0 +1,438 @@ +package workbench + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "aigateway.local/core/internal/apikey" + "aigateway.local/core/internal/gateway" +) + +// toolExecutor runs one tool (regular HTTP tool or MCP tool) during a digital +// employee chat round and returns the tool message body. +type toolExecutor func(ctx context.Context, args map[string]any) (map[string]any, error) + +func (h *RuntimeHTTPHandler) renderSkill(w http.ResponseWriter, r *http.Request) { + principal, ok := h.principal(w, r) + if !ok { + return + } + var input struct { + Variables map[string]any `json:"variables"` + } + if !decodeRuntime(w, r, &input) { + return + } + skill, err := h.market.Skills.GetPublishedByCode(r.Context(), r.PathValue("code")) + if err != nil || !visible(skill.DepartmentIDs, principal, false) { + runtimeError(w, 404, "Skill 不存在或不可见") + return + } + rendered, err := h.market.Skills.Render(skill, input.Variables) + if err != nil { + runtimeError(w, 400, err.Error()) + return + } + writeRuntime(w, 200, map[string]any{"code": skill.Code, "name": skill.Name, "rendered": rendered}) +} + +func (h *RuntimeHTTPHandler) listMCPServers(w http.ResponseWriter, r *http.Request) { + principal, ok := h.principal(w, r) + if !ok { + return + } + portalUserID, err := h.portalUserID(r.Context(), principal) + if err != nil { + runtimeError(w, 503, "安装信息暂不可用") + return + } + servers, err := h.market.MCPServers.List(r.Context()) + if err != nil { + runtimeError(w, 503, "MCP 服务器服务暂不可用") + return + } + result := []map[string]any{} + for _, server := range servers { + if server.Status != "published" || !server.Enabled { + continue + } + allowed, aerr := h.mcpAccessAllowed(r.Context(), principal, portalUserID, server) + if aerr != nil || !allowed { + continue + } + result = append(result, map[string]any{"code": server.Code, "name": server.Name, "description": server.Description, "transport": server.Transport}) + } + writeRuntime(w, 200, map[string]any{"object": "list", "data": result}) +} + +func (h *RuntimeHTTPHandler) mcpServerTools(w http.ResponseWriter, r *http.Request) { + principal, ok := h.principal(w, r) + if !ok { + return + } + server, err := h.market.MCPServers.GetPublishedByCode(r.Context(), r.PathValue("code")) + if err != nil || !h.mcpAccessible(r, principal, server) { + runtimeError(w, 404, "MCP 服务器不存在或不可访问") + return + } + headers, err := h.market.MCPServers.Headers(server) + if err != nil { + runtimeError(w, 503, "MCP 服务器凭据不可用") + return + } + tools, err := h.market.MCPClient.DiscoverTools(r.Context(), server, headers) + if err != nil { + runtimeError(w, 502, err.Error()) + return + } + result := []map[string]any{} + for _, tool := range tools { + result = append(result, map[string]any{"name": mcpToolName(server.Code, tool.Name), "description": tool.Description}) + } + writeRuntime(w, 200, map[string]any{"code": server.Code, "server": server.Name, "data": result}) +} + +func (h *RuntimeHTTPHandler) invokeMCPTool(w http.ResponseWriter, r *http.Request) { + principal, ok := h.principal(w, r) + if !ok { + return + } + server, err := h.market.MCPServers.GetPublishedByCode(r.Context(), r.PathValue("code")) + if err != nil || !h.mcpAccessible(r, principal, server) { + runtimeError(w, 404, "MCP 服务器不存在或不可访问") + return + } + var input struct { + Input map[string]any `json:"input"` + } + if !decodeRuntime(w, r, &input) { + return + } + headers, err := h.market.MCPServers.Headers(server) + if err != nil { + runtimeError(w, 503, "MCP 服务器凭据不可用") + return + } + result, err := h.market.MCPClient.CallTool(r.Context(), server, headers, r.PathValue("tool"), input.Input) + if err != nil { + runtimeError(w, 502, err.Error()) + return + } + status := http.StatusOK + if result.IsError { + status = http.StatusBadGateway + } + writeRuntime(w, status, map[string]any{"name": mcpToolName(server.Code, r.PathValue("tool")), "content": result.Content, "is_error": result.IsError}) +} + +type digitalEmployeeRequest struct { + Messages []map[string]any `json:"messages"` + Variables map[string]any `json:"variables"` +} + +func (h *RuntimeHTTPHandler) runDigitalEmployee(w http.ResponseWriter, r *http.Request) { + principal, ok := h.principal(w, r) + if !ok { + return + } + var input digitalEmployeeRequest + if !decodeRuntime(w, r, &input) { + return + } + employee, err := h.market.Employees.GetPublishedByCode(r.Context(), r.PathValue("code")) + if err != nil { + runtimeError(w, 404, "数字员工不存在或未发布") + return + } + portalUserID, err := h.portalUserID(r.Context(), principal) + if err != nil { + runtimeError(w, 503, "安装信息暂不可用") + return + } + // Department-scoped digital employees are restricted to members of the + // department; everyone else must have installed the resource first. + if !visible(employee.DepartmentIDs, principal, false) { + installed, ierr := h.market.Market.Installed(r.Context(), "digital_employee", employee.ID, portalUserID) + if ierr != nil || !installed { + runtimeError(w, 403, "未安装此数字员工") + return + } + } + started := time.Now() + status := "error" + runError := "" + retrievalCount := 0 + toolCount := 0 + defer func() { + runID, idErr := newUUID() + if idErr == nil { + _, _ = h.service.pool.Exec(context.WithoutCancel(r.Context()), `INSERT INTO gateway.digital_employee_runs(id,digital_employee_id,api_key_id,request_id,status,latency_ms,retrieval_count,tool_count,error) VALUES($1,$2,nullif($3,'')::uuid,$4,$5,$6,$7,$8,$9)`, runID, employee.ID, principal.APIKeyID, gateway.RequestID(r.Context()), status, time.Since(started).Milliseconds(), retrievalCount, toolCount, runError) + } + }() + executors, payload, prepareErr := h.prepareDigitalEmployee(r.Context(), employee, input, principal, &retrievalCount, portalUserID) + if prepareErr != nil { + runError = prepareErr.Error() + runtimeError(w, 400, runError) + return + } + var response map[string]any + var responseHeaders http.Header + var statusCode int + for round := 0; ; round++ { + statusCode, responseHeaders, response, err = h.callGateway(r, payload) + if err != nil { + runError = err.Error() + copyHeaders(w.Header(), responseHeaders) + runtimeError(w, statusCode, runError) + return + } + calls := extractToolCalls(response) + if len(calls) == 0 { + break + } + if round >= employee.MaxToolRounds { + runError = "工具调用轮次已达上限" + runtimeError(w, 502, runError) + return + } + choice := firstChoiceMessage(response) + payload["messages"] = append(payload["messages"].([]map[string]any), choice) + for _, call := range calls { + exec, exists := executors[call.Name] + if !exists { + runError = "模型请求了未授权工具 " + call.Name + runtimeError(w, 400, runError) + return + } + var args map[string]any + if json.Unmarshal([]byte(call.Arguments), &args) != nil { + args = map[string]any{} + } + result, executeErr := exec(r.Context(), args) + if executeErr != nil { + runError = executeErr.Error() + runtimeError(w, 502, runError) + return + } + encoded, _ := json.Marshal(result["body"]) + payload["messages"] = append(payload["messages"].([]map[string]any), map[string]any{"role": "tool", "tool_call_id": call.ID, "name": call.Name, "content": string(encoded)}) + toolCount++ + } + } + response["digital_employee"] = map[string]any{"code": employee.Code, "name": employee.Name, "persona": employee.Persona, "retrieval_count": retrievalCount, "tool_calls": toolCount} + status = "success" + copyHeaders(w.Header(), responseHeaders) + writeRuntime(w, statusCode, response) +} + +// prepareDigitalEmployee assembles the chat payload for a digital employee: +// persona + rendered skills as system context, knowledge RAG evidence, and the +// union of bound tools (regular + MCP) exposed to the model. It returns the +// tool executors keyed by the exact schema name the model may call. +func (h *RuntimeHTTPHandler) prepareDigitalEmployee(ctx context.Context, employee DigitalEmployee, input digitalEmployeeRequest, principal apikey.Principal, retrievalCount *int, portalUserID string) (map[string]toolExecutor, map[string]any, error) { + messages := make([]map[string]any, 0, len(input.Messages)+3) + total := 0 + lastQuestion := "" + for _, message := range input.Messages { + role, _ := message["role"].(string) + content, contentOK := message["content"].(string) + if (role != "user" && role != "assistant") || !contentOK { + return nil, nil, errors.New("数字员工对话只接受 user/assistant 文本消息") + } + total += len(content) + if total > 100000 { + return nil, nil, errors.New("对话历史超过 100000 字符") + } + messages = append(messages, map[string]any{"role": role, "content": content}) + if role == "user" { + lastQuestion = content + } + } + if lastQuestion == "" { + return nil, nil, errors.New("至少需要一条用户消息") + } + system := []string{} + if strings.TrimSpace(employee.Persona) != "" { + system = append(system, employee.Persona) + } + skills := map[string]Skill{} + for _, skillID := range employee.SkillIDs { + skill, err := h.market.Skills.Get(ctx, skillID) + if err != nil || !skill.Enabled || !visible(skill.DepartmentIDs, principal, false) { + return nil, nil, fmt.Errorf("绑定的 Skill %s 当前不可用", skillID) + } + rendered, err := h.market.Skills.Render(skill, input.Variables) + if err != nil { + return nil, nil, err + } + system = append(system, rendered) + skills[skillID] = skill + } + // Knowledge RAG across the employee's own bases and the bases bound to its + // skills (deduplicated). + evidence := []string{} + kbSeen := map[string]bool{} + rag := func(kbID string) error { + if kbSeen[kbID] { + return nil + } + kbSeen[kbID] = true + kb, err := h.service.GetKnowledgeBase(ctx, kbID) + if err != nil || !kb.Enabled || !visible(kb.DepartmentIDs, principal, false) { + return fmt.Errorf("绑定的知识库 %s 当前不可用", kbID) + } + hits, searchErr := h.retriever.Search(ctx, kbID, lastQuestion, employee.RetrievalTopK) + if searchErr != nil { + return nil + } + for _, hit := range hits { + *retrievalCount++ + evidence = append(evidence, fmt.Sprintf("[资料%d|%s]\n%s", len(evidence)+1, hit.DocumentTitle, hit.Content)) + } + return nil + } + for _, kbID := range employee.KnowledgeBaseIDs { + if err := rag(kbID); err != nil { + return nil, nil, err + } + } + for _, skill := range skills { + for _, kbID := range skill.KnowledgeBaseIDs { + if err := rag(kbID); err != nil { + return nil, nil, err + } + } + } + if len(evidence) > 0 { + system = append(system, "请优先依据以下企业资料回答;资料不足时明确说明不确定,不得编造。引用时使用[资料N]。\n\n"+strings.Join(evidence, "\n\n")) + } + executors := map[string]toolExecutor{} + schemas := []map[string]any{} + addTool := func(toolID string) error { + tool, err := h.tools.Get(ctx, toolID) + if err != nil || !tool.Enabled || !visible(tool.DepartmentIDs, principal, true) { + return fmt.Errorf("绑定的工具 %s 当前不可用", toolID) + } + if _, exists := executors[tool.Code]; exists { + return nil + } + executors[tool.Code] = func(ctx context.Context, args map[string]any) (map[string]any, error) { + return h.tools.Execute(ctx, tool, args, principal.APIKeyID, gateway.RequestID(ctx)) + } + var schema any + _ = json.Unmarshal(tool.InputSchema, &schema) + schemas = append(schemas, map[string]any{"type": "function", "function": map[string]any{"name": tool.Code, "description": tool.Description, "parameters": schema}}) + return nil + } + addMCP := func(serverID string) error { + server, err := h.market.MCPServers.Get(ctx, serverID) + if err != nil || !server.Enabled { + return fmt.Errorf("绑定的 MCP 服务器 %s 当前不可用", serverID) + } + allowed, aerr := h.mcpAccessAllowed(ctx, principal, portalUserID, server) + if aerr != nil || !allowed { + return fmt.Errorf("绑定的 MCP 服务器 %s 不可访问", server.Code) + } + headers, err := h.market.MCPServers.Headers(server) + if err != nil { + return fmt.Errorf("绑定的 MCP 服务器 %s 凭据不可用", server.Code) + } + mcpTools, err := h.market.MCPClient.DiscoverTools(ctx, server, headers) + if err != nil { + // A bound server that is transiently unreachable must not brick the + // whole chat; skip its tools and let the employee degrade. + h.logger.Warn("digital employee MCP discovery failed", "server", server.Code, "error", err) + return nil + } + for _, tool := range mcpTools { + name := mcpToolName(server.Code, tool.Name) + if _, exists := executors[name]; exists { + continue + } + executors[name] = func(ctx context.Context, args map[string]any) (map[string]any, error) { + result, err := h.market.MCPClient.CallTool(ctx, server, headers, tool.Name, args) + if err != nil { + return nil, err + } + if result.IsError { + return map[string]any{"body": "[MCP 工具执行失败]\n" + result.Content}, nil + } + return map[string]any{"body": result.Content}, nil + } + var schema any + _ = json.Unmarshal(tool.InputSchema, &schema) + schemas = append(schemas, map[string]any{"type": "function", "function": map[string]any{"name": name, "description": tool.Description, "parameters": schema}}) + } + return nil + } + for _, toolID := range employee.ToolIDs { + if err := addTool(toolID); err != nil { + return nil, nil, err + } + } + for _, skill := range skills { + for _, toolID := range skill.ToolIDs { + if err := addTool(toolID); err != nil { + return nil, nil, err + } + } + } + for _, serverID := range employee.MCPServerIDs { + if err := addMCP(serverID); err != nil { + return nil, nil, err + } + } + for _, skill := range skills { + for _, serverID := range skill.MCPServerIDs { + if err := addMCP(serverID); err != nil { + return nil, nil, err + } + } + } + if len(system) > 0 { + messages = append([]map[string]any{{"role": "system", "content": strings.Join(system, "\n\n")}}, messages...) + } + payload := map[string]any{"model": employee.Model, "messages": messages, "stream": false, "temperature": employee.Temperature} + if len(schemas) > 0 { + payload["tools"] = schemas + payload["tool_choice"] = "auto" + } + return executors, payload, nil +} + +// portalUserID resolves the portal user behind an API key, if any. Resource +// marketplace installs are scoped to portal users. +func (h *RuntimeHTTPHandler) portalUserID(ctx context.Context, principal apikey.Principal) (string, error) { + if principal.APIKeyID == "" { + return "", nil + } + userID, _, err := h.market.Market.PortalUserForAPIKey(ctx, principal.APIKeyID) + return userID, err +} + +// mcpAccessible reports whether a principal may reach a published MCP server: +// either the server is department-visible to them, or they have installed it +// from the marketplace. +func (h *RuntimeHTTPHandler) mcpAccessible(r *http.Request, principal apikey.Principal, server MCPServer) bool { + portalUserID, err := h.portalUserID(r.Context(), principal) + if err != nil { + return false + } + allowed, err := h.mcpAccessAllowed(r.Context(), principal, portalUserID, server) + return err == nil && allowed +} + +func (h *RuntimeHTTPHandler) mcpAccessAllowed(ctx context.Context, principal apikey.Principal, portalUserID string, server MCPServer) (bool, error) { + if visible(server.DepartmentIDs, principal, true) { + return true, nil + } + if portalUserID == "" { + return false, nil + } + return h.market.Market.Installed(ctx, "mcp_server", server.ID, portalUserID) +} diff --git a/internal/workbench/service.go b/internal/workbench/service.go new file mode 100644 index 0000000..f3606e3 --- /dev/null +++ b/internal/workbench/service.go @@ -0,0 +1,69 @@ +package workbench + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + platformid "aigateway.local/core/internal/platform/id" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Service struct{ pool *pgxpool.Pool } + +func NewService(pool *pgxpool.Pool) *Service { return &Service{pool: pool} } + +func newUUID() (string, error) { return platformid.NewUUID() } + +func emit(ctx context.Context, tx pgx.Tx, eventType, aggregateType, aggregateID, actorID string, values map[string]any) error { + eventID, err := newUUID() + if err != nil { + return err + } + if values == nil { + values = make(map[string]any) + } + values[aggregateType+"_id"] = aggregateID + values["actor_id"] = actorID + payload, err := json.Marshal(values) + if err != nil { + return err + } + _, err = tx.Exec(ctx, `INSERT INTO gateway.outbox_events(event_id,event_type,event_version,aggregate_type,aggregate_id,payload) VALUES($1,$2,1,$3,$4,$5)`, eventID, eventType, aggregateType, aggregateID, payload) + return err +} + +func normalizeStrings(values []string, maximum int) ([]string, error) { + seen := make(map[string]struct{}, len(values)) + result := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if len(value) > 255 { + return nil, errors.New("列表项过长") + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + result = append(result, value) + } + if len(result) > maximum { + return nil, fmt.Errorf("列表最多允许 %d 项", maximum) + } + return result, nil +} + +func mapNotFound(err error) error { + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + return err +} + +func rollback(ctx context.Context, tx pgx.Tx) { _ = tx.Rollback(ctx) } diff --git a/internal/workbench/skills.go b/internal/workbench/skills.go new file mode 100644 index 0000000..7088aa4 --- /dev/null +++ b/internal/workbench/skills.go @@ -0,0 +1,223 @@ +package workbench + +import ( + "context" + "encoding/json" + "errors" + "strings" + "time" + + "github.com/jackc/pgx/v5" +) + +type Skill struct { + ID string `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + Description string `json:"description"` + Content string `json:"content"` + Variables []Variable `json:"variables"` + ToolIDs []string `json:"tool_ids"` + MCPServerIDs []string `json:"mcp_server_ids"` + KnowledgeBaseIDs []string `json:"knowledge_base_ids"` + Status string `json:"status"` + CategoryID *string `json:"category_id,omitempty"` + CategoryName string `json:"category_name"` + Tags []string `json:"tags"` + DepartmentIDs []string `json:"department_ids"` + Enabled bool `json:"enabled"` + Revision int64 `json:"revision"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type SkillInput struct { + Code string `json:"code"` + Name string `json:"name"` + Description string `json:"description"` + Content string `json:"content"` + Variables []Variable `json:"variables"` + ToolIDs []string `json:"tool_ids"` + MCPServerIDs []string `json:"mcp_server_ids"` + KnowledgeBaseIDs []string `json:"knowledge_base_ids"` + Status string `json:"status"` + CategoryID *string `json:"category_id,omitempty"` + Tags []string `json:"tags"` + DepartmentIDs []string `json:"department_ids"` + Enabled bool `json:"enabled"` +} + +// SkillService manages packaged capabilities (skills): prompt content plus +// optional tool / MCP server / knowledge-base bindings, publishable to the +// marketplace and consumed by digital employees. +type SkillService struct { + assets *Service +} + +func NewSkillService(assets *Service) *SkillService { return &SkillService{assets: assets} } + +func (s *SkillService) validate(ctx context.Context, input *SkillInput, create bool) error { + input.Code = strings.ToLower(strings.TrimSpace(input.Code)) + input.Name = strings.TrimSpace(input.Name) + input.Description = strings.TrimSpace(input.Description) + input.Content = strings.TrimSpace(input.Content) + if !codeRE.MatchString(input.Code) || input.Name == "" || len(input.Name) > 128 || len(input.Description) > 4000 { + return errors.New("Skill 编码、名称或描述格式无效") + } + if create && (input.Content == "" || len(input.Content) > 100000) { + return errors.New("Skill 正文不能为空且最多 100000 字符") + } + switch input.Status { + case "", "draft": + input.Status = "draft" + case "published", "archived": + default: + return errors.New("无效的资源状态") + } + var err error + if input.Variables, err = validateVariables(input.Variables); err != nil { + return err + } + if input.ToolIDs, err = normalizeStrings(input.ToolIDs, 100); err != nil { + return err + } + if input.MCPServerIDs, err = normalizeStrings(input.MCPServerIDs, 100); err != nil { + return err + } + if input.KnowledgeBaseIDs, err = normalizeStrings(input.KnowledgeBaseIDs, 100); err != nil { + return err + } + if input.CategoryID != nil && strings.TrimSpace(*input.CategoryID) == "" { + input.CategoryID = nil + } + var categoryErr error + input.CategoryID, categoryErr = validCategoryID(ctx, s.assets.pool, input.CategoryID, "skill") + if categoryErr != nil { + return categoryErr + } + if input.Tags, err = normalizeStrings(input.Tags, 30); err != nil { + return err + } + if input.DepartmentIDs, err = normalizeStrings(input.DepartmentIDs, 100); err != nil { + return err + } + return nil +} + +const skillSelect = `SELECT s.id::text,s.code,s.name,s.description,s.content,s.variables,s.tool_ids,s.mcp_server_ids,s.knowledge_base_ids,s.status,s.category_id::text,coalesce(c.name,''),s.tags,s.department_ids::text[],s.enabled,s.revision,s.created_at,s.updated_at FROM gateway.skills s LEFT JOIN gateway.marketplace_categories c ON c.id=s.category_id` + +func scanSkill(row pgx.Row) (Skill, error) { + var s Skill + var variables []byte + err := row.Scan(&s.ID, &s.Code, &s.Name, &s.Description, &s.Content, &variables, &s.ToolIDs, &s.MCPServerIDs, &s.KnowledgeBaseIDs, &s.Status, &s.CategoryID, &s.CategoryName, &s.Tags, &s.DepartmentIDs, &s.Enabled, &s.Revision, &s.CreatedAt, &s.UpdatedAt) + if err != nil { + return s, mapNotFound(err) + } + _ = json.Unmarshal(variables, &s.Variables) + return s, nil +} + +func (s *SkillService) List(ctx context.Context) ([]Skill, error) { + rows, err := s.assets.pool.Query(ctx, skillSelect+` ORDER BY s.updated_at DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Skill{} + for rows.Next() { + skill, err := scanSkill(rows) + if err != nil { + return nil, err + } + items = append(items, skill) + } + return items, rows.Err() +} + +func (s *SkillService) Get(ctx context.Context, id string) (Skill, error) { + return scanSkill(s.assets.pool.QueryRow(ctx, skillSelect+` WHERE s.id=$1`, id)) +} + +func (s *SkillService) GetByCode(ctx context.Context, code string) (Skill, error) { + return scanSkill(s.assets.pool.QueryRow(ctx, skillSelect+` WHERE s.code=$1`, code)) +} + +// GetPublishedByCode returns a published, enabled skill by code. +func (s *SkillService) GetPublishedByCode(ctx context.Context, code string) (Skill, error) { + return scanSkill(s.assets.pool.QueryRow(ctx, skillSelect+` WHERE s.code=$1 AND s.status='published' AND s.enabled`, code)) +} + +func (s *SkillService) Save(ctx context.Context, id string, input SkillInput, actorID string, create bool) (Skill, error) { + if err := s.validate(ctx, &input, create); err != nil { + return Skill{}, err + } + tx, err := s.assets.pool.Begin(ctx) + if err != nil { + return Skill{}, err + } + defer rollback(ctx, tx) + if create { + id, err = newUUID() + if err != nil { + return Skill{}, err + } + variables, _ := json.Marshal(input.Variables) + _, err = tx.Exec(ctx, `INSERT INTO gateway.skills(id,code,name,description,content,variables,tool_ids,mcp_server_ids,knowledge_base_ids,status,category_id,tags,department_ids,enabled,created_by) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)`, id, input.Code, input.Name, input.Description, input.Content, variables, input.ToolIDs, input.MCPServerIDs, input.KnowledgeBaseIDs, input.Status, input.CategoryID, input.Tags, input.DepartmentIDs, input.Enabled, actorID) + } else { + variables, _ := json.Marshal(input.Variables) + tag, updateErr := tx.Exec(ctx, `UPDATE gateway.skills SET code=$2,name=$3,description=$4,content=$5,variables=$6,tool_ids=$7,mcp_server_ids=$8,knowledge_base_ids=$9,status=$10,category_id=$11,tags=$12,department_ids=$13,enabled=$14,revision=revision+1,updated_at=clock_timestamp() WHERE id=$1`, id, input.Code, input.Name, input.Description, input.Content, variables, input.ToolIDs, input.MCPServerIDs, input.KnowledgeBaseIDs, input.Status, input.CategoryID, input.Tags, input.DepartmentIDs, input.Enabled) + err = updateErr + if err == nil && tag.RowsAffected() == 0 { + return Skill{}, ErrNotFound + } + } + if err != nil { + return Skill{}, err + } + event := "skill.updated" + if create { + event = "skill.created" + } + if err = emit(ctx, tx, event, "skill", id, actorID, nil); err != nil { + return Skill{}, err + } + if err = tx.Commit(ctx); err != nil { + return Skill{}, err + } + return s.Get(ctx, id) +} + +func (s *SkillService) Delete(ctx context.Context, id, actorID string) error { + tx, err := s.assets.pool.Begin(ctx) + if err != nil { + return err + } + defer rollback(ctx, tx) + var used bool + if err = tx.QueryRow(ctx, `SELECT EXISTS( + SELECT 1 FROM gateway.digital_employees WHERE $1 = ANY(skill_ids) + UNION ALL SELECT 1 FROM gateway.marketplace_installations WHERE resource_type='skill' AND resource_id=$1 + )`, id).Scan(&used); err != nil { + return err + } + if used { + return ErrConflict + } + tag, err := tx.Exec(ctx, `DELETE FROM gateway.skills WHERE id=$1`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + if err = emit(ctx, tx, "skill.deleted", "skill", id, actorID, nil); err != nil { + return err + } + return tx.Commit(ctx) +} + +// Render applies the skill's variables to its content (same substitution as +// prompt rendering). +func (s *SkillService) Render(skill Skill, provided map[string]any) (string, error) { + return RenderPrompt(skill.Content, skill.Variables, provided) +} diff --git a/internal/workbench/tools.go b/internal/workbench/tools.go new file mode 100644 index 0000000..b9dc479 --- /dev/null +++ b/internal/workbench/tools.go @@ -0,0 +1,365 @@ +package workbench + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "time" + + "aigateway.local/core/internal/platform/cryptox" + "aigateway.local/core/internal/provider" + "github.com/jackc/pgx/v5" +) + +type ToolService struct { + assets *Service + cipher cryptox.Cipher + allowPrivate bool +} + +func NewToolService(assets *Service, cipher cryptox.Cipher, allowPrivate bool) *ToolService { + return &ToolService{assets: assets, cipher: cipher, allowPrivate: allowPrivate} +} + +func (s *ToolService) validate(ctx context.Context, input *ToolInput, create bool) error { + input.Code = strings.ToLower(strings.TrimSpace(input.Code)) + input.Name = strings.TrimSpace(input.Name) + input.Description = strings.TrimSpace(input.Description) + input.EndpointURL = strings.TrimSpace(input.EndpointURL) + input.HTTPMethod = strings.ToUpper(strings.TrimSpace(input.HTTPMethod)) + if !codeRE.MatchString(input.Code) || input.Name == "" || len(input.Name) > 128 || len(input.Description) > 4000 { + return errors.New("工具编码、名称或描述格式无效") + } + if input.HTTPMethod == "" { + input.HTTPMethod = "POST" + } + switch input.HTTPMethod { + case "GET", "POST", "PUT", "PATCH", "DELETE": + default: + return errors.New("不支持的 HTTP 方法") + } + validated, err := provider.ValidateBaseURL(ctx, input.EndpointURL, s.allowPrivate) + if err != nil { + return fmt.Errorf("工具端点校验失败: %w", err) + } + input.EndpointURL = validated + if input.TimeoutSeconds == 0 { + input.TimeoutSeconds = 15 + } + if input.TimeoutSeconds < 1 || input.TimeoutSeconds > 120 { + return errors.New("超时应在 1-120 秒之间") + } + input.DepartmentIDs, err = normalizeStrings(input.DepartmentIDs, 100) + if err != nil { + return err + } + if len(input.InputSchema) == 0 { + input.InputSchema = json.RawMessage(`{}`) + } + var schema map[string]any + if err = json.Unmarshal(input.InputSchema, &schema); err != nil { + return errors.New("input_schema 必须是 JSON 对象") + } + normalized, err := json.Marshal(schema) + if err != nil { + return err + } + input.InputSchema = normalized + if create && input.Headers == nil { + input.Headers = map[string]string{} + } + for key, value := range input.Headers { + if strings.TrimSpace(key) == "" || len(key) > 128 || strings.ContainsAny(key, "\r\n") || len(value) > 8192 || strings.ContainsAny(value, "\r\n") { + return errors.New("工具请求头格式无效") + } + } + return nil +} + +const toolSelect = `SELECT id::text,code,name,description,endpoint_url,http_method,input_schema,timeout_seconds,department_ids::text[],enabled,octet_length(encrypted_headers)>0,revision,created_at,updated_at,encrypted_headers,headers_kek_version FROM gateway.tool_definitions` + +func scanTool(row pgx.Row) (Tool, error) { + var t Tool + err := row.Scan(&t.ID, &t.Code, &t.Name, &t.Description, &t.EndpointURL, &t.HTTPMethod, &t.InputSchema, &t.TimeoutSeconds, &t.DepartmentIDs, &t.Enabled, &t.HasSecretHeaders, &t.Revision, &t.CreatedAt, &t.UpdatedAt, &t.EncryptedHeaders, &t.HeadersKEKVersion) + return t, mapNotFound(err) +} +func (s *ToolService) List(ctx context.Context) ([]Tool, error) { + rows, err := s.assets.pool.Query(ctx, toolSelect+` ORDER BY updated_at DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Tool{} + for rows.Next() { + t, err := scanTool(rows) + if err != nil { + return nil, err + } + items = append(items, t) + } + return items, rows.Err() +} +func (s *ToolService) Get(ctx context.Context, id string) (Tool, error) { + return scanTool(s.assets.pool.QueryRow(ctx, toolSelect+` WHERE id=$1`, id)) +} +func (s *ToolService) GetByCode(ctx context.Context, code string) (Tool, error) { + return scanTool(s.assets.pool.QueryRow(ctx, toolSelect+` WHERE code=$1 AND enabled`, code)) +} + +func (s *ToolService) Save(ctx context.Context, id string, input ToolInput, actorID string, create bool) (Tool, error) { + if err := s.validate(ctx, &input, create); err != nil { + return Tool{}, err + } + tx, err := s.assets.pool.Begin(ctx) + if err != nil { + return Tool{}, err + } + defer rollback(ctx, tx) + var encrypted []byte + var version int + if input.Headers != nil { + raw, _ := json.Marshal(input.Headers) + encrypted, version, err = s.cipher.Encrypt(raw) + if err != nil { + return Tool{}, err + } + } + if create { + id, err = newUUID() + if err != nil { + return Tool{}, err + } + _, err = tx.Exec(ctx, `INSERT INTO gateway.tool_definitions(id,code,name,description,endpoint_url,http_method,encrypted_headers,headers_kek_version,input_schema,timeout_seconds,department_ids,enabled,created_by) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`, id, input.Code, input.Name, input.Description, input.EndpointURL, input.HTTPMethod, encrypted, version, input.InputSchema, input.TimeoutSeconds, input.DepartmentIDs, input.Enabled, actorID) + } else { + if input.Headers == nil { + tag, updateErr := tx.Exec(ctx, `UPDATE gateway.tool_definitions SET code=$2,name=$3,description=$4,endpoint_url=$5,http_method=$6,input_schema=$7,timeout_seconds=$8,department_ids=$9,enabled=$10,revision=revision+1,updated_at=clock_timestamp() WHERE id=$1`, id, input.Code, input.Name, input.Description, input.EndpointURL, input.HTTPMethod, input.InputSchema, input.TimeoutSeconds, input.DepartmentIDs, input.Enabled) + err = updateErr + if err == nil && tag.RowsAffected() == 0 { + return Tool{}, ErrNotFound + } + } else { + tag, updateErr := tx.Exec(ctx, `UPDATE gateway.tool_definitions SET code=$2,name=$3,description=$4,endpoint_url=$5,http_method=$6,encrypted_headers=$7,headers_kek_version=$8,input_schema=$9,timeout_seconds=$10,department_ids=$11,enabled=$12,revision=revision+1,updated_at=clock_timestamp() WHERE id=$1`, id, input.Code, input.Name, input.Description, input.EndpointURL, input.HTTPMethod, encrypted, version, input.InputSchema, input.TimeoutSeconds, input.DepartmentIDs, input.Enabled) + err = updateErr + if err == nil && tag.RowsAffected() == 0 { + return Tool{}, ErrNotFound + } + } + } + if err != nil { + return Tool{}, err + } + event := "tool.updated" + if create { + event = "tool.created" + } + if err = emit(ctx, tx, event, "tool", id, actorID, nil); err != nil { + return Tool{}, err + } + if err = tx.Commit(ctx); err != nil { + return Tool{}, err + } + return s.Get(ctx, id) +} + +func (s *ToolService) Delete(ctx context.Context, id, actorID string) error { + tx, err := s.assets.pool.Begin(ctx) + if err != nil { + return err + } + defer rollback(ctx, tx) + var used bool + if err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gateway.applications WHERE draft_config->'tool_ids' ? $1 UNION ALL SELECT 1 FROM gateway.application_versions WHERE config->'tool_ids' ? $1)`, id).Scan(&used); err != nil { + return err + } + if used { + return ErrConflict + } + tag, err := tx.Exec(ctx, `DELETE FROM gateway.tool_definitions WHERE id=$1`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + if err = emit(ctx, tx, "tool.deleted", "tool", id, actorID, nil); err != nil { + return err + } + return tx.Commit(ctx) +} + +func (s *ToolService) headers(tool Tool) (map[string]string, error) { + plain, err := s.cipher.Decrypt(tool.EncryptedHeaders, tool.HeadersKEKVersion) + if err != nil { + return nil, err + } + headers := map[string]string{} + if err = json.Unmarshal(plain, &headers); err != nil { + return nil, errors.New("工具请求头密文内容无效") + } + return headers, nil +} + +func (s *ToolService) Execute(ctx context.Context, tool Tool, input map[string]any, apiKeyID, requestID string) (result map[string]any, err error) { + started := time.Now() + status := "success" + var responseStatus *int + defer func() { + if err != nil { + status = "error" + } + runID, idErr := newUUID() + if idErr == nil { + message := "" + if err != nil { + message = err.Error() + if len(message) > 1000 { + message = message[:1000] + } + } + _, _ = s.assets.pool.Exec(context.WithoutCancel(ctx), `INSERT INTO gateway.tool_runs(id,tool_id,api_key_id,request_id,status,response_status,latency_ms,error) VALUES($1,$2,nullif($3,'')::uuid,$4,$5,$6,$7,$8)`, runID, tool.ID, apiKeyID, requestID, status, responseStatus, time.Since(started).Milliseconds(), message) + } + }() + if err = validateToolInput(tool.InputSchema, input); err != nil { + return nil, err + } + headers, err := s.headers(tool) + if err != nil { + return nil, err + } + payload, err := json.Marshal(input) + if err != nil { + return nil, err + } + var body io.Reader + parsed, err := url.Parse(tool.EndpointURL) + if err != nil { + return nil, err + } + if tool.HTTPMethod == http.MethodGet { + query := parsed.Query() + for key, value := range input { + query.Set(key, toString(value)) + } + parsed.RawQuery = query.Encode() + } else { + body = bytes.NewReader(payload) + } + request, err := http.NewRequestWithContext(ctx, tool.HTTPMethod, parsed.String(), body) + if err != nil { + return nil, err + } + for key, value := range headers { + request.Header.Set(key, value) + } + request.Header.Set("Accept", "application/json") + if body != nil { + request.Header.Set("Content-Type", "application/json") + } + client := &http.Client{Timeout: time.Duration(tool.TimeoutSeconds) * time.Second, Transport: &http.Transport{DialContext: safeToolDial(s.allowPrivate), ForceAttemptHTTP2: true, TLSHandshakeTimeout: 5 * time.Second, ResponseHeaderTimeout: time.Duration(tool.TimeoutSeconds) * time.Second}, CheckRedirect: func(*http.Request, []*http.Request) error { return errors.New("工具端点不允许重定向") }} + response, err := client.Do(request) + if err != nil { + return nil, fmt.Errorf("工具调用失败: %w", err) + } + defer response.Body.Close() + code := response.StatusCode + responseStatus = &code + raw, err := io.ReadAll(io.LimitReader(response.Body, (1<<20)+1)) + if err != nil { + return nil, err + } + if len(raw) > 1<<20 { + return nil, errors.New("工具响应超过 1 MiB") + } + var decoded any + if json.Unmarshal(raw, &decoded) != nil { + decoded = string(raw) + } + return map[string]any{"status_code": response.StatusCode, "body": decoded}, nil +} + +func validateToolInput(raw json.RawMessage, input map[string]any) error { + var schema struct { + Required []string `json:"required"` + Properties map[string]struct { + Type string `json:"type"` + } `json:"properties"` + } + if len(raw) == 0 { + return nil + } + if err := json.Unmarshal(raw, &schema); err != nil { + return errors.New("工具 input_schema 无效") + } + for _, name := range schema.Required { + if _, ok := input[name]; !ok { + return fmt.Errorf("缺少工具必填参数 %s", name) + } + } + for name, property := range schema.Properties { + value, ok := input[name] + if !ok || property.Type == "" { + continue + } + valid := false + switch property.Type { + case "string": + _, valid = value.(string) + case "number": + switch value.(type) { + case float64, float32, int, int64, json.Number: + valid = true + } + case "integer": + switch v := value.(type) { + case int, int64: + valid = true + case float64: + valid = v == float64(int64(v)) + } + case "boolean": + _, valid = value.(bool) + case "object": + _, valid = value.(map[string]any) + case "array": + _, valid = value.([]any) + } + if !valid { + return fmt.Errorf("工具参数 %s 类型应为 %s", name, property.Type) + } + } + return nil +} + +func safeToolDial(allowPrivate bool) func(context.Context, string, string) (net.Conn, error) { + dialer := &net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second} + if allowPrivate { + return dialer.DialContext + } + return func(ctx context.Context, network, address string) (net.Conn, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, err + } + addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil { + return nil, err + } + if len(addresses) == 0 { + return nil, errors.New("工具主机没有解析结果") + } + for _, candidate := range addresses { + ip := candidate.IP + if ip == nil || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified() { + return nil, fmt.Errorf("工具主机解析到受限地址 %s", ip) + } + } + return dialer.DialContext(ctx, network, net.JoinHostPort(addresses[0].IP.String(), port)) + } +} diff --git a/internal/workbench/types.go b/internal/workbench/types.go new file mode 100644 index 0000000..e2920bb --- /dev/null +++ b/internal/workbench/types.go @@ -0,0 +1,251 @@ +package workbench + +import ( + "encoding/json" + "errors" + "regexp" + "strings" + "time" +) + +var ( + ErrNotFound = errors.New("AI asset not found") + ErrConflict = errors.New("AI asset conflict") + codeRE = regexp.MustCompile(`^[a-z][a-z0-9_-]{1,63}$`) + variableRE = regexp.MustCompile(`\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}`) +) + +type Variable struct { + Name string `json:"name"` + Label string `json:"label,omitempty"` + Default string `json:"default,omitempty"` + Required bool `json:"required"` +} + +type PromptTemplate struct { + ID, Name, Description string `json:"-"` + CategoryID *string `json:"-"` + CategoryName string `json:"-"` + Tags []string `json:"-"` + DepartmentIDs []string `json:"-"` + Enabled bool `json:"-"` + CurrentVersion *int `json:"-"` + Current *PromptVersion `json:"-"` + Revision int64 `json:"-"` + CreatedAt, UpdatedAt time.Time `json:"-"` +} + +func (p PromptTemplate) MarshalJSON() ([]byte, error) { + type output struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + CategoryID *string `json:"category_id,omitempty"` + CategoryName string `json:"category_name"` + Tags []string `json:"tags"` + DepartmentIDs []string `json:"department_ids"` + Enabled bool `json:"enabled"` + CurrentVersion *int `json:"current_version,omitempty"` + Current *PromptVersion `json:"current,omitempty"` + Revision int64 `json:"revision"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + } + return json.Marshal(output{p.ID, p.Name, p.Description, p.CategoryID, p.CategoryName, nonNil(p.Tags), nonNil(p.DepartmentIDs), p.Enabled, p.CurrentVersion, p.Current, p.Revision, p.CreatedAt, p.UpdatedAt}) +} + +type PromptVersion struct { + ID string `json:"id"` + TemplateID string `json:"template_id"` + Version int `json:"version"` + Content string `json:"content"` + Variables []Variable `json:"variables"` + ChangeNote string `json:"change_note"` + CreatedAt time.Time `json:"created_at"` +} + +type PromptInput struct { + Name, Description string + CategoryID *string + Tags, DepartmentIDs []string + Enabled bool + Content, ChangeNote string + Variables []Variable +} + +type KnowledgeBase struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + RetrievalMode string `json:"retrieval_mode"` + ChunkSize int `json:"chunk_size"` + ChunkOverlap int `json:"chunk_overlap"` + DepartmentIDs []string `json:"department_ids"` + Enabled bool `json:"enabled"` + Revision int64 `json:"revision"` + DocumentCount int `json:"document_count"` + ChunkCount int `json:"chunk_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type KnowledgeDocument struct { + ID string `json:"id"` + KnowledgeBaseID string `json:"knowledge_base_id"` + Title string `json:"title"` + SourceType string `json:"source_type"` + SourceURI string `json:"source_uri"` + ContentSHA256 string `json:"content_sha256"` + Status string `json:"status"` + StatusMessage string `json:"status_message"` + CharCount int `json:"char_count"` + ChunkCount int `json:"chunk_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type SearchHit struct { + ChunkID string `json:"chunk_id"` + DocumentID string `json:"document_id"` + DocumentTitle string `json:"document_title"` + Content string `json:"content"` + ChunkIndex int `json:"chunk_index"` + Score float64 `json:"score"` +} + +type Tool struct { + ID string `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + Description string `json:"description"` + EndpointURL string `json:"endpoint_url"` + HTTPMethod string `json:"http_method"` + InputSchema json.RawMessage `json:"input_schema"` + TimeoutSeconds int `json:"timeout_seconds"` + DepartmentIDs []string `json:"department_ids"` + Enabled bool `json:"enabled"` + HasSecretHeaders bool `json:"has_secret_headers"` + Revision int64 `json:"revision"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + EncryptedHeaders []byte `json:"-"` + HeadersKEKVersion int `json:"-"` +} + +type ToolInput struct { + Code, Name, Description, EndpointURL, HTTPMethod string + Headers map[string]string + InputSchema json.RawMessage + TimeoutSeconds int + DepartmentIDs []string + Enabled bool +} + +type ApplicationConfig struct { + Model string `json:"model"` + PromptTemplateID string `json:"prompt_template_id,omitempty"` + KnowledgeBaseIDs []string `json:"knowledge_base_ids"` + ToolIDs []string `json:"tool_ids"` + RetrievalTopK int `json:"retrieval_top_k"` + Temperature float64 `json:"temperature"` + MaxToolRounds int `json:"max_tool_rounds"` +} + +type Application struct { + ID string `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + Description string `json:"description"` + Status string `json:"status"` + DepartmentIDs []string `json:"department_ids"` + DraftConfig ApplicationConfig `json:"draft_config"` + PublishedVersion *int `json:"published_version,omitempty"` + PublishedConfig *ApplicationConfig `json:"published_config,omitempty"` + Revision int64 `json:"revision"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ApplicationRun struct { + ID string `json:"id"` + ApplicationID string `json:"application_id"` + RequestID string `json:"request_id"` + Status string `json:"status"` + Error string `json:"error"` + Version int `json:"version"` + LatencyMS int64 `json:"latency_ms"` + RetrievalCount int `json:"retrieval_count"` + ToolCount int `json:"tool_count"` + CreatedAt time.Time `json:"created_at"` +} + +type NotificationChannel struct { + ID string `json:"id"` + Name string `json:"name"` + WebhookURL string `json:"webhook_url"` + EventPatterns []string `json:"event_patterns"` + Enabled bool `json:"enabled"` + HasSigningSecret bool `json:"has_signing_secret"` + Revision int64 `json:"revision"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + EncryptedSigningSecret []byte `json:"-"` + SigningSecretKEKVersion int `json:"-"` +} + +type NotificationDelivery struct { + ID string `json:"id"` + ChannelID string `json:"channel_id"` + ChannelName string `json:"channel_name"` + EventID string `json:"event_id"` + EventType string `json:"event_type"` + Status string `json:"status"` + LastError string `json:"last_error"` + Payload json.RawMessage `json:"payload"` + Attempts int `json:"attempts"` + ResponseStatus *int `json:"response_status,omitempty"` + DeliveredAt *time.Time `json:"delivered_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func RenderPrompt(content string, definitions []Variable, provided map[string]any) (string, error) { + values := make(map[string]string, len(definitions)) + missing := make([]string, 0) + for _, variable := range definitions { + if raw, ok := provided[variable.Name]; ok && strings.TrimSpace(toString(raw)) != "" { + values[variable.Name] = toString(raw) + } else if variable.Default != "" { + values[variable.Name] = variable.Default + } else if variable.Required { + missing = append(missing, variable.Name) + } else { + values[variable.Name] = "" + } + } + if len(missing) > 0 { + return "", errors.New("缺少必填变量: " + strings.Join(missing, ", ")) + } + return variableRE.ReplaceAllStringFunc(content, func(match string) string { + parts := variableRE.FindStringSubmatch(match) + if value, ok := values[parts[1]]; ok { + return value + } + return match + }), nil +} + +func toString(value any) string { + if text, ok := value.(string); ok { + return text + } + encoded, _ := json.Marshal(value) + return string(encoded) +} + +func nonNil[T any](value []T) []T { + if value == nil { + return []T{} + } + return value +} diff --git a/internal/workbench/workbench_integration_test.go b/internal/workbench/workbench_integration_test.go new file mode 100644 index 0000000..48769ed --- /dev/null +++ b/internal/workbench/workbench_integration_test.go @@ -0,0 +1,155 @@ +package workbench + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "aigateway.local/core/internal/apikey" + "aigateway.local/core/internal/gateway" + "aigateway.local/core/internal/platform/config" + "aigateway.local/core/internal/platform/cryptox" + "aigateway.local/core/internal/platform/database" +) + +func TestWorkbenchPostgreSQLLifecycle(t *testing.T) { + databaseURL := os.Getenv("WORKBENCH_TEST_DATABASE_URL") + if databaseURL == "" { + t.Skip("WORKBENCH_TEST_DATABASE_URL is not set") + } + ctx := context.Background() + pool, err := database.Open(ctx, config.Database{URL: databaseURL, MaxConns: 8, MinConns: 0}) + if err != nil { + t.Fatal(err) + } + defer pool.Close() + actorID := "11111111-1111-4111-8111-111111111111" + _, err = pool.Exec(ctx, `INSERT INTO gateway.admin_accounts(id,username,password_hash,role) VALUES($1,'m4-test','test','superadmin') ON CONFLICT(id) DO NOTHING`, actorID) + if err != nil { + t.Fatal(err) + } + cleanup := func() { + _, _ = pool.Exec(ctx, `DELETE FROM gateway.notification_channels WHERE name='m4-webhook'; DELETE FROM gateway.applications WHERE code='m4_app'; DELETE FROM gateway.tool_definitions WHERE code='m4_lookup'; DELETE FROM gateway.knowledge_bases WHERE name='m4-integration-kb'; DELETE FROM gateway.prompt_templates WHERE name='m4-integration-prompt'`) + } + cleanup() + defer cleanup() + assets := NewService(pool) + prompt, err := assets.CreatePrompt(ctx, PromptInput{Name: "m4-integration-prompt", Description: "test", Enabled: true, Content: "请回答 {{question}}", Variables: []Variable{{Name: "question", Required: true}}}, actorID) + if err != nil { + t.Fatal(err) + } + if prompt.Current == nil || prompt.Current.Version != 1 { + t.Fatalf("unexpected prompt: %#v", prompt) + } + version, err := assets.AddPromptVersion(ctx, prompt.ID, "新版 {{question}}", []Variable{{Name: "question", Required: true}}, "v2", actorID, true) + if err != nil || version.Version != 2 { + t.Fatalf("version=%#v err=%v", version, err) + } + _, rendered, err := assets.RenderPromptByName(ctx, prompt.Name, map[string]any{"question": "可扩展吗"}) + if err != nil || rendered != "新版 可扩展吗" { + t.Fatalf("render=%q err=%v", rendered, err) + } + kb, err := assets.SaveKnowledgeBase(ctx, KnowledgeBase{Name: "m4-integration-kb", Description: "test", RetrievalMode: "postgres_fts", ChunkSize: 200, ChunkOverlap: 20, Enabled: true}, actorID, true) + if err != nil { + t.Fatal(err) + } + doc, err := assets.AddKnowledgeDocument(ctx, kb.ID, "Go 架构说明", "text", "", "Go 网关采用不可变运行时快照。\n\nPostgreSQL 是权威配置存储。", actorID) + if err != nil { + t.Fatal(err) + } + if doc.ChunkCount == 0 { + t.Fatal("expected chunks") + } + hits, err := NewPostgreSQLRetriever(assets).Search(ctx, kb.ID, "不可变运行时快照", 4) + if err != nil || len(hits) == 0 { + t.Fatalf("hits=%#v err=%v", hits, err) + } + key := base64.StdEncoding.EncodeToString(make([]byte, 32)) + cipher, err := cryptox.NewKeyring(key, 1, "", "m4-test-tools") + if err != nil { + t.Fatal(err) + } + receivedAuth := "" + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedAuth = r.Header.Get("Authorization") + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) + })) + defer upstream.Close() + tools := NewToolService(assets, cipher, true) + tool, err := tools.Save(ctx, "", ToolInput{Code: "m4_lookup", Name: "查询", EndpointURL: upstream.URL, HTTPMethod: "POST", Headers: map[string]string{"Authorization": "Bearer secret"}, InputSchema: json.RawMessage(`{"type":"object","required":["q"],"properties":{"q":{"type":"string"}}}`), TimeoutSeconds: 5, Enabled: true}, actorID, true) + if err != nil { + t.Fatal(err) + } + result, err := tools.Execute(ctx, tool, map[string]any{"q": "gateway"}, "", "m4-test") + if err != nil { + t.Fatal(err) + } + if result["status_code"] != http.StatusOK || receivedAuth != "Bearer secret" { + t.Fatalf("result=%#v auth=%q", result, receivedAuth) + } + app, err := assets.SaveApplication(ctx, Application{Code: "m4_app", Name: "M4 App", Status: "draft", DraftConfig: ApplicationConfig{Model: "test-model", PromptTemplateID: prompt.ID, KnowledgeBaseIDs: []string{kb.ID}, ToolIDs: []string{tool.ID}, RetrievalTopK: 4, Temperature: .2, MaxToolRounds: 2}}, actorID, true) + if err != nil { + t.Fatal(err) + } + published, err := assets.PublishApplication(ctx, app.ID, "first", actorID) + if err != nil { + t.Fatal(err) + } + if published.PublishedVersion == nil || *published.PublishedVersion != 1 || published.Status != "active" { + t.Fatalf("unexpected published app: %#v", published) + } + governed := false + fakeGateway := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var payload map[string]any + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Error(err) + } + messages, _ := payload["messages"].([]any) + if len(messages) > 0 && strings.Contains(toString(messages[0]), "不可变运行时快照") { + governed = true + } + _ = json.NewEncoder(w).Encode(map[string]any{"choices": []any{map[string]any{"message": map[string]any{"role": "assistant", "content": "完成"}}}}) + }) + runtime := NewRuntimeHTTPHandler(assets, tools, NewPostgreSQLRetriever(assets), staticPrincipalAuthenticator{}, fakeGateway, MarketplaceDeps{}) + runtimeRequest := httptest.NewRequest(http.MethodPost, "/v1/applications/m4_app/chat/completions", bytes.NewBufferString(`{"messages":[{"role":"user","content":"不可变运行时快照是什么?"}],"variables":{"question":"架构"}}`)) + runtimeRequest.Header.Set("Authorization", "Bearer test") + runtimeRequest = runtimeRequest.WithContext(gateway.WithRequestID(runtimeRequest.Context(), "m4-runtime")) + runtimeResponse := httptest.NewRecorder() + runtime.ServeHTTP(runtimeResponse, runtimeRequest) + if runtimeResponse.Code != http.StatusOK || !governed || !strings.Contains(runtimeResponse.Body.String(), `"application"`) { + t.Fatalf("runtime status=%d governed=%v body=%s", runtimeResponse.Code, governed, runtimeResponse.Body.String()) + } + + signed := "" + webhook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + signed = r.Header.Get("X-Gateway-Signature") + w.WriteHeader(http.StatusNoContent) + })) + defer webhook.Close() + notifications := NewNotificationService(assets, cipher, true) + secret := "m4-signing-secret" + channel, err := notifications.SaveChannel(ctx, "", NotificationInput{Name: "m4-webhook", WebhookURL: webhook.URL, SigningSecret: &secret, EventPatterns: []string{"application.*"}, Enabled: true}, actorID, true) + if err != nil { + t.Fatal(err) + } + eventID, _ := newUUID() + delivery, err := notifications.ensureDelivery(ctx, channel, eventID, "application.published", json.RawMessage(`{"version":1}`)) + if err != nil { + t.Fatal(err) + } + if err = notifications.deliver(ctx, channel, delivery); err != nil || !strings.HasPrefix(signed, "sha256=") { + t.Fatalf("delivery err=%v signature=%q", err, signed) + } +} + +type staticPrincipalAuthenticator struct{} + +func (staticPrincipalAuthenticator) AuthenticatePrincipal(context.Context, string) (apikey.Principal, error) { + return apikey.Principal{}, nil +} diff --git a/internal/workbench/workbench_test.go b/internal/workbench/workbench_test.go new file mode 100644 index 0000000..9d1fc74 --- /dev/null +++ b/internal/workbench/workbench_test.go @@ -0,0 +1,51 @@ +package workbench + +import ( + "reflect" + "testing" +) + +func TestRenderPromptValidatesRequiredVariables(t *testing.T) { + definitions := []Variable{{Name: "topic", Required: true}, {Name: "tone", Default: "简洁"}} + rendered, err := RenderPrompt("用{{ tone }}风格介绍 {{topic}},保留 {{unknown}}。", definitions, map[string]any{"topic": "Go"}) + if err != nil { + t.Fatal(err) + } + if rendered != "用简洁风格介绍 Go,保留 {{unknown}}。" { + t.Fatalf("unexpected render: %q", rendered) + } + if _, err = RenderPrompt("{{topic}}", definitions, nil); err == nil { + t.Fatal("expected missing variable error") + } +} + +func TestChunkTextParagraphAwareAndOverlapped(t *testing.T) { + text := "第一段。\n\n" + repeatRune('甲', 240) + "\n\n最后一段。" + chunks := ChunkText(text, 200, 20) + if len(chunks) < 3 { + t.Fatalf("expected at least 3 chunks, got %d", len(chunks)) + } + for _, chunk := range chunks { + if RuneCount(chunk) > 222 { + t.Fatalf("chunk unexpectedly large: %d", RuneCount(chunk)) + } + } +} + +func TestNormalizeStringsStableDeduplication(t *testing.T) { + values, err := normalizeStrings([]string{" b ", "a", "b", ""}, 10) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(values, []string{"b", "a"}) { + t.Fatalf("unexpected values: %#v", values) + } +} + +func repeatRune(value rune, count int) string { + runes := make([]rune, count) + for i := range runes { + runes[i] = value + } + return string(runes) +} diff --git a/migrations/000001_foundation.sql b/migrations/000001_foundation.sql new file mode 100644 index 0000000..50e94c3 --- /dev/null +++ b/migrations/000001_foundation.sql @@ -0,0 +1,97 @@ +CREATE SCHEMA IF NOT EXISTS gateway; + +CREATE TABLE IF NOT EXISTS gateway.outbox_events ( + event_id uuid PRIMARY KEY, + event_type text NOT NULL, + event_version integer NOT NULL CHECK (event_version > 0), + tenant_id uuid, + aggregate_type text NOT NULL, + aggregate_id text NOT NULL, + payload jsonb NOT NULL, + trace_context jsonb NOT NULL DEFAULT '{}'::jsonb, + occurred_at timestamptz NOT NULL DEFAULT clock_timestamp(), + available_at timestamptz NOT NULL DEFAULT clock_timestamp(), + attempts integer NOT NULL DEFAULT 0 CHECK (attempts >= 0), + locked_at timestamptz, + locked_by text, + processed_at timestamptz, + last_error text +); + +CREATE INDEX IF NOT EXISTS outbox_events_pending_idx + ON gateway.outbox_events (available_at, occurred_at) + WHERE processed_at IS NULL; + +CREATE TABLE IF NOT EXISTS gateway.event_consumptions ( + subscriber text NOT NULL, + event_id uuid NOT NULL, + consumed_at timestamptz NOT NULL DEFAULT clock_timestamp(), + PRIMARY KEY (subscriber, event_id) +); + +CREATE TABLE IF NOT EXISTS gateway.api_keys ( + id uuid PRIMARY KEY, + tenant_id uuid, + name text NOT NULL, + key_prefix varchar(16) NOT NULL, + key_hash bytea NOT NULL, + scopes text[] NOT NULL DEFAULT '{}', + enabled boolean NOT NULL DEFAULT true, + expires_at timestamptz, + last_used_at timestamptz, + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + updated_at timestamptz NOT NULL DEFAULT clock_timestamp(), + UNIQUE (key_prefix, key_hash) +); + +CREATE INDEX IF NOT EXISTS api_keys_tenant_idx ON gateway.api_keys (tenant_id, enabled); + +CREATE TABLE IF NOT EXISTS gateway.providers ( + id uuid PRIMARY KEY, + tenant_id uuid, + code text NOT NULL, + adapter text NOT NULL, + base_url text NOT NULL, + encrypted_credentials bytea NOT NULL, + credential_kek_version integer NOT NULL, + capabilities text[] NOT NULL DEFAULT '{}', + config jsonb NOT NULL DEFAULT '{}'::jsonb, + enabled boolean NOT NULL DEFAULT true, + revision bigint NOT NULL DEFAULT 1, + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + updated_at timestamptz NOT NULL DEFAULT clock_timestamp(), + UNIQUE NULLS NOT DISTINCT (tenant_id, code) +); + +CREATE TABLE IF NOT EXISTS gateway.audit_events ( + id uuid NOT NULL, + tenant_id uuid, + request_id text NOT NULL, + actor_id uuid, + api_key_id uuid, + provider_code text, + model text, + protocol text NOT NULL, + status_code integer, + prompt_tokens bigint CHECK (prompt_tokens IS NULL OR prompt_tokens >= 0), + completion_tokens bigint CHECK (completion_tokens IS NULL OR completion_tokens >= 0), + cost_microunits bigint CHECK (cost_microunits IS NULL OR cost_microunits >= 0), + latency_ms integer CHECK (latency_ms IS NULL OR latency_ms >= 0), + request_preview text CHECK (request_preview IS NULL OR octet_length(request_preview) <= 65536), + response_preview text CHECK (response_preview IS NULL OR octet_length(response_preview) <= 65536), + labels jsonb NOT NULL DEFAULT '{}'::jsonb, + recorded_at timestamptz NOT NULL DEFAULT clock_timestamp(), + PRIMARY KEY (id, recorded_at) +) PARTITION BY RANGE (recorded_at); + +CREATE TABLE IF NOT EXISTS gateway.audit_events_default + PARTITION OF gateway.audit_events DEFAULT; + +CREATE INDEX IF NOT EXISTS audit_events_default_tenant_time_idx + ON gateway.audit_events_default (tenant_id, recorded_at DESC); +CREATE INDEX IF NOT EXISTS audit_events_default_request_idx + ON gateway.audit_events_default (request_id); + +COMMENT ON TABLE gateway.audit_events IS + 'PostgreSQL baseline audit storage. Full unbounded bodies are deliberately not retained.'; + diff --git a/migrations/000002_identity.sql b/migrations/000002_identity.sql new file mode 100644 index 0000000..5cf120e --- /dev/null +++ b/migrations/000002_identity.sql @@ -0,0 +1,82 @@ +CREATE TABLE IF NOT EXISTS gateway.departments ( + id uuid PRIMARY KEY, + tenant_id uuid, + code text NOT NULL, + name text NOT NULL, + active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + updated_at timestamptz NOT NULL DEFAULT clock_timestamp(), + UNIQUE NULLS NOT DISTINCT (tenant_id, code) +); + +CREATE TABLE IF NOT EXISTS gateway.admin_accounts ( + id uuid PRIMARY KEY, + username varchar(64) NOT NULL, + password_hash text NOT NULL, + display_name varchar(64) NOT NULL DEFAULT '', + role varchar(24) NOT NULL DEFAULT 'operator' + CHECK (role IN ('superadmin', 'operator', 'auditor')), + active boolean NOT NULL DEFAULT true, + failed_logins integer NOT NULL DEFAULT 0 CHECK (failed_logins >= 0), + locked_until timestamptz, + last_login timestamptz, + encrypted_totp_secret bytea, + totp_kek_version integer, + totp_enabled boolean NOT NULL DEFAULT false, + totp_last_step bigint, + totp_backup_codes jsonb NOT NULL DEFAULT '[]'::jsonb, + totp_confirmed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + updated_at timestamptz NOT NULL DEFAULT clock_timestamp() +); + +CREATE UNIQUE INDEX IF NOT EXISTS admin_accounts_username_lower_idx + ON gateway.admin_accounts (lower(username)); + +CREATE TABLE IF NOT EXISTS gateway.portal_users ( + id uuid PRIMARY KEY, + tenant_id uuid, + account varchar(128) NOT NULL, + name varchar(64) NOT NULL DEFAULT '', + department_id uuid REFERENCES gateway.departments(id), + password_hash text, + auth_source varchar(24) NOT NULL DEFAULT 'local' + CHECK (auth_source IN ('local', 'feishu', 'oidc', 'saml')), + external_subject text, + active boolean NOT NULL DEFAULT true, + provisioning_status varchar(24) NOT NULL DEFAULT 'active' + CHECK (provisioning_status IN ('active', 'pending', 'rejected')), + failed_logins integer NOT NULL DEFAULT 0 CHECK (failed_logins >= 0), + locked_until timestamptz, + last_login timestamptz, + encrypted_totp_secret bytea, + totp_kek_version integer, + totp_enabled boolean NOT NULL DEFAULT false, + totp_last_step bigint, + totp_backup_codes jsonb NOT NULL DEFAULT '[]'::jsonb, + totp_confirmed_at timestamptz, + deleted_name_snapshot varchar(128) NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + updated_at timestamptz NOT NULL DEFAULT clock_timestamp() +); + +CREATE UNIQUE INDEX IF NOT EXISTS portal_users_account_lower_idx + ON gateway.portal_users (lower(account)); +CREATE INDEX IF NOT EXISTS portal_users_department_idx + ON gateway.portal_users (department_id, active); +CREATE UNIQUE INDEX IF NOT EXISTS portal_users_external_subject_idx + ON gateway.portal_users (auth_source, external_subject) + WHERE external_subject IS NOT NULL; + +ALTER TABLE gateway.api_keys + ADD COLUMN IF NOT EXISTS user_id uuid REFERENCES gateway.portal_users(id), + ADD COLUMN IF NOT EXISTS department_id uuid REFERENCES gateway.departments(id); + +ALTER TABLE gateway.providers + ADD COLUMN IF NOT EXISTS created_by uuid REFERENCES gateway.admin_accounts(id); + +COMMENT ON COLUMN gateway.admin_accounts.password_hash IS + 'PBKDF2-SHA256 self-describing hash; compatible with the Python gateway format.'; +COMMENT ON TABLE gateway.portal_users IS + 'Portal identities. Runtime API keys remain separate credentials.'; + diff --git a/migrations/000003_api_key_actor.sql b/migrations/000003_api_key_actor.sql new file mode 100644 index 0000000..1c71aec --- /dev/null +++ b/migrations/000003_api_key_actor.sql @@ -0,0 +1,8 @@ +ALTER TABLE gateway.api_keys + ADD COLUMN IF NOT EXISTS created_by uuid REFERENCES gateway.admin_accounts(id); + +CREATE INDEX IF NOT EXISTS api_keys_created_by_idx + ON gateway.api_keys (created_by, created_at DESC); + +COMMENT ON COLUMN gateway.api_keys.created_by IS + 'Administrator that created the key. Key material is returned once and never stored in plaintext.'; diff --git a/migrations/000004_provider_default.sql b/migrations/000004_provider_default.sql new file mode 100644 index 0000000..b5da721 --- /dev/null +++ b/migrations/000004_provider_default.sql @@ -0,0 +1,6 @@ +CREATE UNIQUE INDEX IF NOT EXISTS providers_single_global_default_idx + ON gateway.providers ((1)) + WHERE tenant_id IS NULL AND enabled AND config @> '{"default": true}'::jsonb; + +COMMENT ON INDEX gateway.providers_single_global_default_idx IS + 'At most one enabled global provider may be the explicit runtime default.'; diff --git a/migrations/000005_provider_models.sql b/migrations/000005_provider_models.sql new file mode 100644 index 0000000..4d61598 --- /dev/null +++ b/migrations/000005_provider_models.sql @@ -0,0 +1,20 @@ +CREATE TABLE IF NOT EXISTS gateway.provider_models ( + id uuid PRIMARY KEY, + provider_id uuid NOT NULL REFERENCES gateway.providers(id) ON DELETE CASCADE, + provider_model_id text NOT NULL CHECK ( + length(provider_model_id) BETWEEN 1 AND 512 + ), + owned_by text NOT NULL DEFAULT '', + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + enabled boolean NOT NULL DEFAULT true, + discovered_at timestamptz NOT NULL DEFAULT clock_timestamp(), + last_seen_at timestamptz NOT NULL DEFAULT clock_timestamp(), + updated_at timestamptz NOT NULL DEFAULT clock_timestamp(), + UNIQUE (provider_id, provider_model_id) +); + +CREATE INDEX IF NOT EXISTS provider_models_provider_active_idx + ON gateway.provider_models (provider_id, enabled, provider_model_id); + +COMMENT ON TABLE gateway.provider_models IS + 'Provider model catalog snapshots. Missing upstream models are retained and disabled.'; diff --git a/migrations/000006_identity_permissions.sql b/migrations/000006_identity_permissions.sql new file mode 100644 index 0000000..f30b026 --- /dev/null +++ b/migrations/000006_identity_permissions.sql @@ -0,0 +1,11 @@ +ALTER TABLE gateway.admin_accounts + ADD COLUMN IF NOT EXISTS permissions text[] NOT NULL DEFAULT '{}'; + +ALTER TABLE gateway.portal_users + ADD COLUMN IF NOT EXISTS role varchar(24) NOT NULL DEFAULT 'member', + ADD COLUMN IF NOT EXISTS permissions text[] NOT NULL DEFAULT '{}'; + +COMMENT ON COLUMN gateway.admin_accounts.permissions IS + 'Direct RBAC permission grants. Built-in role permissions are resolved in the application.'; +COMMENT ON COLUMN gateway.portal_users.permissions IS + 'Direct portal permission grants using the same resource:action convention.'; diff --git a/migrations/000007_departments.sql b/migrations/000007_departments.sql new file mode 100644 index 0000000..065febd --- /dev/null +++ b/migrations/000007_departments.sql @@ -0,0 +1,13 @@ +ALTER TABLE gateway.departments + ADD COLUMN IF NOT EXISTS parent_id uuid REFERENCES gateway.departments(id), + ADD COLUMN IF NOT EXISTS description text NOT NULL DEFAULT ''; + +CREATE INDEX IF NOT EXISTS departments_parent_active_idx + ON gateway.departments (parent_id, active, code); + +ALTER TABLE gateway.departments + ADD CONSTRAINT departments_not_self_parent + CHECK (parent_id IS NULL OR parent_id <> id); + +COMMENT ON COLUMN gateway.departments.parent_id IS + 'Adjacency-list parent. Application writes reject cycles transactionally.'; diff --git a/migrations/000008_oidc.sql b/migrations/000008_oidc.sql new file mode 100644 index 0000000..9e5322f --- /dev/null +++ b/migrations/000008_oidc.sql @@ -0,0 +1,31 @@ +CREATE TABLE IF NOT EXISTS gateway.identity_providers ( + id uuid PRIMARY KEY, + code text NOT NULL UNIQUE, + kind varchar(16) NOT NULL CHECK (kind IN ('oidc')), + display_name varchar(128) NOT NULL, + issuer_url text NOT NULL, + client_id text NOT NULL, + encrypted_credentials bytea NOT NULL, + credential_kek_version integer NOT NULL, + redirect_uri text NOT NULL, + portal_return_url text NOT NULL, + scopes text[] NOT NULL DEFAULT ARRAY['openid', 'profile', 'email'], + auto_provision boolean NOT NULL DEFAULT false, + default_department_id uuid REFERENCES gateway.departments(id), + enabled boolean NOT NULL DEFAULT false, + revision bigint NOT NULL DEFAULT 1, + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + updated_at timestamptz NOT NULL DEFAULT clock_timestamp() +); + +ALTER TABLE gateway.portal_users + ADD COLUMN IF NOT EXISTS identity_provider_id uuid + REFERENCES gateway.identity_providers(id); + +DROP INDEX IF EXISTS gateway.portal_users_external_subject_idx; +CREATE UNIQUE INDEX IF NOT EXISTS portal_users_provider_subject_idx + ON gateway.portal_users (identity_provider_id, external_subject) + WHERE identity_provider_id IS NOT NULL AND external_subject IS NOT NULL; + +COMMENT ON TABLE gateway.identity_providers IS + 'OIDC provider registry. Client secrets are encrypted with a dedicated AEAD purpose.'; diff --git a/migrations/000009_legacy_id_mappings.sql b/migrations/000009_legacy_id_mappings.sql new file mode 100644 index 0000000..b23d161 --- /dev/null +++ b/migrations/000009_legacy_id_mappings.sql @@ -0,0 +1,19 @@ +CREATE TABLE IF NOT EXISTS gateway.legacy_id_mappings ( + source_system varchar(64) NOT NULL, + entity_type varchar(128) NOT NULL, + legacy_id text NOT NULL, + new_id uuid NOT NULL, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + imported_at timestamptz NOT NULL DEFAULT clock_timestamp(), + PRIMARY KEY (source_system, entity_type, legacy_id), + UNIQUE (source_system, entity_type, new_id), + CHECK (length(source_system) BETWEEN 1 AND 64), + CHECK (length(entity_type) BETWEEN 1 AND 128), + CHECK (length(legacy_id) BETWEEN 1 AND 256) +); + +CREATE INDEX IF NOT EXISTS legacy_id_mappings_new_id_idx + ON gateway.legacy_id_mappings (new_id); + +COMMENT ON TABLE gateway.legacy_id_mappings IS + 'Idempotent compatibility map from legacy entity IDs to deterministic UUIDv5 IDs.'; diff --git a/migrations/000010_saml.sql b/migrations/000010_saml.sql new file mode 100644 index 0000000..1d6ecc3 --- /dev/null +++ b/migrations/000010_saml.sql @@ -0,0 +1,15 @@ +ALTER TABLE gateway.identity_providers + DROP CONSTRAINT IF EXISTS identity_providers_kind_check; + +ALTER TABLE gateway.identity_providers + ADD CONSTRAINT identity_providers_kind_check + CHECK (kind IN ('oidc', 'saml')), + ALTER COLUMN issuer_url DROP NOT NULL, + ALTER COLUMN client_id DROP NOT NULL, + ALTER COLUMN encrypted_credentials DROP NOT NULL, + ALTER COLUMN credential_kek_version DROP NOT NULL, + ALTER COLUMN redirect_uri DROP NOT NULL, + ADD COLUMN IF NOT EXISTS config jsonb NOT NULL DEFAULT '{}'::jsonb; + +COMMENT ON COLUMN gateway.identity_providers.config IS + 'Kind-specific non-secret settings. SAML metadata URL, SP entity ID, ACS URL and attribute names live here.'; diff --git a/migrations/000011_api_key_limits.sql b/migrations/000011_api_key_limits.sql new file mode 100644 index 0000000..a12fc61 --- /dev/null +++ b/migrations/000011_api_key_limits.sql @@ -0,0 +1,18 @@ +ALTER TABLE gateway.api_keys + ADD COLUMN IF NOT EXISTS requests_per_minute integer NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS monthly_request_quota bigint NOT NULL DEFAULT 0; + +ALTER TABLE gateway.api_keys + DROP CONSTRAINT IF EXISTS api_keys_requests_per_minute_check, + DROP CONSTRAINT IF EXISTS api_keys_monthly_request_quota_check; + +ALTER TABLE gateway.api_keys + ADD CONSTRAINT api_keys_requests_per_minute_check + CHECK (requests_per_minute BETWEEN 0 AND 1000000), + ADD CONSTRAINT api_keys_monthly_request_quota_check + CHECK (monthly_request_quota BETWEEN 0 AND 1000000000000); + +COMMENT ON COLUMN gateway.api_keys.requests_per_minute IS + 'Atomic Redis-enforced request limit per UTC minute. Zero means unlimited.'; +COMMENT ON COLUMN gateway.api_keys.monthly_request_quota IS + 'Atomic Redis-enforced accepted request quota per UTC calendar month. Zero means unlimited.'; diff --git a/migrations/000012_api_key_token_quota.sql b/migrations/000012_api_key_token_quota.sql new file mode 100644 index 0000000..84454d6 --- /dev/null +++ b/migrations/000012_api_key_token_quota.sql @@ -0,0 +1,12 @@ +ALTER TABLE gateway.api_keys + ADD COLUMN IF NOT EXISTS monthly_token_quota bigint NOT NULL DEFAULT 0; + +ALTER TABLE gateway.api_keys + DROP CONSTRAINT IF EXISTS api_keys_monthly_token_quota_check; + +ALTER TABLE gateway.api_keys + ADD CONSTRAINT api_keys_monthly_token_quota_check + CHECK (monthly_token_quota BETWEEN 0 AND 1000000000000000); + +COMMENT ON COLUMN gateway.api_keys.monthly_token_quota IS + 'Maximum input plus output tokens per UTC calendar month; zero means unlimited.'; diff --git a/migrations/000013_model_routes.sql b/migrations/000013_model_routes.sql new file mode 100644 index 0000000..666fb06 --- /dev/null +++ b/migrations/000013_model_routes.sql @@ -0,0 +1,21 @@ +CREATE TABLE IF NOT EXISTS gateway.model_routes ( + id uuid PRIMARY KEY, + name text NOT NULL CHECK (length(name) BETWEEN 1 AND 128), + source_model text NOT NULL CHECK (length(source_model) BETWEEN 1 AND 512), + target_model text NOT NULL CHECK (length(target_model) BETWEEN 1 AND 512), + provider_id uuid NOT NULL REFERENCES gateway.providers(id) ON DELETE CASCADE, + weight integer NOT NULL DEFAULT 100 CHECK (weight BETWEEN 1 AND 10000), + priority integer NOT NULL DEFAULT 100 CHECK (priority BETWEEN -100000 AND 100000), + conditions jsonb NOT NULL DEFAULT '{}'::jsonb, + enabled boolean NOT NULL DEFAULT true, + created_by uuid REFERENCES gateway.admin_accounts(id), + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + updated_at timestamptz NOT NULL DEFAULT clock_timestamp(), + UNIQUE (source_model, provider_id, target_model, priority, conditions) +); + +CREATE INDEX IF NOT EXISTS model_routes_lookup_idx + ON gateway.model_routes (source_model, enabled, priority DESC); + +COMMENT ON COLUMN gateway.model_routes.conditions IS + 'Extensible route predicates; initial runtime supports endpoint, API key and tenant matching.'; diff --git a/migrations/000014_audit_usage.sql b/migrations/000014_audit_usage.sql new file mode 100644 index 0000000..a99a965 --- /dev/null +++ b/migrations/000014_audit_usage.sql @@ -0,0 +1,26 @@ +CREATE INDEX IF NOT EXISTS audit_events_recorded_at_idx + ON gateway.audit_events (recorded_at DESC); +CREATE INDEX IF NOT EXISTS audit_events_api_key_time_idx + ON gateway.audit_events (api_key_id, recorded_at DESC) + WHERE api_key_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS audit_events_provider_model_time_idx + ON gateway.audit_events (provider_code, model, recorded_at DESC); + +CREATE TABLE IF NOT EXISTS gateway.usage_daily ( + usage_date date NOT NULL, + api_key_id uuid NOT NULL REFERENCES gateway.api_keys(id), + provider_code text NOT NULL DEFAULT '', + model text NOT NULL DEFAULT '', + requests bigint NOT NULL DEFAULT 0 CHECK (requests >= 0), + failed_requests bigint NOT NULL DEFAULT 0 CHECK (failed_requests >= 0), + prompt_tokens bigint NOT NULL DEFAULT 0 CHECK (prompt_tokens >= 0), + completion_tokens bigint NOT NULL DEFAULT 0 CHECK (completion_tokens >= 0), + updated_at timestamptz NOT NULL DEFAULT clock_timestamp(), + PRIMARY KEY (usage_date, api_key_id, provider_code, model) +); + +CREATE INDEX IF NOT EXISTS usage_daily_date_idx + ON gateway.usage_daily (usage_date DESC, provider_code, model); + +COMMENT ON TABLE gateway.usage_daily IS + 'PostgreSQL daily usage aggregates produced asynchronously from gateway audit events.'; diff --git a/migrations/000015_outbox_delivery.sql b/migrations/000015_outbox_delivery.sql new file mode 100644 index 0000000..a827f4a --- /dev/null +++ b/migrations/000015_outbox_delivery.sql @@ -0,0 +1,14 @@ +ALTER TABLE gateway.outbox_events + ADD COLUMN IF NOT EXISTS dead_lettered_at timestamptz, + ADD COLUMN IF NOT EXISTS published_stream_id text; + +CREATE INDEX IF NOT EXISTS outbox_events_claimable_idx + ON gateway.outbox_events (available_at, occurred_at) + WHERE processed_at IS NULL AND dead_lettered_at IS NULL; + +CREATE INDEX IF NOT EXISTS outbox_events_dead_letter_idx + ON gateway.outbox_events (dead_lettered_at DESC) + WHERE dead_lettered_at IS NOT NULL; + +COMMENT ON COLUMN gateway.outbox_events.published_stream_id IS + 'Redis Stream entry ID, or duplicate when the event marker proved it was already published.'; diff --git a/migrations/000016_content_policy_pricing.sql b/migrations/000016_content_policy_pricing.sql new file mode 100644 index 0000000..a8bca4a --- /dev/null +++ b/migrations/000016_content_policy_pricing.sql @@ -0,0 +1,60 @@ +CREATE TABLE IF NOT EXISTS gateway.content_policies ( + id uuid PRIMARY KEY, + name text NOT NULL UNIQUE, + description text NOT NULL DEFAULT '', + action text NOT NULL CHECK (action IN ('audit', 'block', 'redact')), + priority integer NOT NULL DEFAULT 0, + paths text[] NOT NULL DEFAULT '{}', + models text[] NOT NULL DEFAULT '{}', + api_key_ids uuid[] NOT NULL DEFAULT '{}', + rules jsonb NOT NULL DEFAULT '[]'::jsonb, + enabled boolean NOT NULL DEFAULT true, + revision bigint NOT NULL DEFAULT 1, + created_by uuid REFERENCES gateway.admin_accounts(id), + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + updated_at timestamptz NOT NULL DEFAULT clock_timestamp(), + CHECK (jsonb_typeof(rules) = 'array') +); + +CREATE INDEX IF NOT EXISTS content_policies_runtime_idx + ON gateway.content_policies (enabled, priority DESC, name); + +COMMENT ON TABLE gateway.content_policies IS + 'Ordered request content policies compiled with Go RE2; only textual prompt fields are inspected or redacted.'; + +INSERT INTO gateway.content_policies + (id, name, description, action, priority, rules, enabled) +VALUES + ('00000000-0000-4000-8000-000000000016', '默认敏感信息脱敏', + '对提示词文本中的常见 API 密钥、访问令牌、密码和 secret 赋值执行脱敏。', + 'redact', 1000, + '[{"name":"OpenAI 风格密钥","pattern":"sk-[A-Za-z0-9_-]{16,}","replacement":"[REDACTED_API_KEY]"},{"name":"凭据赋值","pattern":"(?i)(api[_-]?key|access[_-]?token|password|secret)[[:space:]]*[:=][[:space:]]*[\"'']?([A-Za-z0-9_./+=-]{6,})","replacement":"$1=[REDACTED]"}]'::jsonb, + true) +ON CONFLICT (id) DO NOTHING; + +CREATE TABLE IF NOT EXISTS gateway.model_prices ( + id uuid PRIMARY KEY, + provider_code text NOT NULL, + model_pattern text NOT NULL, + input_microunits_per_million bigint NOT NULL CHECK (input_microunits_per_million >= 0), + output_microunits_per_million bigint NOT NULL CHECK (output_microunits_per_million >= 0), + currency char(3) NOT NULL DEFAULT 'USD' CHECK (currency = upper(currency)), + effective_from timestamptz NOT NULL, + effective_to timestamptz, + enabled boolean NOT NULL DEFAULT true, + revision bigint NOT NULL DEFAULT 1, + created_by uuid REFERENCES gateway.admin_accounts(id), + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + updated_at timestamptz NOT NULL DEFAULT clock_timestamp(), + CHECK (effective_to IS NULL OR effective_to > effective_from), + UNIQUE (provider_code, model_pattern, effective_from) +); + +CREATE INDEX IF NOT EXISTS model_prices_runtime_idx + ON gateway.model_prices (provider_code, enabled, effective_from DESC); + +ALTER TABLE gateway.usage_daily + ADD COLUMN IF NOT EXISTS cost_microunits bigint NOT NULL DEFAULT 0 CHECK (cost_microunits >= 0); + +COMMENT ON COLUMN gateway.audit_events.cost_microunits IS + 'Estimated request cost in one-millionth currency units using the price version effective at request time.'; diff --git a/migrations/000017_ai_assets_orchestration.sql b/migrations/000017_ai_assets_orchestration.sql new file mode 100644 index 0000000..008d0a0 --- /dev/null +++ b/migrations/000017_ai_assets_orchestration.sql @@ -0,0 +1,212 @@ +CREATE TABLE IF NOT EXISTS gateway.prompt_categories ( + id uuid PRIMARY KEY, + name text NOT NULL UNIQUE, + description text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT clock_timestamp() +); + +CREATE TABLE IF NOT EXISTS gateway.prompt_templates ( + id uuid PRIMARY KEY, + name text NOT NULL UNIQUE, + description text NOT NULL DEFAULT '', + category_id uuid REFERENCES gateway.prompt_categories(id) ON DELETE SET NULL, + tags text[] NOT NULL DEFAULT '{}', + department_ids uuid[] NOT NULL DEFAULT '{}', + enabled boolean NOT NULL DEFAULT true, + current_version integer, + revision bigint NOT NULL DEFAULT 1, + created_by uuid REFERENCES gateway.admin_accounts(id), + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + updated_at timestamptz NOT NULL DEFAULT clock_timestamp() +); + +CREATE TABLE IF NOT EXISTS gateway.prompt_versions ( + id uuid PRIMARY KEY, + template_id uuid NOT NULL REFERENCES gateway.prompt_templates(id) ON DELETE CASCADE, + version integer NOT NULL CHECK (version > 0), + content text NOT NULL CHECK (length(content) BETWEEN 1 AND 100000), + variables jsonb NOT NULL DEFAULT '[]'::jsonb CHECK (jsonb_typeof(variables) = 'array'), + change_note text NOT NULL DEFAULT '', + created_by uuid REFERENCES gateway.admin_accounts(id), + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + UNIQUE (template_id, version) +); + +ALTER TABLE gateway.prompt_templates + ADD CONSTRAINT prompt_templates_current_version_fk + FOREIGN KEY (id, current_version) + REFERENCES gateway.prompt_versions(template_id, version) + DEFERRABLE INITIALLY DEFERRED; + +CREATE INDEX IF NOT EXISTS prompt_templates_visible_idx + ON gateway.prompt_templates (enabled, updated_at DESC); + +CREATE TABLE IF NOT EXISTS gateway.knowledge_bases ( + id uuid PRIMARY KEY, + name text NOT NULL UNIQUE, + description text NOT NULL DEFAULT '', + retrieval_mode text NOT NULL DEFAULT 'postgres_fts' + CHECK (retrieval_mode IN ('postgres_fts')), + chunk_size integer NOT NULL DEFAULT 800 CHECK (chunk_size BETWEEN 200 AND 8000), + chunk_overlap integer NOT NULL DEFAULT 100 CHECK (chunk_overlap >= 0 AND chunk_overlap <= chunk_size / 2), + department_ids uuid[] NOT NULL DEFAULT '{}', + enabled boolean NOT NULL DEFAULT true, + revision bigint NOT NULL DEFAULT 1, + created_by uuid REFERENCES gateway.admin_accounts(id), + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + updated_at timestamptz NOT NULL DEFAULT clock_timestamp() +); + +CREATE TABLE IF NOT EXISTS gateway.knowledge_documents ( + id uuid PRIMARY KEY, + knowledge_base_id uuid NOT NULL REFERENCES gateway.knowledge_bases(id) ON DELETE CASCADE, + title text NOT NULL, + source_type text NOT NULL DEFAULT 'text' CHECK (source_type IN ('text', 'url', 'import')), + source_uri text NOT NULL DEFAULT '', + content text NOT NULL CHECK (octet_length(content) <= 2097152), + content_sha256 char(64) NOT NULL, + status text NOT NULL DEFAULT 'ready' CHECK (status IN ('ready', 'failed')), + status_message text NOT NULL DEFAULT '', + chunk_count integer NOT NULL DEFAULT 0 CHECK (chunk_count >= 0), + created_by uuid REFERENCES gateway.admin_accounts(id), + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + updated_at timestamptz NOT NULL DEFAULT clock_timestamp(), + UNIQUE (knowledge_base_id, content_sha256) +); + +CREATE TABLE IF NOT EXISTS gateway.knowledge_chunks ( + id uuid PRIMARY KEY, + knowledge_base_id uuid NOT NULL REFERENCES gateway.knowledge_bases(id) ON DELETE CASCADE, + document_id uuid NOT NULL REFERENCES gateway.knowledge_documents(id) ON DELETE CASCADE, + chunk_index integer NOT NULL CHECK (chunk_index >= 0), + content text NOT NULL, + search_vector tsvector GENERATED ALWAYS AS (to_tsvector('simple', content)) STORED, + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + UNIQUE (document_id, chunk_index) +); + +CREATE INDEX IF NOT EXISTS knowledge_chunks_search_idx + ON gateway.knowledge_chunks USING gin (search_vector); +CREATE INDEX IF NOT EXISTS knowledge_chunks_base_idx + ON gateway.knowledge_chunks (knowledge_base_id, document_id, chunk_index); + +CREATE TABLE IF NOT EXISTS gateway.tool_definitions ( + id uuid PRIMARY KEY, + code text NOT NULL UNIQUE CHECK (code ~ '^[a-z][a-z0-9_-]{1,63}$'), + name text NOT NULL, + description text NOT NULL DEFAULT '', + endpoint_url text NOT NULL, + http_method text NOT NULL DEFAULT 'POST' CHECK (http_method IN ('GET','POST','PUT','PATCH','DELETE')), + encrypted_headers bytea NOT NULL DEFAULT ''::bytea, + headers_kek_version integer NOT NULL DEFAULT 1, + input_schema jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(input_schema) = 'object'), + timeout_seconds integer NOT NULL DEFAULT 15 CHECK (timeout_seconds BETWEEN 1 AND 120), + department_ids uuid[] NOT NULL DEFAULT '{}', + enabled boolean NOT NULL DEFAULT true, + revision bigint NOT NULL DEFAULT 1, + created_by uuid REFERENCES gateway.admin_accounts(id), + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + updated_at timestamptz NOT NULL DEFAULT clock_timestamp() +); + +CREATE TABLE IF NOT EXISTS gateway.applications ( + id uuid PRIMARY KEY, + code text NOT NULL UNIQUE CHECK (code ~ '^[a-z][a-z0-9_-]{2,63}$'), + name text NOT NULL, + description text NOT NULL DEFAULT '', + department_ids uuid[] NOT NULL DEFAULT '{}', + status text NOT NULL DEFAULT 'draft' CHECK (status IN ('draft','active','suspended','retired')), + draft_config jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(draft_config) = 'object'), + published_version integer CHECK (published_version > 0), + revision bigint NOT NULL DEFAULT 1, + created_by uuid REFERENCES gateway.admin_accounts(id), + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + updated_at timestamptz NOT NULL DEFAULT clock_timestamp() +); + +CREATE TABLE IF NOT EXISTS gateway.tool_runs ( + id uuid PRIMARY KEY, + tool_id uuid NOT NULL REFERENCES gateway.tool_definitions(id) ON DELETE CASCADE, + api_key_id uuid REFERENCES gateway.api_keys(id) ON DELETE SET NULL, + request_id text NOT NULL, + status text NOT NULL CHECK (status IN ('success','error')), + response_status integer, + latency_ms bigint NOT NULL DEFAULT 0, + error text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT clock_timestamp() +); + +CREATE INDEX IF NOT EXISTS tool_runs_tool_time_idx + ON gateway.tool_runs (tool_id, created_at DESC); + +CREATE TABLE IF NOT EXISTS gateway.application_versions ( + id uuid PRIMARY KEY, + application_id uuid NOT NULL REFERENCES gateway.applications(id) ON DELETE CASCADE, + version integer NOT NULL CHECK (version > 0), + config jsonb NOT NULL CHECK (jsonb_typeof(config) = 'object'), + change_note text NOT NULL DEFAULT '', + published_by uuid REFERENCES gateway.admin_accounts(id), + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + UNIQUE (application_id, version) +); + +ALTER TABLE gateway.applications + ADD CONSTRAINT applications_published_version_fk + FOREIGN KEY (id, published_version) + REFERENCES gateway.application_versions(application_id, version) + DEFERRABLE INITIALLY DEFERRED; + +CREATE TABLE IF NOT EXISTS gateway.application_runs ( + id uuid PRIMARY KEY, + application_id uuid NOT NULL REFERENCES gateway.applications(id) ON DELETE CASCADE, + version integer NOT NULL, + api_key_id uuid REFERENCES gateway.api_keys(id) ON DELETE SET NULL, + request_id text NOT NULL, + status text NOT NULL CHECK (status IN ('success','error')), + latency_ms bigint NOT NULL DEFAULT 0, + retrieval_count integer NOT NULL DEFAULT 0, + tool_count integer NOT NULL DEFAULT 0, + error text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT clock_timestamp() +); + +CREATE INDEX IF NOT EXISTS application_runs_app_time_idx + ON gateway.application_runs (application_id, created_at DESC); + +CREATE TABLE IF NOT EXISTS gateway.notification_channels ( + id uuid PRIMARY KEY, + name text NOT NULL UNIQUE, + webhook_url text NOT NULL, + encrypted_signing_secret bytea NOT NULL DEFAULT ''::bytea, + signing_secret_kek_version integer NOT NULL DEFAULT 1, + event_patterns text[] NOT NULL DEFAULT '{}', + enabled boolean NOT NULL DEFAULT true, + revision bigint NOT NULL DEFAULT 1, + created_by uuid REFERENCES gateway.admin_accounts(id), + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + updated_at timestamptz NOT NULL DEFAULT clock_timestamp() +); + +CREATE TABLE IF NOT EXISTS gateway.notification_deliveries ( + id uuid PRIMARY KEY, + channel_id uuid NOT NULL REFERENCES gateway.notification_channels(id) ON DELETE CASCADE, + event_id uuid NOT NULL, + event_type text NOT NULL, + payload jsonb NOT NULL DEFAULT '{}'::jsonb, + status text NOT NULL CHECK (status IN ('pending','delivered','failed')), + attempts integer NOT NULL DEFAULT 0, + response_status integer, + last_error text NOT NULL DEFAULT '', + delivered_at timestamptz, + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + updated_at timestamptz NOT NULL DEFAULT clock_timestamp(), + UNIQUE (channel_id, event_id) +); + +CREATE INDEX IF NOT EXISTS notification_deliveries_status_idx + ON gateway.notification_deliveries (status, updated_at DESC); + +COMMENT ON TABLE gateway.knowledge_documents IS + 'Baseline stores bounded extracted text in PostgreSQL; blob/object storage is deliberately not required.'; +COMMENT ON TABLE gateway.application_versions IS + 'Immutable published application compositions referencing prompt, knowledge and tool assets.'; diff --git a/migrations/000018_legacy_import_staging.sql b/migrations/000018_legacy_import_staging.sql new file mode 100644 index 0000000..4d5426d --- /dev/null +++ b/migrations/000018_legacy_import_staging.sql @@ -0,0 +1,45 @@ +CREATE TABLE IF NOT EXISTS gateway.legacy_import_batches ( + id uuid PRIMARY KEY, + source_system text NOT NULL CHECK (length(source_system) BETWEEN 1 AND 64), + source_checksum char(64) NOT NULL, + status text NOT NULL CHECK (status IN ('staged','validated','applied','failed')), + record_count integer NOT NULL CHECK (record_count >= 0), + entity_counts jsonb NOT NULL DEFAULT '{}'::jsonb, + error text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + completed_at timestamptz, + UNIQUE (source_system, source_checksum) +); + +CREATE TABLE IF NOT EXISTS gateway.legacy_import_records ( + first_seen_batch_id uuid NOT NULL REFERENCES gateway.legacy_import_batches(id), + source_system text NOT NULL, + entity_type text NOT NULL, + legacy_id text NOT NULL, + new_id uuid NOT NULL, + payload jsonb NOT NULL, + payload_checksum char(64) NOT NULL, + target_table text NOT NULL DEFAULT '', + applied_at timestamptz, + error text NOT NULL DEFAULT '', + PRIMARY KEY (source_system, entity_type, legacy_id), + UNIQUE (source_system, entity_type, new_id) +); + +CREATE TABLE IF NOT EXISTS gateway.legacy_import_batch_records ( + batch_id uuid NOT NULL REFERENCES gateway.legacy_import_batches(id) ON DELETE CASCADE, + source_system text NOT NULL, + entity_type text NOT NULL, + legacy_id text NOT NULL, + payload_checksum char(64) NOT NULL, + PRIMARY KEY (batch_id, entity_type, legacy_id), + FOREIGN KEY (source_system, entity_type, legacy_id) + REFERENCES gateway.legacy_import_records(source_system, entity_type, legacy_id) +); + +CREATE INDEX IF NOT EXISTS legacy_import_records_pending_idx + ON gateway.legacy_import_records (entity_type, legacy_id) + WHERE applied_at IS NULL AND error = ''; + +COMMENT ON TABLE gateway.legacy_import_records IS + 'Validated immutable staging records. Sensitive legacy ciphertext is never treated as a usable new credential; domain transforms must decrypt and re-encrypt with the new purpose label.'; diff --git a/migrations/000019_portal_workspace.sql b/migrations/000019_portal_workspace.sql new file mode 100644 index 0000000..88be732 --- /dev/null +++ b/migrations/000019_portal_workspace.sql @@ -0,0 +1,38 @@ +ALTER TABLE gateway.api_keys + ADD COLUMN IF NOT EXISTS portal_user_id uuid REFERENCES gateway.portal_users(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS api_keys_portal_user_idx + ON gateway.api_keys (portal_user_id, enabled, created_at DESC) + WHERE portal_user_id IS NOT NULL; + +CREATE TABLE IF NOT EXISTS gateway.prompt_favorites ( + portal_user_id uuid NOT NULL REFERENCES gateway.portal_users(id) ON DELETE CASCADE, + prompt_id uuid NOT NULL REFERENCES gateway.prompt_templates(id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + PRIMARY KEY (portal_user_id, prompt_id) +); + +CREATE TABLE IF NOT EXISTS gateway.model_access_requests ( + id uuid PRIMARY KEY, + portal_user_id uuid NOT NULL REFERENCES gateway.portal_users(id) ON DELETE CASCADE, + provider_code text NOT NULL DEFAULT '', + model text NOT NULL CHECK (length(model) BETWEEN 1 AND 512), + reason text NOT NULL DEFAULT '' CHECK (length(reason) <= 4000), + requested_rpm integer NOT NULL DEFAULT 60 CHECK (requested_rpm BETWEEN 1 AND 100000), + requested_monthly_tokens bigint NOT NULL DEFAULT 0 CHECK (requested_monthly_tokens >= 0), + status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','approved','rejected','cancelled')), + decision_note text NOT NULL DEFAULT '' CHECK (length(decision_note) <= 4000), + decided_by uuid REFERENCES gateway.admin_accounts(id) ON DELETE SET NULL, + decided_at timestamptz, + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + updated_at timestamptz NOT NULL DEFAULT clock_timestamp() +); + +CREATE UNIQUE INDEX IF NOT EXISTS model_access_requests_one_pending_idx + ON gateway.model_access_requests (portal_user_id, provider_code, model) + WHERE status = 'pending'; +CREATE INDEX IF NOT EXISTS model_access_requests_status_time_idx + ON gateway.model_access_requests (status, created_at DESC); + +COMMENT ON COLUMN gateway.api_keys.portal_user_id IS + 'Optional portal owner. It enables least-privilege self-service usage and audit views without exposing key material.'; diff --git a/migrations/000020_portal_conversations.sql b/migrations/000020_portal_conversations.sql new file mode 100644 index 0000000..923b600 --- /dev/null +++ b/migrations/000020_portal_conversations.sql @@ -0,0 +1,49 @@ +ALTER TABLE gateway.api_keys + ADD COLUMN IF NOT EXISTS application_id uuid REFERENCES gateway.applications(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS api_keys_application_idx + ON gateway.api_keys (application_id, enabled) + WHERE application_id IS NOT NULL; + +CREATE TABLE IF NOT EXISTS gateway.application_runtime_credentials ( + application_id uuid NOT NULL REFERENCES gateway.applications(id) ON DELETE CASCADE, + department_id uuid REFERENCES gateway.departments(id) ON DELETE CASCADE, + api_key_id uuid NOT NULL UNIQUE REFERENCES gateway.api_keys(id) ON DELETE CASCADE, + encrypted_key bytea NOT NULL, + key_kek_version integer NOT NULL, + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + updated_at timestamptz NOT NULL DEFAULT clock_timestamp(), + UNIQUE NULLS NOT DISTINCT (application_id, department_id) +); + +CREATE TABLE IF NOT EXISTS gateway.portal_conversations ( + id uuid PRIMARY KEY, + portal_user_id uuid NOT NULL REFERENCES gateway.portal_users(id) ON DELETE CASCADE, + application_id uuid NOT NULL REFERENCES gateway.applications(id) ON DELETE CASCADE, + title text NOT NULL DEFAULT '新会话' CHECK (length(title) BETWEEN 1 AND 160), + status text NOT NULL DEFAULT 'active' CHECK (status IN ('active','archived')), + busy boolean NOT NULL DEFAULT false, + busy_token uuid, + busy_since timestamptz, + next_sequence integer NOT NULL DEFAULT 1 CHECK (next_sequence BETWEEN 1 AND 202), + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + updated_at timestamptz NOT NULL DEFAULT clock_timestamp() +); + +CREATE INDEX IF NOT EXISTS portal_conversations_user_time_idx + ON gateway.portal_conversations (portal_user_id, updated_at DESC); + +CREATE TABLE IF NOT EXISTS gateway.portal_conversation_messages ( + id uuid PRIMARY KEY, + conversation_id uuid NOT NULL REFERENCES gateway.portal_conversations(id) ON DELETE CASCADE, + sequence integer NOT NULL CHECK (sequence BETWEEN 1 AND 200), + role text NOT NULL CHECK (role IN ('user','assistant')), + content text NOT NULL CHECK (length(content) BETWEEN 1 AND 100000), + previous_hash char(64) NOT NULL, + message_hash char(64) NOT NULL, + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + UNIQUE (conversation_id, sequence) +); + +COMMENT ON TABLE gateway.portal_conversation_messages IS + 'Server-managed immutable conversation history. A SHA-256 hash chain detects database-side sequence/content corruption before replay.'; diff --git a/migrations/000021_fact_check.sql b/migrations/000021_fact_check.sql new file mode 100644 index 0000000..ae50235 --- /dev/null +++ b/migrations/000021_fact_check.sql @@ -0,0 +1,51 @@ +CREATE TABLE IF NOT EXISTS gateway.fact_check_settings ( + singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton), + provider_id uuid REFERENCES gateway.providers(id) ON DELETE SET NULL, + model text NOT NULL DEFAULT '', + timeout_seconds integer NOT NULL DEFAULT 15 CHECK (timeout_seconds BETWEEN 3 AND 60), + updated_by uuid REFERENCES gateway.admin_accounts(id) ON DELETE SET NULL, + updated_at timestamptz NOT NULL DEFAULT clock_timestamp() +); +INSERT INTO gateway.fact_check_settings(singleton) VALUES(true) ON CONFLICT DO NOTHING; + +CREATE TABLE IF NOT EXISTS gateway.fact_check_policies ( + id uuid PRIMARY KEY, + scope text NOT NULL UNIQUE CHECK (scope='global' OR scope ~ '^(department|application|api_key):[0-9a-f-]{36}$'), + enabled boolean NOT NULL DEFAULT false, + mode text NOT NULL DEFAULT 'async' CHECK (mode IN ('async','sync')), + action text NOT NULL DEFAULT 'observe' CHECK (action IN ('observe','annotate','block')), + knowledge_base_ids uuid[] NOT NULL DEFAULT '{}', + support_threshold integer NOT NULL DEFAULT 70 CHECK (support_threshold BETWEEN 0 AND 100), + evidence_threshold double precision NOT NULL DEFAULT 0.35 CHECK (evidence_threshold BETWEEN 0 AND 1), + top_k integer NOT NULL DEFAULT 4 CHECK (top_k BETWEEN 1 AND 10), + max_claims integer NOT NULL DEFAULT 8 CHECK (max_claims BETWEEN 1 AND 20), + revision bigint NOT NULL DEFAULT 1, + created_by uuid REFERENCES gateway.admin_accounts(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + updated_at timestamptz NOT NULL DEFAULT clock_timestamp(), + CHECK (mode='sync' OR action='observe'), + CHECK (NOT enabled OR cardinality(knowledge_base_ids)>0) +); + +CREATE TABLE IF NOT EXISTS gateway.fact_check_events ( + id uuid PRIMARY KEY, + policy_id uuid REFERENCES gateway.fact_check_policies(id) ON DELETE SET NULL, + request_id text NOT NULL, + model text NOT NULL DEFAULT '', + mode text NOT NULL CHECK (mode IN ('async','sync')), + action text NOT NULL CHECK (action IN ('observe','annotate','block')), + verdict text NOT NULL CHECK (verdict IN ('supported','unsupported','uncertain','error')), + support_score integer CHECK (support_score BETWEEN 0 AND 100), + latency_ms integer NOT NULL DEFAULT 0 CHECK (latency_ms >= 0), + question text NOT NULL DEFAULT '' CHECK (octet_length(question)<=65536), + answer text NOT NULL DEFAULT '' CHECK (octet_length(answer)<=65536), + claims jsonb NOT NULL DEFAULT '[]'::jsonb CHECK (jsonb_typeof(claims)='array'), + evidence jsonb NOT NULL DEFAULT '[]'::jsonb CHECK (jsonb_typeof(evidence)='array'), + error text NOT NULL DEFAULT '' CHECK (length(error)<=4000), + created_at timestamptz NOT NULL DEFAULT clock_timestamp() +); +CREATE INDEX IF NOT EXISTS fact_check_events_time_idx ON gateway.fact_check_events(created_at DESC); +CREATE INDEX IF NOT EXISTS fact_check_events_verdict_time_idx ON gateway.fact_check_events(verdict,created_at DESC); + +COMMENT ON COLUMN gateway.fact_check_settings.provider_id IS + 'Reuses the encrypted Provider credential subsystem; fact-check settings never duplicate plaintext API keys or upstream URLs.'; diff --git a/migrations/000022_resource_marketplace.sql b/migrations/000022_resource_marketplace.sql new file mode 100644 index 0000000..52a6489 --- /dev/null +++ b/migrations/000022_resource_marketplace.sql @@ -0,0 +1,130 @@ +-- 旗舰版资源市场:MCP 服务器 / Skills / 数字员工 +-- 三类可发布资产 + 共享分类 + 市场安装(工作区绑定 + 权限)。 + +-- 共享市场分类(resource_type = '' 表示全局分类,适用所有资源类型) +CREATE TABLE IF NOT EXISTS gateway.marketplace_categories ( + id uuid PRIMARY KEY, + name text NOT NULL UNIQUE, + description text NOT NULL DEFAULT '', + resource_type text NOT NULL DEFAULT '' + CHECK (resource_type IN ('', 'mcp_server', 'skill', 'digital_employee')), + sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0), + created_by uuid REFERENCES gateway.admin_accounts(id), + created_at timestamptz NOT NULL DEFAULT clock_timestamp() +); + +-- MCP 服务器:连接外部 MCP 服务器的注册信息(transport/endpoint/凭证) +CREATE TABLE IF NOT EXISTS gateway.mcp_servers ( + id uuid PRIMARY KEY, + code text NOT NULL UNIQUE CHECK (code ~ '^[a-z][a-z0-9_-]{1,63}$'), + name text NOT NULL, + description text NOT NULL DEFAULT '', + transport text NOT NULL DEFAULT 'streamable-http' + CHECK (transport IN ('streamable-http', 'sse')), + endpoint_url text NOT NULL, + encrypted_headers bytea NOT NULL DEFAULT ''::bytea, + headers_kek_version integer NOT NULL DEFAULT 1, + status text NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'published', 'archived')), + category_id uuid REFERENCES gateway.marketplace_categories(id) ON DELETE SET NULL, + tags text[] NOT NULL DEFAULT '{}', + department_ids uuid[] NOT NULL DEFAULT '{}', + enabled boolean NOT NULL DEFAULT true, + revision bigint NOT NULL DEFAULT 1, + created_by uuid REFERENCES gateway.admin_accounts(id), + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + updated_at timestamptz NOT NULL DEFAULT clock_timestamp() +); + +-- Skills:带绑定的提示词包(content + variables + 工具/MCP/知识库绑定) +CREATE TABLE IF NOT EXISTS gateway.skills ( + id uuid PRIMARY KEY, + code text NOT NULL UNIQUE CHECK (code ~ '^[a-z][a-z0-9_-]{1,63}$'), + name text NOT NULL, + description text NOT NULL DEFAULT '', + content text NOT NULL CHECK (length(content) BETWEEN 1 AND 100000), + variables jsonb NOT NULL DEFAULT '[]'::jsonb CHECK (jsonb_typeof(variables) = 'array'), + tool_ids uuid[] NOT NULL DEFAULT '{}', + mcp_server_ids uuid[] NOT NULL DEFAULT '{}', + knowledge_base_ids uuid[] NOT NULL DEFAULT '{}', + status text NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'published', 'archived')), + category_id uuid REFERENCES gateway.marketplace_categories(id) ON DELETE SET NULL, + tags text[] NOT NULL DEFAULT '{}', + department_ids uuid[] NOT NULL DEFAULT '{}', + enabled boolean NOT NULL DEFAULT true, + revision bigint NOT NULL DEFAULT 1, + created_by uuid REFERENCES gateway.admin_accounts(id), + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + updated_at timestamptz NOT NULL DEFAULT clock_timestamp() +); + +-- 数字员工:复合编排(persona + 模型 + 技能/工具/MCP/知识库绑定 + 生成参数) +CREATE TABLE IF NOT EXISTS gateway.digital_employees ( + id uuid PRIMARY KEY, + code text NOT NULL UNIQUE CHECK (code ~ '^[a-z][a-z0-9_-]{2,63}$'), + name text NOT NULL, + description text NOT NULL DEFAULT '', + persona text NOT NULL DEFAULT '', + model text NOT NULL DEFAULT '', + skill_ids uuid[] NOT NULL DEFAULT '{}', + tool_ids uuid[] NOT NULL DEFAULT '{}', + mcp_server_ids uuid[] NOT NULL DEFAULT '{}', + knowledge_base_ids uuid[] NOT NULL DEFAULT '{}', + temperature numeric NOT NULL DEFAULT 0.7 CHECK (temperature >= 0 AND temperature <= 2), + retrieval_top_k integer NOT NULL DEFAULT 5 CHECK (retrieval_top_k BETWEEN 1 AND 50), + max_tool_rounds integer NOT NULL DEFAULT 5 CHECK (max_tool_rounds BETWEEN 1 AND 20), + status text NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'published', 'archived')), + category_id uuid REFERENCES gateway.marketplace_categories(id) ON DELETE SET NULL, + tags text[] NOT NULL DEFAULT '{}', + department_ids uuid[] NOT NULL DEFAULT '{}', + enabled boolean NOT NULL DEFAULT true, + revision bigint NOT NULL DEFAULT 1, + created_by uuid REFERENCES gateway.admin_accounts(id), + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + updated_at timestamptz NOT NULL DEFAULT clock_timestamp() +); + +CREATE TABLE IF NOT EXISTS gateway.digital_employee_runs ( + id uuid PRIMARY KEY, + digital_employee_id uuid NOT NULL REFERENCES gateway.digital_employees(id) ON DELETE CASCADE, + api_key_id uuid REFERENCES gateway.api_keys(id) ON DELETE SET NULL, + request_id text NOT NULL, + status text NOT NULL CHECK (status IN ('success', 'error')), + latency_ms bigint NOT NULL DEFAULT 0, + retrieval_count integer NOT NULL DEFAULT 0, + tool_count integer NOT NULL DEFAULT 0, + error text NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT clock_timestamp() +); + +CREATE INDEX IF NOT EXISTS digital_employee_runs_de_time_idx + ON gateway.digital_employee_runs (digital_employee_id, created_at DESC); + +-- 市场安装:portal 用户工作区绑定(资源权限:跨部门使用需先安装) +CREATE TABLE IF NOT EXISTS gateway.marketplace_installations ( + id uuid PRIMARY KEY, + resource_type text NOT NULL + CHECK (resource_type IN ('mcp_server', 'skill', 'digital_employee')), + resource_id uuid NOT NULL, + portal_user_id uuid NOT NULL REFERENCES gateway.portal_users(id) ON DELETE CASCADE, + config_override jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(config_override) = 'object'), + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + UNIQUE (resource_type, resource_id, portal_user_id) +); + +CREATE INDEX IF NOT EXISTS marketplace_installations_user_idx + ON gateway.marketplace_installations (portal_user_id); + +-- 市场可见性:published + enabled 的资源按更新时间倒序 +CREATE INDEX IF NOT EXISTS mcp_servers_visible_idx + ON gateway.mcp_servers (status, enabled, updated_at DESC); +CREATE INDEX IF NOT EXISTS skills_visible_idx + ON gateway.skills (status, enabled, updated_at DESC); +CREATE INDEX IF NOT EXISTS digital_employees_visible_idx + ON gateway.digital_employees (status, enabled, updated_at DESC); + +COMMENT ON TABLE gateway.mcp_servers IS + 'Marketplace MCP server registrations (streamable-http/sse). Tools are discovered live via the MCP client, not stored.'; +COMMENT ON TABLE gateway.digital_employees IS + 'Composite digital employees: persona + model + bound skills/tools/MCP servers/knowledge bases.'; +COMMENT ON TABLE gateway.marketplace_installations IS + 'Portal workspace bindings granting cross-department access to published marketplace resources.'; diff --git a/scripts/check_openapi_routes.py b/scripts/check_openapi_routes.py new file mode 100644 index 0000000..0780adc --- /dev/null +++ b/scripts/check_openapi_routes.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +"""Fail when a literal Go ServeMux route is absent from the OpenAPI contract.""" +from __future__ import annotations +import re +from pathlib import Path +import yaml + +ROUTE = re.compile(r'HandleFunc\("(GET|POST|PUT|PATCH|DELETE) ([^" ]+)"') +PARAM = re.compile(r"\{[^{}]+\}") + + +def normalized(method: str, path: str) -> tuple[str, str]: + return method.upper(), PARAM.sub("{}", path) + + +document = yaml.safe_load(Path("api/openapi/gateway.yaml").read_text()) +contract = {normalized(method, path) for path, value in document["paths"].items() for method in value if method.lower() in {"get", "post", "put", "patch", "delete"}} +implemented = set() +for source in list(Path("internal").rglob("*.go")) + list(Path("cmd").rglob("*.go")): + for method, path in ROUTE.findall(source.read_text()): + implemented.add((normalized(method, path), str(source))) +missing = sorted((route, source) for route, source in implemented if route not in contract) +for (method, path), source in missing: + print(f"{method} {path} ({source})") +if missing: + raise SystemExit(f"{len(missing)} implemented literal routes are missing from OpenAPI") +print(f"OpenAPI covers {len(implemented)} literal Go routes") diff --git a/scripts/compare_route_contracts.py b/scripts/compare_route_contracts.py new file mode 100755 index 0000000..46b5465 --- /dev/null +++ b/scripts/compare_route_contracts.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +"""Build a deterministic legacy FastAPI -> Go OpenAPI route contract matrix. + +The script uses Python AST only; importing the legacy application is deliberately +avoided so the audit does not require its runtime dependencies or secrets. +""" +from __future__ import annotations + +import argparse +import ast +import re +from dataclasses import dataclass +from pathlib import Path + +import yaml + +METHODS = {"get", "post", "put", "patch", "delete"} +PARAM = re.compile(r"\{[^{}]+\}") + + +@dataclass(frozen=True) +class Route: + method: str + path: str + source: str + line: int + + +def literal(node: ast.AST | None, default: str = "") -> str: + return node.value if isinstance(node, ast.Constant) and isinstance(node.value, str) else default + + +def legacy_routes(root: Path) -> list[Route]: + result: list[Route] = [] + for source in sorted((root / "app").glob("*.py")): + tree = ast.parse(source.read_text(encoding="utf-8"), filename=str(source)) + prefixes: dict[str, str] = {"app": ""} + for node in tree.body: + if not isinstance(node, (ast.Assign, ast.AnnAssign)): + continue + value = node.value + if not isinstance(value, ast.Call) or not isinstance(value.func, ast.Name) or value.func.id != "APIRouter": + continue + targets = node.targets if isinstance(node, ast.Assign) else [node.target] + prefix = next((literal(item.value) for item in value.keywords if item.arg == "prefix"), "") + for target in targets: + if isinstance(target, ast.Name): + prefixes[target.id] = prefix + for node in ast.walk(tree): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + for decorator in node.decorator_list: + if not isinstance(decorator, ast.Call) or not isinstance(decorator.func, ast.Attribute): + continue + owner = decorator.func.value + if not isinstance(owner, ast.Name) or decorator.func.attr.lower() not in METHODS or not decorator.args: + continue + route_path = literal(decorator.args[0], "") + if route_path == "" and not (isinstance(decorator.args[0], ast.Constant) and decorator.args[0].value == ""): + continue + full = (prefixes.get(owner.id, "") + route_path) or "/" + result.append(Route(decorator.func.attr.upper(), full, source.name, decorator.lineno)) + return sorted(set(result), key=lambda item: (item.path, item.method, item.source, item.line)) + + +def current_routes(spec: Path) -> set[tuple[str, str]]: + document = yaml.safe_load(spec.read_text(encoding="utf-8")) + return {(method.upper(), path) for path, item in document["paths"].items() for method in item if method.lower() in METHODS} + + +def canonical(path: str) -> str: + if path.startswith("/admin/api"): + path = "/api/v1/admin" + path[len("/admin/api"):] + elif path.startswith("/portal/api"): + path = "/api/v1/portal" + path[len("/portal/api"):] + return PARAM.sub("{}", path.rstrip("/") or "/") + + +ALIASES = { + "/api/v1/admin/keys": "/api/v1/admin/api-keys", + "/api/v1/admin/kb": "/api/v1/admin/knowledge-bases", + "/api/v1/admin/model_pricing": "/api/v1/admin/model-prices", + "/api/v1/admin/logs": "/api/v1/admin/audit-events", + "/api/v1/admin/users": "/api/v1/admin/identities/portal-users", + "/api/v1/admin/admins": "/api/v1/admin/identities/admins", + "/api/v1/admin/system_info": "/api/v1/admin/system-info", + "/api/v1/admin/totp/backup_codes": "/api/v1/admin/totp/backup-codes", + "/api/v1/portal/totp/backup_codes": "/api/v1/portal/totp/backup-codes", + "/v1/apps": "/v1/applications", +} + +# Capability replacements whose new API intentionally does not mirror the old +# path shape. They remain migration work (client payload/response validation), +# but are not incorrectly reported as an unimplemented domain. +CAPABILITY_REPLACEMENTS = [ + (re.compile(r"^/api/v1/admin/(rules|rule_dimensions|content_audit_scopes)(/|$)"), "/api/v1/admin/content-policies"), + (re.compile(r"^/api/v1/admin/(rate_limit_rules|model_qps)(/|$)"), "/api/v1/admin/api-keys and /api/v1/admin/model-routes"), + (re.compile(r"^/api/v1/admin/(model_aliases|routing_groups)(/|$)"), "/api/v1/admin/model-routes"), + (re.compile(r"^/api/v1/admin/(stats|dept_stats|cost_report|pricing_config|dept_budgets)(/|$)"), "/api/v1/admin/usage/daily and /api/v1/admin/model-prices"), + (re.compile(r"^/api/v1/admin/(notify_config)(/|$)"), "/api/v1/admin/notification-channels"), + (re.compile(r"^/api/v1/admin/(logs|log_search|log_views)(/|$)"), "/api/v1/admin/audit-events"), + (re.compile(r"^/api/v1/admin/malicious_domains(/|$)"), "/api/v1/admin/content-policies"), + (re.compile(r"^/api/v1/admin/prompts/categories(/|$)"), "/api/v1/admin/prompt-categories"), + (re.compile(r"^/api/v1/admin/prompts/\{\}/preview$"), "/api/v1/admin/prompts/{prompt_id}/render"), + (re.compile(r"^/api/v1/admin/knowledge-bases/\{\}/documents/(paste|upload)$"), "/api/v1/admin/knowledge-bases/{knowledge_base_id}/documents"), + (re.compile(r"^/api/v1/admin/knowledge-bases/\{\}/test_search$"), "/api/v1/admin/knowledge-bases/{knowledge_base_id}/search"), + (re.compile(r"^/api/v1/admin/tools/\{\}/test_invoke$"), "/api/v1/admin/tools/{tool_id}/test"), + (re.compile(r"^/api/v1/admin/applications/\{\}/composition$"), "/api/v1/admin/applications/{application_id}"), + (re.compile(r"^/api/v1/admin/audit_settings(/|$)"), "/api/v1/admin/content-policies and /api/v1/admin/notification-channels"), + (re.compile(r"^/api/v1/admin/feishu_auth(/|$)"), "/api/v1/admin/identity-providers (generic OIDC/SAML)"), + (re.compile(r"^/api/v1/portal/feishu(/|$)"), "/api/v1/portal/sso (generic OIDC/SAML)"), + (re.compile(r"^/api/v1/admin/providers/\{\}/backends(/|$)"), "/api/v1/admin/providers/{provider_id}/models and /api/v1/admin/model-routes"), + (re.compile(r"^/api/v1/admin/providers/\{\}/fetch_models$"), "/api/v1/admin/providers/{provider_id}/models/sync"), + (re.compile(r"^/api/v1/admin/audit-events(/search|/\{\}/view_history)?$"), "/api/v1/admin/audit-events (bounded immutable audit query)"), + (re.compile(r"^/api/v1/admin/applications/\{\}/test$"), "/v1/applications/{application_code}/chat/completions"), + (re.compile(r"^/api/v1/admin/applications/\{\}/keys$"), "/api/v1/admin/api-keys (scoped credentials)"), + (re.compile(r"^/api/v1/admin/identities/portal-users/\{\}/approve$"), "/api/v1/admin/identities/portal-users/{identity_id} active=true"), + (re.compile(r"^/api/v1/admin/identities/portal-users/\{\}/reset_password$"), "/api/v1/admin/identities/portal-users/{identity_id} password field"), + (re.compile(r"^/api/v1/admin/identities/portal-users/\{\}/feishu$"), "/api/v1/admin/identity-providers (generic identity link lifecycle)"), + (re.compile(r"^/api/v1/admin/identities/(admins|portal-users)/\{\}$"), "/api/v1/admin/identities/*/{identity_id} active=false"), + (re.compile(r"^/api/v1/admin/departments/\{\}$"), "/api/v1/admin/departments/{department_id} active=false"), + (re.compile(r"^/api/v1/admin/identities/admins/change_password$"), "/api/v1/admin/password"), + (re.compile(r"^/api/v1/admin/api-keys/\{\}$"), "/api/v1/admin/api-keys/{api_key_id}/limits"), + (re.compile(r"^/api/v1/admin/providers/\{\}$"), "/api/v1/admin/providers/{provider_id} enabled=false"), + (re.compile(r"^/v1/apps/\{\}/chat/completions$"), "/v1/applications/{application_code}/chat/completions"), +] + +RETIRED = [ + (re.compile(r"^/(admin|portal)/ui$"), "Art Design Pro SPA deployment"), + (re.compile(r"^/api/v1/admin/logs/\{\}/view_raw$"), "retired: unbounded raw-body access is excluded by storage/compliance ADR"), + (re.compile(r"^/api/v1/admin/logs$"), "retired: immutable audit data is removed only by the configured retention worker"), + (re.compile(r"^/api/v1/admin/tools/\{\}/headers$"), "retired: decrypted secret headers are intentionally write-only"), +] + + +def alias(path: str) -> str: + for old, new in sorted(ALIASES.items(), key=lambda item: len(item[0]), reverse=True): + if path == old or path.startswith(old + "/"): + return new + path[len(old):] + return path + + +def compare(legacy: list[Route], current: set[tuple[str, str]]) -> list[tuple[Route, str, str]]: + normalized_current = {(method, canonical(path)): path for method, path in current} + rows = [] + for route in legacy: + expected = canonical(route.path) + direct = normalized_current.get((route.method, expected)) + if direct: + rows.append((route, "covered", direct)) + continue + replacement = alias(expected) + target = normalized_current.get((route.method, replacement)) + if target: + rows.append((route, "replaced", target)) + continue + if route.method == "PATCH": + target = normalized_current.get(("PUT", replacement)) + if target: + rows.append((route, "replaced", target + " (PUT with full revision payload)")) + continue + decision = next((target for pattern, target in CAPABILITY_REPLACEMENTS if pattern.search(replacement)), None) + if decision: + rows.append((route, "replaced", decision)) + continue + retirement = next((target for pattern, target in RETIRED if pattern.search(expected)), None) + rows.append((route, "retired" if retirement else "missing", retirement or replacement)) + return rows + + +def write_markdown(rows: list[tuple[Route, str, str]], destination: Path) -> None: + counts = {status: sum(1 for _, value, _ in rows if value == status) for status in ("covered", "replaced", "retired", "missing")} + lines = [ + "# 旧 Python → Go 路由契约矩阵", "", + "> 由 `scripts/compare_route_contracts.py` 通过 AST 与 OpenAPI 自动生成;`replaced` 表示能力有新契约承接,仍需兼容层或客户端迁移,不能视为原路由原样可用。", "", + f"- 旧源码路由装饰器:{len(rows)}", f"- 同方法同规范路径:{counts['covered']}", + f"- 新契约替代:{counts['replaced']}", f"- 明确退役:{counts['retired']}", f"- 尚无契约:{counts['missing']}", "", + "| 状态 | 方法 | 旧路径 | Go 契约/预期目标 | 来源 |", "|---|---|---|---|---|", + ] + labels = {"covered": "已覆盖", "replaced": "新契约替代", "retired": "明确退役", "missing": "缺口"} + for route, status, target in rows: + lines.append(f"| {labels[status]} | `{route.method}` | `{route.path or '/'}` | `{target}` | `{route.source}:{route.line}` |") + destination.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--legacy-root", type=Path, default=Path("/home/ben/ai-gateway-work")) + parser.add_argument("--openapi", type=Path, default=Path("api/openapi/gateway.yaml")) + parser.add_argument("--output", type=Path, default=Path("docs/route-contract-matrix.md")) + parser.add_argument("--fail-on-missing", action="store_true", help="return a non-zero status when an undecided route remains") + args = parser.parse_args() + rows = compare(legacy_routes(args.legacy_root), current_routes(args.openapi)) + args.output.parent.mkdir(parents=True, exist_ok=True) + write_markdown(rows, args.output) + counts = {status: sum(1 for _, value, _ in rows if value == status) for status in ("covered", "replaced", "retired", "missing")} + print(f"legacy={len(rows)} covered={counts['covered']} replaced={counts['replaced']} retired={counts['retired']} missing={counts['missing']}") + if args.fail_on_missing and counts["missing"]: + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/cutover.sh b/scripts/cutover.sh new file mode 100755 index 0000000..d3a510c --- /dev/null +++ b/scripts/cutover.sh @@ -0,0 +1,84 @@ +#!/bin/sh +set -eu + +# Atomic Nginx upstream switch with validation and automatic config rollback. +# This script performs no action unless invoked with preflight, go, legacy, or status. + +action=${1:-status} +active_include=${NGINX_ACTIVE_INCLUDE:-} +go_upstream=${GO_UPSTREAM:-gateway-api:8080} +legacy_upstream=${LEGACY_UPSTREAM:-legacy-gateway:8000} +go_health=${GO_HEALTH_URL:-http://127.0.0.1:8080/readyz} +legacy_health=${LEGACY_HEALTH_URL:-http://127.0.0.1:8000/readyz} +public_health=${PUBLIC_HEALTH_URL:-} +nginx_bin=${NGINX_BIN:-nginx} + +probe() { + curl -fsS --max-time 5 "$1" >/dev/null +} + +preflight() { + probe "$go_health" + probe "$legacy_health" + "$nginx_bin" -t + echo "preflight ok: Go and legacy are ready; Nginx configuration is valid" +} + +status() { + if [ -z "$active_include" ] || [ ! -f "$active_include" ]; then + echo "active upstream include is not configured or does not exist" + exit 1 + fi + sed -n '1,3p' "$active_include" +} + +switch_to() { + target_name=$1 + target_address=$2 + target_health=$3 + if [ -z "$active_include" ]; then + echo "NGINX_ACTIVE_INCLUDE is required" >&2 + exit 2 + fi + probe "$target_health" + directory=$(dirname "$active_include") + timestamp=$(date -u +%Y%m%dT%H%M%SZ) + backup="${active_include}.backup.${timestamp}" + temporary="${directory}/.ai-gateway-upstream.${timestamp}.tmp" + if [ -f "$active_include" ]; then + cp -p "$active_include" "$backup" + fi + umask 027 + { + echo "# ai-gateway-active=${target_name} switched_at=${timestamp}" + echo "upstream ai_gateway_active {" + echo " server ${target_address};" + echo " keepalive 128;" + echo "}" + } >"$temporary" + mv "$temporary" "$active_include" + if ! "$nginx_bin" -t; then + if [ -f "$backup" ]; then mv "$backup" "$active_include"; fi + echo "Nginx validation failed; previous include restored" >&2 + exit 1 + fi + if ! "$nginx_bin" -s reload; then + if [ -f "$backup" ]; then mv "$backup" "$active_include"; "$nginx_bin" -s reload || true; fi + echo "Nginx reload failed; previous include restored" >&2 + exit 1 + fi + if [ -n "$public_health" ] && ! probe "$public_health"; then + if [ -f "$backup" ]; then mv "$backup" "$active_include"; "$nginx_bin" -t; "$nginx_bin" -s reload; fi + echo "public health probe failed; traffic rolled back" >&2 + exit 1 + fi + echo "active upstream switched to ${target_name}; backup: ${backup}" +} + +case "$action" in + preflight) preflight ;; + status) status ;; + go) switch_to go "$go_upstream" "$go_health" ;; + legacy|rollback) switch_to legacy "$legacy_upstream" "$legacy_health" ;; + *) echo "usage: $0 {preflight|status|go|legacy|rollback}" >&2; exit 2 ;; +esac diff --git a/scripts/export_legacy_data.py b/scripts/export_legacy_data.py new file mode 100644 index 0000000..639650c --- /dev/null +++ b/scripts/export_legacy_data.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Export a legacy SQLite database as deterministic JSON Lines for staging. + +The default omits credential/ciphertext columns. --include-encrypted retains old +ciphertext only for a controlled offline transform; it never decrypts or logs it. +""" +from __future__ import annotations + +import argparse +import base64 +import datetime as dt +import hashlib +import json +import sqlite3 +import sys +from pathlib import Path + +SENSITIVE_MARKERS = ("password", "secret", "api_key", "raw_key", "totp", "backup_codes", "headers_template") + + +def encode(value): + if isinstance(value, bytes): + return {"$binary_base64": base64.b64encode(value).decode("ascii")} + if isinstance(value, (dt.date, dt.datetime)): + return value.isoformat() + return value + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("database", type=Path) + parser.add_argument("--source-system", default="python-gateway") + parser.add_argument("--include-encrypted", action="store_true") + args = parser.parse_args() + if not args.database.is_file(): + parser.error("SQLite database does not exist") + connection = sqlite3.connect(f"file:{args.database}?mode=ro", uri=True) + connection.row_factory = sqlite3.Row + tables = [row[0] for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")] + for table in tables: + columns = [row[1] for row in connection.execute(f'PRAGMA table_info("{table}")')] + id_column = "id" if "id" in columns else None + if id_column is None: + print(f"skip table without id: {table}", file=sys.stderr) + continue + selected = [column for column in columns if args.include_encrypted or not any(marker in column.lower() for marker in SENSITIVE_MARKERS)] + order = f' ORDER BY "{id_column}"' + query = "SELECT " + ",".join(f'"{column}"' for column in selected) + f' FROM "{table}"' + order + for row in connection.execute(query): + data = {column: encode(row[column]) for column in selected} + record = {"source_system": args.source_system, "entity_type": table, "legacy_id": str(row[id_column]), "data": data} + canonical = json.dumps(record, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + record["checksum"] = hashlib.sha256(canonical.encode()).hexdigest() + print(json.dumps(record, ensure_ascii=False, sort_keys=True, separators=(",", ":"))) + + +if __name__ == "__main__": + main() diff --git a/scripts/export_legacy_dictionary.py b/scripts/export_legacy_dictionary.py new file mode 100644 index 0000000..63cc286 --- /dev/null +++ b/scripts/export_legacy_dictionary.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Export the Python gateway SQLAlchemy schema as a reviewable Markdown dictionary. + +This is an offline migration-development tool. It is not part of the Go runtime. +""" + +from __future__ import annotations + +import argparse +import importlib +import sys +from pathlib import Path + + +CURRENT_TARGETS = { + "departments": "gateway.departments(已落地)", + "providers": "gateway.providers + gateway.provider_models(已落地,models 拆表)", + "users": "gateway.portal_users(已落地)", + "virtual_keys": "gateway.api_keys(已落地,字段需转换)", + "admin_accounts": "gateway.admin_accounts(已落地)", + "audit_logs": "gateway.audit_events(已落地,不迁移无限长正文)", +} + + +def default_text(column) -> str: + value = column.default + if value is None: + return "—" + argument = value.arg + if callable(argument): + return getattr(argument, "__name__", "callable") + return str(argument).replace("|", "\\|") + + +def render(legacy_root: Path) -> str: + sys.path.insert(0, str(legacy_root)) + db = importlib.import_module("app.db") + tables = [table for name, table in sorted(db.Base.metadata.tables.items()) if name != "audit_log_views"] + + lines = [ + "# 旧 Python 网关数据字典与 ID 迁移映射", + "", + "> 由 `scripts/export_legacy_dictionary.py` 从旧工程 SQLAlchemy metadata 生成。", + "> `audit_log_views` 是兼容查询投影,不作为独立持久化实体,因此这里统计 38 个实体。", + "", + "## 映射约定", + "", + "- 旧整数主键统一通过 `gateway.legacy_id_mappings` 映射为 UUID,不在新业务表保留双主键。", + "- UUID 使用固定 namespace 的 UUIDv5 生成,输入为 `source_system/entity_type/legacy_id`,重复导入保持幂等。", + "- 外键迁移必须先查映射表;缺失映射时整批失败,不允许写入悬空引用。", + "- 旧密文不能直接复用:Provider、TOTP、内部服务 Key 等敏感字段在导入时解密后用新用途标签重新加密。", + "- `audit_logs` 的完整请求/响应正文不进入新库,只迁移受长度约束的预览与结构化元数据。", + "", + "## 实体去向", + "", + "| 旧表 | 新域/目标 | 状态 |", + "|---|---|---|", + ] + for table in tables: + target = CURRENT_TARGETS.get(table.name, "对应 M2–M4 领域表") + status = "已建表,待批量导入器" if table.name in CURRENT_TARGETS else "已登记,随对应里程碑建表" + lines.append(f"| `{table.name}` | {target} | {status} |") + + lines.extend(["", "## 字段字典", ""]) + for table in tables: + lines.extend([ + f"### `{table.name}`", + "", + "| 字段 | 旧类型 | 可空 | 默认值 | 键/引用 |", + "|---|---|---:|---|---|", + ]) + for column in table.columns: + flags = [] + if column.primary_key: + flags.append("PK") + if column.unique: + flags.append("UNIQUE") + flags.extend(f"FK → `{fk.target_fullname}`" for fk in column.foreign_keys) + lines.append( + f"| `{column.name}` | `{column.type}` | {'是' if column.nullable else '否'} | " + f"`{default_text(column)}` | {';'.join(flags) or '—'} |" + ) + lines.append("") + return "\n".join(lines).rstrip() + "\n" + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--legacy-root", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(render(args.legacy_root.resolve()), encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..7594d2b --- /dev/null +++ b/web/README.md @@ -0,0 +1,18 @@ +# AI Gateway Web + +管理端与用户门户均基于 Art Design Pro v3.0.2 的官方 MIT 模板,并已执行官方 `clean:dev` 清理流程移除演示模块。 + +- `apps/admin`:管理控制台,默认端口 `3006`,部署基路径 `/admin/`。 +- `apps/portal`:用户门户,默认端口 `3007`,部署基路径 `/portal/`。 +- `packages/api-client`:由 OpenAPI 契约演进的共享客户端。当前先提供健康检查类型,管理 API 契约迁移后再用生成器替换实现。 + +安装和构建: + +```bash +corepack pnpm install --ignore-scripts +corepack pnpm dev:admin +corepack pnpm build +``` + +模板来源与许可:[Daymychen/art-design-pro v3.0.2](https://github.com/Daymychen/art-design-pro/releases/tag/v3.0.2)。各应用目录保留了原始 `LICENSE`。 + diff --git a/web/apps/admin/.env.development b/web/apps/admin/.env.development new file mode 100755 index 0000000..669834d --- /dev/null +++ b/web/apps/admin/.env.development @@ -0,0 +1,13 @@ +# 【开发】环境变量 + +# 应用部署基础路径(如部署在子目录 /admin,则设置为 /admin/) +VITE_BASE_URL = /admin/ + +# API 请求基础路径(开发环境设置为 / 使用代理,生产环境设置为完整后端地址) +VITE_API_URL = / + +# 代理目标地址(开发环境通过 Vite 代理转发请求到此地址,解决跨域问题) +VITE_API_PROXY_URL = http://127.0.0.1:8080 + +# Delete console +VITE_DROP_CONSOLE = false diff --git a/web/apps/admin/.env.production b/web/apps/admin/.env.production new file mode 100755 index 0000000..62b857c --- /dev/null +++ b/web/apps/admin/.env.production @@ -0,0 +1,13 @@ +# 【生产】环境变量 + +# 应用部署基础路径(如部署在子目录 /admin,则设置为 /admin/) +VITE_BASE_URL = /admin/ + +# API 地址前缀 +VITE_API_URL = / + +# 生产环境必须使用后端权限和菜单,禁止模板前端模拟权限 +VITE_ACCESS_MODE = backend + +# Delete console +VITE_DROP_CONSOLE = true diff --git a/web/apps/admin/.gitattributes b/web/apps/admin/.gitattributes new file mode 100644 index 0000000..866e8ee --- /dev/null +++ b/web/apps/admin/.gitattributes @@ -0,0 +1,2 @@ +*.html linguist-detectable=false +*.vue linguist-detectable=true diff --git a/web/apps/admin/.gitignore b/web/apps/admin/.gitignore new file mode 100755 index 0000000..e48d2e9 --- /dev/null +++ b/web/apps/admin/.gitignore @@ -0,0 +1,11 @@ +node_modules +.DS_Store +dist +dist-ssr +*.local +.cursorrules + +# Auto-generated files +src/types/import/auto-imports.d.ts +src/types/import/components.d.ts +.auto-import.json diff --git a/web/apps/admin/.husky/commit-msg b/web/apps/admin/.husky/commit-msg new file mode 100644 index 0000000..09d2b14 --- /dev/null +++ b/web/apps/admin/.husky/commit-msg @@ -0,0 +1 @@ +pnpm dlx commitlint --edit $1 \ No newline at end of file diff --git a/web/apps/admin/.husky/pre-commit b/web/apps/admin/.husky/pre-commit new file mode 100644 index 0000000..22c0347 --- /dev/null +++ b/web/apps/admin/.husky/pre-commit @@ -0,0 +1 @@ +pnpm run lint:lint-staged \ No newline at end of file diff --git a/web/apps/admin/.prettierignore b/web/apps/admin/.prettierignore new file mode 100644 index 0000000..9e96efc --- /dev/null +++ b/web/apps/admin/.prettierignore @@ -0,0 +1,3 @@ +/node_modules/* +/dist/* +/src/main.ts \ No newline at end of file diff --git a/web/apps/admin/.prettierrc b/web/apps/admin/.prettierrc new file mode 100644 index 0000000..f3d6ad5 --- /dev/null +++ b/web/apps/admin/.prettierrc @@ -0,0 +1,20 @@ +{ + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "semi": false, + "vueIndentScriptAndStyle": true, + "singleQuote": true, + "quoteProps": "as-needed", + "bracketSpacing": true, + "trailingComma": "none", + "bracketSameLine": false, + "jsxSingleQuote": false, + "arrowParens": "always", + "insertPragma": false, + "requirePragma": false, + "proseWrap": "never", + "htmlWhitespaceSensitivity": "strict", + "endOfLine": "auto", + "rangeStart": 0 +} diff --git a/web/apps/admin/.stylelintignore b/web/apps/admin/.stylelintignore new file mode 100644 index 0000000..476ea45 --- /dev/null +++ b/web/apps/admin/.stylelintignore @@ -0,0 +1,9 @@ +dist +node_modules +public +.husky +.vscode + +src/components/Layout/MenuLeft/index.vue +src/assets +stats.html \ No newline at end of file diff --git a/web/apps/admin/.stylelintrc.cjs b/web/apps/admin/.stylelintrc.cjs new file mode 100644 index 0000000..9dbea0b --- /dev/null +++ b/web/apps/admin/.stylelintrc.cjs @@ -0,0 +1,82 @@ +module.exports = { + // 继承推荐规范配置 + extends: [ + 'stylelint-config-standard', + 'stylelint-config-recommended-scss', + 'stylelint-config-recommended-vue/scss', + 'stylelint-config-html/vue', + 'stylelint-config-recess-order' + ], + // 指定不同文件对应的解析器 + overrides: [ + { + files: ['**/*.{vue,html}'], + customSyntax: 'postcss-html' + }, + { + files: ['**/*.{css,scss}'], + customSyntax: 'postcss-scss' + } + ], + // 自定义规则 + rules: { + 'import-notation': 'string', // 指定导入CSS文件的方式("string"|"url") + 'selector-class-pattern': null, // 选择器类名命名规则 + 'custom-property-pattern': null, // 自定义属性命名规则 + 'keyframes-name-pattern': null, // 动画帧节点样式命名规则 + 'no-descending-specificity': null, // 允许无降序特异性 + 'no-empty-source': null, // 允许空样式 + 'property-no-vendor-prefix': null, // 允许属性前缀 + // 允许 global 、export 、deep伪类 + 'selector-pseudo-class-no-unknown': [ + true, + { + ignorePseudoClasses: ['global', 'export', 'deep'] + } + ], + // 允许未知属性 + 'property-no-unknown': [ + true, + { + ignoreProperties: [] + } + ], + // 允许未知规则 + 'at-rule-no-unknown': [ + true, + { + ignoreAtRules: [ + 'apply', + 'use', + 'mixin', + 'include', + 'extend', + 'each', + 'if', + 'else', + 'for', + 'while', + 'reference' + ] + } + ], + 'scss/at-rule-no-unknown': [ + true, + { + ignoreAtRules: [ + 'apply', + 'use', + 'mixin', + 'include', + 'extend', + 'each', + 'if', + 'else', + 'for', + 'while', + 'reference' + ] + } + ] + } +} diff --git a/web/apps/admin/LICENSE b/web/apps/admin/LICENSE new file mode 100755 index 0000000..cf36c79 --- /dev/null +++ b/web/apps/admin/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 SuperManTT + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/web/apps/admin/commitlint.config.cjs b/web/apps/admin/commitlint.config.cjs new file mode 100644 index 0000000..d2ef1bd --- /dev/null +++ b/web/apps/admin/commitlint.config.cjs @@ -0,0 +1,97 @@ +/** + * commitlint 配置文件 + * 文档 + * https://commitlint.js.org/#/reference-rules + * https://cz-git.qbb.sh/zh/guide/ + */ + +module.exports = { + // 继承的规则 + extends: ['@commitlint/config-conventional'], + // 自定义规则 + rules: { + // 提交类型枚举,git提交type必须是以下类型 + 'type-enum': [ + 2, + 'always', + [ + 'feat', // 新增功能 + 'fix', // 修复缺陷 + 'docs', // 文档变更 + 'style', // 代码格式(不影响功能,例如空格、分号等格式修正) + 'refactor', // 代码重构(不包括 bug 修复、功能新增) + 'perf', // 性能优化 + 'test', // 添加疏漏测试或已有测试改动 + 'build', // 构建流程、外部依赖变更(如升级 npm 包、修改 webpack 配置等) + 'ci', // 修改 CI 配置、脚本 + 'revert', // 回滚 commit + 'chore', // 对构建过程或辅助工具和库的更改(不影响源文件、测试用例) + 'wip' // 对构建过程或辅助工具和库的更改(不影响源文件、测试用例) + ] + ], + 'subject-case': [0] // subject大小写不做校验 + }, + + prompt: { + messages: { + type: '选择你要提交的类型 :', + scope: '选择一个提交范围(可选):', + customScope: '请输入自定义的提交范围 :', + subject: '填写简短精炼的变更描述 :\n', + body: '填写更加详细的变更描述(可选)。使用 "|" 换行 :\n', + breaking: '列举非兼容性重大的变更(可选)。使用 "|" 换行 :\n', + footerPrefixesSelect: '选择关联issue前缀(可选):', + customFooterPrefix: '输入自定义issue前缀 :', + footer: '列举关联issue (可选) 例如: #31, #I3244 :\n', + generatingByAI: '正在通过 AI 生成你的提交简短描述...', + generatedSelectByAI: '选择一个 AI 生成的简短描述:', + confirmCommit: '是否提交或修改commit ?' + }, + // prettier-ignore + types: [ + { value: "feat", name: "feat: 新增功能" }, + { value: "fix", name: "fix: 修复缺陷" }, + { value: "docs", name: "docs: 文档变更" }, + { value: "style", name: "style: 代码格式(不影响功能,例如空格、分号等格式修正)" }, + { value: "refactor", name: "refactor: 代码重构(不包括 bug 修复、功能新增)" }, + { value: "perf", name: "perf: 性能优化" }, + { value: "test", name: "test: 添加疏漏测试或已有测试改动" }, + { value: "build", name: "build: 构建流程、外部依赖变更(如升级 npm 包、修改 vite 配置等)" }, + { value: "ci", name: "ci: 修改 CI 配置、脚本" }, + { value: "revert", name: "revert: 回滚 commit" }, + { value: "chore", name: "chore: 对构建过程或辅助工具和库的更改(不影响源文件、测试用例)" }, + ], + useEmoji: true, + emojiAlign: 'center', + useAI: false, + aiNumber: 1, + themeColorCode: '', + scopes: [], + allowCustomScopes: true, + allowEmptyScopes: true, + customScopesAlign: 'bottom', + customScopesAlias: 'custom', + emptyScopesAlias: 'empty', + upperCaseSubject: false, + markBreakingChangeMode: false, + allowBreakingChanges: ['feat', 'fix'], + breaklineNumber: 100, + breaklineChar: '|', + skipQuestions: ['breaking', 'footerPrefix', 'footer'], // 跳过的步骤 + issuePrefixes: [{ value: 'closed', name: 'closed: ISSUES has been processed' }], + customIssuePrefixAlign: 'top', + emptyIssuePrefixAlias: 'skip', + customIssuePrefixAlias: 'custom', + allowCustomIssuePrefix: true, + allowEmptyIssuePrefix: true, + confirmColorize: true, + maxHeaderLength: Infinity, + maxSubjectLength: Infinity, + minSubjectLength: 0, + scopeOverrides: undefined, + defaultBody: '', + defaultIssues: '', + defaultScope: '', + defaultSubject: '' + } +} diff --git a/web/apps/admin/eslint.config.mjs b/web/apps/admin/eslint.config.mjs new file mode 100644 index 0000000..bb317f6 --- /dev/null +++ b/web/apps/admin/eslint.config.mjs @@ -0,0 +1,83 @@ +// 从 URL 和路径模块中导入必要的功能 +import fs from 'fs' +import path, { dirname } from 'path' +import { fileURLToPath } from 'url' + +// 从 ESLint 插件中导入推荐配置 +import pluginJs from '@eslint/js' +import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended' +import pluginVue from 'eslint-plugin-vue' +import globals from 'globals' +import tseslint from 'typescript-eslint' + +// 使用 import.meta.url 获取当前模块的路径 +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) + +// 读取 .auto-import.json 文件的内容,并将其解析为 JSON 对象 +const autoImportConfig = JSON.parse( + fs.readFileSync(path.resolve(__dirname, '.auto-import.json'), 'utf-8') +) + +export default [ + // 指定文件匹配规则 + { + files: ['**/*.{js,mjs,cjs,ts,vue}'] + }, + // 指定全局变量和环境 + { + languageOptions: { + globals: { + ...globals.browser, + ...globals.node + } + } + }, + // 扩展配置 + pluginJs.configs.recommended, + ...tseslint.configs.recommended, + ...pluginVue.configs['flat/essential'], + // 自定义规则 + { + // 针对所有 JavaScript、TypeScript 和 Vue 文件应用以下配置 + files: ['**/*.{js,mjs,cjs,ts,vue}'], + + languageOptions: { + globals: { + // 合并从 autoImportConfig 中读取的全局变量配置 + ...autoImportConfig.globals, + // TypeScript 全局命名空间 + Api: 'readonly' + } + }, + rules: { + quotes: ['error', 'single'], // 使用单引号 + semi: ['error', 'never'], // 语句末尾不加分号 + 'no-var': 'error', // 要求使用 let 或 const 而不是 var + '@typescript-eslint/no-explicit-any': 'off', // 禁用 any 检查 + 'vue/multi-word-component-names': 'off', // 禁用对 Vue 组件名称的多词要求检查 + 'no-multiple-empty-lines': ['warn', { max: 1 }], // 不允许多个空行 + 'no-unexpected-multiline': 'error' // 禁止空余的多行 + } + }, + // vue 规则 + { + files: ['**/*.vue'], + languageOptions: { + parserOptions: { parser: tseslint.parser } + } + }, + // 忽略文件 + { + ignores: [ + 'node_modules', + 'dist', + 'public', + '.vscode/**', + 'src/assets/**', + 'src/utils/console.ts' + ] + }, + // prettier 配置 + eslintPluginPrettierRecommended +] diff --git a/web/apps/admin/index.html b/web/apps/admin/index.html new file mode 100755 index 0000000..4602ca7 --- /dev/null +++ b/web/apps/admin/index.html @@ -0,0 +1,47 @@ + + + + LLMGuardX语枢管理中心 + + + + + + + + + + + +
+ + + diff --git a/web/apps/admin/package.json b/web/apps/admin/package.json new file mode 100755 index 0000000..9a4aecd --- /dev/null +++ b/web/apps/admin/package.json @@ -0,0 +1,124 @@ +{ + "name": "@aigateway/admin", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { + "node": ">=20.19.0", + "pnpm": ">=8.8.0" + }, + "scripts": { + "dev": "vite --open", + "build": "vue-tsc --noEmit && vite build", + "serve": "vite preview", + "lint": "eslint", + "fix": "eslint --fix", + "lint:prettier": "prettier --write \"**/*.{js,cjs,ts,json,tsx,css,less,scss,vue,html,md}\"", + "lint:stylelint": "stylelint \"**/*.{css,scss,vue}\" --fix", + "lint:lint-staged": "lint-staged", + "prepare": "husky", + "commit": "git-cz", + "clean:dev": "tsx scripts/clean-dev.ts" + }, + "config": { + "commitizen": { + "path": "node_modules/cz-git" + } + }, + "lint-staged": { + "*.{js,ts,mjs,mts,tsx}": [ + "eslint --fix", + "prettier --write" + ], + "*.{cjs,json,jsonc}": [ + "prettier --write" + ], + "*.vue": [ + "eslint --fix", + "stylelint --fix --allow-empty-input", + "prettier --write" + ], + "*.{html,htm}": [ + "prettier --write" + ], + "*.{scss,css,less}": [ + "stylelint --fix --allow-empty-input", + "prettier --write" + ], + "*.{md,mdx}": [ + "prettier --write" + ], + "*.{yaml,yml}": [ + "prettier --write" + ] + }, + "dependencies": { + "@element-plus/icons-vue": "^2.3.2", + "@iconify/vue": "^5.0.0", + "@tailwindcss/vite": "^4.1.14", + "@vue/reactivity": "^3.5.21", + "@vueuse/core": "^13.9.0", + "@wangeditor/editor": "^5.1.23", + "@wangeditor/editor-for-vue": "next", + "axios": "^1.12.2", + "crypto-js": "^4.2.0", + "echarts": "^6.0.0", + "element-plus": "^2.11.2", + "file-saver": "^2.0.5", + "highlight.js": "^11.10.0", + "mitt": "^3.0.1", + "nprogress": "^0.2.0", + "ohash": "^2.0.11", + "pinia": "^3.0.3", + "pinia-plugin-persistedstate": "^4.3.0", + "qrcode.vue": "^3.6.0", + "tailwindcss": "^4.1.14", + "vue": "^3.5.21", + "vue-draggable-plus": "^0.6.0", + "vue-i18n": "^9.14.0", + "vue-router": "^4.5.1", + "xgplayer": "^3.0.20", + "xlsx": "^0.18.5" + }, + "devDependencies": { + "@commitlint/cli": "^19.4.1", + "@commitlint/config-conventional": "^19.4.1", + "@eslint/js": "^9.9.1", + "@types/node": "^24.0.5", + "@typescript-eslint/eslint-plugin": "^8.3.0", + "@typescript-eslint/parser": "^8.3.0", + "@vitejs/plugin-vue": "^6.0.1", + "@vue/compiler-sfc": "^3.0.5", + "commitizen": "^4.3.0", + "cz-git": "^1.11.1", + "eslint": "^9.9.1", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.2.1", + "eslint-plugin-vue": "^9.27.0", + "globals": "^15.9.0", + "husky": "^9.1.5", + "lint-staged": "^15.5.2", + "prettier": "^3.5.3", + "rollup-plugin-visualizer": "^5.12.0", + "sass": "^1.81.0", + "stylelint": "^16.20.0", + "stylelint-config-html": "^1.1.0", + "stylelint-config-recess-order": "^4.6.0", + "stylelint-config-recommended-scss": "^14.1.0", + "stylelint-config-recommended-vue": "^1.5.0", + "stylelint-config-standard": "^36.0.1", + "terser": "^5.36.0", + "tsx": "^4.20.3", + "typescript": "~5.6.3", + "typescript-eslint": "^8.9.0", + "unplugin-auto-import": "^20.2.0", + "unplugin-element-plus": "^0.10.0", + "unplugin-vue-components": "^29.1.0", + "vite": "^7.1.5", + "vite-plugin-compression": "^0.5.1", + "vite-plugin-vue-devtools": "^7.7.6", + "vue-demi": "^0.14.9", + "vue-img-cutter": "^3.0.5", + "vue-tsc": "~2.1.6" + } +} diff --git a/web/apps/admin/public/favicon.ico b/web/apps/admin/public/favicon.ico new file mode 100755 index 0000000000000000000000000000000000000000..df36fcfb72584e00488330b560ebcf34a41c64c2 GIT binary patch literal 4286 zcmds*O-Phc6o&64GDVCEQHxsW(p4>LW*W<827=Unuo8sGpRux(DN@jWP-e29Wl%wj zY84_aq9}^Am9-cWTD5GGEo#+5Fi2wX_P*bo+xO!)p*7B;iKlbFd(U~_d(U?#hLj56 zPhFkj-|A6~Qk#@g^#D^U0XT1cu=c-vu1+SElX9NR;kzAUV(q0|dl0|%h|dI$%VICy zJnu2^L*Te9JrJMGh%-P79CL0}dq92RGU6gI{v2~|)p}sG5x0U*z<8U;Ij*hB9z?ei z@g6Xq-pDoPl=MANPiR7%172VA%r)kevtV-_5H*QJKFmd;8yA$98zCxBZYXTNZ#QFk2(TX0;Y2dt&WitL#$96|gJY=3xX zpCoi|YNzgO3R`f@IiEeSmKrPSf#h#Qd<$%Ej^RIeeYfsxhPMOG`S`Pz8q``=511zm zAm)MX5AV^5xIWPyEu7u>qYs?pn$I4nL9J!=K=SGlKLXpE<5x+2cDTXq?brj?n6sp= zphe9;_JHf40^9~}9i08r{XM$7HB!`{Ys~TK0kx<}ZQng`UPvH*11|q7&l9?@FQz;8 zx!=3<4seY*%=OlbCbcae?5^V_}*K>Uo6ZWV8mTyE^B=DKy7-sdLYkR5Z?paTgK-zyIkKjIcpyO z{+uIt&YSa_$QnN_@t~L014dyK(fOOo+W*MIxbA6Ndgr=Y!f#Tokqv}n<7-9qfHkc3 z=>a|HWqcX8fzQCT=dqVbogRq!-S>H%yA{1w#2Pn;=e>JiEj7Hl;zdt-2f+j2%DeVD zsW0Ab)ZK@0cIW%W7z}H{&~yGhn~D;aiP4=;m-HCo`BEI+Kd6 z={Xwx{TKxD#iCLfl2vQGDitKtN>z|-AdCN|$jTFDg0m3O`WLD4_s#$S literal 0 HcmV?d00001 diff --git a/web/apps/admin/scripts/clean-dev.ts b/web/apps/admin/scripts/clean-dev.ts new file mode 100644 index 0000000..cc0b9bc --- /dev/null +++ b/web/apps/admin/scripts/clean-dev.ts @@ -0,0 +1,838 @@ +// scripts/clean-dev.ts +import fs from 'fs/promises' +import path from 'path' + +// 现代化颜色主题 +const theme = { + // 基础颜色 + reset: '\x1b[0m', + bold: '\x1b[1m', + dim: '\x1b[2m', + + // 前景色 + primary: '\x1b[38;5;75m', // 亮蓝色 + success: '\x1b[38;5;82m', // 亮绿色 + warning: '\x1b[38;5;220m', // 亮黄色 + error: '\x1b[38;5;196m', // 亮红色 + info: '\x1b[38;5;159m', // 青色 + purple: '\x1b[38;5;141m', // 紫色 + orange: '\x1b[38;5;208m', // 橙色 + gray: '\x1b[38;5;245m', // 灰色 + white: '\x1b[38;5;255m', // 白色 + + // 背景色 + bgDark: '\x1b[48;5;235m', // 深灰背景 + bgBlue: '\x1b[48;5;24m', // 蓝色背景 + bgGreen: '\x1b[48;5;22m', // 绿色背景 + bgRed: '\x1b[48;5;52m' // 红色背景 +} + +// 现代化图标集 +const icons = { + rocket: '🚀', + fire: '🔥', + star: '⭐', + gem: '💎', + crown: '👑', + magic: '✨', + warning: '⚠️', + success: '✅', + error: '❌', + info: 'ℹ️', + folder: '📁', + file: '📄', + image: '🖼️', + code: '💻', + data: '📊', + globe: '🌐', + map: '🗺️', + chat: '💬', + bolt: '⚡', + shield: '🛡️', + key: '🔑', + link: '🔗', + clean: '🧹', + trash: '🗑️', + check: '✓', + cross: '✗', + arrow: '→', + loading: '⏳' +} + +// 格式化工具 +const fmt = { + title: (text: string) => `${theme.bold}${theme.primary}${text}${theme.reset}`, + subtitle: (text: string) => `${theme.purple}${text}${theme.reset}`, + success: (text: string) => `${theme.success}${text}${theme.reset}`, + error: (text: string) => `${theme.error}${text}${theme.reset}`, + warning: (text: string) => `${theme.warning}${text}${theme.reset}`, + info: (text: string) => `${theme.info}${text}${theme.reset}`, + highlight: (text: string) => `${theme.bold}${theme.white}${text}${theme.reset}`, + dim: (text: string) => `${theme.dim}${theme.gray}${text}${theme.reset}`, + orange: (text: string) => `${theme.orange}${text}${theme.reset}`, + + // 带背景的文本 + badge: (text: string, bg: string = theme.bgBlue) => + `${bg}${theme.white}${theme.bold} ${text} ${theme.reset}`, + + // 渐变效果模拟 + gradient: (text: string) => { + const colors = ['\x1b[38;5;75m', '\x1b[38;5;81m', '\x1b[38;5;87m', '\x1b[38;5;159m'] + const chars = text.split('') + return chars.map((char, i) => `${colors[i % colors.length]}${char}`).join('') + theme.reset + } +} + +// 创建现代化标题横幅 +function createModernBanner() { + console.log() + console.log( + fmt.gradient(' ╔══════════════════════════════════════════════════════════════════╗') + ) + console.log( + fmt.gradient(' ║ ║') + ) + console.log( + ` ║ ${icons.rocket} ${fmt.title('ART DESIGN PRO')} ${fmt.subtitle('· 代码精简程序')} ${icons.magic} ║` + ) + console.log( + ` ║ ${fmt.dim('为项目移除演示数据,快速切换至开发模式')} ║` + ) + console.log( + fmt.gradient(' ║ ║') + ) + console.log( + fmt.gradient(' ╚══════════════════════════════════════════════════════════════════╝') + ) + console.log() +} + +// 创建分割线 +function createDivider(char = '─', color = theme.primary) { + console.log(`${color}${' ' + char.repeat(66)}${theme.reset}`) +} + +// 创建卡片样式容器 +function createCard(title: string, content: string[]) { + console.log(` ${fmt.badge('', theme.bgBlue)} ${fmt.title(title)}`) + console.log() + content.forEach((line) => { + console.log(` ${line}`) + }) + console.log() +} + +// 进度条动画 +function createProgressBar(current: number, total: number, text: string, width = 40) { + const percentage = Math.round((current / total) * 100) + const filled = Math.round((current / total) * width) + const empty = width - filled + + const filledBar = '█'.repeat(filled) + const emptyBar = '░'.repeat(empty) + + process.stdout.write( + `\r ${fmt.info('进度')} [${theme.success}${filledBar}${theme.gray}${emptyBar}${theme.reset}] ${fmt.highlight(percentage + '%')})}` + ) + + if (current === total) { + console.log() + } +} + +// 统计信息 +const stats = { + deletedFiles: 0, + deletedPaths: 0, + failedPaths: 0, + startTime: Date.now(), + totalFiles: 0 +} + +// 清理目标 +const targets = [ + 'README.md', + 'README.zh-CN.md', + 'CHANGELOG.md', + 'CHANGELOG.zh-CN.md', + 'src/views/change', + 'src/views/safeguard', + 'src/views/article', + 'src/views/examples', + 'src/views/system/nested', + 'src/views/widgets', + 'src/views/template', + 'src/views/dashboard/analysis', + 'src/views/dashboard/ecommerce', + 'src/mock/json', + 'src/mock/temp/articleList.ts', + 'src/mock/temp/commentDetail.ts', + 'src/mock/temp/commentList.ts', + 'src/assets/images/cover', + 'src/assets/images/safeguard', + 'src/assets/images/3d', + 'src/components/core/charts/art-map-chart', + 'src/components/business/comment-widget' +] + +// 递归统计文件数量 +async function countFiles(targetPath: string): Promise { + const fullPath = path.resolve(process.cwd(), targetPath) + + try { + const stat = await fs.stat(fullPath) + + if (stat.isFile()) { + return 1 + } else if (stat.isDirectory()) { + const entries = await fs.readdir(fullPath) + let count = 0 + + for (const entry of entries) { + const entryPath = path.join(targetPath, entry) + count += await countFiles(entryPath) + } + + return count + } + } catch { + return 0 + } + + return 0 +} + +// 统计所有目标的文件数量 +async function countAllFiles(): Promise { + let totalCount = 0 + + for (const target of targets) { + const count = await countFiles(target) + totalCount += count + } + + return totalCount +} + +// 删除文件和目录 +async function remove(targetPath: string, index: number) { + const fullPath = path.resolve(process.cwd(), targetPath) + + createProgressBar(index + 1, targets.length, targetPath) + + try { + const fileCount = await countFiles(targetPath) + await fs.rm(fullPath, { recursive: true, force: true }) + stats.deletedFiles += fileCount + stats.deletedPaths++ + await new Promise((resolve) => setTimeout(resolve, 50)) + } catch (err) { + stats.failedPaths++ + console.log() + console.log(` ${icons.error} ${fmt.error('删除失败')}: ${fmt.highlight(targetPath)}`) + console.log(` ${fmt.dim('错误详情: ' + err)}`) + } +} + +// 清理路由模块 +async function cleanRouteModules() { + const modulesPath = path.resolve(process.cwd(), 'src/router/modules') + + try { + // 删除演示相关的路由模块 + const modulesToRemove = [ + 'template.ts', + 'widgets.ts', + 'examples.ts', + 'article.ts', + 'safeguard.ts', + 'help.ts' + ] + + for (const module of modulesToRemove) { + const modulePath = path.join(modulesPath, module) + try { + await fs.rm(modulePath, { force: true }) + } catch { + // 文件不存在时忽略错误 + } + } + + // 重写 dashboard.ts - 只保留 console + const dashboardContent = `import { AppRouteRecord } from '@/types/router' + +export const dashboardRoutes: AppRouteRecord = { + name: 'Dashboard', + path: '/dashboard', + component: '/index/index', + meta: { + title: 'menus.dashboard.title', + icon: 'ri:pie-chart-line', + roles: ['R_SUPER', 'R_ADMIN'] + }, + children: [ + { + path: 'console', + name: 'Console', + component: '/dashboard/console', + meta: { + title: 'menus.dashboard.console', + keepAlive: false, + fixedTab: true + } + } + ] +} +` + await fs.writeFile(path.join(modulesPath, 'dashboard.ts'), dashboardContent, 'utf-8') + + // 重写 system.ts - 移除 nested 嵌套菜单 + const systemContent = `import { AppRouteRecord } from '@/types/router' + +export const systemRoutes: AppRouteRecord = { + path: '/system', + name: 'System', + component: '/index/index', + meta: { + title: 'menus.system.title', + icon: 'ri:user-3-line', + roles: ['R_SUPER', 'R_ADMIN'] + }, + children: [ + { + path: 'user', + name: 'User', + component: '/system/user', + meta: { + title: 'menus.system.user', + keepAlive: true, + roles: ['R_SUPER', 'R_ADMIN'] + } + }, + { + path: 'role', + name: 'Role', + component: '/system/role', + meta: { + title: 'menus.system.role', + keepAlive: true, + roles: ['R_SUPER'] + } + }, + { + path: 'user-center', + name: 'UserCenter', + component: '/system/user-center', + meta: { + title: 'menus.system.userCenter', + isHide: true, + keepAlive: true, + isHideTab: true + } + }, + { + path: 'menu', + name: 'Menus', + component: '/system/menu', + meta: { + title: 'menus.system.menu', + keepAlive: true, + roles: ['R_SUPER'], + authList: [ + { title: '新增', authMark: 'add' }, + { title: '编辑', authMark: 'edit' }, + { title: '删除', authMark: 'delete' } + ] + } + } + ] +} +` + await fs.writeFile(path.join(modulesPath, 'system.ts'), systemContent, 'utf-8') + + // 重写 index.ts - 只导入保留的模块 + const indexContent = `import { AppRouteRecord } from '@/types/router' +import { dashboardRoutes } from './dashboard' +import { systemRoutes } from './system' +import { resultRoutes } from './result' +import { exceptionRoutes } from './exception' + +/** + * 导出所有模块化路由 + */ +export const routeModules: AppRouteRecord[] = [ + dashboardRoutes, + systemRoutes, + resultRoutes, + exceptionRoutes +] +` + await fs.writeFile(path.join(modulesPath, 'index.ts'), indexContent, 'utf-8') + + console.log(` ${icons.success} ${fmt.success('清理路由模块完成')}`) + } catch (err) { + console.log(` ${icons.error} ${fmt.error('清理路由模块失败')}`) + console.log(` ${fmt.dim('错误详情: ' + err)}`) + } +} + +// 清理路由别名 +async function cleanRoutesAlias() { + const routesAliasPath = path.resolve(process.cwd(), 'src/router/routesAlias.ts') + + try { + const cleanedAlias = `/** + * 公共路由别名 + # 存放系统级公共路由路径,如布局容器、登录页等 + */ +export enum RoutesAlias { + Layout = '/index/index', // 布局容器 + Login = '/auth/login' // 登录页 +} +` + + await fs.writeFile(routesAliasPath, cleanedAlias, 'utf-8') + console.log(` ${icons.success} ${fmt.success('重写路由别名配置完成')}`) + } catch (err) { + console.log(` ${icons.error} ${fmt.error('清理路由别名失败')}`) + console.log(` ${fmt.dim('错误详情: ' + err)}`) + } +} + +// 清理变更日志 +async function cleanChangeLog() { + const changeLogPath = path.resolve(process.cwd(), 'src/mock/upgrade/changeLog.ts') + + try { + const cleanedChangeLog = `import { ref } from 'vue' + +interface UpgradeLog { + version: string // 版本号 + title: string // 更新标题 + date: string // 更新日期 + detail?: string[] // 更新内容 + requireReLogin?: boolean // 是否需要重新登录 + remark?: string // 备注 +} + +export const upgradeLogList = ref([]) +` + + await fs.writeFile(changeLogPath, cleanedChangeLog, 'utf-8') + console.log(` ${icons.success} ${fmt.success('清空变更日志数据完成')}`) + } catch (err) { + console.log(` ${icons.error} ${fmt.error('清理变更日志失败')}`) + console.log(` ${fmt.dim('错误详情: ' + err)}`) + } +} + +// 清理语言文件 +async function cleanLanguageFiles() { + const languageFiles = [ + { path: 'src/locales/langs/zh.json', name: '中文语言文件' }, + { path: 'src/locales/langs/en.json', name: '英文语言文件' } + ] + + for (const { path: langPath, name } of languageFiles) { + try { + const fullPath = path.resolve(process.cwd(), langPath) + const content = await fs.readFile(fullPath, 'utf-8') + const langData = JSON.parse(content) + + const menusToRemove = [ + 'widgets', + 'template', + 'article', + 'examples', + 'safeguard', + 'plan', + 'help' + ] + + if (langData.menus) { + menusToRemove.forEach((menuKey) => { + if (langData.menus[menuKey]) { + delete langData.menus[menuKey] + } + }) + + if (langData.menus.dashboard) { + if (langData.menus.dashboard.analysis) { + delete langData.menus.dashboard.analysis + } + if (langData.menus.dashboard.ecommerce) { + delete langData.menus.dashboard.ecommerce + } + } + + if (langData.menus.system) { + const systemKeysToRemove = [ + 'nested', + 'menu1', + 'menu2', + 'menu21', + 'menu3', + 'menu31', + 'menu32', + 'menu321' + ] + systemKeysToRemove.forEach((key) => { + if (langData.menus.system[key]) { + delete langData.menus.system[key] + } + }) + } + } + + await fs.writeFile(fullPath, JSON.stringify(langData, null, 2), 'utf-8') + console.log(` ${icons.success} ${fmt.success(`清理${name}完成`)}`) + } catch (err) { + console.log(` ${icons.error} ${fmt.error(`清理${name}失败`)}`) + console.log(` ${fmt.dim('错误详情: ' + err)}`) + } + } +} + +// 清理快速入口组件 +async function cleanFastEnterComponent() { + const fastEnterPath = path.resolve(process.cwd(), 'src/config/fastEnter.ts') + + try { + const cleanedFastEnter = `/** + * 快速入口配置 + * 包含:应用列表、快速链接等配置 + */ +import { WEB_LINKS } from '@/utils/constants' +import type { FastEnterConfig } from '@/types/config' + +const fastEnterConfig: FastEnterConfig = { + // 显示条件(屏幕宽度) + minWidth: 1200, + // 应用列表 + applications: [ + { + name: '工作台', + description: '系统概览与数据统计', + icon: 'ri:pie-chart-line', + iconColor: '#377dff', + enabled: true, + order: 1, + routeName: 'Console' + }, + { + name: '官方文档', + description: '使用指南与开发文档', + icon: 'ri:bill-line', + iconColor: '#ffb100', + enabled: true, + order: 2, + link: WEB_LINKS.DOCS + }, + { + name: '技术支持', + description: '技术支持与问题反馈', + icon: 'ri:user-location-line', + iconColor: '#ff6b6b', + enabled: true, + order: 3, + link: WEB_LINKS.COMMUNITY + }, + { + name: '哔哩哔哩', + description: '技术分享与交流', + icon: 'ri:bilibili-line', + iconColor: '#FB7299', + enabled: true, + order: 4, + link: WEB_LINKS.BILIBILI + } + ], + // 快速链接 + quickLinks: [ + { + name: '登录', + enabled: true, + order: 1, + routeName: 'Login' + }, + { + name: '注册', + enabled: true, + order: 2, + routeName: 'Register' + }, + { + name: '忘记密码', + enabled: true, + order: 3, + routeName: 'ForgetPassword' + }, + { + name: '个人中心', + enabled: true, + order: 4, + routeName: 'UserCenter' + } + ] +} + +export default Object.freeze(fastEnterConfig) +` + + await fs.writeFile(fastEnterPath, cleanedFastEnter, 'utf-8') + console.log(` ${icons.success} ${fmt.success('清理快速入口配置完成')}`) + } catch (err) { + console.log(` ${icons.error} ${fmt.error('清理快速入口配置失败')}`) + console.log(` ${fmt.dim('错误详情: ' + err)}`) + } +} + +// 更新菜单接口 +async function updateMenuApi() { + const apiPath = path.resolve(process.cwd(), 'src/api/system-manage.ts') + + try { + const content = await fs.readFile(apiPath, 'utf-8') + const updatedContent = content.replace( + "url: '/api/v3/system/menus'", + "url: '/api/v3/system/menus/simple'" + ) + + await fs.writeFile(apiPath, updatedContent, 'utf-8') + console.log(` ${icons.success} ${fmt.success('更新菜单接口完成')}`) + } catch (err) { + console.log(` ${icons.error} ${fmt.error('更新菜单接口失败')}`) + console.log(` ${fmt.dim('错误详情: ' + err)}`) + } +} + +// 用户确认函数 +async function getUserConfirmation(): Promise { + const { createInterface } = await import('readline') + + return new Promise((resolve) => { + const rl = createInterface({ + input: process.stdin, + output: process.stdout + }) + + console.log( + ` ${fmt.highlight('请输入')} ${fmt.success('yes')} ${fmt.highlight('确认执行清理操作,或按 Enter 取消')}` + ) + console.log() + process.stdout.write(` ${icons.arrow} `) + + rl.question('', (answer: string) => { + rl.close() + resolve(answer.toLowerCase().trim() === 'yes') + }) + }) +} + +// 显示清理警告 +async function showCleanupWarning() { + createCard('安全警告', [ + `${fmt.warning('此操作将永久删除以下演示内容,且无法恢复!')}`, + `${fmt.dim('请仔细阅读清理列表,确认后再继续操作')}` + ]) + + const cleanupItems = [ + { + icon: icons.image, + name: '图片资源', + desc: '演示用的封面图片、3D图片、运维图片等', + color: theme.orange + }, + { + icon: icons.file, + name: '演示页面', + desc: 'widgets、template、article、examples、safeguard等页面', + color: theme.purple + }, + { + icon: icons.code, + name: '路由模块文件', + desc: '删除演示路由模块,只保留核心模块(dashboard、system、result、exception)', + color: theme.primary + }, + { + icon: icons.link, + name: '路由别名', + desc: '重写routesAlias.ts,移除演示路由别名', + color: theme.info + }, + { + icon: icons.data, + name: 'Mock数据', + desc: '演示用的JSON数据、文章列表、评论数据等', + color: theme.success + }, + { + icon: icons.globe, + name: '多语言文件', + desc: '清理中英文语言包中的演示菜单项', + color: theme.warning + }, + { icon: icons.map, name: '地图组件', desc: '移除art-map-chart地图组件', color: theme.error }, + { icon: icons.chat, name: '评论组件', desc: '移除comment-widget评论组件', color: theme.orange }, + { + icon: icons.bolt, + name: '快速入口', + desc: '移除分析页、礼花效果、聊天、更新日志、定价、留言管理等无效项目', + color: theme.purple + } + ] + + console.log(` ${fmt.badge('', theme.bgRed)} ${fmt.title('将要清理的内容')}`) + console.log() + + cleanupItems.forEach((item, index) => { + console.log(` ${item.color}${theme.reset} ${fmt.highlight(`${index + 1}. ${item.name}`)}`) + console.log(` ${fmt.dim(item.desc)}`) + }) + + console.log() + console.log(` ${fmt.badge('', theme.bgGreen)} ${fmt.title('保留的功能模块')}`) + console.log() + + const preservedModules = [ + { name: 'Dashboard', desc: '工作台页面' }, + { name: 'System', desc: '系统管理模块' }, + { name: 'Result', desc: '结果页面' }, + { name: 'Exception', desc: '异常页面' }, + { name: 'Auth', desc: '登录注册功能' }, + { name: 'Core Components', desc: '核心组件库' } + ] + + preservedModules.forEach((module) => { + console.log(` ${icons.check} ${fmt.success(module.name)} ${fmt.dim(`- ${module.desc}`)}`) + }) + + console.log() + createDivider() + console.log() +} + +// 显示统计信息 +async function showStats() { + const duration = Date.now() - stats.startTime + const seconds = (duration / 1000).toFixed(2) + + console.log() + createCard('清理统计', [ + `${fmt.success('成功删除')}: ${fmt.highlight(stats.deletedFiles.toString())} 个文件`, + `${fmt.info('涉及路径')}: ${fmt.highlight(stats.deletedPaths.toString())} 个目录/文件`, + ...(stats.failedPaths > 0 + ? [ + `${icons.error} ${fmt.error('删除失败')}: ${fmt.highlight(stats.failedPaths.toString())} 个路径` + ] + : []), + `${fmt.info('耗时')}: ${fmt.highlight(seconds)} 秒` + ]) +} + +// 创建成功横幅 +function createSuccessBanner() { + console.log() + console.log( + fmt.gradient(' ╔══════════════════════════════════════════════════════════════════╗') + ) + console.log( + fmt.gradient(' ║ ║') + ) + console.log( + ` ║ ${icons.star} ${fmt.success('清理完成!项目已准备就绪')} ${icons.rocket} ║` + ) + console.log( + ` ║ ${fmt.dim('现在可以开始您的开发之旅了!')} ║` + ) + console.log( + fmt.gradient(' ║ ║') + ) + console.log( + fmt.gradient(' ╚══════════════════════════════════════════════════════════════════╝') + ) + console.log() +} + +// 主函数 +async function main() { + // 清屏并显示横幅 + console.clear() + createModernBanner() + + // 显示清理警告 + await showCleanupWarning() + + // 统计文件数量 + console.log(` ${fmt.info('正在统计文件数量...')}`) + stats.totalFiles = await countAllFiles() + + console.log(` ${fmt.info('即将清理')}: ${fmt.highlight(stats.totalFiles.toString())} 个文件`) + console.log(` ${fmt.dim(`涉及 ${targets.length} 个目录/文件路径`)}`) + console.log() + + // 用户确认 + const confirmed = await getUserConfirmation() + + if (!confirmed) { + console.log(` ${fmt.warning('操作已取消,清理中止')}`) + console.log() + return + } + + console.log() + console.log(` ${icons.check} ${fmt.success('确认成功,开始清理...')}`) + console.log() + + // 开始清理过程 + console.log(` ${fmt.badge('步骤 1/6', theme.bgBlue)} ${fmt.title('删除演示文件')}`) + console.log() + for (let i = 0; i < targets.length; i++) { + await remove(targets[i], i) + } + console.log() + + console.log(` ${fmt.badge('步骤 2/6', theme.bgBlue)} ${fmt.title('清理路由模块')}`) + console.log() + await cleanRouteModules() + console.log() + + console.log(` ${fmt.badge('步骤 3/6', theme.bgBlue)} ${fmt.title('重写路由别名')}`) + console.log() + await cleanRoutesAlias() + console.log() + + console.log(` ${fmt.badge('步骤 4/6', theme.bgBlue)} ${fmt.title('清空变更日志')}`) + console.log() + await cleanChangeLog() + console.log() + + console.log(` ${fmt.badge('步骤 5/6', theme.bgBlue)} ${fmt.title('清理语言文件')}`) + console.log() + await cleanLanguageFiles() + console.log() + + console.log(` ${fmt.badge('步骤 6/7', theme.bgBlue)} ${fmt.title('清理快速入口')}`) + console.log() + await cleanFastEnterComponent() + console.log() + + console.log(` ${fmt.badge('步骤 7/7', theme.bgBlue)} ${fmt.title('更新菜单接口')}`) + console.log() + await updateMenuApi() + + // 显示统计信息 + await showStats() + + // 显示成功横幅 + createSuccessBanner() +} + +main().catch((err) => { + console.log() + console.log(` ${icons.error} ${fmt.error('清理脚本执行出错')}`) + console.log(` ${fmt.dim('错误详情: ' + err)}`) + console.log() + process.exit(1) +}) diff --git a/web/apps/admin/src/App.vue b/web/apps/admin/src/App.vue new file mode 100755 index 0000000..d941a48 --- /dev/null +++ b/web/apps/admin/src/App.vue @@ -0,0 +1,41 @@ + + + diff --git a/web/apps/admin/src/api/apiKeys.ts b/web/apps/admin/src/api/apiKeys.ts new file mode 100644 index 0000000..4ec7523 --- /dev/null +++ b/web/apps/admin/src/api/apiKeys.ts @@ -0,0 +1,45 @@ +import request from '@/utils/http' + +export interface APIKeyRecord { + id: string + name: string + key_prefix: string + scopes: string[] + enabled: boolean + requests_per_minute: number + monthly_request_quota: number + monthly_token_quota: number + monthly_token_usage: number + expires_at: string | null + last_used_at: string | null + created_at: string + key?: string + warning?: string +} + +export function fetchAPIKeys() { + return request.get({ url: '/api/v1/admin/api-keys' }) +} + +export function createAPIKey(data: { + name: string + scopes: string[] + requests_per_minute: number + monthly_request_quota: number + monthly_token_quota: number + expires_at: string | null +}) { + return request.post({ url: '/api/v1/admin/api-keys', params: data }) +} + +export function revokeAPIKey(id: string) { + return request.del<{ revoked: boolean }>({ url: `/api/v1/admin/api-keys/${id}` }) +} + +export function updateAPIKeyLimits(id: string, data: { + requests_per_minute: number + monthly_request_quota: number + monthly_token_quota: number +}) { + return request.put({ url: `/api/v1/admin/api-keys/${id}/limits`, params: data }) +} diff --git a/web/apps/admin/src/api/audit.ts b/web/apps/admin/src/api/audit.ts new file mode 100644 index 0000000..1593471 --- /dev/null +++ b/web/apps/admin/src/api/audit.ts @@ -0,0 +1,45 @@ +import request from '@/utils/http' + +export interface AuditEvent { + id: string + tenant_id: string | null + request_id: string + api_key_id: string | null + api_key_name: string | null + provider_code: string | null + model: string | null + protocol: string + status_code: number | null + prompt_tokens: number | null + completion_tokens: number | null + cost_microunits: number | null + latency_ms: number | null + labels: Record + recorded_at: string +} + +export interface AuditPage { + items: AuditEvent[] + next_before: string +} + +export interface DailyUsage { + date: string + api_key_id: string + api_key_name: string + provider_code: string + model: string + requests: number + failed_requests: number + prompt_tokens: number + completion_tokens: number + cost_microunits: number +} + +export function fetchAuditEvents(params: Record) { + return request.get({ url: '/api/v1/admin/audit-events', params }) +} + +export function fetchDailyUsage(params: Record) { + return request.get({ url: '/api/v1/admin/usage/daily', params }) +} diff --git a/web/apps/admin/src/api/auth.ts b/web/apps/admin/src/api/auth.ts new file mode 100644 index 0000000..04f1af7 --- /dev/null +++ b/web/apps/admin/src/api/auth.ts @@ -0,0 +1,36 @@ +import request from '@/utils/http' + +/** + * 登录 + * @param params 登录参数 + * @returns 登录响应 + */ +export function fetchLogin(params: Api.Auth.LoginParams) { + return request.post({ + url: '/api/v1/admin/login', + params + // showSuccessMessage: true // 显示成功消息 + // showErrorMessage: false // 不显示错误消息 + }) +} + +export function fetchTOTPLogin(params: Api.Auth.TOTPLoginParams) { + return request.post({ + url: '/api/v1/admin/login/totp', + params + }) +} + +/** + * 获取用户信息 + * @returns 用户信息 + */ +export function fetchGetUserInfo() { + return request.get({ + url: '/api/v1/admin/whoami' + // 自定义请求头 + // headers: { + // 'X-Custom-Header': 'your-custom-value' + // } + }) +} diff --git a/web/apps/admin/src/api/content-policy.ts b/web/apps/admin/src/api/content-policy.ts new file mode 100644 index 0000000..b8562e9 --- /dev/null +++ b/web/apps/admin/src/api/content-policy.ts @@ -0,0 +1,12 @@ +import request from '@/utils/http' + +export interface ContentRule { name: string; pattern: string; replacement: string } +export interface ContentPolicy { + id: string; name: string; description: string; action: 'audit' | 'block' | 'redact'; priority: number + paths: string[]; models: string[]; api_key_ids: string[]; rules: ContentRule[]; enabled: boolean; revision: number +} +export type ContentPolicyInput = Omit +export const fetchContentPolicies = () => request.get({ url: '/api/v1/admin/content-policies' }) +export const createContentPolicy = (data: ContentPolicyInput) => request.post({ url: '/api/v1/admin/content-policies', params: data }) +export const updateContentPolicy = (id: string, data: ContentPolicyInput) => request.put({ url: `/api/v1/admin/content-policies/${id}`, params: data }) +export const deleteContentPolicy = (id: string) => request.del({ url: `/api/v1/admin/content-policies/${id}` }) diff --git a/web/apps/admin/src/api/governance.ts b/web/apps/admin/src/api/governance.ts new file mode 100644 index 0000000..d8e8315 --- /dev/null +++ b/web/apps/admin/src/api/governance.ts @@ -0,0 +1,15 @@ +import request from '@/utils/http' +export interface ModelRequest{id:string;user_login:string;provider_code:string;model:string;reason:string;requested_rpm:number;requested_monthly_tokens:number;status:string;decision_note:string;created_at:string} +export const fetchModelRequests=(status='')=>request.get({url:'/api/v1/admin/model-requests',params:{status}}) +export const decideModelRequest=(id:string,status:'approve'|'reject',note:string)=>request.post({url:`/api/v1/admin/model-requests/${id}/${status}`,params:{note}}) +export interface FactSettings{provider_id?:string;provider_code:string;model:string;timeout_seconds:number;updated_at?:string} +export interface FactPolicy{id?:string;scope:string;enabled:boolean;mode:'async'|'sync';action:'observe'|'annotate'|'block';knowledge_base_ids:string[];support_threshold:number;evidence_threshold:number;top_k:number;max_claims:number;revision?:number} +export interface FactEvent{id:string;request_id:string;model:string;mode:string;action:string;verdict:string;support_score?:number;latency_ms:number;error:string;created_at:string} +export const fetchFactSettings=()=>request.get({url:'/api/v1/admin/fact-check/settings'}) +export const saveFactSettings=(params:FactSettings)=>request.put({url:'/api/v1/admin/fact-check/settings',params}) +export const fetchFactPolicies=()=>request.get<{items:FactPolicy[]}>({url:'/api/v1/admin/fact-check/policies'}) +export const createFactPolicy=(params:FactPolicy)=>request.post({url:'/api/v1/admin/fact-check/policies',params}) +export const updateFactPolicy=(id:string,params:FactPolicy)=>request.put({url:`/api/v1/admin/fact-check/policies/${id}`,params}) +export const deleteFactPolicy=(id:string)=>request.del({url:`/api/v1/admin/fact-check/policies/${id}`}) +export const fetchFactEvents=()=>request.get<{items:FactEvent[]}>({url:'/api/v1/admin/fact-check/events'}) + diff --git a/web/apps/admin/src/api/identities.ts b/web/apps/admin/src/api/identities.ts new file mode 100644 index 0000000..57c9cb1 --- /dev/null +++ b/web/apps/admin/src/api/identities.ts @@ -0,0 +1,162 @@ +import request from '@/utils/http' + +export type IdentityKind = 'admin' | 'portal' + +export interface IdentityRecord { + id: string + kind: IdentityKind + login: string + display_name: string + role: string + permissions: string[] + effective_permissions: string[] + active: boolean + auth_source: string + totp_enabled: boolean + locked_until?: string + created_at: string + updated_at: string + department_id?: string + department_name?: string +} + +export interface IdentityInput { + login: string + display_name: string + role: string + password?: string + permissions: string[] + active: boolean + department_id?: string +} + +export interface DepartmentRecord { + id: string + code: string + name: string + description: string + parent_id?: string + parent_name?: string + active: boolean + user_count: number + created_at: string + updated_at: string +} + +export interface DepartmentInput { + code: string + name: string + description: string + parent_id?: string + active: boolean +} + +export interface IdentityProviderRecord { + id: string + code: string + display_name: string + issuer_url: string + client_id: string + secret_configured: boolean + redirect_uri: string + portal_return_url: string + scopes: string[] + auto_provision: boolean + default_department_id?: string + enabled: boolean + revision: number + credential_kek_version: number +} + +export interface IdentityProviderInput { + code: string + display_name: string + issuer_url: string + client_id: string + client_secret?: string + redirect_uri: string + portal_return_url: string + scopes: string[] + auto_provision: boolean + default_department_id?: string + enabled: boolean +} + +export interface SAMLProviderRecord { + id: string + kind: 'saml' + code: string + display_name: string + metadata_url: string + sp_entity_id: string + acs_url: string + portal_return_url: string + email_attribute: string + name_attribute: string + auto_provision: boolean + default_department_id?: string + enabled: boolean + revision: number +} + +export type SAMLProviderInput = Omit + +function resource(kind: IdentityKind) { + return kind === 'admin' ? 'admins' : 'portal-users' +} + +export function fetchIdentities(kind: IdentityKind) { + return request.get({ + url: `/api/v1/admin/identities/${resource(kind)}` + }) +} + +export function createIdentity(kind: IdentityKind, data: IdentityInput) { + return request.post({ + url: `/api/v1/admin/identities/${resource(kind)}`, + params: data + }) +} + +export function updateIdentity(kind: IdentityKind, id: string, data: IdentityInput) { + return request.put({ + url: `/api/v1/admin/identities/${resource(kind)}/${id}`, + params: data + }) +} + +export function fetchDepartments() { + return request.get({ url: '/api/v1/admin/departments' }) +} + +export function createDepartment(data: DepartmentInput) { + return request.post({ url: '/api/v1/admin/departments', params: data }) +} + +export function updateDepartment(id: string, data: DepartmentInput) { + return request.put({ url: `/api/v1/admin/departments/${id}`, params: data }) +} + +export function fetchIdentityProviders() { + return request.get({ url: '/api/v1/admin/identity-providers' }) +} + +export function createIdentityProvider(data: IdentityProviderInput) { + return request.post({ url: '/api/v1/admin/identity-providers', params: data }) +} + +export function updateIdentityProvider(id: string, data: IdentityProviderInput) { + return request.put({ url: `/api/v1/admin/identity-providers/${id}`, params: data }) +} + +export function fetchSAMLProviders() { + return request.get({ url: '/api/v1/admin/saml-providers' }) +} + +export function createSAMLProvider(data: SAMLProviderInput) { + return request.post({ url: '/api/v1/admin/saml-providers', params: data }) +} + +export function updateSAMLProvider(id: string, data: SAMLProviderInput) { + return request.put({ url: `/api/v1/admin/saml-providers/${id}`, params: data }) +} diff --git a/web/apps/admin/src/api/marketplace.ts b/web/apps/admin/src/api/marketplace.ts new file mode 100644 index 0000000..4175997 --- /dev/null +++ b/web/apps/admin/src/api/marketplace.ts @@ -0,0 +1,71 @@ +import request from '@/utils/http' +import type { PromptVariable } from './workbench' + +export interface MarketplaceCategory { id: string; name: string; description: string; resource_type: string; sort_order: number; created_at: string } +export const fetchMarketplaceCategories = (params?: { type?: string }) => request.get({ url: '/api/v1/admin/marketplace-categories', params }) +export const createMarketplaceCategory = (params: { name: string; description: string; resource_type?: string; sort_order?: number }) => request.post({ url: '/api/v1/admin/marketplace-categories', params }) +export const deleteMarketplaceCategory = (id: string) => request.del({ url: `/api/v1/admin/marketplace-categories/${id}` }) + +export interface MCPTool { name: string; description: string; inputSchema: Record } + +export interface MCPServer { + id: string; code: string; name: string; description: string; transport: string; endpoint_url: string + status: 'draft' | 'published' | 'archived'; category_id?: string; category_name: string + tags: string[]; department_ids: string[]; enabled: boolean; has_secret_headers: boolean; revision: number +} +export interface MCPServerInput { + code: string; name: string; description: string; transport: string; endpoint_url: string + headers?: Record; status: string; category_id?: string | null + tags: string[]; department_ids: string[]; enabled: boolean +} +export const fetchMCPServers = () => request.get({ url: '/api/v1/admin/mcp-servers' }) +export const createMCPServer = (params: MCPServerInput) => request.post({ url: '/api/v1/admin/mcp-servers', params }) +export const updateMCPServer = (id: string, params: MCPServerInput) => request.put({ url: `/api/v1/admin/mcp-servers/${id}`, params }) +export const deleteMCPServer = (id: string) => request.del({ url: `/api/v1/admin/mcp-servers/${id}` }) +export const testMCPServer = (id: string) => request.post({ url: `/api/v1/admin/mcp-servers/${id}/test` }) + +export interface Skill { + id: string; code: string; name: string; description: string; content: string; variables: PromptVariable[] + tool_ids: string[]; mcp_server_ids: string[]; knowledge_base_ids: string[] + status: string; category_id?: string; category_name: string; tags: string[]; department_ids: string[]; enabled: boolean; revision: number +} +export interface SkillInput { + code: string; name: string; description: string; content: string; variables: PromptVariable[] + tool_ids: string[]; mcp_server_ids: string[]; knowledge_base_ids: string[] + status: string; category_id?: string | null; tags: string[]; department_ids: string[]; enabled: boolean +} +export const fetchSkills = () => request.get({ url: '/api/v1/admin/skills' }) +export const createSkill = (params: SkillInput) => request.post({ url: '/api/v1/admin/skills', params }) +export const updateSkill = (id: string, params: SkillInput) => request.put({ url: `/api/v1/admin/skills/${id}`, params }) +export const deleteSkill = (id: string) => request.del({ url: `/api/v1/admin/skills/${id}` }) +export const renderSkill = (id: string, variables: Record) => request.post<{ code: string; name: string; rendered: string }>({ url: `/api/v1/admin/skills/${id}/render`, params: { variables } }) + +export interface DigitalEmployee { + id: string; code: string; name: string; description: string; persona: string; model: string + skill_ids: string[]; tool_ids: string[]; mcp_server_ids: string[]; knowledge_base_ids: string[] + temperature: number; retrieval_top_k: number; max_tool_rounds: number + status: string; category_id?: string; category_name: string; tags: string[]; department_ids: string[]; enabled: boolean; revision: number +} +export interface DigitalEmployeeInput { + code: string; name: string; description: string; persona: string; model: string + skill_ids: string[]; tool_ids: string[]; mcp_server_ids: string[]; knowledge_base_ids: string[] + temperature: number; retrieval_top_k: number; max_tool_rounds: number + status: string; category_id?: string | null; tags: string[]; department_ids: string[]; enabled: boolean +} +export interface DigitalEmployeeRun { + id: string; digital_employee: string; request_id: string; status: string; error: string + latency_ms: number; retrieval_count: number; tool_count: number; created_at: string +} +export const fetchDigitalEmployees = () => request.get({ url: '/api/v1/admin/digital-employees' }) +export const createDigitalEmployee = (params: DigitalEmployeeInput) => request.post({ url: '/api/v1/admin/digital-employees', params }) +export const updateDigitalEmployee = (id: string, params: DigitalEmployeeInput) => request.put({ url: `/api/v1/admin/digital-employees/${id}`, params }) +export const deleteDigitalEmployee = (id: string) => request.del({ url: `/api/v1/admin/digital-employees/${id}` }) +export const publishDigitalEmployee = (id: string) => request.post({ url: `/api/v1/admin/digital-employees/${id}/publish` }) +export const archiveDigitalEmployee = (id: string) => request.post({ url: `/api/v1/admin/digital-employees/${id}/archive` }) +export const fetchDigitalEmployeeRuns = (id: string) => request.get({ url: `/api/v1/admin/digital-employees/${id}/runs` }) + +export interface MarketItem { + type: string; code: string; name: string; description: string; category_id?: string; category_name: string + tags: string[]; department_ids: string[]; updated_at: string +} +export const fetchMarketplaceCatalog = (params?: { type?: string; category_id?: string; tag?: string; q?: string; limit?: number }) => request.get({ url: '/api/v1/admin/marketplace/catalog', params }) diff --git a/web/apps/admin/src/api/modelRoutes.ts b/web/apps/admin/src/api/modelRoutes.ts new file mode 100644 index 0000000..b1c5c6e --- /dev/null +++ b/web/apps/admin/src/api/modelRoutes.ts @@ -0,0 +1,40 @@ +import request from '@/utils/http' + +export interface ModelRouteConditions { + endpoints?: string[] + api_key_ids?: string[] + tenant_ids?: string[] +} + +export interface ModelRouteRecord { + id: string + name: string + source_model: string + target_model: string + provider_id: string + provider_code: string + weight: number + priority: number + conditions: ModelRouteConditions + enabled: boolean + created_at: string + updated_at: string +} + +export type ModelRouteInput = Omit + +export function fetchModelRoutes() { + return request.get({ url: '/api/v1/admin/model-routes' }) +} + +export function createModelRoute(data: ModelRouteInput) { + return request.post({ url: '/api/v1/admin/model-routes', params: data }) +} + +export function updateModelRoute(id: string, data: ModelRouteInput) { + return request.put({ url: `/api/v1/admin/model-routes/${id}`, params: data }) +} + +export function deleteModelRoute(id: string) { + return request.del<{ deleted: boolean }>({ url: `/api/v1/admin/model-routes/${id}` }) +} diff --git a/web/apps/admin/src/api/outbox.ts b/web/apps/admin/src/api/outbox.ts new file mode 100644 index 0000000..7967790 --- /dev/null +++ b/web/apps/admin/src/api/outbox.ts @@ -0,0 +1,29 @@ +import request from '@/utils/http' + +export interface OutboxEvent { + event_id: string + event_type: string + event_version: number + tenant_id: string | null + aggregate_type: string + aggregate_id: string + payload: Record + trace_context: Record + occurred_at: string + available_at: string + attempts: number + locked_at: string | null + locked_by: string | null + processed_at: string | null + dead_lettered_at: string | null + last_error: string | null + published_stream_id: string | null +} + +export function fetchOutboxEvents(params: { status?: string; type?: string; limit?: number }) { + return request.get({ url: '/api/v1/admin/outbox-events', params }) +} + +export function retryOutboxEvent(eventId: string) { + return request.post<{ retried: boolean }>({ url: `/api/v1/admin/outbox-events/${eventId}/retry` }) +} diff --git a/web/apps/admin/src/api/pricing.ts b/web/apps/admin/src/api/pricing.ts new file mode 100644 index 0000000..57c151b --- /dev/null +++ b/web/apps/admin/src/api/pricing.ts @@ -0,0 +1,12 @@ +import request from '@/utils/http' + +export interface ModelPrice { + id: string; provider_code: string; model_pattern: string; input_microunits_per_million: number + output_microunits_per_million: number; currency: string; effective_from: string; effective_to: string | null + enabled: boolean; revision: number +} +export type ModelPriceInput = Omit +export const fetchModelPrices = () => request.get({ url: '/api/v1/admin/model-prices' }) +export const createModelPrice = (data: ModelPriceInput) => request.post({ url: '/api/v1/admin/model-prices', params: data }) +export const updateModelPrice = (id: string, data: ModelPriceInput) => request.put({ url: `/api/v1/admin/model-prices/${id}`, params: data }) +export const deleteModelPrice = (id: string) => request.del({ url: `/api/v1/admin/model-prices/${id}` }) diff --git a/web/apps/admin/src/api/providers.ts b/web/apps/admin/src/api/providers.ts new file mode 100644 index 0000000..e8b717f --- /dev/null +++ b/web/apps/admin/src/api/providers.ts @@ -0,0 +1,91 @@ +import request from '@/utils/http' + +export interface ProviderRecord { + id: string + code: string + adapter: string + base_url: string + api_key_masked: string + key_configured: boolean + capabilities: string[] + config: Record + enabled: boolean + revision: number + credential_kek_version: number +} + +export interface ProviderInput { + code: string + adapter: string + base_url: string + api_key?: string + capabilities: string[] + config: Record + enabled: boolean +} + +export interface ProviderConnectionResult { + connected: boolean + status_code?: number + latency_ms: number + message: string +} + +export interface ProviderModel { + id: string + provider_id: string + provider_model_id: string + owned_by?: string + metadata: Record + enabled: boolean + discovered_at: string + last_seen_at: string +} + +export interface ModelSyncResult { + discovered: number + active: number + disabled: number + synced_at: string +} + +export interface CredentialRotationResult { + active_version: number + loaded_versions: number[] + rotated: number + skipped: number +} + +export function fetchProviders() { + return request.get({ url: '/api/v1/admin/providers' }) +} + +export function createProvider(data: ProviderInput) { + return request.post({ url: '/api/v1/admin/providers', params: data }) +} + +export function updateProvider(id: string, data: ProviderInput) { + return request.put({ url: `/api/v1/admin/providers/${id}`, params: data }) +} + +export function testProviderConnection(id: string) { + return request.post({ + url: `/api/v1/admin/providers/${id}/test` + }) +} + +export function fetchProviderModels(id: string) { + return request.get({ url: `/api/v1/admin/providers/${id}/models` }) +} + +export function syncProviderModels(id: string) { + return request.post({ + url: `/api/v1/admin/providers/${id}/models/sync` + }) +} + +export function rotateProviderCredentials() { + return request.post({ + url: '/api/v1/admin/providers/credentials/rotate' + }) +} diff --git a/web/apps/admin/src/api/system-manage.ts b/web/apps/admin/src/api/system-manage.ts new file mode 100644 index 0000000..dac8f97 --- /dev/null +++ b/web/apps/admin/src/api/system-manage.ts @@ -0,0 +1,25 @@ +import request from '@/utils/http' +import { AppRouteRecord } from '@/types/router' + +// 获取用户列表 +export function fetchGetUserList(params: Api.SystemManage.UserSearchParams) { + return request.get({ + url: '/api/user/list', + params + }) +} + +// 获取角色列表 +export function fetchGetRoleList(params: Api.SystemManage.RoleSearchParams) { + return request.get({ + url: '/api/role/list', + params + }) +} + +// 获取菜单列表 +export function fetchGetMenuList() { + return request.get({ + url: '/api/v1/admin/menus' + }) +} diff --git a/web/apps/admin/src/api/workbench.ts b/web/apps/admin/src/api/workbench.ts new file mode 100644 index 0000000..5f7be68 --- /dev/null +++ b/web/apps/admin/src/api/workbench.ts @@ -0,0 +1,59 @@ +import request from '@/utils/http' + +export interface PromptVariable { name: string; label?: string; default?: string; required: boolean } +export interface PromptVersion { id: string; template_id: string; version: number; content: string; variables: PromptVariable[]; change_note: string; created_at: string } +export interface PromptTemplate { id: string; name: string; description: string; category_id?: string; category_name: string; tags: string[]; department_ids: string[]; enabled: boolean; current_version?: number; current?: PromptVersion; revision: number } +export interface PromptInput { name: string; description: string; category_id?: string | null; tags: string[]; department_ids: string[]; enabled: boolean; content: string; variables: PromptVariable[]; change_note: string } +export interface PromptCategory { id: string; name: string; description: string; template_count: number } +export const fetchPromptCategories=()=>request.get({url:'/api/v1/admin/prompt-categories'}) +export const createPromptCategory=(params:{name:string;description:string})=>request.post({url:'/api/v1/admin/prompt-categories',params}) +export const deletePromptCategory=(id:string)=>request.del({url:`/api/v1/admin/prompt-categories/${id}`}) +export const fetchPrompts=()=>request.get({url:'/api/v1/admin/prompts'}) +export const createPrompt=(params:PromptInput)=>request.post({url:'/api/v1/admin/prompts',params}) +export const updatePrompt=(id:string,params:PromptInput)=>request.put({url:`/api/v1/admin/prompts/${id}`,params}) +export const deletePrompt=(id:string)=>request.del({url:`/api/v1/admin/prompts/${id}`}) +export const fetchPromptVersions=(id:string)=>request.get({url:`/api/v1/admin/prompts/${id}/versions`}) +export const createPromptVersion=(id:string,params:{content:string;variables:PromptVariable[];change_note:string;activate:boolean})=>request.post({url:`/api/v1/admin/prompts/${id}/versions`,params}) +export const activatePromptVersion=(id:string,version:number)=>request.post({url:`/api/v1/admin/prompts/${id}/versions/${version}/activate`}) + +export interface KnowledgeBase { id:string;name:string;description:string;retrieval_mode:string;chunk_size:number;chunk_overlap:number;department_ids:string[];enabled:boolean;revision:number;document_count:number;chunk_count:number } +export interface KnowledgeDocument {id:string;knowledge_base_id:string;title:string;source_type:string;source_uri:string;content_sha256:string;status:string;status_message:string;char_count:number;chunk_count:number;created_at:string} +export interface SearchHit {chunk_id:string;document_id:string;document_title:string;chunk_index:number;content:string;score:number} +export type KnowledgeBaseInput=Pick +export const fetchKnowledgeBases=()=>request.get({url:'/api/v1/admin/knowledge-bases'}) +export const createKnowledgeBase=(params:KnowledgeBaseInput)=>request.post({url:'/api/v1/admin/knowledge-bases',params}) +export const updateKnowledgeBase=(id:string,params:KnowledgeBaseInput)=>request.put({url:`/api/v1/admin/knowledge-bases/${id}`,params}) +export const deleteKnowledgeBase=(id:string)=>request.del({url:`/api/v1/admin/knowledge-bases/${id}`}) +export const fetchKnowledgeDocuments=(id:string)=>request.get({url:`/api/v1/admin/knowledge-bases/${id}/documents`}) +export const addKnowledgeDocument=(id:string,params:{title:string;source_type:string;source_uri:string;content:string})=>request.post({url:`/api/v1/admin/knowledge-bases/${id}/documents`,params}) +export const deleteKnowledgeDocument=(kb:string,id:string)=>request.del({url:`/api/v1/admin/knowledge-bases/${kb}/documents/${id}`}) +export const searchKnowledge=(id:string,params:{query:string;top_k:number})=>request.post({url:`/api/v1/admin/knowledge-bases/${id}/search`,params}) + +export interface ToolDefinition {id:string;code:string;name:string;description:string;endpoint_url:string;http_method:string;input_schema:Record;timeout_seconds:number;department_ids:string[];enabled:boolean;has_secret_headers:boolean;revision:number} +export interface ToolInput {code:string;name:string;description:string;endpoint_url:string;http_method:string;headers?:Record;input_schema:Record;timeout_seconds:number;department_ids:string[];enabled:boolean} +export const fetchTools=()=>request.get({url:'/api/v1/admin/tools'}) +export const createTool=(params:ToolInput)=>request.post({url:'/api/v1/admin/tools',params}) +export const updateTool=(id:string,params:ToolInput)=>request.put({url:`/api/v1/admin/tools/${id}`,params}) +export const deleteTool=(id:string)=>request.del({url:`/api/v1/admin/tools/${id}`}) +export const testTool=(id:string,input:Record)=>request.post<{status_code:number;body:unknown}>({url:`/api/v1/admin/tools/${id}/test`,params:{input}}) + +export interface ApplicationConfig {model:string;prompt_template_id?:string;knowledge_base_ids:string[];tool_ids:string[];retrieval_top_k:number;temperature:number;max_tool_rounds:number} +export interface Application {id:string;code:string;name:string;description:string;status:'draft'|'active'|'suspended'|'retired';department_ids:string[];draft_config:ApplicationConfig;published_version?:number;published_config?:ApplicationConfig;revision:number} +export type ApplicationInput=Pick +export const fetchApplications=()=>request.get({url:'/api/v1/admin/applications'}) +export const createApplication=(params:ApplicationInput)=>request.post({url:'/api/v1/admin/applications',params}) +export const updateApplication=(id:string,params:ApplicationInput)=>request.put({url:`/api/v1/admin/applications/${id}`,params}) +export const deleteApplication=(id:string)=>request.del({url:`/api/v1/admin/applications/${id}`}) +export const publishApplication=(id:string,change_note:string)=>request.post({url:`/api/v1/admin/applications/${id}/publish`,params:{change_note}}) +export const fetchApplicationVersions=(id:string)=>request.get[]>({url:`/api/v1/admin/applications/${id}/versions`}) +export const fetchApplicationRuns=(id:string)=>request.get[]>({url:`/api/v1/admin/applications/${id}/runs`}) + +export interface NotificationChannel {id:string;name:string;webhook_url:string;event_patterns:string[];enabled:boolean;has_signing_secret:boolean;revision:number} +export interface NotificationDelivery {id:string;channel_name:string;event_id:string;event_type:string;status:string;attempts:number;response_status?:number;last_error:string;created_at:string} +export interface NotificationInput {name:string;webhook_url:string;signing_secret?:string;event_patterns:string[];enabled:boolean} +export const fetchNotificationChannels=()=>request.get({url:'/api/v1/admin/notification-channels'}) +export const createNotificationChannel=(params:NotificationInput)=>request.post({url:'/api/v1/admin/notification-channels',params}) +export const updateNotificationChannel=(id:string,params:NotificationInput)=>request.put({url:`/api/v1/admin/notification-channels/${id}`,params}) +export const deleteNotificationChannel=(id:string)=>request.del({url:`/api/v1/admin/notification-channels/${id}`}) +export const fetchNotificationDeliveries=()=>request.get({url:'/api/v1/admin/notification-deliveries'}) +export const retryNotificationDelivery=(id:string)=>request.post({url:`/api/v1/admin/notification-deliveries/${id}/retry`}) diff --git a/web/apps/admin/src/assets/images/avatar/avatar.webp b/web/apps/admin/src/assets/images/avatar/avatar.webp new file mode 100644 index 0000000000000000000000000000000000000000..bea307b7d2e5e1a9d952d834eb57f73605dc55b8 GIT binary patch literal 954 zcmV;r14aB&Nk&Gp0{{S5MM6+kP&go_0{{Rp6abw8Do_AW06w)$ok}MpBB89-ZP?%z ziD>{53Yw2F8^s*TeR(t1;sau?)6x+IJ1ULup4dPqlKRaw%L00&PyRBV@TRirkLMoh znecI$mizfs;B~7~IjdMHs2{A`^5xH2WwlFsv{w~GUrD-c6mAR;p8@&L1l{|oRAzySXHiNX^g1uHD- zk>h_3$=>CvsNexHz6NhKDop!@&$-)@k^G zpxC;k(_I+TKnb=kfcV<3(d@OeA$m8)Ux;2hwn++&N9ggUrswPzKFBA>H1|z9H9&|78oYJ6 zqmYWjF_7HcNYd8We?h}xgx?|h{o>j~Y6Ort0-Tr&f;blhrY6~brlb8`aT?w+$`-AaIO=h`ieN z$~Gg-mXxYMelEHs`4HK%%m9=g4(z;v{W(jv8Yy0+*!MmE7Wb&l>IMYrKWC`)P-M2m zu$RqPHPng}*nD$a!U%{{NAQRJtAxj3Skt$rNr5Sz(*ys48Sm7CQ28#6@u$SQp8jq1 z)0|6ELT|Zgn}T|MFJ^9l$ZpXeQ$e+^QI50?#DWtekW>TGc)Xar_&#%;r9pb{&G`?s zaP-tYMb^D)AT=a2z3RNn!)}O2cKg(?2+hD+pV;B^mgx83f_)BBT#K1H) cmY(20IDjVb)DMN`D2T(p^ZM$jri0Q@7_2mk;8 literal 0 HcmV?d00001 diff --git a/web/apps/admin/src/assets/images/avatar/avatar1.webp b/web/apps/admin/src/assets/images/avatar/avatar1.webp new file mode 100644 index 0000000000000000000000000000000000000000..68e256c297cea6017efb892509f005290f32c95f GIT binary patch literal 2296 zcmVLHNCsINV&i=yrkQTpUuSn>Acb z2FE|`v?5W%`1-J1k%&b^{1D+E*v&*cgPXpOkC1j6c3zl~THPT33XotPrLtyQ6T5WG zK)!TAT}RO#s23>vzA4R_R4Z`A z)!tkNowc|9f8G#J-=@;b?8g;}XfMv3vXpQ%%8H}JEd>Q-E=Is*dOG0dKP#mpzK6Zu z!~hOswhdJQ>~$JV9YE{yxG0G7eyp!4WjSqX4`vFrIzpIQ0C7!XfcamHM$sc`M2fb8 zp?r)l(JQ^6s8qm)T%}1_SBx8Ei&#F=-z9}B6N}fuspI`o)nIez>LrpO#ZCv{{2?o7 zizMB-j`ht%neuFw9`x-5QLn<^-`6#2Q&Jjy+o`+KTIQK8(?v)43qCH3lBIY}J^8V+ z!&1a_htv&I@_M)c0092^2zg+Z2FA6yE){J(rlOXGqB`_f57m^A&4PKhbc!KFs7OB8 zdlaLr7TvuJI%#4#-P7{r$I-Vawb#sYrY1ZHul|y?8{3F>u|DwD9~|=ZB=TKNp#;aH z6p{8M)-)<+c{^=m6B0X{RudGJjVD&&L_)4Q_O-UM8aHDF436jm=6;o62nG$yl1N%V z^US=K*lNl}uT*xK!uV;kLRBU>FcxuN!aJ-6^T-zU<4BjZHoo!!CG`NKe=YJ;2G_dxc7RRuC!*ki8f7LL%n`-+KYn3a!J?P@;tfvk2Nst5@wK3y zRdIUSYB8(CZTDhk8nxBMCfFOiau)hb=Rx7ub2hR3WL{Hyrpsf+J+hGLMDG-~woLz+c}Z@!7eEkAC9e9%w>(!zlJ@E3xS^#||H1lMw>!0H5qz`? zDTk_akMd^!5{RFXFk>aW@yCUb(ne!t0QU(B9Kw(BX97mmD2irsOwo)JKe)-C9>6-W z8>zQiA&goy3?**OV)g6ha!+LWYa$kEDvBX5cFt_J}Y0=#0<)9Gxp~2QlLcB8}FqW>Rt=Zky2M5|a z;Hc#Pb0Ak4j|G(TYytIeThz9Q7q9nW1+bIw%qZ{hkGJdCN^(2I&!>>*jiM;zsNsay zfD%f*gMDH+xlIIpl2^zsEveXG-)Itxrc5f{VG z2+%x%J-=#LxVgp3INtBtb9Dls=&UII?9k3BspvyU-yDWy#ZZ~p{*3H`GxnG%ZA&CL z&*2_n6+ig<_r zr$au4SfLz_%DUXgc@3zY%{O#}L*SV!h@$ z0&J2lMSWRS?_2t+t%IovykIn^Gf0E(4)1S?`Q&xoPeYMB3I7AQVb*GGTG&qx)z35` zm-*_NYHX|F!A=SFfAm+@A_k_kST8zZ^As*W84Ywga!W-8E;52QU#|P1>A>0AG0ZKh z1{j-VQFdICVO6OER;VL$V>bR5N9PP03GmD;8Qdn}J)%-_?J)}HeZciZ5q@(VMY4g( z+?!k6V8t}Nw0vuxbc5?JCw4MK7peL5#PnJHvA}FX`~lUGz3J zY|qT!%$9q#ltNvl4zfC+1!n>208_(%8;BBBv?g=;kmh|_nCPRCC&3y^hj$-@-t&>} z-b^A#l{w#iwS?_Z7@c;6zG?p5>zH=`r^z&UF!0@+o>6G)n+?TjkXzz577X8g2C<5o SVD2lzkuUx<_ZG<9l>h)e2Ygfj literal 0 HcmV?d00001 diff --git a/web/apps/admin/src/assets/images/avatar/avatar10.webp b/web/apps/admin/src/assets/images/avatar/avatar10.webp new file mode 100644 index 0000000000000000000000000000000000000000..a813d4c23ca9d2945fa089f0e4281a7e6e122e42 GIT binary patch literal 1410 zcmV-|1%3KbNk&F`1pok7MM6+kP&goN1pok0BmkWODxd(M06w);rA;R!qoSu4ia_8N ziDxRj@G(#c8w@MiZ2Pm1xtXg&SV}tO*upn_$e3T{3IKsG)2P7zl)<|FlG0beLZ66? zP8MKY$g2W!|C9%Jz7n>$67R(*k0AH;%P<`vmTFO)=V##pXZCZ)gtN(Z%p*SC`lfMv zD>&oYT&*hXU^Rn_m=qyJP9*z79qaCF(f*3BmcvomuMR=u8kHme^Vh+Y#SverH3t|FRmiPRS&TRUoCKe-bsYMg$7@O?MPgGwcid>XA$|n6Z=L) zZ2uB6qou3e=(axuwzL2Vp-2eWFAM$_IkRILs9hSH#S0Z+MXb;wb8oKdBte3;R)bA} z*+BGDjYM<{i3fa8{Zlse2Nb(xOK+^ybEndK`Zlp`kDA%hMHUtv;J0j=R{~n*D$iXB z&!JSJ=<`@=^kw-&i^Bsa0`G0K_b+M}`aMe#zB&dJWAP(c47c>965b2)B(4!Vx4$; zw1H=hX#>fy7P?nk_rl#aH{h1yQ$2BN$smP1u-VbsMnWv11? zLp058?xk1an|Sw(?NjDb;uebjVkWHHiQ7W(f`P#D#fli5R=&I_)-XU?fc6SBhQ?kN zJ$AQ42J^$gWWE%oe-N;M|ApbrPsxz_(cK-~ZAFgxPE2^B`Q75YhbMy^7Pw~YD-ChS zA=>F9_HN9N3FH?)0f>*-9{l#atdGpurvFT^8H)aFa0%PFn~F3`ubg8gY$SI3_!~2` zS_l(^H=T)7S7kzz(;dvwpf17sUZ}3lKcvMGGLfTwlHX5cAC;>SC~wT_CZ)TPLJ|+^ zO1^m4YAnp%qoe6yc=?;Ew9>7%+BplwY*&G+-T%ccSxkn1Qw7Y2OFWn*`da)d=D&$8 zPbb~qkF!m1wX=~@J>R*@ZYZ}fr6gbA|E>MzHvxRQN8k;s6giiV7hzBm=J2h|Fq!!k zd3*yobrI{Cf)}+vu8w>&2vWX5W68fofgrK9qFsRY-2?*b~wc7TUc73C>=31jaEDl4iGgxUWZ zB4rTdKzTY{)gGK&8Am&B2>R;B9wKiG*l5R=A=#t?3mmUkxUGS7kn QTF;h&;#lF?m{x!Q0CCo}*Z=?k literal 0 HcmV?d00001 diff --git a/web/apps/admin/src/assets/images/avatar/avatar2.webp b/web/apps/admin/src/assets/images/avatar/avatar2.webp new file mode 100644 index 0000000000000000000000000000000000000000..6716e3ff9a7c07198562500a8b7b15d86eddd92a GIT binary patch literal 1214 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 SOT){6x=sa6x ziDv-LJda-J@i#;hzW8~^&ycs+x+Z)S_h@HPs{&NWIsvF}Yf|wew7S9Za;U>aQ$~y5id98~wmJMzcHjDBq)*uuBf~SLu z)Gp7IuZx!uI9P=5&_Dqy#yuEZD#{4@MMZH0ciM^|XF*;3r z9;A{NP`bRGi#ZJEeNWg`G5RMK9xvPpo%yTs_LyO5m9BZ6M8d=6DrzKBk#26MucKg% z@Jmlisk&6TglV1uE@+Mi6dU?WTlj^OWWD+VusOAR0QO@7&Is!osAf6}pPg{NW71Lz z;rRW1v6D6$EyDKb`U3Y#iU6JgDBoZ>M~GrxedkT`+jJu#XwKj42Uj3@#eB16DhPMe z?b(<`( zs`~_!4&_XB-I(Go!Ti!(B zzX28-dwQ8RJL`7&KAUMlIpDVwTj)zHMR}xDz-D@3~Cc z7|ZkGAVEdXA(ao{SKLu?NRz`qi;$@P>N24l+deOf>s))>An)p$#2X!_5;0xOako)X zjIOe+FA+F+H}$q69Y(8V+=}dvt+qbRSF%REpd_z<{gNTS|1wDRih&;=->ZRCM90dv%N9SSzr(f;PM-%fXrH3Pfe2f(DQ2NV&X2&-DORYw}T--4;V);q5 z_0DY^&XL%`xt(>cWVu58mGS#f*E!W%9W)`^Ht({sb?}UPP-X{41Jd+OelWn1DB=!? z?MC>k2#5dG-RGm{KTf5t%~`eqSg6fR1{uXI1M|uCGp8g4^^df%E{456Kx`7P(>#EL zmhGFzWA7={)deTE9PtU4F*C@=vOzZKQHFT0AgxVwj1%kfZW*%UC{wbm;GRO6K_yc< zw+EhfXW12v2HeMBD0!Hq=)pPKt|b&p`+N5s*oO##&@~ZAhA7o3S52Pz{bcveBBC5r zCvhxa8Nh;#y@aO<5eJyW?$Hl}R1WvJQx&b&?*|4N4T}!E?@yxeE3b2sV1`a@^R+7J zp27xGD+@x*xmx7d&{M%Yfd#$BB*qzx1LzqTkQa0)>ycf>%P|WAHa3;I)bi6;o@^&8 zlF!9&kvZXgXme;5J9SggsLI9x!bbkq`v&rTj4TS4=%It-3h6O>)z;jL+26!mg@0s7 z{4(NDv3IR6vYgZOFlTl!c!b?aLBP6L#|@u(xpJ83IMLF?7);-IWQly}m|w*of%ad$ zs4&NHOL!Vvik=DMMaQ-=WnunMXR#%% zIGbDlMeFoEBXYga#NI7n8!Wj(pa!rZe>u0q3$8d;_hEE_F!k`bcamCdsV5*a6aolrOAz(+B zd@P)%Sve@b1T5Gzk>0D?uqoxzL9g;Hq#>W|iLtr^A-5BQTpT9ebo8522y|Yi&}N}+ z!nlr^T4DNF?&R$Cvv9rm^Me9(!ik`_w@ulxS$XEX<#KKx{wypTfIc~YJvA0GHEjBa z{tEX`KyB>SnrEk(@oucu?SqXbBaEcn5w^tTc2WGK(%4D)aa56stfLL0kuMEM7Rqi; zo`xQal&o-#zU;IGUcZdFg?D_jdE*(s*c%p)6)*r98xXMy&ILWlQaYSn60FD<1I}dkS@TL!3$rhUX&agD&Hyk%~IJImH>Onl3MQU|Ay%bPf&#ECZX4~J>IhG$r&FUr~Xpd&X)r{G|={i?b_HnA@GVU z)1>)kALc5Yj)mf+#4Bd2-y+^^2?%cJf4F uD!N*}&DvQ+z%4JRZt z**wNaQ)XNFP~Hyc-5LfbIC&@l{_)m6_A6qduzFFQLe7hG z+`Dg7`MP=8PMs#W z>)Zvn?0Q`J3a~$cdz%M6qCYPRal2tH)ADr7pFDSr0hhEecfKJAnD?cGv8&)tngt<% zBEa;{SzSMNkCB(s_+f1EmeKCik^88@dGSs1>7b6hrH2O!%;F^AILl`PB#npS3=mrv ztu!z!I`*4f;;#|N@y$x9puJq@e%MLELvFmQ$7sQp+jYi8zXn z7!yB{(N+rF_;=ECz2YtbVQ{P8&hmx-SM^Px`+Fx?{VVD?*kb%yULK5fou&W;zx@57 ztjN5fd9K(%4A*ppn$C~0uwAicQHGUJqaXtTq-SEO3i*S5UtzkmT-;2S-*yKx^S4)p zu(xtY`uuGFdnEcF#&D-^5r5^8ea?Ikb(enTfiNd*6A`*r_w3nuP4|nOjv4Ar7>Bn* oum=sD9}=@(JYWHzN=bN_eTt*WCui4r!^|MQ?*(KtUpN2&0LE>RZvX%Q literal 0 HcmV?d00001 diff --git a/web/apps/admin/src/assets/images/avatar/avatar7.webp b/web/apps/admin/src/assets/images/avatar/avatar7.webp new file mode 100644 index 0000000000000000000000000000000000000000..e5ef6fea28c4dea1f9e10e78c8183bbb544b75d6 GIT binary patch literal 2712 zcmV;J3TO3FNk&GH3IG6CMM6+kP&goj3IG6bGyt6eDxd(M06w);p-ZPFBO<9-OL*WG zi9i_aoN0I-4^TUVIodrc|E|84pR(K^4Ye}2him)-9_C)Uf2WJ@`4U!&jCB@!jQSWf zkWi1W!e|7=uqM|}&jWCK0bJPHdljLAMtSOBhK2FL9vIJwZ*jR0^qz{%o|;eu@jIID z>1Dho=M;OiIBQ6k90N7)U-LUB4fNbnB zcVd!#l@ge#a^5^>ovyb^=oxMdg}d1U^l`2Fm~d{`GrP+hqwRdlsSp zZ-P@AID|3okzavUmSMCE8gScpMng0Q`5{KtM0ZUi0ncfw=fKTHEdntzAfZJM&W&Uv z67K-k4KmgBg(%$Njk|Jg-*xprnCV_zKfhnobWW_!Q|jolDX+OMrq@H(J)qOVrR-qv zRIBNovB7=pht`u{?#HO!58IuZqG!54`tQ%x7=FyAh}uj_ZRsSbgQ2Z1#W=b?n({iD zm$Y-O(G)Itc^B80u=Sua!8J2(sb2BUsv8#G)g?fpX(OpesA<(_`VsuMZ6dfL41iYJ z?*M;4b?t@DbYfq${(Al({l4n!l089uXnn-u$MA2MX|98@2|Cx#>)`8d($l@QFME%7 z_6}QMOoeB?j@rwb*TH=HoDV(FiLvgaLY2@MYNJ=k3TkS{3csOEw_&C-HxHy;ajX(Z z@#lxXph1y}+iOME!-6i8f&P2>gA0==<@-?sKQ{II&j$D`7MMhkLpi2;N%;HPy+G{o z000uB!pDPiCpwuSQQiLY##czfeQr3R&d!)&9!I35n8R!_q{}a)ZBY|b^-u<_=bdjLM zsH;7oYDXr^TFEo<|H}DS%GT1C0L2i5=EqD1g9i2J^W;u>#;j@z=K^hzFU= zYY2QMrIAKg`G1?4OlL+SauH1y`A{Uz#84Uv44XbR0-O9-ol{Wgj|tx$nKLP-P2du% z-Q0jz6G7SVI+1s!A4*xZp5N|eZYSJHTa=>)Ajvyi?EeB#w59bKeTsO@(I;I(WI77R zTk~JMwUgwE@w2~AD^;ya94Kp&WMaW0WwLS@H`8wZvU0zjb@itK&I(FIx@VOp7hB0H zotLS1e03mJ6E`f%jAQB%^&LSPkvnigLijZK-J)3ej9wE3&g(7ejXm+7C@$Tjez_I- z&tVa7hX+KoAg}8LEcb@&UGwPMMlWWtOM7&0LePzZIRAMRmZmAs1`e%T@SSGa=*OGY zhQzw}Ca-M_vl&mQDqK~wClFH;H49m0Y^Ym{L4&A ztXLwKYl_@y2ljr2kkQYC=V5w0{+F69RiFdTm)%T&!mfzP-kw#`aCmmRG$H1>hu-6$F77UAgTvGa;v!U9uan0`(ci0>}JF8n)U zn-=c1mA3i;uD8W=(^j(nz4X`EN%~rB&EFjc;cV(?)IMy<#JIAZJKCn2H!jd#+FP3d zh|Te*k9)A5x_gw)Y-n@x$ zX==z!&?XI>ePW83uOwKEkDLxe*zeJA4Pq9#;q%y~fS0-v=()A!rQFSY%-}3u_+s~H zW9zimEqBTB=n_B`hS84Ispf(XDkch~Aqqj;#aFJNd-kp*#*uNX>22y$d5{l&6GXjo zj>~j?H5qFF3f<<()y8WFAO-OjT6Bgy#(Rr=E4LU_h_>6k79MC*;=Qq*6sN;;TC+an#IgsR6KrAO z$c@_{eBZ9#Y{_X^v3H=kdKtigpF(Gy(*r=wmktt#QMs28C30ForRlH#j}jMaWuk@E zA_pE51~<1C0n?zwLyZVm9FlRO5^l`sqbf$ri^uN7MVKB^fThgOshG+$Pouyye&KMJ zbG2%FWThe&(<80fo5owGc^_cOzvBH@`F?*~*BqZnd>}Yoo_nqw(`pk)V z*Z@dqLMOC}XJjR@tX1aZzBlMg5_@O^19uBchc*Buw}AJhXJgG{jpTle SlnZDgHxy*uU+B%U0002~4@gr0 literal 0 HcmV?d00001 diff --git a/web/apps/admin/src/assets/images/avatar/avatar8.webp b/web/apps/admin/src/assets/images/avatar/avatar8.webp new file mode 100644 index 0000000000000000000000000000000000000000..b66e48f7b056cc570d368c4e0f5d962216d29971 GIT binary patch literal 3946 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 E044g>D*ylh literal 0 HcmV?d00001 diff --git a/web/apps/admin/src/assets/images/avatar/avatar9.webp b/web/apps/admin/src/assets/images/avatar/avatar9.webp new file mode 100644 index 0000000000000000000000000000000000000000..7974139777dcf5db9848b8f0b97b58708d60d369 GIT binary patch literal 1680 zcmV;B25Cq`s!Rw6r*sJG2T- zxVvRExAV}fjI%u($cwje4h=Qh8M^Vhm$-}w&Q^=5w2y(KN)o9n&3k|tlSOs7*31#L zZd*A}kBRj5Ao@<*ftTKbJz|QgG6yI1)!FB5ojbeD5SIT??x5_85>vF3(iY~_s}B6z zJIL03h+X)vz5D?ncg&2BI7M|1dFC`>e0JBim5qthgnd;jgMITvIsJRf`(5IKpu&FX z-rCs3S~Eqe$321H8YQh+4o=)+a^L}065GaGUCEK0%hpy+*Qx>$m=Rv}gtBWa=77lX z(VYa&S4AY~L9+k={`NoGgSxyFZfl)Pi^vA#KNDL6dX8Q>us5c5t;6L0o&x6W@m%lA zDM)$!t-!khhGc^`eu!>cuJy#x%f0VI5!yaa=td}Xunnnhr8ev;uMHIhcr0J$XyqK9 zuX57K{X^gJE0uk0OB+EWP-c7ns_RMON}bt%QVj{Q$m;#|WErNiIZFFI{kvAmC0JDA z6S~aC#;gaT!rVstFalV1u>i%0BG3(ck`%1_k7)7_k4dLbflBWF-$=y zYXuFm?Pkkw>>-p*ze|ub5=lw7qz?%ivp7%e~o)zuOB^QE*8~1b{~M=$X$;g4OB&<`_pQ(fAcH!qE9%DO$8BLT*BZq0o#qHcMiMyX~<| z-=+m)v%h3{wy>hD`#}La=L=Ej{j!TN*UL@gmGY6yNO_SKe0~V$KMCY{uAJ4ke=00S z#(Ss89O&bj<#0={Y6sz^ccLBU{iacl0AlStaKAP5^H&%N#arv<9wubKsR@^3#Bh3j zH^cK&-QW8*&El;j|62<;pvqeA-&I-=$SY3PHw%JH{W2HBMLgw~VZOlbad0jQB0lI? z&A2OQ_kK9>21w6V#o%fNugN!cbI}(?Mnr>8{3YLGv|JBl>tsulVv@2-!G>Bp(?L%z z_4KMfeT23VcUa&**Qq9gpKCv+thWy@(5FZ;SP-pYt;n#N8hgyGcT~yp0o7wWy%$XW zaP2yYd?+xSzH1i5u=5^{bo z<@blJ^<1?6)cNf7eacQ)dqC94Z}gOf7S(g?b+Z=2PdS8(tvK#JzU@=G-<7lJE^qf| zB`U!>-htj`Lio0dQ+Ic}ohghs09r;1A1!tJ0XI!YVxlj%Az=?yczeG5GPnkT{O zS^iGH_&v0;c>ZPbqB~?md!#IKb(FKUmVRUw=bZ%(nx>OTGVw(Tc|O;5oLA5MX`wQgQMaiXBB zpcHtwlRdTvXZ+CKlb4)N9T#5@t0>l_Y-OHSL)^qXurPW*__rJjBN4{NpNd!kblcd- a+aN;YfSUTyaGIA8bqP%u?tI%K&_Do!2TN1{ literal 0 HcmV?d00001 diff --git a/web/apps/admin/src/assets/images/ceremony/hb.png b/web/apps/admin/src/assets/images/ceremony/hb.png new file mode 100644 index 0000000000000000000000000000000000000000..41033245879f1051f34c33766bb6fdb4fffb38a3 GIT binary patch literal 2275 zcmcImS5#B!8a)ttO(dY8U=WaA1Y@Yu5|SVSLls*nQRyN9At)NUNHK_l2oV%TMHq%6 zW~HUV&dd2T-CeP=QYuma z0LbETXb%7o0!tx40s`K`s*ZSpjlH{z7Y1zkPUy`(6yFK+V`I>pEOS2QpB60u2atRa z*&=|CHo&?cCD<6u+7|PZwe=5v|Ia}Tf6!8(^HbId%`rgn(T)P-2M!tue5VWrG=c1Y zkT+Nd_-qS47W*&AGXD__%+_x6kNBj)76=aZn@?`(2$HZY1yh1oaM~J2Z-T)_>suDMOUNnq>hN9D^-_6J|>Uy(NO0(zje=p;q63A<>}zMlWb#0TvU62S8ac z07zD!Aq9aSF*wG<)f1pZWe1pPJ%2*G6%G6F_GRUjI6r$kBgt=^G5T*;Bzg0ljC=>^ zTA$bQySC>KX({n%kGSrXof++Ks6K%aUFVLzA`*S^mMFw_IJ=zSVXphv=Z`g|v}=@v zkW=o5F-T**U1gO1`|gn_LJ|OoE#T4iUh%`+ku$z3_HZ#yp`3kNGF)E^iVL*YYTQv? z8+LCYGgG%>Y?9BXh&0l}l}5J}sGqsxre+&;o>0IpvWi1UB>&w09{yeJikG(;SxTLS z#Wn6tiR!tOM!#_~RsUW`wJIH9zsm|cIN*~Rvmf1v}8ir>tu{H8@G7<-_?S9f4WqpX>ZAj-RS!{3iHgYJB zUwD;AvY_vo`WkkWk(^XsHys=n)m2qKB41Wbsh5zOEWl?xo1jVXChu6YO<~L&VFjYw zw#cTr@2bd+kbW!cXN?C?T-A;JvN^iHmR^4TNyPNjBe~X$BU#ALLS7$a{CvEOYDRRV zG6%F*{<3bL?KU@yIzv>b<7oQjr)z$E;WZSrh^J8kV~U^F+JusB<=9WDqlDcRbn?lo zDw^VpRVgbFTqxPpSSGWpx|7=Vg`GJH({vk>Wl(p0=&mBKY>r9U9Y&BHDmvuUl><-- zke+~Sm&f6DQ|Cw)7o~azQXhx8IGA?04Egj`Qze(paIRz3;vJsB&Y1brzu-=qc%C*W zd2@Bwt3g;?GotQhY6qXmmG&yXwFUU)Yf$?(kc(W`pdCN5-v1v zToE&Uf$CjP!!rM9eto^UnAX)6u2_d=JoFBbCuX!>Up9};7BjBz&PorOhBeMurH0=f zJhhx@)-9aJV>Kk5tAI5H(RWDg%UMq1Vdil_oj?8Cn($3MxV$#rSQXuVp<QP8WMB>}Vqe`-kf-H`C_x$6V+`uqTu6i7O9 zesm99cek;XW0C5kb#mWzqqrL(Id-*-nH3{LNVOzgqb`tZiz|gS zah?EmIB}!yIU4$WTNq*NILvI~9UMQ^rq$Ss{Djx zCuivMP<2n!v=%s=P&EFXkiv{8Pww@7q^4wA($G!y(@*=7XW2erCPmTxN}3%W@7J@n zaUixi-xr&6qZq&N&WHO&%gqHbB?%`2iTK}Fk8vLLzZD8MFBRvK-fQCu49rUh$Mx+uW!0jUCcU-x}#WRz?c^S|49F*g^)J36r3gg%c2}Kte-E~7qe#2lqfT1 zofWC(7}s;Jxq}l?&*3doa{VHgsRu_M2W_J`=m3<@h2QhDWJ^j!7Kdz;Bt^7g4C!;^ rtK9?{oKY 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

yjkcOw&(>Y8mz^;5!^gv~u``#^d4Gx|bp z2-7XGTS)^qhf7!w*}02Gj=X#OF@kb6Jt3zqO+zHUi|*y6rT)zw2fWpG5HATLaEHBA?3FMMUbKN! z#PTx@LRbQWSrGV>{xDg;Y&mz%O`b0->jK)iZ$3px^C{9_{ufhP&J*iK#sD|4mEY65 zo_)#pd^kuy=pjs7EPZ($mXiA4=KVU~KK%qdoT$y{+sF>raM|L3$68%+)I;OfUJTDx z1U>Y4LD0a6u&j$I!_&)rdJZMen2S(UBFCq5k=S`7fSS^^=A=x(J*#i zKz~~J*K*g#S=`a;a@2hrk*YK539RJvg8R}@o;E{PoRRvhvd?og-ce(w(-^A772(B( zwL2heplT-AQ4b4Hp0}RR@qK_+3oDXt)Qd>>d`CW+gt~#ZDk|uueQ--tz-xBiqRf(p zSffC>me&9Q_wx4x z#)Qrvl1BIk!nP|C;SW46WI9zBNIC596gbh(Um)?0RRV@0Qr)AD2Mixv&BN8B^nL0xEYRQ+MWL)?U}O`I{8z}8Q? zqG61P(MtfE8g1vOCeR!PQzyS$q+)y+-wzSkduvQVFUmM|rHAQycjo0CzZDu)rXQXWDzDqA%HPG7qYby8MxfS z>2kznxxt&D$SV6{@UgaOVu)ace0YO7ty}dTMy^_LW@vip+jR!lQOLYfcUR}c^Y@!Jnct35&-L@u9|L%xY>y6!J@{c3l!bY^F&>hg%@dSqG{+5` zE4-aaWE0hb+H_tHF}e*kiqSi+VPyDuDOBh%0Q9PS8x*jW{fsR42v9aN&nPoT-pD;Mch2^&mj&z{}% z?ao99Xsn)nJGGiN&%)>Y*vrk5Qd)>~eENEgD#>kYk?|f?-Dc2*)GlQs5fWw2%`c@1 zquNsRdGT*p7sf+aGhl!xV{-_s@?%#S8;Q64tRn2_{uzADxkbqN!l&w_!_$_;R zdnsX>lMrYouxu71`|g>Q3b*oO4VgPmk9>qyZ|=MVra^f>#y@Cjo3Le`o&ytIK5lE8sW9IS|@f;Q>AtCT=tq~<&h4j>sRb|(%9~?-avmzJm z(1;a^3(S5?{Nf<=?U3i=pJL|ad65!sGoKsxu4*RAB1&0F-Mi^-asgy-m6dR=Z{s3z zavmTR&xu&hAESu(?TAP1rOXTC!xu7C0j{|8YD=rQ4Ws+FS?yX}djleb%}G-Eu3=yv z)mED!6H2>+u;Zxto<8<9Xt45@(?|y8300?P{epb5UOjZ0LTRvgAN0Gwa^aTvL}hcM z)9UMLmZ(pjz3255xlAiZd9kwXYEhk{s~v3B#SbqriecXENeu4c8U6V)#W*Wz7YpRb zLVC&9vT1wI*EHG3Ct7ZF#!Rq;MAkc;sIb$ya+2Ek5XwAq3L;suewr*%>z-P4&q}Ax zsEye_4g<-Mn9J*lN*ADRlVX_0btd~eEGu;uXcR_)`tC!^^j(VFwKJ-BR~mm~ZhU>= zTNr;_cxu*T!7VxB&C=LX(l&Z7ZKcou4OcsR9#@%L%ez_E-QhOB!gpU@uEl@cTj}=6 zb4utCT-AslKou@|scXX_;#vr13|;2cdxC|i810%0mPcpmHEW+^gai|EgUzEXEtq-2 zG@*;-2-P_$0p0UO1rm9p^wUi_;7Y?p7k{1K!t4@hxiGazC{z9&y8B&~{Soo0+aYe{ z$lVfa8?;-5&uRUxp?d4ZgPt(ISdo%bOB#jOF+-ujYKA}jn`5q`)J-P4USjFJZw%MhK;@VL4a^|*giXyGFs{+74$5gu-fsXz^`$=Lh literal 0 HcmV?d00001 diff --git a/web/apps/admin/src/assets/images/ceremony/yd.png b/web/apps/admin/src/assets/images/ceremony/yd.png new file mode 100644 index 0000000000000000000000000000000000000000..426912d581d699afa00e95ad5e9c54d9cdda03f7 GIT binary patch literal 4629 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 literal 0 HcmV?d00001 diff --git a/web/apps/admin/src/assets/images/draw/draw1.png b/web/apps/admin/src/assets/images/draw/draw1.png new file mode 100644 index 0000000000000000000000000000000000000000..da5d87a3528ce62da611614d3cf3ed57c8b0288f GIT binary patch literal 11315 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 Q{0ji6DrrHR6s@EF2ReyiTmS$7 literal 0 HcmV?d00001 diff --git a/web/apps/admin/src/assets/images/favicon.ico b/web/apps/admin/src/assets/images/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..21e7063503b8c6eaaad646f6592ff0372aa0cf88 GIT binary patch literal 4286 zcmeH}S!`5Q7{^ZmX^4%bgOEf`9AQ5&B8oFA?$zA!I!YZ;>n%y{{bj?D_FL0{^85fX7IpmzXuNGUKjz zsqSE+ROd>RXb=$jMCCKi_Y1J^o>{Yy4P-D{>;kHF0vQD^a=K zC3Trya<9cDQT3sGR+kj8y2POE06$o;DK@zOg=nei?L=vA2-lZUFs&RMqsnuD(}d42 zC&ek}97tT+4$(7N5IMFPBF8tw#E+Zd$;mAcJDUl7LoPs{8RUMH&jND53Ubg2Fvx&3 zWPivHz!u&0FStoJG<7Wz)l+9O7X^JU$ z^a_tMg&IAGwT7g;Mx37u(Njt9lem{e#Ws*J{(y0Z0aS zw!$z(J!)PRE_$j;`cjxGw+4AslM zEK$F>zTAjvqQX5iqpl#k1=X6A&}fWG-DXb7yXt)^_Hx+qNEpe_5=Zj1*HFC5NMdfw zMy^Ufs?`?eTlFr~VxFhilES7;RErUy71fTqifoj0)y(sqj?YBDty;#KSL$v6<4$WH z^Dcy)-;IS1ElPxoHU7AF7lCtY2y9?8`()N);2Ps1N*xZ z)nx{;i)oE@7Ovp^J2px|=1(Q>*zd`HbNs@N6sB_{`5Idt#N0OW#N&LKa~pb+Y#r8F zvc9D{WKuSbH}&0$bwxdQAiERQjWvGF3{sDIjO%f36?$ysK~o8lf{rib0Y;{EFAv+| ze0azEaNSHE4o-Vl`Qh^)5#D1U@oGko=f@)Um&TvUxkPh~Y*}F=AK5bPZ~BhuMelvK zN8U2|wLS7SM@_89dp zp?9U$sP{HhN0JSc-Gh5Tw$yLE&iV4wdEX9Yxk%3*f?OgD=DjC|?A2np;7wt8y5>~o z+%xNCNWyD>jjuKRmaIA2V>Iu`#d@RO>IZSTcIifvVXz-vipMW=;uwLsvvChcSK9^{((VC;)X`OeeF~>8$-=uz~hV>V} z$oqF{%SJBf$XJPC+2mz?j^H&M7Mjh@L@TqBZ!u|e78*oron)eR$8w?G+@N@;{3-9W z=9HXc+@TNq&1bpC`SRWp{B~`*`=w6u$N4>rqZf5wa}_z0Vx122W)OrfOI575C@tPo zsLfqyz&Dx*+sI^yO~${Ibw|A?W3Sd5{;tZR-~QEKzn)Fu2X|{rO_W&)>Q(%IVN~$s zV*K=2tN7i1OPmilbwXfzuiBU93?>>2Ntfqz3g2uwDygx@y3@SV-*i6{5C#fYi379y zIa+Qa(Q@-U`1>4@>d|{U_G>T3EocET!ZtIj#TL_WvhI*idky0* z`^-`FVwHS)^4Ra1wLr|n;CtP+lJ^>Y@)cxvW95rh zk=t?!*T&>YS4~l+?r6WEchP6PFcH|5IvIF6m-icX6KLN`;*Iu9vE9;%{drdk+J@PI zyj^#FA1)dX?wLV(_At>|NTMCZOT`XLi4uF->ptu6@qWj8*0*pR$6EIinAkuP-9^jA zK1VtqELu6{Kb$hrxBuC|d7et$_#WwUtQNbipNqY=W4zC~1%QypzE9_w{ruMR2>gE{ z0P`G2=ihz`-ltmJiy9>)b(Abgi;$(Lw6!c*yH+IWX(Gu;l@%RH1tO-xQ8^X9gf#HY I1HxPX0yQi+Qvd(} literal 0 HcmV?d00001 diff --git a/web/apps/admin/src/assets/images/lock/bg_dark.webp b/web/apps/admin/src/assets/images/lock/bg_dark.webp new file mode 100644 index 0000000000000000000000000000000000000000..1c334354f0145e7ae1d91c8d51022fb8067d0b83 GIT binary patch literal 70592 zcma&MW0)w(vNhbct=+b5+qSja-MekuwvFAkZQHhOe=~F6^WFPn=A2W%;>oI~GAkoi z#EO-rC?P7^f(!_tCL$=WD$kBz@rQi_0+Izt=?l&d!kZ|6X89%e{pGOzJ!7zUFZcbOjDE+;#D|@={rx4<-SGX{8F5m6D)T_D zr}tg9R4`$8=NWHMZHq+h}^Qivmar}MDQ|9wp^qt;a#I{4R~VpXZQE_ zht}5{C#>i`5yPjz)2wHD<9dx zM^3_>x7_GIndF#tf8C`F#5?C6;J$vH*{9CP8n|PvA3SZ8gs0x7U1I*E#B~dGtHZqp z8%f2i{HtSmcN>Ay4b-UF@ppVc>$5TYA=59La`_((WoM@~oVldyH(Xf)R&~b<$z3DP zUHJus+EQV5v?K#9DM)+&Xxb{hF#}Zqtm@1rHlYRTQ9{lvcu*2TxL96Cg}_4#LbGyL zU{sTP(Oq?sJ1i=UMZoN9n^t~YaSg9744{A8ceK*xSgAwz84_ zqZz=W&2BNWH(vDs!uq7JP-9CumThC0-p3r*=6kfCNWE1bM55E@o+QMPjRP8KaxnEa z9p`QwnqHgVnpZEMLQt#OKAbkE9zX--X`wzGZ22EO#cu|G<2yynFNlAoC6UTG3)yMh zWp&S0=xmLddQwjN9uGK}cv&Dd+gwP=D;l+YN489Qz8-6Ybz_$;IaDm$i>8CEqK5J- zlVywVEV#XNa2bzR@IUS8Uz;9vEzwx$?7yTi8j?=C#_>whwn~H;@L=JJ60>`dSW?;H z@7Kc)>mtzo1HpT~JlgRWT@JD?3fL;Bdo}FI4@28-Bk#mS{hG83C~YDY`qzrQ?AQNHh>F4~7JkoSkuQl}9z` z0w7GmewMj94Y!IQf2~y)+b^_rSe&dkCj-Kz9xw(|m;ZOpO^6npY*fL}z13Rc5IZN* zeZ)K(L(;?A-QUbA`~{$G5_Jj93s{)fV%aU5Ln>;iEcv_0a^fEQn^BsNBI@C|2WqZk+a zPb1^HBy)0Vv`b2$OHCNJN2q?RA{+TlV$+v1xmEo*(()d9+@LQ>=v+yy>)1@&(|;Gi z|C+KMCumsF&xQ^ss?4ihIwnYXMz5ri<35=8G#LDyl!9d=aJqfq6WqEZn0e9+X2p2* zF1yXqcQCO!87PUE>0Dl{g-^7U%+CJ?kN^6b zKL8ugiQ2AJx5}k~v#4SKo-yJV=?0K;n3L}JX|Wi)zlrt;05xB9#!jU{y`=bjXqn>s znOr=LM!e6fITcn=DOgcR6yk7piJx+B&(F}b8TqCCN9gY+?1I~{ygfLA7%wwR>vYxd5RZ)1AARahpKzu{a>Je5b~0Z49CJd0ke(GG81A5xzkY< zhQ)F`WDs*1gCE@(ggyf$AmiSk`>15`1=Y=#T4ZvQJKs*iSh#|& z!l8)f@2pXb|1rq^c%br+`r-d#7Kl4IqgVc}nVZ2NOUg=6j-xleXKdE?hN$KD3O`ipSR>55jP2ZKpx;OtCJ=6H3!!0O`t=LV-D4 zxG5$78*%|EO~aZXTl1i2m651sTNHLe9*n&$a_^0Qcx}*FpHGe`QA#FIWQzPa%Yy{q z!J#VRzohNUr^mT*Z_Ure*s^v3TY&IiP#AtKVBr3ejTy%A46Zi)o811txcuE(XH#}2Qd)O8oym_fWN|-%QNJe=pzaK*XEuxSAK35z z#B8au6p0-8vn2mpVdv2){m6usUEJkU%aBKLh7!?t0lg*CIrfOcxzT^Yq5h!5%Kdz; zKfLkgW@wC}i}Ef5COA(6)SVJj!~YEn|3BCvJNUc+&EHLcwr{k<34|73>b)tVlZvtW z)7MHN$n8@c2qyo?8vkn|$^cCSwX2y4Zv}osOkfvcD<6Y_f#fQx8yMNUBQ^9-0!Zg@_W*SGPGA7;zj|i4Y z5mCkFYE0lV%MBX^I4!B-VfGEhk!P8FyXgPSu>%+dA&UM^hQ`w4DG&_y3qt>|z-2GEp z!IsH6k$Tgh#o|Gp;luro>W;*`PqxYk*xY2dS~RlmFk}ulJ|8$MLHOqu!OV8!7tVA4 z(n3XW-cTei`AZxvMLr%%(;Wmhol+xutV&i>D58X){N4b324=Um4#IG`8 zU)4bx)cdEAynq2#g1GGm=|LaCahheM0*JTU-{%!+9SlDu*kMUwXa0O8$jE_Z50TCj z(7M$}fIZKt?m5MPZAx*XAsUF3xoXTGtPK%pf4`JWdn}@^-7^4*88wqrzA>O9ex|WT z+3Wo#<)QSDH5bU*-H(Hls_`(&=`XEf+Y?)Z@1loApV%AP&lMJDlfhFPHI5eTn~1;Z zs1D8lqy!9clFtQsb-d=~vbE1(eNFLYF0)Lx2_nE@D)$#-_HqRoST z$YcETFK?E=286E|HULW9-RUa@a?_1)I?ypIF#6Sq*bD!O2KuLB7OXa8yiPVDuS0Fr z)MM3PwQmtt)r*G5WP;OE^>C!A*o!1C3ZQxtiVFcnkBl1l>eU}F4V67FoA(y!kjd8~ z(e3c9L17Rbom%%LSj|B%bi@{}Ou@g`elgTzkh z_*dAiDt>PULBr6FTfW)|&{GRu<&V&UUzj7b_kr=ItI=tH3+5ZfVar{VVo%dSe+`|b z&3F5HJOU_myHC$jOe^4Jx(+(D#vC4;8D?@|I?t3Vy+;T*Prz6qj61#+Xf3)yEB|Uw}Ap6W6;BgxDd)F#YkDP7Jyv z$t`^%)ZZ8Iz($f}^MB8Cz9C>i!iIfo(13c>!PS(f1m_%a7^;E@FO@xeK#kJvW{dOc z)?==GpaZ?36#R#&-`I2ROhPY)1`bfr)3_RiI6HA2<1O8jp@_}?XaKTD$r-9!F68`| zjVFQWe)b<1gZ6uge+RtE`&X(@$Tb4Z4M(Tw_zC4N_1LG&epB#?|KJI!q_?;HZ2OIU zqz1M7Sf1k3PrUa}1>&tE;{R3sxjWc!nhlXFv|L^UA}dJCE}Dh#2zEymhE5_${w5%a zTkDn2rl`^lsL}?NV*5^5r7pUx(T#X4!RzR{Kf` z%<>?Qrp}C3vVh$82sKI3#7bIw;|-$_Bq;ZE z*m|(225w-|b;|)T@*8lZE}Jo1{^X_*y2H9>C5W{FEl-HOK5$)+F!1A`*f?WXFwD%Q zD_oCQ`W@!6X-0g2WShg^O6viuZ=&H+buX&4f~4uxcbhDkjOVMJaH9jFbpxDV0SiDR28BWRQ2#L(aCr}8U1{-kZ&YRfIyvltzId^L?BF>yMOER$S!jbbFaYJ&)k6)_dmBhV$X36 zt}<9=l)>51e-b7OH~xnc_QwEX`1{Yzpxfs~`8&K57|jL5?QM5CbQdw@7bF^yWN>Cn zg}AR$6b*raL1VILewXx!EOMvk8qVM}rahfk0+M(GtuO-}rEwv#F#v=Q%p0G&p$D~Ng(L+2eMeokmQcz_@;WUwHywOacSiMu4s?gs9!OE%g99C<`PpcI~| znGMT%R76%XS1c?5-PqseT6%&AB%ws`B*}|~biXJtq7%*T?;=~j+fIQfGPPf7U^oL^ z;^Z(B^g3!W)2@NwsiwjIjgWcB9NLnCX#6-0J~A|JT!Go=P7oF)>mx9KfW;q4cms}D zsW}yW_0iO%WYVUtqN_Vd2vaBXDX<^Zvc+44GH`Gdwhf`#-`EpgZ7UNvwf+r|GBcZY zY0{sCsp3^3^Ycc? zm8TgJwY_nwwNpxBx%dldAi8y7*_VR@eofszZL28=T484`7}uWhM+T_^b%DMzKq>~* zdiwRX@IjUnaP!)&C`~AfPNkwCbuRHeIst5(o5uzeWte4%|Eo9s4MW^BAf#w7-PsL7 zHwj&)+EFDc9U?~AcwedM!2+R1;8zhxzKq&Wh^kQZ<3R6MuAeZ(#$6hJr&7^BGR*Zl zww+08Nb*IoN0M^ljh9F9!uI2vB;oA0er^5*W;ChwI}A4H_&dUkaooG1CoUD(T6A?p zpwCAS0=K$W!;LV2wZc#Mt7`&brRtYAhVbNOWu0|8XEp4sa`#QsP;z4KFD|-je&J4( zZ^SN5V}5mLKz(Y^|0$k-YpQJ)&Q5`@))3pQSw1=u2}X!w)PQY|6t-fozOSvel>)KP z1Lw-z@71|t3>N2x==2*?b|d^Ck6)+ECJ}y#h#AP#-7vmoM{e4kMf0O&-TD)Z;IL_?4-vZOJ2DI34?g z$Sr>Jq^;P7c~+;rnJ?JT`?s_jY$+&+Wlt}AYxaK#3T@FG6LuN+d{avk5vSH-ANOUn z3LCL0m{uu}@5c#6NCntj^c#27a`&*je3vpO@U7JEyL!)y&WyS=as@YQl zgMC2C{WyCo94#Gvx6?L&SH0@~!@*60ifm|jZ~-HspP=CS zJB~{nWq!OP#HbYqLJWVSE~tknKDYz&MMwrOwI(YszFPDafP&6b8+CrWQ;7hvy7`i0 zX|x-;+9d@k+igQ1?1R0~*pT^bZrFlwjpYwaUw#cct-jGR`TwM2)zwV#dLZ;6&ndDJ zFEeYp!f&c*q97uUHt%aBOV7Yn^c0_! zZ1A?%of+AQo3@CXOk1J}l^Wv;^}>a}W)HP#@OSB+%~Rc-${BVG8N>Bu?ukc(`l0uq zB_JldZkeVSM`A!Xpx%61(k@Y}oHziC-*{P2d0PPi0Z9EZgMA#13QJI=1NIo|YdUG- z7!%nM+UI=J04L3RWx7v&J^fp{__iPu(i~y9S!(0ws?O1A+?!?Hc(Mdi827_qHRr{4 z$*lmYHwFJl2mVz8OdnGnr`46^)s?66Z9U|Da`DV1>h{-L@@6_Z%zqRSqipGu+9TfC znvT+!>w*6vg7IrWkoe6baI#u_n%oDx9@Zo+O|x9MEkF$CN|z+uDhpDy{?nQ3|EfJ= z`RS+f+Njavh@^lxOg+%Xd2LONopCi#Y{8=KCIJU3^2O4-Yk;yWEXN)L492yqSZbqcu$OG_r$o%J#%|GWv!%)VLuxTGN|BqJ zQ_wC{AjrY!Kqv6ovnDQDUT<+&Ti9iKes&pZd}o%-H!gBlzk4sOr$BX<@SQ);eCiqt z|AFcLU;djWrje>y^V|kcItvy6mGCfVA;82>Oag&*odHQJ{&SC|2s2GfPJDOcsZb_Z ztIu*H0KKAMNm{!{Zc;4VO@0ir8RPa1vAq-%rYWL*+)9TkPB!5O#`mQ_Tf*qE2Rn`o zV)I4zyK7osL2nLCsv>YG4H5sfju6xsz%hih*Y>B$V(pMDF7u zp{H=!{pzP#1e{oL+laoZTU1Sz(k%vK+NrE(^jwA>73*%PinyrKKa~2SFxzCm=HPBc zYjpUYBU60-#4;`O^q?;cxWfhIfbB^1&g3ylQWTZ=YPN7M8xLPSv0$7SoD~k39X?C@ zofcD$q-#kI>n&X$b0{y{{ll#z_@E?Ip1mf8P#&Y9*fa;_+IA0GF6ui!=hLyK9#-kc zt7qbABN!e&^Py?4JlcRhud5OD3Y$MfIJ`-Pp|ED`QLX3Pv{*IK;YhVIO^+qnr8zy0 z!t+(U#oxRTtQ-6%U;!fYM~#WcWcA)#a?XqXTVi#|B^v|h%9g^v)_$n? zqG<SJegY+G?G-ZI~uzX2UejAVw~$kZ5@&rS?BEYV%wJ#tKG%P6WA3TNMw zmppkhq=WJYi+_HU&9K?A4Qq+bg(;`MnJ8YNmu%ph+6GpV(ID-S<#ZtON&FAmZHA2l z(oPj(!9L6ipe&_>Y+$P@@Vxo}Kj$^Of^jQDyu3@lD(uJc;s9d-V)?aN30Yl1^y8_o z(_S=O{IW4m5W-Z|iDnlKhsB>xla8 zjz=oI+6MdUtc^xdj&WH~kIu-h(dJy;Cympk%hy^#IklI+CDjv^Sl+ojW3}76KB-OK5CftTIYTg z#T86Ctcv^)8(g@KVi`i}t_(40@KKTAm|?UkfG_VXQP<=!ZHCpDw<;WpIgTo%ATh@Y z6)CBgMThuK#c2-bUML7H;_s-CS0PS^%glne!A!ww+e08Ofp#_+w~3~#0F{4DEWh)s z9Z~cpk>RE%2MjuGE%vk9qPI&w>e_$GeV^r-%c$Z4t5o0O=895V`iidsHT3sf<%`@r z_)*b?QID9Q#gb6t!&9!%{i6v$22RZzDrU1caBhH|}kBDB$~RaaBN{niOk8!L5Uo{clU-R8aQh!>8vbB-#yf01>yJ*EVp^df8ltWVgs8 zHc`-Y!YT>D+oUrqY8FI|3C0!c7ASd1LlSr`az{Lr)7W~lP7<5Sdz?k$zEL$m znuCy#oE8Q@#b_exKG0p;l{&WN>YZ^x_cG?wD#EoR9lFKdo($U^d^l+7rcuO`2gJW;z;&eyD#V3#}{?DCm~#)(r$H3`6^3g8CAv1z&8PHDyKF}v)r<`Xv(CE zsOF-PuJ<#IN)(bI|FTN8Hh8!U+PetyW99@<5?Y5pWCTd?ALxxoAe`F2&(b>g71rYq zQWt9|+VhA4N)N;+pHLkAgmzAFkKj{h-_vHgLqbsu1|b-F4g;t$W3lY^!CsIzkR?`TXj+qgir$&cP8^m`t z5=%Ea##<#7!5c|IW*Ewvpx!*CmUoo=4W}oTd!Et(O%ODp(7d|p-q&+7zKZekmW^ev z--QK}cED)T@l65i!l$NMmIWbv$mBo}bQ(DUCN^SocMtQ1<@9XrOiQ3_!`%pGnG(b? zs6}3PIA{C;))9_#Pl*2bNA$xP?t#fbl)Qym19OWp7^MhBd&l!>s4);#C--{7-)MAY z+{qY)+oJ?(xO*xaY&vveOyBv7wb!vplJ}KTsE#c!XG@B6m`7a=tBPX`AEyj*vo=4~ z)+0Sc%mn<*N5_h2(-8KDkkR^>UR^w@3uV0+WZrUS6E)D-a!ZJy{anV{FrgT&Nz%vL zmxfm3O!)W6FQ5)%1l&?qM=Nt&LI6`$OAXLUiwr$HUlb_~K1EjX0XKgwva7G`{otVJ z!1lhsumxaPo1tB+r7yhZ=3p8;gi*!6PnpZ2udCL)xoc-($dyv|gUib7Jk5#aV~VN|=EL1O3!)&Gf|nLT!EH z9s}T@Yl1^zqOHpX;o^|QUk_mBrTFK&fy27&Po>n_-+%>By@`^P*So9i+we)EvH|!~ z`A$iM?2DgMclt>Hg+E6xvN?LZoMVLA3^B#Svq2&C01-Wbce;^YKJMY${qGe5b@ic= zDG)fZG`N(XPoRG#skz{=yG+rJh* z-Y;Q@86;Ol`!GR_lS~jOoEJqN#kBxIfIJCR%%4jyei}EIGaG^a zc7pJXHdRJnH$80v6#6k(r8_eza}E-($a*eQei{NO5du-yKK{g>)?x1~??x4vcWK^VbKhw)9!eQbD;qa6*k9R0{UX*|p&th>wFE5aRLn@=%?*wKPA(uw9Dw+JsJ zPeab5>98)Xy@~CJU8k8Phq-2q5Q(P9a(?gDopAMrsC!`BH=v&FL0uTOTl6;L0{XPv zTy>s64KnRviFf2iPf>w(Y|V@ns}%vTq+^Dw2nv~MGKlffn5I7iIMbPUs40p0gFf~z zjtAP`0zwNlE;0Xj=$?l@Ann{vkSGTvG&ftq?Qj^1SdkLYZ+Hd+}>Bl>pbeFq|aG~M!^ z!%{a@n;?H@p6T^wA>48U^ejVKof;tK?(MHc_guB4Bi>~nh5%UZD{rBs=U7S`^@4l) zfUMB!6f681+f)C$tvjuvzle@YXt}FG%;gF1f;r5&YmYezn|@AL$uMQ}lwKYey$j?x zJQv!mt@#k3LMA5S+S^aCkJl34G`)&HafEFoUwPa4bSJHz40XE;CFMbC2(UDoPUoof zHuk6Hp?L*C@GwW{m$a4`f66@HLX|+$ZXUm%NI)XvQWWDJmty6;d|E;u_Loc^SJ+>YnHKk8JpcmZ}Joo!o&KJ@c~X_NhKF8r zD6v544cA#qD-S^;1mx|2t{dG)1uJ9aiWdNMuEB*AL-!TY?GTPk7s0ya4t*oAX62<- z4mzybAT`m9|AoG~msRvO!ev?j_wLZfQ^RLw=O^?T8DOe=N^DM2BN2%nfv!*#w9cF3 znSQV7l54{_?=5UN<(iU56{I;~K|VV`!3$mVdd0lw$10bavN>~3BEA+;k1Ud?uPcCL z{b|ncVTm4V_v1vl-=!x1^N>?8sS3;?8zb9JAY^8yujHXrb$`JB(G$ZypZ0+>|9L5N zwlg0)le{hQ+65s5lMR|&@kNmOG0b4p>S;8dDI>ZeBaq^C+Dd{1!}o_~S>|ABx0Sy! zA1DCK;XH7;*=|)b2uwUBS`(-uxOU*j)vh^=aygBd)XonVkBm7aw+vCg%9r zZJRtHqODKuDY9^sNZPb623AqlKbSAoq&VxLLN#yCry9J3Q2k=M_v>FCDkU4 z2$NMFl$fUg__@&Wc>D0NbLF_DYp8N1&(>dNCf7(Zp>I+qkQV^fTj!< ze&_kvur5jS&O}Q}risHS5R!@$_ zQYu)LQ>BMv0wM`~5qo+oZ;#*`bKz$MOINdOE_^?%F&iM6q(7jz1#FeOyxLo{ zp)sk5vl(Hf)0MO#f6L~rK%8kdtXljOI)(pSmQ;!W8QMw&3MhFqT}19h)FUYQds;W1 zmB_kbltZCwk$mtqbilHJpFNx>K+Oq$B-)1dxZ;2%{9b<0oKY22Y*j2MJp9(@PLjQh zQs~jT)X4s1MgnDjOP^_ueQpt1rDz6CP0{o60ic4;5W2%+=GMI^fJt*vG27I)M<9EM z5|&(KhP}!I8>%aU-{x*2p5X)Shn=Io$kL7&&J3lLXJ1ZxGRPdsPy9bI2ebtOO59PX zw^|R6GvOo7h!NP;g5^%L9>U7z$ymqG27P2)HO@$fRMZHDQkRa(U}G`?bllpK*>e%{M@+V69PfoIPrr$9q&k&cEH|I{MZ97)o%f+1a4H(&f@x(MY+x^I%kd@g(!=-4nD zhLTWu*guX=A91nX&?-CsRzP3Cu<7%Pnl~QpzxOyr>hZSf0;-?!Dv@f{@8e8xgSekm z+zIGWw=Sj-rr^wizLhShY04Z->yJ2#rg+6Ni=gC$gXjYYt5HR6Q}pI%Y}T3uhz#)E zFd9C!>K(oY>1r$8<&OKfTO<;~uG@^v6#yGDO*)%R``w=oj^G{1$A z625t6i?#8F@>BJ7?doUB4?!;Fl> zxn14h#yk3KDXn@pa0(e_=V7ylVSta5fF#H6c4B5`CGlwO@P|o@Y)EB?GVY{QU*nNX zsLw8v|NMfE%0IYQ?QdVZ5=1!gxq4)Y@!H9|cv1NH&*P&OAz zEAH&-KxP%1!M8+(d?nEXy5Hju=p|2jO^XNi&k?CM4mI=)D#qXyZP0Q+i(xH1g}Ey^ z=i`RxQXh&28Xul2(^2vJwD{F{cKnr8Ss2qJ zvnOGjspWkkh5Hxtz3sv{>?aKX_>qrYs3o?nl4##CRRA!;6!NfsXsM4W%0u4k%tSvo)|6U)rwaO<+y9r>mGD*h*VT1@IiKg$}YG}?g> zDx?cyM^v~WJC*z>>z^)*RMNz+dN@>pvNy>|y##{s5UOskk59qv!xXA+G_hhQ#|f@Y zyR3c|wH_V~>FZUtoqS(SFzaYc;}kVUV-eLp%3fQ%tSm@%ji)c8(jOQ1f@F(FN~^+y z@e?{>#CNFjEY?o--t%HveC==EWxs;}q1@XPi?9jW%nG1D(7B@he%tS#1D{_Oi9H#< zn%4pDdm(lRrVw(z4g)j`^Un%9O;y8c+r5gdmGQP<_!{r4HPU^|6qh%_W5-{d(Oojp z{N^9MdY`nql>LH}d`z6nM74tflfMvtP_?0~|-d<2ei){H&MTK$?Ps-GofyV*UFG30We7^g>avF`p$fWm! zAtp}ripYd7jL-zC^wEw}-`1ja)={aoJb`z2h1${^2w(}pvY<)|EOw?Dz=^y-K2mZ97$syuNwr!wU@`J&AXzD@ZW4%yIIdhFvm8lt-fK>)6%@Y+2Io zUXPdcGBeq_r>zMmcFdzPbKrX$W3i|8(#ks_blr>p zh>J!re|JpTAAjq0q!J=YLWPA<9Lxli+il&P^6+1abkv9Y52mmKDpoL}M9kFMNg4eN z(5cRn^QH&_3R5+_d9g~Btn;d`UWtzT#$`-_%xD`x+LqXFYzqmlyU^Vp#rReTH&7swyBEbSW{av@%%HP?VYHIft#Wmo28Bn>XydcIX@YY9!=`r|nev!s<=NcvyLE31nOuqbOLLHDqQR*7uBka z{aY90{q|B3V;j<;gTnX;vbt0ZH4bZw4wZ*e|Hjn zN@%hWERg#;+N@m4l}Wu+JKm3enmx$RV$G0Kh3OvHw8QA?fTwafZ7+mj5F58Vopt!V z?MQ*JM=4L0@A>J*69g5A8@b~vfyE}cQunB55izl)PCIr~MQBaw(ryLaUBwe@K#{AF z*@Bexo)qW+bs=Xhe6!lBYD6$nw_!AMYkUs>7jCazQ#HTvM9Pa$W4dSN?v`NMq{b3& zqXhp!U?lB+)G8$oc*6@mV zz1{I#8p<;^LM`Z$GJBJ38+zxil`S4qZuo}J#?F!)v8>q>aOguClAEoMi9D{-3PZv(vheifJ%F5+M{QvSTqXpBCm{mTMsj4 zKz20Qa2)#EX{@b)lgzQmq`h7uyZzh_p^JJ%K-?F}!Ogp+Ay zC3Ot`gc<;dC5+z&8~f*v76HC!+~Wv+BP#?6JA%P-c~AVkuVlTS5Pj)3HBi9IqQ*Xo zw4qyWV9ri#N`OrkC>IJe`TC(qkEgS{{1dP>^w=%uEwMTgLRrRk<c5u-LKM;B zLFyuV1O%z5m~QAj7;N`ql#50JsESMlSL``?gKn$X327$bU2XXeSd~9UfFU%uqOGH| zc!r8>n)g$lUYhfsLPtgsA)24?xR8EB#m5~d;V|)Kb`8SW?HTyp6Y(upPacAP_WIof zq_6{vli0VDZ-uEMFq0Q+vttVpVTNk}25Z}a zlw0`t!#-FDaqI-b_zf+|X+YUx%+q%*TK1v-qLAq=y3*FwbrVyi*dzcsEO|wy(AG2b zPESUkA8i2$tN)=l(NeQUWW~CQ6-$B%OLLQ`jgE@|>H}7w%ERToV9iNQ2wqlQlj7n| zZ$7!|Kc6iQOS^s(pdVf7>chsKrc#L71THX$nvGJDg=^$9LQ~^Tw7tAYqR(X*O%Hya zt!K*ZVbM%vese7j*5U*pNBg!&oL1i3(YhAa(5{@|$rYKkSuI!?mQg6Cs*i$hdr91Q zK(Y6!P|{RU1D~EMw-MHu=r^d;KcBl^aCaA+jvUeuhj)y%9e5wQa>1ysZr3?U^jMS| z(lTtukpAWg)xM|I^Zb*xg_OgU-*J0B3Ps!ov?1cd;1|?$8F#iW(~e-&0Grr5&F4Z% zS{xlRiM8t^*bL#QWcyg86RQkTm3i?Rb?AgiPpliT1U>D`{L!%BGv<&!>I$gRb9~Iu zv477afgjm>eCw#dcqU-s3*-2g8EQf;n#K5(bfOO)B5ki4ZiVQj0(~M9KIZdxgkX5` zPmrn#c{=363gxHExL}IVzrX}wH*pVZ@$JNXc^?aYV@^-oMbxL(9Eyjg7CG)_MedMh z%y~xsbv}snvj!wZ(`HBU)$8Wvo1W{~?PoHN@(;CHaHYm*Cd5Hu>@wdk<&wi1OX5rp zK0RotYin=Z^#B2N)(QE*>>k|YR^*df|GIbYtjjc|>%MdHx;zd#8+whm>9NH2!CxZO z0w^tZ6Z|X8GQtZivSoa))&u;Tz3cw#TThDp(MUdCHN0H#&%s})U^696k6)j^s z->LeG<(+)x@3;03l&&QBYeQUsBS7gBSI&ba)A~s%)`Yzm&ERBH5KYGWsUl_U2D|}i zGLY=qaZxST0cG03h!9*P3Np4Tog8+rj=|?M8YewEW>@jc$FkrdMD5E=C$ol{yVW*2ZVo4;Wkc+wU z%9AxwBh!Wtl4p3g5J}*$Q|4P!j%imQ>r9(e1842T6X3w^bq?N1+O^n=SL0pU*2mP0 zx7nocpgG@VdmLK2ZSP=`}^L1+*Wpr3mNZ0fBp_k_+QPv&*AWqYlE z0tf}XxK?W&RKRRkAnbKl*C$-O(}gsc(W%kir@2C651mlBY>+PIHY`_lYb4`cBG0n} z82~+2N-=CFsHjeyFMubVL5ztn(IXA-wVtM+sLQyxnOqs`e@((}1Kg-9W6cZ5QZ6W4 zi#)O$KrKt%!N{6guI*qzWr(=F8*|EGhglEuq9UAbXEJttXA}Z@j-24nn!BEU?tZRI z$}o;Sf=PCCG&pu&NCzL7d;hjkpbA3N2ZO9F!!YWl?OgS_{l?zD!}cMi6<;8EFLIP5 zFadf09$Au^6h7D>t~;(dDQOX5?ku4@n-PyKk>zD9D#cYzQqYah&-PDL0y}^}y&%|| z?pn)!LLy_Lk=|ldzp%&RSI86NPG)k*!WAP2hi3bC|DWR7fBPX3sii>D%Xa!8kR=X6;0FoY_D~3 zYW^b}sXsB~kOmBSido0>0rmevRF;|CD$DTLlq8YAj4BMl}34|oSQChR) zG+2JBk1=(A!USJ*9wi*I&7MwpZ!>`|o9=4%yasm7xjiZU<9z$N_=a$XvxR!(fwyo6HbMQULSL4>Rv4ms^GtO02rH z5TXBb(?bmkZ_&GK`qt+;hpUxLf=FCe(f)C@@T&9jMYlWUVTZHXO9g;(71{G*S8 z&;mFg6H(HJBO$=7+uqdV1oI5=y^*UeD;{0Mr02`2mp}p;wE@anfc%LE9Q2j|b1SIQ z)v^op#It7zir#m>_ffFA3z)Pj-Nfam2#P@AnI0uyf;*x^FJtU;O%K>bZA|q~%-KmU1=pS6l>o$7;_~N3%7q7??f7r(`2>=7OT9!5{~j z9-D|D+VnMzdfO%$2*rJCG#be(rA@oJeD81lnC@W<^u;8TWb({(6IxO~S)MZAX<_3B z2JSo4&7^ZeYtDfzliPq#a<6%j|TX?KzY!n2*l^6W!!qS7r;%%|HmK1`YaA4%63Bnq!A z*S2k2XKmZIZQHhW*0yciwrv~l-0%ILN=+u+>8hk_1}h@!&8L6j*Nkif*U`YT?qPs{ zK;`>wO8T`%!4YNjEfUMXN_kniWe436v2Q}}sPTLYcXVj=b5oDP_9khGa@d`-G&y(n zu4fzbEw(^dUkFNLw?Wm3VtE&uL=f2r>qBKsaZLGp>kgk(oDtvCpCd*uKMpHEv5Sl&>9mN+U*`B9 zLja`$Q}MDmX-l)V7{*cF6HyxUIR40iBrLk0jgFBYxfV#D)}A?6ylE)peK!$ZaL4clD~B5ZZSm!C5+I#d0$P@gWCdCoFtZ(*03V9oI}43r~Ayucr8G z$&OIhw1q1o57pnCuHA~PHQ7>GelEe&il_8>v5Gf^_{Cx*oJDK30@recREdm`-{VFZ z92xg-k`0M%)@BIk9zb#UkY5g*IT=I~JiM~3r6~-Ep5c`@EWpy&Aq>6bs+52NGu9o6 zJB+yg%$?YxHC*}~vkZ1!D}9#Im&s&)hg5T#os_zA9F&Yyjtylfns?$b1<~0jvbMB+ zw9PV?DEOKy(qtxMegA9yBO{5YVzd`>AZBk+CZ0#(;vloN4$kt&<7lQ;it{vn3d z?a!Iu5@+b=-zorWQ=6VKobV50k8^RAToh)}PIQ6R2W=cu^J^U`yY$hxl0u#`=Re034ZDTqOT2LjyCC8%(o0Qpcis*b0TL@vFSG#`n78i;+K6bum@u~zylym&A z-LTRc>RuLVHcj}$fMjGSfMe$wSpDE+Nb`=iqw#0*iJXB&9(0XJMF^iqdW>O)+SOiZ zoA!T$4S5@-fK&2;06QT`z7fu$P13C4B?!yTqoMlU zLP=G%W~>UYAU7CY0DT_j!rSewhgr_N zZI-zC5UqwEnJ5Tlp?hko{FI~!`5jF`!#Xi%MrF#$`vlfu!VboC_B#7cIKfSo1v~Wl z;hCokaI3n*NdszFuMUTY7T8QeUTtmhh8|$k=p4cEgb*F6w%v3FBJs0uvNl&qlXs8e z8zNLD$!PMb&O2PyPb0xf$;_T4G}r8PD4Sn6^|htCu-40GF`hv*XfJJct8mG@sYw~13WPqRK`7Kjq88YJMtn{S4y)(+C*(s_oOdXW;c%7Ph-89?*1hV3}(EhV}rg>cFXHzhU| zFlAg^@UZMd#-#f)J%*_x^=2uhg@@78Eygz%NnKO7DzLhdQeDGGTz7(d19wWYuKWqo8h!mUIZ& zo8|L#uM6U*r=REPmMo6<3w-qWYg_~06Pn~D`G=`&di4L(6@9tC{49(p%s_oWbKN1x z1!Mb!16#ZRQg&NQsWg{__mmQsZ8)-8YRc$>+)u(v&^O#DpCP4wcU&YQ7*giUv2wx_ z2HkBJjJiR9dd@GjKR!>sYskrVuM%=w$f;X->npjhB7imGt}T+i!qWjD`|6>>wXA>x z2n4CvXJ@f8hX6$8=(ziowO$tle6e(t{N_9kDhqnP#yW2VB$-y|fAjj4`uG~|v$3w6 zkXbVCELc$55p!f)$g#+TzBWYz1J-$1h5(;$f);zr`gZv`BK@fVo3I&iP{`@Ly2K(i9MOcbQjA#d;hYt7iS)7Ok z@0Ol!b$wi!*}d&k5o1+(gU6^HBP>rMXZVr2N^pmEr;h+(OG54sr2y;H8uZI^#)Oqb zl35U{ha!XpP+CW%vl>2MEQBV-Iflt@%VnjVx~6F0Wy9ER2G=PqpMh7Xq>e170O?So zcAybR#5RnBSa4x zy#wB@ct9^0Of_4gw<1Hpg9tyf$?*4Gr{Mnk2Wn66LsQdia^&gTEV=|N)l*}Z9{!xG zw@@-QH>pnZx%oN?`J!rq*fCa8w{vQ^Vx>Na^~*q}7y|JuWL;!nN*a zlHZRZi-gXMKW_?Rm*9RhoP{HxK%j3);oPjDVL`$tUj$m%>1H&=Er}c~*6jfIP3h~A zz70#AiY&MOMxyoZID|H0M9$4X%g}kq#$jOJEhf*>sSO;S=j|sM!Q7T`T=i_G3~Gyp5vbBo*$oBwjw$r^Zkn zJ0km;-&~7kWlA%l+Rd6NC+$hj__+4vcL5Tj&5_S{baZNsU84`Kxw-5CM_v7`5TOj` z0JK`bf^Gt}()j2;G`w%&b>UXD#n9Dp0OsO1T6=wky>k#J_H@1z{>E}7NbaW6vlb7- zsO9X?e-r_^`%r=MVn-!fmqpn1du~z444%od1U$r!&g$uyHPcbbt8s~_0$vSOZM#m9 z?0r_#*cV$y>BMq=5;?=6ipf;-1Kvzmk0f{BZ_r;apQ^g!2Mx#$gt&O|=n8t#CNe?s zhN|rzGnjPDSvrj!LYnZ30!lmEKDEJ-fS%)V6&tva-d%+O4X}OP{J8Dg zpi`T2c34QDE6{BLD4%_rer&)M-kbUS4D9LHAai2dwEXAm(!EU(r10N9+zq?};SPa^ z>`c<74V5lrxrXFGzK#m{tBV6eLx^)KiAts6bRwI3558X3B=c97BFAqwU*XDIBwO@A zf};t6=!=NYVt6LMWitI7wUeR|BG_ToV!Kol87y`+a?%&)`6u5F1{#Cr6ArC70pxH6 zwGpL-mUGN*zE3%1**rI+Bz!27>pflklTpY)rM;7UU{cxCHZ zaRuMrk2S^PD%N_*C{1>O7SHoOD|G zj)Cwq(&q6dY`Xo>Uoq~NQ%Kn1{DH#Tsx{2lc%7Hu&zW{&@*Jblv?SkM2dtoKHSeQi z?7=spdSX{${wNW})h=fLets*SLb{_7IRqkXjE_k}-jBjxQcifUP;92}7dh4GiQF0% z4(M{Zs7!MQ3*sRd6u=@-_Y0&2?7TRiYI$sLgpE@(Qs|^BKU1;8ep6M8q&WHO&cn)=)%(FGZ;S$&sAOX# zui%lBC9DP>mdBncduvTAf?~hBR5z$M`LFUZ@7(~X+D(q1*2&PvepXGwUBu;Ry*$8MnR@cDTy*_)$Sf?-}7jRH*pnpo%#!UUK zc%#8dikMIkY?)Y+4Tlr4 z^yHtN5j80a-VXu>&UR688a|K@bl!GCm3>`^(N$6~vJEyx25+(PrIR*5{!S#`(_p>9 zU(FFAk=U$iI2B2(F6)R+Eea`O?Aps5Vj>575R@9JdtAj@lWGl7qn?7G)le}o+86Cl zEOojYxTt|#vj%l%?0vG2b^uKPgA;TJTjC9y&L}&}$g^k;v*htrTrXT27w?`o8OUv= z6gkM*=A7}peOW}=GlT}pc*y+Nm&ifYd+K@=fgeJ-aY?Hli<4Ki_yT{<1FzRkQbV1l zoFv0tF%;Dd_=UCS*l!q6(*aYI?>Dt4EyUY;ZZxyhaET&(#b+wrBB2ez<2`Ncxl{6DU9+!nTF7bu&zk+(1>FGCWVe2Y4UHm>=`+xDnS zy24)huRF=M_>2?_)Ou+-RQ_u%q2NV)eb2d`B&2y!|coVXdTTKERqOH*vy_@OdP)q*7)dD+(Z1S)mPz}>1UJv+S+a+-xJ*^ z2pxz<7~VbhjH>__X1ee!bkR;te}ZX(dc|Hz6=aNaGS0#Z=`6`2-v){&FP72+;v2A> zBJqH})?!!GX)tiYyQ}xSj)vtQ46aGlx~Kq{2KOM_r;@<31s*Sx|Xk*MuDw%Mm7TB%8 zT>uq9o6KImN;L!)t5JF=VDz|7)W5$S4`tck9Z8~8L}s^*6)kFEGVtT#GXb|yU*KSn zjA@zf0FgzfWTg+Z0|8|0vm#_521G=BemSXzW6nVD?nngVL`fl&(2ts~7t+>o4&?*& zKL(qE5fT0x;=PZO>2+cd^*(#7l;dkqM}@RjjFp8m*@Q z8dNrV^CBf9yp-n^BY$KsP4qHpP=7u*HcrZ-HrmiJLp_eap7y>OF0<=``I)T-5fxF`bzO9Ch`N2WQfY;Dmio zGG8S5R*_A#-*;+mR&Vu-6s9_Q!=e6X|f zmtU+)T*|1c9Alb(>dOE@P+{4X#=_h@#=Zq&73PBw5L-`a;t*#3KjufqY9BC)akTrs z7gS{!gcLy!0h4m>FPy|pmCQ_CRL=#z;R3o(0kSY$EPw`=yzAW4B>;=7#{gQlGaY5V zUaESVxaBty>v+WXzrhu}goi%adFrQc;N0 zw6`j7$O=Fbtn5!^0y@il6I(dH+X%0}w!q+D}=7G7+Tj@!mx+TdW$NZUey*U1SRIAWO~6~I-|s5W7jhSpq=Y|eKkP?OF)CL6 z(s#@ez|n)Gw2iGOI;s20@p_QoG#z@oe^s$Hm~lh%GN(t?*u9u$VArHbS&b&XjM050 zyAS{ioPQs?*m}H_p_i)D9TzOnJ?1A|SMc=dqJe_{!{~WgJ|DAW_IO#8FAx=2M(<2!CdpUx<)V z=3n4IH8VwqRHZZVv?mUVgd;N@5BJ%z_xqeyV4)Q_IN-<2TrG#VFhwGl*1m|oo}-h8 zwka!Df$TwSxYVDefHfK>za{g59V(pZto`}cIhP2Wf68LGSbI6&-r|Q?xw?ke z{R9K#n<~?OC;_4NK2OqMXEFSne34Y0a8PM{?Rmt$G|l4bHj4&;A`3g3Qs3Ihj6kHfX6(ksP_LL z2Zzr4L;1~K=!#cm)Vl71213C6Gzm+dB({9$I;`>OmK~fp8rIY-yq}7j48ph{C-+jr~lM;Y! z2)el$6Cc?hezF@u=1Q_~cp2v1AJi!?FOyz<7^^o=Lw1HsGqBd762s4{QtX`s!M&Ci znjC(S>KRA9&Zc}dK>O6|5W|yvsA<*Y4Gock>5;94 z3W*;quW3(H4}C#fqnzWU>vMXnRy$*OgBW(ZBs^F(uKjhO{Yq2)ukWuX#Q7^_2#Z*? zeH2y|jysBdBTv}%?wPCQ3^9;k{?DsCjDMp%O-rsD`6L;?a#bd$%H>~uBiH+v0ie5X z|FwbZ!rxBak_BMoB`YP<>0P3TcPw$=UiNx7spApNarh91Sc>j>tfSKxa_5CHe~4xB zU^ui3;x=X%r8{L|Gej@zIC!bQjx$1Rc)djT59dnO?KWm8y*PkH;K)%r0`Ge?leWI`6f7ivPE8vn`{{rwRcBJ@)W7nWn>Vmsjv~b$}3< zEkT(**)5+^$|2#CE3lig6?@_>^rusA zAiNgs+QCQO<kQ_zi7usj0v((8ts-t z)94&4sU}K5HI)}VURx=TI@njWaU%P&Fzku;V;tx*wEmGNRN=RWs=8RtlLE)yYq1@r zc={unSk&)+^i!@6rB(c4iD!fWQJ`m;bVuciLgN|4v-L+!k_*;WH}r=q4^0dowB!d3o&H5FM0H zio~EuH@l}n;=Q&X9&PSSXM0UW^7sCVwEsp&`;n0)W@xiu>Ugl!iTpPNpj*~H4frm+ zSx&PwDdUSnQ?K3i!$I69Sw5JK+#{*tKF1}U?d}x4JwoIi#S3gSiVU3vIE6RsyHh9e)Jp-$d}x#`1KbrxnV%^gV%vF#<0AazRAk;_aDK`DU8IQ5D& z0{P~Uu6CpoP#97laTmdhE?L+*@$RyEbfH%$p1~3#s&PYZeOYMTq!xH)joA|KIsa6K zE9V&JL?xOWa%PJDmmZomrV}yy5qYo`m>qC+7>UR4n^uA8pEI}rNAm~QQekQz80H>Lj71To5_(wC6vL9UW%#l`YrMwb?;k8Owb0XvGP=5BEy}Zt?#oF5S3%Yx z2xR$hel8!X^Ymel*BkvmowEfKA+2~VjP~98T5Z`Qs}wGN!-0;xQy&16@}*F}0`L)S zn`eMPrO|ggZgQCv(m4kehksOH!`TmlD8I%TPsU#(IpSaSkFj-eXk(6i%l>4cV>MI_ z%^jl^r4{Z~&BkODAAGU}-F{R#c7uY;gq>Mam@#@cf@c5&)dbbb%)H=fNLJm}Zz&YVSJT5(!3)cu+GLQ7TRYYYy5V@ZR&r<*9mQ z;mRa-DC-&td`6#W!Ij+rn@`6aPK^`J&H5TIv|dSFU%fEg=Dcjm)k>HT9%BPyG2_{ zIh{pre~UGsWkj7xD9+O0iad2+h~muX&y_#zdb;BO;aT5cYryx}^8&@X_sGIUDy3W3~sde9>`zX2iwOR5D`xwMGxy@vr!i zxzB-1@!QldzS)evq&UrlF^YHQiDUe-1d1+Fg#`h1ggO6Xy=lQmIpwiEn&&g6qzE`~ zZL0(*VDTFy2l|H^LH1@kp_f;Kowt5vreuSRYD*Q^>B-(9T}KhtV77-+)}M*dU!D5L zhVs>!#7kHpD@pM%XHf5Mk12oC5>GxsSjhX&`>c+O^~oa|u4O(n`0t|@MV{egly6Eb zD?}Lse_4wTPWV`WR6U7})K}p!gxL^uF$dei1NuDcf)>yE>rB8kRN8Wt=%r;c{{9ZU zen5Km;qLf%<(GrUMJw>N?`*d%T^ni^ple5HaC)JY0fC7gDSZc2i_G+SB3o7PY`~H_U{qSDiuA2&PxvWaCzAonv>0 z>aCs`^UP-uQTUd$@%#;O=K)TM_v6>vDJkMX9totQTgUiC z&(JkQ8_rN%R$C(wT!O9;Mmz=*AC*|d)CLcO-e1c*a9wXS(=UI@uW@w6uM%NFQ-gs8 z5m(qqbb03wM4I8MB9CZe9iCtc%e)T@WdWv$B)4^#tD^>KM&P^k*1E6CTkn(=t)rmB zy<>x_Le$1*Hbm1scEaHpZ!~A=)R~BszXq!y*o*~#F=>+Ide&Ll5#?5}EGj{Ow7Bss zaJ|jF5cvpu(bD(w%1K^_EaCK|?d?mD-hylq%z%EhrdlH%FYZCB&nUiJMq5k+1=`HNFfrd@+ZJ<|0@ddIBwq z#~woFPETvbJr|Paq)LIGku5lXm~)S;LNbCq6$*QC89@~ddk?pQXJxeC9yNxf>uRtHU3LI ztF{S#wG*_)AB*AU3gP|>y4dCEQ8D*xcQ{lcI-^DHPDHV&vs-!2m(3qAa;75-3okMy z&B_1ji$TX~i@KGom$+%A`iGmnE=HLJXYnN$opFgy6pVS_e`<7!d`uiDydn;%HaUPg;HPQO_ag5GleygK)=CIzln2tQitaEDC#4yj(f!18J!cVJ}hq36X z-+}l#TQA=ei`RJH0R2U13Uf{Bga6hksK88982_6Pa zA9;yl>>4-s#K7jj;oj1r1CwpfU5+D_^J;pAx;KQF0W`SZgnQS#`}?Yxf}8I!J|~d~ zahm90XkSt~Z6rZIF?#$GtO4n_prV8TsI=v*zK(>!9|-kQtlRYFx8WMbsl#l@UGGyA z-ARKXoP~Ifmm%DaO(jK)*A}We(0Y_$FYUun^lUfqB|quiyS{b=aN-srIh-e~6L_t8 ziHL$DA>Z+IEjTV#no1i!5nHxPxM}XAk|uvg#R_t7!QhWF1LWy&Ev#?Nadw)nPfLZF zU{~JyNfnr6{x}Ntg~-?@e?ZSIDxaWw1VABaDG^Fg=S~O{**u>?uMRgKE-2mP>rM(VEnt`> z1E-m>uGCj#8-@afz)M1)NkW$9*Z?K2=E;vxN;J`v7P|FDxh{H21Hc&w4;kJ^A7`(-f28L-2GV1cLQvlMDL#5vHoM(u^C z!jru~h7E^lOM!ZdqPALjlgD&oY}@(#*-iYn4>|}~`1bT7&ICeQ*Qr&)z8t+}T^RHH zL&UdWr1QN9XMcj7?yUYaI$&$evEfA-H{r%dLrSYdodKG0Ddp>R(L9aGX=wVD^2Im3 zwZR={CK^VKT!8^I(lH_9SmRdde*v`kZeP)(l(AaV^hetdRCk^Lq2tQ^@u`EkM%92E zLm8+TCwQ~62hBRug9d__p=GoOf8w%R4vWqBZ+L)Cl7@c zD2rw}_@S}fUP8-ruKawKQ?1}Y((T66R`&TcNw2t2o0zKk0^`H&qKk?J^Bfwp7bkW% zG!bNVCUqWaq#`Fy#!|5``8iL#8;NiE3$v+T2SwOj|DDZ@Bkg?wYaI*$*ja+Um)j3H z$s)C;g%ustnhV4fwYj^%^CGy+#$Pbd7E*`??{_z6;I8od;C+|iiE@E5@66RoV)TMl zm1^#e(OshRQ-yO}Hb&9=hLzRAIIM&rGV#SDRIH4*y+{jqi~JVc3><&|tqNmMb6b7= zXwGyLJrq#>Vw?BIwab_SYiA98Mrc}{03wJAVTj*OmsscjKU_fFMd99aB7}o)s2C%z z?<1+9-}j9YGH2fae-C0l{|8~`7*Fn z9aZ_>q=}dJd$~e~TAb3Xw;-dK1}+gHw<}xe!Ssm7A3o7k{C+}VG=)RY=itSP;vYdO z5y?`3MLG3IbK@8|aNhtqFDS4oLSWLIE(Zf*oj)xOco!YBu^;o|=<~@hBK9Iea1`o8 zR@CND{J@{qClGOk53?o$Jer35po7}IxqAi3c_T>Bj0yy93qxUH!PpVoU zjC;57(9WHdsZ!5uqYQ zqgeWyRh%|#sYS3-PcUADNhb>5DF8=en|lDaz^E{uEmvG&FF$9$!)^{ifNDSez?3F-~Rqk-!Ms7_H1 zh-mB}SIZ3gD|4WuHATY#z~iiaA+=!Bw(}jFYw1AvdTI8EOP%SJQh*dSRjHfOAZkF* z0~^V?jmCwBhx-U1>1x9)zA>ew9o@SiAao^EUAe|*%NY`01-ZotonwG)+(gdm2js2A z)=4Kmi4zLHFweWlH7Ie@G_$fQ8b>E4Td0)Xy#4hI#2MnKbg5)8x~se%l2d*`O3_^{ zT!bZ3W=phGj2U9@_RtoXwcWnux5*j6LUx|9_9VGvalhC8Lo@WiAa(Ptmkno!=jSY= zZ#mM75kkn&;vrH%CwE+M_*5{l=lK}<6Si^2mK<7xBgiU)7<`;~${%77wCM~p0O3?>@PKA(oT_I@94DTRtFVxR9tAwX2wb0T_32QSfOiEDCO=!O)f(%rq|cViIVqwj>v zEu(k94!Kf_=}fqj+Z&Tf%sNrB{EJ|MB5DKM&UUw4o#YV@<&$KfhjsIBNGrA&)k93zEcHRqzSnAMGLzt3QAw(G?ht?tobS5#yiU zAI=y8iMIi48%{So6)Pj64gASVLJJAsU?YdN_KQ=GHOvML$OxXvbMvnH4Rgqmh!;j z+_Aon!3D#6EeUS?tZtMMaJKxx&{`~h8dk~TlXu%`K(!m+$qHhSjt@(gr@wz*<{$kJ;uZ$BzRc1ae-gvTP64t4jw>-0Njm=s*Z7Nz=G)m_7+( z3T;x<6Hh02zET?6ECkod!k}0AXT<62Iw7_1;^Xz~54khTK6d*-6Df%n^2c?;1&|4#?mZY3Fot4v^4Js^;5I56yt0F@LtWdFF~S@a9esM}n5 zy0~ZIF8{xFLb#k}lSg4jyIe#1MLgn}PKgrVHGg%FY$#8 z2{Z`;STJ})aJO%*LNxk=oJ8J+<6g#dS>Geh4bz^*u3+?{BcMxSJ2 zi8+eH@!`#2j>RVITeFJ^b`#p6nPMLg4+V~_OsCXCEVa>20WqRO$Cm1uvr&q|>gVum zNNP1c&g8){?q!zO1FxboHzZQi!asTnYM)x>zxJJdBic2(n^K_9LwIZ!_dGXC`<`2& z|5qwjQq8ikA6VwQN@=>-OB4awpN@?4= z4q=KHW=F75s005_9d&pbvMS_s!hUVMgv$6SxFID&mvj^%)QVfL%58LSgt^Tr%15!Q zZvqyrgjrMkW36<-b2Id?Q*av=l5kxD2aQm2l!mID^wqRJSJ64ZV4@!$0LnK!g8u-9 zSf%pEBoD5&is1bQ^##|Q*z8vpq2q913eg^bztN^FQdJvO=rct@ie&edlbLYmGu|X0 zBH##m*JXx33I+$Y?vwYYOC;)=R@1pib zB$l9W18WWsQ4Vd~9lia8r0c@Y3^NB3R3u0e>QIC61R}mu-(;9du7Zs`aOmFGjkpOT zZ4@-{>gNqHWtEE@0M5t{aavzY;G?961k2#2ab3|~JS z?@}eO(=90&1SQFR76wVHh`qO1Otk3!anus<-1aYFQ~-9epaq+?IlnRp@p{CmGs;*c z6}5Id@3bhiXbqQq%&kD%;z7IxL^G6!9blBEuDiF#SfT=BSsaW8X7t1cmU^O+n5-Tw z3p8hxtJbW(w_~Aos~1Qyx0-{ge55vNBN8z<1<@FLMZ|N`z`u5Tk-EGVPU)HMK?a^# zY&=cB*9iNu8}9X2a6NnfvJuTB|0`div+gt>7(kvqU*1 zO|Ap?3}Qb*AS&MK&^OKfvq?E<&57?5!Lq^sFB2dY<$ktu9H;pR+_=UzpUnOK1O+~f zn|KNL7JV?p?8%g?BXF9Aiv|5HO=H5bLK$bKyGdxDDjZS~^mZ6CQdTZm8kycU^TjJk zfYPDOmpP*cKsA~Fsi+Rx7ShIDz>TYr;C2we12~f3Uvdwv5TrAWv@(%G_-7ip9Ed~NCMnP#%!VixwnA1UYs|7e+(q10K?(bp&Zm8xbTC{If=@Ix zqFga={BL41I%YR1B}ztv>hO~}j;P8I!2Fg2Q=w@FA0n4)=7bqOEL3E8a(vq4N$WJl;B5W>xL^3ljinXzo2T2nf1u)X4a|J-q8eW?4>j^m9} zxY8zAAPg05Td{;0P=XQbPj14{A$%7M+IrFN$*dkTDNNuu1k18v!Mr1B%>WoR>jB($ z5+Xu0vH}SV8IdbVhkdk&>9QoA(9wD|iteJJKo~Lcg!C%S5fjLM9{POn9yG1>GJFiy z{CMp44BaDby+gUHWirMlVMCUh6NY(XAJh?kJyVSqj~CF#Cf@ z^?lB_U?lIhpkp7jiWM&fO3zwWp}BMnH-zSUV`E zZ8o|daTtRa7K-!ilN2TC4YU*lU$c|g;u2y#t_cYy?$Cli3mx-DY`)Sz(w|s{e)CH*^|#w_)T;m z>a>9%(I5>~3*KPUbp}^aYoTJXN^j{{ZoEiP?mXX22ZcH%D+{HYR&~up1~n#9X8{NTqPpZ zlRkS65k%!FU|@!gjJIq44L$!{!p5F3hzt|1>Tz$~9Ic{oWih!D7>ED6v5%xLmeIsf>HW^Yg?IgsiP4Zxd<4nZ zYd{`T=}J4_CS|Z->c_cCpOM5nfFZne9Nhx+w|N@8&q))YmLq-l9dI=rV{4x7DG+dj zi-MX1ZFubr)FaQuTp!hZ%aa{9eIxoH&f?jUGTKI}7oj`U=JaKSVZ85X2AxTcp>D`o zDVVIVLi?yu*VPf8i(0veO;I8pE%DL8-1X5WQ^2hr@D@7i2DD6jNLti8?30?40y2c4 zVIXw#5}oX*#R@X|)6)^ZwRjY~ZCco1#vXGDc>G7wU5V^~E4Gs&mN;P~{h)4p{CGDX zSU=ZGqEa>j^%qd5Z};j->6ak~+W;jjER!*=C4ueT$kGX`0~P!8=#E9fESY# zfyuDCX#=vo7l4>?3!O-(^rmS%n-Os3^Za#}gpA&ECPsBRj zl0So##^V5XK&)=klzm5{^p|=kt>1SgR0*Erwfp5H6$e>Ww&Aelf#Op=fMbhZL+Dn3 zjanIAa}4v8nNHLF9->iQKN{0FsD1!rkjhy;mPA+}p7V)a-O!XPF(H}15VohJ?cv(J zT}Uo^_xXnOucz&~f}PD+O=klTV@R$|P)4ek?Jh^x4`-7o_ze~*%R-mvk&w)R%L zY#YhI3kmgut;yf&Xsc%jV%vP+^ebIXl zP0dMsYazNgs=@mI)Et`3S-v`$8c_@u8q!SQ8_fnjFC&7A!%?yspK7|<(VrdP8B)b- zGzrE|;5H05EmN|kv+kzIJLts?2NsP+2Cj`DFk@XHac(je1f$Rroe<_|QdjWyM>r>R zaq%g{VVNqusK$5E5ujCbtx!pq;fTd)WGq9QY@BD(6ZI*K{RR*L`!UmZ>5$KljkKY&K+ zr!w+9Z`;02zot{y6YMJT^4Uu;FjjE{0Wr?oCU%K1`4EjI8+>j!Hr~c?SA>mF@{aKl zxRx&;W=AOpsqdyPoospNzAbSG+}pJnsHzdSj4M=Obh zek~VA-rw|0vOs}XlP%~{0d^xiA+)DqQ3^&gl3Pu&A;Q8=vY<zzQduRMwNzIyaH1YQTqa(HK!$b&kp4^eCc`NH}D#(?H#h zeYCX886DUeB2Fm#=U@I%4E zt+Fb=wm{zSaZ)r*gR1LpG?F{`;l7)RqZz%4z9hAtZ6cXYp)ytdg)UT$?jhE37B_6^ zNuTzL6iAeUzV-VPR^iWNzriOFtADBqn1lNg2^i;i&4ZGglyS1H@5t5IOo3VplwkxP za|uvP#-nqeHE9*al?Pg-w;|n@BGT)t9_mm^Cj&Yq@#*Q8Pn3}tqoeT86_Id{QL-TY zlW@Uqza!?fWFwnH{ga4Woc*e`BIhl^jlR8~Wzz^rqGv^#)>ftX4!Ax&o+SLzG&ll$ zSe4q}!FTy{Sp*$?YJ2it7;7BOX9U;VZ&{CTA+X>FG zy{IhlrxF(m6W&2FsSrZFaO!jkcP*u|K(0Ie^Y@O*{c;(D_$L5RH3|{R)3t>X*Onn7 z{1D}lW7{(geQO|kO*rx2V~O?O@i0({{{vY-roW}mtrDH1I7!Z^ZQu_}!|yxRG2gN0ew$)Tagg029EJpXxX9kDM&vz3IwHAPBaD_lee8VjUAx zl%*$~LEr}~!cd47krMw6sE_~yrGHC+PuBzDDVYWm4YU`~YKM}Gk0Q zHiL3lOW*`UnO`f#o7|W3YCLlpPY(#BO4rkUU$UJSzY!Z|qQrkx<~d0NJQ9~Ia;KPc z1LJA|A9CX1i*u&~S|4e4QO3R~EQ z$s~ciT3eNnwrL3K`-`FMt*+{j{Zf-^oJ=rQ&Fc!Kh-_Shw+x0wDLTjK(t7zi|9*y; zInK)+I{^pqhWIyE(ym0rUPT{5Xo1P8{%B)E~(P&EH62^;+)!Q zySH`xF^m$5jxR{XnT9B?LPyMHU%sbe8?4msnRj54` zE3x=JQ0vwWY^NCzAf6lpS?A4fb6M!a1E@1yy}k#EIg%{hXh_CvY10}MoZQ?DeP8hg zMxZ_a`wu153b<#yB(jktkG#Yp z(UOl}Vt&ChqS7hg+?|~P%;<*Zn za}jkYs&ayF{0Ey-?}oD!3lr@^T`H;-giF`d)&BWDd4P001Y5;B}=vc~E#NFI``DBrwy` z1eRD(W&}T&TK%eIWq`zFZETPJP~juY9@kas%8yomDENwa0!>&dJ-QmXBJ`ul8d5io zq7%l(IhZN8xrU`8Qk_|Yt?A)r?I)3`#&cST{uHu#n8Ut7r^-@7jKK|dcdN~FP3g2D zp(w&UWw~stwf0v@c@YSY{e4TX7^!D>xF`MO)aea{)pdyWP%}NUKv`{H5NimRaCz%O z2cf&g5IE5-!2t2vvTJMH42LvzIUzf#)n}mKvL6U(O6+38QH`$L&_UD8)-fu!aL2Td%f#i#p(XL<3ELU}?SuR?~=ewnsW27r$ zxmN*H^)@$R4#Wcl@#lY(ub463Fe8`W1vg+JlIc(3b3wg%v-Lr@SYbjcG-jJ|aw*ob zjhJwM@(9rW*LL)fZ~fv`lwT_Q)-H!Ssfo#jfbL9duD)x2P^Z-Zu^s(NBh@qZ3i@x}%$GR$|B!*>0+DJNV2yr6L(+>ugAZF{1ux%)a?XmETFMPCcZcA%s7 zlrEI6bui|)sw;uQdmo^cni9J4whRCr_BoN5&wL63t?AD|+^)fIlJb<2aB!D^2TF9x zKh4&eDL-Oq`Tt&G{+rdmTd`3&+bD&vVsy1f%xwD=zke@()f6>%tl_S8Jz}!g6*}ACEix*Qyt|J70XXc_n9T1yFJvyu( z^d}GXo1!!$XSg|FhObr*I!o7#+*`*guMDnICrTqWdLe%b`7QscMlm|YIVcoJT2h4X zPy?*$fbaVkLfc;7iVRkF+Jrc;O`_=yC?wjTVVNWL5K9XDZX*DTy8K$8cZ%mM}452xBl25dEmJlqby zfMDI2!L;*AdkNg?r$u-+wzC-O-JMhXuS$j#=WmiGt=Z6u3Y%;OD9$Wc$>Il*IHh=d z6+A7^BND>yLa52G+n$q^3R_ly00000cdRN=BhU#1Tw)3TxZO8J<}`M~x6pYG@>9&l zn$9GVXCFC)P>0~#6&N313Kbw22GDzUggsK5k75ZmUtzbpb%-T>lCnGlm@o@-4%r~Z zULG^|`Ogm^!>}!j4``(Q4W0_|F& z*a7IZzAL_u*uS4EEP&D960%U?aRaYV+WNrrY%&&^!0aFZl3i(3)BD~F%UUgY4hV^X z)!=4L+pl&pyaA1%CJTtmeUF+4jJnP>F};6jl;XmMH39J05ArRNPJHX^qDhRsaKGAh z@icr2&Q6=7u^>iF?1)ETEZNca9nqUd-IP<19q2fBd?UJCvfJUABu~AIHwmRE&TpXx zNTnMZfo6tdsWvKd{7T8*HYdC8WW`gV^&%vYE4_P@BrMJrtqDD?3s#Ws680K3z-R55 zW3?C=(CSb9ts!Ya>A5m6bl4tJ7^MOjhcJ&3HWF-6NcHp!N#S~vgb%KG23E8R-dxbC zwesC3-<9W2*zV#*`6BO%NCmEj?H>SH+0rv8D5*8koTBnZ49yvLmezlJZA_dMGH`}3 z9n57|3W;ur70`E_o%)gfUnBuNFIO3vV=?inv=8)0-cF;jqW3zy#qHau+hB}S?W_ON z_c^GupA3x|i2T%D>YC*`%HU3d2Gu3ILL2IFB#UCfN=>ncm|36aCP$iWg?(`hlST{sA(GOi%VYW*=we=HdYXu*gK6)DgL}q!lsB67xoMB~E|(MtAt2%~xv1 zI7FNp-^@ehVXzF}US-2sr=t=(UnIRv6x)C(FVF#1O@400wPgdB(| z%&j8P(v|a=nWu0TkC#m){oQWfO_w(N{U z+s2)noqFk(#QwoDRVP?4k0c1{b?PTOjRxDwLO#^2$}mQ7kbe`IeI$j>BK_h+oGwCO%2CWi^5`L`$g3Aaw+@Y2gtp zV>8-ifp|3y^)_;ir{tVPLsYwZUG%H}uIf-BSpr|@vO1|bBtLzmhv)ziQa_~^>9769 zVm|PEZ0Gmb8TxCn$nScH;H$=wpx>jRM56hs=QhCOI5x{yAk6N8en;dj|Zb z7>E{QRY+rzDo5Dcf(uo|2#`6h77=+!qPA`zh>TkM-9&?r>Cal<$(!-WJ;#--i<@-L z!GMLI*xrrG`b4#MDe_dU#i0`W=ZiTXYGy@0000DPTScjG5?PxN0?ZF8IB)3 zY9VcImQ~o!9UT05*bF1jIbr@hWcbU3Xqx4!@)PI><9j`D8i42CK6bhIRJ8Aiv)Zsb zGp&g40Tl=JvXqJ3~E!?Wsh?aDc;f$`jE;gYQ$?GL?M z%2>Pn1!cnczmWYy+hVJ~Z*r#lIuv#*vXeDG4fik4UnNBmRJaItQsn@)>c|x6%6ac3 zdTL1gKuXiEks+R{tk!ltqNPcehugCU)^4Lk)BGHR2obTc_jfCN7>Uq!Q(6DW zx8K0mT{(F2v<*n;#B&ctrmsztw;o(dwnSR+R(}=@0p_Vl8bhAR6{uV!uTbdSOEaCf zNcG~3+uLDgsmtz`dAesoBwzgqJ~Y_9K|nMDp*G6#{vFQRgI!Ulr=B_OgZWWF&ub?! zrB(kDKzG7sN-*(f<6{Ta8pC(D^cG}0)kt;Q@BD5@yNVT|g)SxALUFdn z;v$J3NOG=E(!q{K?1-xFk#y0ONV2pjJg{5IUFS^+4?w(HejwE6AQm*BUkz9FPPpu| z0Rt1IDP|smt%D_+^D>cc(X4~=DyL_Soj_nuNflSR18L}5hjl1cXj=-Lz&FK|K^!p< zG#qpc#E!fz`(>|`0ascumZ-qGo*+zalaQKVLOd%LoNsy#(1zxtQB=>0yJWayTeXSJ zjeG;RAfNk8?wxbBh7==BRCAzVKdFCo$L8nC`e4JVW=>CUh2gKOp}6`-QU5ZhtXo(g zpE@HmSi1=>Krh|$CHSBqeU=%XQg+71RAEqV4_)IWE1F!G4ZLoD03hu7cW^yo4P8%S zT!BC|a^)4S_qKfz>Nlfg~<^}m( zx3KBRe4Mf9uga4NR_`RI?13L7>LlG6sKv=BU~gySOeTE;KEmPU<*t+IuQw`u**!d= z{MvgB|7B)9r}l)B$A|b8y^J&>e74r00WfR0b1hvc--CoO5)CHx1z^_*Er4BWYFv5X zo}0zxIUV{{F6DZ(zQr%9lHarPRI)-l>=t;wZwYNz6WS9-EQ>ig^5IVLZ};R76Hp3z z;UMbP|K+-7?A6G4UwR7YJxrM^ODC^fC1;BVkrGRQLzZvx3+m2LWzKRihx;6uy)eYByP_ThP z(7DF!3m6=lDa0lO^h9*brLze6=vM?ZCD}hfh7spV5`d@;$;19Eq8r$vq1A@4j;Te)pp5%!4dwW6maso8MxdRK??bd=z_05o++rZm-!O%6%}siPFDUY8 zEXq>0b}4axJ*xm|oM&XC-4K60Xhfxe4^MH4O7}5b z{qY6`P%-uIqB3oMK(3K+p!ATU+kDOvm!+SYz||)iXzlaIY^Wl)fPH<)(ie}}6KmGr zg}0$zEL_1F?bzNMO;ygQE!~bi5KKmiQShi)>w=@?gl=%pX%b6=J_W;u;!0r>c@QiC z{kilDhLGr54UY+VG#cwrA%pVOl|Bun>`4I^#vH^+G?2~Mr!Iy&3bLwpJ#1=67gFCC zb-*W@asI-uH{*<9lELIMX8uc4td&hZ_pbJL8OW1!leDwB6-G(IH>SDa!?eiA&^F$J z3eP9-IvNp)`10xqTWYC+ae7z*MkdLStC8Iv2$yuVwC4XkXo%nsn|RUV#mxO@z!;Pv zZL9jqp@qa!p{xl)>ES_jaJ^bi$d7@)nuV{WN|oFwNmlJd&PLv^o<3X|_BG@EX3I@_ zga{`!Jvfd5)Occd8*{8`8H|j#E#rG)3e{5^5S1Q-!%-+!!_7G2DvPU+a44u@n7gMukvV#s zLoxi(gS)UzTPa!u(bK_eE&dBDeP3Q{eR+ky;nt`)#*>71Yu|?PbPo6n{xDrR-q$l> zdC#1plTGM7D7H@)NTmdPrZ-ypo_sFlzhhHX_ASzTDwm2mg2hX{Z!+vCs?ez|P6;vw z-I5R_I@FR?i379l`}KF93KXacFt6Tx+$$<)BbRiqHmJ(~PEGsVjlUiN!IU%Wn&URg zneDBk^XP0;Wf9uZ>Mdn&6@iKL7qv%~YWDiuZ#GX9kFi5LVB&GVXUiT%uc+-YREingEGD;? zgXz*YNiMfYk+Xm-o1$|6)(2>xDCS&$1i10oH!;G-Wgn00!f;|j^}ON8DDC6U=V)z>CQN% zeMb2a0T#-)-6U|11Mui}q&T`ZpRb$XCCI&n?8l=Yx!z#y+M1`!E#g>y#k9H6#+OK! zQ!Qg(i*6J!mhH&Cu73-pdi={?0|&?I5r@2c&r=0WV&hnGYR~qU&UpQy{`wCAMM(z~ zP)mKBlN!#qk*&cI>76d(pgRMz94)C8b`&Y6%2Z?Qyaq?^izvesu6a_~;txt+cqMNm za~!%h@25)7L|}yR?a}AVG(h-bIVxRCy#ZFJ_87vMiev$5f-LgaYJQUm8M-_`7I7ZF ziiQXR)A%YeUnp?E*l|7|TihK`V5$iET+-6Wzc=rJBUo(h|p@jd4Ru_Cp?Y^j`l(J?))(vP42T=Fb;_9e7)(4MkrBz(=mzd@sC({JzwI`^x4p zE&G#Q1f_@z?If^vUz1(p_cO9Pgl0juS(8~aY>l`ANwBo!?&gp0UdY;YhdVw{j{7)=_;H`QdI ziD%A!yYT)mAeSJs6_N_ij6toJas5b^^#9th?5cl%Q0g_|Vs_w#@VYWo3__ zIkAR>#@h4WdA;x-0I=rcok&0k8Q77DD~Hh5wXVK|9$f37goppG)+$bfE?7PBa5zs3 zku(~5IKC2EK4PVFOp3faqxvAb5Q{o=sKhg83U9S~PFs{pRuF$7$w|Jmuh>3}*oIf_ z*4%OP4jEg^AweQI24+m5gM@IWzUY8|>-R=2nlM}tzcV%=kS&+VD81*!xIc;Xm+8-a zh_=h@P0&yS#pTDh6!L_EHJ?P_R}c;!KOu>BKZ7L>y$n6#4~Yq>@i-@ybkpe0D#1)$ zZ|14A%^A{rbXA-B@rMV*z*G8%nCvYu*A(y*o=-ih`%IQh`k#W5{W zJA-aL{nDZJpU{Ohm%GiZQ^Ub=)$s2RlI?Ol*-1W%pR7nd+)Y--^1;K?yR_ue zj<6muF*65m_971)Pk{RPzw-a%mNUWF8sY;NOe@$?j*P{>OmqQUM9Yv9lcZ9jx!DVE zU2i$swQ2;;g6*f>0-{0*S#EFxS+3(5b}FWPf$G%>X-sJoKNT}G@Zn-j71i~Uk_NR@ zslzt{gr0+3rC#QPc{lWY_QYV1iN7IPv2iyNO}7sf&kYbt7r2iELMt7dCR|ct>^IF- zM&q$I+2>1OQ)+uwO4|l+KKSnz zB)D9;+>;RC(ODPNQ&-_;KsGs@AD9+_CuL*`0C46MzO&Uu_@9wEd-Fon7u2lQ+Ropq zY%@me2jf0&8b*}OK%Zp4e1q*V2qllot@|fqgw#t3{@pfbrZZw5_ezW8-YOywiW>JNW?iFR$zL`-JZe`bqr+R>oWzz%lyvYvgpY3?eX5_;rshIFp_a zonSbAjOAIFV6Q6$VOiDNDRCaQ@@3*$IMU+UPqHQyHKs_T)xQ;9nY4^xV_--?wG}zl zyuKca+x>q|0;2>9g34SkN80p=Efr*dB%(WNpJ8<_Z={GM6IGlnyf;8ryY~vdR_&OS zzje@5iDN@7!ywK&+Blu_cU;6qlwz3Ua8k6Nx0=}y@neIo)J4AkWaixug9!4DT!kR4 zS9Z(7ERVQ?@?IFf-P`Ye%*x^UtaR5hRE!-z6F4 zGHbj$x*K9ew`PVnCPpK$&Tc1UDpCf+lkgyand071!+YaZDIPkeYQm~fQ+1|n=#KS- z`PIjQfqH2`>9>%;j1bEedz?iTeiK)e8#sB!`I6F$RR8?JO)Ay4vn zxN=${8Bed+n%5sc|G5rckP56DgdOf#8jIbUpv@M+8f!*OeBZ_h9>^ghZNpWa8e$D` zCt-U;iDXhBdu6|~F0M{)+bomV63=U68sfcd#6;JXbKo9+v|dSbTj+JwC0bL<(-F6M zsrq&*1@zS0-#&i{8w|OmWJp&r#V4qAym7NF)0Ph}IFU?R9sPS(x*r@)yH6VcoRa_m z02eQZW)|IwP5yA*8FabaB`st@eWwP7e6tmr(utFoQb2C#LIM^d6oNIcd-d_VLZ7rx zaWMqz6UU+tUN9=7;0yqs$1t%v1T02c-B;SB82up6-QyAth}05h>gI-=TsncI^WnW9 zz=C1KB7uoCJlRqh@oc+ti~Jl8Rhm=Fk!S!C&M}r8Vqt&I?8aDG&qQkmirMf`_YHcO zHbYLs;`mi}ij`~-zPbtmj*61BvVCO+!iuE{p<}k5!(J&`!KUnGKWSHj#pO;P0mH4} z@{!0V;#sN0lH^0{W4fTcmFm^@9-nlbgQU}e zc*Fxgi{EX~lf<~+Q@XqIcG}!@?0;?*#J@&_n-4Gm;O?lvnYHje;2}n_O*?}rXI2B! zT7>@t$KiA75#*LqAk2LE=gQlzVdtIE9{oYy)_I3uICKwFjweOMQ+cV7owv(;zlc1_ zmuES%`S@(Goz)273Q}afp}*E&P#6fK2^Jwr})k@he1BibO&!BXhy^fz&b;n&X{`TStuB`Z}5odB;E9u*-W6Ta!7q8XD@to z$ORTyFClhXXwFe{Z>%fzzMnU`UJmBmyM|inTi?R3@cih&T~{) zV*P{t?>|{dDAA770>>f#z8mcMLY31gImQOhe8Rq0O62qXQ-oLf1$|%{T$c?!k4*Y^ zcR;#v{&beX0F=0RPrO_<)MhH_gG&d~(MQrlP`_bVgM#i9L$k~kKdWM^PpSrMcFFgW z8XhE3S2{0{m7M|AcbDuZi!i#sh4B_>s|`T?F10QLzaTIT!U7n&lGh-s6W;`63Rrw&1n(Un=-66N&7ENJ$p;`YiUi4mQ^@ckZE5p|;}{j<*N0 zmonp~Ae(F@hvK@g>}gcG591V^ymei-7=v7lTn^(%Nib@-WF}@ZN?0X2_BSyXA$l)c zP7Xf+@8O(WN0`#7*9w$k(YD%{o;_8=3A8qa-95fe_9Vl>+W3ecEx z3?4oaG8p^uh$7|$0H${{B(SPF4#+663{kcln0(Ks1q|S4w#3>dMQvm!1Biqqqyh2k z8hF}pF|HTmMW{b>8T140jx+9%y8T-VWcXl_ZAVpO#4y8o4wXORuh0zcw`?{%IA^l- zklu7PuYC&j3BM}yn7)d#f>( zR(}s!aZud7+rc^tkhJBE+S>Uc#vm~g7XIQD0(_S$i$UJ&jJBAsPDWW98@|d(r%YSA z9buUKs)ZXgB$dS!CX_G3?#R+DZHcO2yHt}wi~{et1+z#YFnZ2fn#J(A`tLL7{nb#q zi4MYy_Twtogd24#qv6#8=XQx!}u z`#J7x=lpbE1kBk#%fejI%J1*PWwSPh0zDE3tk_x$3<^k5w7?F6B(`0;m#L`h#6ju9 z`f?#FUUbK&cF+-+`-w-$#%bY{UO7$&hf)>#di^^P1923Qx}^uO9E6}LV~AlL2CySr zS`Wf&GV<_Uu;vG{BvCk9rfY3P1$Wx9e zY(!w1i}RxU=`8gufPB8C_fcg)!YE|;DE7-g*IakD`W&iZQn=v#V-SzU08kwGXl=yA zX)E!?2O)`F)xZR}Zcxkv=2#A1K)02~nfNRoO4A@velMV@gtpGYHTB7BMke4QD7##T(bK$`vh0R%A{|?{a%PF1SC>2Z^f^)Z;JvXRSfI-R`6nZy)ts`eo8;gC00FrW z!f?O?4xY@HyQy^CJdRjfb*gFYbwm#mGxI_hnQCLJNU-3(C9c<~RYgzRCzwG9w) zHCen!Em@S5Neu{Hw-NDN z5=IsCLY9?qHnwXc6rhF$U-yn1oOcFiTVM9SW(Ua#ma2zRD{Ra-r^yNuw4)ZQ+myVr zN^?Z3#JLmPS-*8Rb@uhYxn)HmDQY^{ z5_Y+g4aQ#|iCumrYc#VO>0TG$#D}LE7k@Az#C3?EFOIUscAuP>pt;*ScwM`WV9#$I zNsKL$%BU(%&^}=(XG=qWQF9IX_hnvC-%yfkGEb_$5E28nA}t6&!3*i?>g6zV%7}PC z-eKhZD7%0={=#V~K!Ht`%QagRn5{x8x9gxB4T=M0ag&6Jk+>6o&>~^nL7AX(p%6hk z&KS+-rCiOA|EalWUnL&l7j_nOXK68-!U-UC73zSA1@bnU%^qL_SyTGLjz&D6u1Nf* zi|rI`maww8uTw0^cj$^x2Bm`zSE>w95uEFv{q{-u=rQ+I-L$|A^yy>Hp9&;ovvKMl zoO&?J8uu3UEM_FOyq_GWnt`k>n)&1~DZOOIF~CJanezR!0vuaPaxQM1jrz@|?YkWL z^s_?=bQ!BD(l{;`6*$ktj0Y6v`hsSWhoHR%E~pLsI#9B5n$nv2A+oLZvtq$fxSa!k zn6$7wJOYJ4`oCE+x7~nPRg;}QT#frILzG-%hpy2$YHObHd?X|>=wzNjWGhG+7F@)+ z8#>h3>5B!JGf!7mj&GI1wFjP2%C1=$EzQv8Qg2w`^9w{uu=c_OZLhO+oDm;^fK;uk zseU*C=chr#A6KrrP|_L`8!kaUTjlN3XMs2-u~-K$u}kzCM4G8%dH!Lgl>3s!52!V4 z=iV!n0ar!sQ7-aM_NKXY9&46I*Plx?*n0yj&wWXNNL`o~o48_A&(Tmo>BfWQ<83O~ zp8=OZocw99GZu=vX9MEOGoHzp+#%{2#BB;Ku^6wNYx2%Hk>2k(&V3|!2D_6s^m)mv zd{!L4<|uCvRofX{+O8_OT~UZQCS>aoHSye1JZF&M3Pw}cCE_3W;)CmgtGM3#kU1u) znNUtn91Z&SQye|xqsK|;X0jyW(}|GufB4H^R!-5XwBM~v2OzbofrO=yP9Zf_mW`Y` z)w(#$RQHn0n3!BOYPH~ZsDYuT4J4dZCNy3~pCG*ftkyu!CiYnna_F{cn&F-y$!$%G zqru(!c7?e}U^~l3Xm9M)-G=!8LrWgNs;(R=W>n4^fmNT3e=R|idv;wpsDIDorfL`d ziv2&b#w)z$?e+BCry{(4&Mq6sfFuol%e-1-g8#>qrPVj0O#NZ^K$-eRc3`;KgZ;NE z_rzD5M%YA-L1<7~fF1ByyMkE{x*mLBE9v0~P}#SUw}!4xGBY#G2D|gU<>ZIFH<+GA z>|&?r9jJqcR7ch`s!G2+oh%9}{02?u|KEqjEv`-`9rN!}hL_&_L-9zmWhVkHBKJ5N z=OJ^#ZY$z6sok-PIsXxg1EMGNU3%{i>3_^U&1WZYg300uBWMR{>}Y+!IP4Yt%L-v6 zi&q=+;Rlu4?EmsKGr6`Y1#9Z??m1SJxVk2Q$r0ch$G}tg_>JMH0AgLZW}@PuVSU#! zE6ej@yP7YO#FTWIs6mFk9Cq*-f(QnVwf*xJtTSq{7U*jyZ_~GjLOdd6v_mOd;AD}> z$BT1cUmd7sRa#j=@hb&v68yMT4j|=YOznwrh`F5&9cFbqu@ATYx!oC)cuLNrf~F0~m}A;`@#dt592QJdM})68Ob14Wawy z7Crkn(P)?la0&nb00A(LG%2a1_pV#!LfmN?c)A$NMVBB&Y3k=|lk3%Oo;79e7P4!` z!#F6vt^mij2R*C&d*CQIY_DL@d@J6)r5tDAIX^=`m%mYP7+mH3iQ`t(K1)$`4T98G@(Dxg(q4OV5yqSo-gA(F}iVgFiy*_L>ECCQ@= zp+>e5+){}rOc8Ko`=te?Rd^~1JO!3(F~RQ_3c&LL#XC!X{T7f4kb^M#>u^(&A38ic z^%!5Ob;c71C`E(oE%Xoa8FVyjtBZp!=d4|sfmyP4Nb(q=&mQS(WU%B>B?*v5L7^bs z(wW)ztt%CmaAbNZ?zYFNSXfO+=XtN=8+T+i$BU?ql7u`rZfm*XB2z_r*L5MSI8g>9 z((~ucF0x;f*DpA8GDz;p! zQzG>n;HJf9fDM06Z${2o!u9{gTj+u2;_@+$$cpThziIM?L zMn084AR9|vDtErh94XAS6jnOH_|&DNZ&Jq|Muf!PmSpex7~^b;xH2E&hKdgM8UE5o zE3z-~8~E#s#X}Vzb47Qs_{Nm+i^6DtWEZbdg$w|C^+J{ag)dN0x%zgN z_1Na4F&VX^;CZ*c2Vi>qvDl+p5)7h5^RYbuP%E(*Pglq{i!!F){p-?7ycURT9E57w zr5hpjge*xzNIGR5E#XgOqpR) zY+odWwCtDs)9P_YD_nMmtIL@tUDDuHx9JA$d}t{O`_f#E5Od8G?ON0Y(xhOUj8Wj= z(IT~rWMSDSQUv>}ro=IN1WA2)ha&-_90Yx)oRkd$yd+V5JqonOnszOJ%W!0NqCq)& z7=_9_jeD0h^76{uM@$Z*bS|_b3b$w%l#EYi(E$Q)sAgr;kr_Q5T`k%=V#>ExYq-@t zZ)0^{1a&u$;q{N%Z^%64@Tr)i^A5r}Eea9)^lV+&%kc~^OOUMayl@Uq$^>nj^l#yD zp8(yK&U`$Yyw0`h*Zy~?0f>oFQcdSO$i+F2q2+?uQB>`IVQ@&It(oC~=h&q>aG#e+ z7z$(__myt+TkMQAuScjhx^h~)-fDI7UD$5-a6Ek<*LxedDJQd}TB&T?R~XT-N}5jp zz?z z9Qf*am*8lJw02fPd1 zS*lF6Cs#}Y!LPOw9@t@IU_zruD4 z-aOykOE`901}95h>|XZRxG6#+VG=ukwT-@%HG2RZl)=5A#fQ*=Y(}CCt?^+uBLj^FB+~Qg4&!#maeO-^*P$X@91ViMOYPOOZ<&7`ECBjccd6hJXstOT+B9Q<+&jU0WP`BLZ2%gbWmso< zo?9NYZvChAhl0}06<~HYM6BBGCns=O_zgI1jjI=jlT$nl>%g$J`&`Y&)jP6sD%M=ao!}=kmg+Q1s2(RNAwL7?MGnVkba)64uadE+5hWq7!j^# zmZ{Rf0000G7h1SAk-R<-h-07eK|hw{8y-w$RsDL)0fV;kBXkV3teyHORxILt2lBf- zW&U~l@SG9^$62;cD^sF>LY(cP)kyX$A)C^z8WjZoCpI0m&Tm|CeKAu+W4&k3Aw}n6 zCJp>^gt{Q>Ic#eG=n!P}9c?mk(e)MJP|^w8t8Z0hNT2XDJ2vcwzX|xPcm%BM*K#!E z7kj}Sc}I8So!ypkB-TELHxC2DQkKv92aEHAu9?WuXIC+LJQGVJ!F+ZiC2OqL2ilIx9p1yf_-#s#}r4;{>?4%gca zp0>E2C`9FxK1=zt>E_3n^CwbhxTkS-c-B`M$(op!-m3rl#;#tK0%0pD(4n%R_3Bb( z8lW5<-u&Ws$$+GOb8V)x;SQ1WE@`havYPc9hmup)44Gs9I~KbN{4t6Pj{*5pu{HplvD^9qoJ79j2aj5Jlp?hL*LX$f51 z*Fp@ZfC?1WM49LgHfCd42}`z#H~n&$%q4RR+eNLScC`C?mBnG`U1oIAB$zHafl{R+ z#y*gDKTKQsJ`)~|!`P@#56->RQYk&g97#Nufa`VtUa_iFzN`uqyKYk6Th8GH zd%qskBK_(a7Z>U(&gyA_GDrF|bi(bvWb#26rtcZ9mXP^DfQ{w0w`**keq(PIE|$WH z|23rnaCf(w0Tr`Y;OJ)Dg$FsRtnrIWV`ue58!9>u+qk=r*>cws=mgDO-YpzC2x(e6 zAeyP5Lf5)qCBWl!UmR;wg#XZd#M{N-Op(1}$Y4M`Z?HK4=opbF{h}{&qQjKfVB&Z> zFMXGeY08MS*wR1mUyqVG8dqVijmpgmXR%Ve&5&WMJLFq!jZoN_vbri7`>`$gB(YFX z#mbb3h!v5VaS=!Nh_!Nh>I)M9L)*K-UM02RDV(+^W z_D`UUS&q-fM#Dk|z)>dhn<$>85O=Bl+2ghPQ#&I#f|MXVi~A`!7&P_sTFL67w7Oy9 ztu)qHwi6ch97-cdJa^27WHtRNwSDF@pTnCT)N9m%GX}7ub(3~MH?HPjpF{ZNG|V(} zOU3A{X2OPV;J2Pzzn-4phS2!zDF%Mdq?q;Eh?woDc2t~*`fD$HEqph|CM{<%Vo5}fJS7BtXG-V`_VDtPV{rA&q4-bfO z($)lZIbr`143C)}@~tnawg>j@4LPaJ$sbxwzTlPE{7~2b9Y`_%;^|=%%B_;j@6V07 z0Om#Z)PFK9Zd=v3x0k9y5R&xcVc8~Q3WE)NrXqAu#eP^l1b1i!VMKvz8DiM{ywZNSgOOA zcZ973r;YL9(19-%y(eMEDf$XkRKyR4DfF47_nU)ik6kLpEGh}}Nqcc`dlAnHT@AH#XO^7vdvY|qm2B^g|scVy@_HDKoweqRkOkI_ZZtuKjV-1D!<*kUCS zG^*?rYk9#8mc&!8bOc+2;FYSZX?kJ=9E_N;i zIpQT*2MYixB2Awv-Z2XNCV6ZIr)8tDc@+sx+s864UMn>>m-|Qb?$KWM@)OeP*-1Cc zD?bzBB^dfB;NsU>5CfUo`a=5w@SZBQ%dkAS;W4BHRFVA_@@zw@;Eb1g?pSEcwQN@9 zi_A>?H{y8059#^Sb$j!{QJz^O%wlySxizZ>5mM98W*JsTrm(=bfI>?T0@Pn+zk!(h zY&ok!cKR^NCsao}0#i7UBLlW%QZFHbKnXM1W8q^;Vs3T;cf8qzI#gq$h3XlK*000g7 zJ%M=MQ_ooyKNu-c;-2HaNdS)s=7@GAH;6Fmyht9NdfUdroA0H~^KS0fXpdY4%ShDR zUgty{LD7Nm1PWnHMe)(*^IfJsi1mR;!ScT=ea9V!j@_jlB1>Kh?VRd(&(& z`|ay1u&XVyCZ@r#mBMP)fr8@jX;9|lkk|sLdRr<_rbX}W4*lM!Pl+&gn z`;-xt#Eb0};$n)to_i=QbD%*(De>LM!E8&9>V&I7OEkp-Op8|y zX<_w1za&y_d7WU?>4=4EHPwO3q;kA!{E-rJdRN5&UqGP0oz&^SsTitK^s&RnrtI{# z@W!IWNARqg+ak#cK$Q}5&<{*IjOUhaR6E;tDErB3njAI~&P zhHjv|D0J&dceAJOJB(2ea4T@uKPsswhJl1e9!`7R+spCg5HTHG(#_2!JMFh-aopWf z$%%$?J?Uxax{h42Ecnls@Y8=rl8KtpFYeAcwP+^w$*nlB*TN^arGf0_tQyF5%{&Rx z!Vk-khh>WnUfyQ$(4?TVmoD$#T+W3|@wG~=M(4a1|Ju!_N_AxNN&(esB3X+Q9&zaa|KEj?P_Wyi69?TsC03;gMj0a4o1qeBe6(F@+RrX`o zXV@Go=_`rBXaLJeVeb-5(1*psOy{Ru*i7XPr@{?~s>3MO>52I4Y#Zv>Q z4uXX0@8LWkSYj-x3&4@d)=EIZg^{51{CSVPLwM{))>JX?$N6c;@h+*UCoRGt;r9{f z)i0OgA7Is?t>PuM7goaGnQA*sQrrFtO6GRpvr9F09h&VH7lF>pF3*23wNpbl04_AB zi887;1WT@3b?(-XG#y{)C-%WKRFyx@O=fyk!iqGfTO(Ul)py(xMHMOcZsk^KNQr)u zp6XAd%Y!mG8**DQo_D%TSvV;4BL9%@i}Dp*D~DgFJ;x_lh!YyBjD(rigHE=YcBZJx zuIL|RUc-7T0qP6&H>+1fY=o1g6!uB&1@JGL?f0f`7M^7Z)Ww3J&Jc_&;uE9{POSI7 zp|{VqhCC?_q1P6i_4xKtGLG8%pug4RnENB7$}XW0TXN{m^?vPCd_QT`01Z;IS}8X7P2=9aX-FuNj!fUEM4MOBn| z5jH1Nf_16aQfR>ks6uD1%2yCu9gBaL_?@j8B+<1sI+5(3jRVb9Bh2@s;av*Ty=8*qiFSolz#{CzEqOJkdB3gbylRjr ziWA%IJZDovkBP3|bZswq%fkEUZ`$b0mjZZl>AKb{n1$x|I8|x?PzTc+iW?dA7eEgD zI19Mvr#NL2JaNkmK1uwB`g#>AZ0)F~TdpQ+0g;#mpSvBBd+KnnR zI=3!EquaW$6{vAv;lO{y`Gdg(iUhOSJNC88pFE6$N1qD_-JvK4xqP{}*e53Z5*L~Q_ zwH!VHy`{S)DKL4Ka}TvSA&_d&p|q2E=J${1dsoen z1L}Fe)MCWgZPz3A;Ontw!tIHoXl1<>_3H6d*@zuqifPw3cuMmc@M8!;Cdn`?`p&m* zoNi?;|Nb$PkWh?@H*=L|4jd=$3FoywnL&*GAsxt|00000bJsAjoR`QrgZh~|%SPi1 z3(4T3=?aD_`n=to$PtigRCB6HJxsTHOGtN!k-p{Ld^pCCk8XNd#epM9UXm;Nn6OI! z*W;S`*hdG4{hvR~VyINr{uj%^&{!rG6(@&mL@&OZzp8p-rz4PlsPoKmIY1(|Nr?gql>n=jT(JUcW5g;|5lntwvPSEI%HObxRXRc?RnC}R=eaos} z1mM98Vb9AdhwR5OAv6R}AB;@uI>KP{pTAG`YDczzV3BfG!K+@D8%TAo)r1T_ioQ15 zQ4gzv*k_7SpJ}|Kxs&~@8KkfgAGI|GNOE}i+~D>4JC2J**X1FxnZra2hk6)H!95iX zmWbwUW?Dt+4!{9c0VFX6wQ?2^V+rix&cN5Is7~BwvsX|YLt7xzi(@@U1e<>VuEt~> zaoDn-r-WM0vKZ_#`sKp~yu?-gLWWeXieI5cN|85&aGi$@ZO`(ST};t8(MlLd5UX&? zL*CO9-#&_%6aL0Y*r$ACk<%(*)){)ezLSN8v&q|W$j1>TQ)yP6yFC}3BfX^uOV>8Q zHHasYqg4knt}UC{k%&%vsA|$UjM55lGXy!(?Nenljl`q>H08igWAO1l8X7XGAy$_x z^XG<@V6f@=cU&F-dvYwTbY$Z3$BD0Qn!#y_qr}2rC+~BoyAyX>s2;>{>uLbGFtLKm z1+KI*^vg<3H26iZh9q~;qX*qke1DM$XxjyB%3Rl_&zYoXH-C$hd=d;o)`w>Gb=g^` zpE3=&c}=^7HJ)k*0mv-*3kyTMO|NlEuTBD@d4O&$_L|6yxK;`ivM*k@ychA@he>;5 z^)K#Ym?k2CpP$AO<8QCGsXH0G$f!b}&&#JrJNnVgyzvgx@vSSq3I*qCBXA02e{&eH zFp)8(=4thaPjH#H+aivsZqoPsD&wo?aONon>p#w3V${Px?%_d`4XUhUhuv1C8ez;* z7Jj-sJfU8x^GZ>Jc6YX5BhbfY*{~FYb}R`_hooLby)J@koNxs}@v_R=^?%q~Ul4L^ z19x-ZLI1v`e=+Q+Om8|6C$w;9pUeYio_<2eZjt-Kzjwfey>QyIb2aYy_y?8EIUkjM z$i&Eg$UEf%SiGoP@mrNd>my54z8@q)kkPVdAF!1Du*s%2Tvocsy&$r2rLx8f(zhv# zc}oSQY#be5!#J@3eo-y^gcfV@wzw&gH(qFSQ5j@Wkq-Aa6v{RKn$;|K8hv6HvzKOqgu192---4Tx$QrUKsFs_*r3oVA|MNBbLvjwGf2==EgcXK#$ z-0h66XKoBDVbSTQ;M^QLvMaiHRG4hG3iv=a< z(?ylLR}Aw!sf!1be`MKwUF>A3dgL9A=3p_3p;K55gLK)CErj7sm=+J<#E+x%iCm71 z_jv7L+pgt%O@838d{ofDF!)1SrA%9bBx3@Ke3Vh?fi%~KqbWi=9T z6QzyqLpB&zSEQjIjhJ&rg>Wl>%n&WbZ!(p&>Ml>T()JSiii@Liv!+pmW9Yx7I@p9A zh-|lSJ|NaTQzg#n&`s$-8!B%7^u2}m2{)?Se+D>v%{j3=vG$sx@%f*m(U!&X;bsP$ zGY=d2333Rti?*T9?lAF$AyUSH=g6M?{Gv49=hVAU`eFgVB-+fhhkz^Az~1tXq?dSW z*TgSZjuhFGls?O;ErJz9iqP%hg>o{%`S`?~ZEoKf)OD*>aXJu=K_zfXh9Xl#IH?df zxHL0FJ;hg^>nwB&XCqGhs56au^X_ySxRhpA_O;xL{oGqs~v>#|Gz}85Dw|G|l zZEu58eZ6n zuqEm3AOF|E-E8X-2j0{r*zS^29x}6t-1ece7WxMDCx*&vM;ChGx-XZCUN9pL*IVJV zY%Eu->zmYyQ2861a<1WBP8S`Zj9xwKKIv4CDNS(_@Fj5tD^XoQYmU13h3G&R^}e!V z)Cq~Wxm0aCZ1~$$_;BuK6y@MSDdvO-u?Y#qaBvXT=)!HjBrU1oM`a*zu1Sor=a|}( zX;K%BXW1Z3xI82=d~1%itnB6sr$v?r_1vadn;Eg3&UK*g2-yxE9sFrd%vBq>vydRR z(zH0>cl*G;_Zp@Ik?7(sVNCAa~WB zf)=GLp}C`WMPPH+6xTEU^qfb5cH6j?U%6%l)%r#Ele~0~SQyc}r_ZcrvFo_0S>H() z+IyTow}zG1KuM}eUkQl39B*pac5Ky=b7;ZgTv60-yK?Y&6!3NBElz7f_x8IW=-2C< zZlvQ>bcu9KCTfx&cv)EY&2MS{O5?B23O?mWB||PrP};HTd8G4HRPsjNi+fe2Z^XCO zWUBWw_&XcbVT2b5B-1L?VIL;$^^jCm5|7IIoi;vSIhAz>>LGk)3L$b~D7wXt zt)+Xh$q=eqccU%-cBw*$wz7Z#jrkc6o^(<7gx@aeE%wUsK~O0&U%{0Y8i8o0P)G$M1DW2!%lW08o!MJxvqq%RN$w2I)4$NH) zLIlZIdZ^yX`ILAImf~fEFcJ4s(wm9t=>0DmN2Sn$QC%;qs#Vt{%`-e+mzXzDBM}!J z6G&zS{!}EdSo`|Mk2xtVT(>KOO%F>}`OAkdq-4IeSUyC0oIa{2B!wSe3K;4~a-VQk zrm70Z#r%hg64wVm9z934fw%Z1y}C``jjX)mE~(#b0OI$6Is6z$zh@p@OFssbwB zfXBa}5Kd;KN^m)!1iZ{k&u^xlUBZrRIK%J{>qtYd_ta!L!tXOzed=XOYX(av7hU8unp=* zaysYt?$;-SRcpYxotS|&{=kq8B_T?eLZW(z--`w0aok#p3g_2TVw{R`vs2FDw>>tH zI_s@>JoCmj%Zq%XL}TZ&*(EhwlVaW_fy-0&r#Yl9CA&pLs~QU2FOx-WJv>mF!^S1i z`shO|r=r{!xrnYoK5v|P-pU5cgcZ2Px%6sYWseZRB~{_^~r3*L@UkK z7rJc>3Olg-RU;bqL{Zo_35Nej1LTCFMeHp4YKS_VL&KmncE{7L4hcq!u!<&cYkeK= z)_5s` zBp_(4wuNQS5{;>6a3=+RXQKh7X%=lH_@-sTNO(~#Svuaf@*oZjbV&Ek_S!fR|G8QhW&Bp|h+W2IpMPpphE_$ejW`p}h z!ouSf5m}@+HFonhMrhWb6L(nE9sv?QlPZWbX%HZKtd$RS;|S>Mu7Y|ao&P(Xi3Hih zgJ3(f`+|keg=%?Tkr1PeA;NTt9p`2gWD+u^dSM1*^F!--!75b+KPyO~)g>B{u@(%* z5T_H;cVt}&_Cp#N;PWDHY9s@L_N?`H^TGLCLNK2i9ecV;=h-Vp^TIl!DALlOCt$8i z{7)##QTVYRFBuqpSlk|5F z0KI3_xZpMglAu$m>)Ly_C}p7Qfd@&JuKIKV4~686 zd$U;$XgY~G2pV6vt>OH3hrI_>w+F7_Lk|h+PbM;49^8DgAS%~aT%dZ69ak&>mo=vj z2_tD0R?AjKm2m#6tVhVnfo{FAKcS3zgzKDEd4}1MS}zhblj3`#?QrDl-o$g3)WMr5 z$lFAFN@|^PXl)>fXG**IW`o6$ZIMcdWTQhb>c!t7YOmk<+NAn{x*AzdI5V*n6bf|-c-69L{At9Nr$1=7#o8-#5KcnOhpg_AA6tiCr1<{+rU

TD-eWE$*|X9h61tDog6 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 ZSO66fIy%JW+RF@^AI^XP00000008fkeAui8$xP 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 literal 0 HcmV?d00001 diff --git a/web/apps/admin/src/assets/images/login/lf_icon2.webp b/web/apps/admin/src/assets/images/login/lf_icon2.webp new file mode 100644 index 0000000000000000000000000000000000000000..5e4f3fd0dd461d4e92ab3222aa9f546e2c4ca2bd GIT binary patch literal 25016 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 Ef{6$HX8-^I literal 0 HcmV?d00001 diff --git a/web/apps/admin/src/assets/images/settings/menu_layouts/horizontal.png b/web/apps/admin/src/assets/images/settings/menu_layouts/horizontal.png new file mode 100644 index 0000000000000000000000000000000000000000..ca779bc78ad5bf45e4b26e4e75e6481295383369 GIT binary patch literal 409 zcmV;K0cQS*P) zvbn(=z`lWa1n(axjo^q~;4t&bS@^%KH`vFc7`p2Q0I=EJ`>gf&x{iCB)gIPy+H$?# zI&Rh@j<_p{ds>?h{E0Z?i2HPk&-@4Ch&w}E!NafkSzG;R2LRxg$z)yzam_;Ieh_DF zDlv-FwX;E-%i6gQ+qsC>eh{Sfe@zd^TDtroDA!A5hii^ozT^kui2GyQ`9Fk7vFhZSNmiXsv;oD2I1{Wo zId{pb(`jB}R@*LWXIIUn~YTl0Pqhc#kP}ccEGly z+kj$coC&s_oYRhWw4>dQcKbJ=%iasfw_w`=03i1PMtw+J%=}$J00000NkvXXu0mjf DJf+bZ literal 0 HcmV?d00001 diff --git a/web/apps/admin/src/assets/images/settings/menu_layouts/mixed.png b/web/apps/admin/src/assets/images/settings/menu_layouts/mixed.png new file mode 100644 index 0000000000000000000000000000000000000000..c82b58038fcbdab260ef4df85cd0bf53fe232ecf GIT binary patch literal 431 zcmV;g0Z{&lP)Yy$(XK&WqF=)bm5 z&;o+?zlB7C)K}fi`)JgA{>Jxlc!KXfgb6|jAx2Z&sveg*c(ShsoP#IVp>AiIo`88@ zr(tz#nx24pU$**en#2z4P8E+W)>TaYs*4?@TZnE@z)$QW={$9Pq+5t?5s4t(vUL8d zk>BD6MzrHvYjymk9q8Cd*CO3GT_5SB%UpMi1j=u9n@Hh+b*4E&dq@{atWMW0ODCOl z4eTM7=ZGc+P_FT6@YfY7)!;zX6{(>H2c9mqfCEieq?Y>qVa~er;Qc|m&m&qoqK^6@ zVBN$3Du2{%NO?+K9&mf!tI@d6TsrBbTaC_bh#7oGv|&;J<@f7bI_ad#=vP1p(n&`P zAWTV(SImLAJYY(!i%iy}>sF+bPPzt{A(q{UW(H7(@yY~TT`{;`%(2Y&+R=+kRYC}n ZKX+7z8kqwQRWJYm002ovPDHLkV1jqY&3ym> literal 0 HcmV?d00001 diff --git a/web/apps/admin/src/assets/images/settings/menu_layouts/vertical.png b/web/apps/admin/src/assets/images/settings/menu_layouts/vertical.png new file mode 100644 index 0000000000000000000000000000000000000000..16e942b0e9f3919eb199134692e32004544b280c GIT binary patch literal 439 zcmV;o0Z9IdP)(# zD2kaKe_n4u2iGeYpx=`+HyKd(2SbJL9}%}U{@8`CedQuyi89i?#7jp@$(zBsZj{qeqluEicf z*6Rj#SRgdoBZzCSqT|}D=pbE-bUD@21=xhdeC5x5a>aJJ;D*%S|aFn{?|8j z&_So{5lo1xtA1;g<9us`n+K#rx>Kg(1@+SDdjvb0&iU3z)8W<#y8VnK9qURScO3WW h%8Q3N0002Cig literal 0 HcmV?d00001 diff --git a/web/apps/admin/src/assets/images/settings/menu_styles/dark.png b/web/apps/admin/src/assets/images/settings/menu_styles/dark.png new file mode 100644 index 0000000000000000000000000000000000000000..e1653b7a657edd6e2e83b8b479bb1b4af4620e15 GIT binary patch literal 292 zcmeAS@N?(olHy`uVBq!ia0vp^cYt^j3p0qNwsRej;t%i%asBk^tGc?@&!0c-?cF|q z{-!dsl@Ta)!PCVtB;(%On+JK17;v}*%3nHAmJs>!0B^+Y`oPQsN=8b26YP2SNGmV+ z(<1Wuz(Ft&-I2P^pJ{r#^(-^z!+)>dS^035-TET1?9HMwt^CXP@rda`@oSCvV)pRD9`Ic+V>RN|cZ9-Q}41PonSK>R4CZA96Tw@1diy fo(~{Er;M1%fByXW@#Dw4 zckfEI`&$F0PIK_}Tq&4}k`{J&n3>JYDATM6ZXdH1D1|rwka( a+00#Z%j6q-R9HLER}7x6elF{r5}E*&a(=A< literal 0 HcmV?d00001 diff --git a/web/apps/admin/src/assets/images/settings/menu_styles/light.png b/web/apps/admin/src/assets/images/settings/menu_styles/light.png new file mode 100644 index 0000000000000000000000000000000000000000..3007b99ea227f5f203ad309d28358d95aa40aad2 GIT binary patch literal 293 zcmeAS@N?(olHy`uVBq!ia0vp^cYt^j3p0qNwsRej5(@AMasBk^>;M1%fByXW@#Dw4 zckhOVhNk`~V+6{c^K@|x$+-9SW+HE^0gvlLR`&CZGg~7o#6IM+GMaerh)K|XF7e~v z*2wU>xm~svV6cO!eAexM#~%n4hWb7dn;9*-?$zE?M-Ku)rRC!eF^lI+S&sX@aO&C)xGbBzUl*1H|~cH`T*7S|L8+xoqd^+pNbuX$T2rdEOtd33n=<}rH(r4Bw?X0 z19j9Zq$jx5yQq&=Rv*u@&uV zZv5wD2VJi=A#|57GsIK59E1=t&(Kn59-)r9I&_O8$G{R>i|A%Y2O}3yS41Z&E$H;i qY~iWsL1-c78CvShBh*n>SoaHFuY$So!<9_{0000$3b*O46wRy83EweL3E-p zTDPTC^?=rG?@;xC)#cYaJXSqGb<2^89-z9fBYo%rs(T&jp$Dk$Kk9+HS#|cPcrJon zbI(i6T+zM{&>wNHId#;{s7qL=Yd{@!)cq1mSEY~blI|(J>{oO}dReaMR_P^Q(MdlO z>i~~A@u&cbFmcTrMB15WsH1KUUF~df!d~+osw?&ewtya>4AqH=XPy0+o=3$lg6Eny zh_o}$P)FSyx+-_rpi9hVN3Lj_1$lsMGW5LKxX|5xOc$eay9h37-XPM>JVPCIbLeVE zkAX{UAEGNAI~aL@@(`VV>$4IoA-;gkJ&1+Cgm8pmlOdPn{^_RrAUFTa z?Nwrlv;qeX95`^`z<~n?4ji~TxhJFJb(>akZP2?200000NkvXXu0mjfdG+rO literal 0 HcmV?d00001 diff --git a/web/apps/admin/src/assets/images/svg/403.svg b/web/apps/admin/src/assets/images/svg/403.svg new file mode 100644 index 0000000..68790ad --- /dev/null +++ b/web/apps/admin/src/assets/images/svg/403.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/apps/admin/src/assets/images/svg/404.svg b/web/apps/admin/src/assets/images/svg/404.svg new file mode 100644 index 0000000..48e1ca3 --- /dev/null +++ b/web/apps/admin/src/assets/images/svg/404.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/apps/admin/src/assets/images/svg/500.svg b/web/apps/admin/src/assets/images/svg/500.svg new file mode 100644 index 0000000..512429f --- /dev/null +++ b/web/apps/admin/src/assets/images/svg/500.svg @@ -0,0 +1,5 @@ + diff --git a/web/apps/admin/src/assets/images/svg/login_icon.svg b/web/apps/admin/src/assets/images/svg/login_icon.svg new file mode 100644 index 0000000..4beb3ab --- /dev/null +++ b/web/apps/admin/src/assets/images/svg/login_icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/apps/admin/src/assets/images/user/avatar.webp b/web/apps/admin/src/assets/images/user/avatar.webp new file mode 100644 index 0000000000000000000000000000000000000000..6d7234b9eb1d4962efb9d01a4703fe4540d3ec34 GIT binary patch literal 2130 zcmV-Y2(9;0Nk&FW2mkB(|iQh=J6I zyxsF})jWZrx1zAfq`;31uOrtG^aslZ++-*oHbA=Ws=r@BH$nEcT>f}v8c<1MaH|2|TsFVAnjLEj8 z)v3~&1Tg$gPbv>1OlgM(i_=4U(n13#54;Dp%@xYc7c42<_;HI{&blw#e zNxIkz>nHOh3a&_eJ&|2SelyaGrVT5H-&xr3qchs0k*P(yrJZP27PwoGyrzP(HQNq) zdnp%e(8+0Kvd=#P@!j@e@d-;H6ynU*1u2;INSHcM)CHM>4FQx&W>lUOt}HM!x%Fka z%X5GL{_uBtT#(I|h-+J>-VdN=0k`87mNLbrC!e0hz)eum&xzb_(qj;)6C_`RprQp+gHMV1x!s=;2Rb%2PsYfg* zh??Ve&~jKa^pwSDNm#|Es3PLmb7sTuZ(x2P^RA`Y&UPQ||EZaGfbNJ@(YlYt9%HYh zZd6LrWC~-IbvBzuEVN?AAJR!ig~P0-MlXvR7Z7bx7?0-n<*4=SW=S5*A~>8h3`T5! z`I{Rw5T_p0VkFU17Z&8dG9Tw3V-0ioX90$ba-jE=Z$T+Ka@XHj^&Ah5&3^#B4xzV6Zt7h5hW%IMfcreWr(GyA?@Q(v8ZjmLibkv4Aj2n8A)< z=`*~B`m=?dsA{+ox>qZp7zsL(cLHfKRXL9|zDz?~SSeGwu!UZyhdt^PsU~45yW9Oj z|7T$>K9wSeG)6;}llOZ5%}(}z0=hks13|B$4L31VdOF*sd@YU?jiH_PWPK9UZtNa*gq3V&9l+*yS!e&SOs`1NHt-i^1q)x3)% z>It^qg|*?RqvUXDdRScQn|t^Lu)KXY5VsWF%#?Kcx(iSN8x!^6ce+4JmX87y6y?wZZ4&b%iwh!QfdXkYoDFzcx@r36&& zo2APee^J><5DkZ2e=>DFvmAxNih)1iwKOrXu;mfGI%PS5k!rJn1B3wHU(p$^yBEHx zq~i|&7X}r0s20A5acrQR9#hvK2Xx=6c*tFM@J9UuD8ohc`RLDsu}deGD`*`c;m8QS zgCUifRD^>%<4S8#^2TMADqw+gjHZ>sulGW1(}gDKwF2hjvQB>n-jtmz#9@~4F*^x@%R}e{Y2&KCz?duu^aa>y%iPm%1zqnPR{G?Y4 z48C*_3ipwVb2oo`j1WM8AYDT&ioo^DN+XA-8Nq=KolVDJ>^(q9Nykz^AO8@jU-TDL8=@Yp# z`0V^~J;sb-xt;21tD-A@Q!b4`@x|E>D83vGJA5%XgyFaLGshP4j^0BP@nJi;8QPq} zZasCOfyHw%T^l_D156Rxq9l?k^_qF>+c(_`j%kBrqB>?FPa!(O&K%uAjw|3vex zg~6iGGvYe?pO(JYfzZ{%9l2^x{GmywWC-Ereh^T;KWV6K)&<>!={pX|l+g~?h}`RK z0g*I#Xa`GBWma{trr5d?fk)++ZmoO}fx6-Yk^Ncr>2Umkn-MFU(O?_Y9peO6vtnRF z$QVFE%8MY|Xohq7+4+t3Y-1=b`+7f0p6}a*tiScRyO=V+;Q3*I3IM{IKduC`j8lKO I28@6J0QOxWo&W#< literal 0 HcmV?d00001 diff --git a/web/apps/admin/src/assets/images/user/bg.webp b/web/apps/admin/src/assets/images/user/bg.webp new file mode 100644 index 0000000000000000000000000000000000000000..762b22dbbe34305886a78b1d103dddcc61d106ec GIT binary patch literal 12352 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 literal 0 HcmV?d00001 diff --git a/web/apps/admin/src/assets/styles/core/app.scss b/web/apps/admin/src/assets/styles/core/app.scss new file mode 100755 index 0000000..c0efeed --- /dev/null +++ b/web/apps/admin/src/assets/styles/core/app.scss @@ -0,0 +1,292 @@ +// 全局样式 +// 顶部进度条颜色 +#nprogress .bar { + z-index: 2400; + background-color: color-mix(in srgb, var(--theme-color) 70%, white); +} + +#nprogress .peg { + box-shadow: + 0 0 10px var(--theme-color), + 0 0 5px var(--theme-color) !important; +} + +#nprogress .spinner-icon { + border-top-color: var(--theme-color) !important; + border-left-color: var(--theme-color) !important; +} + +// 处理移动端组件兼容性 +@media screen and (max-width: 640px) { + * { + cursor: default !important; + } +} + +// 背景滤镜 +*, +::before, +::after { + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; +} + +// 色弱模式 +.color-weak { + filter: invert(80%); + -webkit-filter: invert(80%); +} + +#noop { + display: none; +} + +// 语言切换选中样式 +.langDropDownStyle { + // 选中项背景颜色 + .is-selected { + background-color: var(--art-el-active-color) !important; + } + + // 语言切换按钮菜单样式优化 + .lang-btn-item { + .el-dropdown-menu__item { + padding-left: 13px !important; + padding-right: 6px !important; + margin-bottom: 3px !important; + } + + &:last-child { + .el-dropdown-menu__item { + margin-bottom: 0 !important; + } + } + + .menu-txt { + min-width: 60px; + display: block; + } + + i { + font-size: 10px; + margin-left: 10px; + } + } +} + +// 盒子默认边框 +.page-content { + border: 1px solid var(--art-card-border) !important; +} + +@mixin art-card-base($border-color, $shadow: none, $radius-diff: 4px) { + background: var(--default-box-color); + border: 1px solid #{$border-color} !important; + border-radius: calc(var(--custom-radius) + #{$radius-diff}) !important; + box-shadow: #{$shadow} !important; + + --el-card-border-color: var(--default-border) !important; +} + +.art-card, +.art-card-sm, +.art-card-xs { + border: 1px solid var(--art-card-border); +} + +// 盒子边框 +[data-box-mode='border-mode'] { + .page-content, + .art-table-card { + border: 1px solid var(--art-card-border) !important; + } + + .art-card { + @include art-card-base(var(--art-card-border), none, 4px); + } + + .art-card-sm { + @include art-card-base(var(--art-card-border), none, 0px); + } + + .art-card-xs { + @include art-card-base(var(--art-card-border), none, -4px); + } +} + +// 盒子阴影 +[data-box-mode='shadow-mode'] { + .page-content, + .art-table-card { + box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.04) !important; + border: 1px solid var(--art-gray-200) !important; + } + + .layout-sidebar { + border-right: 1px solid var(--art-card-border) !important; + } + + .art-card { + @include art-card-base( + var(--art-gray-200), + (0 1px 3px 0 rgba(0, 0, 0, 0.03), 0 1px 2px -1px rgba(0, 0, 0, 0.08)), + 4px + ); + } + + .art-card-sm { + @include art-card-base( + var(--art-gray-200), + (0 1px 3px 0 rgba(0, 0, 0, 0.03), 0 1px 2px -1px rgba(0, 0, 0, 0.08)), + 2px + ); + } + + .art-card-xs { + @include art-card-base( + var(--art-gray-200), + (0 1px 2px 0 rgba(0, 0, 0, 0.03), 0 1px 1px -1px rgba(0, 0, 0, 0.08)), + -4px + ); + } +} + +// 元素全屏 +.el-full-screen { + position: fixed; + top: 0; + left: 0; + right: 0; + width: 100vw !important; + height: 100% !important; + z-index: 2300; + margin-top: 0; + padding: 15px; + box-sizing: border-box; + background-color: var(--default-box-color); + display: flex; + flex-direction: column; +} + +// 表格卡片 +.art-table-card { + flex: 1; + display: flex; + flex-direction: column; + margin-top: 12px; + border-radius: calc(var(--custom-radius) / 2 + 2px) !important; + + .el-card__body { + height: 100%; + overflow: hidden; + } +} + +// 容器全高 +.art-full-height { + height: var(--art-full-height); + display: flex; + flex-direction: column; + + @media (max-width: 640px) { + height: auto; + } +} + +// 徽章样式 +.art-badge { + position: absolute; + top: 0; + right: 20px; + bottom: 0; + width: 6px; + height: 6px; + margin: auto; + background: #ff3860; + border-radius: 50%; + animation: breathe 1.5s ease-in-out infinite; + + &.art-badge-horizontal { + right: 0; + } + + &.art-badge-mixed { + right: 0; + } + + &.art-badge-dual { + right: 5px; + top: 5px; + bottom: auto; + } +} + +// 文字徽章样式 +.art-text-badge { + position: absolute; + top: 0; + right: 12px; + bottom: 0; + min-width: 20px; + height: 18px; + line-height: 17px; + padding: 0 5px; + margin: auto; + font-size: 10px; + color: #fff; + text-align: center; + background: #fd4e4e; + border-radius: 4px; +} + +@keyframes breathe { + 0% { + opacity: 0.7; + transform: scale(1); + } + + 50% { + opacity: 1; + transform: scale(1.1); + } + + 100% { + opacity: 0.7; + transform: scale(1); + } +} + +// 修复老机型 loading 定位问题 +.art-loading-fix { + position: fixed !important; + top: 0 !important; + left: 0 !important; + right: 0 !important; + bottom: 0 !important; + width: 100vw !important; + height: 100vh !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; +} + +.art-loading-fix .el-loading-spinner { + position: static !important; + top: auto !important; + left: auto !important; + transform: none !important; +} + +// 去除移动端点击背景色 +@media screen and (max-width: 1180px) { + * { + -webkit-tap-highlight-color: transparent; + } +} diff --git a/web/apps/admin/src/assets/styles/core/dark.scss b/web/apps/admin/src/assets/styles/core/dark.scss new file mode 100755 index 0000000..c52abc3 --- /dev/null +++ b/web/apps/admin/src/assets/styles/core/dark.scss @@ -0,0 +1,93 @@ +/* +* 深色主题 +* 单页面移除深色主题 document.getElementsByTagName("html")[0].removeAttribute('class') +*/ + +$font-color: rgba(#ffffff, 0.85); + +/* 覆盖element-plus默认深色背景色 */ +html.dark { + // element-plus + --el-bg-color: var(--default-box-color); + --el-text-color-regular: #{$font-color}; + + // 富文本编辑器 + // 工具栏背景颜色 + --w-e-toolbar-bg-color: #18191c; + // 输入区域背景颜色 + --w-e-textarea-bg-color: #090909; + // 工具栏文字颜色 + --w-e-toolbar-color: var(--art-gray-600); + // 选中菜单颜色 + --w-e-toolbar-active-bg-color: #25262b; + // 弹窗边框颜色 + --w-e-toolbar-border-color: var(--default-border-dashed); + // 分割线颜色 + --w-e-textarea-border-color: var(--default-border-dashed); + // 链接输入框边框颜色 + --w-e-modal-button-border-color: var(--default-border-dashed); + // 表格头颜色 + --w-e-textarea-slight-bg-color: #090909; + // 按钮背景颜色 + --w-e-modal-button-bg-color: #090909; + // hover toolbar 背景颜色 + --w-e-toolbar-active-color: var(--art-gray-800); +} + +.dark { + .page-content .article-list .item .left .outer > div { + border-right-color: var(--dark-border-color) !important; + } + + // 富文本编辑器 + .editor-wrapper { + *:not(pre code *) { + color: inherit !important; + } + } + // 分隔线 + .w-e-bar-divider { + background-color: var(--art-gray-300) !important; + } + + .w-e-select-list, + .w-e-drop-panel, + .w-e-bar-item-group .w-e-bar-item-menus-container, + .w-e-text-container [data-slate-editor] pre > code { + border: 1px solid var(--default-border) !important; + } + + // 下拉选择框 + .w-e-select-list { + background-color: var(--default-box-color) !important; + } + + /* 下拉选择框 hover 样式调整 */ + .w-e-select-list ul li:hover, + /* 工具栏 hover 按钮背景颜色 */ + .w-e-bar-item button:hover { + background-color: #090909 !important; + } + + /* 代码块 */ + .w-e-text-container [data-slate-editor] pre > code { + background-color: #25262b !important; + text-shadow: none !important; + } + + /* 引用 */ + .w-e-text-container [data-slate-editor] blockquote { + border-left: 4px solid var(--default-border-dashed) !important; + background-color: var(--art-color); + } + + .editor-wrapper { + .w-e-text-container [data-slate-editor] .table-container th:last-of-type { + border-right: 1px solid var(--default-border-dashed) !important; + } + + .w-e-modal { + background-color: var(--art-color); + } + } +} diff --git a/web/apps/admin/src/assets/styles/core/el-dark.scss b/web/apps/admin/src/assets/styles/core/el-dark.scss new file mode 100644 index 0000000..8f81cdf --- /dev/null +++ b/web/apps/admin/src/assets/styles/core/el-dark.scss @@ -0,0 +1,2 @@ +// 导入暗黑主题 +@use 'element-plus/theme-chalk/src/dark/css-vars.scss' as *; diff --git a/web/apps/admin/src/assets/styles/core/el-light.scss b/web/apps/admin/src/assets/styles/core/el-light.scss new file mode 100644 index 0000000..ddf2bc5 --- /dev/null +++ b/web/apps/admin/src/assets/styles/core/el-light.scss @@ -0,0 +1,34 @@ +// https://github.com/element-plus/element-plus/blob/dev/packages/theme-chalk/src/common/var.scss +// 自定义Element 亮色主题 + +@forward 'element-plus/theme-chalk/src/common/var.scss' with ( + $colors: ( + 'white': #ffffff, + 'black': #000000, + 'success': ( + 'base': #13deb9 + ), + 'warning': ( + 'base': #ffae1f + ), + 'danger': ( + 'base': #ff4d4f + ), + 'error': ( + 'base': #fa896b + ) + ), + $button: ( + 'hover-bg-color': var(--el-color-primary-light-9), + 'hover-border-color': var(--el-color-primary), + 'border-color': var(--el-color-primary), + 'text-color': var(--el-color-primary) + ), + $messagebox: ( + 'border-radius': '12px' + ), + $popover: ( + 'padding': '14px', + 'border-radius': '10px' + ) +); diff --git a/web/apps/admin/src/assets/styles/core/el-ui.scss b/web/apps/admin/src/assets/styles/core/el-ui.scss new file mode 100644 index 0000000..44429a1 --- /dev/null +++ b/web/apps/admin/src/assets/styles/core/el-ui.scss @@ -0,0 +1,519 @@ +// 优化 Element Plus 组件库默认样式 + +:root { + // 系统主色 + --main-color: var(--el-color-primary); + --el-color-white: white !important; + --el-color-black: white !important; + // 输入框边框颜色 + // --el-border-color: #E4E4E7 !important; // DCDFE6 + // 按钮粗度 + --el-font-weight-primary: 400 !important; + + --el-component-custom-height: 36px !important; + + --el-component-size: var(--el-component-custom-height) !important; + + // 边框、按钮圆角... + --el-border-radius-base: calc(var(--custom-radius) / 3 + 2px) !important; + + --el-border-radius-small: calc(var(--custom-radius) / 3 + 4px) !important; + --el-messagebox-border-radius: calc(var(--custom-radius) / 3 + 4px) !important; + --el-popover-border-radius: calc(var(--custom-radius) / 3 + 4px) !important; + + .region .el-radio-button__original-radio:checked + .el-radio-button__inner { + color: var(--theme-color); + } +} + +// 优化 el-form-item 标签高度 +.el-form-item__label { + height: var(--el-component-custom-height) !important; + line-height: var(--el-component-custom-height) !important; +} + +// 日期选择器 +.el-date-range-picker { + --el-datepicker-inrange-bg-color: var(--art-gray-200) !important; +} + +// el-card 背景色跟系统背景色保持一致 +html.dark .el-card { + --el-card-bg-color: var(--default-box-color) !important; +} + +// 修改 el-pagination 大小 +.el-pagination--default { + & { + --el-pagination-button-width: 32px !important; + --el-pagination-button-height: var(--el-pagination-button-width) !important; + } + + @media (max-width: 1180px) { + & { + --el-pagination-button-width: 28px !important; + } + } + + .el-select--default .el-select__wrapper { + min-height: var(--el-pagination-button-width) !important; + } + + .el-pagination__jump .el-input { + height: var(--el-pagination-button-width) !important; + } +} + +.el-pager li { + padding: 0 10px !important; + // border: 1px solid red !important; +} + +// 优化菜单折叠展开动画(提升动画流畅度) +.el-menu.el-menu--inline { + transition: max-height 0.26s cubic-bezier(0.4, 0, 0.2, 1) !important; +} + +// 优化菜单 item hover 动画(提升鼠标跟手感) +.el-sub-menu__title, +.el-menu-item { + transition: background-color 0s !important; +} + +// -------------------------------- 修改 el-size=default 组件默认高度 start -------------------------------- +// 修改 el-button 高度 +.el-button--default { + height: var(--el-component-custom-height) !important; +} + +// circle 按钮宽度优化 +.el-button--default.is-circle { + width: var(--el-component-custom-height) !important; +} + +// 修改 el-select 高度 +.el-select--default { + .el-select__wrapper { + min-height: var(--el-component-custom-height) !important; + } +} + +// 修改 el-checkbox-button 高度 +.el-checkbox-button--default .el-checkbox-button__inner, +// 修改 el-radio-button 高度 +.el-radio-button--default .el-radio-button__inner { + padding: 10px 15px !important; +} +// -------------------------------- 修改 el-size=default 组件默认高度 end -------------------------------- + +.el-pagination.is-background .btn-next, +.el-pagination.is-background .btn-prev, +.el-pagination.is-background .el-pager li { + border-radius: 6px; +} + +.el-popover { + min-width: 80px; + border-radius: var(--el-border-radius-small) !important; +} + +.el-dialog { + border-radius: 100px !important; + border-radius: calc(var(--custom-radius) / 1.2 + 2px) !important; + overflow: hidden; +} + +.el-dialog__header { + .el-dialog__title { + font-size: 16px; + } +} + +.el-dialog__body { + padding: 25px 0 !important; + position: relative; // 为了兼容 el-pagination 样式,需要设置 relative,不然会影响 el-pagination 的样式,比如 el-pagination__jump--small 会被影响,导致 el-pagination__jump--small 按钮无法点击,详见 URL_ADDRESS.com/element-plus/element-plus/issues/5684#issuecomment-1176299275; +} + +.el-dialog.el-dialog-border { + .el-dialog__body { + // 上边框 + &::before, + // 下边框 + &::after { + content: ''; + position: absolute; + left: -16px; + width: calc(100% + 32px); + height: 1px; + background-color: var(--art-gray-300); + } + + &::before { + top: 0; + } + + &::after { + bottom: 0; + } + } +} + +// el-message 样式优化 +.el-message { + background-color: var(--default-box-color) !important; + border: 0 !important; + box-shadow: + 0 6px 16px 0 rgba(0, 0, 0, 0.08), + 0 3px 6px -4px rgba(0, 0, 0, 0.12), + 0 9px 28px 8px rgba(0, 0, 0, 0.05) !important; + + p { + font-size: 13px; + } +} + +// 修改 el-dropdown 样式 +.el-dropdown-menu { + padding: 6px !important; + border-radius: 10px !important; + border: none !important; + + .el-dropdown-menu__item { + padding: 6px 16px !important; + border-radius: 6px !important; + + &:hover:not(.is-disabled) { + color: var(--art-gray-900) !important; + background-color: var(--art-el-active-color) !important; + } + + &:focus:not(.is-disabled) { + color: var(--art-gray-900) !important; + background-color: var(--art-gray-200) !important; + } + } +} + +// 隐藏 select、dropdown 的三角 +.el-select__popper, +.el-dropdown__popper { + margin-top: -6px !important; + + .el-popper__arrow { + display: none; + } +} + +.el-dropdown-selfdefine:focus { + outline: none !important; +} + +// 处理移动端组件兼容性 +@media screen and (max-width: 640px) { + .el-message-box, + .el-dialog { + width: calc(100% - 24px) !important; + } + + .el-date-picker.has-sidebar.has-time { + width: calc(100% - 24px); + left: 12px !important; + } + + .el-picker-panel *[slot='sidebar'], + .el-picker-panel__sidebar { + display: none; + } + + .el-picker-panel *[slot='sidebar'] + .el-picker-panel__body, + .el-picker-panel__sidebar + .el-picker-panel__body { + margin-left: 0; + } +} + +// 修改el-button样式 +.el-button { + &.el-button--text { + background-color: transparent !important; + padding: 0 !important; + + span { + margin-left: 0 !important; + } + } +} + +// 修改el-tag样式 +.el-tag { + font-weight: 500; + transition: all 0s !important; + + &.el-tag--default { + height: 26px !important; + } +} + +.el-checkbox-group { + &.el-table-filter__checkbox-group label.el-checkbox { + height: 17px !important; + + .el-checkbox__label { + font-weight: 400 !important; + } + } +} + +.el-radio--default { + // 优化单选按钮大小 + .el-radio__input { + .el-radio__inner { + width: 16px; + height: 16px; + + &::after { + width: 6px; + height: 6px; + } + } + } +} + +.el-checkbox { + .el-checkbox__inner { + border-radius: 2px !important; + } +} + +// 优化复选框样式 +.el-checkbox--default { + .el-checkbox__inner { + width: 16px !important; + height: 16px !important; + border-radius: 4px !important; + + &::before { + content: ''; + height: 4px !important; + top: 5px !important; + background-color: #fff !important; + transform: scale(0.6) !important; + } + } + + .is-checked { + .el-checkbox__inner { + &::after { + width: 3px; + height: 8px; + margin: auto; + border: 2px solid var(--el-checkbox-checked-icon-color); + border-left: 0; + border-top: 0; + transform: translate(-45%, -60%) rotate(45deg) scale(0.86) !important; + transform-origin: center; + } + } + } +} + +.el-notification .el-notification__icon { + font-size: 22px !important; +} + +// 修改 el-message-box 样式 +.el-message-box__headerbtn .el-message-box__close, +.el-dialog__headerbtn .el-dialog__close { + top: 7px; + right: 7px; + width: 30px; + height: 30px; + border-radius: 5px; + transition: all 0.3s; + + &:hover { + background-color: var(--art-hover-color) !important; + color: var(--art-gray-900) !important; + } +} + +.el-message-box { + padding: 25px 20px !important; +} + +.el-message-box__title { + font-weight: 500 !important; +} + +.el-table__column-filter-trigger i { + color: var(--theme-color) !important; + margin: -3px 0 0 2px; +} + +// 去除 el-dropdown 鼠标放上去出现的边框 +.el-tooltip__trigger:focus-visible { + outline: unset; +} + +// ipad 表单右侧按钮优化 +@media screen and (max-width: 1180px) { + .el-table-fixed-column--right { + padding-right: 0 !important; + } +} + +.login-out-dialog { + padding: 30px 20px !important; + border-radius: 10px !important; +} + +// 修改 dialog 动画 +.dialog-fade-enter-active { + .el-dialog:not(.is-draggable) { + animation: dialog-open 0.3s cubic-bezier(0.32, 0.14, 0.15, 0.86); + + // 修复 el-dialog 动画后宽度不自适应问题 + .el-select__selected-item { + display: inline-block; + } + } +} + +.dialog-fade-leave-active { + animation: fade-out 0.2s linear; + + .el-dialog:not(.is-draggable) { + animation: dialog-close 0.5s; + } +} + +@keyframes dialog-open { + 0% { + opacity: 0; + transform: scale(0.2); + } + + 100% { + opacity: 1; + transform: scale(1); + } +} + +@keyframes dialog-close { + 0% { + opacity: 1; + transform: scale(1); + } + + 100% { + opacity: 0; + transform: scale(0.2); + } +} + +// 遮罩层动画 +@keyframes fade-out { + 0% { + opacity: 1; + } + + 100% { + opacity: 0; + } +} + +// 修改 el-select 样式 +.el-select__popper:not(.el-tree-select__popper) { + .el-select-dropdown__list { + padding: 5px !important; + + .el-select-dropdown__item { + height: 34px !important; + line-height: 34px !important; + border-radius: 6px !important; + + &.is-selected { + color: var(--art-gray-900) !important; + font-weight: 400 !important; + background-color: var(--art-el-active-color) !important; + margin-bottom: 4px !important; + } + + &:hover { + background-color: var(--art-hover-color) !important; + } + } + + .el-select-dropdown__item:hover ~ .is-selected, + .el-select-dropdown__item.is-selected:has(~ .el-select-dropdown__item:hover) { + background-color: transparent !important; + } + } +} + +// 修改 el-tree-select 样式 +.el-tree-select__popper { + .el-select-dropdown__list { + padding: 5px !important; + + .el-tree-node { + .el-tree-node__content { + height: 36px !important; + border-radius: 6px !important; + + &:hover { + background-color: var(--art-gray-200) !important; + } + } + } + } +} + +// 实现水波纹在文字下面效果 +.el-button > span { + position: relative; + z-index: 10; +} + +// 优化颜色选择器圆角 +.el-color-picker__color { + border-radius: 2px !important; +} + +// 优化日期时间选择器底部圆角 +.el-picker-panel { + .el-picker-panel__footer { + border-radius: 0 0 var(--el-border-radius-base) var(--el-border-radius-base); + } +} + +// 优化树型菜单样式 +.el-tree-node__content { + border-radius: 4px; + margin-bottom: 4px; + padding: 1px 0; + + &:hover { + background-color: var(--art-hover-color) !important; + } +} + +.dark { + .el-tree--highlight-current .el-tree-node.is-current > .el-tree-node__content { + background-color: var(--art-gray-300) !important; + } +} + +// 隐藏折叠菜单弹窗 hover 出现的边框 +.menu-left-popper:focus-within, +.horizontal-menu-popper:focus-within { + box-shadow: none !important; + outline: none !important; +} + +// 数字输入组件右侧按钮高度跟随自定义组件高度 +.el-input-number--default.is-controls-right { + .el-input-number__decrease, + .el-input-number__increase { + height: calc((var(--el-component-size) / 2)) !important; + } +} diff --git a/web/apps/admin/src/assets/styles/core/md.scss b/web/apps/admin/src/assets/styles/core/md.scss new file mode 100644 index 0000000..b22fdc2 --- /dev/null +++ b/web/apps/admin/src/assets/styles/core/md.scss @@ -0,0 +1,1036 @@ +/* 文章标题设置(h1-h6)*/ +/* ------------------------------------------------ */ +$font-color: #24292e; + +.markdown-body h1, +.markdown-body h2, +.markdown-body h3, +.markdown-body h4, +.markdown-body h5, +.markdown-body h6 { + color: var(--art-gray-800) !important; + margin: 30px 0 10px 0; + font-weight: 600; +} + +.markdown-body h1 { + font-size: 30px; +} + +@media only screen and (max-width: 550px) { + .markdown-body h1 { + font-size: 26px; + } + + .markdown-body h2 { + font-size: 22px; + } + + .markdown-body h3 { + font-size: 18px; + } +} + +/* 块引用 */ +/* ------------------------------------------------ */ +.markdown-body blockquote { + color: rgba(60, 60, 67, 0.7); + font-size: 15px !important; + border-left: 0.18em solid #e7e7e8; + background: #f8f8f8; + padding: 15px 1em; + font-weight: 400 !important; +} + +/* 详情页文章字体颜色 */ +/* ------------------------------------------------ */ +.markdown-body p { + line-height: 28px; + margin-bottom: 10px; +} + +.markdown-body li, +.markdown-body p { + color: var(--art-gray-800) !important; + font-size: 16px !important; +} + +.dark .markdown-body li span { + color: var(--art-gray-800) !important; + background-color: transparent !important; +} + +.dark .markdown-body p span { + color: var(--art-gray-800) !important; + background-color: transparent !important; +} + +.line-numbers-mode { + background-color: var(--art-code-bg); + border-radius: 8px; + position: relative; + padding-left: 32px; + box-sizing: border-box; +} + +.line-numbers-mode pre { + flex: 1; + border-radius: 0 8px 8px 0; + background-color: var(--art-code-bg); +} + +.line-numbers-mode .line-numbers-wrapper { + width: 32px; + height: 100%; + text-align: center; + padding: 16px 0; + box-sizing: border-box; + border-right: 1px solid #000000; + position: absolute; + left: 0; + top: 0; +} + +.line-numbers-mode .line-numbers-wrapper span { + height: 23.6px; + line-height: 23.6px; + display: block; + color: #72747b; + font-size: 13px; + box-sizing: border-box; +} + +.line-numbers-mode .copy-btn { + display: inline-block; + display: flex; + position: absolute; + right: 10px; + top: 10px; + cursor: pointer; + opacity: 0; + background-color: #000; + border-radius: 5px; + text-align: center; + color: rgba(255, 255, 255, 0.6); + transition: opacity 0.3s; +} + +.line-numbers-mode .copy-btn div { + width: 34px; + height: 34px; + line-height: 34px; + cursor: pointer; + text-align: center; + font-size: 20px; +} + +.line-numbers-mode:hover .copy-btn { + opacity: 1; +} + +.line-numbers-mode .copy-btn span { + height: 34px; + line-height: 34px; + font-size: 13px; + padding-left: 10px; + display: none; +} + +.line-numbers-mode .copy-btn .show-copy { + opacity: 1; + display: block; +} + +.line-numbers-mode ::-webkit-scrollbar-track { + background-color: #292b30 !important; +} + +.markdown-body .anchor { + float: left; + line-height: 1; + margin-left: -20px; + padding-right: 4px; +} + +.markdown-body .anchor:focus { + outline: none; +} + +.markdown-body h1 .octicon-link, +.markdown-body h2 .octicon-link, +.markdown-body h3 .octicon-link, +.markdown-body h4 .octicon-link, +.markdown-body h5 .octicon-link, +.markdown-body h6 .octicon-link { + color: #1b1f23; + vertical-align: middle; + visibility: hidden; +} + +.markdown-body h1:hover .anchor, +.markdown-body h2:hover .anchor, +.markdown-body h3:hover .anchor, +.markdown-body h4:hover .anchor, +.markdown-body h5:hover .anchor, +.markdown-body h6:hover .anchor { + text-decoration: none; +} + +.markdown-body h1:hover .anchor .octicon-link, +.markdown-body h2:hover .anchor .octicon-link, +.markdown-body h3:hover .anchor .octicon-link, +.markdown-body h4:hover .anchor .octicon-link, +.markdown-body h5:hover .anchor .octicon-link, +.markdown-body h6:hover .anchor .octicon-link { + visibility: visible; +} + +.markdown-body h1:hover .anchor .octicon-link:before, +.markdown-body h2:hover .anchor .octicon-link:before, +.markdown-body h3:hover .anchor .octicon-link:before, +.markdown-body h4:hover .anchor .octicon-link:before, +.markdown-body h5:hover .anchor .octicon-link:before, +.markdown-body h6:hover .anchor .octicon-link:before { + width: 16px; + height: 16px; + content: ' '; + display: inline-block; +} + +.markdown-body { + -ms-text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; + line-height: 1.5; + color: $font-color; + font-size: 16px; + line-height: 1.5; + word-wrap: break-word; +} + +.markdown-body details { + display: block; +} + +.markdown-body summary { + display: list-item; +} + +.markdown-body a { + background-color: initial; +} + +.markdown-body a:active, +.markdown-body a:hover { + outline-width: 0; +} + +.markdown-body strong { + font-weight: inherit; + font-weight: bolder; +} + +.markdown-body p br { + display: inline; + line-height: 11px; +} + +.markdown-body img { + border-style: none; +} + +.markdown-body hr { + box-sizing: initial; + height: 0; + overflow: visible; +} + +.markdown-body input { + font: inherit; + margin: 0; +} + +.markdown-body input { + overflow: visible; +} + +.markdown-body [type='checkbox'] { + box-sizing: border-box; + padding: 0; +} + +.markdown-body * { + box-sizing: border-box; +} + +.markdown-body input { + font-size: inherit; + line-height: inherit; +} + +.markdown-body a { + color: #0366d6; + text-decoration: none; +} + +.markdown-body a:hover { + text-decoration: underline; +} + +.markdown-body strong { + font-weight: 600; +} + +.markdown-body hr { + height: 0; + margin: 15px 0; + overflow: hidden; + background: transparent; + border: 0; + border-bottom: 1px solid #dfe2e5; +} + +.markdown-body hr:after, +.markdown-body hr:before { + display: table; + content: ''; +} + +.markdown-body hr:after { + clear: both; +} + +.markdown-body table { + border-spacing: 0; + border-collapse: collapse; +} + +.markdown-body td, +.markdown-body th { + padding: 0; +} + +.markdown-body details summary { + cursor: pointer; +} + +.markdown-body kbd { + display: inline-block; + padding: 3px 5px; + font: + 11px SFMono-Regular, + Consolas, + Liberation Mono, + Menlo, + monospace; + line-height: 10px; + color: #444d56; + vertical-align: middle; + background-color: #fafbfc; + border: 1px solid #d1d5da; + border-radius: 3px; + box-shadow: inset 0 -1px 0 #d1d5da; +} + +.markdown-body blockquote { + margin: 0; +} + +.markdown-body ol, +.markdown-body ul { + padding-left: 0; + margin-top: 0; + margin-bottom: 0; +} + +.markdown-body ol ol, +.markdown-body ul ol { + list-style-type: lower-roman; +} + +.markdown-body ol ol ol, +.markdown-body ol ul ol, +.markdown-body ul ol ol, +.markdown-body ul ul ol { + list-style-type: lower-alpha; +} + +.markdown-body dd { + margin-left: 0; +} + +.markdown-body code, +.markdown-body pre, +.markdown-body .line-number { + font-size: 14px !important; + border-radius: 8px; + background-color: #282c34; +} + +.dark { + .markdown-body code, + .markdown-body pre, + .markdown-body .line-number { + background-color: #252525; + } +} + +.markdown-body pre { + margin-top: 0; + margin-bottom: 0; +} + +.markdown-body input::-webkit-inner-spin-button, +.markdown-body input::-webkit-outer-spin-button { + margin: 0; + -webkit-appearance: none; + appearance: none; +} + +.markdown-body :checked + .radio-label { + position: relative; + z-index: 1; + border-color: #0366d6; +} + +.markdown-body .border { + border: 1px solid #e1e4e8 !important; +} + +.markdown-body .border-0 { + border: 0 !important; +} + +.markdown-body .border-bottom { + border-bottom: 1px solid #e1e4e8 !important; +} + +.markdown-body .rounded-1 { + border-radius: 3px !important; +} + +.markdown-body .bg-white { + background-color: #fff !important; +} + +.markdown-body .bg-gray-light { + background-color: #fafbfc !important; +} + +.markdown-body .text-gray-light { + color: #6a737d !important; +} + +.markdown-body .mb-0 { + margin-bottom: 0 !important; +} + +.markdown-body .my-2 { + margin-top: 8px !important; + margin-bottom: 8px !important; +} + +.markdown-body .pl-0 { + padding-left: 0 !important; +} + +.markdown-body .py-0 { + padding-top: 0 !important; + padding-bottom: 0 !important; +} + +.markdown-body .pl-1 { + padding-left: 4px !important; +} + +.markdown-body .pl-2 { + padding-left: 8px !important; +} + +.markdown-body .py-2 { + padding-top: 8px !important; + padding-bottom: 8px !important; +} + +.markdown-body .pl-3, +.markdown-body .px-3 { + padding-left: 16px !important; +} + +.markdown-body .px-3 { + padding-right: 16px !important; +} + +.markdown-body .pl-4 { + padding-left: 24px !important; +} + +.markdown-body .pl-5 { + padding-left: 32px !important; +} + +.markdown-body .pl-6 { + padding-left: 40px !important; +} + +.markdown-body .f6 { + font-size: 12px !important; +} + +.markdown-body .lh-condensed { + line-height: 1.25 !important; +} + +.markdown-body .text-bold { + font-weight: 600 !important; +} + +.markdown-body .pl-c { + color: #6a737d; +} + +.markdown-body .pl-c1, +.markdown-body .pl-s .pl-v { + color: #005cc5; +} + +.markdown-body .pl-e, +.markdown-body .pl-en { + color: #6f42c1; +} + +.markdown-body .pl-s .pl-s1, +.markdown-body .pl-smi { + color: $font-color; +} + +.markdown-body .pl-ent { + color: #22863a; +} + +.markdown-body .pl-k { + color: #d73a49; +} + +.markdown-body .pl-pds, +.markdown-body .pl-s, +.markdown-body .pl-s .pl-pse .pl-s1, +.markdown-body .pl-sr, +.markdown-body .pl-sr .pl-cce, +.markdown-body .pl-sr .pl-sra, +.markdown-body .pl-sr .pl-sre { + color: #032f62; +} + +.markdown-body .pl-smw, +.markdown-body .pl-v { + color: #e36209; +} + +.markdown-body .pl-bu { + color: #b31d28; +} + +.markdown-body .pl-ii { + color: #fafbfc; + background-color: #b31d28; +} + +.markdown-body .pl-c2 { + color: #fafbfc; + background-color: #d73a49; +} + +.markdown-body .pl-c2:before { + content: '^M'; +} + +.markdown-body .pl-sr .pl-cce { + font-weight: 700; + color: #22863a; +} + +.markdown-body .pl-ml { + color: #735c0f; +} + +.markdown-body .pl-mh, +.markdown-body .pl-mh .pl-en, +.markdown-body .pl-ms { + font-weight: 700; + color: #005cc5; +} + +.markdown-body .pl-mi { + font-style: italic; + color: $font-color; +} + +.markdown-body .pl-mb { + font-weight: 700; + color: $font-color; +} + +.markdown-body .pl-md { + color: #b31d28; + background-color: #ffeef0; +} + +.markdown-body .pl-mi1 { + color: #22863a; + background-color: #f0fff4; +} + +.markdown-body .pl-mc { + color: #e36209; + background-color: #ffebda; +} + +.markdown-body .pl-mi2 { + color: #f6f8fa; + background-color: #005cc5; +} + +.markdown-body .pl-mdr { + font-weight: 700; + color: #6f42c1; +} + +.markdown-body .pl-ba { + color: #586069; +} + +.markdown-body .pl-sg { + color: #959da5; +} + +.markdown-body .pl-corl { + text-decoration: underline; + color: #032f62; +} + +.markdown-body .mb-0 { + margin-bottom: 0 !important; +} + +.markdown-body .my-2 { + margin-bottom: 8px !important; +} + +.markdown-body .my-2 { + margin-top: 8px !important; +} + +.markdown-body .pl-0 { + padding-left: 0 !important; +} + +.markdown-body .py-0 { + padding-top: 0 !important; + padding-bottom: 0 !important; +} + +.markdown-body .pl-1 { + padding-left: 4px !important; +} + +.markdown-body .pl-2 { + padding-left: 8px !important; +} + +.markdown-body .py-2 { + padding-top: 8px !important; + padding-bottom: 8px !important; +} + +.markdown-body .pl-3 { + padding-left: 16px !important; +} + +.markdown-body .pl-4 { + padding-left: 24px !important; +} + +.markdown-body .pl-5 { + padding-left: 32px !important; +} + +.markdown-body .pl-6 { + padding-left: 40px !important; +} + +.markdown-body .pl-7 { + padding-left: 48px !important; +} + +.markdown-body .pl-8 { + padding-left: 64px !important; +} + +.markdown-body .pl-9 { + padding-left: 80px !important; +} + +.markdown-body .pl-10 { + padding-left: 96px !important; +} + +.markdown-body .pl-11 { + padding-left: 112px !important; +} + +.markdown-body .pl-12 { + padding-left: 128px !important; +} + +.markdown-body hr { + border-bottom-color: #eee; +} + +.markdown-body kbd { + display: inline-block; + padding: 3px 5px; + font: + 11px SFMono-Regular, + Consolas, + Liberation Mono, + Menlo, + monospace; + line-height: 10px; + color: #444d56; + vertical-align: middle; + background-color: #fafbfc; + border: 1px solid #d1d5da; + border-radius: 3px; + box-shadow: inset 0 -1px 0 #d1d5da; +} + +.markdown-body:after, +.markdown-body:before { + display: table; + content: ''; +} + +.markdown-body:after { + clear: both; +} + +.markdown-body > :first-child { + margin-top: 0 !important; +} + +.markdown-body > :last-child { + margin-bottom: 0 !important; +} + +.markdown-body a:not([href]) { + color: inherit; + text-decoration: none; +} + +.markdown-body blockquote, +.markdown-body details, +.markdown-body dl, +.markdown-body ol, +.markdown-body pre, +.markdown-body table, +.markdown-body ul { + margin-top: 0; + margin-bottom: 16px; +} + +.markdown-body hr { + height: 0.25em; + padding: 0; + margin: 24px 0; + background-color: #e1e4e8; + border: 0; +} + +.markdown-body blockquote > :first-child { + margin-top: 0; +} + +.markdown-body blockquote > :last-child { + margin-bottom: 0; +} + +.markdown-body ol, +.markdown-body ul { + padding-left: 1em; +} + +.markdown-body ol ol, +.markdown-body ol ul, +.markdown-body ul ol, +.markdown-body ul ul { + margin-top: 0; + margin-bottom: 0; +} + +.markdown-body li { + line-height: 28px; + font-size: 14px; + word-wrap: break-all; + list-style: disc; + margin-left: 10px; +} + +.markdown-body li > p { + margin-top: 16px; +} + +.markdown-body li + li { + margin-top: 0.25em; +} + +.markdown-body dl { + padding: 0; +} + +.markdown-body dl dt { + padding: 0; + margin-top: 16px; + font-size: 1em; + font-style: italic; + font-weight: 600; +} + +.markdown-body dl dd { + padding: 0 16px; + margin-bottom: 16px; +} + +.markdown-body table { + display: block; + width: 100%; + overflow: auto; +} + +.markdown-body table th { + font-weight: 600; +} + +.markdown-body table td, +.markdown-body table th { + padding: 6px 13px; + border: 1px solid #dfe2e5; +} + +.markdown-body table tr { + background-color: #fff; + border-top: 1px solid #c6cbd1; +} + +.markdown-body table tr:nth-child(2n) { + background-color: #f6f8fa; +} + +.markdown-body img { + max-width: 100%; + box-sizing: initial; + background-color: #fff; + border: 1px solid #eee; + border: 1px solid var(--art-c-border-2); + cursor: zoom-in; +} + +.markdown-body img[align='right'] { + padding-left: 20px; +} + +.markdown-body img[align='left'] { + padding-right: 20px; +} + +.markdown-body code { + padding: 0.2em 0.4em; + margin: 0; + font-size: 85%; + background-color: rgba(27, 31, 35, 0.05); + border-radius: 3px; +} + +.markdown-body pre { + word-wrap: normal; +} + +.markdown-body pre > code { + padding: 0; + margin: 0; + font-size: 100%; + word-break: normal; + white-space: pre; + background: transparent; + border: 0; +} + +.markdown-body .highlight { + margin-bottom: 16px; +} + +.markdown-body .highlight pre { + margin-bottom: 0; + word-break: normal; +} + +.markdown-body .highlight pre, +.markdown-body pre { + padding: 15px 20px 15px 0; + overflow: auto; + font-size: 92%; + line-height: 1.6; +} + +.markdown-body pre code { + display: inline; + max-width: auto; + padding: 0; + margin: 0; + overflow: visible; + line-height: inherit; + word-wrap: normal; + background-color: initial; + border: 0; +} + +.markdown-body .commit-tease-sha { + display: inline-block; + font-size: 90%; + color: #444d56; +} + +.markdown-body .full-commit .btn-outline:not(:disabled):hover { + color: #005cc5; + border-color: #005cc5; +} + +.markdown-body .blob-wrapper { + overflow-x: auto; + overflow-y: hidden; +} + +.markdown-body .blob-wrapper-embedded { + max-height: 240px; + overflow-y: auto; +} + +.markdown-body .blob-num { + width: 1%; + min-width: 50px; + padding-right: 10px; + padding-left: 10px; + font-size: 12px; + line-height: 20px; + color: rgba(27, 31, 35, 0.3); + text-align: right; + white-space: nowrap; + vertical-align: top; + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.markdown-body .blob-num:hover { + color: rgba(27, 31, 35, 0.6); +} + +.markdown-body .blob-num:before { + content: attr(data-line-number); +} + +.markdown-body .blob-code { + position: relative; + padding-right: 10px; + padding-left: 10px; + line-height: 20px; + vertical-align: top; +} + +.markdown-body .blob-code-inner { + overflow: visible; + font-size: 12px; + color: $font-color; + word-wrap: normal; + white-space: pre; +} + +.markdown-body .pl-token.active, +.markdown-body .pl-token:hover { + cursor: pointer; + background: #ffea7f; +} + +.markdown-body .tab-size[data-tab-size='1'] { + -moz-tab-size: 1; + tab-size: 1; +} + +.markdown-body .tab-size[data-tab-size='2'] { + -moz-tab-size: 2; + tab-size: 2; +} + +.markdown-body .tab-size[data-tab-size='3'] { + -moz-tab-size: 3; + tab-size: 3; +} + +.markdown-body .tab-size[data-tab-size='4'] { + -moz-tab-size: 4; + tab-size: 4; +} + +.markdown-body .tab-size[data-tab-size='5'] { + -moz-tab-size: 5; + tab-size: 5; +} + +.markdown-body .tab-size[data-tab-size='6'] { + -moz-tab-size: 6; + tab-size: 6; +} + +.markdown-body .tab-size[data-tab-size='7'] { + -moz-tab-size: 7; + tab-size: 7; +} + +.markdown-body .tab-size[data-tab-size='8'] { + -moz-tab-size: 8; + tab-size: 8; +} + +.markdown-body .tab-size[data-tab-size='9'] { + -moz-tab-size: 9; + tab-size: 9; +} + +.markdown-body .tab-size[data-tab-size='10'] { + -moz-tab-size: 10; + tab-size: 10; +} + +.markdown-body .tab-size[data-tab-size='11'] { + -moz-tab-size: 11; + tab-size: 11; +} + +.markdown-body .tab-size[data-tab-size='12'] { + -moz-tab-size: 12; + tab-size: 12; +} + +.markdown-body .task-list-item { + list-style-type: none; +} + +.markdown-body .task-list-item + .task-list-item { + margin-top: 3px; +} + +.markdown-body .task-list-item input { + margin: 0 0.2em 0.25em -1.6em; + vertical-align: middle; +} diff --git a/web/apps/admin/src/assets/styles/core/mixin.scss b/web/apps/admin/src/assets/styles/core/mixin.scss new file mode 100755 index 0000000..db36888 --- /dev/null +++ b/web/apps/admin/src/assets/styles/core/mixin.scss @@ -0,0 +1,157 @@ +// sass 混合宏(函数) + +/** +* 溢出省略号 +* @param {Number} 行数 +*/ +@mixin ellipsis($rowCount: 1) { + @if $rowCount <=1 { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } @else { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: $rowCount; + -webkit-box-orient: vertical; + } +} + +/** +* 控制用户能否选中文本 +* @param {String} 类型 +*/ +@mixin userSelect($value: none) { + user-select: $value; + -moz-user-select: $value; + -ms-user-select: $value; + -webkit-user-select: $value; +} + +// 绝对定位居中 +@mixin absoluteCenter() { + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + margin: auto; +} + +/** +* css3动画 +* +*/ +@mixin animation( + $from: ( + width: 0px + ), + $to: ( + width: 100px + ), + $name: mymove, + $animate: mymove 2s 1 linear infinite +) { + -webkit-animation: $animate; + -o-animation: $animate; + animation: $animate; + + @keyframes #{$name} { + from { + @each $key, $value in $from { + #{$key}: #{$value}; + } + } + + to { + @each $key, $value in $to { + #{$key}: #{$value}; + } + } + } + + @-webkit-keyframes #{$name} { + from { + @each $key, $value in $from { + $key: $value; + } + } + + to { + @each $key, $value in $to { + $key: $value; + } + } + } +} + +// 圆形盒子 +@mixin circle($size: 11px, $bg: #fff) { + border-radius: 50%; + width: $size; + height: $size; + line-height: $size; + text-align: center; + background: $bg; +} + +// placeholder +@mixin placeholder($color: #bbb) { + // Firefox + &::-moz-placeholder { + color: $color; + opacity: 1; + } + + // Internet Explorer 10+ + &:-ms-input-placeholder { + color: $color; + } + + // Safari and Chrome + &::-webkit-input-placeholder { + color: $color; + } + + &:placeholder-shown { + text-overflow: ellipsis; + } +} + +//背景透明,文字不透明。兼容IE8 +@mixin betterTransparentize($color, $alpha) { + $c: rgba($color, $alpha); + $ie_c: ie_hex_str($c); + background: rgba($color, 1); + background: $c; + background: transparent \9; + zoom: 1; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr=#{$ie_c}, endColorstr=#{$ie_c}); + -ms-filter: 'progid:DXImageTransform.Microsoft.gradient(startColorstr=#{$ie_c}, endColorstr=#{$ie_c})'; +} + +//添加浏览器前缀 +@mixin browserPrefix($propertyName, $value) { + @each $prefix in -webkit-, -moz-, -ms-, -o-, '' { + #{$prefix}#{$propertyName}: $value; + } +} + +// 边框 +@mixin border($color: red) { + border: 1px solid $color; +} + +// 背景滤镜 +@mixin backdropBlur() { + --tw-backdrop-blur: blur(30px); + -webkit-backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) + var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) + var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) + var(--tw-backdrop-sepia); + backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) + var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) + var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia); +} diff --git a/web/apps/admin/src/assets/styles/core/reset.scss b/web/apps/admin/src/assets/styles/core/reset.scss new file mode 100755 index 0000000..17a3bcf --- /dev/null +++ b/web/apps/admin/src/assets/styles/core/reset.scss @@ -0,0 +1,41 @@ +@charset "UTF-8"; + +/*滚动条*/ +/*滚动条整体部分,必须要设置*/ +::-webkit-scrollbar { + width: 8px !important; + height: 0 !important; +} + +/*滚动条的轨道*/ +::-webkit-scrollbar-track { + background-color: var(--art-gray-200); +} + +/*滚动条的滑块按钮*/ +::-webkit-scrollbar-thumb { + border-radius: 5px; + background-color: #cccccc !important; + transition: all 0.2s; + -webkit-transition: all 0.2s; +} + +::-webkit-scrollbar-thumb:hover { + background-color: #b0abab !important; +} + +/*滚动条的上下两端的按钮*/ +::-webkit-scrollbar-button { + height: 0px; + width: 0; +} + +.dark { + ::-webkit-scrollbar-track { + background-color: var(--default-bg-color); + } + + ::-webkit-scrollbar-thumb { + background-color: var(--art-gray-300) !important; + } +} diff --git a/web/apps/admin/src/assets/styles/core/router-transition.scss b/web/apps/admin/src/assets/styles/core/router-transition.scss new file mode 100644 index 0000000..f47c741 --- /dev/null +++ b/web/apps/admin/src/assets/styles/core/router-transition.scss @@ -0,0 +1,104 @@ +@use 'sass:map'; + +// === 变量区域 === +$transition: ( + // 动画持续时间 + duration: 0.25s, + // 滑动动画的移动距离 + distance: 15px, + // 默认缓动函数 + easing: cubic-bezier(0.25, 0.1, 0.25, 1), + // 淡入淡出专用的缓动函数 + fade-easing: cubic-bezier(0.4, 0, 0.6, 1) +); + +// 抽取配置值函数,提高可复用性 +@function transition-config($key) { + @return map.get($transition, $key); +} + +// 变量简写 +$duration: transition-config('duration'); +$distance: transition-config('distance'); +$easing: transition-config('easing'); +$fade-easing: transition-config('fade-easing'); + +// === 动画类 === + +// 淡入淡出动画 +.fade { + &-enter-active, + &-leave-active { + transition: opacity $duration $fade-easing; + will-change: opacity; + } + + &-enter-from, + &-leave-to { + opacity: 0; + } + + &-enter-to, + &-leave-from { + opacity: 1; + } +} + +// 滑动动画通用样式 +@mixin slide-transition($direction) { + $distance-x: 0; + $distance-y: 0; + + @if $direction == 'left' { + $distance-x: -$distance; + } @else if $direction == 'right' { + $distance-x: $distance; + } @else if $direction == 'top' { + $distance-y: -$distance; + } @else if $direction == 'bottom' { + $distance-y: $distance; + } + + &-enter-active { + transition: + opacity $duration $easing, + transform $duration $easing; + will-change: opacity, transform; + } + + &-leave-active { + transition: + opacity calc($duration * 0.7) $easing, + transform calc($duration * 0.7) $easing; + will-change: opacity, transform; + } + + &-enter-from { + opacity: 0; + transform: translate3d($distance-x, $distance-y, 0); + } + + &-enter-to { + opacity: 1; + transform: translate3d(0, 0, 0); + } + + &-leave-to { + opacity: 0; + transform: translate3d(-$distance-x, -$distance-y, 0); + } +} + +// 滑动动画方向类 +.slide-left { + @include slide-transition('left'); +} +.slide-right { + @include slide-transition('right'); +} +.slide-top { + @include slide-transition('top'); +} +.slide-bottom { + @include slide-transition('bottom'); +} diff --git a/web/apps/admin/src/assets/styles/core/tailwind.css b/web/apps/admin/src/assets/styles/core/tailwind.css new file mode 100644 index 0000000..b810fb7 --- /dev/null +++ b/web/apps/admin/src/assets/styles/core/tailwind.css @@ -0,0 +1,208 @@ +@import 'tailwindcss'; +@custom-variant dark (&:where(.dark, .dark *)); + +/* ==================== Light Mode Variables ==================== */ +:root { + /* Base Colors */ + --art-color: #ffffff; + --theme-color: var(--main-color); + + /* Theme Colors - OKLCH Format (语枢网关:青色主色,去蓝化) */ + --art-primary: #00e5ff; + --art-secondary: #38c0fc; + --art-error: oklch(0.73 0.15 25.3); + --art-info: oklch(0.58 0.03 254.1); + --art-success: oklch(0.78 0.17 166.1); + --art-warning: oklch(0.78 0.14 75.5); + --art-danger: oklch(0.68 0.22 25.3); + + /* Gray Scale - Light Mode */ + --art-gray-100: #f9fafb; + --art-gray-200: #f2f4f5; + --art-gray-300: #e6eaeb; + --art-gray-400: #dbdfe1; + --art-gray-500: #949eb7; + --art-gray-600: #7987a1; + --art-gray-700: #4d5875; + --art-gray-800: #383853; + --art-gray-900: #323251; + + /* Border Colors */ + --art-card-border: rgba(0, 0, 0, 0.08); + + --default-border: #e2e8ee; + --default-border-dashed: #dbdfe9; + + /* Background Colors */ + --default-bg-color: #fafbfc; + --default-box-color: #ffffff; + + /* Hover Color */ + --art-hover-color: #edeff0; + + /* Active Color */ + --art-active-color: #f2f4f5; + + /* Element Component Active Color */ + --art-el-active-color: #f2f4f5; +} + +/* ==================== Dark Mode Variables (语枢网关 中性深色画布 + 青色点缀) ==================== */ +.dark { + /* Base Colors */ + --art-color: #000000; + + /* Gray Scale - Dark Mode (中性暖灰,去蓝调;青色仅作交互点缀) */ + --art-gray-100: #0c0e12; + --art-gray-200: #14171c; + --art-gray-300: #1e2229; + --art-gray-400: #2a2f38; + --art-gray-500: #626a77; + --art-gray-600: #6a7280; + --art-gray-700: #98a0ad; + --art-gray-800: #c9ced6; + --art-gray-900: #e8ebf0; + + /* Border Colors (中性白低透明度,冷蓝感最低) */ + --art-card-border: rgba(255, 255, 255, 0.1); + + --default-border: rgba(255, 255, 255, 0.12); + --default-border-dashed: rgba(255, 255, 255, 0.16); + + /* Background Colors (近黑中性画布 + 中性炭灰面板) */ + --default-bg-color: #0b0d11; + --default-box-color: #15181e; + + /* Hover Color */ + --art-hover-color: #20242b; + + /* Active Color */ + --art-active-color: #1a1e24; + + /* Element Component Active Color */ + --art-el-active-color: #22262d; +} + +/* ==================== Tailwind Theme Configuration ==================== */ +@theme { + /* Box Color (Light: white / Dark: black) */ + --color-box: var(--default-box-color); + + /* System Theme Color */ + --color-theme: var(--theme-color); + + /* Hover Color */ + --color-hover-color: var(--art-hover-color); + + /* Active Color */ + --color-active-color: var(--art-active-color); + + /* Active Color */ + --color-el-active-color: var(--art-active-color); + + /* ElementPlus Theme Colors */ + --color-primary: var(--art-primary); + --color-secondary: var(--art-secondary); + --color-error: var(--art-error); + --color-info: var(--art-info); + --color-success: var(--art-success); + --color-warning: var(--art-warning); + --color-danger: var(--art-danger); + + /* Gray Scale Colors (Auto-adapts to dark mode) */ + --color-g-100: var(--art-gray-100); + --color-g-200: var(--art-gray-200); + --color-g-300: var(--art-gray-300); + --color-g-400: var(--art-gray-400); + --color-g-500: var(--art-gray-500); + --color-g-600: var(--art-gray-600); + --color-g-700: var(--art-gray-700); + --color-g-800: var(--art-gray-800); + --color-g-900: var(--art-gray-900); +} + +/* ==================== Custom Border Radius Utilities ==================== */ +@utility rounded-custom-xs { + border-radius: calc(var(--custom-radius) / 2); +} + +@utility rounded-custom-sm { + border-radius: calc(var(--custom-radius) / 2 + 2px); +} + +/* ==================== Custom Utility Classes ==================== */ +@layer utilities { + /* Flexbox Layout Utilities */ + .flex-c { + @apply flex items-center; + } + + .flex-b { + @apply flex justify-between; + } + + .flex-cc { + @apply flex items-center justify-center; + } + + .flex-cb { + @apply flex items-center justify-between; + } + + /* Transition Utilities */ + .tad-200 { + @apply transition-all duration-200; + } + + .tad-300 { + @apply transition-all duration-300; + } + + /* Border Utilities */ + .border-full-d { + @apply border border-[var(--default-border)]; + } + + .border-b-d { + @apply border-b border-[var(--default-border)]; + } + + .border-t-d { + @apply border-t border-[var(--default-border)]; + } + + .border-l-d { + @apply border-l border-[var(--default-border)]; + } + + .border-r-d { + @apply border-r border-[var(--default-border)]; + } + + /* Cursor Utilities */ + .c-p { + @apply cursor-pointer; + } +} + +/* ==================== Custom Component Classes ==================== */ +@layer components { + /* Art Card Header Component */ + .art-card-header { + @apply flex justify-between pr-6 pb-1; + + .title { + h4 { + @apply text-lg font-medium text-g-900; + } + + p { + @apply mt-1 text-sm text-g-600; + + span { + @apply ml-2 font-medium; + } + } + } + } +} diff --git a/web/apps/admin/src/assets/styles/core/theme-animation.scss b/web/apps/admin/src/assets/styles/core/theme-animation.scss new file mode 100644 index 0000000..377b945 --- /dev/null +++ b/web/apps/admin/src/assets/styles/core/theme-animation.scss @@ -0,0 +1,63 @@ +// 定义基础变量 +$bg-animation-color-light: #000; +$bg-animation-color-dark: #fff; +$bg-animation-duration: 0.5s; + +html { + --bg-animation-color: $bg-animation-color-light; + + &.dark { + --bg-animation-color: $bg-animation-color-dark; + } + + // View transition styles + &::view-transition-old(*) { + animation: none; + } + + &::view-transition-new(*) { + animation: clip $bg-animation-duration ease-in both; + } + + &::view-transition-old(root) { + z-index: 1; + } + + &::view-transition-new(root) { + z-index: 9999; + } + + &.dark { + &::view-transition-old(*) { + animation: clip $bg-animation-duration ease-in reverse both; + } + + &::view-transition-new(*) { + animation: none; + } + + &::view-transition-old(root) { + z-index: 9999; + } + + &::view-transition-new(root) { + z-index: 1; + } + } +} + +// 定义动画 +@keyframes clip { + from { + clip-path: circle(0% at var(--x) var(--y)); + } + + to { + clip-path: circle(var(--r) at var(--x) var(--y)); + } +} + +// body 相关样式 +body { + background-color: var(--bg-animation-color); +} diff --git a/web/apps/admin/src/assets/styles/core/theme-change.scss b/web/apps/admin/src/assets/styles/core/theme-change.scss new file mode 100755 index 0000000..5b640d2 --- /dev/null +++ b/web/apps/admin/src/assets/styles/core/theme-change.scss @@ -0,0 +1,11 @@ +// 主题切换过渡优化,优化除视觉上的不适感 +.theme-change { + * { + transition: 0s !important; + } + + .el-switch__core, + .el-switch__action { + transition: all 0.3s !important; + } +} diff --git a/web/apps/admin/src/assets/styles/custom/one-dark-pro.scss b/web/apps/admin/src/assets/styles/custom/one-dark-pro.scss new file mode 100644 index 0000000..36bdf63 --- /dev/null +++ b/web/apps/admin/src/assets/styles/custom/one-dark-pro.scss @@ -0,0 +1,98 @@ +.hljs { + display: block; + overflow-x: auto; + padding: 0.5em; + + color: #a6accd; +} + +.hljs-string, +.hljs-section, +.hljs-selector-class, +.hljs-template-variable, +.hljs-deletion { + color: #aed07e !important; +} + +.hljs-comment, +.hljs-quote { + color: #6f747d; +} + +.hljs-doctag, +.hljs-keyword, +.hljs-formula { + color: #c792ea; +} + +.hljs-section, +.hljs-name, +.hljs-selector-tag, +.hljs-deletion, +.hljs-subst { + color: #c86068; +} + +.hljs-literal { + color: #56b6c2; +} + +.hljs-string, +.hljs-regexp, +.hljs-addition, +.hljs-attribute, +.hljs-meta-string { + color: #abb2bf; +} + +.hljs-attribute { + color: #c792ea; +} + +.hljs-function { + color: #c792ea; +} + +.hljs-type { + color: #f07178; +} + +.hljs-title { + color: #82aaff !important; +} + +.hljs-built_in, +.hljs-class { + color: #82aaff; +} + +// 括号 +.hljs-params { + color: #a6accd; +} + +.hljs-attr, +.hljs-variable, +.hljs-template-variable, +.hljs-selector-class, +.hljs-selector-attr, +.hljs-selector-pseudo, +.hljs-number { + color: #de7e61; +} + +.hljs-symbol, +.hljs-bullet, +.hljs-link, +.hljs-meta, +.hljs-selector-id { + color: #61aeee; +} + +.hljs-strong { + font-weight: bold; +} + +.hljs-link { + text-decoration: underline; +} diff --git a/web/apps/admin/src/assets/styles/index.scss b/web/apps/admin/src/assets/styles/index.scss new file mode 100644 index 0000000..cdc2ddc --- /dev/null +++ b/web/apps/admin/src/assets/styles/index.scss @@ -0,0 +1,23 @@ +// 重置默认样式 +@use './core/reset.scss'; + +// 应用全局样式 +@use './core/app.scss'; + +// Element Plus 样式优化 +@use './core/el-ui.scss'; + +// Element Plus 暗黑主题 +@use './core/el-dark.scss'; + +// 暗黑主题样式优化 +@use './core/dark.scss'; + +// 路由切换动画 +@use './core/router-transition'; + +// 主题切换过渡优化 +@use './core/theme-change.scss'; + +// 主题切换圆形扩散动画 +@use './core/theme-animation.scss'; diff --git a/web/apps/admin/src/assets/svg/loading.ts b/web/apps/admin/src/assets/svg/loading.ts new file mode 100644 index 0000000..fdfb078 --- /dev/null +++ b/web/apps/admin/src/assets/svg/loading.ts @@ -0,0 +1,32 @@ +// 自定义四点旋转SVG +export const fourDotsSpinnerSvg = ` + + + + + + + + + +` diff --git a/web/apps/admin/src/components/core/banners/art-basic-banner/index.vue b/web/apps/admin/src/components/core/banners/art-basic-banner/index.vue new file mode 100644 index 0000000..65b47e4 --- /dev/null +++ b/web/apps/admin/src/components/core/banners/art-basic-banner/index.vue @@ -0,0 +1,343 @@ + + + + + + diff --git a/web/apps/admin/src/components/core/banners/art-card-banner/index.vue b/web/apps/admin/src/components/core/banners/art-card-banner/index.vue new file mode 100644 index 0000000..8a5f9d4 --- /dev/null +++ b/web/apps/admin/src/components/core/banners/art-card-banner/index.vue @@ -0,0 +1,114 @@ + + + + diff --git a/web/apps/admin/src/components/core/base/art-back-to-top/index.vue b/web/apps/admin/src/components/core/base/art-back-to-top/index.vue new file mode 100644 index 0000000..6f8da61 --- /dev/null +++ b/web/apps/admin/src/components/core/base/art-back-to-top/index.vue @@ -0,0 +1,40 @@ + + + + diff --git a/web/apps/admin/src/components/core/base/art-logo/index.vue b/web/apps/admin/src/components/core/base/art-logo/index.vue new file mode 100644 index 0000000..8bc8309 --- /dev/null +++ b/web/apps/admin/src/components/core/base/art-logo/index.vue @@ -0,0 +1,21 @@ + + + + diff --git a/web/apps/admin/src/components/core/base/art-svg-icon/index.vue b/web/apps/admin/src/components/core/base/art-svg-icon/index.vue new file mode 100644 index 0000000..0bfcd0c --- /dev/null +++ b/web/apps/admin/src/components/core/base/art-svg-icon/index.vue @@ -0,0 +1,24 @@ + + + + diff --git a/web/apps/admin/src/components/core/cards/art-bar-chart-card/index.vue b/web/apps/admin/src/components/core/cards/art-bar-chart-card/index.vue new file mode 100644 index 0000000..6815c2b --- /dev/null +++ b/web/apps/admin/src/components/core/cards/art-bar-chart-card/index.vue @@ -0,0 +1,103 @@ + + + + diff --git a/web/apps/admin/src/components/core/cards/art-data-list-card/index.vue b/web/apps/admin/src/components/core/cards/art-data-list-card/index.vue new file mode 100644 index 0000000..fc43323 --- /dev/null +++ b/web/apps/admin/src/components/core/cards/art-data-list-card/index.vue @@ -0,0 +1,74 @@ + + + + diff --git a/web/apps/admin/src/components/core/cards/art-donut-chart-card/index.vue b/web/apps/admin/src/components/core/cards/art-donut-chart-card/index.vue new file mode 100644 index 0000000..df2dcbb --- /dev/null +++ b/web/apps/admin/src/components/core/cards/art-donut-chart-card/index.vue @@ -0,0 +1,124 @@ + + + + diff --git a/web/apps/admin/src/components/core/cards/art-image-card/index.vue b/web/apps/admin/src/components/core/cards/art-image-card/index.vue new file mode 100644 index 0000000..d27fe00 --- /dev/null +++ b/web/apps/admin/src/components/core/cards/art-image-card/index.vue @@ -0,0 +1,89 @@ + + + + diff --git a/web/apps/admin/src/components/core/cards/art-line-chart-card/index.vue b/web/apps/admin/src/components/core/cards/art-line-chart-card/index.vue new file mode 100644 index 0000000..e58c9b2 --- /dev/null +++ b/web/apps/admin/src/components/core/cards/art-line-chart-card/index.vue @@ -0,0 +1,126 @@ + + + + diff --git a/web/apps/admin/src/components/core/cards/art-progress-card/index.vue b/web/apps/admin/src/components/core/cards/art-progress-card/index.vue new file mode 100644 index 0000000..048a836 --- /dev/null +++ b/web/apps/admin/src/components/core/cards/art-progress-card/index.vue @@ -0,0 +1,86 @@ + + + + diff --git a/web/apps/admin/src/components/core/cards/art-stats-card/index.vue b/web/apps/admin/src/components/core/cards/art-stats-card/index.vue new file mode 100644 index 0000000..8e0341b --- /dev/null +++ b/web/apps/admin/src/components/core/cards/art-stats-card/index.vue @@ -0,0 +1,67 @@ + + + + diff --git a/web/apps/admin/src/components/core/cards/art-timeline-list-card/index.vue b/web/apps/admin/src/components/core/cards/art-timeline-list-card/index.vue new file mode 100644 index 0000000..fbb2c78 --- /dev/null +++ b/web/apps/admin/src/components/core/cards/art-timeline-list-card/index.vue @@ -0,0 +1,69 @@ + + + + diff --git a/web/apps/admin/src/components/core/charts/art-bar-chart/index.vue b/web/apps/admin/src/components/core/charts/art-bar-chart/index.vue new file mode 100644 index 0000000..d677196 --- /dev/null +++ b/web/apps/admin/src/components/core/charts/art-bar-chart/index.vue @@ -0,0 +1,203 @@ + + + + diff --git a/web/apps/admin/src/components/core/charts/art-dual-bar-compare-chart/index.vue b/web/apps/admin/src/components/core/charts/art-dual-bar-compare-chart/index.vue new file mode 100644 index 0000000..32aa60f --- /dev/null +++ b/web/apps/admin/src/components/core/charts/art-dual-bar-compare-chart/index.vue @@ -0,0 +1,195 @@ + + + + diff --git a/web/apps/admin/src/components/core/charts/art-h-bar-chart/index.vue b/web/apps/admin/src/components/core/charts/art-h-bar-chart/index.vue new file mode 100644 index 0000000..2e34759 --- /dev/null +++ b/web/apps/admin/src/components/core/charts/art-h-bar-chart/index.vue @@ -0,0 +1,208 @@ + + + + diff --git a/web/apps/admin/src/components/core/charts/art-k-line-chart/index.vue b/web/apps/admin/src/components/core/charts/art-k-line-chart/index.vue new file mode 100644 index 0000000..0061b51 --- /dev/null +++ b/web/apps/admin/src/components/core/charts/art-k-line-chart/index.vue @@ -0,0 +1,152 @@ + + + + diff --git a/web/apps/admin/src/components/core/charts/art-line-chart/index.vue b/web/apps/admin/src/components/core/charts/art-line-chart/index.vue new file mode 100644 index 0000000..b70c2c3 --- /dev/null +++ b/web/apps/admin/src/components/core/charts/art-line-chart/index.vue @@ -0,0 +1,371 @@ + + + + diff --git a/web/apps/admin/src/components/core/charts/art-radar-chart/index.vue b/web/apps/admin/src/components/core/charts/art-radar-chart/index.vue new file mode 100644 index 0000000..e99fff6 --- /dev/null +++ b/web/apps/admin/src/components/core/charts/art-radar-chart/index.vue @@ -0,0 +1,105 @@ + + + + diff --git a/web/apps/admin/src/components/core/charts/art-ring-chart/index.vue b/web/apps/admin/src/components/core/charts/art-ring-chart/index.vue new file mode 100644 index 0000000..79115f7 --- /dev/null +++ b/web/apps/admin/src/components/core/charts/art-ring-chart/index.vue @@ -0,0 +1,133 @@ + + + + diff --git a/web/apps/admin/src/components/core/charts/art-scatter-chart/index.vue b/web/apps/admin/src/components/core/charts/art-scatter-chart/index.vue new file mode 100644 index 0000000..995b56a --- /dev/null +++ b/web/apps/admin/src/components/core/charts/art-scatter-chart/index.vue @@ -0,0 +1,115 @@ + + + + diff --git a/web/apps/admin/src/components/core/forms/art-button-more/index.vue b/web/apps/admin/src/components/core/forms/art-button-more/index.vue new file mode 100644 index 0000000..858d305 --- /dev/null +++ b/web/apps/admin/src/components/core/forms/art-button-more/index.vue @@ -0,0 +1,71 @@ + + + + diff --git a/web/apps/admin/src/components/core/forms/art-button-table/index.vue b/web/apps/admin/src/components/core/forms/art-button-table/index.vue new file mode 100644 index 0000000..c849901 --- /dev/null +++ b/web/apps/admin/src/components/core/forms/art-button-table/index.vue @@ -0,0 +1,59 @@ + + + + diff --git a/web/apps/admin/src/components/core/forms/art-drag-verify/index.vue b/web/apps/admin/src/components/core/forms/art-drag-verify/index.vue new file mode 100644 index 0000000..5306e04 --- /dev/null +++ b/web/apps/admin/src/components/core/forms/art-drag-verify/index.vue @@ -0,0 +1,430 @@ + + + + + + + + diff --git a/web/apps/admin/src/components/core/forms/art-excel-export/index.vue b/web/apps/admin/src/components/core/forms/art-excel-export/index.vue new file mode 100644 index 0000000..08207c2 --- /dev/null +++ b/web/apps/admin/src/components/core/forms/art-excel-export/index.vue @@ -0,0 +1,389 @@ + + + + + + diff --git a/web/apps/admin/src/components/core/forms/art-excel-import/index.vue b/web/apps/admin/src/components/core/forms/art-excel-import/index.vue new file mode 100644 index 0000000..8aa82fe --- /dev/null +++ b/web/apps/admin/src/components/core/forms/art-excel-import/index.vue @@ -0,0 +1,62 @@ + + + + diff --git a/web/apps/admin/src/components/core/forms/art-form/index.vue b/web/apps/admin/src/components/core/forms/art-form/index.vue new file mode 100644 index 0000000..561e878 --- /dev/null +++ b/web/apps/admin/src/components/core/forms/art-form/index.vue @@ -0,0 +1,507 @@ + + + + + + diff --git a/web/apps/admin/src/components/core/forms/art-search-bar/index.vue b/web/apps/admin/src/components/core/forms/art-search-bar/index.vue new file mode 100644 index 0000000..6786dca --- /dev/null +++ b/web/apps/admin/src/components/core/forms/art-search-bar/index.vue @@ -0,0 +1,581 @@ + + + + + + + + diff --git a/web/apps/admin/src/components/core/forms/art-wang-editor/index.vue b/web/apps/admin/src/components/core/forms/art-wang-editor/index.vue new file mode 100644 index 0000000..3a55691 --- /dev/null +++ b/web/apps/admin/src/components/core/forms/art-wang-editor/index.vue @@ -0,0 +1,257 @@ + + + + + + diff --git a/web/apps/admin/src/components/core/forms/art-wang-editor/style.scss b/web/apps/admin/src/components/core/forms/art-wang-editor/style.scss new file mode 100644 index 0000000..7f22e98 --- /dev/null +++ b/web/apps/admin/src/components/core/forms/art-wang-editor/style.scss @@ -0,0 +1,273 @@ +$box-radius: calc(var(--custom-radius) / 3 + 2px); + +// 全屏容器 z-index 调整 +.w-e-full-screen-container { + z-index: 100 !important; +} + +/* 编辑器容器 */ +.editor-wrapper { + width: 100%; + height: 100%; + border: 1px solid var(--art-gray-300); + border-radius: $box-radius !important; + + .w-e-bar { + border-radius: $box-radius $box-radius 0 0 !important; + } + + .menu-item { + display: flex; + flex-direction: row; + align-items: center; + + i { + margin-right: 5px; + } + } + + /* 工具栏 */ + .editor-toolbar { + border-bottom: 1px solid var(--default-border); + } + + /* 下拉选择框配置 */ + .w-e-select-list { + min-width: 140px; + padding: 5px 10px 10px; + border: none; + border-radius: $box-radius; + } + + /* 下拉选择框元素配置 */ + .w-e-select-list ul li { + margin-top: 5px; + font-size: 15px !important; + border-radius: $box-radius; + } + + /* 下拉选择框 正文文字大小调整 */ + .w-e-select-list ul li:last-of-type { + font-size: 16px !important; + } + + /* 下拉选择框 hover 样式调整 */ + .w-e-select-list ul li:hover { + background-color: var(--art-gray-200); + } + + :root { + /* 激活颜色 */ + --w-e-toolbar-active-bg-color: var(--art-gray-200); + + /* toolbar 图标和文字颜色 */ + --w-e-toolbar-color: #000; + + /* 表格选中时候的边框颜色 */ + --w-e-textarea-selected-border-color: #ddd; + + /* 表格头背景颜色 */ + --w-e-textarea-slight-bg-color: var(--art-gray-200); + } + + /* 工具栏按钮样式 */ + .w-e-bar-item svg { + fill: var(--art-gray-800); + } + + .w-e-bar-item button { + color: var(--art-gray-800); + border-radius: $box-radius; + } + + /* 工具栏 hover 按钮背景颜色 */ + .w-e-bar-item button:hover { + background-color: var(--art-gray-200); + } + + /* 工具栏分割线 */ + .w-e-bar-divider { + height: 20px; + margin-top: 10px; + background-color: #ccc; + } + + /* 工具栏菜单 */ + .w-e-bar-item-group .w-e-bar-item-menus-container { + min-width: 120px; + padding: 10px 0; + border: none; + border-radius: $box-radius; + + .w-e-bar-item { + button { + width: 100%; + margin: 0 5px; + } + } + } + + /* 代码块 */ + .w-e-text-container [data-slate-editor] pre > code { + padding: 0.6rem 1rem; + background-color: var(--art-gray-50); + border-radius: $box-radius; + } + + /* 弹出框 */ + .w-e-drop-panel { + border: 0; + border-radius: $box-radius; + } + + a { + color: #318ef4; + } + + .w-e-text-container { + [data-slate-editor] { + h1, + h2, + h3, + h4, + h5, + h6 { + margin: 0.8em 0 0.4em; + font-weight: 700; + line-height: 1.35; + } + + h1 { + font-size: 2em; + } + + h2 { + font-size: 1.5em; + } + + h3 { + font-size: 1.25em; + } + + h4 { + font-size: 1.125em; + } + + h5 { + font-size: 1em; + } + + h6 { + font-size: 0.875em; + } + + ul, + ol { + padding-left: 1.5em; + margin: 0.8em 0; + } + + ul { + list-style: disc; + } + + ol { + list-style: decimal; + } + + li { + margin: 0.25em 0; + } + + ul ul { + list-style: circle; + } + + ul ul ul { + list-style: square; + } + } + + strong, + b { + font-weight: 700; + } + + i, + em { + font-style: italic; + } + } + + /* 表格样式优化 */ + .w-e-text-container [data-slate-editor] .table-container th { + border-right: none; + } + + .w-e-text-container [data-slate-editor] .table-container th:last-of-type { + border-right: 1px solid #ccc !important; + } + + /* 引用 */ + .w-e-text-container [data-slate-editor] blockquote { + background-color: var(--art-gray-200); + border-left: 4px solid var(--art-gray-300); + } + + /* 输入区域弹出 bar */ + .w-e-hover-bar { + border-radius: $box-radius; + } + + /* 超链接弹窗 */ + .w-e-modal { + border: none; + border-radius: $box-radius; + } + + /* 图片样式调整 */ + .w-e-text-container [data-slate-editor] .w-e-selected-image-container { + overflow: inherit; + + &:hover { + border: 0; + } + + img { + border: 1px solid transparent; + transition: border 0.3s; + + &:hover { + border: 1px solid #318ef4 !important; + } + } + + .w-e-image-dragger { + width: 12px; + height: 12px; + background-color: #318ef4; + border: 2px solid #fff; + border-radius: $box-radius; + } + + .left-top { + top: -6px; + left: -6px; + } + + .right-top { + top: -6px; + right: -6px; + } + + .left-bottom { + bottom: -6px; + left: -6px; + } + + .right-bottom { + right: -6px; + bottom: -6px; + } + } +} diff --git a/web/apps/admin/src/components/core/layouts/art-breadcrumb/index.vue b/web/apps/admin/src/components/core/layouts/art-breadcrumb/index.vue new file mode 100755 index 0000000..4b54859 --- /dev/null +++ b/web/apps/admin/src/components/core/layouts/art-breadcrumb/index.vue @@ -0,0 +1,142 @@ + + + + diff --git a/web/apps/admin/src/components/core/layouts/art-chat-window/index.vue b/web/apps/admin/src/components/core/layouts/art-chat-window/index.vue new file mode 100644 index 0000000..f3d9471 --- /dev/null +++ b/web/apps/admin/src/components/core/layouts/art-chat-window/index.vue @@ -0,0 +1,262 @@ + + + + diff --git a/web/apps/admin/src/components/core/layouts/art-fast-enter/index.vue b/web/apps/admin/src/components/core/layouts/art-fast-enter/index.vue new file mode 100644 index 0000000..fdde222 --- /dev/null +++ b/web/apps/admin/src/components/core/layouts/art-fast-enter/index.vue @@ -0,0 +1,113 @@ + + + + diff --git a/web/apps/admin/src/components/core/layouts/art-fireworks-effect/index.vue b/web/apps/admin/src/components/core/layouts/art-fireworks-effect/index.vue new file mode 100644 index 0000000..be85274 --- /dev/null +++ b/web/apps/admin/src/components/core/layouts/art-fireworks-effect/index.vue @@ -0,0 +1,633 @@ + + + + diff --git a/web/apps/admin/src/components/core/layouts/art-global-component/index.vue b/web/apps/admin/src/components/core/layouts/art-global-component/index.vue new file mode 100644 index 0000000..6908f94 --- /dev/null +++ b/web/apps/admin/src/components/core/layouts/art-global-component/index.vue @@ -0,0 +1,14 @@ + + + + diff --git a/web/apps/admin/src/components/core/layouts/art-global-search/index.vue b/web/apps/admin/src/components/core/layouts/art-global-search/index.vue new file mode 100644 index 0000000..f2c0b88 --- /dev/null +++ b/web/apps/admin/src/components/core/layouts/art-global-search/index.vue @@ -0,0 +1,431 @@ + + + + + + + diff --git a/web/apps/admin/src/components/core/layouts/art-header-bar/index.vue b/web/apps/admin/src/components/core/layouts/art-header-bar/index.vue new file mode 100755 index 0000000..ed8ec22 --- /dev/null +++ b/web/apps/admin/src/components/core/layouts/art-header-bar/index.vue @@ -0,0 +1,501 @@ + + + + + + diff --git a/web/apps/admin/src/components/core/layouts/art-header-bar/widget/ArtUserMenu.vue b/web/apps/admin/src/components/core/layouts/art-header-bar/widget/ArtUserMenu.vue new file mode 100644 index 0000000..c8c5832 --- /dev/null +++ b/web/apps/admin/src/components/core/layouts/art-header-bar/widget/ArtUserMenu.vue @@ -0,0 +1,167 @@ + + + + + + diff --git a/web/apps/admin/src/components/core/layouts/art-menus/art-horizontal-menu/index.vue b/web/apps/admin/src/components/core/layouts/art-menus/art-horizontal-menu/index.vue new file mode 100644 index 0000000..edd1473 --- /dev/null +++ b/web/apps/admin/src/components/core/layouts/art-menus/art-horizontal-menu/index.vue @@ -0,0 +1,110 @@ + + + + + + diff --git a/web/apps/admin/src/components/core/layouts/art-menus/art-horizontal-menu/widget/HorizontalSubmenu.vue b/web/apps/admin/src/components/core/layouts/art-menus/art-horizontal-menu/widget/HorizontalSubmenu.vue new file mode 100644 index 0000000..0f3a817 --- /dev/null +++ b/web/apps/admin/src/components/core/layouts/art-menus/art-horizontal-menu/widget/HorizontalSubmenu.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/web/apps/admin/src/components/core/layouts/art-menus/art-mixed-menu/index.vue b/web/apps/admin/src/components/core/layouts/art-menus/art-mixed-menu/index.vue new file mode 100644 index 0000000..4e98246 --- /dev/null +++ b/web/apps/admin/src/components/core/layouts/art-menus/art-mixed-menu/index.vue @@ -0,0 +1,279 @@ + + + + + + + + diff --git a/web/apps/admin/src/components/core/layouts/art-menus/art-sidebar-menu/index.vue b/web/apps/admin/src/components/core/layouts/art-menus/art-sidebar-menu/index.vue new file mode 100755 index 0000000..16f28c7 --- /dev/null +++ b/web/apps/admin/src/components/core/layouts/art-menus/art-sidebar-menu/index.vue @@ -0,0 +1,354 @@ + + + + + + + + diff --git a/web/apps/admin/src/components/core/layouts/art-menus/art-sidebar-menu/style.scss b/web/apps/admin/src/components/core/layouts/art-menus/art-sidebar-menu/style.scss new file mode 100755 index 0000000..8cccdd9 --- /dev/null +++ b/web/apps/admin/src/components/core/layouts/art-menus/art-sidebar-menu/style.scss @@ -0,0 +1,251 @@ +.layout-sidebar { + display: flex; + height: 100vh; + user-select: none; + scrollbar-width: none; + border-right: 1px solid var(--art-card-border); + + &.no-border { + border-right: none !important; + } + + // 自定义滚动条宽度 + :deep(.el-scrollbar__bar.is-vertical) { + width: 4px; + } + + :deep(.el-scrollbar__thumb) { + right: -2px; + background-color: #ccc; + border-radius: 2px; + } + + .dual-menu-left { + position: relative; + width: 80px; + height: 100%; + border-right: 1px solid var(--art-card-border) !important; + transition: width 0.25s; + + .logo { + margin: auto; + margin-top: 12px; + margin-bottom: 3px; + cursor: pointer; + } + + ul { + li { + > div { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + margin: 8px; + overflow: hidden; + text-align: center; + cursor: pointer; + border-radius: 5px; + + .art-svg-icon { + display: block; + margin: 0 auto; + font-size: 20px; + } + + span { + display: -webkit-box; + width: 100%; + overflow: hidden; + font-size: 12px; + text-overflow: ellipsis; + -webkit-line-clamp: 1; + line-clamp: 1; + -webkit-box-orient: vertical; + } + + &.is-active { + background: var(--el-color-primary-light-9); + + .art-svg-icon, + span { + color: var(--theme-color) !important; + } + } + } + } + } + + .switch-btn { + position: absolute; + right: 0; + bottom: 15px; + left: 0; + margin: auto; + } + } + + .menu-left { + position: relative; + box-sizing: border-box; + height: 100vh; + + @media only screen and (width <= 640px) { + height: 100dvh; + } + + .el-menu { + height: 100%; + } + + &:hover { + .dual-menu-collapse-btn { + opacity: 1 !important; + } + } + + .dual-menu-collapse-btn { + position: absolute; + top: 50%; + right: -11px; + z-index: 10; + width: 11px; + height: 50px; + cursor: pointer; + background-color: var(--default-box-color); + border: 1px solid var(--art-card-border); + border-radius: 0 15px 15px 0; + opacity: 0; + transition: opacity 0.2s; + transform: translateY(-50%); + + &:hover { + .art-svg-icon { + color: var(--art-gray-800) !important; + } + } + + .art-svg-icon { + position: absolute; + top: 0; + bottom: 0; + left: -4px; + margin: auto; + transition: all 0.3s; + } + } + } + + .header { + position: relative; + box-sizing: border-box; + display: flex; + align-items: center; + width: 100%; + height: 60px; + overflow: hidden; + line-height: 60px; + cursor: pointer; + + .logo { + margin-left: 22px; + } + + p { + position: absolute; + top: 0; + bottom: 0; + left: 58px; + box-sizing: border-box; + margin-left: 10px; + font-size: 18px; + + &.is-dual-menu-name { + left: 25px; + margin: auto; + } + } + } + + .el-menu { + box-sizing: border-box; + // 防止菜单内的滚动影响整个页面滚动 + overscroll-behavior: contain; + border-right: 0; + scrollbar-width: none; + -ms-scroll-chaining: contain; + + &::-webkit-scrollbar { + width: 0 !important; + } + } + + .menu-model { + display: none; + } +} + +@media only screen and (width <= 800px) { + .layout-sidebar { + width: 0; + + .header { + height: 50px; + line-height: 50px; + } + + .el-menu { + height: calc(100vh - 60px); + } + + .el-menu--collapse { + width: 0; + } + + // 折叠状态下的header样式 + .menu-left-close .header { + .logo { + display: none; + } + + p { + left: 16px; + font-size: 0; + opacity: 0 !important; + } + } + + .menu-model { + position: fixed; + top: 0; + left: 0; + z-index: -1; + display: block; + width: 100%; + height: 100vh; + background: rgba($color: #000, $alpha: 50%); + transition: opacity 0.2s ease-in-out; + } + } +} + +@media only screen and (width <= 640px) { + .layout-sidebar { + border-right: 0 !important; + } +} + +.dark { + .layout-sidebar { + border-right: 1px solid rgb(255 255 255 / 13%); + + :deep(.el-scrollbar__thumb) { + background-color: #777; + } + + .dual-menu-left { + border-right: 1px solid rgb(255 255 255 / 9%) !important; + } + } +} diff --git a/web/apps/admin/src/components/core/layouts/art-menus/art-sidebar-menu/theme.scss b/web/apps/admin/src/components/core/layouts/art-menus/art-sidebar-menu/theme.scss new file mode 100644 index 0000000..a6329ea --- /dev/null +++ b/web/apps/admin/src/components/core/layouts/art-menus/art-sidebar-menu/theme.scss @@ -0,0 +1,274 @@ +@use '@styles/core/mixin.scss' as *; + +// 菜单样式变量 +$menu-height: 42px; +$menu-icon-size: 20px; +$menu-font-size: 14px; +$hover-bg-color: var(--art-gray-200); +$popup-menu-height: 40px; +$popup-menu-padding: 8px; +$popup-menu-margin: 5px; +$popup-menu-radius: 6px; + +// 通用菜单项样式 +@mixin menu-item-base { + width: calc(100% - 16px); + margin-left: 8px; + border-radius: 6px; + + .menu-icon { + margin-left: -7px; + } +} + +// 通用 hover 样式 +@mixin menu-hover($bg-color) { + .el-sub-menu__title:hover, + .el-menu-item:not(.is-active):hover { + background: $bg-color !important; + } +} + +// 通用选中样式(语枢网关:左侧青色高亮条 + 图标辉光) +@mixin menu-active($color, $bg-color, $icon-color: var(--theme-color)) { + .el-menu-item.is-active { + color: $color !important; + background-color: $bg-color; + + &::before { + position: absolute; + top: 50%; + left: 0; + width: 3px; + height: 60%; + content: ''; + background: var(--theme-color); + border-radius: 0 3px 3px 0; + box-shadow: 0 0 8px var(--theme-color); + transform: translateY(-50%); + } + + .menu-icon { + .art-svg-icon { + color: $icon-color !important; + filter: drop-shadow(0 0 6px var(--theme-color)); + } + } + } +} + +// 弹窗菜单项样式 +@mixin popup-menu-item { + height: $popup-menu-height; + margin-bottom: $popup-menu-margin; + border-radius: $popup-menu-radius; + + .menu-icon { + margin-right: 5px; + } + + &:last-of-type { + margin-bottom: 0; + } +} + +// 主题菜单通用样式(合并 design 和 dark 主题的共同逻辑) +@mixin theme-menu-base { + .el-sub-menu__title, + .el-menu-item { + @include menu-item-base; + } +} + +// 弹窗菜单通用样式 +@mixin popup-menu-base($hover-bg, $active-color, $active-bg) { + .el-menu--popup { + padding: $popup-menu-padding; + + .el-sub-menu__title:hover, + .el-menu-item:hover { + background-color: $hover-bg !important; + border-radius: $popup-menu-radius; + } + + .el-menu-item { + @include popup-menu-item; + + &.is-active { + color: $active-color !important; + background-color: $active-bg !important; + } + } + + .el-sub-menu { + @include popup-menu-item; + + height: $popup-menu-height !important; + + .el-sub-menu__title { + height: $popup-menu-height !important; + border-radius: $popup-menu-radius; + } + } + } +} + +.layout-sidebar { + // ---------------------- Modify default style ---------------------- + + // 菜单折叠样式 + .menu-left-close { + .header { + .logo { + margin: 0 auto; + } + } + } + + // 菜单图标 + .menu-icon { + margin-right: 8px; + font-size: $menu-icon-size; + } + + // 菜单高度 + .el-sub-menu__title, + .el-menu-item { + height: $menu-height !important; + margin-bottom: 4px; + line-height: $menu-height !important; + + span { + font-size: $menu-font-size !important; + + @include ellipsis(); + } + } + + // 右侧箭头 + .el-sub-menu__icon-arrow { + width: 13px !important; + font-size: 13px !important; + } + + // 菜单折叠 + .el-menu--collapse { + .el-sub-menu.is-active { + .el-sub-menu__title { + .menu-icon { + .art-svg-icon { + // 选中菜单图标颜色 + color: var(--theme-color) !important; + } + } + } + } + } + + // ---------------------- Design theme menu ---------------------- + .el-menu-design { + @include theme-menu-base; + @include menu-active(var(--theme-color), var(--el-color-primary-light-9)); + @include menu-hover($hover-bg-color); + + .el-sub-menu__icon-arrow { + color: var(--art-gray-600); + } + } + + // ---------------------- Dark theme menu (navy HUD) ---------------------- + .el-menu-dark { + @include theme-menu-base; + @include menu-active(#fff, #1a2740, #fff); + @include menu-hover(#0d1b33); + + .el-sub-menu__icon-arrow { + color: var(--art-gray-400); + } + } + + // ---------------------- Light theme menu ---------------------- + .el-menu-light { + .el-sub-menu__title, + .el-menu-item { + .menu-icon { + margin-left: 1px; + } + } + + .el-menu-item.is-active { + background-color: var(--el-color-primary-light-9); + + .art-svg-icon { + color: var(--theme-color) !important; + } + + &::before { + position: absolute; + top: 0; + left: 0; + width: 4px; + height: 100%; + content: ''; + background: var(--theme-color); + } + } + + @include menu-hover($hover-bg-color); + + .el-sub-menu__icon-arrow { + color: var(--art-gray-600); + } + } +} + +@media only screen and (width <= 640px) { + .layout-sidebar { + .el-menu-design { + > .el-sub-menu { + margin-left: 0; + } + + .el-sub-menu { + width: 100% !important; + } + } + } +} + +// 菜单折叠 hover 弹窗样式(浅色主题) +.el-menu--vertical, +.el-menu--popup-container { + @include popup-menu-base(var(--art-gray-200), var(--art-gray-900), var(--art-gray-200)); +} + +// 暗黑模式菜单样式 +.dark { + .el-menu--vertical, + .el-menu--popup-container { + @include popup-menu-base(var(--art-gray-200), var(--art-gray-900), #1a2740); + } + + .layout-sidebar { + // 图标颜色、文字颜色 + .menu-icon .art-svg-icon, + .menu-name { + color: var(--art-gray-800) !important; + } + + // 选中的文字颜色跟图标颜色(深色下用半透明青底,替代过亮的 light-9 底色) + .el-menu-item.is-active { + span, + .menu-icon .art-svg-icon { + color: var(--theme-color) !important; + } + + background-color: rgb(0 229 255 / 10%) !important; + } + + // 右侧箭头颜色 + .el-sub-menu__icon-arrow { + color: #fff; + } + } +} diff --git a/web/apps/admin/src/components/core/layouts/art-menus/art-sidebar-menu/widget/SidebarSubmenu.vue b/web/apps/admin/src/components/core/layouts/art-menus/art-sidebar-menu/widget/SidebarSubmenu.vue new file mode 100755 index 0000000..9afa262 --- /dev/null +++ b/web/apps/admin/src/components/core/layouts/art-menus/art-sidebar-menu/widget/SidebarSubmenu.vue @@ -0,0 +1,199 @@ + + + diff --git a/web/apps/admin/src/components/core/layouts/art-notification/index.vue b/web/apps/admin/src/components/core/layouts/art-notification/index.vue new file mode 100755 index 0000000..a58853c --- /dev/null +++ b/web/apps/admin/src/components/core/layouts/art-notification/index.vue @@ -0,0 +1,456 @@ + + + + + + diff --git a/web/apps/admin/src/components/core/layouts/art-page-content/index.vue b/web/apps/admin/src/components/core/layouts/art-page-content/index.vue new file mode 100644 index 0000000..a862df1 --- /dev/null +++ b/web/apps/admin/src/components/core/layouts/art-page-content/index.vue @@ -0,0 +1,136 @@ + + + diff --git a/web/apps/admin/src/components/core/layouts/art-screen-lock/index.vue b/web/apps/admin/src/components/core/layouts/art-screen-lock/index.vue new file mode 100644 index 0000000..5bf5248 --- /dev/null +++ b/web/apps/admin/src/components/core/layouts/art-screen-lock/index.vue @@ -0,0 +1,519 @@ + + + + + + diff --git a/web/apps/admin/src/components/core/layouts/art-settings-panel/composables/useSettingsConfig.ts b/web/apps/admin/src/components/core/layouts/art-settings-panel/composables/useSettingsConfig.ts new file mode 100644 index 0000000..35e8066 --- /dev/null +++ b/web/apps/admin/src/components/core/layouts/art-settings-panel/composables/useSettingsConfig.ts @@ -0,0 +1,248 @@ +import { computed } from 'vue' +import { useI18n } from 'vue-i18n' +import { ContainerWidthEnum } from '@/enums/appEnum' +import AppConfig from '@/config' +import { headerBarConfig } from '@/config/modules/headerBar' + +/** + * 设置项配置选项管理 + */ +export function useSettingsConfig() { + const { t } = useI18n() + + // 标签页风格选项 + const tabStyleOptions = computed(() => [ + { + value: 'tab-default', + label: t('setting.tabStyle.default') + }, + { + value: 'tab-card', + label: t('setting.tabStyle.card') + }, + { + value: 'tab-google', + label: t('setting.tabStyle.google') + } + ]) + + // 页面切换动画选项 + const pageTransitionOptions = computed(() => [ + { + value: '', + label: t('setting.transition.list.none') + }, + { + value: 'fade', + label: t('setting.transition.list.fade') + }, + { + value: 'slide-left', + label: t('setting.transition.list.slideLeft') + }, + { + value: 'slide-bottom', + label: t('setting.transition.list.slideBottom') + }, + { + value: 'slide-top', + label: t('setting.transition.list.slideTop') + } + ]) + + // 圆角大小选项 + const customRadiusOptions = [ + { value: '0', label: '0' }, + { value: '0.25', label: '0.25' }, + { value: '0.5', label: '0.5' }, + { value: '0.75', label: '0.75' }, + { value: '1', label: '1' } + ] + + // 容器宽度选项 + const containerWidthOptions = computed(() => [ + { + value: ContainerWidthEnum.FULL, + label: t('setting.container.list[0]'), + icon: 'icon-park-outline:auto-width' + }, + { + value: ContainerWidthEnum.BOXED, + label: t('setting.container.list[1]'), + icon: 'ix:width' + } + ]) + + // 盒子样式选项 + const boxStyleOptions = computed(() => [ + { + value: 'border-mode', + label: t('setting.box.list[0]'), + type: 'border-mode' as const + }, + { + value: 'shadow-mode', + label: t('setting.box.list[1]'), + type: 'shadow-mode' as const + } + ]) + + // 从配置文件获取的选项 + const configOptions = { + // 主题色彩选项 + mainColors: AppConfig.systemMainColor, + + // 主题风格选项 + themeList: AppConfig.settingThemeList, + + // 菜单布局选项 + menuLayoutList: AppConfig.menuLayoutList + } + + // 基础设置项配置 + const basicSettingsConfig = computed(() => { + // 定义所有基础设置项 + const allSettings = [ + { + key: 'showWorkTab', + label: t('setting.basics.list.multiTab'), + type: 'switch' as const, + handler: 'workTab', + headerBarKey: null // 不依赖headerBar配置 + }, + { + key: 'uniqueOpened', + label: t('setting.basics.list.accordion'), + type: 'switch' as const, + handler: 'uniqueOpened', + headerBarKey: null // 不依赖headerBar配置 + }, + { + key: 'showMenuButton', + label: t('setting.basics.list.collapseSidebar'), + type: 'switch' as const, + handler: 'menuButton', + headerBarKey: 'menuButton' as const + }, + { + key: 'showFastEnter', + label: t('setting.basics.list.fastEnter'), + type: 'switch' as const, + handler: 'fastEnter', + headerBarKey: 'fastEnter' as const + }, + { + key: 'showRefreshButton', + label: t('setting.basics.list.reloadPage'), + type: 'switch' as const, + handler: 'refreshButton', + headerBarKey: 'refreshButton' as const + }, + { + key: 'showCrumbs', + label: t('setting.basics.list.breadcrumb'), + type: 'switch' as const, + handler: 'crumbs', + mobileHide: true, + headerBarKey: 'breadcrumb' as const + }, + { + key: 'showLanguage', + label: t('setting.basics.list.language'), + type: 'switch' as const, + handler: 'language', + headerBarKey: 'language' as const + }, + { + key: 'showNprogress', + label: t('setting.basics.list.progressBar'), + type: 'switch' as const, + handler: 'nprogress', + headerBarKey: null // 不依赖headerBar配置 + }, + { + key: 'colorWeak', + label: t('setting.basics.list.weakMode'), + type: 'switch' as const, + handler: 'colorWeak', + headerBarKey: null // 不依赖headerBar配置 + }, + { + key: 'watermarkVisible', + label: t('setting.basics.list.watermark'), + type: 'switch' as const, + handler: 'watermark', + headerBarKey: null // 不依赖headerBar配置 + }, + { + key: 'menuOpenWidth', + label: t('setting.basics.list.menuWidth'), + type: 'input-number' as const, + handler: 'menuOpenWidth', + min: 180, + max: 320, + step: 10, + style: { width: '120px' }, + controlsPosition: 'right' as const, + headerBarKey: null // 不依赖headerBar配置 + }, + { + key: 'tabStyle', + label: t('setting.basics.list.tabStyle'), + type: 'select' as const, + handler: 'tabStyle', + options: tabStyleOptions.value, + style: { width: '120px' }, + headerBarKey: null // 不依赖headerBar配置 + }, + { + key: 'pageTransition', + label: t('setting.basics.list.pageTransition'), + type: 'select' as const, + handler: 'pageTransition', + options: pageTransitionOptions.value, + style: { width: '120px' }, + headerBarKey: null // 不依赖headerBar配置 + }, + { + key: 'customRadius', + label: t('setting.basics.list.borderRadius'), + type: 'select' as const, + handler: 'customRadius', + options: customRadiusOptions, + style: { width: '120px' }, + headerBarKey: null // 不依赖headerBar配置 + } + ] + + // 根据 headerBarConfig 过滤设置项 + return ( + allSettings + .filter((setting) => { + // 如果设置项不依赖headerBar配置,则始终显示 + if (setting.headerBarKey === null) { + return true + } + + // 如果依赖headerBar配置,检查对应的功能是否启用 + const headerBarFeature = headerBarConfig[setting.headerBarKey] + return headerBarFeature?.enabled !== false + }) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + .map(({ headerBarKey: _headerBarKey, ...setting }) => setting) + ) + }) + + return { + // 选项配置 + tabStyleOptions, + pageTransitionOptions, + customRadiusOptions, + containerWidthOptions, + boxStyleOptions, + configOptions, + + // 设置项配置 + basicSettingsConfig + } +} diff --git a/web/apps/admin/src/components/core/layouts/art-settings-panel/composables/useSettingsHandlers.ts b/web/apps/admin/src/components/core/layouts/art-settings-panel/composables/useSettingsHandlers.ts new file mode 100644 index 0000000..392c690 --- /dev/null +++ b/web/apps/admin/src/components/core/layouts/art-settings-panel/composables/useSettingsHandlers.ts @@ -0,0 +1,167 @@ +import { useSettingStore } from '@/store/modules/setting' +import { storeToRefs } from 'pinia' +import type { ContainerWidthEnum } from '@/enums/appEnum' + +/** + * 设置项通用处理逻辑 + */ +export function useSettingsHandlers() { + const settingStore = useSettingStore() + + // DOM 操作相关 + const domOperations = { + // 设置HTML类名 + setHtmlClass: (className: string, add: boolean) => { + const el = document.getElementsByTagName('html')[0] + if (add) { + el.classList.add(className) + } else { + el.classList.remove(className) + } + }, + + // 设置根元素属性 + setRootAttribute: (attribute: string, value: string) => { + const el = document.documentElement + el.setAttribute(attribute, value) + }, + + // 设置body类名 + setBodyClass: (className: string, add: boolean) => { + const el = document.getElementsByTagName('body')[0] + if (add) { + el.classList.add(className) + } else { + el.classList.remove(className) + } + } + } + + // 通用切换处理器 + const createToggleHandler = (storeMethod: () => void, callback?: () => void) => { + return () => { + storeMethod() + callback?.() + } + } + + // 通用值变更处理器 + const createValueHandler = ( + storeMethod: (value: T) => void, + callback?: (value: T) => void + ) => { + return (value: T) => { + if (value !== undefined && value !== null) { + storeMethod(value) + callback?.(value) + } + } + } + + // 基础设置处理器 + const basicHandlers = { + // 工作台标签页 + workTab: createToggleHandler(() => settingStore.setWorkTab(!settingStore.showWorkTab)), + + // 菜单手风琴 + uniqueOpened: createToggleHandler(() => settingStore.setUniqueOpened()), + + // 显示菜单按钮 + menuButton: createToggleHandler(() => settingStore.setButton()), + + // 显示快速入口 + fastEnter: createToggleHandler(() => settingStore.setFastEnter()), + + // 显示刷新按钮 + refreshButton: createToggleHandler(() => settingStore.setShowRefreshButton()), + + // 显示面包屑 + crumbs: createToggleHandler(() => settingStore.setCrumbs()), + + // 显示语言切换 + language: createToggleHandler(() => settingStore.setLanguage()), + + // 显示进度条 + nprogress: createToggleHandler(() => settingStore.setNprogress()), + + // 色弱模式 + colorWeak: createToggleHandler( + () => settingStore.setColorWeak(), + () => { + domOperations.setHtmlClass('color-weak', settingStore.colorWeak) + } + ), + + // 水印显示 + watermark: createToggleHandler(() => + settingStore.setWatermarkVisible(!settingStore.watermarkVisible) + ), + + // 菜单展开宽度 + menuOpenWidth: createValueHandler((width: number) => + settingStore.setMenuOpenWidth(width) + ), + + // 标签页风格 + tabStyle: createValueHandler((style: string) => settingStore.setTabStyle(style)), + + // 页面切换动画 + pageTransition: createValueHandler((transition: string) => + settingStore.setPageTransition(transition) + ), + + // 圆角大小 + customRadius: createValueHandler((radius: string) => + settingStore.setCustomRadius(radius) + ) + } + + // 盒子样式处理器 + const boxStyleHandlers = { + // 设置盒子模式 + setBoxMode: (type: 'border-mode' | 'shadow-mode') => { + const { boxBorderMode } = storeToRefs(settingStore) + + // 防止重复设置 + if ( + (type === 'shadow-mode' && boxBorderMode.value === false) || + (type === 'border-mode' && boxBorderMode.value === true) + ) { + return + } + + setTimeout(() => { + domOperations.setRootAttribute('data-box-mode', type) + settingStore.setBorderMode() + }, 50) + } + } + + // 颜色设置处理器 + const colorHandlers = { + // 选择主题色 + selectColor: (theme: string) => { + settingStore.setElementTheme(theme) + settingStore.reload() + } + } + + // 容器设置处理器 + const containerHandlers = { + // 设置容器宽度 + setWidth: (type: ContainerWidthEnum) => { + settingStore.setContainerWidth(type) + settingStore.reload() + } + } + + return { + domOperations, + basicHandlers, + boxStyleHandlers, + colorHandlers, + containerHandlers, + createToggleHandler, + createValueHandler + } +} diff --git a/web/apps/admin/src/components/core/layouts/art-settings-panel/composables/useSettingsPanel.ts b/web/apps/admin/src/components/core/layouts/art-settings-panel/composables/useSettingsPanel.ts new file mode 100644 index 0000000..f5e4e56 --- /dev/null +++ b/web/apps/admin/src/components/core/layouts/art-settings-panel/composables/useSettingsPanel.ts @@ -0,0 +1,233 @@ +import { ref, computed, watch } from 'vue' +import { useSettingStore } from '@/store/modules/setting' +import { storeToRefs } from 'pinia' +import { useBreakpoints } from '@vueuse/core' +import AppConfig from '@/config' +import { SystemThemeEnum, MenuTypeEnum } from '@/enums/appEnum' +import { mittBus } from '@/utils/sys' +import { StorageConfig } from '@/utils' +import { useTheme } from '@/hooks/core/useTheme' +import { useCeremony } from '@/hooks/core/useCeremony' +import { useSettingsState } from './useSettingsState' +import { useSettingsHandlers } from './useSettingsHandlers' + +/** + * 设置面板核心逻辑管理 + */ +export function useSettingsPanel() { + const settingStore = useSettingStore() + const { systemThemeType, systemThemeMode, menuType } = storeToRefs(settingStore) + + // Composables + const { openFestival, cleanup } = useCeremony() + const { setSystemTheme, setSystemAutoTheme } = useTheme() + const { initColorWeak } = useSettingsState() + const { domOperations } = useSettingsHandlers() + + // 响应式状态 + const showDrawer = ref(false) + + // 使用 VueUse breakpoints 优化性能 + const breakpoints = useBreakpoints({ tablet: 1000 }) + const isMobile = breakpoints.smaller('tablet') + + // 记录窗口宽度变化前的菜单类型 + const getStoredDesktopMenuType = (): MenuTypeEnum | undefined => { + const storedMenuType = localStorage.getItem(StorageConfig.RESPONSIVE_MENU_TYPE_KEY) + return Object.values(MenuTypeEnum).includes(storedMenuType as MenuTypeEnum) + ? (storedMenuType as MenuTypeEnum) + : undefined + } + + const setStoredDesktopMenuType = (type: MenuTypeEnum) => { + localStorage.setItem(StorageConfig.RESPONSIVE_MENU_TYPE_KEY, type) + } + + const clearStoredDesktopMenuType = () => { + localStorage.removeItem(StorageConfig.RESPONSIVE_MENU_TYPE_KEY) + } + + const storedDesktopMenuType = getStoredDesktopMenuType() + const beforeMenuType = ref(storedDesktopMenuType) + const hasChangedMenu = ref(Boolean(storedDesktopMenuType)) + + // 计算属性 + const systemThemeColor = computed(() => settingStore.systemThemeColor as string) + + // 主题相关处理 + const useThemeHandlers = () => { + // 初始化系统颜色 + const initSystemColor = () => { + if (!AppConfig.systemMainColor.includes(systemThemeColor.value)) { + settingStore.setElementTheme(AppConfig.systemMainColor[0]) + settingStore.reload() + } + } + + // 初始化系统主题 + const initSystemTheme = () => { + if (systemThemeMode.value === SystemThemeEnum.AUTO) { + setSystemAutoTheme() + } else { + setSystemTheme(systemThemeType.value) + } + } + + // 监听系统主题变化 + const listenerSystemTheme = () => { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') + mediaQuery.addEventListener('change', initSystemTheme) + return () => { + mediaQuery.removeEventListener('change', initSystemTheme) + } + } + + return { + initSystemColor, + initSystemTheme, + listenerSystemTheme + } + } + + // 响应式布局处理 + const useResponsiveLayout = () => { + // 使用 watch 监听断点变化,性能更优 + const stopWatch = watch( + isMobile, + (mobile: boolean) => { + if (mobile) { + // 切换到移动端布局 + if (!hasChangedMenu.value) { + beforeMenuType.value = menuType.value + if (menuType.value !== MenuTypeEnum.LEFT) { + setStoredDesktopMenuType(menuType.value) + useSettingsState().switchMenuLayouts(MenuTypeEnum.LEFT) + hasChangedMenu.value = true + } + } + + settingStore.setMenuOpen(false) + } else { + // 恢复桌面端布局 + if (hasChangedMenu.value && beforeMenuType.value) { + if (menuType.value === MenuTypeEnum.LEFT) { + useSettingsState().switchMenuLayouts(beforeMenuType.value) + } + + clearStoredDesktopMenuType() + hasChangedMenu.value = false + } + + settingStore.setMenuOpen(true) + } + }, + { immediate: true } + ) + + return { stopWatch } + } + + // 抽屉控制 + const useDrawerControl = () => { + // 用于存储 setTimeout 的 ID,以便在需要时清除 + let themeChangeTimer: ReturnType | null = null + + // 打开抽屉 + const handleOpen = () => { + // 清除可能存在的旧定时器 + if (themeChangeTimer) { + clearTimeout(themeChangeTimer) + } + // 延迟添加 theme-change class,避免抽屉打开动画受影响 + themeChangeTimer = setTimeout(() => { + domOperations.setBodyClass('theme-change', true) + themeChangeTimer = null + }, 500) + } + + // 关闭抽屉 + const handleClose = () => { + // 清除未执行的定时器,防止关闭后才添加 class + if (themeChangeTimer) { + clearTimeout(themeChangeTimer) + themeChangeTimer = null + } + // 立即移除 theme-change class + domOperations.setBodyClass('theme-change', false) + } + + // 打开设置 + const openSetting = () => { + showDrawer.value = true + } + + // 关闭设置 + const closeDrawer = () => { + showDrawer.value = false + } + + return { + handleOpen, + handleClose, + openSetting, + closeDrawer + } + } + + // Props 变化监听 + const usePropsWatcher = (props: { open?: boolean }) => { + watch( + () => props.open, + (val: boolean | undefined) => { + if (val !== undefined) { + showDrawer.value = val + } + } + ) + } + + // 初始化设置 + const useSettingsInitializer = () => { + const themeHandlers = useThemeHandlers() + const { openSetting } = useDrawerControl() + const { stopWatch } = useResponsiveLayout() + let themeCleanup: (() => void) | null = null + + const initializeSettings = () => { + mittBus.on('openSetting', openSetting) + themeHandlers.initSystemColor() + themeCleanup = themeHandlers.listenerSystemTheme() + initColorWeak() + + // 设置盒子模式 + const boxMode = settingStore.boxBorderMode ? 'border-mode' : 'shadow-mode' + domOperations.setRootAttribute('data-box-mode', boxMode) + + themeHandlers.initSystemTheme() + openFestival() + } + + const cleanupSettings = () => { + stopWatch() + themeCleanup?.() + cleanup() + } + + return { + initializeSettings, + cleanupSettings + } + } + + return { + // 状态 + showDrawer, + + // 方法组合 + useThemeHandlers, + useResponsiveLayout, + useDrawerControl, + usePropsWatcher, + useSettingsInitializer + } +} diff --git a/web/apps/admin/src/components/core/layouts/art-settings-panel/composables/useSettingsState.ts b/web/apps/admin/src/components/core/layouts/art-settings-panel/composables/useSettingsState.ts new file mode 100644 index 0000000..65352d2 --- /dev/null +++ b/web/apps/admin/src/components/core/layouts/art-settings-panel/composables/useSettingsState.ts @@ -0,0 +1,37 @@ +import { useSettingStore } from '@/store/modules/setting' +import { MenuThemeEnum, MenuTypeEnum } from '@/enums/appEnum' + +/** + * 设置状态管理 + */ +export function useSettingsState() { + const settingStore = useSettingStore() + + // 色弱模式初始化 + const initColorWeak = () => { + if (settingStore.colorWeak) { + const el = document.getElementsByTagName('html')[0] + setTimeout(() => { + el.classList.add('color-weak') + }, 100) + } + } + + // 菜单布局切换 + const switchMenuLayouts = (type: MenuTypeEnum) => { + if (type === MenuTypeEnum.LEFT || type === MenuTypeEnum.TOP_LEFT) { + settingStore.setMenuOpen(true) + } + settingStore.switchMenuLayouts(type) + if (type === MenuTypeEnum.DUAL_MENU) { + settingStore.switchMenuStyles(MenuThemeEnum.DESIGN) + settingStore.setMenuOpen(true) + } + } + + return { + // 方法 + initColorWeak, + switchMenuLayouts + } +} diff --git a/web/apps/admin/src/components/core/layouts/art-settings-panel/index.vue b/web/apps/admin/src/components/core/layouts/art-settings-panel/index.vue new file mode 100755 index 0000000..0cbf344 --- /dev/null +++ b/web/apps/admin/src/components/core/layouts/art-settings-panel/index.vue @@ -0,0 +1,72 @@ + + + + + + diff --git a/web/apps/admin/src/components/core/layouts/art-settings-panel/style.scss b/web/apps/admin/src/components/core/layouts/art-settings-panel/style.scss new file mode 100755 index 0000000..e863074 --- /dev/null +++ b/web/apps/admin/src/components/core/layouts/art-settings-panel/style.scss @@ -0,0 +1,92 @@ +@use '@styles/core/mixin.scss' as *; + +// 设置抽屉模态框样式 +.setting-modal { + background: transparent !important; + + .el-drawer { + // 背景滤镜效果 + background: rgba($color: #fff, $alpha: 50%) !important; + box-shadow: 0 0 30px rgb(0 0 0 / 10%) !important; + + @include backdropBlur(); + + .setting-box-wrap { + display: flex; + flex-wrap: wrap; + align-items: center; + width: calc(100% + 15px); + margin-bottom: 10px; + + .setting-item { + box-sizing: border-box; + width: calc(33.333% - 15px); + margin-right: 15px; + text-align: center; + + .box { + position: relative; + box-sizing: border-box; + display: flex; + height: 52px; + overflow: hidden; + cursor: pointer; + border: 2px solid var(--default-border); + border-radius: 8px; + box-shadow: 0 0 8px 0 rgb(0 0 0 / 10%); + transition: box-shadow 0.1s; + + &.mt-16 { + margin-top: 16px; + } + + &.is-active { + border: 2px solid var(--theme-color); + } + + img { + width: 100%; + height: 100%; + } + } + + .name { + margin-top: 6px; + font-size: 14px; + text-align: center; + } + } + } + } + + // 去除滚动条 + .el-drawer__body::-webkit-scrollbar { + width: 0 !important; + } +} + +.dark { + .setting-modal { + .el-drawer { + background: rgba($color: #000, $alpha: 50%) !important; + + .setting-item { + .box { + border: 2px solid transparent; + } + } + } + } +} + +// 去除火狐浏览器滚动条 +:deep(.el-drawer__body) { + scrollbar-width: none; +} + +// 移动端隐藏 +@media screen and (width <= 800px) { + .mobile-hide { + display: none !important; + } +} diff --git a/web/apps/admin/src/components/core/layouts/art-settings-panel/widget/BasicSettings.vue b/web/apps/admin/src/components/core/layouts/art-settings-panel/widget/BasicSettings.vue new file mode 100644 index 0000000..b6dc9d3 --- /dev/null +++ b/web/apps/admin/src/components/core/layouts/art-settings-panel/widget/BasicSettings.vue @@ -0,0 +1,77 @@ + + + diff --git a/web/apps/admin/src/components/core/layouts/art-settings-panel/widget/BoxStyleSettings.vue b/web/apps/admin/src/components/core/layouts/art-settings-panel/widget/BoxStyleSettings.vue new file mode 100644 index 0000000..86c7a9e --- /dev/null +++ b/web/apps/admin/src/components/core/layouts/art-settings-panel/widget/BoxStyleSettings.vue @@ -0,0 +1,38 @@ + + + diff --git a/web/apps/admin/src/components/core/layouts/art-settings-panel/widget/ColorSettings.vue b/web/apps/admin/src/components/core/layouts/art-settings-panel/widget/ColorSettings.vue new file mode 100644 index 0000000..05a4b41 --- /dev/null +++ b/web/apps/admin/src/components/core/layouts/art-settings-panel/widget/ColorSettings.vue @@ -0,0 +1,35 @@ + + + diff --git a/web/apps/admin/src/components/core/layouts/art-settings-panel/widget/ContainerSettings.vue b/web/apps/admin/src/components/core/layouts/art-settings-panel/widget/ContainerSettings.vue new file mode 100644 index 0000000..1f5be72 --- /dev/null +++ b/web/apps/admin/src/components/core/layouts/art-settings-panel/widget/ContainerSettings.vue @@ -0,0 +1,33 @@ + + + diff --git a/web/apps/admin/src/components/core/layouts/art-settings-panel/widget/MenuLayoutSettings.vue b/web/apps/admin/src/components/core/layouts/art-settings-panel/widget/MenuLayoutSettings.vue new file mode 100644 index 0000000..dbcae46 --- /dev/null +++ b/web/apps/admin/src/components/core/layouts/art-settings-panel/widget/MenuLayoutSettings.vue @@ -0,0 +1,31 @@ + + + diff --git a/web/apps/admin/src/components/core/layouts/art-settings-panel/widget/MenuStyleSettings.vue b/web/apps/admin/src/components/core/layouts/art-settings-panel/widget/MenuStyleSettings.vue new file mode 100644 index 0000000..61237eb --- /dev/null +++ b/web/apps/admin/src/components/core/layouts/art-settings-panel/widget/MenuStyleSettings.vue @@ -0,0 +1,44 @@ + + + diff --git a/web/apps/admin/src/components/core/layouts/art-settings-panel/widget/SectionTitle.vue b/web/apps/admin/src/components/core/layouts/art-settings-panel/widget/SectionTitle.vue new file mode 100644 index 0000000..31ef00c --- /dev/null +++ b/web/apps/admin/src/components/core/layouts/art-settings-panel/widget/SectionTitle.vue @@ -0,0 +1,17 @@ + + + diff --git a/web/apps/admin/src/components/core/layouts/art-settings-panel/widget/SettingActions.vue b/web/apps/admin/src/components/core/layouts/art-settings-panel/widget/SettingActions.vue new file mode 100644 index 0000000..7b47d1a --- /dev/null +++ b/web/apps/admin/src/components/core/layouts/art-settings-panel/widget/SettingActions.vue @@ -0,0 +1,235 @@ + + + + diff --git a/web/apps/admin/src/components/core/layouts/art-settings-panel/widget/SettingDrawer.vue b/web/apps/admin/src/components/core/layouts/art-settings-panel/widget/SettingDrawer.vue new file mode 100644 index 0000000..85372be --- /dev/null +++ b/web/apps/admin/src/components/core/layouts/art-settings-panel/widget/SettingDrawer.vue @@ -0,0 +1,51 @@ + + + diff --git a/web/apps/admin/src/components/core/layouts/art-settings-panel/widget/SettingHeader.vue b/web/apps/admin/src/components/core/layouts/art-settings-panel/widget/SettingHeader.vue new file mode 100644 index 0000000..e3ead9e --- /dev/null +++ b/web/apps/admin/src/components/core/layouts/art-settings-panel/widget/SettingHeader.vue @@ -0,0 +1,18 @@ + + + diff --git a/web/apps/admin/src/components/core/layouts/art-settings-panel/widget/SettingItem.vue b/web/apps/admin/src/components/core/layouts/art-settings-panel/widget/SettingItem.vue new file mode 100644 index 0000000..5721027 --- /dev/null +++ b/web/apps/admin/src/components/core/layouts/art-settings-panel/widget/SettingItem.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/web/apps/admin/src/components/core/layouts/art-settings-panel/widget/ThemeSettings.vue b/web/apps/admin/src/components/core/layouts/art-settings-panel/widget/ThemeSettings.vue new file mode 100644 index 0000000..4b46fcd --- /dev/null +++ b/web/apps/admin/src/components/core/layouts/art-settings-panel/widget/ThemeSettings.vue @@ -0,0 +1,28 @@ + + + diff --git a/web/apps/admin/src/components/core/layouts/art-work-tab/index.vue b/web/apps/admin/src/components/core/layouts/art-work-tab/index.vue new file mode 100755 index 0000000..152ff63 --- /dev/null +++ b/web/apps/admin/src/components/core/layouts/art-work-tab/index.vue @@ -0,0 +1,584 @@ + + + + + + diff --git a/web/apps/admin/src/components/core/media/art-cutter-img/index.vue b/web/apps/admin/src/components/core/media/art-cutter-img/index.vue new file mode 100644 index 0000000..191ceed --- /dev/null +++ b/web/apps/admin/src/components/core/media/art-cutter-img/index.vue @@ -0,0 +1,350 @@ + + + + + + diff --git a/web/apps/admin/src/components/core/media/art-video-player/index.vue b/web/apps/admin/src/components/core/media/art-video-player/index.vue new file mode 100644 index 0000000..4f681ea --- /dev/null +++ b/web/apps/admin/src/components/core/media/art-video-player/index.vue @@ -0,0 +1,111 @@ + + + + diff --git a/web/apps/admin/src/components/core/others/art-menu-right/index.vue b/web/apps/admin/src/components/core/others/art-menu-right/index.vue new file mode 100644 index 0000000..1cc92ab --- /dev/null +++ b/web/apps/admin/src/components/core/others/art-menu-right/index.vue @@ -0,0 +1,415 @@ + + + + + + diff --git a/web/apps/admin/src/components/core/others/art-watermark/index.vue b/web/apps/admin/src/components/core/others/art-watermark/index.vue new file mode 100644 index 0000000..1d7f06b --- /dev/null +++ b/web/apps/admin/src/components/core/others/art-watermark/index.vue @@ -0,0 +1,64 @@ + + + + diff --git a/web/apps/admin/src/components/core/tables/art-table-header/index.vue b/web/apps/admin/src/components/core/tables/art-table-header/index.vue new file mode 100644 index 0000000..788c2b7 --- /dev/null +++ b/web/apps/admin/src/components/core/tables/art-table-header/index.vue @@ -0,0 +1,339 @@ + + + + + + diff --git a/web/apps/admin/src/components/core/tables/art-table/index.vue b/web/apps/admin/src/components/core/tables/art-table/index.vue new file mode 100644 index 0000000..be99e70 --- /dev/null +++ b/web/apps/admin/src/components/core/tables/art-table/index.vue @@ -0,0 +1,368 @@ + + + + + + + + + diff --git a/web/apps/admin/src/components/core/tables/art-table/style.scss b/web/apps/admin/src/components/core/tables/art-table/style.scss new file mode 100644 index 0000000..67459e8 --- /dev/null +++ b/web/apps/admin/src/components/core/tables/art-table/style.scss @@ -0,0 +1,99 @@ +.art-table { + position: relative; + height: 100%; + + .el-table { + height: 100%; + margin-top: 10px; + } + + :deep(.el-loading-mask) { + z-index: 100; + background-color: var(--default-box-color) !important; + } + + // Loading 过渡动画 - 消失时淡出 + .loading-fade-leave-active { + transition: opacity 0.3s ease-out; + } + + .loading-fade-leave-to { + opacity: 0; + } + + // 空状态垂直居中 + &.is-empty { + :deep(.el-scrollbar__wrap) { + display: flex; + } + } + + .pagination { + display: flex; + margin-top: 13px; + + :deep(.el-select) { + width: 102px !important; + } + + // 分页对齐方式 + &.left { + justify-content: flex-start; + } + + &.center { + justify-content: center; + } + + &.right { + justify-content: flex-end; + } + + // 自定义分页组件样式 + &.custom-pagination { + :deep(.el-pagination) { + .btn-prev, + .btn-next { + background-color: transparent; + border: 1px solid var(--art-gray-300); + transition: border-color 0.15s; + + &:hover:not(.is-disabled) { + color: var(--theme-color); + border-color: var(--theme-color); + } + } + + li { + box-sizing: border-box; + font-weight: 400 !important; + background-color: transparent; + border: 1px solid var(--art-gray-300); + transition: border-color 0.15s; + + &.is-active { + font-weight: 400; + color: #fff; + background-color: var(--theme-color); + border: 1px solid var(--theme-color); + } + + &:hover:not(.is-disabled) { + border-color: var(--theme-color); + } + } + } + } + } +} + +// 移动端分页 +@media (width <= 640px) { + :deep(.el-pagination) { + display: flex; + flex-wrap: wrap; + gap: 15px 0; + align-items: center; + justify-content: center; + } +} diff --git a/web/apps/admin/src/components/core/text-effect/art-count-to/index.vue b/web/apps/admin/src/components/core/text-effect/art-count-to/index.vue new file mode 100644 index 0000000..7fb104b --- /dev/null +++ b/web/apps/admin/src/components/core/text-effect/art-count-to/index.vue @@ -0,0 +1,310 @@ + + + + diff --git a/web/apps/admin/src/components/core/text-effect/art-festival-text-scroll/index.vue b/web/apps/admin/src/components/core/text-effect/art-festival-text-scroll/index.vue new file mode 100644 index 0000000..770b457 --- /dev/null +++ b/web/apps/admin/src/components/core/text-effect/art-festival-text-scroll/index.vue @@ -0,0 +1,32 @@ + + + + diff --git a/web/apps/admin/src/components/core/text-effect/art-text-scroll/index.vue b/web/apps/admin/src/components/core/text-effect/art-text-scroll/index.vue new file mode 100644 index 0000000..90be30f --- /dev/null +++ b/web/apps/admin/src/components/core/text-effect/art-text-scroll/index.vue @@ -0,0 +1,285 @@ + + + + diff --git a/web/apps/admin/src/components/core/theme/theme-svg/index.vue b/web/apps/admin/src/components/core/theme/theme-svg/index.vue new file mode 100644 index 0000000..0b565a9 --- /dev/null +++ b/web/apps/admin/src/components/core/theme/theme-svg/index.vue @@ -0,0 +1,100 @@ + + + + + + + diff --git a/web/apps/admin/src/components/core/views/exception/ArtException.vue b/web/apps/admin/src/components/core/views/exception/ArtException.vue new file mode 100644 index 0000000..4be59ae --- /dev/null +++ b/web/apps/admin/src/components/core/views/exception/ArtException.vue @@ -0,0 +1,55 @@ + + + diff --git a/web/apps/admin/src/components/core/views/login/AuthTopBar.vue b/web/apps/admin/src/components/core/views/login/AuthTopBar.vue new file mode 100644 index 0000000..e47bba0 --- /dev/null +++ b/web/apps/admin/src/components/core/views/login/AuthTopBar.vue @@ -0,0 +1,171 @@ + + + + + + diff --git a/web/apps/admin/src/components/core/views/login/LoginLeftView.vue b/web/apps/admin/src/components/core/views/login/LoginLeftView.vue new file mode 100644 index 0000000..cbd53ef --- /dev/null +++ b/web/apps/admin/src/components/core/views/login/LoginLeftView.vue @@ -0,0 +1,453 @@ + + + + + + diff --git a/web/apps/admin/src/components/core/views/result/ArtResultPage.vue b/web/apps/admin/src/components/core/views/result/ArtResultPage.vue new file mode 100644 index 0000000..b2eca48 --- /dev/null +++ b/web/apps/admin/src/components/core/views/result/ArtResultPage.vue @@ -0,0 +1,43 @@ + + + diff --git a/web/apps/admin/src/components/core/widget/art-icon-button/index.vue b/web/apps/admin/src/components/core/widget/art-icon-button/index.vue new file mode 100644 index 0000000..760888b --- /dev/null +++ b/web/apps/admin/src/components/core/widget/art-icon-button/index.vue @@ -0,0 +1,23 @@ + + + + diff --git a/web/apps/admin/src/config/assets/images.ts b/web/apps/admin/src/config/assets/images.ts new file mode 100644 index 0000000..f3e89dd --- /dev/null +++ b/web/apps/admin/src/config/assets/images.ts @@ -0,0 +1,61 @@ +/** + * 配置图片资源 + * + * 统一管理设置中心使用的预览图片资源。 + * 包含主题样式、菜单布局、菜单风格的预览图。 + * + * ## 图片分类 + * + * - themeStyles: 系统主题预览图(亮色/暗色/自动) + * - menuLayouts: 菜单布局预览图(左侧/顶部/混合/双栏) + * - menuStyles: 菜单风格预览图(设计/暗色/亮色) + * + * @module config/assets/images + * @author Art Design Pro Team + */ + +import lightTheme from '@imgs/settings/theme_styles/light.png' +import darkTheme from '@imgs/settings/theme_styles/dark.png' +import systemTheme from '@imgs/settings/theme_styles/system.png' +import verticalLayout from '@imgs/settings/menu_layouts/vertical.png' +import horizontalLayout from '@imgs/settings/menu_layouts/horizontal.png' +import mixedLayout from '@imgs/settings/menu_layouts/mixed.png' +import dualColumnLayout from '@imgs/settings/menu_layouts/dual_column.png' +import designStyle from '@imgs/settings/menu_styles/design.png' +import darkStyle from '@imgs/settings/menu_styles/dark.png' +import lightStyle from '@imgs/settings/menu_styles/light.png' + +/** + * 配置中心图片资源对象 + */ +export const configImages = { + /** 系统主题预览图 */ + themeStyles: { + /** 亮色主题 */ + light: lightTheme, + /** 暗色主题 */ + dark: darkTheme, + /** 自动主题(跟随系统) */ + system: systemTheme + }, + /** 菜单布局预览图 */ + menuLayouts: { + /** 左侧菜单 */ + vertical: verticalLayout, + /** 顶部菜单 */ + horizontal: horizontalLayout, + /** 混合菜单 */ + mixed: mixedLayout, + /** 双栏菜单 */ + dualColumn: dualColumnLayout + }, + /** 菜单风格预览图 */ + menuStyles: { + /** 设计风格 */ + design: designStyle, + /** 暗色风格 */ + dark: darkStyle, + /** 亮色风格 */ + light: lightStyle + } +} diff --git a/web/apps/admin/src/config/fastEnter.ts b/web/apps/admin/src/config/fastEnter.ts new file mode 100644 index 0000000..ccade16 --- /dev/null +++ b/web/apps/admin/src/config/fastEnter.ts @@ -0,0 +1,79 @@ +/** + * 快速入口配置 + * 包含:应用列表、快速链接等配置 + */ +import { WEB_LINKS } from '@/utils/constants' +import type { FastEnterConfig } from '@/types/config' + +const fastEnterConfig: FastEnterConfig = { + // 显示条件(屏幕宽度) + minWidth: 1200, + // 应用列表 + applications: [ + { + name: '工作台', + description: '系统概览与数据统计', + icon: 'ri:pie-chart-line', + iconColor: '#377dff', + enabled: true, + order: 1, + routeName: 'Console' + }, + { + name: '官方文档', + description: '使用指南与开发文档', + icon: 'ri:bill-line', + iconColor: '#ffb100', + enabled: true, + order: 2, + link: WEB_LINKS.DOCS + }, + { + name: '技术支持', + description: '技术支持与问题反馈', + icon: 'ri:user-location-line', + iconColor: '#ff6b6b', + enabled: true, + order: 3, + link: WEB_LINKS.COMMUNITY + }, + { + name: '哔哩哔哩', + description: '技术分享与交流', + icon: 'ri:bilibili-line', + iconColor: '#FB7299', + enabled: true, + order: 4, + link: WEB_LINKS.BILIBILI + } + ], + // 快速链接 + quickLinks: [ + { + name: '登录', + enabled: true, + order: 1, + routeName: 'Login' + }, + { + name: '注册', + enabled: true, + order: 2, + routeName: 'Register' + }, + { + name: '忘记密码', + enabled: true, + order: 3, + routeName: 'ForgetPassword' + }, + { + name: '个人中心', + enabled: true, + order: 4, + routeName: 'UserCenter' + } + ] +} + +export default Object.freeze(fastEnterConfig) diff --git a/web/apps/admin/src/config/index.ts b/web/apps/admin/src/config/index.ts new file mode 100644 index 0000000..461ab20 --- /dev/null +++ b/web/apps/admin/src/config/index.ts @@ -0,0 +1,136 @@ +/** + * 系统全局配置 + * + * 这是系统的核心配置文件,集中管理所有全局配置项。 + * 包含系统信息、主题样式、菜单布局、颜色方案等所有可配置项。 + * + * ## 主要功能 + * + * - 系统信息 - 系统名称等基础信息 + * - 主题配置 - 亮色/暗色/自动主题的样式配置 + * - 菜单配置 - 菜单布局、主题、宽度等配置 + * - 颜色方案 - 系统主色和预设颜色列表 + * - 快速入口 - 快速入口应用和链接配置 + * - 顶部栏配置 - 顶部栏功能模块配置 + * + * ## 配置项说明 + * + * - systemInfo: 系统基础信息(名称等) + * - systemThemeStyles: 系统主题样式映射 + * - settingThemeList: 可选的系统主题列表 + * - menuLayoutList: 可选的菜单布局列表 + * - themeList: 菜单主题样式列表 + * - darkMenuStyles: 暗黑模式下的菜单样式 + * - systemMainColor: 预设的系统主色列表 + * - fastEnter: 快速入口配置 + * - headerBar: 顶部栏功能配置 + * + * @module config + * @author Art Design Pro Team + */ + +import { MenuThemeEnum, MenuTypeEnum, SystemThemeEnum } from '@/enums/appEnum' +import { SystemConfig } from '@/types/config' +import { configImages } from './assets/images' +import fastEnterConfig from './modules/fastEnter' +import { headerBarConfig } from './modules/headerBar' + +const appConfig: SystemConfig = { + // 系统信息 + systemInfo: { + name: 'LLMGuardX语枢管理中心', // 系统名称 + shortName: 'LLMGuardX语枢' // 侧边栏/顶栏紧凑显示 + }, + // 系统主题 + systemThemeStyles: { + [SystemThemeEnum.LIGHT]: { className: '' }, + [SystemThemeEnum.DARK]: { className: SystemThemeEnum.DARK } + }, + // 系统主题列表 + settingThemeList: [ + { + name: 'Light', + theme: SystemThemeEnum.LIGHT, + color: ['#fff', '#fff'], + leftLineColor: '#EDEEF0', + rightLineColor: '#EDEEF0', + img: configImages.themeStyles.light + }, + { + name: 'Dark', + theme: SystemThemeEnum.DARK, + color: ['#22252A'], + leftLineColor: '#3F4257', + rightLineColor: '#3F4257', + img: configImages.themeStyles.dark + }, + { + name: 'System', + theme: SystemThemeEnum.AUTO, + color: ['#fff', '#22252A'], + leftLineColor: '#EDEEF0', + rightLineColor: '#3F4257', + img: configImages.themeStyles.system + } + ], + // 菜单布局列表 + menuLayoutList: [ + { name: 'Left', value: MenuTypeEnum.LEFT, img: configImages.menuLayouts.vertical }, + { name: 'Top', value: MenuTypeEnum.TOP, img: configImages.menuLayouts.horizontal }, + { name: 'Mixed', value: MenuTypeEnum.TOP_LEFT, img: configImages.menuLayouts.mixed }, + { name: 'Dual Column', value: MenuTypeEnum.DUAL_MENU, img: configImages.menuLayouts.dualColumn } + ], + // 菜单主题列表 + themeList: [ + { + theme: MenuThemeEnum.DESIGN, + background: '#FFFFFF', + systemNameColor: 'var(--art-gray-800)', + iconColor: '#6B6B6B', + textColor: '#29343D', + img: configImages.menuStyles.design + }, + { + theme: MenuThemeEnum.DARK, + background: '#191A23', + systemNameColor: '#D9DADB', + iconColor: '#BABBBD', + textColor: '#BABBBD', + img: configImages.menuStyles.dark + }, + { + theme: MenuThemeEnum.LIGHT, + background: '#ffffff', + systemNameColor: 'var(--art-gray-800)', + iconColor: '#6B6B6B', + textColor: '#29343D', + img: configImages.menuStyles.light + } + ], + // 暗黑模式菜单样式 + darkMenuStyles: [ + { + theme: MenuThemeEnum.DARK, + background: 'var(--default-box-color)', + systemNameColor: '#DDDDDD', + iconColor: '#BABBBD', + textColor: 'rgba(#FFFFFF, 0.7)' + } + ], + // 系统主色(首项为默认主色:语枢网关电光青 #00E5FF) + systemMainColor: [ + '#00E5FF', + '#2F80FF', + '#1D84FF', + '#60C041', + '#38C0FC', + '#F9901F', + '#FF80C8' + ] as const, + // 快速入口配置 + fastEnter: fastEnterConfig, + // 顶部栏功能配置 + headerBar: headerBarConfig +} + +export default Object.freeze(appConfig) diff --git a/web/apps/admin/src/config/modules/component.ts b/web/apps/admin/src/config/modules/component.ts new file mode 100644 index 0000000..bc709e0 --- /dev/null +++ b/web/apps/admin/src/config/modules/component.ts @@ -0,0 +1,105 @@ +/** + * 全局组件配置 + * + * 统一管理系统级全局组件的注册。 + * 这些组件会在应用启动时全局注册,可在任何地方使用。 + * + * ## 主要功能 + * + * - 组件配置 - 集中管理全局组件的配置信息 + * - 异步加载 - 使用 defineAsyncComponent 实现按需加载 + * - 开关控制 - 支持通过 enabled 字段启用/禁用组件 + * - 配置查询 - 提供工具函数快速查询组件配置 + * + * @module config/component + * @author Art Design Pro Team + */ + +import { defineAsyncComponent } from 'vue' + +/** + * 全局组件配置列表 + */ +export const globalComponentsConfig: GlobalComponentConfig[] = [ + { + name: '设置面板', + key: 'settings-panel', + component: defineAsyncComponent( + () => import('@/components/core/layouts/art-settings-panel/index.vue') + ), + enabled: true + }, + { + name: '全局搜索', + key: 'global-search', + component: defineAsyncComponent( + () => import('@/components/core/layouts/art-global-search/index.vue') + ), + enabled: true + }, + { + name: '锁屏', + key: 'screen-lock', + component: defineAsyncComponent( + () => import('@/components/core/layouts/art-screen-lock/index.vue') + ), + enabled: true + }, + { + name: '聊天窗口', + key: 'chat-window', + component: defineAsyncComponent( + () => import('@/components/core/layouts/art-chat-window/index.vue') + ), + enabled: true + }, + { + name: '礼花效果', + key: 'fireworks-effect', + component: defineAsyncComponent( + () => import('@/components/core/layouts/art-fireworks-effect/index.vue') + ), + enabled: true + }, + { + name: '水印效果', + key: 'watermark', + component: defineAsyncComponent( + () => import('@/components/core/others/art-watermark/index.vue') + ), + enabled: true + } +] + +/** + * 全局组件配置接口 + */ +export interface GlobalComponentConfig { + /** 组件名称 */ + name: string + /** 组件标识 */ + key: string + /** 组件 */ + component: any + /** 是否启用 */ + enabled?: boolean + /** 组件描述 */ + description?: string +} + +/** + * 获取启用的全局组件 + * @returns 已启用的组件配置列表 + */ +export const getEnabledGlobalComponents = () => { + return globalComponentsConfig.filter((config) => config.enabled !== false) +} + +/** + * 根据 key 获取组件配置 + * @param key 组件标识 + * @returns 组件配置对象 + */ +export const getGlobalComponentByKey = (key: string) => { + return globalComponentsConfig.find((config) => config.key === key) +} diff --git a/web/apps/admin/src/config/modules/fastEnter.ts b/web/apps/admin/src/config/modules/fastEnter.ts new file mode 100644 index 0000000..6b9740c --- /dev/null +++ b/web/apps/admin/src/config/modules/fastEnter.ts @@ -0,0 +1,127 @@ +/** + * 快速入口配置 + * 包含:应用列表、快速链接等配置 + */ +import { WEB_LINKS } from '@/utils/constants' +import type { FastEnterConfig } from '@/types/config' + +const fastEnterConfig: FastEnterConfig = { + // 显示条件(屏幕宽度) + minWidth: 1200, + // 应用列表 + applications: [ + { + name: '工作台', + description: '系统概览与数据统计', + icon: 'ri:pie-chart-line', + iconColor: '#377dff', + enabled: true, + order: 1, + routeName: 'Console' + }, + { + name: '分析页', + description: '数据分析与可视化', + icon: 'ri:game-line', + iconColor: '#ff3b30', + enabled: true, + order: 2, + routeName: 'Analysis' + }, + { + name: '礼花效果', + description: '动画特效展示', + icon: 'ri:loader-line', + iconColor: '#7A7FFF', + enabled: true, + order: 3, + routeName: 'Fireworks' + }, + { + name: '聊天', + description: '即时通讯功能', + icon: 'ri:user-line', + iconColor: '#13DEB9', + enabled: true, + order: 4, + routeName: 'Chat' + }, + { + name: '官方文档', + description: '使用指南与开发文档', + icon: 'ri:bill-line', + iconColor: '#ffb100', + enabled: true, + order: 5, + link: WEB_LINKS.DOCS + }, + { + name: '技术支持', + description: '技术支持与问题反馈', + icon: 'ri:user-location-line', + iconColor: '#ff6b6b', + enabled: true, + order: 6, + link: WEB_LINKS.COMMUNITY + }, + { + name: '更新日志', + description: '版本更新与变更记录', + icon: 'ri:gamepad-line', + iconColor: '#38C0FC', + enabled: true, + order: 7, + routeName: 'ChangeLog' + }, + { + name: '哔哩哔哩', + description: '技术分享与交流', + icon: 'ri:bilibili-line', + iconColor: '#FB7299', + enabled: true, + order: 8, + link: WEB_LINKS.BILIBILI + } + ], + // 快速链接 + quickLinks: [ + { + name: '登录', + enabled: true, + order: 1, + routeName: 'Login' + }, + { + name: '注册', + enabled: true, + order: 2, + routeName: 'Register' + }, + { + name: '忘记密码', + enabled: true, + order: 3, + routeName: 'ForgetPassword' + }, + { + name: '定价', + enabled: true, + order: 4, + routeName: 'Pricing' + }, + { + name: '个人中心', + enabled: true, + order: 5, + routeName: 'UserCenter' + }, + { + name: '留言管理', + enabled: true, + order: 6, + routeName: 'ArticleComment' + } + ] +} + +export default Object.freeze(fastEnterConfig) diff --git a/web/apps/admin/src/config/modules/festival.ts b/web/apps/admin/src/config/modules/festival.ts new file mode 100644 index 0000000..39cd790 --- /dev/null +++ b/web/apps/admin/src/config/modules/festival.ts @@ -0,0 +1,51 @@ +/** + * 节日庆祝配置 + * + * 配置系统的节日烟花效果和祝福文本。 + * 支持单日节日和跨日期节日,可自定义烟花播放次数。 + * + * ## 配置说明 + * + * - name: 节日名称 + * - date: 节日开始日期(格式:YYYY-MM-DD) + * - endDate: 节日结束日期(可选,用于跨日期节日) + * - image: 烟花图片(需要预先导入) + * - scrollText: 滚动显示的祝福文本 + * - count: 烟花播放次数(可选,默认为 3 次) + * + * ## 注意事项 + * + * - 图片需要预先导入并在配置中引用 + * - 跨日期节日会在整个日期范围内生效 + * - 每个用户每天只会播放一次烟花效果 + * + * @module config/modules/festival + * @author Art Design Pro Team + */ + +import { FestivalConfig } from '@/types/config' + +// 导入烟花图片(根据需要取消注释) +// import sd from '@imgs/ceremony/sd.png' +// import yd from '@imgs/ceremony/yd.png' + +export const festivalConfigList: FestivalConfig[] = [ + // 跨日期示例 + // { + // name: 'v3.0 Sass 升级至 TailwindCSS', + // date: '2025-11-03', + // endDate: '2025-11-09', + // image: '', + // count: 3, + // scrollText: + // '🚀 系统 v3.0 测试阶段正式开启!测试周期为 11 月 3 日 - 11 月 16 日,通过 TailwindCSS 重构样式体系、统一 Iconify 图标方案,带来更高效现代的开发体验,正式发布敬请期待~' + // } + // 单日示例:圣诞节 + // { + // name: '圣诞节', + // date: '2024-12-25', + // image: sd, + // count: 3 // 可选,不设置则使用默认值 3 次 + // scrollText: 'Merry Christmas!Art Design Pro 祝您圣诞快乐,愿节日的欢乐与祝福如雪花般纷至沓来!', + // } +] diff --git a/web/apps/admin/src/config/modules/headerBar.ts b/web/apps/admin/src/config/modules/headerBar.ts new file mode 100644 index 0000000..a420e82 --- /dev/null +++ b/web/apps/admin/src/config/modules/headerBar.ts @@ -0,0 +1,63 @@ +/** + * 顶部栏功能配置 + * + * 统一管理顶部栏各个功能模块的启用状态。 + * 通过修改此配置文件可以快速启用或禁用顶部栏的功能按钮。 + * + * @module config/headerBar + * @author Art Design Pro Team + */ + +import { HeaderBarFeatureConfig } from '@/types' + +/** + * 顶部栏功能配置对象 + */ +export const headerBarConfig: HeaderBarFeatureConfig = { + menuButton: { + enabled: true, + description: '控制左侧菜单的展开/收起按钮' + }, + refreshButton: { + enabled: true, + description: '页面刷新按钮' + }, + fastEnter: { + enabled: true, + description: '快速入口功能,提供常用应用和链接的快速访问' + }, + breadcrumb: { + enabled: true, + description: '面包屑导航,显示当前页面路径' + }, + globalSearch: { + enabled: true, + description: '全局搜索功能,支持快捷键 Ctrl+K 或 Cmd+K' + }, + fullscreen: { + enabled: true, + description: '全屏切换功能' + }, + notification: { + enabled: true, + description: '通知中心,显示系统通知和消息' + }, + chat: { + enabled: true, + description: '聊天功能,提供实时沟通' + }, + language: { + enabled: true, + description: '多语言切换功能' + }, + settings: { + enabled: true, + description: '系统设置面板' + }, + themeToggle: { + enabled: true, + description: '主题切换功能(明暗主题)' + } +} + +export default headerBarConfig diff --git a/web/apps/admin/src/config/setting.ts b/web/apps/admin/src/config/setting.ts new file mode 100644 index 0000000..20a0a0d --- /dev/null +++ b/web/apps/admin/src/config/setting.ts @@ -0,0 +1,109 @@ +/** + * 系统设置默认值配置 + * + * 统一管理系统设置的所有默认值 + * + * ## 主要功能 + * + * - 菜单相关默认配置 + * - 主题相关默认配置 + * - 界面显示默认配置 + * - 功能开关默认配置 + * - 样式相关默认配置 + * + * ## 注意事项 + * + * 1. 修改此文件的配置项时,需要同步更新以下文件: + * - src/components/core/layouts/art-settings-panel/widget/SettingActions.vue(复制配置和重置配置逻辑) + * - src/store/modules/setting.ts(Store 状态定义) + * 2. 可以通过设置面板的"复制配置"按钮快速生成配置代码 + * 3. 枚举类型的值需要与 src/enums/appEnum.ts 中的定义保持一致 + */ + +import AppConfig from '@/config' +import { SystemThemeEnum, MenuThemeEnum, MenuTypeEnum, ContainerWidthEnum } from '@/enums/appEnum' + +/** + * 系统设置默认值配置 + */ +export const SETTING_DEFAULT_CONFIG = { + /** 菜单类型 */ + menuType: MenuTypeEnum.LEFT, + /** 菜单展开宽度 */ + menuOpenWidth: 230, + /** 菜单是否展开 */ + menuOpen: true, + /** 双菜单是否显示文本 */ + dualMenuShowText: false, + /** 系统主题类型(语枢网关默认深色 HUD,.dark 依赖此项) */ + systemThemeType: SystemThemeEnum.DARK, + /** 系统主题模式 */ + systemThemeMode: SystemThemeEnum.DARK, + /** 菜单风格 */ + menuThemeType: MenuThemeEnum.DESIGN, + /** 系统主题颜色 */ + systemThemeColor: AppConfig.systemMainColor[0], + /** 是否显示菜单按钮 */ + showMenuButton: true, + /** 是否显示快速入口 */ + showFastEnter: true, + /** 是否显示刷新按钮 */ + showRefreshButton: true, + /** 是否显示面包屑 */ + showCrumbs: true, + /** 是否显示工作台标签 */ + showWorkTab: true, + /** 是否显示语言切换 */ + showLanguage: true, + /** 是否显示进度条 */ + showNprogress: false, + /** 是否显示设置引导 */ + showSettingGuide: true, + /** 是否显示节日文本 */ + showFestivalText: false, + /** 是否显示水印 */ + watermarkVisible: false, + /** 是否自动关闭 */ + autoClose: false, + /** 是否唯一展开 */ + uniqueOpened: true, + /** 是否色弱模式 */ + colorWeak: false, + /** 是否刷新 */ + refresh: false, + /** 是否加载节日烟花 */ + holidayFireworksLoaded: false, + /** 边框模式 */ + boxBorderMode: true, + /** 页面过渡效果 */ + pageTransition: 'slide-left', + /** 标签页样式 */ + tabStyle: 'tab-default', + /** 自定义圆角 */ + customRadius: '0.75', + /** 容器宽度 */ + containerWidth: ContainerWidthEnum.FULL, + /** 节日日期 */ + festivalDate: '' +} + +/** + * 获取设置默认值 + * @returns 设置默认值对象 + */ +export function getSettingDefaults() { + return { ...SETTING_DEFAULT_CONFIG } +} + +/** + * 重置为默认设置 + * @param currentSettings 当前设置对象 + */ +export function resetToDefaults(currentSettings: Record) { + const defaults = getSettingDefaults() + Object.keys(defaults).forEach((key) => { + if (key in currentSettings) { + currentSettings[key] = defaults[key as keyof typeof defaults] + } + }) +} diff --git a/web/apps/admin/src/directives/business/highlight.ts b/web/apps/admin/src/directives/business/highlight.ts new file mode 100644 index 0000000..07b6c1d --- /dev/null +++ b/web/apps/admin/src/directives/business/highlight.ts @@ -0,0 +1,250 @@ +/** + * v-highlight 代码高亮指令 + * + * 为代码块提供语法高亮、行号显示和一键复制功能。 + * 基于 highlight.js 实现,支持多种编程语言的语法高亮。 + * + * ## 主要功能 + * + * - 语法高亮 - 使用 highlight.js 自动识别并高亮代码 + * - 行号显示 - 自动为每行代码添加行号 + * - 一键复制 - 提供复制按钮,点击即可复制代码(自动过滤行号) + * - 性能优化 - 批量处理代码块,避免阻塞渲染 + * - 动态监听 - 使用 MutationObserver 监听新增代码块 + * - 防重复处理 - 自动标记已处理的代码块,避免重复处理 + * + * ## 使用示例 + * + * ```vue + * + * ``` + * + * ## 性能优化 + * + * - 批量处理:每次处理 10 个代码块,避免长时间阻塞 + * - 延迟处理:使用 requestAnimationFrame 分批处理 + * - 重试机制:自动重试处理失败的代码块 + * - 智能监听:只在有新代码块时才触发处理 + * + * @module directives/highlight + * @author Art Design Pro Team + */ + +import { App, Directive } from 'vue' +import hljs from 'highlight.js' + +export type HighlightDirective = Directive + +// 高亮代码 +function highlightCode(block: HTMLElement) { + hljs.highlightElement(block) +} + +// 插入行号 +function insertLineNumbers(block: HTMLElement) { + const lines = block.innerHTML.split('\n') + const numberedLines = lines + .map((line, index) => { + return `${index + 1} ${line}` + }) + .join('\n') + block.innerHTML = numberedLines +} + +// 添加复制按钮:调整 DOM 结构,将代码部分包裹在 .code-wrapper 内 +function addCopyButton(block: HTMLElement) { + const copyButton = document.createElement('i') + copyButton.className = 'copy-button' + copyButton.innerHTML = + '' + copyButton.onclick = () => { + // 过滤掉行号,只复制代码内容 + const codeContent = block.innerText.replace(/^\d+\s+/gm, '') + navigator.clipboard.writeText(codeContent).then(() => { + ElMessage.success('复制成功') + }) + } + + const preElement = block.parentElement + if (preElement) { + let codeWrapper: HTMLElement + // 如果代码块还没有被包裹,则创建包裹容器 + if (!block.parentElement.classList.contains('code-wrapper')) { + codeWrapper = document.createElement('div') + codeWrapper.className = 'code-wrapper' + preElement.replaceChild(codeWrapper, block) + codeWrapper.appendChild(block) + } else { + codeWrapper = block.parentElement + } + // 将复制按钮添加到 pre 元素(而非 codeWrapper 内),这样它不会随滚动条滚动 + preElement.appendChild(copyButton) + } +} + +// 检查代码块是否已经被处理过 +function isBlockProcessed(block: HTMLElement): boolean { + return ( + block.hasAttribute('data-highlighted') || + !!block.querySelector('.line-number') || + !!block.parentElement?.querySelector('.copy-button') + ) +} + +// 标记代码块为已处理 +function markBlockAsProcessed(block: HTMLElement) { + block.setAttribute('data-highlighted', 'true') +} + +// 处理单个代码块 +function processBlock(block: HTMLElement) { + if (isBlockProcessed(block)) { + return + } + + try { + highlightCode(block) + insertLineNumbers(block) + addCopyButton(block) + markBlockAsProcessed(block) + } catch (error) { + console.warn('处理代码块时出错:', error) + } +} + +// 查找并处理所有代码块 +function processAllCodeBlocks(el: HTMLElement) { + const blocks = Array.from(el.querySelectorAll('pre code')) + const unprocessedBlocks = blocks.filter((block) => !isBlockProcessed(block)) + + if (unprocessedBlocks.length === 0) { + return + } + + if (unprocessedBlocks.length <= 10) { + // 如果代码块数量少于等于10,直接处理所有代码块 + unprocessedBlocks.forEach((block) => processBlock(block)) + } else { + // 定义每次处理的代码块数 + const batchSize = 10 + let currentIndex = 0 + + const processBatch = () => { + const batch = unprocessedBlocks.slice(currentIndex, currentIndex + batchSize) + + batch.forEach((block) => { + processBlock(block) + }) + + // 更新索引并继续处理下一批 + currentIndex += batchSize + if (currentIndex < unprocessedBlocks.length) { + // 使用 requestAnimationFrame 确保下一帧再处理 + requestAnimationFrame(processBatch) + } + } + + // 开始处理第一批代码块 + processBatch() + } +} + +// 重试处理函数 +function retryProcessing(el: HTMLElement, maxRetries: number = 3, delay: number = 200) { + let retryCount = 0 + + const tryProcess = () => { + processAllCodeBlocks(el) + + // 检查是否还有未处理的代码块 + const remainingBlocks = Array.from(el.querySelectorAll('pre code')).filter( + (block) => !isBlockProcessed(block) + ) + + if (remainingBlocks.length > 0 && retryCount < maxRetries) { + retryCount++ + setTimeout(tryProcess, delay * retryCount) // 递增延迟 + } + } + + tryProcess() +} + +// 代码高亮、插入行号、复制按钮 +const highlightDirective: HighlightDirective = { + mounted(el: HTMLElement) { + // 立即尝试处理一次 + processAllCodeBlocks(el) + + // 延迟处理,确保 v-html 内容已经渲染 + setTimeout(() => { + retryProcessing(el) + }, 100) + + // 使用 MutationObserver 监听 DOM 变化 + const observer = new MutationObserver((mutations) => { + let hasNewCodeBlocks = false + + mutations.forEach((mutation) => { + if (mutation.type === 'childList') { + mutation.addedNodes.forEach((node) => { + if (node.nodeType === Node.ELEMENT_NODE) { + const element = node as HTMLElement + // 检查新添加的节点是否包含代码块 + if (element.tagName === 'PRE' || element.querySelector('pre code')) { + hasNewCodeBlocks = true + } + } + }) + } + }) + + if (hasNewCodeBlocks) { + // 延迟处理新添加的代码块 + setTimeout(() => { + processAllCodeBlocks(el) + }, 50) + } + }) + + // 开始观察 + observer.observe(el, { + childList: true, + subtree: true + }) + + // 将 observer 存储到元素上,以便在 unmounted 时清理 + ;(el as any)._highlightObserver = observer + }, + + updated(el: HTMLElement) { + // 当组件更新时,重新处理代码块 + setTimeout(() => { + processAllCodeBlocks(el) + }, 50) + }, + + unmounted(el: HTMLElement) { + // 清理 MutationObserver + const observer = (el as any)._highlightObserver + if (observer) { + observer.disconnect() + delete (el as any)._highlightObserver + } + } +} + +export function setupHighlightDirective(app: App) { + app.directive('highlight', highlightDirective) +} diff --git a/web/apps/admin/src/directives/business/ripple.ts b/web/apps/admin/src/directives/business/ripple.ts new file mode 100644 index 0000000..e65470e --- /dev/null +++ b/web/apps/admin/src/directives/business/ripple.ts @@ -0,0 +1,116 @@ +/** + * v-ripple 水波纹效果指令 + * + * 为元素添加 Material Design 风格的水波纹点击效果。 + * 点击时从点击位置扩散出圆形水波纹动画,提升交互体验。 + * + * ## 主要功能 + * + * - 水波纹动画 - 点击时从点击位置扩散圆形波纹 + * - 自适应大小 - 根据元素尺寸自动调整波纹大小和动画时长 + * - 智能配色 - 自动识别按钮类型,使用合适的波纹颜色 + * - 自定义颜色 - 支持通过参数自定义波纹颜色 + * - 性能优化 - 使用 requestAnimationFrame 和自动清理机制 + * + * ## 使用示例 + * + * ```vue + * + * ``` + * + * ## 颜色规则 + * + * - 有色按钮(primary、success、warning 等):使用白色半透明波纹 + * - 默认按钮:使用主题色半透明波纹 + * - 自定义:通过 color 参数指定任意颜色 + * + * @module directives/ripple + * @author Art Design Pro Team + */ + +import type { App, Directive, DirectiveBinding } from 'vue' + +export interface RippleOptions { + /** 水波纹颜色 */ + color?: string +} + +export type RippleDirective = Directive + +export const vRipple: RippleDirective = { + mounted(el: HTMLElement, binding: DirectiveBinding) { + // 获取指令的配置参数 + const options: RippleOptions = binding.value || {} + + // 设置元素为相对定位,并隐藏溢出部分 + el.style.position = 'relative' + el.style.overflow = 'hidden' + + // 点击事件处理 + el.addEventListener('mousedown', (e: MouseEvent) => { + const rect = el.getBoundingClientRect() + const left = e.clientX - rect.left + const top = e.clientY - rect.top + + // 创建水波纹元素 + const ripple = document.createElement('div') + const diameter = Math.max(el.clientWidth, el.clientHeight) + const radius = diameter / 2 + + // 根据直径计算动画时间(直径越大,动画时间越长) + const baseTime = 600 // 基础动画时间(毫秒) + const scaleFactor = 0.5 // 缩放因子 + const animationDuration = baseTime + diameter * scaleFactor + + // 设置水波纹的尺寸和位置 + ripple.style.width = ripple.style.height = `${diameter}px` + ripple.style.left = `${left - radius}px` + ripple.style.top = `${top - radius}px` + ripple.style.position = 'absolute' + ripple.style.borderRadius = '50%' + ripple.style.pointerEvents = 'none' + + // 判断是否为有色按钮(Element Plus 按钮类型) + const buttonTypes = ['primary', 'info', 'warning', 'danger', 'success'].map( + (type) => `el-button--${type}` + ) + const isColoredButton = buttonTypes.some((type) => el.classList.contains(type)) + const defaultColor = isColoredButton + ? 'rgba(255, 255, 255, 0.25)' // 有色按钮使用白色水波纹 + : 'var(--el-color-primary-light-7)' // 默认按钮使用主题色水波纹 + + // 设置水波纹颜色、初始状态和过渡效果 + ripple.style.backgroundColor = options.color || defaultColor + ripple.style.transform = 'scale(0)' + ripple.style.transition = `transform ${animationDuration}ms cubic-bezier(0.3, 0, 0.2, 1), opacity ${animationDuration}ms cubic-bezier(0.3, 0, 0.5, 1)` + ripple.style.zIndex = '1' + + // 添加水波纹元素到DOM中 + el.appendChild(ripple) + + // 触发动画 + requestAnimationFrame(() => { + ripple.style.transform = 'scale(2)' + ripple.style.opacity = '0' + }) + + // 动画结束后移除水波纹元素 + setTimeout(() => { + ripple.remove() + }, animationDuration + 500) // 增加500ms缓冲时间 + }) + } +} + +export function setupRippleDirective(app: App) { + app.directive('ripple', vRipple) +} diff --git a/web/apps/admin/src/directives/core/auth.ts b/web/apps/admin/src/directives/core/auth.ts new file mode 100755 index 0000000..3d244c7 --- /dev/null +++ b/web/apps/admin/src/directives/core/auth.ts @@ -0,0 +1,66 @@ +/** + * v-auth 权限指令 + * + * 适用于后端权限控制模式,基于权限标识控制 DOM 元素的显示和隐藏。 + * 如果用户没有对应权限,元素将从 DOM 中移除。 + * + * ## 主要功能 + * + * - 权限验证 - 根据路由 meta 中的权限列表验证用户权限 + * - DOM 控制 - 无权限时自动移除元素,而非隐藏 + * - 响应式更新 - 权限变化时自动更新元素状态 + * + * ## 使用示例 + * + * ```vue + * + * 新增 + * + * + * 编辑 + * + * + * 删除 + * ``` + * + * ## 注意事项 + * + * - 该指令会直接移除 DOM 元素,而不是使用 v-if 隐藏 + * - 权限列表从当前路由的 meta.authList 中获取 + * + * @module directives/auth + * @author Art Design Pro Team + */ + +import { router } from '@/router' +import { App, Directive, DirectiveBinding } from 'vue' + +export type AuthDirective = Directive + +function checkAuthPermission(el: HTMLElement, binding: DirectiveBinding): void { + // 获取当前路由的权限列表 + const authList = (router.currentRoute.value.meta.authList as Array<{ authMark: string }>) || [] + + // 检查是否有对应的权限标识 + const hasPermission = authList.some((item) => item.authMark === binding.value) + + // 如果没有权限,移除元素 + if (!hasPermission) { + removeElement(el) + } +} + +function removeElement(el: HTMLElement): void { + if (el.parentNode) { + el.parentNode.removeChild(el) + } +} + +const authDirective: AuthDirective = { + mounted: checkAuthPermission, + updated: checkAuthPermission +} + +export function setupAuthDirective(app: App): void { + app.directive('auth', authDirective) +} diff --git a/web/apps/admin/src/directives/core/roles.ts b/web/apps/admin/src/directives/core/roles.ts new file mode 100644 index 0000000..f1db35b --- /dev/null +++ b/web/apps/admin/src/directives/core/roles.ts @@ -0,0 +1,87 @@ +/** + * v-roles 角色权限指令 + * + * 基于用户角色控制 DOM 元素的显示和隐藏。 + * 只要用户拥有指定角色中的任意一个,元素就会显示,否则从 DOM 中移除。 + * + * ## 主要功能 + * + * - 角色验证 - 检查用户是否拥有指定角色 + * - 多角色支持 - 支持单个角色或多个角色(满足其一即可) + * - DOM 控制 - 无权限时自动移除元素,而非隐藏 + * - 响应式更新 - 角色变化时自动更新元素状态 + * + * ## 使用示例 + * + * ```vue + * + * ``` + * + * ## 权限逻辑 + * + * - 用户角色从 userStore.getUserInfo.roles 获取 + * - 只要用户拥有指定角色中的任意一个,元素就会显示 + * - 如果用户没有任何角色或不满足条件,元素将被移除 + * + * ## 注意事项 + * + * - 该指令会直接移除 DOM 元素,而不是使用 v-if 隐藏 + * - 适用于基于角色的粗粒度权限控制 + * - 如需基于具体操作的细粒度权限控制,请使用 v-auth 指令 + * + * @module directives/roles + * @author Art Design Pro Team + */ + +import { useUserStore } from '@/store/modules/user' +import { App, Directive, DirectiveBinding } from 'vue' + +export type RolesDirective = Directive + +function checkRolePermission(el: HTMLElement, binding: DirectiveBinding): void { + const userStore = useUserStore() + const userRoles = userStore.getUserInfo.roles + + // 如果用户角色为空或未定义,移除元素 + if (!userRoles?.length) { + removeElement(el) + return + } + + // 确保指令值为数组格式 + const requiredRoles = Array.isArray(binding.value) ? binding.value : [binding.value] + + // 检查用户是否具有所需角色之一 + const hasPermission = requiredRoles.some((role: string) => userRoles.includes(role)) + + // 如果没有权限,安全地移除元素 + if (!hasPermission) { + removeElement(el) + } +} + +function removeElement(el: HTMLElement): void { + if (el.parentNode) { + el.parentNode.removeChild(el) + } +} + +const rolesDirective: RolesDirective = { + mounted: checkRolePermission, + updated: checkRolePermission +} + +export function setupRolesDirective(app: App): void { + app.directive('roles', rolesDirective) +} diff --git a/web/apps/admin/src/directives/index.ts b/web/apps/admin/src/directives/index.ts new file mode 100755 index 0000000..b428a2b --- /dev/null +++ b/web/apps/admin/src/directives/index.ts @@ -0,0 +1,14 @@ +import type { App } from 'vue' +import { setupAuthDirective, type AuthDirective } from './core/auth' +import { setupHighlightDirective, type HighlightDirective } from './business/highlight' +import { setupRippleDirective, type RippleDirective } from './business/ripple' +import { setupRolesDirective, type RolesDirective } from './core/roles' + +export function setupGlobDirectives(app: App) { + setupAuthDirective(app) // 权限指令 + setupRolesDirective(app) // 角色权限指令 + setupHighlightDirective(app) // 高亮指令 + setupRippleDirective(app) // 水波纹指令 +} + +export type { AuthDirective, HighlightDirective, RippleDirective, RolesDirective } diff --git a/web/apps/admin/src/enums/appEnum.ts b/web/apps/admin/src/enums/appEnum.ts new file mode 100755 index 0000000..a39c278 --- /dev/null +++ b/web/apps/admin/src/enums/appEnum.ts @@ -0,0 +1,81 @@ +/** + * 系统级别枚举定义模块 + * + * ## 主要功能 + * + * - 菜单类型枚举(左侧、顶部、混合、双栏) + * - 主题类型枚举(亮色、暗色、自动) + * - 菜单主题枚举(设计、亮色、暗色) + * - 语言类型枚举(中文、英文) + * - 容器宽度枚举(全屏、固定) + * - 菜单宽度枚举(收起宽度) + * + * @module enums/appEnum + * @author Art Design Pro Team + */ + +/** + * 菜单类型 + */ +export enum MenuTypeEnum { + /** 左侧菜单 */ + LEFT = 'left', + /** 顶部菜单 */ + TOP = 'top', + /** 顶部+左侧菜单 */ + TOP_LEFT = 'top-left', + /** 双栏菜单 */ + DUAL_MENU = 'dual-menu' +} + +/** + * 系统主题 + */ +export enum SystemThemeEnum { + /** 暗色主题 */ + DARK = 'dark', + /** 亮色主题 */ + LIGHT = 'light', + /** 自动主题(跟随系统) */ + AUTO = 'auto' +} + +/** + * 菜单主题 + */ +export enum MenuThemeEnum { + /** 暗色主题 */ + DARK = 'dark', + /** 亮色主题 */ + LIGHT = 'light', + /** 设计主题 */ + DESIGN = 'design' +} + +/** + * 菜单宽度 + */ +export enum MenuWidth { + /** 收起宽度 */ + CLOSE = '64px' +} + +/** + * 语言类型 + */ +export enum LanguageEnum { + /** 中文 */ + ZH = 'zh', + /** 英文 */ + EN = 'en' +} + +/** + * 容器宽度 + */ +export enum ContainerWidthEnum { + /** 全屏宽度 */ + FULL = '100%', + /** 固定宽度 */ + BOXED = '1200px' +} diff --git a/web/apps/admin/src/enums/formEnum.ts b/web/apps/admin/src/enums/formEnum.ts new file mode 100644 index 0000000..8e9b3b4 --- /dev/null +++ b/web/apps/admin/src/enums/formEnum.ts @@ -0,0 +1,24 @@ +/** + * 表单相关枚举定义模块 + * + * ## 主要功能 + * + * - 页面模式枚举(新增、编辑) + * - 表格尺寸枚举(默认、紧凑、宽松) + * + * @module enums/formEnum + * @author Art Design Pro Team + */ + +// 页面类型 +export enum PageModeEnum { + Add, // 新增 + Edit // 编辑 +} + +// 表格大小 +export enum TableSizeEnum { + DEFAULT = 'default', + SMALL = 'small', + LARGE = 'large' +} diff --git a/web/apps/admin/src/env.d.ts b/web/apps/admin/src/env.d.ts new file mode 100755 index 0000000..4401f21 --- /dev/null +++ b/web/apps/admin/src/env.d.ts @@ -0,0 +1,34 @@ +/// + +declare module 'nprogress' + +declare module 'crypto-js' + +declare module 'vue-img-cutter' + +declare module 'file-saver' + +declare module 'qrcode.vue' { + export type Level = 'L' | 'M' | 'Q' | 'H' + export type RenderAs = 'canvas' | 'svg' + export type GradientType = 'linear' | 'radial' + export interface ImageSettings { + src: string + height: number + width: number + excavate: boolean + } + export interface QRCodeProps { + value: string + size?: number + level?: Level + background?: string + foreground?: string + renderAs?: RenderAs + } + const QrcodeVue: any + export default QrcodeVue +} + +// 全局变量声明 +declare const __APP_VERSION__: string // 版本号 diff --git a/web/apps/admin/src/hooks/core/useAppMode.ts b/web/apps/admin/src/hooks/core/useAppMode.ts new file mode 100644 index 0000000..c39cd9e --- /dev/null +++ b/web/apps/admin/src/hooks/core/useAppMode.ts @@ -0,0 +1,45 @@ +/** + * useAppMode - 应用模式管理 + * + * 提供应用访问模式的判断和管理功能,支持前端和后端两种权限控制模式。 + * 根据环境变量 VITE_ACCESS_MODE 自动识别当前运行模式。 + * + * ## 主要功能 + * + * 1. 模式识别 - 自动识别前端模式或后端模式 + * 2. 前端模式 - 权限由前端路由配置控制,适合小型项目或演示环境 + * 3. 后端模式 - 权限由后端接口返回的菜单数据控制,适合企业级应用 + * 4. 响应式状态 - 提供响应式的模式判断,方便在组件中使用 + * + * @module useAppMode + * @author Art Design Pro Team + */ + +import { computed } from 'vue' + +export function useAppMode() { + // 获取访问模式配置 + const accessMode = import.meta.env.VITE_ACCESS_MODE + + /** + * 是否为前端控制模式 + * 前端模式:权限由前端路由配置控制 + */ + const isFrontendMode = computed(() => accessMode === 'frontend') + /** + * 是否为后端控制模式 + * 后端模式:权限由后端接口返回的菜单数据控制 + */ + const isBackendMode = computed(() => accessMode === 'backend') + + /** + * 当前应用模式 + */ + const currentMode = computed(() => accessMode) + + return { + isFrontendMode, + isBackendMode, + currentMode + } +} diff --git a/web/apps/admin/src/hooks/core/useAuth.ts b/web/apps/admin/src/hooks/core/useAuth.ts new file mode 100644 index 0000000..283b859 --- /dev/null +++ b/web/apps/admin/src/hooks/core/useAuth.ts @@ -0,0 +1,74 @@ +/** + * useAuth - 权限验证管理 + * + * 提供统一的权限验证功能,支持前端和后端两种权限模式。 + * 用于控制页面按钮、操作等功能的显示和访问权限。 + * + * ## 主要功能 + * + * 1. 权限检查 - 检查用户是否拥有指定的权限标识 + * 2. 双模式支持 - 自动适配前端模式和后端模式的权限验证 + * 3. 前端模式 - 从用户信息中获取按钮权限列表(如 ['add', 'edit', 'delete']) + * 4. 后端模式 - 从路由 meta 配置中获取权限列表(如 [{ authMark: 'add' }]) + * + * ## 使用示例 + * + * ```typescript + * const { hasAuth } = useAuth() + * + * // 检查是否有新增权限 + * if (hasAuth('add')) { + * // 显示新增按钮 + * } + * + * // 在模板中使用 + * 编辑 + * 删除 + * ``` + * + * @module useAuth + * @author Art Design Pro Team + */ + +import { useRoute } from 'vue-router' +import { storeToRefs } from 'pinia' +import { useUserStore } from '@/store/modules/user' +import { useAppMode } from '@/hooks/core/useAppMode' +import type { AppRouteRecord } from '@/types/router' + +type AuthItem = NonNullable[number] + +const userStore = useUserStore() + +export const useAuth = () => { + const route = useRoute() + const { isFrontendMode } = useAppMode() + const { info } = storeToRefs(userStore) + + // 前端按钮权限(例如:['add', 'edit']) + const frontendAuthList = info.value?.buttons ?? [] + + // 后端路由 meta 配置的权限列表(例如:[{ authMark: 'add' }]) + const backendAuthList: AuthItem[] = Array.isArray(route.meta.authList) + ? (route.meta.authList as AuthItem[]) + : [] + + /** + * 检查是否拥有某权限标识(前后端模式通用) + * @param auth 权限标识 + * @returns 是否有权限 + */ + const hasAuth = (auth: string): boolean => { + // 前端模式 + if (isFrontendMode.value) { + return frontendAuthList.includes(auth) + } + + // 后端模式 + return backendAuthList.some((item) => item?.authMark === auth) + } + + return { + hasAuth + } +} diff --git a/web/apps/admin/src/hooks/core/useCeremony.ts b/web/apps/admin/src/hooks/core/useCeremony.ts new file mode 100644 index 0000000..ead2630 --- /dev/null +++ b/web/apps/admin/src/hooks/core/useCeremony.ts @@ -0,0 +1,184 @@ +/** + * useCeremony - 节日庆祝管理 + * + * 提供节日烟花效果和祝福文本展示功能,为系统增添节日氛围。 + * 自动检测当前日期是否为节日,并在首次进入时播放烟花动画和显示祝福语。 + * + * ## 主要功能 + * + * 1. 节日检测 - 自动匹配当前日期与节日配置列表,支持单日和跨日期节日 + * 2. 烟花动画 - 播放节日烟花特效,支持自定义图片和触发次数 + * 3. 祝福文本 - 烟花结束后显示节日祝福文本 + * 4. 状态管理 - 记录烟花播放状态,避免重复播放 + * 5. 清理机制 - 提供清理方法,支持手动停止和重置 + * + * ## 使用示例 + * + * ```typescript + * // 在配置文件中定义节日 + * // 单日节日 + * { + * date: '2024-12-25', + * name: '圣诞节', + * image: christmasImage, + * count: 3 // 可选,不设置则使用默认值 3 次 + * scrollText: 'Merry Christmas!', + * } + * + * // 跨日期节日 + * { + * date: '2025-11-07', + * endDate: '2025-11-10', + * name: 'v3.0 测试阶段', + * image: '', + * count: 5 // 自定义烟花播放次数 + * scrollText: '系统 v3.0 测试阶段正式开启!', + * } + * ``` + * + * @module useCeremony + * @author Art Design Pro Team + */ + +import { useTimeoutFn, useIntervalFn, useDateFormat } from '@vueuse/core' +import { storeToRefs } from 'pinia' +import { computed } from 'vue' +import { useSettingStore } from '@/store/modules/setting' +import { mittBus } from '@/utils/sys' +import { festivalConfigList } from '@/config/modules/festival' + +/** + * 节日庆祝配置常量 + */ +const FESTIVAL_CONFIG = { + /** 初始延迟(毫秒) */ + INITIAL_DELAY: 300, + /** 烟花播放间隔(毫秒) */ + FIREWORK_INTERVAL: 1000, + /** 文本显示延迟(毫秒) */ + TEXT_DELAY: 2000, + /** 默认烟花播放次数 */ + DEFAULT_FIREWORKS_COUNT: 3 +} as const + +/** + * 节日庆祝功能 + * 提供节日烟花效果和祝福文本展示 + */ +export function useCeremony() { + const settingStore = useSettingStore() + const { holidayFireworksLoaded, isShowFireworks } = storeToRefs(settingStore) + + let fireworksInterval: { pause: () => void } | null = null + + /** + * 检查日期是否在节日范围内 + * @param currentDate 当前日期 + * @param festivalDate 节日开始日期 + * @param festivalEndDate 节日结束日期(可选) + */ + const isDateInRange = ( + currentDate: string, + festivalDate: string, + festivalEndDate?: string + ): boolean => { + if (!festivalEndDate) { + // 单日节日 + return currentDate === festivalDate + } + + // 跨日期节日 + const current = new Date(currentDate) + const start = new Date(festivalDate) + const end = new Date(festivalEndDate) + + return current >= start && current <= end + } + + /** + * 获取当前日期对应的节日数据 + */ + const currentFestivalData = computed(() => { + const currentDate = useDateFormat(new Date(), 'YYYY-MM-DD').value + return festivalConfigList.find((item) => isDateInRange(currentDate, item.date, item.endDate)) + }) + + /** + * 更新节日日期到 store + */ + const updateFestivalDate = () => { + settingStore.setFestivalDate(currentFestivalData.value?.date || '') + } + + /** + * 触发烟花效果 + */ + const triggerFirework = () => { + mittBus.emit('triggerFireworks', currentFestivalData.value?.image) + } + + /** + * 完成烟花效果后显示文本 + */ + const showFestivalText = () => { + settingStore.setholidayFireworksLoaded(true) + + useTimeoutFn(() => { + settingStore.setShowFestivalText(true) + updateFestivalDate() + }, FESTIVAL_CONFIG.TEXT_DELAY) + } + + /** + * 启动烟花循环 + */ + const startFireworksLoop = () => { + let playedCount = 0 + // 使用节日配置的播放次数,如果没有则使用默认值 + const count = currentFestivalData.value?.count ?? FESTIVAL_CONFIG.DEFAULT_FIREWORKS_COUNT + + const { pause } = useIntervalFn(() => { + triggerFirework() + playedCount++ + + if (playedCount >= count) { + pause() + showFestivalText() + } + }, FESTIVAL_CONFIG.FIREWORK_INTERVAL) + + fireworksInterval = { pause } + } + + /** + * 开启节日庆祝 + */ + const openFestival = () => { + if (!currentFestivalData.value || !isShowFireworks.value) { + return + } + + const { start } = useTimeoutFn(startFireworksLoop, FESTIVAL_CONFIG.INITIAL_DELAY) + start() + } + + /** + * 清理烟花效果 + */ + const cleanup = () => { + if (fireworksInterval) { + fireworksInterval.pause() + fireworksInterval = null + } + settingStore.setShowFestivalText(false) + updateFestivalDate() + } + + return { + openFestival, + cleanup, + holidayFireworksLoaded, + currentFestivalData, + isShowFireworks + } +} diff --git a/web/apps/admin/src/hooks/core/useChart.ts b/web/apps/admin/src/hooks/core/useChart.ts new file mode 100644 index 0000000..29ba1d1 --- /dev/null +++ b/web/apps/admin/src/hooks/core/useChart.ts @@ -0,0 +1,745 @@ +/** + * useChart - ECharts 图表管理 + * + * 提供完整的 ECharts 图表生命周期管理和配置能力,简化图表开发流程。 + * 自动处理图表初始化、更新、销毁、主题切换、响应式调整等复杂逻辑。 + * + * ## 核心功能 + * + * 1. 图表生命周期管理 - 自动处理初始化、更新、销毁,支持延迟加载和可见性检测 + * 2. 主题自动适配 - 响应系统主题变化,自动更新图表样式和配色 + * 3. 响应式调整 - 监听窗口大小、菜单展开等变化,自动调整图表尺寸 + * 4. 空状态处理 - 优雅的空数据展示,自动显示"暂无数据"提示 + * 5. 样式配置统一 - 提供坐标轴、图例、提示框等统一的样式配置方法 + * 6. 性能优化 - 防抖处理、样式缓存、requestAnimationFrame 优化 + * 7. 高级组件抽象 - useChartComponent 提供更高层次的图表组件封装 + * + * ## 使用示例 + * + * ```typescript + * // 基础用法 + * const { + * chartRef, + * initChart, + * updateChart, + * getAxisLineStyle, + * getTooltipStyle + * } = useChart() + * + * onMounted(() => { + * initChart({ + * xAxis: { type: 'category', data: ['Mon', 'Tue', 'Wed'] }, + * yAxis: { type: 'value' }, + * series: [{ data: [120, 200, 150], type: 'bar' }] + * }) + * }) + * + * // 高级用法 - 组件抽象 + * const chart = useChartComponent({ + * props, + * generateOptions: () => ({ + * // ECharts 配置 + * }), + * checkEmpty: () => data.value.length === 0, + * watchSources: [() => props.data] + * }) + * ``` + * + * @module useChart + * @author Art Design Pro Team + */ + +import { echarts, type EChartsOption } from '@/plugins/echarts' +import { storeToRefs } from 'pinia' +import { useSettingStore } from '@/store/modules/setting' +import { getCssVar } from '@/utils/ui' +import type { BaseChartProps, ChartThemeConfig, UseChartOptions } from '@/types/component/chart' + +// 图表主题配置 +export const useChartOps = (): ChartThemeConfig => ({ + /** */ + chartHeight: '16rem', + /** 字体大小 */ + fontSize: 13, + /** 字体颜色 */ + fontColor: '#999', + /** 主题颜色 */ + themeColor: getCssVar('--el-color-primary-light-1'), + /** 颜色组 */ + colors: [ + getCssVar('--el-color-primary-light-1'), + '#4ABEFF', + '#EDF2FF', + '#14DEBA', + '#FFAF20', + '#FA8A6C', + '#FFAF20' + ] +}) + +// 常量定义 +const RESIZE_DELAYS = [50, 100, 200, 350] as const +const MENU_RESIZE_DELAYS = [50, 100, 200] as const +const RESIZE_DEBOUNCE_DELAY = 100 + +export function useChart(options: UseChartOptions = {}) { + const { initOptions, initDelay = 0, threshold = 0.1, autoTheme = true } = options + + const settingStore = useSettingStore() + const { isDark, menuOpen, menuType } = storeToRefs(settingStore) + + const chartRef = ref() + let chart: echarts.ECharts | null = null + let intersectionObserver: IntersectionObserver | null = null + let pendingOptions: EChartsOption | null = null + let resizeTimeoutId: number | null = null + let resizeFrameId: number | null = null + let isDestroyed = false + let emptyStateDiv: HTMLElement | null = null + + // 清理定时器的统一方法 + const clearTimers = () => { + if (resizeTimeoutId) { + clearTimeout(resizeTimeoutId) + resizeTimeoutId = null + } + if (resizeFrameId) { + cancelAnimationFrame(resizeFrameId) + resizeFrameId = null + } + } + + // 使用 requestAnimationFrame 优化 resize 处理 + const requestAnimationResize = () => { + if (resizeFrameId) { + cancelAnimationFrame(resizeFrameId) + } + resizeFrameId = requestAnimationFrame(() => { + handleResize() + resizeFrameId = null + }) + } + + // 防抖的resize处理(用于窗口resize事件) + const debouncedResize = () => { + if (resizeTimeoutId) { + clearTimeout(resizeTimeoutId) + } + resizeTimeoutId = window.setTimeout(() => { + requestAnimationResize() + resizeTimeoutId = null + }, RESIZE_DEBOUNCE_DELAY) + } + + // 多延迟resize处理 - 统一方法 + const multiDelayResize = (delays: readonly number[]) => { + // 立即调用一次,快速响应 + nextTick(requestAnimationResize) + + // 使用延迟时间,确保图表正确适应变化 + delays.forEach((delay) => { + setTimeout(requestAnimationResize, delay) + }) + } + + // 收缩菜单时,重新计算图表大小(仅在图表存在时监听) + let menuOpenStopHandle: (() => void) | null = null + let menuTypeStopHandle: (() => void) | null = null + + const setupMenuWatchers = () => { + menuOpenStopHandle = watch(menuOpen, () => multiDelayResize(RESIZE_DELAYS)) + menuTypeStopHandle = watch(menuType, () => { + nextTick(requestAnimationResize) + setTimeout(() => multiDelayResize(MENU_RESIZE_DELAYS), 0) + }) + } + + const cleanupMenuWatchers = () => { + menuOpenStopHandle?.() + menuTypeStopHandle?.() + menuOpenStopHandle = null + menuTypeStopHandle = null + } + + // 主题变化时重新设置图表选项 + let themeStopHandle: (() => void) | null = null + + const setupThemeWatcher = () => { + if (autoTheme) { + themeStopHandle = watch(isDark, () => { + // 更新空状态样式 + emptyStateManager.updateStyle() + + if (chart && !isDestroyed) { + // 使用 requestAnimationFrame 优化主题更新 + requestAnimationFrame(() => { + if (chart && !isDestroyed) { + const currentOptions = chart.getOption() + if (currentOptions) { + updateChart(currentOptions as EChartsOption) + } + } + }) + } + }) + } + } + + const cleanupThemeWatcher = () => { + themeStopHandle?.() + themeStopHandle = null + } + + // 样式生成器 - 统一的样式配置 + const createLineStyle = (color: string, width = 1, type?: 'solid' | 'dashed') => ({ + color, + width, + ...(type && { type }) + }) + + // 缓存样式配置以减少重复计算 + const styleCache = { + axisLine: null as any, + splitLine: null as any, + axisLabel: null as any, + lastDarkValue: isDark.value + } + + const clearStyleCache = () => { + styleCache.axisLine = null + styleCache.splitLine = null + styleCache.axisLabel = null + styleCache.lastDarkValue = isDark.value + } + + // 坐标轴线样式 + const getAxisLineStyle = (show: boolean = true) => { + if (styleCache.lastDarkValue !== isDark.value) { + clearStyleCache() + } + if (!styleCache.axisLine) { + styleCache.axisLine = { + show, + lineStyle: createLineStyle(isDark.value ? '#444' : '#EDEDED') + } + } + return styleCache.axisLine + } + + // 分割线样式 + const getSplitLineStyle = (show: boolean = true) => { + if (styleCache.lastDarkValue !== isDark.value) { + clearStyleCache() + } + if (!styleCache.splitLine) { + styleCache.splitLine = { + show, + lineStyle: createLineStyle(isDark.value ? '#444' : '#EDEDED', 1, 'dashed') + } + } + return styleCache.splitLine + } + + // 坐标轴标签样式 + const getAxisLabelStyle = (show: boolean = true) => { + if (styleCache.lastDarkValue !== isDark.value) { + clearStyleCache() + } + if (!styleCache.axisLabel) { + const { fontColor, fontSize } = useChartOps() + styleCache.axisLabel = { + show, + color: fontColor, + fontSize + } + } + return styleCache.axisLabel + } + + // 坐标轴刻度样式(静态配置,无需缓存) + const getAxisTickStyle = () => ({ + show: false + }) + + // 获取动画配置 + const getAnimationConfig = (animationDelay: number = 50, animationDuration: number = 1500) => ({ + animationDelay: (idx: number) => idx * animationDelay + 200, + animationDuration: (idx: number) => animationDuration - idx * 50, + animationEasing: 'quarticOut' as const + }) + + // 获取统一的 tooltip 配置 + const getTooltipStyle = (trigger: 'item' | 'axis' = 'axis', customOptions: any = {}) => ({ + trigger, + backgroundColor: isDark.value ? 'rgba(0, 0, 0, 0.8)' : 'rgba(255, 255, 255, 0.9)', + borderColor: isDark.value ? '#333' : '#ddd', + borderWidth: 1, + textStyle: { + color: isDark.value ? '#fff' : '#333' + }, + ...customOptions + }) + + // 获取统一的图例配置 + const getLegendStyle = ( + position: 'bottom' | 'top' | 'left' | 'right' = 'bottom', + customOptions: any = {} + ) => { + const baseConfig = { + textStyle: { + color: isDark.value ? '#fff' : '#333' + }, + itemWidth: 12, + itemHeight: 12, + itemGap: 20, + ...customOptions + } + + // 根据位置设置不同的配置 + switch (position) { + case 'bottom': + return { + ...baseConfig, + bottom: 0, + left: 'center', + orient: 'horizontal', + icon: 'roundRect' + } + case 'top': + return { + ...baseConfig, + top: 0, + left: 'center', + orient: 'horizontal', + icon: 'roundRect' + } + case 'left': + return { + ...baseConfig, + left: 0, + top: 'center', + orient: 'vertical', + icon: 'roundRect' + } + case 'right': + return { + ...baseConfig, + right: 0, + top: 'center', + orient: 'vertical', + icon: 'roundRect' + } + default: + return baseConfig + } + } + + // 根据图例位置计算 grid 配置 + const getGridWithLegend = ( + showLegend: boolean, + legendPosition: 'bottom' | 'top' | 'left' | 'right' = 'bottom', + baseGrid: any = {} + ) => { + const defaultGrid = { + top: 15, + right: 15, + bottom: 8, + left: 0, + containLabel: true, + ...baseGrid + } + + if (!showLegend) { + return defaultGrid + } + + // 根据图例位置调整 grid + switch (legendPosition) { + case 'bottom': + return { + ...defaultGrid, + bottom: 40 + } + case 'top': + return { + ...defaultGrid, + top: 40 + } + case 'left': + return { + ...defaultGrid, + left: 120 + } + case 'right': + return { + ...defaultGrid, + right: 120 + } + default: + return defaultGrid + } + } + + // 创建IntersectionObserver + const createIntersectionObserver = () => { + if (intersectionObserver || !chartRef.value) return + + intersectionObserver = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting && pendingOptions && !isDestroyed) { + // 使用 requestAnimationFrame 确保在下一帧初始化图表 + requestAnimationFrame(() => { + if (!isDestroyed && pendingOptions) { + try { + // 元素变为可见,初始化图表 + if (!chart) { + chart = echarts.init(entry.target as HTMLElement) + } + + // 触发自定义事件,让组件处理动画逻辑 + const event = new CustomEvent('chartVisible', { + detail: { options: pendingOptions } + }) + entry.target.dispatchEvent(event) + + pendingOptions = null + cleanupIntersectionObserver() + } catch (error) { + console.error('图表初始化失败:', error) + } + } + }) + } + }) + }, + { threshold } + ) + + intersectionObserver.observe(chartRef.value) + } + + // 清理IntersectionObserver + const cleanupIntersectionObserver = () => { + if (intersectionObserver) { + intersectionObserver.disconnect() + intersectionObserver = null + } + } + + // 检查容器是否可见 + const isContainerVisible = (element: HTMLElement): boolean => { + const rect = element.getBoundingClientRect() + return rect.width > 0 && rect.height > 0 && rect.top < window.innerHeight && rect.bottom > 0 + } + + // 图表初始化核心逻辑 + const performChartInit = (options: EChartsOption) => { + if (!chart && chartRef.value && !isDestroyed) { + chart = echarts.init(chartRef.value) + // 图表创建后立即设置监听器 + setupMenuWatchers() + setupThemeWatcher() + } + if (chart && !isDestroyed) { + chart.setOption(options) + pendingOptions = null + } + } + + // 空状态管理器 + const emptyStateManager = { + create: () => { + if (!chartRef.value || emptyStateDiv) return + + emptyStateDiv = document.createElement('div') + emptyStateDiv.style.cssText = ` + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + font-size: 12px; + color: ${isDark.value ? '#555555' : '#B3B2B2'}; + background: transparent; + z-index: 10; + ` + emptyStateDiv.innerHTML = `暂无数据` + + // 确保父容器有相对定位 + if ( + chartRef.value.style.position !== 'relative' && + chartRef.value.style.position !== 'absolute' + ) { + chartRef.value.style.position = 'relative' + } + + chartRef.value.appendChild(emptyStateDiv) + }, + + remove: () => { + if (emptyStateDiv && chartRef.value) { + chartRef.value.removeChild(emptyStateDiv) + emptyStateDiv = null + } + }, + + updateStyle: () => { + if (emptyStateDiv) { + emptyStateDiv.style.color = isDark.value ? '#666' : '#999' + } + } + } + + // 初始化图表 + const initChart = (options: EChartsOption = {}, isEmpty: boolean = false) => { + if (!chartRef.value || isDestroyed) return + + const mergedOptions = { ...initOptions, ...options } + + try { + if (isEmpty) { + // 处理空数据情况 - 显示自定义空状态div + if (chart) { + chart.clear() + } + emptyStateManager.create() + return + } else { + // 有数据时移除空状态div + emptyStateManager.remove() + } + + if (isContainerVisible(chartRef.value)) { + // 容器可见,正常初始化 + if (initDelay > 0) { + setTimeout(() => performChartInit(mergedOptions), initDelay) + } else { + performChartInit(mergedOptions) + } + } else { + // 容器不可见,保存选项并设置监听器 + pendingOptions = mergedOptions + createIntersectionObserver() + } + } catch (error) { + console.error('图表初始化失败:', error) + } + } + + // 更新图表 + const updateChart = (options: EChartsOption) => { + if (isDestroyed) return + + try { + if (!chart) { + // 如果图表不存在,先初始化 + initChart(options) + return + } + chart.setOption(options) + } catch (error) { + console.error('图表更新失败:', error) + } + } + + // 处理窗口大小变化 + const handleResize = () => { + if (chart && !isDestroyed) { + try { + chart.resize() + } catch (error) { + console.error('图表resize失败:', error) + } + } + } + + // 销毁图表 + const destroyChart = () => { + isDestroyed = true + + if (chart) { + try { + chart.dispose() + } catch (error) { + console.error('图表销毁失败:', error) + } finally { + chart = null + } + } + + // 清理所有监听器和资源 + cleanupMenuWatchers() + cleanupThemeWatcher() + emptyStateManager.remove() + cleanupIntersectionObserver() + clearTimers() + clearStyleCache() + pendingOptions = null + } + + // 获取图表实例 + const getChartInstance = () => chart + + // 获取图表是否已初始化 + const isChartInitialized = () => chart !== null + + onMounted(() => { + window.addEventListener('resize', debouncedResize) + }) + + onBeforeUnmount(() => { + window.removeEventListener('resize', debouncedResize) + }) + + onUnmounted(() => { + destroyChart() + }) + + return { + isDark, + chartRef, + initChart, + updateChart, + handleResize, + destroyChart, + getChartInstance, + isChartInitialized, + emptyStateManager, + getAxisLineStyle, + getSplitLineStyle, + getAxisLabelStyle, + getAxisTickStyle, + getAnimationConfig, + getTooltipStyle, + getLegendStyle, + useChartOps, + getGridWithLegend + } +} + +// 高级图表组件抽象 +interface UseChartComponentOptions { + /** Props响应式对象 */ + props: T + /** 图表配置生成函数 */ + generateOptions: () => EChartsOption + /** 空数据检查函数 */ + checkEmpty?: () => boolean + /** 自定义监听的响应式数据 */ + watchSources?: (() => any)[] + /** 自定义可视事件处理 */ + onVisible?: () => void + /** useChart选项 */ + chartOptions?: UseChartOptions +} + +export function useChartComponent(options: UseChartComponentOptions) { + const { + props, + generateOptions, + checkEmpty, + watchSources = [], + onVisible, + chartOptions = {} + } = options + + const chart = useChart(chartOptions) + const { chartRef, initChart, isDark, emptyStateManager } = chart + + // 检查是否为空数据 + const isEmpty = computed(() => { + if (props.isEmpty) return true + if (checkEmpty) return checkEmpty() + return false + }) + + // 更新图表 + const updateChart = () => { + nextTick(() => { + if (isEmpty.value) { + // 处理空数据情况 - 显示自定义空状态div + if (chart.getChartInstance()) { + chart.getChartInstance()?.clear() + } + emptyStateManager.create() + } else { + // 有数据时移除空状态div并初始化图表 + emptyStateManager.remove() + initChart(generateOptions()) + } + }) + } + + // 处理图表进入可视区域时的逻辑 + const handleChartVisible = () => { + if (onVisible) { + onVisible() + } else { + updateChart() + } + } + + // 存储监听器停止函数 + const stopHandles: (() => void)[] = [] + + // 设置数据监听 + const setupWatchers = () => { + // 监听自定义数据源 + if (watchSources.length > 0) { + const stopHandle = watch(watchSources, updateChart, { deep: true }) + stopHandles.push(stopHandle) + } + + // 监听主题变化 + const themeStopHandle = watch(isDark, () => { + emptyStateManager.updateStyle() + updateChart() + }) + stopHandles.push(themeStopHandle) + } + + // 清理所有监听器 + const cleanupWatchers = () => { + stopHandles.forEach((stop) => stop()) + stopHandles.length = 0 + } + + // 设置生命周期 + const setupLifecycle = () => { + onMounted(() => { + updateChart() + + // 监听图表可见事件 + if (chartRef.value) { + chartRef.value.addEventListener('chartVisible', handleChartVisible) + } + }) + + onBeforeUnmount(() => { + // 清理事件监听器 + if (chartRef.value) { + chartRef.value.removeEventListener('chartVisible', handleChartVisible) + } + // 清理所有监听器 + cleanupWatchers() + // 清理空状态div + emptyStateManager.remove() + }) + } + + // 初始化 + setupWatchers() + setupLifecycle() + + return { + ...chart, + isEmpty, + updateChart, + handleChartVisible + } +} diff --git a/web/apps/admin/src/hooks/core/useCommon.ts b/web/apps/admin/src/hooks/core/useCommon.ts new file mode 100644 index 0000000..c936854 --- /dev/null +++ b/web/apps/admin/src/hooks/core/useCommon.ts @@ -0,0 +1,87 @@ +/** + * useCommon - 通用功能集合 + * + * 提供常用的页面操作功能,包括页面刷新、滚动控制、路径获取等。 + * 这些功能在多个页面和组件中都会用到,统一封装便于复用。 + * + * ## 主要功能 + * + * 1. 首页路径 - 获取系统配置的首页路径 + * 2. 页面刷新 - 刷新当前页面内容 + * 3. 滚动控制 - 提供多种滚动到顶部和指定位置的方法 + * 4. 平滑滚动 - 支持平滑滚动动画效果 + * + * @module useCommon + * @author Art Design Pro Team + */ + +import { computed } from 'vue' +import { useMenuStore } from '@/store/modules/menu' +import { useSettingStore } from '@/store/modules/setting' + +export function useCommon() { + const menuStore = useMenuStore() + const settingStore = useSettingStore() + + /** + * 首页路径 + * 从菜单 store 中获取配置的首页路径 + */ + const homePath = computed(() => menuStore.getHomePath()) + + /** + * 刷新当前页面 + * 通过切换 setting store 中的 refresh 状态触发页面重新渲染 + */ + const refresh = () => { + settingStore.reload() + } + + /** + * 滚动到页面顶部 + * 查找主内容区域并将其滚动位置重置为顶部 + */ + const scrollToTop = () => { + const scrollContainer = document.getElementById('app-main') + if (scrollContainer) { + scrollContainer.scrollTop = 0 + } + } + + /** + * 平滑滚动到页面顶部 + * 使用 smooth 行为实现平滑滚动效果 + */ + const smoothScrollToTop = () => { + const scrollContainer = document.getElementById('app-main') + if (scrollContainer) { + scrollContainer.scrollTo({ + top: 0, + behavior: 'smooth' + }) + } + } + + /** + * 滚动到指定位置 + * @param top 目标滚动位置(像素) + * @param smooth 是否使用平滑滚动 + */ + const scrollTo = (top: number, smooth: boolean = false) => { + const scrollContainer = document.getElementById('app-main') + if (scrollContainer) { + scrollContainer.scrollTo({ + top, + behavior: smooth ? 'smooth' : 'auto' + }) + } + } + + return { + homePath, + refresh, + scrollTo, + scrollToTop, + smoothScrollToTop + } +} diff --git a/web/apps/admin/src/hooks/core/useFastEnter.ts b/web/apps/admin/src/hooks/core/useFastEnter.ts new file mode 100644 index 0000000..555eb65 --- /dev/null +++ b/web/apps/admin/src/hooks/core/useFastEnter.ts @@ -0,0 +1,55 @@ +/** + * useFastEnter - 快速入口管理 + * + * 管理顶部栏的快速入口功能,提供应用列表和快速链接的配置和过滤。 + * 支持动态启用/禁用、自定义排序、响应式宽度控制等功能。 + * + * ## 主要功能 + * + * 1. 应用列表管理 - 获取启用的应用列表,自动按排序权重排序 + * 2. 快速链接管理 - 获取启用的快速链接,支持自定义排序 + * 3. 响应式配置 - 所有配置自动响应变化,无需手动更新 + * 4. 宽度控制 - 提供最小显示宽度配置,支持响应式布局 + * + * @module useFastEnter + * @author Art Design Pro Team + */ + +import { computed } from 'vue' +import appConfig from '@/config' +import type { FastEnterApplication, FastEnterQuickLink } from '@/types/config' + +export function useFastEnter() { + // 获取快速入口配置 + const fastEnterConfig = computed(() => appConfig.fastEnter) + + // 获取启用的应用列表(按排序权重排序) + const enabledApplications = computed(() => { + if (!fastEnterConfig.value?.applications) return [] + + return fastEnterConfig.value.applications + .filter((app) => app.enabled !== false) + .sort((a, b) => (a.order || 0) - (b.order || 0)) + }) + + // 获取启用的快速链接(按排序权重排序) + const enabledQuickLinks = computed(() => { + if (!fastEnterConfig.value?.quickLinks) return [] + + return fastEnterConfig.value.quickLinks + .filter((link) => link.enabled !== false) + .sort((a, b) => (a.order || 0) - (b.order || 0)) + }) + + // 获取最小显示宽度 + const minWidth = computed(() => { + return fastEnterConfig.value?.minWidth || 1200 + }) + + return { + fastEnterConfig, + enabledApplications, + enabledQuickLinks, + minWidth + } +} diff --git a/web/apps/admin/src/hooks/core/useHeaderBar.ts b/web/apps/admin/src/hooks/core/useHeaderBar.ts new file mode 100644 index 0000000..be10712 --- /dev/null +++ b/web/apps/admin/src/hooks/core/useHeaderBar.ts @@ -0,0 +1,201 @@ +/** + * useHeaderBar - 顶部栏功能管理 + * + * 统一管理顶部栏各个功能模块的显示状态和配置信息。 + * 提供灵活的功能开关控制,支持动态显示/隐藏顶部栏的各个功能按钮。 + * + * ## 主要功能 + * + * 1. 功能开关控制 - 统一管理菜单按钮、刷新按钮、快速入口等功能的显示状态 + * 2. 配置信息获取 - 获取各个功能模块的详细配置信息 + * 3. 功能列表查询 - 快速获取所有启用或禁用的功能列表 + * 4. 响应式状态 - 所有状态自动响应配置和 store 变化 + * + * @module useHeaderBar + * @author Art Design Pro Team + */ + +import { computed } from 'vue' +import { storeToRefs } from 'pinia' +import { useSettingStore } from '@/store/modules/setting' +import { headerBarConfig } from '@/config/modules/headerBar' +import { HeaderBarFeatureConfig } from '@/types' + +/** + * 顶部栏功能管理 + * @returns 顶部栏功能相关的状态和方法 + */ +export function useHeaderBar() { + const settingStore = useSettingStore() + + // 获取顶部栏配置 + const headerBarConfigRef = computed(() => headerBarConfig) + + // 从store中获取相关状态 + const { showMenuButton, showFastEnter, showRefreshButton, showCrumbs, showLanguage } = + storeToRefs(settingStore) + + /** + * 检查特定功能是否启用 + * @param feature 功能名称 + * @returns 是否启用 + */ + const isFeatureEnabled = (feature: keyof HeaderBarFeatureConfig): boolean => { + return headerBarConfigRef.value[feature]?.enabled ?? false + } + + /** + * 获取功能配置信息 + * @param feature 功能名称 + * @returns 功能配置信息 + */ + const getFeatureConfig = (feature: keyof HeaderBarFeatureConfig) => { + return headerBarConfigRef.value[feature] + } + + // 检查菜单按钮是否显示 + const shouldShowMenuButton = computed(() => { + return isFeatureEnabled('menuButton') && showMenuButton.value + }) + + // 检查刷新按钮是否显示 + const shouldShowRefreshButton = computed(() => { + return isFeatureEnabled('refreshButton') && showRefreshButton.value + }) + + // 检查快速入口是否显示 + const shouldShowFastEnter = computed(() => { + return isFeatureEnabled('fastEnter') && showFastEnter.value + }) + + // 检查面包屑是否显示 + const shouldShowBreadcrumb = computed(() => { + return isFeatureEnabled('breadcrumb') && showCrumbs.value + }) + + // 检查全局搜索是否显示 + const shouldShowGlobalSearch = computed(() => { + return isFeatureEnabled('globalSearch') + }) + + // 检查全屏按钮是否显示 + const shouldShowFullscreen = computed(() => { + return isFeatureEnabled('fullscreen') + }) + + // 检查通知中心是否显示 + const shouldShowNotification = computed(() => { + return isFeatureEnabled('notification') + }) + + // 检查聊天功能是否显示 + const shouldShowChat = computed(() => { + return isFeatureEnabled('chat') + }) + + // 检查语言切换是否显示 + const shouldShowLanguage = computed(() => { + return isFeatureEnabled('language') && showLanguage.value + }) + + // 检查设置面板是否显示 + const shouldShowSettings = computed(() => { + return isFeatureEnabled('settings') + }) + + // 检查主题切换是否显示 + const shouldShowThemeToggle = computed(() => { + return isFeatureEnabled('themeToggle') + }) + + // 获取快速入口的最小宽度 + const fastEnterMinWidth = computed(() => { + const config = getFeatureConfig('fastEnter') + return (config as any)?.minWidth || 1200 + }) + + /** + * 检查功能是否启用(别名) + * @param feature 功能名称 + * @returns 是否启用 + */ + const isFeatureActive = (feature: keyof HeaderBarFeatureConfig): boolean => { + return isFeatureEnabled(feature) + } + + /** + * 获取功能配置(别名) + * @param feature 功能名称 + * @returns 功能配置 + */ + const getFeatureInfo = (feature: keyof HeaderBarFeatureConfig) => { + return getFeatureConfig(feature) + } + + /** + * 获取所有启用的功能列表 + * @returns 启用的功能名称数组 + */ + const getEnabledFeatures = (): (keyof HeaderBarFeatureConfig)[] => { + return Object.keys(headerBarConfigRef.value).filter( + (key) => headerBarConfigRef.value[key as keyof HeaderBarFeatureConfig]?.enabled + ) as (keyof HeaderBarFeatureConfig)[] + } + + /** + * 获取所有禁用的功能列表 + * @returns 禁用的功能名称数组 + */ + const getDisabledFeatures = (): (keyof HeaderBarFeatureConfig)[] => { + return Object.keys(headerBarConfigRef.value).filter( + (key) => !headerBarConfigRef.value[key as keyof HeaderBarFeatureConfig]?.enabled + ) as (keyof HeaderBarFeatureConfig)[] + } + + /** + * 获取所有启用的功能(别名) + * @returns 启用的功能列表 + */ + const getActiveFeatures = () => { + return getEnabledFeatures() + } + + /** + * 获取所有禁用的功能(别名) + * @returns 禁用的功能列表 + */ + const getInactiveFeatures = () => { + return getDisabledFeatures() + } + + return { + // 配置 + headerBarConfig: headerBarConfigRef, + + // 显示状态计算属性 + shouldShowMenuButton, // 是否显示菜单按钮 + shouldShowRefreshButton, // 是否显示刷新按钮 + shouldShowFastEnter, // 是否显示快速入口 + shouldShowBreadcrumb, // 是否显示面包屑 + shouldShowGlobalSearch, // 是否显示全局搜索 + shouldShowFullscreen, // 是否显示全屏按钮 + shouldShowNotification, // 是否显示通知中心 + shouldShowChat, // 是否显示聊天功能 + shouldShowLanguage, // 是否显示语言切换 + shouldShowSettings, // 是否显示设置面板 + shouldShowThemeToggle, // 是否显示主题切换 + + // 配置相关 + fastEnterMinWidth, // 快速入口最小宽度 + + // 方法 + isFeatureEnabled, // 检查功能是否启用 + isFeatureActive, // 检查功能是否启用(别名) + getFeatureConfig, // 获取功能配置 + getFeatureInfo, // 获取功能配置(别名) + getEnabledFeatures, // 获取所有启用的功能 + getDisabledFeatures, // 获取所有禁用的功能 + getActiveFeatures, // 获取所有启用的功能(别名) + getInactiveFeatures // 获取所有禁用的功能(别名) + } +} diff --git a/web/apps/admin/src/hooks/core/useLayoutHeight.ts b/web/apps/admin/src/hooks/core/useLayoutHeight.ts new file mode 100644 index 0000000..4b1171a --- /dev/null +++ b/web/apps/admin/src/hooks/core/useLayoutHeight.ts @@ -0,0 +1,148 @@ +/** + * useLayoutHeight - 页面布局高度管理 + * + * 自动计算和管理页面内容区域的高度,确保内容区域能够正确填充剩余空间。 + * 监听头部元素高度变化,动态调整内容区域高度,避免出现滚动条或布局错乱。 + * + * ## 主要功能 + * + * 1. 动态高度计算 - 根据头部元素高度自动计算内容区域高度 + * 2. 响应式监听 - 自动监听元素尺寸变化并更新高度 + * 3. CSS 变量同步 - 自动更新 CSS 变量,方便全局使用 + * 4. 灵活配置 - 支持自定义间距、CSS 变量名等 + * 5. 自动查找模式 - 提供通过 ID 自动查找元素的便捷方式 + * + * @module useLayoutHeight + * @author Art Design Pro Team + */ + +import { ref, computed, watch, onMounted } from 'vue' +import { useElementSize } from '@vueuse/core' + +/** + * 页面容器高度配置 + */ +interface LayoutHeightOptions { + /** 额外的间距(默认 15px) */ + extraSpacing?: number + /** 是否自动更新 CSS 变量(默认 true) */ + updateCssVar?: boolean + /** CSS 变量名称(默认 '--art-full-height') */ + cssVarName?: string +} + +export function useLayoutHeight(options: LayoutHeightOptions = {}) { + const { extraSpacing = 15, updateCssVar = true, cssVarName = '--art-full-height' } = options + + // 元素引用 + const headerRef = ref() + const contentHeaderRef = ref() + + // 使用 VueUse 自动监听元素尺寸变化 + const { height: headerHeight } = useElementSize(headerRef) + const { height: contentHeaderHeight } = useElementSize(contentHeaderRef) + + // 计算容器最小高度(响应式) + const containerMinHeight = computed(() => { + const totalHeight = headerHeight.value + contentHeaderHeight.value + extraSpacing + return `calc(100vh - ${totalHeight}px)` + }) + + if (updateCssVar) { + watch( + containerMinHeight, + (newHeight) => { + requestAnimationFrame(() => { + document.documentElement.style.setProperty(cssVarName, newHeight) + }) + }, + { immediate: true } + ) + } + + return { + /** 容器最小高度(响应式) */ + containerMinHeight, + /** 头部元素引用 */ + headerRef, + /** 内容头部元素引用 */ + contentHeaderRef, + /** 头部高度(响应式) */ + headerHeight, + /** 内容头部高度(响应式) */ + contentHeaderHeight + } +} + +/** + * 通过 ID 自动查找元素的布局高度管理 + * 适用于无法直接获取元素引用的场景 + * + * @param headerIds 头部元素的 ID 数组 + * @param options 配置选项 + * + * ``` + */ +export function useAutoLayoutHeight( + headerIds: string[] = ['app-header', 'app-content-header'], + options: LayoutHeightOptions = {} +) { + const { extraSpacing = 15, updateCssVar = true, cssVarName = '--art-full-height' } = options + + // 创建元素引用 + const headerRef = ref() + const contentHeaderRef = ref() + + // 使用 VueUse 自动监听元素尺寸变化 + const { height: headerHeight } = useElementSize(headerRef) + const { height: contentHeaderHeight } = useElementSize(contentHeaderRef) + + // 计算容器最小高度(响应式) + const containerMinHeight = computed(() => { + const totalHeight = headerHeight.value + contentHeaderHeight.value + extraSpacing + return `calc(100vh - ${totalHeight}px)` + }) + + if (updateCssVar) { + watch( + containerMinHeight, + (newHeight) => { + requestAnimationFrame(() => { + document.documentElement.style.setProperty(cssVarName, newHeight) + }) + }, + { immediate: true } + ) + } + + // 在 DOM 挂载后查找元素 + onMounted(() => { + if (typeof document !== 'undefined') { + // 使用 nextTick 确保 DOM 完全渲染 + requestAnimationFrame(() => { + const header = document.getElementById(headerIds[0]) + const contentHeader = document.getElementById(headerIds[1]) + + if (header) { + headerRef.value = header + } + if (contentHeader) { + contentHeaderRef.value = contentHeader + } + }) + } + }) + + return { + /** 容器最小高度(响应式) */ + containerMinHeight, + /** 头部元素引用 */ + headerRef, + /** 内容头部元素引用 */ + contentHeaderRef, + /** 头部高度(响应式) */ + headerHeight, + /** 内容头部高度(响应式) */ + contentHeaderHeight + } +} diff --git a/web/apps/admin/src/hooks/core/useTable.ts b/web/apps/admin/src/hooks/core/useTable.ts new file mode 100644 index 0000000..afe9ebe --- /dev/null +++ b/web/apps/admin/src/hooks/core/useTable.ts @@ -0,0 +1,736 @@ +/** + * useTable - 企业级表格数据管理方案 + * + * 功能完整的表格数据管理解决方案,专为后台管理系统设计。 + * 封装了表格开发中的所有常见需求,让你专注于业务逻辑。 + * + * ## 主要功能 + * + * 1. 数据管理 - 自动处理 API 请求、响应转换、加载状态和错误处理 + * 2. 分页控制 - 自动同步分页状态、移动端适配、智能页码边界处理 + * 3. 搜索功能 - 防抖搜索优化、参数管理、一键重置、参数过滤 + * 4. 缓存系统 - 智能请求缓存、多种清理策略、自动过期管理、统计信息 + * 5. 刷新策略 - 提供 5 种刷新方法适配不同业务场景(新增/更新/删除/手动/定时) + * 6. 列配置管理 - 动态显示/隐藏列、列排序、配置持久化、批量操作(可选) + * + * @module useTable + * @author Art Design Pro Team + */ + +import { ref, reactive, computed, onMounted, onUnmounted, nextTick, readonly } from 'vue' +import { useWindowSize } from '@vueuse/core' +import { useTableColumns } from './useTableColumns' +import type { ColumnOption } from '@/types/component' +import { + TableCache, + CacheInvalidationStrategy, + type ApiResponse +} from '../../utils/table/tableCache' +import { + type TableError, + defaultResponseAdapter, + extractTableData, + updatePaginationFromResponse, + createSmartDebounce, + createErrorHandler +} from '../../utils/table/tableUtils' +import { tableConfig } from '../../utils/table/tableConfig' + +// 类型推导工具类型 +type InferApiParams = T extends (params: infer P) => any ? P : never +type InferApiResponse = T extends (params: any) => Promise ? R : never +type InferRecordType = T extends Api.Common.PaginatedResponse ? U : never + +// 优化的配置接口 - 支持自动类型推导 +export interface UseTableConfig< + TApiFn extends (params: any) => Promise = (params: any) => Promise, + TRecord = InferRecordType>, + TParams = InferApiParams, + TResponse = InferApiResponse +> { + // 核心配置 + core: { + /** API 请求函数 */ + apiFn: TApiFn + /** 默认请求参数 */ + apiParams?: Partial + /** 排除 apiParams 中的属性 */ + excludeParams?: string[] + /** 是否立即加载数据 */ + immediate?: boolean + /** 列配置工厂函数 */ + columnsFactory?: () => ColumnOption[] + /** 自定义分页字段映射 */ + paginationKey?: { + /** 当前页码字段名,默认为 'current' */ + current?: string + /** 每页条数字段名,默认为 'size' */ + size?: string + } + } + + // 数据处理 + transform?: { + /** 数据转换函数 */ + dataTransformer?: (data: TRecord[]) => TRecord[] + /** 响应数据适配器 */ + responseAdapter?: (response: TResponse) => ApiResponse + } + + // 性能优化 + performance?: { + /** 是否启用缓存 */ + enableCache?: boolean + /** 缓存时间(毫秒) */ + cacheTime?: number + /** 防抖延迟时间(毫秒) */ + debounceTime?: number + /** 最大缓存条数限制 */ + maxCacheSize?: number + } + + // 生命周期钩子 + hooks?: { + /** 数据加载成功回调(仅网络请求成功时触发) */ + onSuccess?: (data: TRecord[], response: ApiResponse) => void + /** 错误处理回调 */ + onError?: (error: TableError) => void + /** 缓存命中回调(从缓存获取数据时触发) */ + onCacheHit?: (data: TRecord[], response: ApiResponse) => void + /** 加载状态变化回调 */ + onLoading?: (loading: boolean) => void + /** 重置表单回调函数 */ + resetFormCallback?: () => void + } + + // 调试配置 + debug?: { + /** 是否启用日志输出 */ + enableLog?: boolean + /** 日志级别 */ + logLevel?: 'info' | 'warn' | 'error' + } +} + +export function useTable Promise>( + config: UseTableConfig +) { + return useTableImpl(config) +} + +/** + * useTable 的核心实现 - 强大的表格数据管理 Hook + * + * 提供完整的表格解决方案,包括: + * - 数据获取与缓存 + * - 分页控制 + * - 搜索功能 + * - 智能刷新策略 + * - 错误处理 + * - 列配置管理 + */ +function useTableImpl Promise>( + config: UseTableConfig +) { + type TRecord = InferRecordType> + type TParams = InferApiParams + const { + core: { + apiFn, + apiParams = {} as Partial, + excludeParams = [], + immediate = true, + columnsFactory, + paginationKey + }, + transform: { dataTransformer, responseAdapter = defaultResponseAdapter } = {}, + performance: { + enableCache = false, + cacheTime = 5 * 60 * 1000, + debounceTime = 300, + maxCacheSize = 50 + } = {}, + hooks: { onSuccess, onError, onCacheHit, resetFormCallback } = {}, + debug: { enableLog = false } = {} + } = config + + // 分页字段名配置:优先使用传入的配置,否则使用全局配置 + const pageKey = paginationKey?.current || tableConfig.paginationKey.current + const sizeKey = paginationKey?.size || tableConfig.paginationKey.size + + // 响应式触发器,用于手动更新缓存统计信息 + const cacheUpdateTrigger = ref(0) + + // 日志工具函数 + const logger = { + log: (message: string, ...args: unknown[]) => { + if (enableLog) { + console.log(`[useTable] ${message}`, ...args) + } + }, + warn: (message: string, ...args: unknown[]) => { + if (enableLog) { + console.warn(`[useTable] ${message}`, ...args) + } + }, + error: (message: string, ...args: unknown[]) => { + if (enableLog) { + console.error(`[useTable] ${message}`, ...args) + } + } + } + + // 缓存实例 + const cache = enableCache ? new TableCache(cacheTime, maxCacheSize, enableLog) : null + + // 加载状态机 + type LoadingState = 'idle' | 'loading' | 'success' | 'error' + const loadingState = ref('idle') + const loading = computed(() => loadingState.value === 'loading') + + // 错误状态 + const error = ref(null) + + // 表格数据 + const data = ref([]) + + // 请求取消控制器 + let abortController: AbortController | null = null + + // 缓存清理定时器 + let cacheCleanupTimer: NodeJS.Timeout | null = null + + // 搜索参数 + const searchParams = reactive( + Object.assign( + { + [pageKey]: 1, + [sizeKey]: 10 + }, + apiParams || {} + ) as TParams + ) + + // 分页配置 + const pagination = reactive({ + current: ((searchParams as Record)[pageKey] as number) || 1, + size: ((searchParams as Record)[sizeKey] as number) || 10, + total: 0 + }) + + // 移动端分页 (响应式) + const { width } = useWindowSize() + const mobilePagination = computed(() => ({ + ...pagination, + small: width.value < 768 + })) + + // 列配置 + const columnConfig = columnsFactory ? useTableColumns(columnsFactory) : null + const columns = columnConfig?.columns + const columnChecks = columnConfig?.columnChecks + + // 是否有数据 + const hasData = computed(() => data.value.length > 0) + + // 缓存统计信息 + const cacheInfo = computed(() => { + // 依赖触发器,确保缓存变化时重新计算 + void cacheUpdateTrigger.value + if (!cache) return { total: 0, size: '0KB', hitRate: '0 avg hits' } + return cache.getStats() + }) + + // 错误处理函数 + const handleError = createErrorHandler(onError, enableLog) + + // 清理缓存,根据不同的业务场景选择性地清理缓存 + const clearCache = (strategy: CacheInvalidationStrategy, context?: string): void => { + if (!cache) return + + let clearedCount = 0 + + switch (strategy) { + case CacheInvalidationStrategy.CLEAR_ALL: + cache.clear() + logger.log(`清空所有缓存 - ${context || ''}`) + break + + case CacheInvalidationStrategy.CLEAR_CURRENT: + clearedCount = cache.clearCurrentSearch(searchParams) + logger.log(`清空当前搜索缓存 ${clearedCount} 条 - ${context || ''}`) + break + + case CacheInvalidationStrategy.CLEAR_PAGINATION: + clearedCount = cache.clearPagination() + logger.log(`清空分页缓存 ${clearedCount} 条 - ${context || ''}`) + break + + case CacheInvalidationStrategy.KEEP_ALL: + default: + logger.log(`保持缓存不变 - ${context || ''}`) + break + } + // 手动触发缓存状态更新 + cacheUpdateTrigger.value++ + } + + // 获取数据的核心方法 + const fetchData = async ( + params?: Partial, + useCache = enableCache + ): Promise> => { + // 取消上一个请求 + if (abortController) { + abortController.abort() + } + + // 创建新的取消控制器 + const currentController = new AbortController() + abortController = currentController + + // 状态机:进入 loading 状态 + loadingState.value = 'loading' + error.value = null + + try { + let requestParams = Object.assign( + {}, + searchParams, + { + [pageKey]: pagination.current, + [sizeKey]: pagination.size + }, + params || {} + ) as TParams + + // 剔除不需要的参数 + if (excludeParams.length > 0) { + const filteredParams = { ...requestParams } + excludeParams.forEach((key) => { + delete (filteredParams as Record)[key] + }) + requestParams = filteredParams as TParams + } + + // 检查缓存 + if (useCache && cache) { + const cachedItem = cache.get(requestParams) + if (cachedItem) { + data.value = cachedItem.data + updatePaginationFromResponse(pagination, cachedItem.response) + + // 修复:避免重复设置相同的值,防止响应式循环更新 + const paramsRecord = searchParams as Record + if (paramsRecord[pageKey] !== pagination.current) { + paramsRecord[pageKey] = pagination.current + } + if (paramsRecord[sizeKey] !== pagination.size) { + paramsRecord[sizeKey] = pagination.size + } + + // 状态机:缓存命中,进入 success 状态 + loadingState.value = 'success' + + // 缓存命中时触发专门的回调,而不是 onSuccess + if (onCacheHit) { + onCacheHit(cachedItem.data, cachedItem.response) + } + + logger.log(`缓存命中`) + return cachedItem.response + } + } + + const response = await apiFn(requestParams) + + // 检查请求是否被取消 + if (currentController.signal.aborted) { + throw new Error('请求已取消') + } + + // 使用响应适配器转换为标准格式 + const standardResponse = responseAdapter(response) + + // 处理响应数据 + let tableData = extractTableData(standardResponse) + + // 应用数据转换函数 + if (dataTransformer) { + tableData = dataTransformer(tableData) + } + + // 更新状态 + data.value = tableData + updatePaginationFromResponse(pagination, standardResponse) + + // 修复:避免重复设置相同的值,防止响应式循环更新 + const paramsRecord = searchParams as Record + if (paramsRecord[pageKey] !== pagination.current) { + paramsRecord[pageKey] = pagination.current + } + if (paramsRecord[sizeKey] !== pagination.size) { + paramsRecord[sizeKey] = pagination.size + } + + // 缓存数据 + if (useCache && cache) { + cache.set(requestParams, tableData, standardResponse) + // 手动触发缓存状态更新 + cacheUpdateTrigger.value++ + logger.log(`数据已缓存`) + } + + // 状态机:请求成功,进入 success 状态 + loadingState.value = 'success' + + // 成功回调 + if (onSuccess) { + onSuccess(tableData, standardResponse) + } + + return standardResponse + } catch (err) { + if (err instanceof Error && err.message === '请求已取消') { + // 请求被取消,回到 idle 状态 + loadingState.value = 'idle' + return { records: [], total: 0, current: 1, size: 10 } + } + + // 状态机:请求失败,进入 error 状态 + loadingState.value = 'error' + data.value = [] + const tableError = handleError(err, '获取表格数据失败') + throw tableError + } finally { + // 只有当前控制器是活跃的才清空 + if (abortController === currentController) { + abortController = null + } + } + } + + // 获取数据 (保持当前页) + const getData = async (params?: Partial): Promise | void> => { + try { + return await fetchData(params) + } catch { + // 错误已在 fetchData 中处理 + return Promise.resolve() + } + } + + // 分页获取数据 (重置到第一页) - 专门用于搜索场景 + const getDataByPage = async (params?: Partial): Promise | void> => { + pagination.current = 1 + ;(searchParams as Record)[pageKey] = 1 + + // 搜索时清空当前搜索条件的缓存,确保获取最新数据 + clearCache(CacheInvalidationStrategy.CLEAR_CURRENT, '搜索数据') + + try { + return await fetchData(params, false) // 搜索时不使用缓存 + } catch { + // 错误已在 fetchData 中处理 + return Promise.resolve() + } + } + + // 智能防抖搜索函数 + const debouncedGetDataByPage = createSmartDebounce(getDataByPage, debounceTime) + + // 重置搜索参数 + const resetSearchParams = async (): Promise => { + // 取消防抖的搜索 + debouncedGetDataByPage.cancel() + + // 保存分页相关的默认值 + const paramsRecord = searchParams as Record + const defaultPagination = { + [pageKey]: 1, + [sizeKey]: (paramsRecord[sizeKey] as number) || 10 + } + + // 清空所有搜索参数 + Object.keys(searchParams).forEach((key) => { + delete paramsRecord[key] + }) + + // 重新设置默认参数 + Object.assign(searchParams, apiParams || {}, defaultPagination) + + // 重置分页 + pagination.current = 1 + pagination.size = defaultPagination[sizeKey] as number + + // 清空错误状态 + error.value = null + + // 清空缓存 + clearCache(CacheInvalidationStrategy.CLEAR_ALL, '重置搜索') + + // 重新获取数据 + await getData() + + // 执行重置回调 + if (resetFormCallback) { + await nextTick() + resetFormCallback() + } + } + + // 防重复调用的标志 + let isCurrentChanging = false + + // 处理分页大小变化 + const handleSizeChange = async (newSize: number): Promise => { + if (newSize <= 0) return + + debouncedGetDataByPage.cancel() + + const paramsRecord = searchParams as Record + pagination.size = newSize + pagination.current = 1 + paramsRecord[sizeKey] = newSize + paramsRecord[pageKey] = 1 + + clearCache(CacheInvalidationStrategy.CLEAR_CURRENT, '分页大小变化') + + await getData() + } + + // 处理当前页变化 + const handleCurrentChange = async (newCurrent: number): Promise => { + if (newCurrent <= 0) return + + // 修复:防止重复调用 + if (isCurrentChanging) { + return + } + + // 修复:如果当前页没有变化,不需要重新请求 + if (pagination.current === newCurrent) { + logger.log('分页页码未变化,跳过请求') + return + } + + try { + isCurrentChanging = true + + // 修复:只更新必要的状态 + const paramsRecord = searchParams as Record + pagination.current = newCurrent + // 只有当 searchParams 的分页字段与新值不同时才更新 + if (paramsRecord[pageKey] !== newCurrent) { + paramsRecord[pageKey] = newCurrent + } + + await getData() + } finally { + isCurrentChanging = false + } + } + + // 针对不同业务场景的刷新方法 + + // 新增后刷新:回到第一页并清空分页缓存(适用于新增数据后) + const refreshCreate = async (): Promise => { + debouncedGetDataByPage.cancel() + pagination.current = 1 + ;(searchParams as Record)[pageKey] = 1 + clearCache(CacheInvalidationStrategy.CLEAR_PAGINATION, '新增数据') + await getData() + } + + // 更新后刷新:保持当前页,仅清空当前搜索缓存(适用于更新数据后) + const refreshUpdate = async (): Promise => { + clearCache(CacheInvalidationStrategy.CLEAR_CURRENT, '编辑数据') + await getData() + } + + // 删除后刷新:智能处理页码,避免空页面(适用于删除数据后) + const refreshRemove = async (): Promise => { + const { current } = pagination + + // 清除缓存并获取最新数据 + clearCache(CacheInvalidationStrategy.CLEAR_CURRENT, '删除数据') + await getData() + + // 如果当前页为空且不是第一页,回到上一页 + if (data.value.length === 0 && current > 1) { + pagination.current = current - 1 + ;(searchParams as Record)[pageKey] = current - 1 + await getData() + } + } + + // 全量刷新:清空所有缓存,重新获取数据(适用于手动刷新按钮) + const refreshData = async (): Promise => { + debouncedGetDataByPage.cancel() + clearCache(CacheInvalidationStrategy.CLEAR_ALL, '手动刷新') + await getData() + } + + // 轻量刷新:仅清空当前搜索条件的缓存,保持分页状态(适用于定时刷新) + const refreshSoft = async (): Promise => { + clearCache(CacheInvalidationStrategy.CLEAR_CURRENT, '软刷新') + await getData() + } + + // 取消当前请求 + const cancelRequest = (): void => { + if (abortController) { + abortController.abort() + } + debouncedGetDataByPage.cancel() + } + + // 清空数据 + const clearData = (): void => { + data.value = [] + error.value = null + clearCache(CacheInvalidationStrategy.CLEAR_ALL, '清空数据') + } + + // 清理已过期的缓存条目,释放内存空间 + const clearExpiredCache = (): number => { + if (!cache) return 0 + const cleanedCount = cache.cleanupExpired() + if (cleanedCount > 0) { + // 手动触发缓存状态更新 + cacheUpdateTrigger.value++ + } + return cleanedCount + } + + // 设置定期清理过期缓存 + if (enableCache && cache) { + cacheCleanupTimer = setInterval(() => { + const cleanedCount = cache.cleanupExpired() + if (cleanedCount > 0) { + logger.log(`自动清理 ${cleanedCount} 条过期缓存`) + // 手动触发缓存状态更新 + cacheUpdateTrigger.value++ + } + }, cacheTime / 2) // 每半个缓存周期清理一次 + } + + // 挂载时自动加载数据 + if (immediate) { + onMounted(async () => { + await getData() + }) + } + + // 组件卸载时彻底清理 + onUnmounted(() => { + cancelRequest() + if (cache) { + cache.clear() + } + if (cacheCleanupTimer) { + clearInterval(cacheCleanupTimer) + } + }) + + // 优化的返回值结构 + return { + // 数据相关 + /** 表格数据 */ + data, + /** 数据加载状态 */ + loading: readonly(loading), + /** 错误状态 */ + error: readonly(error), + /** 数据是否为空 */ + isEmpty: computed(() => data.value.length === 0), + /** 是否有数据 */ + hasData, + + // 分页相关 + /** 分页状态信息 */ + pagination: readonly(pagination), + /** 移动端分页配置 */ + paginationMobile: mobilePagination, + /** 页面大小变化处理 */ + handleSizeChange, + /** 当前页变化处理 */ + handleCurrentChange, + + // 搜索相关 - 统一前缀 + /** 搜索参数 */ + searchParams, + /** 重置搜索参数 */ + resetSearchParams, + + // 数据操作 - 更明确的操作意图 + /** 加载数据 */ + fetchData: getData, + /** 获取数据 */ + getData: getDataByPage, + /** 获取数据(防抖) */ + getDataDebounced: debouncedGetDataByPage, + /** 清空数据 */ + clearData, + + // 刷新策略 + /** 全量刷新:清空所有缓存,重新获取数据(适用于手动刷新按钮) */ + refreshData, + /** 轻量刷新:仅清空当前搜索条件的缓存,保持分页状态(适用于定时刷新) */ + refreshSoft, + /** 新增后刷新:回到第一页并清空分页缓存(适用于新增数据后) */ + refreshCreate, + /** 更新后刷新:保持当前页,仅清空当前搜索缓存(适用于更新数据后) */ + refreshUpdate, + /** 删除后刷新:智能处理页码,避免空页面(适用于删除数据后) */ + refreshRemove, + + // 缓存控制 + /** 缓存统计信息 */ + cacheInfo, + /** 清除缓存,根据不同的业务场景选择性地清理缓存: */ + clearCache, + // 支持4种清理策略 + // clearCache(CacheInvalidationStrategy.CLEAR_ALL, '手动刷新') // 清空所有缓存 + // clearCache(CacheInvalidationStrategy.CLEAR_CURRENT, '搜索数据') // 只清空当前搜索条件的缓存 + // clearCache(CacheInvalidationStrategy.CLEAR_PAGINATION, '新增数据') // 清空分页相关缓存 + // clearCache(CacheInvalidationStrategy.KEEP_ALL, '保持缓存') // 不清理任何缓存 + /** 清理已过期的缓存条目,释放内存空间 */ + clearExpiredCache, + + // 请求控制 + /** 取消当前请求 */ + cancelRequest, + + // 列配置 (如果提供了 columnsFactory) + ...(columnConfig && { + /** 表格列配置 */ + columns, + /** 列显示控制 */ + columnChecks, + /** 新增列 */ + addColumn: columnConfig.addColumn, + /** 删除列 */ + removeColumn: columnConfig.removeColumn, + /** 切换列显示状态 */ + toggleColumn: columnConfig.toggleColumn, + /** 更新列配置 */ + updateColumn: columnConfig.updateColumn, + /** 批量更新列配置 */ + batchUpdateColumns: columnConfig.batchUpdateColumns, + /** 重新排序列 */ + reorderColumns: columnConfig.reorderColumns, + /** 获取指定列配置 */ + getColumnConfig: columnConfig.getColumnConfig, + /** 获取所有列配置 */ + getAllColumns: columnConfig.getAllColumns, + /** 重置所有列配置到默认状态 */ + resetColumns: columnConfig.resetColumns + }) + } +} + +// 重新导出类型和枚举,方便使用 +export { CacheInvalidationStrategy } from '../../utils/table/tableCache' +export type { ApiResponse, CacheItem } from '../../utils/table/tableCache' +export type { BaseRequestParams, TableError } from '../../utils/table/tableUtils' diff --git a/web/apps/admin/src/hooks/core/useTableColumns.ts b/web/apps/admin/src/hooks/core/useTableColumns.ts new file mode 100644 index 0000000..84b6e13 --- /dev/null +++ b/web/apps/admin/src/hooks/core/useTableColumns.ts @@ -0,0 +1,312 @@ +/** + * useTableColumns - 表格列配置管理 + * + * 提供动态的表格列配置管理能力,支持运行时灵活控制列的显示、隐藏、排序等操作。 + * 通常与 useTable 配合使用,为表格提供完整的列管理功能。 + * + * ## 主要功能 + * + * 1. 列显示控制 - 动态显示/隐藏列,支持批量操作 + * 2. 列排序 - 拖拽或编程方式重新排列列顺序 + * 3. 列配置管理 - 新增、删除、更新列配置 + * 4. 特殊列支持 - 自动处理 selection、expand、index 等特殊列 + * 5. 状态持久化 - 保持列的显示状态,支持重置到初始状态 + * + * ## 使用示例 + * + * ```typescript + * const { columns, columnChecks, toggleColumn, reorderColumns } = useTableColumns(() => [ + * { prop: 'name', label: '姓名', visible: true }, + * { prop: 'email', label: '邮箱', visible: true }, + * { prop: 'status', label: '状态', visible: false } + * ]) + * + * // 切换列显示 + * toggleColumn('email', false) + * + * // 重新排序 + * reorderColumns(0, 2) + * ``` + * + * @module useTableColumns + * @author Art Design Pro Team + */ + +import { ref, computed, watch } from 'vue' +import { $t } from '@/locales' +import type { ColumnOption } from '@/types/component' + +/** + * 特殊列类型 + */ +const SPECIAL_COLUMNS: Record = { + selection: { prop: '__selection__', label: $t('table.column.selection') }, + expand: { prop: '__expand__', label: $t('table.column.expand') }, + index: { prop: '__index__', label: $t('table.column.index') } +} + +/** + * 获取列的唯一标识 + */ +export const getColumnKey = (col: ColumnOption) => + SPECIAL_COLUMNS[col.type as keyof typeof SPECIAL_COLUMNS]?.prop ?? (col.prop as string) + +/** + * 获取列的显示状态 + * 优先使用 visible 字段,如果不存在则使用 checked 字段 + */ +export const getColumnVisibility = (col: ColumnOption): boolean => { + // visible 优先级高于 checked + if (col.visible !== undefined) { + return col.visible + } + // 如果 visible 未定义,使用 checked,默认为 true + return col.checked ?? true +} + +/** + * 获取列的检查状态 + */ +export const getColumnChecks = (columns: ColumnOption[]) => + columns.map((col) => { + const special = col.type && SPECIAL_COLUMNS[col.type] + const visibility = getColumnVisibility(col) + + if (special) { + return { ...col, prop: special.prop, label: special.label, checked: true, visible: true } + } + return { ...col, checked: visibility, visible: visibility } + }) + +/** + * 动态列配置接口 + */ +export interface DynamicColumnConfig { + /** + * 新增列(支持单个或批量) + * @param column 列配置或列配置数组 + * @param index 可选的插入位置,默认末尾(批量时为第一个列的位置) + */ + addColumn: (column: ColumnOption | ColumnOption[], index?: number) => void + /** + * 删除列(支持单个或批量) + * @param prop 列的唯一标识或标识数组 + */ + removeColumn: (prop: string | string[]) => void + /** + * 切换列显示状态(支持单个或批量) + * @param prop 列的唯一标识或标识数组 + * @param visible 可选的显示状态,默认取反 + */ + toggleColumn: (prop: string | string[], visible?: boolean) => void + + /** + * 更新列(支持单个或批量) + * @param prop 列的唯一标识或更新配置数组 + * @param updates 列配置更新(当 prop 为字符串时使用) + */ + updateColumn: ( + prop: string | Array<{ prop: string; updates: Partial> }>, + updates?: Partial> + ) => void + /** + * 批量更新列(兼容旧版本,推荐使用 updateColumn 的数组模式) + * @param updates 列更新配置 + * @deprecated 推荐使用 updateColumn 的数组模式 + */ + batchUpdateColumns: (updates: Array<{ prop: string; updates: Partial> }>) => void + /** + * 重新排序列 + * @param fromIndex 源索引 + * @param toIndex 目标索引 + */ + reorderColumns: (fromIndex: number, toIndex: number) => void + /** + * 获取列配置 + * @param prop 列的唯一标识 + * @returns 列配置 + */ + getColumnConfig: (prop: string) => ColumnOption | undefined + /** + * 获取所有列配置 + * @returns 所有列配置 + */ + getAllColumns: () => ColumnOption[] + /** + * 重置所有列 + */ + resetColumns: () => void +} + +export function useTableColumns( + columnsFactory: () => ColumnOption[] +): { + columns: any + columnChecks: any +} & DynamicColumnConfig { + const dynamicColumns = ref[]>(columnsFactory()) + const columnChecks = ref[]>(getColumnChecks(dynamicColumns.value)) + + // 当 dynamicColumns 变动时,重新生成 columnChecks 且保留已存在的显示状态 + watch( + dynamicColumns, + (newCols) => { + const visibilityMap = new Map( + columnChecks.value.map((c) => [getColumnKey(c), getColumnVisibility(c)]) + ) + const newChecks = getColumnChecks(newCols).map((c) => { + const key = getColumnKey(c) + const visibility = visibilityMap.has(key) ? visibilityMap.get(key) : getColumnVisibility(c) + return { + ...c, + checked: visibility, + visible: visibility + } + }) + columnChecks.value = newChecks + }, + { deep: true } + ) + + // 当前显示列(基于 columnChecks 的 checked 或 visible) + const columns = computed(() => { + const colMap = new Map(dynamicColumns.value.map((c) => [getColumnKey(c), c])) + return columnChecks.value + .filter((c) => getColumnVisibility(c)) + .map((c) => colMap.get(getColumnKey(c))) + .filter(Boolean) as ColumnOption[] + }) + + // 支持 updater 返回新数组或直接在传入数组上 mutate + const setDynamicColumns = (updater: (cols: ColumnOption[]) => void | ColumnOption[]) => { + const copy = [...dynamicColumns.value] + const result = updater(copy) + dynamicColumns.value = Array.isArray(result) ? result : copy + } + + return { + columns, + columnChecks, + + /** + * 新增列(支持单个或批量) + */ + addColumn: (column: ColumnOption | ColumnOption[], index?: number) => + setDynamicColumns((cols) => { + const next = [...cols] + const columnsToAdd = Array.isArray(column) ? column : [column] + const insertIndex = + typeof index === 'number' && index >= 0 && index <= next.length ? index : next.length + + // 批量插入 + next.splice(insertIndex, 0, ...columnsToAdd) + return next + }), + + /** + * 删除列(支持单个或批量) + */ + removeColumn: (prop: string | string[]) => + setDynamicColumns((cols) => { + const propsToRemove = Array.isArray(prop) ? prop : [prop] + return cols.filter((c) => !propsToRemove.includes(getColumnKey(c))) + }), + + /** + * 更新列(支持单个或批量) + */ + updateColumn: ( + prop: string | Array<{ prop: string; updates: Partial> }>, + updates?: Partial> + ) => { + // 批量模式:prop 是数组 + if (Array.isArray(prop)) { + setDynamicColumns((cols) => { + const map = new Map(prop.map((u) => [u.prop, u.updates])) + return cols.map((c) => { + const key = getColumnKey(c) + const upd = map.get(key) + return upd ? { ...c, ...upd } : c + }) + }) + } + // 单个模式:prop 是字符串 + else if (updates) { + setDynamicColumns((cols) => + cols.map((c) => (getColumnKey(c) === prop ? { ...c, ...updates } : c)) + ) + } + }, + + /** + * 切换列显示状态(支持单个或批量) + */ + toggleColumn: (prop: string | string[], visible?: boolean) => { + const propsToToggle = Array.isArray(prop) ? prop : [prop] + const next = [...columnChecks.value] + + propsToToggle.forEach((p) => { + const i = next.findIndex((c) => getColumnKey(c) === p) + if (i > -1) { + const currentVisibility = getColumnVisibility(next[i]) + const newVisibility = visible ?? !currentVisibility + // 同时更新 checked 和 visible 以保持兼容性 + next[i] = { ...next[i], checked: newVisibility, visible: newVisibility } + } + }) + + columnChecks.value = next + }, + + /** + * 重置所有列 + */ + resetColumns: () => { + dynamicColumns.value = columnsFactory() + }, + + /** + * 批量更新列(兼容旧版本) + * @deprecated 推荐使用 updateColumn 的数组模式 + */ + batchUpdateColumns: (updates) => + setDynamicColumns((cols) => { + const map = new Map(updates.map((u) => [u.prop, u.updates])) + return cols.map((c) => { + const key = getColumnKey(c) + const upd = map.get(key) + return upd ? { ...c, ...upd } : c + }) + }), + + /** + * 重新排序列 + */ + reorderColumns: (fromIndex: number, toIndex: number) => + setDynamicColumns((cols) => { + if ( + fromIndex < 0 || + fromIndex >= cols.length || + toIndex < 0 || + toIndex >= cols.length || + fromIndex === toIndex + ) { + return cols + } + const next = [...cols] + const [moved] = next.splice(fromIndex, 1) + next.splice(toIndex, 0, moved) + return next + }), + + /** + * 获取列配置 + */ + getColumnConfig: (prop: string) => dynamicColumns.value.find((c) => getColumnKey(c) === prop), + + /** + * 获取所有列配置 + */ + getAllColumns: () => [...dynamicColumns.value] + } +} diff --git a/web/apps/admin/src/hooks/core/useTableHeight.ts b/web/apps/admin/src/hooks/core/useTableHeight.ts new file mode 100644 index 0000000..8fdf6da --- /dev/null +++ b/web/apps/admin/src/hooks/core/useTableHeight.ts @@ -0,0 +1,105 @@ +/** + * useTableHeight - 表格高度自动计算 + * + * 自动计算表格容器的最佳高度,确保表格在不同布局场景下都能正确显示。 + * 根据表格头部、分页器等元素的高度动态调整容器高度,避免出现滚动条或布局错乱。 + * + * ## 主要功能 + * + * 1. 动态高度计算 - 根据表格头部、分页器高度自动计算容器高度 + * 2. 响应式更新 - 配置变化时自动重新计算高度 + * 3. 灵活配置 - 支持自定义各部分高度和间距 + * 4. 智能适配 - 无额外元素时自动使用 100% 高度 + * + * @module useTableHeight + * @author Art Design Pro Team + */ + +import { computed, type Ref } from 'vue' + +/** + * 表格高度计算器配置接口 + */ +interface TableHeightOptions { + /** 是否显示表格头部 */ + showTableHeader: Ref + /** 分页器高度 */ + paginationHeight: Ref + /** 表格头部高度 */ + tableHeaderHeight: Ref + /** 分页器间距 */ + paginationSpacing: Ref +} + +/** + * 表格高度计算器类 + */ +class TableHeightCalculator { + // 常量配置 + private static readonly DEFAULT_TABLE_HEADER_HEIGHT = 44 + private static readonly TABLE_HEADER_SPACING = 12 + + constructor(private options: TableHeightOptions) {} + + /** + * 计算容器高度 + */ + calculate(): { height: string } { + const offset = this.calculateOffset() + return { + height: offset === 0 ? '100%' : `calc(100% - ${offset}px)` + } + } + + /** + * 计算偏移量 + */ + private calculateOffset(): number { + if (!this.options.showTableHeader.value) { + return this.calculatePaginationOffset() + } + + const headerHeight = this.getHeaderHeight() + const paginationOffset = this.calculatePaginationOffset() + + return headerHeight + paginationOffset + TableHeightCalculator.TABLE_HEADER_SPACING + } + + /** + * 获取表格头部高度 + */ + private getHeaderHeight(): number { + return this.options.tableHeaderHeight.value || TableHeightCalculator.DEFAULT_TABLE_HEADER_HEIGHT + } + + /** + * 计算分页器偏移量 + */ + private calculatePaginationOffset(): number { + const { paginationHeight, paginationSpacing } = this.options + return paginationHeight.value === 0 ? 0 : paginationHeight.value + paginationSpacing.value + } +} + +/** + * 表格高度计算 Hook + * + * 提供表格容器高度的自动计算功能,支持: + * - 表格头部高度 + * - 分页器高度 + * - 动态间距计算 + * + * @param options 配置选项 + * @returns 容器高度计算结果 + */ +export function useTableHeight(options: TableHeightOptions) { + const containerHeight = computed(() => { + const calculator = new TableHeightCalculator(options) + return calculator.calculate() + }) + + return { + /** 容器高度样式对象 */ + containerHeight + } +} diff --git a/web/apps/admin/src/hooks/core/useTheme.ts b/web/apps/admin/src/hooks/core/useTheme.ts new file mode 100644 index 0000000..187c3e0 --- /dev/null +++ b/web/apps/admin/src/hooks/core/useTheme.ts @@ -0,0 +1,174 @@ +/** + * useTheme - 系统主题管理 + * + * 提供完整的主题切换和管理功能,支持亮色、暗色和自动模式。 + * 自动处理主题切换时的过渡效果,确保切换流畅无闪烁。 + * + * ## 主要功能 + * + * 1. 主题切换 - 支持亮色、暗色、自动三种主题模式 + * 2. 自动模式 - 根据系统偏好自动切换主题 + * 3. 颜色适配 - 自动调整主题色的明暗变体(9 个层级) + * 4. 过渡优化 - 切换时临时禁用过渡效果,避免闪烁 + * 5. 状态持久化 - 主题设置自动保存到 store + * + * ## 使用示例 + * + * ```typescript + * const { switchThemeStyles } = useTheme() + * + * // 切换到暗色主题 + * switchThemeStyles(SystemThemeEnum.DARK) + * + * // 切换到亮色主题 + * switchThemeStyles(SystemThemeEnum.LIGHT) + * + * // 切换到自动模式(跟随系统) + * switchThemeStyles(SystemThemeEnum.AUTO) + * ``` + * + * @module useTheme + * @author Art Design Pro Team + */ + +import { useSettingStore } from '@/store/modules/setting' +import { SystemThemeEnum } from '@/enums/appEnum' +import AppConfig from '@/config' +import { SystemThemeTypes } from '@/types/store' +import { getDarkColor, getLightColor, setElementThemeColor } from '@/utils/ui' +import { usePreferredDark } from '@vueuse/core' +import { watch } from 'vue' + +export function useTheme() { + const settingStore = useSettingStore() + + // 禁用过渡效果 + const disableTransitions = () => { + const style = document.createElement('style') + style.setAttribute('id', 'disable-transitions') + style.textContent = '* { transition: none !important; }' + document.head.appendChild(style) + } + + // 启用过渡效果 + const enableTransitions = () => { + const style = document.getElementById('disable-transitions') + if (style) { + style.remove() + } + } + + // 设置系统主题 + const setSystemTheme = (theme: SystemThemeEnum, themeMode?: SystemThemeEnum) => { + // 临时禁用过渡效果 + disableTransitions() + + const el = document.getElementsByTagName('html')[0] + const isDark = theme === SystemThemeEnum.DARK + + if (!themeMode) { + themeMode = theme + } + + const currentTheme = AppConfig.systemThemeStyles[theme as keyof SystemThemeTypes] + + if (currentTheme) { + el.setAttribute('class', currentTheme.className) + } + + // 设置按钮颜色加深或变浅 + const primary = settingStore.systemThemeColor + + for (let i = 1; i <= 9; i++) { + document.documentElement.style.setProperty( + `--el-color-primary-light-${i}`, + isDark ? `${getDarkColor(primary, i / 10)}` : `${getLightColor(primary, i / 10)}` + ) + } + + // 更新store中的主题设置 + settingStore.setGlopTheme(theme, themeMode) + + // 使用 requestAnimationFrame 确保在下一帧恢复过渡效果 + requestAnimationFrame(() => { + requestAnimationFrame(() => { + enableTransitions() + }) + }) + } + + // 使用 VueUse 的 usePreferredDark 检测系统主题偏好 + const prefersDark = usePreferredDark() + + // 自动设置系统主题 + const setSystemAutoTheme = () => { + const theme = prefersDark.value ? SystemThemeEnum.DARK : SystemThemeEnum.LIGHT + setSystemTheme(theme, SystemThemeEnum.AUTO) + } + + // 切换主题 + const switchThemeStyles = (theme: SystemThemeEnum) => { + if (theme === SystemThemeEnum.AUTO) { + setSystemAutoTheme() + } else { + setSystemTheme(theme) + } + } + + return { + setSystemTheme, + setSystemAutoTheme, + switchThemeStyles, + prefersDark + } +} + +/** + * 初始化主题系统 + */ +export function initializeTheme() { + const settingStore = useSettingStore() + const prefersDark = usePreferredDark() + + // 根据系统偏好应用主题 + const applyThemeByMode = () => { + const el = document.getElementsByTagName('html')[0] + let actualTheme = settingStore.systemThemeType + + // 如果是 AUTO 模式,检测系统偏好 + if (settingStore.systemThemeMode === SystemThemeEnum.AUTO) { + actualTheme = prefersDark.value ? SystemThemeEnum.DARK : SystemThemeEnum.LIGHT + // 更新实际应用的主题类型 + settingStore.systemThemeType = actualTheme + } + + // 设置主题 class + const currentTheme = AppConfig.systemThemeStyles[actualTheme as keyof SystemThemeTypes] + if (currentTheme) { + el.setAttribute('class', currentTheme.className) + } + + // 设置主题颜色 + setElementThemeColor(settingStore.systemThemeColor) + + // 设置圆角 + document.documentElement.style.setProperty('--custom-radius', `${settingStore.customRadius}rem`) + } + + // 应用主题 + applyThemeByMode() + + // 如果是 AUTO 模式,监听系统主题变化(使用 VueUse 的响应式特性) + if (settingStore.systemThemeMode === SystemThemeEnum.AUTO) { + watch( + prefersDark, + () => { + // 只有在 AUTO 模式下才响应系统主题变化 + if (settingStore.systemThemeMode === SystemThemeEnum.AUTO) { + applyThemeByMode() + } + }, + { immediate: false } + ) + } +} diff --git a/web/apps/admin/src/hooks/index.ts b/web/apps/admin/src/hooks/index.ts new file mode 100644 index 0000000..472b09c --- /dev/null +++ b/web/apps/admin/src/hooks/index.ts @@ -0,0 +1,32 @@ +// 通用功能集合 +export { useCommon } from './core/useCommon' + +// 应用模式 +export { useAppMode } from './core/useAppMode' + +// 权限控制 +export { useAuth } from './core/useAuth' + +// 表格数据管理方案 +export { useTable } from './core/useTable' + +// 表格列配置管理 +export { useTableColumns } from './core/useTableColumns' + +// 主题相关 +export { useTheme } from './core/useTheme' + +// 礼花+文字滚动 +export { useCeremony } from './core/useCeremony' + +// 顶栏快速入口 +export { useFastEnter } from './core/useFastEnter' + +// 顶栏功能管理 +export { useHeaderBar } from './core/useHeaderBar' + +// 图表相关 +export { useChart, useChartComponent, useChartOps } from './core/useChart' + +// 布局高度 +export { useLayoutHeight, useAutoLayoutHeight } from './core/useLayoutHeight' diff --git a/web/apps/admin/src/locales/index.ts b/web/apps/admin/src/locales/index.ts new file mode 100644 index 0000000..36c2648 --- /dev/null +++ b/web/apps/admin/src/locales/index.ts @@ -0,0 +1,123 @@ +/** + * 国际化配置 + * + * 基于 vue-i18n 实现的多语言国际化解决方案。 + * 支持中文和英文切换,自动从本地存储恢复用户的语言偏好。 + * + * ## 主要功能 + * + * - 多语言支持 - 支持中文(简体)和英文两种语言 + * - 语言切换 - 运行时动态切换语言,无需刷新页面 + * - 持久化存储 - 自动保存和恢复用户的语言偏好 + * - 全局注入 - 在任何组件中都可以使用 $t 函数进行翻译 + * - 类型安全 - 提供 TypeScript 类型支持 + * + * ## 支持的语言 + * + * - zh: 简体中文 + * - en: English + * + * @module locales + * @author Art Design Pro Team + */ + +import { createI18n } from 'vue-i18n' +import type { I18n, I18nOptions } from 'vue-i18n' +import { LanguageEnum } from '@/enums/appEnum' +import { getSystemStorage } from '@/utils/storage' +import { StorageKeyManager } from '@/utils/storage/storage-key-manager' + +// 同步导入语言文件 +import enMessages from './langs/en.json' +import zhMessages from './langs/zh.json' + +/** + * 存储键管理器实例 + */ +const storageKeyManager = new StorageKeyManager() + +/** + * 语言消息对象 + */ +const messages = { + [LanguageEnum.EN]: enMessages, + [LanguageEnum.ZH]: zhMessages +} + +/** + * 语言选项列表 + * 用于语言切换下拉框 + */ +export const languageOptions = [ + { value: LanguageEnum.ZH, label: '简体中文' }, + { value: LanguageEnum.EN, label: 'English' } +] + +/** + * 从存储中获取语言设置 + * @returns 语言设置,如果获取失败则返回默认语言 + */ +const getDefaultLanguage = (): LanguageEnum => { + // 尝试从版本化的存储中获取语言设置 + try { + const storageKey = storageKeyManager.getStorageKey('user') + const userStore = localStorage.getItem(storageKey) + + if (userStore) { + const { language } = JSON.parse(userStore) + if (language && Object.values(LanguageEnum).includes(language)) { + return language + } + } + } catch (error) { + console.warn('[i18n] 从版本化存储获取语言设置失败:', error) + } + + // 尝试从系统存储中获取语言设置 + try { + const sys = getSystemStorage() + if (sys) { + const { user } = JSON.parse(sys) + if (user?.language && Object.values(LanguageEnum).includes(user.language)) { + return user.language + } + } + } catch (error) { + console.warn('[i18n] 从系统存储获取语言设置失败:', error) + } + + // 返回默认语言 + console.debug('[i18n] 使用默认语言:', LanguageEnum.ZH) + return LanguageEnum.ZH +} + +/** + * i18n 配置选项 + */ +const i18nOptions: I18nOptions = { + locale: getDefaultLanguage(), + legacy: false, + globalInjection: true, + fallbackLocale: LanguageEnum.ZH, + messages +} + +/** + * i18n 实例 + */ +const i18n: I18n = createI18n(i18nOptions) + +/** + * 翻译函数类型 + */ +interface Translation { + (key: string): string +} + +/** + * 全局翻译函数 + * 可在任何地方使用,无需导入 useI18n + */ +export const $t = i18n.global.t as Translation + +export default i18n diff --git a/web/apps/admin/src/locales/langs/en.json b/web/apps/admin/src/locales/langs/en.json new file mode 100644 index 0000000..6dc27ea --- /dev/null +++ b/web/apps/admin/src/locales/langs/en.json @@ -0,0 +1,296 @@ +{ + "httpMsg": { + "unauthorized": "Unauthorized access, please login again", + "forbidden": "Access to this resource is forbidden", + "notFound": "The requested resource does not exist", + "methodNotAllowed": "Request method not allowed", + "requestTimeout": "Request timeout, please try again later", + "internalServerError": "Internal server error, please try again later", + "badGateway": "Bad gateway error, please try again later", + "serviceUnavailable": "Service temporarily unavailable, please try again later", + "gatewayTimeout": "Gateway timeout, please try again later", + "requestCancelled": "Request cancelled", + "networkError": "Network connection error, please check your connection", + "requestFailed": "Request failed", + "requestConfigError": "Request configuration error" + }, + "topBar": { + "search": { + "title": "Search" + }, + "user": { + "userCenter": "User center", + "docs": "Document", + "github": "Github", + "lockScreen": "Lock screen", + "logout": "Log out" + }, + "guide": { + "title": "Click here to view", + "theme": "Theme style", + "menu": "Open top menu", + "description": "More configurations" + } + }, + "common": { + "tips": "Prompt", + "cancel": "Cancel", + "confirm": "Confirm", + "logOutTips": "Do you want to log out?" + }, + "search": { + "placeholder": "Search page", + "historyTitle": "Search history", + "switchKeydown": "Navigate", + "selectKeydown": "Select", + "exitKeydown": "Close" + }, + "setting": { + "menuType": { + "title": "Menu Layout", + "list": [ + "Vertical", + "Horizontal", + "Mixed", + "Dual" + ] + }, + "theme": { + "title": "Theme Style", + "list": [ + "Light", + "Dark", + "System" + ] + }, + "menu": { + "title": "Menu Style" + }, + "color": { + "title": "Theme Color" + }, + "box": { + "title": "Box Style", + "list": [ + "Border", + "Shadow" + ] + }, + "container": { + "title": "Container Width", + "list": [ + "Full", + "Boxed" + ] + }, + "basics": { + "title": "Basic Config", + "list": { + "multiTab": "Show work tab", + "accordion": "Sidebar opens accordion", + "collapseSidebar": "Show sidebar button", + "reloadPage": "Show reload page button", + "fastEnter": "Show fast enter", + "breadcrumb": "Show crumb navigation", + "language": "Show multilingual selection", + "progressBar": "Show top progress bar", + "weakMode": "Color Weakness Mode", + "watermark": "Global watermark", + "menuWidth": "Menu width", + "tabStyle": "Tab style", + "pageTransition": "Page animation", + "borderRadius": "Custom radius" + } + }, + "tabStyle": { + "default": "Default", + "card": "Card", + "google": "Chrome" + }, + "transition": { + "list": { + "none": "None", + "fade": "Fade", + "slideLeft": "Slide Left", + "slideBottom": "Slide Bottom", + "slideTop": "Slide Top" + } + }, + "actions": { + "resetConfig": "Reset Config", + "copyConfig": "Copy Config", + "copySuccess": "Configuration copied to clipboard, paste it into src/config/setting.ts file", + "copyFailed": "Copy failed, please try again", + "resetFailed": "Reset failed, please refresh the page and try again" + } + }, + "notice": { + "title": "Notice", + "btnRead": "Mark as read", + "bar": [ + "Notice", + "Message", + "Todo" + ], + "text": [ + "No" + ], + "viewAll": "View all" + }, + "worktab": { + "btn": { + "refresh": "Refresh", + "fixed": "Fixed", + "unfixed": "Unfixed", + "closeLeft": "Close left", + "closeRight": "Close right", + "closeOther": "Close other", + "closeAll": "Close all" + } + }, + "login": { + "leftView": { + "title": "Unified LLM Access Gateway", + "subTitle": "Unified auth · rate limiting · quota · security audit" + }, + "title": "Welcome back", + "subTitle": "Secure access · unified governance", + "tagline": "Encrypted transport · zero-trust access", + "toggleTheme": "Toggle theme", + "roles": { + "super": "Super Admin", + "admin": "Admin", + "user": "User" + }, + "placeholder": { + "username": "Please enter your account", + "password": "Please enter your password" + }, + "rememberPwd": "Remember password", + "securityBadge": "AES-256 encryption · TLS 1.3 · login audit", + "forgetPwd": "Forgot password", + "btnText": "Login", + "noAccount": "No account yet?", + "register": "Register", + "success": { + "title": "Login successful", + "message": "Welcome back" + } + }, + "forgetPassword": { + "title": "Forgot password?", + "subTitle": "Enter your email to reset your password", + "placeholder": "Please enter your email", + "submitBtnText": "Submit", + "backBtnText": "Back" + }, + "register": { + "title": "Create account", + "subTitle": "Welcome to join us, please fill in the following information to complete the registration", + "placeholder": { + "username": "Please enter your account", + "password": "Please enter your password", + "confirmPassword": "Please enter your password again" + }, + "rule": { + "confirmPasswordRequired": "Please enter your password again", + "passwordMismatch": "The two passwords are inconsistent!", + "usernameLength": "The length is 3 to 20 characters", + "passwordLength": "The password length cannot be less than 6 digits", + "agreementRequired": "Please agree to the privacy policy" + }, + "agreeText": "I agree", + "privacyPolicy": "Privacy policy", + "submitBtnText": "Register", + "hasAccount": "Already have an account?", + "toLogin": "To login" + }, + "lockScreen": { + "pwdError": "Password error", + "lock": { + "inputPlaceholder": "Please input lock screen password", + "btnText": "Lock" + }, + "unlock": { + "inputPlaceholder": "Please input unlock password", + "btnText": "Unlock", + "backBtnText": "Back to login" + } + }, + "greeting": { + "dawn": "Good morning!", + "morning": "Good morning!", + "afternoon": "Good afternoon!", + "evening": "Good evening!" + }, + "exceptionPage": { + "403": "Sorry, you do not have permission to access this page", + "404": "Sorry, the page you are trying to access does not exist", + "500": "Sorry, there was an error on the server", + "gohome": "Go Home" + }, + "menus": { + "login": { + "title": "Login" + }, + "register": { + "title": "Register" + }, + "forgetPassword": { + "title": "Forget Password" + }, + "outside": { + "title": "Outside" + }, + "dashboard": { + "title": "Dashboard", + "console": "Console" + }, + "result": { + "title": "Result Page", + "success": "Success", + "fail": "Fail" + }, + "exception": { + "title": "Exception", + "forbidden": "403", + "notFound": "404", + "serverError": "500" + }, + "system": { + "title": "System Settings", + "user": "User Manage", + "role": "Role Manage", + "userCenter": "User Center", + "menu": "Menu Manage" + } + }, + "table": { + "form": { + "reset": "Reset", + "submit": "Submit" + }, + "searchBar": { + "reset": "Reset", + "search": "Search", + "expand": "Expand", + "collapse": "Collapse", + "searchInputPlaceholder": "Please enter", + "searchSelectPlaceholder": "Please select" + }, + "selection": "Select", + "sizeOptions": { + "small": "Compact", + "default": "Default", + "large": "Loose" + }, + "column": { + "selection": "Select", + "expand": "Expand", + "index": "Index" + }, + "zebra": "Zebra", + "border": "Border", + "headerBackground": "Header BG" + } +} \ No newline at end of file diff --git a/web/apps/admin/src/locales/langs/zh.json b/web/apps/admin/src/locales/langs/zh.json new file mode 100644 index 0000000..2c0abd9 --- /dev/null +++ b/web/apps/admin/src/locales/langs/zh.json @@ -0,0 +1,296 @@ +{ + "httpMsg": { + "unauthorized": "未授权访问,请重新登录", + "forbidden": "禁止访问该资源", + "notFound": "请求的资源不存在", + "methodNotAllowed": "请求方法不允许", + "requestTimeout": "请求超时,请稍后重试", + "internalServerError": "服务器内部错误,请稍后重试", + "badGateway": "网关错误,请稍后重试", + "serviceUnavailable": "服务暂时不可用,请稍后重试", + "gatewayTimeout": "网关超时,请稍后重试", + "requestCancelled": "请求已取消", + "networkError": "网络连接异常,请检查网络连接", + "requestFailed": "请求失败", + "requestConfigError": "请求配置错误" + }, + "topBar": { + "search": { + "title": "搜索" + }, + "user": { + "userCenter": "个人中心", + "docs": "使用文档", + "github": "Github", + "lockScreen": "锁定屏幕", + "logout": "退出登录" + }, + "guide": { + "title": "点击这里查看", + "theme": "主题风格", + "menu": "开启顶栏菜单", + "description": "等更多配置" + } + }, + "common": { + "tips": "提示", + "cancel": "取消", + "confirm": "确定", + "logOutTips": "您是否要退出登录?" + }, + "search": { + "placeholder": "搜索页面", + "historyTitle": "搜索历史", + "switchKeydown": "切换", + "selectKeydown": "选择", + "exitKeydown": "关闭" + }, + "setting": { + "menuType": { + "title": "菜单布局", + "list": [ + "垂直", + "水平", + "混合", + "双列" + ] + }, + "theme": { + "title": "主题风格", + "list": [ + "浅色", + "深色", + "系统" + ] + }, + "menu": { + "title": "菜单风格" + }, + "color": { + "title": "系统主题色" + }, + "box": { + "title": "盒子样式", + "list": [ + "边框", + "阴影" + ] + }, + "container": { + "title": "容器宽度", + "list": [ + "铺满", + "定宽" + ] + }, + "basics": { + "title": "基础配置", + "list": { + "multiTab": "开启多标签栏", + "accordion": "侧边栏开启手风琴模式", + "collapseSidebar": "显示折叠侧边栏按钮", + "fastEnter": "显示快速入口", + "reloadPage": "显示重载页面按钮", + "breadcrumb": "显示全局面包屑导航", + "language": "显示多语言选择", + "progressBar": "显示顶部进度条", + "weakMode": "色弱模式", + "watermark": "全局水印", + "menuWidth": "菜单宽度", + "tabStyle": "标签页风格", + "pageTransition": "页面切换动画", + "borderRadius": "自定义圆角" + } + }, + "tabStyle": { + "default": "默认", + "card": "卡片", + "google": "谷歌" + }, + "transition": { + "list": { + "none": "无动画", + "fade": "淡入淡出", + "slideLeft": "左侧滑入", + "slideBottom": "下方滑入", + "slideTop": "上方滑入" + } + }, + "actions": { + "resetConfig": "重置配置", + "copyConfig": "复制配置", + "copySuccess": "配置已复制到剪贴板,可粘贴到 src/config/setting.ts 文件中", + "copyFailed": "复制失败,请重试", + "resetFailed": "重置失败,请刷新页面后重试" + } + }, + "notice": { + "title": "通知", + "btnRead": "标为已读", + "bar": [ + "通知", + "消息", + "代办" + ], + "text": [ + "暂无" + ], + "viewAll": "查看全部" + }, + "worktab": { + "btn": { + "refresh": "刷新", + "fixed": "固定", + "unfixed": "取消固定", + "closeLeft": "关闭左侧", + "closeRight": "关闭右侧", + "closeOther": "关闭其他", + "closeAll": "关闭全部" + } + }, + "login": { + "leftView": { + "title": "大模型统一接入网关", + "subTitle": "统一鉴权 · 限流 · 配额 · 安全审计" + }, + "title": "欢迎回来", + "subTitle": "安全接入 · 统一治理", + "tagline": "数据加密传输 · 零信任接入", + "toggleTheme": "切换主题", + "roles": { + "super": "超级管理员", + "admin": "管理员", + "user": "普通用户" + }, + "placeholder": { + "username": "请输入账号", + "password": "请输入密码" + }, + "rememberPwd": "记住密码", + "securityBadge": "AES-256 加密 · TLS 1.3 · 登录安全审计", + "forgetPwd": "忘记密码", + "btnText": "登录", + "noAccount": "还没有账号?", + "register": "注册", + "success": { + "title": "登录成功", + "message": "欢迎回来" + } + }, + "forgetPassword": { + "title": "忘记密码?", + "subTitle": "输入您的电子邮件来重置您的密码", + "placeholder": "请输入您的电子邮件", + "submitBtnText": "提交", + "backBtnText": "返回" + }, + "register": { + "title": "创建账号", + "subTitle": "欢迎加入我们,请填写以下信息完成注册", + "placeholder": { + "username": "请输入账号", + "password": "请输入密码", + "confirmPassword": "请再次输入密码" + }, + "rule": { + "confirmPasswordRequired": "请再次输入密码", + "passwordMismatch": "两次输入密码不一致!", + "usernameLength": "长度在 3 到 20 个字符", + "passwordLength": "密码长度不能小于6位", + "agreementRequired": "请同意隐私协议" + }, + "agreeText": "我同意", + "privacyPolicy": "《隐私政策》", + "submitBtnText": "注册", + "hasAccount": "已有账号?", + "toLogin": "去登录" + }, + "lockScreen": { + "pwdError": "密码错误", + "lock": { + "inputPlaceholder": "请输入锁屏密码", + "btnText": "锁定" + }, + "unlock": { + "inputPlaceholder": "请输入解锁密码", + "btnText": "解锁", + "backBtnText": "返回登录" + } + }, + "greeting": { + "dawn": "凌晨了!", + "morning": "上午好!", + "afternoon": "下午好!", + "evening": "晚上好!" + }, + "exceptionPage": { + "403": "抱歉,您无权访问该页面", + "404": "抱歉,您访问的页面不存在", + "500": "抱歉,服务器出错了", + "gohome": "返回首页" + }, + "menus": { + "login": { + "title": "登录" + }, + "register": { + "title": "注册" + }, + "forgetPassword": { + "title": "忘记密码" + }, + "outside": { + "title": "内嵌页面" + }, + "dashboard": { + "title": "仪表盘", + "console": "工作台" + }, + "result": { + "title": "结果页面", + "success": "成功页", + "fail": "失败页" + }, + "exception": { + "title": "异常页面", + "forbidden": "403", + "notFound": "404", + "serverError": "500" + }, + "system": { + "title": "系统管理", + "user": "用户管理", + "role": "角色管理", + "userCenter": "个人中心", + "menu": "菜单管理" + } + }, + "table": { + "form": { + "reset": "重置", + "submit": "提交" + }, + "searchBar": { + "reset": "重置", + "search": "查询", + "expand": "展开", + "collapse": "收起", + "searchInputPlaceholder": "请输入", + "searchSelectPlaceholder": "请选择" + }, + "selection": "选择", + "sizeOptions": { + "small": "紧凑", + "default": "默认", + "large": "宽松" + }, + "column": { + "selection": "勾选", + "expand": "展开", + "index": "序号" + }, + "zebra": "斑马纹", + "border": "边框", + "headerBackground": "表头背景" + } +} \ No newline at end of file diff --git a/web/apps/admin/src/main.ts b/web/apps/admin/src/main.ts new file mode 100755 index 0000000..7df948a --- /dev/null +++ b/web/apps/admin/src/main.ts @@ -0,0 +1,25 @@ +import App from './App.vue' +import { createApp } from 'vue' +import { initStore } from './store' // Store +import { initRouter } from './router' // Router +import language from './locales' // 国际化 +import '@styles/core/tailwind.css' // tailwind +import '@styles/index.scss' // 样式 +import '@utils/sys/console.ts' // 控制台输出内容 +import { setupGlobDirectives } from './directives' +import { setupErrorHandle } from './utils/sys/error-handle' + +document.addEventListener( + 'touchstart', + function () {}, + { passive: false } +) + +const app = createApp(App) +initStore(app) +initRouter(app) +setupGlobDirectives(app) +setupErrorHandle(app) + +app.use(language) +app.mount('#app') \ No newline at end of file diff --git a/web/apps/admin/src/mock/temp/formData.ts b/web/apps/admin/src/mock/temp/formData.ts new file mode 100755 index 0000000..8aa7ae5 --- /dev/null +++ b/web/apps/admin/src/mock/temp/formData.ts @@ -0,0 +1,273 @@ +import avatar1 from '@/assets/images/avatar/avatar1.webp' +import avatar2 from '@/assets/images/avatar/avatar2.webp' +import avatar3 from '@/assets/images/avatar/avatar3.webp' +import avatar4 from '@/assets/images/avatar/avatar4.webp' +import avatar5 from '@/assets/images/avatar/avatar5.webp' +import avatar6 from '@/assets/images/avatar/avatar6.webp' +import avatar7 from '@/assets/images/avatar/avatar7.webp' +import avatar8 from '@/assets/images/avatar/avatar8.webp' +import avatar9 from '@/assets/images/avatar/avatar9.webp' +import avatar10 from '@/assets/images/avatar/avatar10.webp' + +export interface User { + id: number + username: string + gender: 1 | 0 + mobile: string + email: string + dep: string + status: string + create_time: string + avatar: string +} + +// 用户列表 +export const ACCOUNT_TABLE_DATA: User[] = [ + { + id: 1, + username: 'alexmorgan', + gender: 1, + mobile: '18670001591', + email: 'alexmorgan@company.com', + dep: '研发部', + status: '1', + create_time: '2020-09-09 10:01:10', + avatar: avatar1 + }, + { + id: 2, + username: 'sophiabaker', + gender: 1, + mobile: '17766664444', + email: 'sophiabaker@company.com', + dep: '电商部', + status: '1', + create_time: '2020-10-10 13:01:12', + avatar: avatar2 + }, + { + id: 3, + username: 'liampark', + gender: 1, + mobile: '18670001597', + email: 'liampark@company.com', + dep: '人事部', + status: '1', + create_time: '2020-11-14 12:01:45', + avatar: avatar3 + }, + { + id: 4, + username: 'oliviagrant', + gender: 0, + mobile: '18670001596', + email: 'oliviagrant@company.com', + dep: '产品部', + status: '1', + create_time: '2020-11-14 09:01:20', + avatar: avatar4 + }, + { + id: 5, + username: 'emmawilson', + gender: 0, + mobile: '18670001595', + email: 'emmawilson@company.com', + dep: '财务部', + status: '1', + create_time: '2020-11-13 11:01:05', + avatar: avatar5 + }, + { + id: 6, + username: 'noahevan', + gender: 1, + mobile: '18670001594', + email: 'noahevan@company.com', + dep: '运营部', + status: '1', + create_time: '2020-10-11 13:10:26', + avatar: avatar6 + }, + { + id: 7, + username: 'avamartin', + gender: 1, + mobile: '18123820191', + email: 'avamartin@company.com', + dep: '客服部', + status: '2', + create_time: '2020-05-14 12:05:10', + avatar: avatar7 + }, + { + id: 8, + username: 'jacoblee', + gender: 1, + mobile: '18670001592', + email: 'jacoblee@company.com', + dep: '总经办', + status: '3', + create_time: '2020-11-12 07:22:25', + avatar: avatar8 + }, + { + id: 9, + username: 'miaclark', + gender: 0, + mobile: '18670001581', + email: 'miaclark@company.com', + dep: '研发部', + status: '4', + create_time: '2020-06-12 05:04:20', + avatar: avatar9 + }, + { + id: 10, + username: 'ethanharris', + gender: 1, + mobile: '13755554444', + email: 'ethanharris@company.com', + dep: '研发部', + status: '1', + create_time: '2020-11-12 16:01:10', + avatar: avatar10 + }, + { + id: 11, + username: 'isabellamoore', + gender: 1, + mobile: '13766660000', + email: 'isabellamoore@company.com', + dep: '研发部', + status: '1', + create_time: '2020-11-14 12:01:20', + avatar: avatar6 + }, + { + id: 12, + username: 'masonwhite', + gender: 1, + mobile: '18670001502', + email: 'masonwhite@company.com', + dep: '研发部', + status: '1', + create_time: '2020-11-14 12:01:20', + avatar: avatar7 + }, + { + id: 13, + username: 'charlottehall', + gender: 1, + mobile: '13006644977', + email: 'charlottehall@company.com', + dep: '研发部', + status: '1', + create_time: '2020-11-14 12:01:20', + avatar: avatar8 + }, + { + id: 14, + username: 'benjaminscott', + gender: 0, + mobile: '13599998888', + email: 'benjaminscott@company.com', + dep: '研发部', + status: '1', + create_time: '2020-11-14 12:01:20', + avatar: avatar9 + }, + { + id: 15, + username: 'ameliaking', + gender: 1, + mobile: '13799998888', + email: 'ameliaking@company.com', + dep: '研发部', + status: '1', + create_time: '2020-11-14 12:01:20', + avatar: avatar10 + } +] + +export interface Role { + roleName: string + roleCode: string + des: string + date: string + enable: boolean +} + +// 角色列表 +export const ROLE_LIST_DATA: Role[] = [ + { + roleName: '超级管理员', + roleCode: 'R_SUPER', + des: '拥有系统全部权限', + date: '2025-05-15 12:30:45', + enable: true + }, + { + roleName: '管理员', + roleCode: 'R_ADMIN', + des: '拥有系统管理权限', + date: '2025-05-15 12:30:45', + enable: true + }, + { + roleName: '普通用户', + roleCode: 'R_USER', + des: '拥有系统普通权限', + date: '2025-05-15 12:30:45', + enable: true + }, + { + roleName: '财务管理员', + roleCode: 'R_FINANCE', + des: '管理财务相关权限', + date: '2025-05-16 09:15:30', + enable: true + }, + { + roleName: '数据分析师', + roleCode: 'R_ANALYST', + des: '拥有数据分析权限', + date: '2025-05-16 11:45:00', + enable: false + }, + { + roleName: '客服专员', + roleCode: 'R_SUPPORT', + des: '处理客户支持请求', + date: '2025-05-17 14:30:22', + enable: true + }, + { + roleName: '营销经理', + roleCode: 'R_MARKETING', + des: '管理营销活动权限', + date: '2025-05-17 15:10:50', + enable: true + }, + { + roleName: '访客用户', + roleCode: 'R_GUEST', + des: '仅限浏览权限', + date: '2025-05-18 08:25:40', + enable: false + }, + { + roleName: '系统维护员', + roleCode: 'R_MAINTAINER', + des: '负责系统维护和更新', + date: '2025-05-18 09:50:12', + enable: true + }, + { + roleName: '项目经理', + roleCode: 'R_PM', + des: '管理项目相关权限', + date: '2025-05-19 13:40:35', + enable: true + } +] diff --git a/web/apps/admin/src/mock/upgrade/changeLog.ts b/web/apps/admin/src/mock/upgrade/changeLog.ts new file mode 100644 index 0000000..dd6b772 --- /dev/null +++ b/web/apps/admin/src/mock/upgrade/changeLog.ts @@ -0,0 +1,12 @@ +import { ref } from 'vue' + +interface UpgradeLog { + version: string // 版本号 + title: string // 更新标题 + date: string // 更新日期 + detail?: string[] // 更新内容 + requireReLogin?: boolean // 是否需要重新登录 + remark?: string // 备注 +} + +export const upgradeLogList = ref([]) diff --git a/web/apps/admin/src/plugins/echarts.ts b/web/apps/admin/src/plugins/echarts.ts new file mode 100644 index 0000000..4f56d89 --- /dev/null +++ b/web/apps/admin/src/plugins/echarts.ts @@ -0,0 +1,76 @@ +/** + * ECharts 插件配置 + * + * 按需导入 ECharts 图表和组件,减小打包体积。 + * 只注册项目中实际使用的图表类型和组件。 + * + * @module plugins/echarts + * @author Art Design Pro Team + */ + +// ECharts 按需导入配置 +import * as echarts from 'echarts/core' + +// 导入图表类型 +import { + BarChart, + LineChart, + PieChart, + ScatterChart, + RadarChart, + MapChart, + CandlestickChart +} from 'echarts/charts' + +// 导入组件 +import { + TitleComponent, + TooltipComponent, + GridComponent, + LegendComponent, + DataZoomComponent, + MarkPointComponent, + MarkLineComponent, + ToolboxComponent, + BrushComponent, + GeoComponent, + VisualMapComponent +} from 'echarts/components' + +// 导入渲染器 +import { CanvasRenderer } from 'echarts/renderers' + +// 注册必要的组件 +echarts.use([ + // 图表类型 + BarChart, + LineChart, + PieChart, + ScatterChart, + RadarChart, + MapChart, + CandlestickChart, + + // 组件 + TitleComponent, + TooltipComponent, + GridComponent, + LegendComponent, + DataZoomComponent, + MarkPointComponent, + MarkLineComponent, + ToolboxComponent, + BrushComponent, + GeoComponent, + VisualMapComponent, + + // 渲染器 + CanvasRenderer +]) + +// 导出 echarts 实例和类型 +export { echarts } +export type { EChartsOption, BarSeriesOption } from 'echarts' + +// 导出常用的图形工具 +export const graphic = echarts.graphic diff --git a/web/apps/admin/src/plugins/index.ts b/web/apps/admin/src/plugins/index.ts new file mode 100644 index 0000000..4536a86 --- /dev/null +++ b/web/apps/admin/src/plugins/index.ts @@ -0,0 +1,6 @@ +/** + * 插件统一导出 + * 集中管理第三方库的封装和配置 + */ + +export * from './echarts' diff --git a/web/apps/admin/src/router/core/ComponentLoader.ts b/web/apps/admin/src/router/core/ComponentLoader.ts new file mode 100644 index 0000000..8af3ce3 --- /dev/null +++ b/web/apps/admin/src/router/core/ComponentLoader.ts @@ -0,0 +1,82 @@ +/** + * 组件加载器 + * + * 负责动态加载 Vue 组件 + * + * @module router/core/ComponentLoader + * @author Art Design Pro Team + */ + +import { h } from 'vue' + +export class ComponentLoader { + private modules: Record Promise> + + constructor() { + // 动态导入 views 目录下所有 .vue 组件 + this.modules = import.meta.glob('../../views/**/*.vue') + } + + /** + * 加载组件 + */ + load(componentPath: string): () => Promise { + if (!componentPath) { + return this.createEmptyComponent() + } + + // 构建可能的路径 + const fullPath = `../../views${componentPath}.vue` + const fullPathWithIndex = `../../views${componentPath}/index.vue` + + // 先尝试直接路径,再尝试添加/index的路径 + const module = this.modules[fullPath] || this.modules[fullPathWithIndex] + + if (!module) { + console.error( + `[ComponentLoader] 未找到组件: ${componentPath},尝试过的路径: ${fullPath} 和 ${fullPathWithIndex}` + ) + return this.createErrorComponent(componentPath) + } + + return module + } + + /** + * 加载布局组件 + */ + loadLayout(): () => Promise { + return () => import('@/views/index/index.vue') + } + + /** + * 加载 iframe 组件 + */ + loadIframe(): () => Promise { + return () => import('@/views/outside/Iframe.vue') + } + + /** + * 创建空组件 + */ + private createEmptyComponent(): () => Promise { + return () => + Promise.resolve({ + render() { + return h('div', {}) + } + }) + } + + /** + * 创建错误提示组件 + */ + private createErrorComponent(componentPath: string): () => Promise { + return () => + Promise.resolve({ + render() { + return h('div', { class: 'route-error' }, `组件未找到: ${componentPath}`) + } + }) + } +} diff --git a/web/apps/admin/src/router/core/IframeRouteManager.ts b/web/apps/admin/src/router/core/IframeRouteManager.ts new file mode 100644 index 0000000..c054ca1 --- /dev/null +++ b/web/apps/admin/src/router/core/IframeRouteManager.ts @@ -0,0 +1,78 @@ +/** + * Iframe 路由管理器 + * + * 负责管理 iframe 类型的路由 + * + * @module router/core/IframeRouteManager + * @author Art Design Pro Team + */ + +import type { AppRouteRecord } from '@/types/router' + +export class IframeRouteManager { + private static instance: IframeRouteManager + private iframeRoutes: AppRouteRecord[] = [] + + private constructor() {} + + static getInstance(): IframeRouteManager { + if (!IframeRouteManager.instance) { + IframeRouteManager.instance = new IframeRouteManager() + } + return IframeRouteManager.instance + } + + /** + * 添加 iframe 路由 + */ + add(route: AppRouteRecord): void { + if (!this.iframeRoutes.find((r) => r.path === route.path)) { + this.iframeRoutes.push(route) + } + } + + /** + * 获取所有 iframe 路由 + */ + getAll(): AppRouteRecord[] { + return this.iframeRoutes + } + + /** + * 根据路径查找 iframe 路由 + */ + findByPath(path: string): AppRouteRecord | undefined { + return this.iframeRoutes.find((route) => route.path === path) + } + + /** + * 清空所有 iframe 路由 + */ + clear(): void { + this.iframeRoutes = [] + } + + /** + * 保存到 sessionStorage + */ + save(): void { + if (this.iframeRoutes.length > 0) { + sessionStorage.setItem('iframeRoutes', JSON.stringify(this.iframeRoutes)) + } + } + + /** + * 从 sessionStorage 加载 + */ + load(): void { + try { + const data = sessionStorage.getItem('iframeRoutes') + if (data) { + this.iframeRoutes = JSON.parse(data) + } + } catch (error) { + console.error('[IframeRouteManager] 加载 iframe 路由失败:', error) + this.iframeRoutes = [] + } + } +} diff --git a/web/apps/admin/src/router/core/MenuProcessor.ts b/web/apps/admin/src/router/core/MenuProcessor.ts new file mode 100644 index 0000000..3f822d5 --- /dev/null +++ b/web/apps/admin/src/router/core/MenuProcessor.ts @@ -0,0 +1,280 @@ +/** + * 菜单处理器 + * + * 负责菜单数据的获取、过滤和处理 + * + * @module router/core/MenuProcessor + * @author Art Design Pro Team + */ + +import type { AppRouteRecord } from '@/types/router' +import { useUserStore } from '@/store/modules/user' +import { useAppMode } from '@/hooks/core/useAppMode' +import { fetchGetMenuList } from '@/api/system-manage' +import { asyncRoutes } from '../routes/asyncRoutes' +import { RoutesAlias } from '../routesAlias' +import { formatMenuTitle } from '@/utils' + +export class MenuProcessor { + /** + * 获取菜单数据 + */ + async getMenuList(): Promise { + const { isFrontendMode } = useAppMode() + + let menuList: AppRouteRecord[] + if (isFrontendMode.value) { + menuList = await this.processFrontendMenu() + } else { + menuList = await this.processBackendMenu() + } + + // 在规范化路径之前,验证原始路径配置 + this.validateMenuPaths(menuList) + + // 规范化路径(将相对路径转换为完整路径) + return this.normalizeMenuPaths(menuList) + } + + /** + * 处理前端控制模式的菜单 + */ + private async processFrontendMenu(): Promise { + const userStore = useUserStore() + const roles = userStore.info?.roles + + let menuList = [...asyncRoutes] + + // 根据角色过滤菜单 + if (roles && roles.length > 0) { + menuList = this.filterMenuByRoles(menuList, roles) + } + + return this.filterEmptyMenus(menuList) + } + + /** + * 处理后端控制模式的菜单 + */ + private async processBackendMenu(): Promise { + const list = await fetchGetMenuList() + return this.filterEmptyMenus(list) + } + + /** + * 根据角色过滤菜单 + */ + private filterMenuByRoles(menu: AppRouteRecord[], roles: string[]): AppRouteRecord[] { + return menu.reduce((acc: AppRouteRecord[], item) => { + const itemRoles = item.meta?.roles + const hasPermission = !itemRoles || itemRoles.some((role) => roles?.includes(role)) + + if (hasPermission) { + const filteredItem = { ...item } + if (filteredItem.children?.length) { + filteredItem.children = this.filterMenuByRoles(filteredItem.children, roles) + } + acc.push(filteredItem) + } + + return acc + }, []) + } + + /** + * 递归过滤空菜单项 + */ + private filterEmptyMenus(menuList: AppRouteRecord[]): AppRouteRecord[] { + return menuList + .map((item) => { + // 如果有子菜单,先递归过滤子菜单 + if (item.children && item.children.length > 0) { + const filteredChildren = this.filterEmptyMenus(item.children) + return { + ...item, + children: filteredChildren + } + } + return item + }) + .filter((item) => { + // 如果定义了 children 属性(即使是空数组),说明这是一个目录菜单,应该保留 + if ('children' in item) { + return true + } + + // 如果有外链或 iframe,保留 + if (item.meta?.isIframe === true || item.meta?.link) { + return true + } + + // 如果有有效的 component,保留 + if (item.component && item.component !== '' && item.component !== RoutesAlias.Layout) { + return true + } + + // 其他情况过滤掉 + return false + }) + } + + /** + * 验证菜单列表是否有效 + */ + validateMenuList(menuList: AppRouteRecord[]): boolean { + return Array.isArray(menuList) && menuList.length > 0 + } + + /** + * 规范化菜单路径 + * 将相对路径转换为完整路径,确保菜单跳转正确 + */ + private normalizeMenuPaths(menuList: AppRouteRecord[], parentPath = ''): AppRouteRecord[] { + return menuList.map((item) => { + // 构建完整路径 + const fullPath = this.buildFullPath(item.path || '', parentPath) + + // 递归处理子菜单 + const children = item.children?.length + ? this.normalizeMenuPaths(item.children, fullPath) + : item.children + + const redirect = item.redirect || this.resolveDefaultRedirect(children) + + return { + ...item, + path: fullPath, + redirect, + children + } + }) + } + + /** + * 为目录型菜单推导默认跳转地址 + */ + private resolveDefaultRedirect(children?: AppRouteRecord[]): string | undefined { + if (!children?.length) { + return undefined + } + + for (const child of children) { + if (this.isNavigableRoute(child)) { + return child.path + } + + const nestedRedirect = this.resolveDefaultRedirect(child.children) + if (nestedRedirect) { + return nestedRedirect + } + } + + return undefined + } + + /** + * 判断子路由是否可以作为默认落点 + */ + private isNavigableRoute(route: AppRouteRecord): boolean { + return Boolean( + route.path && + route.path !== '/' && + !route.meta?.link && + route.meta?.isIframe !== true && + route.component && + route.component !== '' + ) + } + + /** + * 验证菜单路径配置 + * 检测非一级菜单是否错误使用了 / 开头的路径 + */ + /** + * 验证菜单路径配置 + * 检测非一级菜单是否错误使用了 / 开头的路径 + */ + private validateMenuPaths(menuList: AppRouteRecord[], level = 1): void { + menuList.forEach((route) => { + if (!route.children?.length) return + + const parentName = String(route.name || route.path || '未知路由') + + route.children.forEach((child) => { + const childPath = child.path || '' + + // 跳过合法的绝对路径:外部链接和 iframe 路由 + if (this.isValidAbsolutePath(childPath)) return + + // 检测非法的绝对路径 + if (childPath.startsWith('/')) { + this.logPathError(child, childPath, parentName, level) + } + }) + + // 递归检查更深层级的子路由 + this.validateMenuPaths(route.children, level + 1) + }) + } + + /** + * 判断是否为合法的绝对路径 + */ + private isValidAbsolutePath(path: string): boolean { + return ( + path.startsWith('http://') || + path.startsWith('https://') || + path.startsWith('/outside/iframe/') + ) + } + + /** + * 输出路径配置错误日志 + */ + private logPathError( + route: AppRouteRecord, + path: string, + parentName: string, + level: number + ): void { + const routeName = String(route.name || path || '未知路由') + const menuTitle = route.meta?.title || routeName + const suggestedPath = path.split('/').pop() || path.slice(1) + + console.error( + `[路由配置错误] 菜单 "${formatMenuTitle(menuTitle)}" (name: ${routeName}, path: ${path}) 配置错误\n` + + ` 位置: ${parentName} > ${routeName}\n` + + ` 问题: ${level + 1}级菜单的 path 不能以 / 开头\n` + + ` 当前配置: path: '${path}'\n` + + ` 应该改为: path: '${suggestedPath}'` + ) + } + + /** + * 构建完整路径 + */ + private buildFullPath(path: string, parentPath: string): string { + if (!path) return '' + + // 外部链接直接返回 + if (path.startsWith('http://') || path.startsWith('https://')) { + return path + } + + // 如果已经是绝对路径,直接返回 + if (path.startsWith('/')) { + return path + } + + // 拼接父路径和当前路径 + if (parentPath) { + // 移除父路径末尾的斜杠,移除子路径开头的斜杠,然后拼接 + const cleanParent = parentPath.replace(/\/$/, '') + const cleanChild = path.replace(/^\//, '') + return `${cleanParent}/${cleanChild}` + } + + // 没有父路径,添加前导斜杠 + return `/${path}` + } +} diff --git a/web/apps/admin/src/router/core/RoutePermissionValidator.ts b/web/apps/admin/src/router/core/RoutePermissionValidator.ts new file mode 100644 index 0000000..1e607e0 --- /dev/null +++ b/web/apps/admin/src/router/core/RoutePermissionValidator.ts @@ -0,0 +1,161 @@ +/** + * 路由权限验证模块 + * + * 提供路由权限验证和路径检查功能 + * + * ## 主要功能 + * + * - 验证路径是否在用户菜单权限中 + * - 构建菜单路径集合(扁平化处理) + * - 支持动态路由参数匹配 + * - 路径前缀匹配 + * + * ## 使用场景 + * + * - 路由守卫中验证用户权限 + * - 动态路由注册后的权限检查 + * - 防止用户访问无权限的页面 + * + * @module router/core/RoutePermissionValidator + * @author Art Design Pro Team + */ + +import type { AppRouteRecord } from '@/types/router' + +/** + * 路由权限验证器 + */ +export class RoutePermissionValidator { + /** + * 验证路径是否在用户菜单权限中 + * @param targetPath 目标路径 + * @param menuList 菜单列表 + * @returns 是否有权限访问 + */ + static hasPermission(targetPath: string, menuList: AppRouteRecord[]): boolean { + // 根路径始终允许访问 + if (targetPath === '/') { + return true + } + + return this.matchRoute(targetPath, menuList) + } + + /** + * 构建菜单路径集合(扁平化处理) + * @param menuList 菜单列表 + * @param pathSet 路径集合 + * @returns 路径集合 + */ + static buildMenuPathSet( + menuList: AppRouteRecord[], + pathSet: Set = new Set() + ): Set { + if (!Array.isArray(menuList) || menuList.length === 0) { + return pathSet + } + + for (const menuItem of menuList) { + if (!menuItem.path) { + continue + } + + // 标准化路径并添加到集合 + const menuPath = menuItem.path.startsWith('/') ? menuItem.path : `/${menuItem.path}` + pathSet.add(menuPath) + + // 递归处理子菜单 + if (menuItem.children?.length) { + this.buildMenuPathSet(menuItem.children, pathSet) + } + } + + return pathSet + } + + /** + * 检查目标路径是否匹配集合中的某个路径前缀 + * 用于支持动态路由参数匹配,如 /user/123 匹配 /user + * @param targetPath 目标路径 + * @param pathSet 路径集合 + * @returns 是否匹配 + */ + static checkPathPrefix(targetPath: string, pathSet: Set): boolean { + // 遍历路径集合,检查是否有前缀匹配 + for (const menuPath of pathSet) { + if (targetPath.startsWith(`${menuPath}/`)) { + return true + } + } + return false + } + + /** + * 递归匹配路由配置,支持隐藏路由和动态参数路由 + */ + static matchRoute(targetPath: string, routes: AppRouteRecord[]): boolean { + if (!Array.isArray(routes) || routes.length === 0) { + return false + } + + for (const route of routes) { + if (!route.path) { + continue + } + + const routePath = route.path.startsWith('/') ? route.path : `/${route.path}` + + if ( + routePath === targetPath || + this.isDynamicRouteMatch(targetPath, routePath) || + targetPath.startsWith(`${routePath}/`) + ) { + return true + } + + if (route.children?.length && this.matchRoute(targetPath, route.children)) { + return true + } + } + + return false + } + + /** + * 检查目标路径是否匹配动态参数路由,如 /demo/123 匹配 /demo/:id + */ + static isDynamicRouteMatch(targetPath: string, routePath: string): boolean { + if (!routePath.includes(':')) { + return false + } + + const pattern = routePath + .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + .replace(/:([^/]+)/g, '[^/]+') + .replace(/\\\*/g, '.*') + + return new RegExp(`^${pattern}$`).test(targetPath) + } + + /** + * 验证并返回有效的路径 + * 如果目标路径无权限,返回首页路径 + * @param targetPath 目标路径 + * @param menuList 菜单列表 + * @param homePath 首页路径 + * @returns 验证后的路径 + */ + static validatePath( + targetPath: string, + menuList: AppRouteRecord[], + homePath: string = '/' + ): { path: string; hasPermission: boolean } { + const hasPermission = this.hasPermission(targetPath, menuList) + + if (hasPermission) { + return { path: targetPath, hasPermission: true } + } + + return { path: homePath, hasPermission: false } + } +} diff --git a/web/apps/admin/src/router/core/RouteRegistry.ts b/web/apps/admin/src/router/core/RouteRegistry.ts new file mode 100644 index 0000000..e1acb9e --- /dev/null +++ b/web/apps/admin/src/router/core/RouteRegistry.ts @@ -0,0 +1,90 @@ +/** + * 路由注册核心类 + * + * 负责动态路由的注册、验证和管理 + * + * @module router/core/RouteRegistry + * @author Art Design Pro Team + */ + +import type { Router, RouteRecordRaw } from 'vue-router' +import type { AppRouteRecord } from '@/types/router' +import { ComponentLoader } from './ComponentLoader' +import { RouteValidator } from './RouteValidator' +import { RouteTransformer } from './RouteTransformer' + +export class RouteRegistry { + private router: Router + private componentLoader: ComponentLoader + private validator: RouteValidator + private transformer: RouteTransformer + private removeRouteFns: (() => void)[] = [] + private registered = false + + constructor(router: Router) { + this.router = router + this.componentLoader = new ComponentLoader() + this.validator = new RouteValidator() + this.transformer = new RouteTransformer(this.componentLoader) + } + + /** + * 注册动态路由 + */ + register(menuList: AppRouteRecord[]): void { + if (this.registered) { + console.warn('[RouteRegistry] 路由已注册,跳过重复注册') + return + } + + // 验证路由配置 + const validationResult = this.validator.validate(menuList) + if (!validationResult.valid) { + throw new Error(`路由配置验证失败: ${validationResult.errors.join(', ')}`) + } + + // 转换并注册路由 + const removeRouteFns: (() => void)[] = [] + + menuList.forEach((route) => { + if (route.name && !this.router.hasRoute(route.name)) { + const routeConfig = this.transformer.transform(route) + const removeRouteFn = this.router.addRoute(routeConfig as RouteRecordRaw) + removeRouteFns.push(removeRouteFn) + } + }) + + this.removeRouteFns = removeRouteFns + this.registered = true + } + + /** + * 移除所有动态路由 + */ + unregister(): void { + this.removeRouteFns.forEach((fn) => fn()) + this.removeRouteFns = [] + this.registered = false + } + + /** + * 检查是否已注册 + */ + isRegistered(): boolean { + return this.registered + } + + /** + * 获取移除函数列表(用于 store 管理) + */ + getRemoveRouteFns(): (() => void)[] { + return this.removeRouteFns + } + + /** + * 标记为已注册(用于错误处理场景,避免重复请求) + */ + markAsRegistered(): void { + this.registered = true + } +} diff --git a/web/apps/admin/src/router/core/RouteTransformer.ts b/web/apps/admin/src/router/core/RouteTransformer.ts new file mode 100644 index 0000000..0f6900c --- /dev/null +++ b/web/apps/admin/src/router/core/RouteTransformer.ts @@ -0,0 +1,132 @@ +/** + * 路由转换器 + * + * 负责将菜单数据转换为 Vue Router 路由配置 + * + * @module router/core/RouteTransformer + * @author Art Design Pro Team + */ + +import type { RouteRecordRaw } from 'vue-router' +import type { AppRouteRecord } from '@/types/router' +import { ComponentLoader } from './ComponentLoader' +import { IframeRouteManager } from './IframeRouteManager' + +interface ConvertedRoute extends Omit { + id?: number + children?: ConvertedRoute[] + component?: RouteRecordRaw['component'] | (() => Promise) +} + +export class RouteTransformer { + private componentLoader: ComponentLoader + private iframeManager: IframeRouteManager + + constructor(componentLoader: ComponentLoader) { + this.componentLoader = componentLoader + this.iframeManager = IframeRouteManager.getInstance() + } + + /** + * 转换路由配置 + */ + transform(route: AppRouteRecord, depth = 0): ConvertedRoute { + const { component, children, ...routeConfig } = route + + // 基础路由配置 + const converted: ConvertedRoute = { + ...routeConfig, + component: undefined + } + + // 处理不同类型的路由 + if (route.meta.isIframe) { + this.handleIframeRoute(converted, route, depth) + } else if (this.isFirstLevelRoute(route, depth)) { + this.handleFirstLevelRoute(converted, route, component as string) + } else { + this.handleNormalRoute(converted, component as string) + } + + // 递归处理子路由 + if (children?.length) { + converted.children = children.map((child) => this.transform(child, depth + 1)) + } + + return converted + } + + /** + * 判断是否为一级路由(需要 Layout 包裹) + */ + private isFirstLevelRoute(route: AppRouteRecord, depth: number): boolean { + return depth === 0 && (!route.children || route.children.length === 0) + } + + /** + * 处理 iframe 类型路由 + */ + private handleIframeRoute( + targetRoute: ConvertedRoute, + sourceRoute: AppRouteRecord, + depth: number + ): void { + if (depth === 0) { + // 顶级 iframe:用 Layout 包裹 + targetRoute.component = this.componentLoader.loadLayout() + targetRoute.path = this.extractFirstSegment(sourceRoute.path || '') + targetRoute.name = '' + + targetRoute.children = [ + { + ...sourceRoute, + component: this.componentLoader.loadIframe() + } as ConvertedRoute + ] + } else { + // 非顶级(嵌套)iframe:直接使用 Iframe.vue + targetRoute.component = this.componentLoader.loadIframe() + } + + // 记录 iframe 路由 + this.iframeManager.add(sourceRoute) + } + + /** + * 处理一级菜单路由 + */ + private handleFirstLevelRoute( + converted: ConvertedRoute, + route: AppRouteRecord, + component: string | undefined + ): void { + converted.component = this.componentLoader.loadLayout() + converted.path = this.extractFirstSegment(route.path || '') + converted.name = '' + route.meta.isFirstLevel = true + + converted.children = [ + { + ...route, + component: component ? this.componentLoader.load(component) : undefined + } as ConvertedRoute + ] + } + + /** + * 处理普通路由 + */ + private handleNormalRoute(converted: ConvertedRoute, component: string | undefined): void { + if (component) { + converted.component = this.componentLoader.load(component) + } + } + + /** + * 提取路径的第一段 + */ + private extractFirstSegment(path: string): string { + const segments = path.split('/').filter(Boolean) + return segments.length > 0 ? `/${segments[0]}` : '/' + } +} diff --git a/web/apps/admin/src/router/core/RouteValidator.ts b/web/apps/admin/src/router/core/RouteValidator.ts new file mode 100644 index 0000000..f8e58fc --- /dev/null +++ b/web/apps/admin/src/router/core/RouteValidator.ts @@ -0,0 +1,187 @@ +/** + * 路由验证器 + * + * 负责验证路由配置的合法性 + * + * @module router/core/RouteValidator + * @author Art Design Pro Team + */ + +import type { AppRouteRecord } from '@/types/router' +import { RoutesAlias } from '../routesAlias' + +export interface ValidationResult { + valid: boolean + errors: string[] + warnings: string[] +} + +export class RouteValidator { + // 用于记录已经提示过的路由,避免重复提示 + private warnedRoutes = new Set() + + /** + * 验证路由配置 + */ + validate(routes: AppRouteRecord[]): ValidationResult { + const errors: string[] = [] + const warnings: string[] = [] + + // 检测重复路由 + this.checkDuplicates(routes, errors, warnings) + + // 检测组件配置 + this.checkComponents(routes, errors, warnings) + + // 检测嵌套菜单的 /index/index 配置 + this.checkNestedIndexComponent(routes) + + return { + valid: errors.length === 0, + errors, + warnings + } + } + + /** + * 检测重复路由 + */ + private checkDuplicates( + routes: AppRouteRecord[], + errors: string[], + warnings: string[], + parentPath = '' + ): void { + const routeNameMap = new Map() + const componentPathMap = new Map() + + const checkRoutes = (routes: AppRouteRecord[], parentPath = '') => { + routes.forEach((route) => { + const currentPath = route.path || '' + const fullPath = this.resolvePath(parentPath, currentPath) + + // 名称重复检测 + if (route.name) { + const routeName = String(route.name) + if (routeNameMap.has(routeName)) { + warnings.push(`路由名称重复: "${routeName}" (${fullPath})`) + } else { + routeNameMap.set(routeName, fullPath) + } + } + + // 组件路径重复检测 + if (route.component && typeof route.component === 'string') { + const componentPath = route.component + if (componentPath !== RoutesAlias.Layout) { + const componentKey = `${parentPath}:${componentPath}` + if (componentPathMap.has(componentKey)) { + warnings.push(`组件路径重复: "${componentPath}" (${fullPath})`) + } else { + componentPathMap.set(componentKey, fullPath) + } + } + } + + // 递归处理子路由 + if (route.children?.length) { + checkRoutes(route.children, fullPath) + } + }) + } + + checkRoutes(routes, parentPath) + } + + /** + * 检测组件配置 + */ + private checkComponents( + routes: AppRouteRecord[], + errors: string[], + warnings: string[], + parentPath = '' + ): void { + routes.forEach((route) => { + const hasExternalLink = !!route.meta?.link?.trim() + const hasChildren = Array.isArray(route.children) && route.children.length > 0 + const routePath = route.path || '[未定义路径]' + const isIframe = route.meta?.isIframe + + // 如果配置了 component,则无需校验 + if (route.component) { + // 递归检查子路由 + if (route.children?.length) { + const fullPath = this.resolvePath(parentPath, route.path || '') + this.checkComponents(route.children, errors, warnings, fullPath) + } + return + } + + // 一级菜单:必须指定 Layout,除非是外链或 iframe + if (parentPath === '' && !hasExternalLink && !isIframe) { + errors.push(`一级菜单(${routePath}) 缺少 component,必须指向 ${RoutesAlias.Layout}`) + return + } + + // 非一级菜单:如果既不是外链、iframe,也没有子路由,则必须配置 component + if (!hasExternalLink && !isIframe && !hasChildren) { + errors.push(`路由(${routePath}) 缺少 component 配置`) + } + + // 递归检查子路由 + if (route.children?.length) { + const fullPath = this.resolvePath(parentPath, route.path || '') + this.checkComponents(route.children, errors, warnings, fullPath) + } + }) + } + + /** + * 检测嵌套菜单的 Layout 组件配置 + * 只有一级菜单才能使用 Layout,二级及以下菜单不能使用 + */ + private checkNestedIndexComponent(routes: AppRouteRecord[], level = 1): void { + routes.forEach((route) => { + // 检查二级及以下菜单是否错误使用了 Layout + if (level > 1 && route.component === RoutesAlias.Layout) { + this.logLayoutError(route, level) + } + + // 递归检查子路由 + if (route.children?.length) { + this.checkNestedIndexComponent(route.children, level + 1) + } + }) + } + + /** + * 输出 Layout 组件配置错误日志 + */ + private logLayoutError(route: AppRouteRecord, level: number): void { + const routeName = String(route.name || route.path || '未知路由') + const routeKey = `${routeName}_${route.path}` + + // 避免重复提示 + if (this.warnedRoutes.has(routeKey)) return + this.warnedRoutes.add(routeKey) + + const menuTitle = route.meta?.title || routeName + const routePath = route.path || '/' + + console.error( + `[路由配置错误] 菜单 "${menuTitle}" (name: ${routeName}, path: ${routePath}) 配置错误\n` + + ` 问题: ${level}级菜单不能使用 ${RoutesAlias.Layout} 作为 component\n` + + ` 说明: 只有一级菜单才能使用 ${RoutesAlias.Layout},二级及以下菜单应该指向具体的组件路径\n` + + ` 当前配置: component: '${RoutesAlias.Layout}'\n` + + ` 应该改为: component: '/your/component/path' 或留空 ''(如果是目录菜单)` + ) + } + + /** + * 路径解析 + */ + private resolvePath(parent: string, child: string): string { + return [parent.replace(/\/$/, ''), child.replace(/^\//, '')].filter(Boolean).join('/') + } +} diff --git a/web/apps/admin/src/router/core/index.ts b/web/apps/admin/src/router/core/index.ts new file mode 100644 index 0000000..fcfecfc --- /dev/null +++ b/web/apps/admin/src/router/core/index.ts @@ -0,0 +1,14 @@ +/** + * 路由核心模块导出 + * + * @module router/core + * @author Art Design Pro Team + */ + +export { RouteRegistry } from './RouteRegistry' +export { ComponentLoader } from './ComponentLoader' +export { RouteValidator } from './RouteValidator' +export { RouteTransformer } from './RouteTransformer' +export { IframeRouteManager } from './IframeRouteManager' +export { MenuProcessor } from './MenuProcessor' +export { RoutePermissionValidator } from './RoutePermissionValidator' diff --git a/web/apps/admin/src/router/guards/afterEach.ts b/web/apps/admin/src/router/guards/afterEach.ts new file mode 100644 index 0000000..d60572d --- /dev/null +++ b/web/apps/admin/src/router/guards/afterEach.ts @@ -0,0 +1,34 @@ +import { nextTick } from 'vue' +import { useSettingStore } from '@/store/modules/setting' +import { Router } from 'vue-router' +import NProgress from 'nprogress' +import { useCommon } from '@/hooks/core/useCommon' +import { loadingService } from '@/utils/ui' +import { getPendingLoading, resetPendingLoading } from './beforeEach' + +/** 路由全局后置守卫 */ +export function setupAfterEachGuard(router: Router) { + const { scrollToTop } = useCommon() + + router.afterEach(() => { + scrollToTop() + + // 关闭进度条 + const settingStore = useSettingStore() + if (settingStore.showNprogress) { + NProgress.done() + // 确保进度条完全移除,避免残影 + setTimeout(() => { + NProgress.remove() + }, 600) + } + + // 关闭 loading 效果 + if (getPendingLoading()) { + nextTick(() => { + loadingService.hideLoading() + resetPendingLoading() + }) + } + }) +} diff --git a/web/apps/admin/src/router/guards/beforeEach.ts b/web/apps/admin/src/router/guards/beforeEach.ts new file mode 100644 index 0000000..bc97e09 --- /dev/null +++ b/web/apps/admin/src/router/guards/beforeEach.ts @@ -0,0 +1,418 @@ +/** + * 路由全局前置守卫模块 + * + * 提供完整的路由导航守卫功能 + * + * ## 主要功能 + * + * - 登录状态验证和重定向 + * - 动态路由注册和权限控制 + * - 菜单数据获取和处理(前端/后端模式) + * - 用户信息获取和缓存 + * - 页面标题设置 + * - 工作标签页管理 + * - 进度条和加载动画控制 + * - 静态路由识别和处理 + * - 错误处理和异常跳转 + * + * ## 使用场景 + * + * - 路由跳转前的权限验证 + * - 动态菜单加载和路由注册 + * - 用户登录状态管理 + * - 页面访问控制 + * - 路由级别的加载状态管理 + * + * ## 工作流程 + * + * 1. 检查登录状态,未登录跳转到登录页 + * 2. 首次访问时获取用户信息和菜单数据 + * 3. 根据权限动态注册路由 + * 4. 设置页面标题和工作标签页 + * 5. 处理根路径重定向到首页 + * 6. 未匹配路由跳转到 404 页面 + * + * @module router/guards/beforeEach + * @author Art Design Pro Team + */ +import type { Router, RouteLocationNormalized, NavigationGuardNext } from 'vue-router' +import { nextTick } from 'vue' +import NProgress from 'nprogress' +import { useSettingStore } from '@/store/modules/setting' +import { useUserStore } from '@/store/modules/user' +import { useMenuStore } from '@/store/modules/menu' +import { setWorktab } from '@/utils/navigation' +import { setPageTitle } from '@/utils/router' +import { RoutesAlias } from '../routesAlias' +import { staticRoutes } from '../routes/staticRoutes' +import { loadingService } from '@/utils/ui' +import { useCommon } from '@/hooks/core/useCommon' +import { useWorktabStore } from '@/store/modules/worktab' +import { fetchGetUserInfo } from '@/api/auth' +import { ApiStatus } from '@/utils/http/status' +import { isHttpError } from '@/utils/http/error' +import { RouteRegistry, MenuProcessor, IframeRouteManager, RoutePermissionValidator } from '../core' + +// 路由注册器实例 +let routeRegistry: RouteRegistry | null = null + +// 菜单处理器实例 +const menuProcessor = new MenuProcessor() + +// 跟踪是否需要关闭 loading +let pendingLoading = false + +// 路由初始化失败标记,防止死循环 +// 一旦设置为 true,只有刷新页面或重新登录才能重置 +let routeInitFailed = false + +// 路由初始化进行中标记,防止并发请求 +let routeInitInProgress = false + +/** + * 获取 pendingLoading 状态 + */ +export function getPendingLoading(): boolean { + return pendingLoading +} + +/** + * 重置 pendingLoading 状态 + */ +export function resetPendingLoading(): void { + pendingLoading = false +} + +/** + * 获取路由初始化失败状态 + */ +export function getRouteInitFailed(): boolean { + return routeInitFailed +} + +/** + * 重置路由初始化状态(用于重新登录场景) + */ +export function resetRouteInitState(): void { + routeInitFailed = false + routeInitInProgress = false +} + +/** + * 设置路由全局前置守卫 + */ +export function setupBeforeEachGuard(router: Router): void { + // 初始化路由注册器 + routeRegistry = new RouteRegistry(router) + + router.beforeEach( + async ( + to: RouteLocationNormalized, + from: RouteLocationNormalized, + next: NavigationGuardNext + ) => { + try { + await handleRouteGuard(to, from, next, router) + } catch (error) { + console.error('[RouteGuard] 路由守卫处理失败:', error) + closeLoading() + next({ name: 'Exception500' }) + } + } + ) +} + +/** + * 关闭 loading 效果 + */ +function closeLoading(): void { + if (pendingLoading) { + nextTick(() => { + loadingService.hideLoading() + pendingLoading = false + }) + } +} + +/** + * 处理路由守卫逻辑 + */ +async function handleRouteGuard( + to: RouteLocationNormalized, + from: RouteLocationNormalized, + next: NavigationGuardNext, + router: Router +): Promise { + const settingStore = useSettingStore() + const userStore = useUserStore() + + // 启动进度条 + if (settingStore.showNprogress) { + NProgress.start() + } + + // 1. 检查登录状态 + if (!handleLoginStatus(to, userStore, next)) { + return + } + + // 2. 检查路由初始化是否已失败(防止死循环) + if (routeInitFailed) { + // 已经失败过,直接放行到错误页面,不再重试 + if (to.matched.length > 0) { + next() + } else { + // 未匹配到路由,跳转到 500 页面 + next({ name: 'Exception500', replace: true }) + } + return + } + + // 3. 处理动态路由注册 + if (!routeRegistry?.isRegistered() && userStore.isLogin) { + // 防止并发请求(快速连续导航场景) + if (routeInitInProgress) { + // 正在初始化中,等待完成后重新导航 + next(false) + return + } + await handleDynamicRoutes(to, next, router) + return + } + + // 4. 处理根路径重定向 + if (handleRootPathRedirect(to, next)) { + return + } + + // 5. 处理已匹配的路由 + if (to.matched.length > 0) { + setWorktab(to) + setPageTitle(to) + next() + return + } + + // 6. 未匹配到路由,跳转到 404 + next({ name: 'Exception404' }) +} + +/** + * 处理登录状态 + * @returns true 表示可以继续,false 表示已处理跳转 + */ +function handleLoginStatus( + to: RouteLocationNormalized, + userStore: ReturnType, + next: NavigationGuardNext +): boolean { + // 已登录或访问登录页或静态路由,直接放行 + if (userStore.isLogin || to.path === RoutesAlias.Login || isStaticRoute(to.path)) { + return true + } + + // 未登录且访问需要权限的页面,跳转到登录页并携带 redirect 参数 + userStore.logOut() + next({ + name: 'Login', + query: { redirect: to.fullPath } + }) + return false +} + +/** + * 检查路由是否为静态路由 + */ +function isStaticRoute(path: string): boolean { + const checkRoute = (routes: any[], targetPath: string): boolean => { + return routes.some((route) => { + // 404 catch-all 路由不应视为可匿名访问的静态页, + // 否则未登录时手动输入任意地址会直接落到 404,无法跳转登录页。 + if (route.name === 'Exception404') { + return false + } + + // 处理动态路由参数匹配 + const routePath = route.path + const pattern = routePath.replace(/:[^/]+/g, '[^/]+').replace(/\*/g, '.*') + const regex = new RegExp(`^${pattern}$`) + + if (regex.test(targetPath)) { + return true + } + if (route.children && route.children.length > 0) { + return checkRoute(route.children, targetPath) + } + return false + }) + } + + return checkRoute(staticRoutes, path) +} + +/** + * 处理动态路由注册 + */ +async function handleDynamicRoutes( + to: RouteLocationNormalized, + next: NavigationGuardNext, + router: Router +): Promise { + // 标记初始化进行中 + routeInitInProgress = true + + // 显示 loading + pendingLoading = true + loadingService.showLoading() + + try { + // 1. 获取用户信息 + await fetchUserInfo() + + // 2. 获取菜单数据 + const menuList = await menuProcessor.getMenuList() + + // 3. 验证菜单数据 + if (!menuProcessor.validateMenuList(menuList)) { + throw new Error('获取菜单列表失败,请重新登录') + } + + // 4. 注册动态路由 + routeRegistry?.register(menuList) + + // 5. 保存菜单数据到 store + const menuStore = useMenuStore() + menuStore.setMenuList(menuList) + menuStore.addRemoveRouteFns(routeRegistry?.getRemoveRouteFns() || []) + + // 6. 保存 iframe 路由 + IframeRouteManager.getInstance().save() + + // 7. 验证工作标签页 + useWorktabStore().validateWorktabs(router) + + // 8. 静态路由不依赖菜单权限,初始化后直接恢复目标地址。 + if (isStaticRoute(to.path)) { + routeInitInProgress = false + next({ + path: to.path, + query: to.query, + hash: to.hash, + replace: true + }) + return + } + + // 8. 验证目标路径权限 + const { homePath } = useCommon() + const { path: validatedPath, hasPermission } = RoutePermissionValidator.validatePath( + to.path, + menuList, + homePath.value || '/' + ) + + // 初始化成功,重置进行中标记 + routeInitInProgress = false + + // 9. 重新导航到目标路由 + if (!hasPermission) { + // 无权限访问,跳转到首页 + closeLoading() + + // 输出警告信息 + console.warn(`[RouteGuard] 用户无权限访问路径: ${to.path},已跳转到首页`) + + // 直接跳转到首页 + next({ + path: validatedPath, + replace: true + }) + } else { + // 有权限,正常导航 + next({ + path: to.path, + query: to.query, + hash: to.hash, + replace: true + }) + } + } catch (error) { + console.error('[RouteGuard] 动态路由注册失败:', error) + + // 关闭 loading + closeLoading() + + // 401 错误:axios 拦截器已处理退出登录,取消当前导航 + if (isUnauthorizedError(error)) { + // 重置状态,允许重新登录后再次初始化 + routeInitInProgress = false + next(false) + return + } + + // 标记初始化失败,防止死循环 + routeInitFailed = true + routeInitInProgress = false + + // 输出详细错误信息,便于排查 + if (isHttpError(error)) { + console.error(`[RouteGuard] 错误码: ${error.code}, 消息: ${error.message}`) + } + + // 跳转到 500 页面,使用 replace 避免产生历史记录 + next({ name: 'Exception500', replace: true }) + } +} + +/** + * 获取用户信息 + */ +async function fetchUserInfo(): Promise { + const userStore = useUserStore() + const data = await fetchGetUserInfo() + userStore.setUserInfo(data) + // 检查并清理工作台标签页(如果是不同用户登录) + userStore.checkAndClearWorktabs() +} + +/** + * 重置路由相关状态 + */ +export function resetRouterState(delay: number): void { + setTimeout(() => { + routeRegistry?.unregister() + IframeRouteManager.getInstance().clear() + + const menuStore = useMenuStore() + menuStore.removeAllDynamicRoutes() + menuStore.setMenuList([]) + + // 重置路由初始化状态,允许重新登录后再次初始化 + resetRouteInitState() + }, delay) +} + +/** + * 处理根路径重定向到首页 + * @returns true 表示已处理跳转,false 表示无需跳转 + */ +function handleRootPathRedirect(to: RouteLocationNormalized, next: NavigationGuardNext): boolean { + if (to.path !== '/') { + return false + } + + const { homePath } = useCommon() + if (homePath.value && homePath.value !== '/') { + next({ path: homePath.value, replace: true }) + return true + } + + return false +} + +/** + * 判断是否为未授权错误(401) + */ +function isUnauthorizedError(error: unknown): boolean { + return isHttpError(error) && error.code === ApiStatus.unauthorized +} diff --git a/web/apps/admin/src/router/index.ts b/web/apps/admin/src/router/index.ts new file mode 100755 index 0000000..286ae58 --- /dev/null +++ b/web/apps/admin/src/router/index.ts @@ -0,0 +1,23 @@ +import type { App } from 'vue' +import { createRouter, createWebHashHistory } from 'vue-router' +import { staticRoutes } from './routes/staticRoutes' +import { configureNProgress } from '@/utils/router' +import { setupBeforeEachGuard } from './guards/beforeEach' +import { setupAfterEachGuard } from './guards/afterEach' + +// 创建路由实例 +export const router = createRouter({ + history: createWebHashHistory(), + routes: staticRoutes // 静态路由 +}) + +// 初始化路由 +export function initRouter(app: App): void { + configureNProgress() // 顶部进度条 + setupBeforeEachGuard(router) // 路由前置守卫 + setupAfterEachGuard(router) // 路由后置守卫 + app.use(router) +} + +// 主页路径,默认使用菜单第一个有效路径,配置后使用此路径 +export const HOME_PAGE_PATH = '' diff --git a/web/apps/admin/src/router/modules/dashboard.ts b/web/apps/admin/src/router/modules/dashboard.ts new file mode 100644 index 0000000..5f9c3e9 --- /dev/null +++ b/web/apps/admin/src/router/modules/dashboard.ts @@ -0,0 +1,24 @@ +import { AppRouteRecord } from '@/types/router' + +export const dashboardRoutes: AppRouteRecord = { + name: 'Dashboard', + path: '/dashboard', + component: '/index/index', + meta: { + title: 'menus.dashboard.title', + icon: 'ri:pie-chart-line', + roles: ['R_SUPER', 'R_ADMIN'] + }, + children: [ + { + path: 'console', + name: 'Console', + component: '/dashboard/console', + meta: { + title: 'menus.dashboard.console', + keepAlive: false, + fixedTab: true + } + } + ] +} diff --git a/web/apps/admin/src/router/modules/exception.ts b/web/apps/admin/src/router/modules/exception.ts new file mode 100644 index 0000000..07c5604 --- /dev/null +++ b/web/apps/admin/src/router/modules/exception.ts @@ -0,0 +1,46 @@ +import { AppRouteRecord } from '@/types/router' + +export const exceptionRoutes: AppRouteRecord = { + path: '/exception', + name: 'Exception', + component: '/index/index', + meta: { + title: 'menus.exception.title', + icon: 'ri:error-warning-line' + }, + children: [ + { + path: '403', + name: 'Exception403', + component: '/exception/403', + meta: { + title: 'menus.exception.forbidden', + keepAlive: true, + isHideTab: true, + isFullPage: true + } + }, + { + path: '404', + name: 'Exception404', + component: '/exception/404', + meta: { + title: 'menus.exception.notFound', + keepAlive: true, + isHideTab: true, + isFullPage: true + } + }, + { + path: '500', + name: 'Exception500', + component: '/exception/500', + meta: { + title: 'menus.exception.serverError', + keepAlive: true, + isHideTab: true, + isFullPage: true + } + } + ] +} diff --git a/web/apps/admin/src/router/modules/gateway.ts b/web/apps/admin/src/router/modules/gateway.ts new file mode 100644 index 0000000..ea81fae --- /dev/null +++ b/web/apps/admin/src/router/modules/gateway.ts @@ -0,0 +1,64 @@ +import { AppRouteRecord } from '@/types/router' + +export const gatewayRoutes: AppRouteRecord = { + name: 'Gateway', + path: '/gateway', + component: '/index/index', + meta: { + title: '网关管理', + icon: 'ri:router-line', + roles: ['R_SUPER'] + }, + children: [ + { + path: 'providers', + name: 'Providers', + component: '/gateway/providers', + meta: { + title: '模型供应商', + keepAlive: true, + roles: ['R_SUPER'] + } + }, + { + path: 'api-keys', + name: 'APIKeys', + component: '/gateway/api-keys', + meta: { + title: 'API Key', + keepAlive: true, + roles: ['R_SUPER'] + } + }, + { + path: 'model-routes', + name: 'ModelRoutes', + component: '/gateway/model-routes', + meta: { + title: '模型路由', + keepAlive: true, + roles: ['R_SUPER'] + } + }, + { + path: 'audit-usage', + name: 'AuditUsage', + component: '/gateway/audit-usage', + meta: { + title: '审计与用量', + keepAlive: true, + roles: ['R_SUPER'] + } + }, + { + path: 'outbox', + name: 'Outbox', + component: '/gateway/outbox', + meta: { + title: '事件投递', + keepAlive: true, + roles: ['R_SUPER'] + } + } + ] +} diff --git a/web/apps/admin/src/router/modules/index.ts b/web/apps/admin/src/router/modules/index.ts new file mode 100644 index 0000000..1f31eeb --- /dev/null +++ b/web/apps/admin/src/router/modules/index.ts @@ -0,0 +1,17 @@ +import { AppRouteRecord } from '@/types/router' +import { dashboardRoutes } from './dashboard' +import { systemRoutes } from './system' +import { resultRoutes } from './result' +import { exceptionRoutes } from './exception' +import { gatewayRoutes } from './gateway' + +/** + * 导出所有模块化路由 + */ +export const routeModules: AppRouteRecord[] = [ + dashboardRoutes, + gatewayRoutes, + systemRoutes, + resultRoutes, + exceptionRoutes +] diff --git a/web/apps/admin/src/router/modules/result.ts b/web/apps/admin/src/router/modules/result.ts new file mode 100644 index 0000000..575a2f7 --- /dev/null +++ b/web/apps/admin/src/router/modules/result.ts @@ -0,0 +1,33 @@ +import { AppRouteRecord } from '@/types/router' + +export const resultRoutes: AppRouteRecord = { + path: '/result', + name: 'Result', + component: '/index/index', + meta: { + title: 'menus.result.title', + icon: 'ri:checkbox-circle-line' + }, + children: [ + { + path: 'success', + name: 'ResultSuccess', + component: '/result/success', + meta: { + title: 'menus.result.success', + icon: 'ri:checkbox-circle-line', + keepAlive: true + } + }, + { + path: 'fail', + name: 'ResultFail', + component: '/result/fail', + meta: { + title: 'menus.result.fail', + icon: 'ri:close-circle-line', + keepAlive: true + } + } + ] +} diff --git a/web/apps/admin/src/router/modules/system.ts b/web/apps/admin/src/router/modules/system.ts new file mode 100644 index 0000000..16df585 --- /dev/null +++ b/web/apps/admin/src/router/modules/system.ts @@ -0,0 +1,60 @@ +import { AppRouteRecord } from '@/types/router' + +export const systemRoutes: AppRouteRecord = { + path: '/system', + name: 'System', + component: '/index/index', + meta: { + title: 'menus.system.title', + icon: 'ri:user-3-line', + roles: ['R_SUPER', 'R_ADMIN'] + }, + children: [ + { + path: 'user', + name: 'User', + component: '/system/user', + meta: { + title: 'menus.system.user', + keepAlive: true, + roles: ['R_SUPER', 'R_ADMIN'] + } + }, + { + path: 'role', + name: 'Role', + component: '/system/role', + meta: { + title: 'menus.system.role', + keepAlive: true, + roles: ['R_SUPER'] + } + }, + { + path: 'user-center', + name: 'UserCenter', + component: '/system/user-center', + meta: { + title: 'menus.system.userCenter', + isHide: true, + keepAlive: true, + isHideTab: true + } + }, + { + path: 'menu', + name: 'Menus', + component: '/system/menu', + meta: { + title: 'menus.system.menu', + keepAlive: true, + roles: ['R_SUPER'], + authList: [ + { title: '新增', authMark: 'add' }, + { title: '编辑', authMark: 'edit' }, + { title: '删除', authMark: 'delete' } + ] + } + } + ] +} diff --git a/web/apps/admin/src/router/routes/asyncRoutes.ts b/web/apps/admin/src/router/routes/asyncRoutes.ts new file mode 100644 index 0000000..ccf1201 --- /dev/null +++ b/web/apps/admin/src/router/routes/asyncRoutes.ts @@ -0,0 +1,9 @@ +// 权限文档:https://www.artd.pro/docs/zh/guide/in-depth/permission.html +import { AppRouteRecord } from '@/types/router' +import { routeModules } from '../modules' + +/** + * 动态路由(需要权限才能访问的路由) + * 用于渲染菜单以及根据菜单权限动态加载路由,如果没有权限无法访问 + */ +export const asyncRoutes: AppRouteRecord[] = routeModules diff --git a/web/apps/admin/src/router/routes/staticRoutes.ts b/web/apps/admin/src/router/routes/staticRoutes.ts new file mode 100644 index 0000000..334d0c2 --- /dev/null +++ b/web/apps/admin/src/router/routes/staticRoutes.ts @@ -0,0 +1,72 @@ +import { AppRouteRecordRaw } from '@/utils/router' + +/** + * 静态路由配置(不需要权限就能访问的路由) + * + * 属性说明: + * isHideTab: true 表示不在标签页中显示 + * + * 注意事项: + * 1、path、name 不要和动态路由冲突,否则会导致路由冲突无法访问 + * 2、静态路由不管是否登录都可以访问 + */ +export const staticRoutes: AppRouteRecordRaw[] = [ + // 不需要登录就能访问的路由示例 + // { + // path: '/welcome', + // name: 'WelcomeStatic', + // component: () => import('@views/dashboard/console/index.vue'), + // meta: { title: 'menus.dashboard.title' } + // }, + { + path: '/auth/login', + name: 'Login', + component: () => import('@views/auth/login/index.vue'), + meta: { title: 'menus.login.title', isHideTab: true } + }, + { + path: '/auth/register', + name: 'Register', + component: () => import('@views/auth/register/index.vue'), + meta: { title: 'menus.register.title', isHideTab: true } + }, + { + path: '/auth/forget-password', + name: 'ForgetPassword', + component: () => import('@views/auth/forget-password/index.vue'), + meta: { title: 'menus.forgetPassword.title', isHideTab: true } + }, + { + path: '/403', + name: 'Exception403', + component: () => import('@views/exception/403/index.vue'), + meta: { title: '403', isHideTab: true } + }, + { + path: '/:pathMatch(.*)*', + name: 'Exception404', + component: () => import('@views/exception/404/index.vue'), + meta: { title: '404', isHideTab: true } + }, + { + path: '/500', + name: 'Exception500', + component: () => import('@views/exception/500/index.vue'), + meta: { title: '500', isHideTab: true } + }, + { + path: '/outside', + component: () => import('@views/index/index.vue'), + name: 'Outside', + meta: { title: 'menus.outside.title' }, + children: [ + // iframe 内嵌页面 + { + path: '/outside/iframe/:path', + name: 'Iframe', + component: () => import('@/views/outside/Iframe.vue'), + meta: { title: 'iframe' } + } + ] + } +] diff --git a/web/apps/admin/src/router/routesAlias.ts b/web/apps/admin/src/router/routesAlias.ts new file mode 100644 index 0000000..2af1c68 --- /dev/null +++ b/web/apps/admin/src/router/routesAlias.ts @@ -0,0 +1,8 @@ +/** + * 公共路由别名 + # 存放系统级公共路由路径,如布局容器、登录页等 + */ +export enum RoutesAlias { + Layout = '/index/index', // 布局容器 + Login = '/auth/login' // 登录页 +} diff --git a/web/apps/admin/src/store/index.ts b/web/apps/admin/src/store/index.ts new file mode 100755 index 0000000..b485999 --- /dev/null +++ b/web/apps/admin/src/store/index.ts @@ -0,0 +1,52 @@ +/** + * Pinia Store 配置模块 + * + * 提供全局状态管理的初始化和配置 + * + * ## 主要功能 + * + * - Pinia Store 实例创建 + * - 持久化插件配置(pinia-plugin-persistedstate) + * - 版本化存储键管理 + * - 自动数据迁移(跨版本) + * - LocalStorage 序列化配置 + * - Store 初始化函数 + * + * ## 持久化策略 + * + * - 使用 StorageKeyManager 生成版本化的存储键 + * - 格式:sys-v{version}-{storeId} + * - 自动迁移旧版本数据到当前版本 + * - 使用 localStorage 作为存储介质 + * + * @module store/index + * @author Art Design Pro Team + */ +import type { App } from 'vue' +import { createPinia } from 'pinia' +import { createPersistedState } from 'pinia-plugin-persistedstate' +import { StorageKeyManager } from '@/utils/storage/storage-key-manager' + +export const store = createPinia() + +// 创建存储键管理器实例 +const storageKeyManager = new StorageKeyManager() + +// 配置持久化插件 +store.use( + createPersistedState({ + key: (storeId: string) => storageKeyManager.getStorageKey(storeId), + storage: localStorage, + serializer: { + serialize: JSON.stringify, + deserialize: JSON.parse + } + }) +) + +/** + * 初始化 Store + */ +export function initStore(app: App): void { + app.use(store) +} diff --git a/web/apps/admin/src/store/modules/menu.ts b/web/apps/admin/src/store/modules/menu.ts new file mode 100755 index 0000000..85d13da --- /dev/null +++ b/web/apps/admin/src/store/modules/menu.ts @@ -0,0 +1,109 @@ +/** + * 菜单状态管理模块 + * + * 提供菜单数据和动态路由的状态管理 + * + * ## 主要功能 + * + * - 菜单列表存储和管理 + * - 首页路径配置 + * - 动态路由注册和移除 + * - 路由移除函数管理 + * - 菜单宽度配置 + * + * ## 使用场景 + * + * - 动态菜单加载和渲染 + * - 路由权限控制 + * - 首页路径动态设置 + * - 登出时清理动态路由 + * + * ## 工作流程 + * + * 1. 获取菜单数据(前端/后端模式) + * 2. 设置菜单列表和首页路径 + * 3. 注册动态路由并保存移除函数 + * 4. 登出时调用移除函数清理路由 + * + * @module store/modules/menu + * @author Art Design Pro Team + */ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { AppRouteRecord } from '@/types/router' +import { getFirstMenuPath } from '@/utils' +import { HOME_PAGE_PATH } from '@/router' + +/** + * 菜单状态管理 + * 管理应用的菜单列表、首页路径、菜单宽度和动态路由移除函数 + */ +export const useMenuStore = defineStore('menuStore', () => { + /** 首页路径 */ + const homePath = ref(HOME_PAGE_PATH) + /** 菜单列表 */ + const menuList = ref([]) + /** 菜单宽度 */ + const menuWidth = ref('') + /** 存储路由移除函数的数组 */ + const removeRouteFns = ref<(() => void)[]>([]) + + /** + * 设置菜单列表 + * @param list 菜单路由记录数组 + */ + const setMenuList = (list: AppRouteRecord[]) => { + menuList.value = list + setHomePath(HOME_PAGE_PATH || getFirstMenuPath(list)) + } + + /** + * 获取首页路径 + * @returns 首页路径字符串 + */ + const getHomePath = () => homePath.value + + /** + * 设置主页路径 + * @param path 主页路径 + */ + const setHomePath = (path: string) => { + homePath.value = path + } + + /** + * 添加路由移除函数 + * @param fns 要添加的路由移除函数数组 + */ + const addRemoveRouteFns = (fns: (() => void)[]) => { + removeRouteFns.value.push(...fns) + } + + /** + * 移除所有动态路由 + * 执行所有存储的路由移除函数并清空数组 + */ + const removeAllDynamicRoutes = () => { + removeRouteFns.value.forEach((fn) => fn()) + removeRouteFns.value = [] + } + + /** + * 清空路由移除函数数组 + */ + const clearRemoveRouteFns = () => { + removeRouteFns.value = [] + } + + return { + menuList, + menuWidth, + removeRouteFns, + setMenuList, + getHomePath, + setHomePath, + addRemoveRouteFns, + removeAllDynamicRoutes, + clearRemoveRouteFns + } +}) diff --git a/web/apps/admin/src/store/modules/setting.ts b/web/apps/admin/src/store/modules/setting.ts new file mode 100755 index 0000000..2878259 --- /dev/null +++ b/web/apps/admin/src/store/modules/setting.ts @@ -0,0 +1,450 @@ +/** + * 系统设置状态管理模块 + * + * 提供完整的系统设置状态管理 + * + * ## 主要功能 + * + * - 菜单布局配置(左侧、顶部、混合、双栏) + * - 主题管理(亮色、暗色、自动) + * - 菜单主题样式配置 + * - 界面显示开关(面包屑、标签页、语言切换等) + * - 功能开关(手风琴模式、色弱模式、水印等) + * - 样式配置(边框、圆角、容器宽度、页面过渡) + * - 节日功能配置 + * - Element Plus 主题色动态设置 + * + * ## 使用场景 + * + * - 设置面板配置管理 + * - 主题切换和样式定制 + * - 界面功能开关控制 + * - 用户偏好设置持久化 + * + * ## 持久化 + * + * - 使用 localStorage 存储 + * - 存储键:sys-v{version}-setting + * - 支持跨版本数据迁移 + * + * @module store/modules/setting + * @author Art Design Pro Team + */ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { MenuThemeType } from '@/types/store' +import AppConfig from '@/config' +import { SystemThemeEnum, MenuThemeEnum, MenuTypeEnum, ContainerWidthEnum } from '@/enums/appEnum' +import { setElementThemeColor } from '@/utils/ui' +import { useCeremony } from '@/hooks/core/useCeremony' +import { StorageConfig } from '@/utils' +import { SETTING_DEFAULT_CONFIG } from '@/config/setting' + +/** + * 系统设置状态管理 + * 管理应用的菜单、主题、界面显示等各项设置 + */ +export const useSettingStore = defineStore( + 'settingStore', + () => { + // 菜单相关设置 + /** 菜单类型 */ + const menuType = ref(SETTING_DEFAULT_CONFIG.menuType) + /** 菜单展开宽度 */ + const menuOpenWidth = ref(SETTING_DEFAULT_CONFIG.menuOpenWidth) + /** 菜单是否展开 */ + const menuOpen = ref(SETTING_DEFAULT_CONFIG.menuOpen) + /** 双菜单是否显示文本 */ + const dualMenuShowText = ref(SETTING_DEFAULT_CONFIG.dualMenuShowText) + + // 主题相关设置 + /** 系统主题类型 */ + const systemThemeType = ref(SETTING_DEFAULT_CONFIG.systemThemeType) + /** 系统主题模式 */ + const systemThemeMode = ref(SETTING_DEFAULT_CONFIG.systemThemeMode) + /** 菜单主题类型 */ + const menuThemeType = ref(SETTING_DEFAULT_CONFIG.menuThemeType) + /** 系统主题颜色 */ + const systemThemeColor = ref(SETTING_DEFAULT_CONFIG.systemThemeColor) + + // 界面显示设置 + /** 是否显示菜单按钮 */ + const showMenuButton = ref(SETTING_DEFAULT_CONFIG.showMenuButton) + /** 是否显示快速入口 */ + const showFastEnter = ref(SETTING_DEFAULT_CONFIG.showFastEnter) + /** 是否显示刷新按钮 */ + const showRefreshButton = ref(SETTING_DEFAULT_CONFIG.showRefreshButton) + /** 是否显示面包屑 */ + const showCrumbs = ref(SETTING_DEFAULT_CONFIG.showCrumbs) + /** 是否显示工作台标签 */ + const showWorkTab = ref(SETTING_DEFAULT_CONFIG.showWorkTab) + /** 是否显示语言切换 */ + const showLanguage = ref(SETTING_DEFAULT_CONFIG.showLanguage) + /** 是否显示进度条 */ + const showNprogress = ref(SETTING_DEFAULT_CONFIG.showNprogress) + /** 是否显示设置引导 */ + const showSettingGuide = ref(SETTING_DEFAULT_CONFIG.showSettingGuide) + /** 是否显示节日文本 */ + const showFestivalText = ref(SETTING_DEFAULT_CONFIG.showFestivalText) + /** 是否显示水印 */ + const watermarkVisible = ref(SETTING_DEFAULT_CONFIG.watermarkVisible) + + // 功能设置 + /** 是否自动关闭 */ + const autoClose = ref(SETTING_DEFAULT_CONFIG.autoClose) + /** 是否唯一展开 */ + const uniqueOpened = ref(SETTING_DEFAULT_CONFIG.uniqueOpened) + /** 是否色弱模式 */ + const colorWeak = ref(SETTING_DEFAULT_CONFIG.colorWeak) + /** 是否刷新 */ + const refresh = ref(SETTING_DEFAULT_CONFIG.refresh) + /** 是否加载节日烟花 */ + const holidayFireworksLoaded = ref(SETTING_DEFAULT_CONFIG.holidayFireworksLoaded) + + // 样式设置 + /** 边框模式 */ + const boxBorderMode = ref(SETTING_DEFAULT_CONFIG.boxBorderMode) + /** 页面过渡效果 */ + const pageTransition = ref(SETTING_DEFAULT_CONFIG.pageTransition) + /** 标签页样式 */ + const tabStyle = ref(SETTING_DEFAULT_CONFIG.tabStyle) + /** 自定义圆角 */ + const customRadius = ref(SETTING_DEFAULT_CONFIG.customRadius) + /** 容器宽度 */ + const containerWidth = ref(SETTING_DEFAULT_CONFIG.containerWidth) + + // 节日相关 + /** 节日日期 */ + const festivalDate = ref('') + + /** + * 获取菜单主题 + * 根据当前主题类型和暗色模式返回对应的主题配置 + */ + const getMenuTheme = computed((): MenuThemeType => { + const list = AppConfig.themeList.filter((item) => item.theme === menuThemeType.value) + if (isDark.value) { + return AppConfig.darkMenuStyles[0] + } else { + return list[0] + } + }) + + /** + * 判断是否为暗色模式 + */ + const isDark = computed((): boolean => { + return systemThemeType.value === SystemThemeEnum.DARK + }) + + /** + * 获取菜单展开宽度 + */ + const getMenuOpenWidth = computed((): string => { + return menuOpenWidth.value + 'px' || SETTING_DEFAULT_CONFIG.menuOpenWidth + 'px' + }) + + /** + * 获取自定义圆角 + */ + const getCustomRadius = computed((): string => { + return customRadius.value + 'rem' || SETTING_DEFAULT_CONFIG.customRadius + 'rem' + }) + + /** + * 是否显示烟花 + * 根据当前日期和节日日期判断是否显示烟花效果 + */ + const isShowFireworks = computed((): boolean => { + return festivalDate.value === useCeremony().currentFestivalData.value?.date ? false : true + }) + + /** + * 切换菜单布局 + * @param type 菜单类型 + */ + const switchMenuLayouts = (type: MenuTypeEnum) => { + menuType.value = type + } + + /** + * 设置菜单展开宽度 + * @param width 宽度值 + */ + const setMenuOpenWidth = (width: number) => { + menuOpenWidth.value = width + } + + /** + * 设置全局主题 + * @param theme 主题类型 + * @param themeMode 主题模式 + */ + const setGlopTheme = (theme: SystemThemeEnum, themeMode: SystemThemeEnum) => { + systemThemeType.value = theme + systemThemeMode.value = themeMode + localStorage.setItem(StorageConfig.THEME_KEY, theme) + } + + /** + * 切换菜单样式 + * @param theme 菜单主题 + */ + const switchMenuStyles = (theme: MenuThemeEnum) => { + menuThemeType.value = theme + } + + /** + * 设置Element Plus主题颜色 + * @param theme 主题颜色 + */ + const setElementTheme = (theme: string) => { + systemThemeColor.value = theme + setElementThemeColor(theme) + } + + /** + * 切换边框模式 + */ + const setBorderMode = () => { + boxBorderMode.value = !boxBorderMode.value + } + + /** + * 设置容器宽度 + * @param width 容器宽度枚举值 + */ + const setContainerWidth = (width: ContainerWidthEnum) => { + containerWidth.value = width + } + + /** + * 切换唯一展开模式 + */ + const setUniqueOpened = () => { + uniqueOpened.value = !uniqueOpened.value + } + + /** + * 切换菜单按钮显示 + */ + const setButton = () => { + showMenuButton.value = !showMenuButton.value + } + + /** + * 切换快速入口显示 + */ + const setFastEnter = () => { + showFastEnter.value = !showFastEnter.value + } + + /** + * 切换自动关闭 + */ + const setAutoClose = () => { + autoClose.value = !autoClose.value + } + + /** + * 切换刷新按钮显示 + */ + const setShowRefreshButton = () => { + showRefreshButton.value = !showRefreshButton.value + } + + /** + * 切换面包屑显示 + */ + const setCrumbs = () => { + showCrumbs.value = !showCrumbs.value + } + + /** + * 设置工作台标签显示 + * @param show 是否显示 + */ + const setWorkTab = (show: boolean) => { + showWorkTab.value = show + } + + /** + * 切换语言切换显示 + */ + const setLanguage = () => { + showLanguage.value = !showLanguage.value + } + + /** + * 切换进度条显示 + */ + const setNprogress = () => { + showNprogress.value = !showNprogress.value + } + + /** + * 切换色弱模式 + */ + const setColorWeak = () => { + colorWeak.value = !colorWeak.value + } + + /** + * 隐藏设置引导 + */ + const hideSettingGuide = () => { + showSettingGuide.value = false + } + + /** + * 显示设置引导 + */ + const openSettingGuide = () => { + showSettingGuide.value = true + } + + /** + * 设置页面过渡效果 + * @param transition 过渡效果名称 + */ + const setPageTransition = (transition: string) => { + pageTransition.value = transition + } + + /** + * 设置标签页样式 + * @param style 样式名称 + */ + const setTabStyle = (style: string) => { + tabStyle.value = style + } + + /** + * 设置菜单展开状态 + * @param open 是否展开 + */ + const setMenuOpen = (open: boolean) => { + menuOpen.value = open + } + + /** + * 刷新页面 + */ + const reload = () => { + refresh.value = !refresh.value + } + + /** + * 设置水印显示 + * @param visible 是否显示 + */ + const setWatermarkVisible = (visible: boolean) => { + watermarkVisible.value = visible + } + + /** + * 设置自定义圆角 + * @param radius 圆角值 + */ + const setCustomRadius = (radius: string) => { + customRadius.value = radius + document.documentElement.style.setProperty('--custom-radius', `${radius}rem`) + } + + /** + * 设置节日烟花加载状态 + * @param isLoad 是否已加载 + */ + const setholidayFireworksLoaded = (isLoad: boolean) => { + holidayFireworksLoaded.value = isLoad + } + + /** + * 设置节日文本显示 + * @param show 是否显示 + */ + const setShowFestivalText = (show: boolean) => { + showFestivalText.value = show + } + + const setFestivalDate = (date: string) => { + festivalDate.value = date + } + + const setDualMenuShowText = (show: boolean) => { + dualMenuShowText.value = show + } + + return { + menuType, + menuOpenWidth, + systemThemeType, + systemThemeMode, + menuThemeType, + systemThemeColor, + boxBorderMode, + uniqueOpened, + showMenuButton, + showFastEnter, + showRefreshButton, + showCrumbs, + autoClose, + showWorkTab, + showLanguage, + showNprogress, + colorWeak, + showSettingGuide, + pageTransition, + tabStyle, + menuOpen, + refresh, + watermarkVisible, + customRadius, + holidayFireworksLoaded, + showFestivalText, + festivalDate, + dualMenuShowText, + containerWidth, + getMenuTheme, + isDark, + getMenuOpenWidth, + getCustomRadius, + isShowFireworks, + switchMenuLayouts, + setMenuOpenWidth, + setGlopTheme, + switchMenuStyles, + setElementTheme, + setBorderMode, + setContainerWidth, + setUniqueOpened, + setButton, + setFastEnter, + setAutoClose, + setShowRefreshButton, + setCrumbs, + setWorkTab, + setLanguage, + setNprogress, + setColorWeak, + hideSettingGuide, + openSettingGuide, + setPageTransition, + setTabStyle, + setMenuOpen, + reload, + setWatermarkVisible, + setCustomRadius, + setholidayFireworksLoaded, + setShowFestivalText, + setFestivalDate, + setDualMenuShowText + } + }, + { + persist: { + key: 'setting', + storage: localStorage + } + } +) diff --git a/web/apps/admin/src/store/modules/table.ts b/web/apps/admin/src/store/modules/table.ts new file mode 100644 index 0000000..094c310 --- /dev/null +++ b/web/apps/admin/src/store/modules/table.ts @@ -0,0 +1,97 @@ +/** + * 表格状态管理模块 + * + * 提供表格显示配置的状态管理 + * + * ## 主要功能 + * + * - 表格尺寸配置(紧凑、默认、宽松) + * - 斑马纹显示开关 + * - 边框显示开关 + * - 表头背景显示开关 + * - 全屏模式开关 + * + * ## 使用场景 + * - 表格组件样式配置 + * - 用户表格偏好设置 + * - 表格工具栏功能控制 + * + * ## 持久化 + * + * - 使用 localStorage 存储 + * - 存储键:sys-v{version}-table + * - 用户配置跨页面保持 + * + * @module store/modules/table + * @author Art Design Pro Team + */ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { TableSizeEnum } from '@/enums/formEnum' + +// 表格 +export const useTableStore = defineStore( + 'tableStore', + () => { + // 表格大小 + const tableSize = ref(TableSizeEnum.DEFAULT) + // 斑马纹 + const isZebra = ref(false) + // 边框 + const isBorder = ref(false) + // 表头背景 + const isHeaderBackground = ref(false) + + // 是否全屏 + const isFullScreen = ref(false) + + /** + * 设置表格大小 + * @param size 表格大小枚举值 + */ + const setTableSize = (size: TableSizeEnum) => (tableSize.value = size) + + /** + * 设置斑马纹显示状态 + * @param value 是否显示斑马纹 + */ + const setIsZebra = (value: boolean) => (isZebra.value = value) + + /** + * 设置表格边框显示状态 + * @param value 是否显示边框 + */ + const setIsBorder = (value: boolean) => (isBorder.value = value) + + /** + * 设置表头背景显示状态 + * @param value 是否显示表头背景 + */ + const setIsHeaderBackground = (value: boolean) => (isHeaderBackground.value = value) + + /** + * 设置是否全屏 + * @param value 是否全屏 + */ + const setIsFullScreen = (value: boolean) => (isFullScreen.value = value) + + return { + tableSize, + isZebra, + isBorder, + isHeaderBackground, + setTableSize, + setIsZebra, + setIsBorder, + setIsHeaderBackground, + isFullScreen, + setIsFullScreen + } + }, + { + persist: { + key: 'table', + storage: localStorage + } + } +) diff --git a/web/apps/admin/src/store/modules/user.ts b/web/apps/admin/src/store/modules/user.ts new file mode 100755 index 0000000..08f7684 --- /dev/null +++ b/web/apps/admin/src/store/modules/user.ts @@ -0,0 +1,235 @@ +/** + * 用户状态管理模块 + * + * 提供用户相关的状态管理 + * + * ## 主要功能 + * + * - 用户登录状态管理 + * - 用户信息存储 + * - 访问令牌和刷新令牌管理 + * - 语言设置 + * - 搜索历史记录 + * - 锁屏状态和密码管理 + * - 登出清理逻辑 + * + * ## 使用场景 + * + * - 用户登录和认证 + * - 权限验证 + * - 个人信息展示 + * - 多语言切换 + * - 锁屏功能 + * - 搜索历史管理 + * + * ## 持久化 + * + * - 使用 localStorage 存储 + * - 存储键:sys-v{version}-user + * - 登出时自动清理 + * + * @module store/modules/user + * @author Art Design Pro Team + */ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { LanguageEnum } from '@/enums/appEnum' +import { router } from '@/router' +import { useSettingStore } from './setting' +import { useWorktabStore } from './worktab' +import { AppRouteRecord } from '@/types/router' +import { setPageTitle } from '@/utils/router' +import { resetRouterState } from '@/router/guards/beforeEach' +import { useMenuStore } from './menu' +import { StorageConfig } from '@/utils/storage/storage-config' + +/** + * 用户状态管理 + * 管理用户登录状态、个人信息、语言设置、搜索历史、锁屏状态等 + */ +export const useUserStore = defineStore( + 'userStore', + () => { + // 语言设置 + const language = ref(LanguageEnum.ZH) + // 登录状态 + const isLogin = ref(false) + // 锁屏状态 + const isLock = ref(false) + // 锁屏密码 + const lockPassword = ref('') + // 用户信息 + const info = ref>({}) + // 搜索历史记录 + const searchHistory = ref([]) + // 访问令牌 + const accessToken = ref('') + // 刷新令牌 + const refreshToken = ref('') + + // 计算属性:获取用户信息 + const getUserInfo = computed(() => info.value) + // 计算属性:获取设置状态 + const getSettingState = computed(() => useSettingStore().$state) + // 计算属性:获取工作台状态 + const getWorktabState = computed(() => useWorktabStore().$state) + + /** + * 设置用户信息 + * @param newInfo 新的用户信息 + */ + const setUserInfo = (newInfo: Api.Auth.UserInfo) => { + info.value = newInfo + } + + /** + * 设置登录状态 + * @param status 登录状态 + */ + const setLoginStatus = (status: boolean) => { + isLogin.value = status + } + + /** + * 设置语言 + * @param lang 语言枚举值 + */ + const setLanguage = (lang: LanguageEnum) => { + setPageTitle(router.currentRoute.value) + language.value = lang + } + + /** + * 设置搜索历史 + * @param list 搜索历史列表 + */ + const setSearchHistory = (list: AppRouteRecord[]) => { + searchHistory.value = list + } + + /** + * 设置锁屏状态 + * @param status 锁屏状态 + */ + const setLockStatus = (status: boolean) => { + isLock.value = status + } + + /** + * 设置锁屏密码 + * @param password 锁屏密码 + */ + const setLockPassword = (password: string) => { + lockPassword.value = password + } + + /** + * 设置令牌 + * @param newAccessToken 访问令牌 + * @param newRefreshToken 刷新令牌(可选) + */ + const setToken = (newAccessToken: string, newRefreshToken?: string) => { + accessToken.value = newAccessToken + if (newRefreshToken) { + refreshToken.value = newRefreshToken + } + } + + /** + * 退出登录 + * 清空所有用户相关状态并跳转到登录页 + * 如果是同一账号重新登录,保留工作台标签页 + */ + const logOut = () => { + // 保存当前用户 ID,用于下次登录时判断是否为同一用户 + const currentUserId = info.value.userId + if (currentUserId) { + localStorage.setItem(StorageConfig.LAST_USER_ID_KEY, String(currentUserId)) + } + + // 清空用户信息 + info.value = {} + // 重置登录状态 + isLogin.value = false + // 重置锁屏状态 + isLock.value = false + // 清空锁屏密码 + lockPassword.value = '' + // 清空访问令牌 + accessToken.value = '' + // 清空刷新令牌 + refreshToken.value = '' + // 注意:不清空工作台标签页,等下次登录时根据用户判断 + // 移除iframe路由缓存 + sessionStorage.removeItem('iframeRoutes') + // 清空主页路径 + useMenuStore().setHomePath('') + // 重置路由状态 + resetRouterState(500) + // 跳转到登录页,携带当前路由作为 redirect 参数 + const currentRoute = router.currentRoute.value + const redirect = currentRoute.path !== '/login' ? currentRoute.fullPath : undefined + router.push({ + name: 'Login', + query: redirect ? { redirect } : undefined + }) + } + + /** + * 检查并清理工作台标签页 + * 如果不是同一用户登录,清空工作台标签页 + * 应在登录成功后调用 + */ + const checkAndClearWorktabs = () => { + const lastUserId = localStorage.getItem(StorageConfig.LAST_USER_ID_KEY) + const currentUserId = info.value.userId + + // 无法获取当前用户 ID,跳过检查 + if (!currentUserId) return + + // 首次登录或缓存已清除,保留现有标签页 + if (!lastUserId) { + return + } + + // 不同用户登录,清空工作台标签页 + if (String(currentUserId) !== lastUserId) { + const worktabStore = useWorktabStore() + worktabStore.opened = [] + worktabStore.keepAliveExclude = [] + } + + // 清除临时存储 + localStorage.removeItem(StorageConfig.LAST_USER_ID_KEY) + } + + return { + language, + isLogin, + isLock, + lockPassword, + info, + searchHistory, + accessToken, + refreshToken, + getUserInfo, + getSettingState, + getWorktabState, + setUserInfo, + setLoginStatus, + setLanguage, + setSearchHistory, + setLockStatus, + setLockPassword, + setToken, + logOut, + checkAndClearWorktabs + } + }, + { + persist: { + key: 'user', + storage: localStorage + } + } +) diff --git a/web/apps/admin/src/store/modules/worktab.ts b/web/apps/admin/src/store/modules/worktab.ts new file mode 100755 index 0000000..caa0d90 --- /dev/null +++ b/web/apps/admin/src/store/modules/worktab.ts @@ -0,0 +1,568 @@ +/** + * 工作标签页状态管理模块 + * + * 提供多标签页功能的完整状态管理 + * + * ## 主要功能 + * + * - 标签页打开和关闭 + * - 标签页固定和取消固定 + * - 批量关闭(左侧、右侧、其他、全部) + * - 标签页缓存管理(KeepAlive) + * - 标签页标题自定义 + * - 标签页路由验证 + * - 动态路由参数处理 + * + * ## 使用场景 + * + * - 多标签页导航 + * - 页面缓存控制 + * - 标签页右键菜单 + * - 固定常用页面 + * - 批量关闭标签 + * + * ## 核心特性 + * + * - 智能标签页复用(同路由名称复用) + * - 固定标签页保护(不可关闭) + * - KeepAlive 缓存排除管理 + * - 路由有效性验证 + * - 首页自动保留 + * + * ## 持久化 + * - 使用 localStorage 存储 + * - 存储键:sys-v{version}-worktab + * - 刷新页面保持标签状态 + * + * @module store/modules/worktab + * @author Art Design Pro Team + */ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { router } from '@/router' +import { LocationQueryRaw, Router } from 'vue-router' +import { WorkTab } from '@/types' +import { useCommon } from '@/hooks/core/useCommon' + +interface WorktabState { + current: Partial + opened: WorkTab[] + keepAliveExclude: string[] +} + +/** + * 工作台标签页管理 Store + */ +export const useWorktabStore = defineStore( + 'worktabStore', + () => { + // 状态定义 + const current = ref>({}) + const opened = ref([]) + const keepAliveExclude = ref([]) + + // 计算属性 + const hasOpenedTabs = computed(() => opened.value.length > 0) + const hasMultipleTabs = computed(() => opened.value.length > 1) + const currentTabIndex = computed(() => + current.value.path ? opened.value.findIndex((tab) => tab.path === current.value.path) : -1 + ) + + /** + * 查找标签页索引 + */ + const findTabIndex = (path: string): number => { + return opened.value.findIndex((tab) => tab.path === path) + } + + /** + * 获取标签页 + */ + const getTab = (path: string): WorkTab | undefined => { + return opened.value.find((tab) => tab.path === path) + } + + /** + * 检查标签页是否可关闭 + */ + const isTabClosable = (tab: WorkTab): boolean => { + return !tab.fixedTab + } + + /** + * 安全的路由跳转 + */ + const safeRouterPush = (tab: Partial): void => { + if (!tab.path) { + console.warn('尝试跳转到无效路径的标签页') + return + } + + try { + router.push({ + path: tab.path, + query: tab.query as LocationQueryRaw + }) + } catch (error) { + console.error('路由跳转失败:', error) + } + } + + /** + * 打开或激活一个选项卡 + */ + const openTab = (tab: WorkTab): void => { + if (!tab.path) { + console.warn('尝试打开无效的标签页') + return + } + + // 从 keepAlive 排除列表中移除 + if (tab.name) { + removeKeepAliveExclude(tab.name) + } + + // 先根据路由名称查找(应对动态路由参数导致的多开问题),找不到再根据路径查找 + let existingIndex = -1 + if (tab.name) { + existingIndex = opened.value.findIndex((t) => t.name === tab.name) + } + if (existingIndex === -1) { + existingIndex = findTabIndex(tab.path) + } + + if (existingIndex === -1) { + // 新增标签页 + const insertIndex = tab.fixedTab ? findFixedTabInsertIndex() : opened.value.length + const newTab = { ...tab } + + if (tab.fixedTab) { + opened.value.splice(insertIndex, 0, newTab) + } else { + opened.value.push(newTab) + } + + current.value = newTab + } else { + // 更新现有标签页(当动态路由参数或查询变更时,复用同一标签) + const existingTab = opened.value[existingIndex] + + opened.value[existingIndex] = { + ...existingTab, + path: tab.path, + params: tab.params, + query: tab.query, + title: tab.title || existingTab.title, + fixedTab: tab.fixedTab ?? existingTab.fixedTab, + keepAlive: tab.keepAlive ?? existingTab.keepAlive, + name: tab.name || existingTab.name, + icon: tab.icon || existingTab.icon + } + + current.value = opened.value[existingIndex] + } + } + + /** + * 查找固定标签页的插入位置 + */ + const findFixedTabInsertIndex = (): number => { + let insertIndex = 0 + for (let i = 0; i < opened.value.length; i++) { + if (opened.value[i].fixedTab) { + insertIndex = i + 1 + } else { + break + } + } + return insertIndex + } + + /** + * 关闭指定的选项卡 + */ + const removeTab = (path: string): void => { + const targetTab = getTab(path) + const targetIndex = findTabIndex(path) + + if (targetIndex === -1) { + console.warn(`尝试关闭不存在的标签页: ${path}`) + return + } + + if (targetTab && !isTabClosable(targetTab)) { + console.warn(`尝试关闭固定标签页: ${path}`) + return + } + + // 从标签页列表中移除 + opened.value.splice(targetIndex, 1) + + // 处理缓存排除 + if (targetTab?.name) { + addKeepAliveExclude(targetTab) + } + + const { homePath } = useCommon() + + // 如果关闭后无标签页,跳转首页 + if (!hasOpenedTabs.value) { + if (path !== homePath.value) { + current.value = {} + safeRouterPush({ path: homePath.value }) + } + return + } + + // 如果关闭的是当前激活标签,需要激活其他标签 + if (current.value.path === path) { + const newIndex = targetIndex >= opened.value.length ? opened.value.length - 1 : targetIndex + current.value = opened.value[newIndex] + safeRouterPush(current.value) + } + } + + /** + * 关闭左侧选项卡 + */ + const removeLeft = (path: string): void => { + const targetIndex = findTabIndex(path) + + if (targetIndex === -1) { + console.warn(`尝试关闭左侧标签页,但目标标签页不存在: ${path}`) + return + } + + // 获取左侧可关闭的标签页 + const leftTabs = opened.value.slice(0, targetIndex) + const closableLeftTabs = leftTabs.filter(isTabClosable) + + if (closableLeftTabs.length === 0) { + console.warn('左侧没有可关闭的标签页') + return + } + + // 标记为缓存排除 + markTabsToRemove(closableLeftTabs) + + // 移除左侧可关闭的标签页 + opened.value = opened.value.filter( + (tab, index) => index >= targetIndex || !isTabClosable(tab) + ) + + // 确保当前标签是激活状态 + const targetTab = getTab(path) + if (targetTab) { + current.value = targetTab + } + } + + /** + * 关闭右侧选项卡 + */ + const removeRight = (path: string): void => { + const targetIndex = findTabIndex(path) + + if (targetIndex === -1) { + console.warn(`尝试关闭右侧标签页,但目标标签页不存在: ${path}`) + return + } + + // 获取右侧可关闭的标签页 + const rightTabs = opened.value.slice(targetIndex + 1) + const closableRightTabs = rightTabs.filter(isTabClosable) + + if (closableRightTabs.length === 0) { + console.warn('右侧没有可关闭的标签页') + return + } + + // 标记为缓存排除 + markTabsToRemove(closableRightTabs) + + // 移除右侧可关闭的标签页 + opened.value = opened.value.filter( + (tab, index) => index <= targetIndex || !isTabClosable(tab) + ) + + // 确保当前标签是激活状态 + const targetTab = getTab(path) + if (targetTab) { + current.value = targetTab + } + } + + /** + * 关闭其他选项卡 + */ + const removeOthers = (path: string): void => { + const targetTab = getTab(path) + + if (!targetTab) { + console.warn(`尝试关闭其他标签页,但目标标签页不存在: ${path}`) + return + } + + // 获取其他可关闭的标签页 + const otherTabs = opened.value.filter((tab) => tab.path !== path) + const closableTabs = otherTabs.filter(isTabClosable) + + if (closableTabs.length === 0) { + console.warn('没有其他可关闭的标签页') + return + } + + // 标记为缓存排除 + markTabsToRemove(closableTabs) + + // 只保留当前标签和固定标签 + opened.value = opened.value.filter((tab) => tab.path === path || !isTabClosable(tab)) + + // 确保当前标签是激活状态 + current.value = targetTab + } + + /** + * 关闭所有可关闭的标签页 + */ + const removeAll = (): void => { + const { homePath } = useCommon() + const hasFixedTabs = opened.value.some((tab) => tab.fixedTab) + + // 获取可关闭的标签页 + const closableTabs = opened.value.filter((tab) => { + if (!isTabClosable(tab)) return false + // 如果有固定标签,则所有可关闭的都可以关闭;否则保留首页 + return hasFixedTabs || tab.path !== homePath.value + }) + + if (closableTabs.length === 0) { + console.warn('没有可关闭的标签页') + return + } + + // 标记为缓存排除 + markTabsToRemove(closableTabs) + + // 保留不可关闭的标签页和首页(当没有固定标签时) + opened.value = opened.value.filter((tab) => { + return !isTabClosable(tab) || (!hasFixedTabs && tab.path === homePath.value) + }) + + // 处理激活状态 + if (!hasOpenedTabs.value) { + current.value = {} + safeRouterPush({ path: homePath.value }) + return + } + + // 选择激活的标签页:优先首页,其次第一个可用标签 + const homeTab = opened.value.find((tab) => tab.path === homePath.value) + const targetTab = homeTab || opened.value[0] + + current.value = targetTab + safeRouterPush(targetTab) + } + + /** + * 将指定选项卡添加到 keepAlive 排除列表中 + */ + const addKeepAliveExclude = (tab: WorkTab): void => { + if (!tab.keepAlive || !tab.name) return + + if (!keepAliveExclude.value.includes(tab.name)) { + keepAliveExclude.value.push(tab.name) + } + } + + /** + * 从 keepAlive 排除列表中移除指定组件名称 + */ + const removeKeepAliveExclude = (name: string): void => { + if (!name) return + + keepAliveExclude.value = keepAliveExclude.value.filter((item) => item !== name) + } + + /** + * 将传入的一组选项卡的组件名称标记为排除缓存 + */ + const markTabsToRemove = (tabs: WorkTab[]): void => { + tabs.forEach((tab) => { + if (tab.name) { + addKeepAliveExclude(tab) + } + }) + } + + /** + * 切换指定标签页的固定状态 + */ + const toggleFixedTab = (path: string): void => { + const targetIndex = findTabIndex(path) + + if (targetIndex === -1) { + console.warn(`尝试切换不存在标签页的固定状态: ${path}`) + return + } + + const tab = { ...opened.value[targetIndex] } + tab.fixedTab = !tab.fixedTab + + // 移除原位置 + opened.value.splice(targetIndex, 1) + + if (tab.fixedTab) { + // 固定标签插入到所有固定标签的末尾 + const firstNonFixedIndex = opened.value.findIndex((t) => !t.fixedTab) + const insertIndex = firstNonFixedIndex === -1 ? opened.value.length : firstNonFixedIndex + opened.value.splice(insertIndex, 0, tab) + } else { + // 非固定标签插入到所有固定标签后 + const fixedCount = opened.value.filter((t) => t.fixedTab).length + opened.value.splice(fixedCount, 0, tab) + } + + // 更新当前标签引用 + if (current.value.path === path) { + current.value = tab + } + } + + /** + * 验证工作台标签页的路由有效性 + */ + const validateWorktabs = (routerInstance: Router): void => { + try { + // 动态路由校验:优先使用路由 name 判断有效性;否则用 resolve 匹配参数化路径 + const isTabRouteValid = (tab: Partial): boolean => { + try { + if (tab.name) { + const routes = routerInstance.getRoutes() + if (routes.some((r) => r.name === tab.name)) return true + } + if (tab.path) { + const resolved = routerInstance.resolve({ + path: tab.path, + query: (tab.query as LocationQueryRaw) || undefined + }) + return resolved.matched.length > 0 + } + return false + } catch { + return false + } + } + + // 过滤出有效的标签页 + const validTabs = opened.value.filter((tab) => isTabRouteValid(tab)) + + if (validTabs.length !== opened.value.length) { + console.warn('发现无效的标签页路由,已自动清理') + opened.value = validTabs + } + + // 验证当前激活标签的有效性 + const isCurrentValid = current.value && isTabRouteValid(current.value) + + if (!isCurrentValid && validTabs.length > 0) { + console.warn('当前激活标签无效,已自动切换') + current.value = validTabs[0] + } else if (!isCurrentValid) { + current.value = {} + } + } catch (error) { + console.error('验证工作台标签页失败:', error) + } + } + + /** + * 清空所有状态(用于登出等场景) + */ + const clearAll = (): void => { + current.value = {} + opened.value = [] + keepAliveExclude.value = [] + } + + /** + * 获取状态快照(用于持久化存储) + */ + const getStateSnapshot = (): WorktabState => { + return { + current: { ...current.value }, + opened: [...opened.value], + keepAliveExclude: [...keepAliveExclude.value] + } + } + + /** + * 获取标签页标题 + */ + const getTabTitle = (path: string): WorkTab | undefined => { + const tab = getTab(path) + return tab + } + + /** + * 更新标签页标题 + */ + const updateTabTitle = (path: string, title: string): void => { + const tab = getTab(path) + if (tab) { + tab.customTitle = title + } + } + + /** + * 重置标签页标题 + */ + const resetTabTitle = (path: string): void => { + const tab = getTab(path) + if (tab) { + tab.customTitle = '' + } + } + + return { + // 状态 + current, + opened, + keepAliveExclude, + + // 计算属性 + hasOpenedTabs, + hasMultipleTabs, + currentTabIndex, + + // 方法 + openTab, + removeTab, + removeLeft, + removeRight, + removeOthers, + removeAll, + toggleFixedTab, + validateWorktabs, + clearAll, + getStateSnapshot, + + // 工具方法 + findTabIndex, + getTab, + isTabClosable, + addKeepAliveExclude, + removeKeepAliveExclude, + markTabsToRemove, + getTabTitle, + updateTabTitle, + resetTabTitle + } + }, + { + persist: { + key: 'worktab', + storage: localStorage + } + } +) diff --git a/web/apps/admin/src/types/api/api.d.ts b/web/apps/admin/src/types/api/api.d.ts new file mode 100644 index 0000000..d90fc75 --- /dev/null +++ b/web/apps/admin/src/types/api/api.d.ts @@ -0,0 +1,143 @@ +/** + * API 接口类型定义模块 + * + * 提供所有后端接口的类型定义 + * + * ## 主要功能 + * + * - 通用类型(分页参数、响应结构等) + * - 认证类型(登录、用户信息等) + * - 系统管理类型(用户、角色等) + * - 全局命名空间声明 + * + * ## 使用场景 + * + * - API 请求参数类型约束 + * - API 响应数据类型定义 + * - 接口文档类型同步 + * + * ## 注意事项 + * + * - 在 .vue 文件使用需要在 eslint.config.mjs 中配置 globals: { Api: 'readonly' } + * - 使用全局命名空间,无需导入即可使用 + * + * ## 使用方式 + * + * ```typescript + * const params: Api.Auth.LoginParams = { userName: 'admin', password: '123456' } + * const response: Api.Auth.UserInfo = await fetchUserInfo() + * ``` + * + * @module types/api/api + * @author Art Design Pro Team + */ + +declare namespace Api { + /** 通用类型 */ + namespace Common { + /** 分页参数 */ + interface PaginationParams { + /** 当前页码 */ + current: number + /** 每页条数 */ + size: number + /** 总条数 */ + total: number + } + + /** 通用搜索参数 */ + type CommonSearchParams = Pick + + /** 分页响应基础结构 */ + interface PaginatedResponse { + records: T[] + current: number + size: number + total: number + } + + /** 启用状态 */ + type EnableStatus = '1' | '2' + } + + /** 认证类型 */ + namespace Auth { + /** 登录参数 */ + interface LoginParams { + userName: string + password: string + } + + /** 登录响应 */ + interface LoginResponse { + token?: string + refreshToken?: string + require_totp?: boolean + temp_token?: string + } + + interface TOTPLoginParams { + temp_token: string + code?: string + backup_code?: string + } + + /** 用户信息 */ + interface UserInfo { + buttons: string[] + roles: string[] + userId: string + userName: string + email: string + avatar?: string + } + } + + /** 系统管理类型 */ + namespace SystemManage { + /** 用户列表 */ + type UserList = Api.Common.PaginatedResponse + + /** 用户列表项 */ + interface UserListItem { + id: number + avatar: string + status: string + userName: string + userGender: string + nickName: string + userPhone: string + userEmail: string + userRoles: string[] + createBy: string + createTime: string + updateBy: string + updateTime: string + } + + /** 用户搜索参数 */ + type UserSearchParams = Partial< + Pick & + Api.Common.CommonSearchParams + > + + /** 角色列表 */ + type RoleList = Api.Common.PaginatedResponse + + /** 角色列表项 */ + interface RoleListItem { + roleId: number + roleName: string + roleCode: string + description: string + enabled: boolean + createTime: string + } + + /** 角色搜索参数 */ + type RoleSearchParams = Partial< + Pick & + Api.Common.CommonSearchParams + > + } +} diff --git a/web/apps/admin/src/types/common/index.ts b/web/apps/admin/src/types/common/index.ts new file mode 100644 index 0000000..7e751d1 --- /dev/null +++ b/web/apps/admin/src/types/common/index.ts @@ -0,0 +1,95 @@ +/** + * 通用类型定义模块 + * + * 提供项目中常用的通用类型定义 + * + * ## 主要功能 + * + * - 状态类型(启用/禁用) + * - 性别类型 + * - 排序方向类型 + * - 操作类型(增删改查) + * - 记录类型(键值对) + * - 时间范围类型 + * - 文件信息类型 + * - 坐标和尺寸类型 + * - 响应式断点类型 + * - 主题和语言类型 + * - 环境和弹窗类型 + * + * ## 使用场景 + * + * - 通用数据结构定义 + * - 类型约束和提示 + * - 减少重复类型定义 + * + * @module types/common/index + * @author Art Design Pro Team + */ + +// 导出响应类型 +export * from './response' + +// 状态类型 +export type Status = 0 | 1 // 0: 禁用, 1: 启用 + +// 性别类型 +export type Gender = 'male' | 'female' | 'unknown' + +// 排序方向 +export type SortOrder = 'ascending' | 'descending' + +// 操作类型 +export type ActionType = 'create' | 'update' | 'delete' | 'view' + +// 可选的记录类型 +export type Recordable = Record + +// 键值对类型 +export type KeyValue = { + key: string + value: T + label?: string +} + +// 时间范围类型 +export interface TimeRange { + startTime: string + endTime: string +} + +// 文件类型 +export interface FileInfo { + name: string + url: string + size: number + type: string + lastModified?: number +} + +// 坐标类型 +export interface Position { + x: number + y: number +} + +// 尺寸类型 +export interface Size { + width: number + height: number +} + +// 响应式断点类型 +export type Breakpoint = 'xs' | 'sm' | 'md' | 'lg' | 'xl' + +// 主题类型 +export type ThemeMode = 'light' | 'dark' | 'auto' + +// 语言类型 +export type Language = 'zh-CN' | 'en-US' + +// 环境类型 +export type Environment = 'development' | 'production' | 'test' + +// 弹窗类型 +export type DialogType = 'add' | 'edit' diff --git a/web/apps/admin/src/types/common/response.ts b/web/apps/admin/src/types/common/response.ts new file mode 100644 index 0000000..4a5fdab --- /dev/null +++ b/web/apps/admin/src/types/common/response.ts @@ -0,0 +1,30 @@ +/** + * API 响应类型定义模块 + * + * 提供统一的 API 响应结构类型定义 + * + * ## 主要功能 + * + * - 基础响应结构定义 + * - 泛型支持(适配不同数据类型) + * - 统一的响应格式约束 + * + * ## 使用场景 + * + * - API 请求响应类型约束 + * - 接口数据类型定义 + * - 响应数据解析 + * + * @module types/common/response + * @author Art Design Pro Team + */ + +/** 基础 API 响应结构 */ +export interface BaseResponse { + /** 状态码 */ + code: number + /** 消息 */ + msg: string + /** 数据 */ + data: T +} diff --git a/web/apps/admin/src/types/component/chart.ts b/web/apps/admin/src/types/component/chart.ts new file mode 100644 index 0000000..c3225c9 --- /dev/null +++ b/web/apps/admin/src/types/component/chart.ts @@ -0,0 +1,324 @@ +/** + * 图表组件类型定义模块 + * + * 提供 ECharts 图表组件的完整类型定义 + * + * ## 主要功能 + * + * - 基础图表配置类型 + * - 柱状图类型定义 + * - 折线图类型定义 + * - 饼图/环形图类型定义 + * - 雷达图类型定义 + * - K线图类型定义 + * - 散点图类型定义 + * - 地图图表类型定义 + * - 双向堆叠柱状图类型定义 + * - 图表主题配置类型 + * - 图表事件回调类型 + * + * ## 使用场景 + * + * - 图表组件 Props 类型约束 + * - 图表配置类型定义 + * - 图表数据结构定义 + * - 图表事件处理 + * + * @module types/component/chart + * @author Art Design Pro Team + */ +import type { EChartsOption } from '@/plugins/echarts' + +// 图例位置类型 +export type LegendPosition = 'bottom' | 'top' | 'left' | 'right' + +export type SymbolType = + | 'circle' + | 'rect' + | 'roundRect' + | 'triangle' + | 'diamond' + | 'pin' + | 'arrow' + | 'none' + +// 图表主题配置 +export interface ChartThemeConfig { + /** 图表高度 */ + chartHeight: string + /** 字体大小 */ + fontSize: number + /** 字体颜色 */ + fontColor: string + /** 主题颜色 */ + themeColor: string + /** 颜色组 */ + colors: string[] +} + +// 图表初始化选项 +export interface UseChartOptions { + /** 初始化选项 */ + initOptions?: EChartsOption + /** 延迟初始化时间(ms) */ + initDelay?: number + /** IntersectionObserver阈值 */ + threshold?: number + /** 是否自动响应主题变化 */ + autoTheme?: boolean +} + +// 基础图表 Props 接口 - 统一所有图表的基础属性 +export interface BaseChartProps { + /** 图表高度 */ + height?: string + /** 是否加载中 */ + loading?: boolean + isEmpty?: boolean + /** 颜色配置 */ + colors?: string[] +} + +// 轴线显示控制接口 - 统一轴线相关配置 +export interface AxisDisplayProps { + /** 是否显示坐标轴标签 */ + showAxisLabel?: boolean + /** 是否显示坐标轴线 */ + showAxisLine?: boolean + /** 是否显示分割线 */ + showSplitLine?: boolean +} + +// 交互显示控制接口 - 统一交互相关配置 +export interface InteractionProps { + /** 是否显示提示框 */ + showTooltip?: boolean + /** 是否显示图例 */ + showLegend?: boolean + /** 图例位置 */ + legendPosition?: LegendPosition +} + +// 柱状图数据项接口 +export interface BarDataItem { + /** 系列名称 */ + name: string + /** 数据值 */ + data: number[] + /** 柱状图宽度 */ + barWidth?: string | number + /** 堆叠分组名称 */ + stack?: string +} + +// 柱状图 Props 接口 - 统一柱状图配置 +export interface BarChartProps extends BaseChartProps, AxisDisplayProps, InteractionProps { + /** 图表数据 - 支持单组数据或多组数据 */ + data: number[] | BarDataItem[] + /** X轴标签数据 */ + xAxisData?: string[] + /** 柱状图宽度 */ + barWidth?: string | number + /** 是否堆叠显示 */ + stack?: boolean + /** 圆角 */ + borderRadius?: number | number[] +} + +// 折线图数据项接口 +export interface LineDataItem { + /** 系列名称 */ + name: string + /** 数据值 */ + data: number[] + /** 线条宽度 */ + lineWidth?: number + /** 是否显示区域填充 */ + showAreaColor?: boolean + /** 区域样式配置 */ + areaStyle?: { + /** 渐变开始透明度 */ + startOpacity?: number + /** 渐变结束透明度 */ + endOpacity?: number + /** 自定义 ECharts areaStyle 配置 */ + custom?: any + } + /** 是否平滑曲线 */ + smooth?: boolean + /** 数据点符号 */ + symbol?: SymbolType + /** 数据点大小 */ + symbolSize?: number +} + +// 折线图 Props 接口 - 统一折线图配置 +export interface LineChartProps extends BaseChartProps, AxisDisplayProps, InteractionProps { + /** 图表数据 - 支持单组数据或多组数据 */ + data: number[] | LineDataItem[] + /** X轴标签数据 */ + xAxisData?: string[] + /** 线条宽度 */ + lineWidth?: number + /** 是否显示区域填充 */ + showAreaColor?: boolean + /** 是否平滑曲线 */ + smooth?: boolean + /** 数据点符号 */ + symbol?: SymbolType + /** 数据点大小 */ + symbolSize?: number + /** 多数据动画延迟间隔(毫秒) */ + animationDelay?: number +} + +// 雷达图数据项接口 +export interface RadarDataItem { + /** 系列名称 */ + name: string + /** 数据值 */ + value: number[] +} + +// 雷达图 Props 接口 - 统一雷达图配置 +export interface RadarChartProps extends BaseChartProps, InteractionProps { + /** 雷达图指标配置 */ + indicator?: Array<{ name: string; max: number }> + /** 图表数据 */ + data?: RadarDataItem[] +} + +// 饼图/环形图数据项接口 +export interface PieDataItem { + /** 数据值 */ + value: number + /** 数据名称 */ + name: string +} + +// 环形图 Props 接口 - 统一环形图配置 +export interface RingChartProps extends BaseChartProps, InteractionProps { + /** 图表数据 */ + data: PieDataItem[] + /** 内外半径 */ + radius?: string[] + /** 边框圆角 */ + borderRadius?: number + /** 中心文本 */ + centerText?: string + /** 是否显示标签 */ + showLabel?: boolean +} + +// K线图数据项接口 +export interface KLineDataItem { + /** 时间标签 */ + time: string + /** 开盘价 */ + open: number + /** 收盘价 */ + close: number + /** 最高价 */ + high: number + /** 最低价 */ + low: number +} + +// K线图 Props 接口 - 统一K线图配置 +export interface KLineChartProps extends BaseChartProps { + /** 图表数据 */ + data?: KLineDataItem[] + /** 是否显示数据缩放控件 */ + showDataZoom?: boolean + /** 数据缩放初始开始位置 */ + dataZoomStart?: number + /** 数据缩放初始结束位置 */ + dataZoomEnd?: number +} + +// 散点图数据项接口 +export interface ScatterDataItem { + /** 坐标值 [x, y] */ + value: number[] +} + +// 散点图 Props 接口 - 统一散点图配置 +export interface ScatterChartProps extends BaseChartProps, AxisDisplayProps, InteractionProps { + /** 图表数据 */ + data?: ScatterDataItem[] + /** 散点大小 */ + symbolSize?: number +} + +// 双柱对比图 Props 接口 - 统一双柱对比图配置 +export interface DualBarCompareChartProps extends BaseChartProps { + /** 上方数据 */ + topData: number[] + /** 下方数据 */ + bottomData: number[] + /** X轴标签数据 */ + xAxisData: string[] + /** 上方柱子颜色 */ + topColor?: string + /** 下方柱子颜色 */ + bottomColor?: string + /** 柱状图宽度 */ + barWidth?: number +} + +// 地图图表 Props 接口 - 统一地图图表配置 +export interface MapChartProps extends BaseChartProps { + /** 地图数据 */ + mapData?: any[] + /** 选中区域 */ + selectedRegion?: string + /** 是否显示标签 */ + showLabels?: boolean + /** 是否显示散点 */ + showScatter?: boolean +} + +// 双向堆叠柱状图 Props 接口(人口金字塔样式) +export interface BidirectionalBarChartProps + extends BaseChartProps, + AxisDisplayProps, + InteractionProps { + /** 正向数据(向上显示) */ + positiveData: number[] + /** 负向数据(向下显示) */ + negativeData: number[] + /** X轴标签数据 */ + xAxisData?: string[] + /** 正向数据名称 */ + positiveName?: string + /** 负向数据名称 */ + negativeName?: string + /** 柱状图宽度 */ + barWidth?: string | number + /** Y轴最小值 */ + yAxisMin?: number + /** Y轴最大值 */ + yAxisMax?: number + /** 是否显示数据标签 */ + showDataLabel?: boolean + /** 正向数据圆角配置 */ + positiveBorderRadius?: number | number[] + /** 负向数据圆角配置 */ + negativeBorderRadius?: number | number[] +} + +// 图表配置生成器函数类型 +export type ChartOptionGenerator = () => EChartsOption + +// 图表事件回调类型 +export type ChartEventCallback = (params: any) => void + +// 图表错误信息接口 +export interface ChartError { + /** 错误码 */ + code: string + /** 错误信息 */ + message: string + /** 错误详情 */ + details?: any +} diff --git a/web/apps/admin/src/types/component/index.ts b/web/apps/admin/src/types/component/index.ts new file mode 100644 index 0000000..b9e90c6 --- /dev/null +++ b/web/apps/admin/src/types/component/index.ts @@ -0,0 +1,145 @@ +/** + * 组件类型定义模块 + * + * 提供项目组件的类型定义 + * + * ## 主要功能 + * + * - 搜索组件类型定义 + * - 表格列配置类型 + * - 分页配置类型 + * - 表单规则类型 + * - 对话框配置类型 + * + * ## 使用场景 + * + * - 组件 Props 类型约束 + * - 组件配置类型定义 + * - 组件事件参数类型 + * + * @module types/component/index + * @author Art Design Pro Team + */ + +// 搜索组件类型 +export type SearchComponentType = + | 'input' + | 'select' + | 'radio' + | 'checkbox' + | 'date' + | 'datetime' + | 'daterange' + | 'datetimerange' + | 'month' + | 'monthrange' + | 'year' + | 'yearrange' + | 'week' + | 'time' + | 'timerange' + +// 搜索框值变化参数 +export interface SearchChangeParams { + prop: string + val: unknown +} + +// 表格列配置接口 +export interface ColumnOption { + // 列类型 + type?: 'selection' | 'expand' | 'index' | 'globalIndex' + // 列属性名 + prop?: string + // 列标题 + label?: string + // 列宽度 + width?: string | number + // 最小列宽度 + minWidth?: string | number + // 固定列 + fixed?: boolean | 'left' | 'right' + // 是否可排序 + sortable?: boolean | 'custom' + // 过滤器选项 + filters?: any[] + // 过滤方法 + filterMethod?: (value: any, row: any) => boolean + // 过滤器位置 + filterPlacement?: string + // 是否禁用 + disabled?: boolean + // 是否显示列 + visible?: boolean + // 是否选中显示 + checked?: boolean + // 自定义渲染函数 + formatter?: (row: T) => any + // 插槽相关配置 + // 是否使用插槽渲染内容 + useSlot?: boolean + // 插槽名称(默认为 prop 值) + slotName?: string + // 是否使用表头插槽 + useHeaderSlot?: boolean + // 表头插槽名称(默认为 `${prop}-header`) + headerSlotName?: string + // 其他属性 + [key: string]: any +} + +// 分页配置 +export interface PaginationConfig { + // 当前页 + currentPage: number + // 每页条数 + pageSize: number + // 总条数 + total: number + // 每页显示个数选择器的选项 + pageSizes?: number[] + // 组件布局 + layout?: string + // 是否为小型分页 + small?: boolean +} + +// 表单规则 +export interface FormRule { + // 是否必填 + required?: boolean + // 错误提示信息 + message?: string + // 触发方式 + trigger?: string | string[] + // 最小长度 + min?: number + // 最大长度 + max?: number + // 正则表达式 + pattern?: RegExp + // 自定义验证函数 + validator?: (rule: any, value: any, callback: any) => void +} + +// 对话框配置 +export interface DialogConfig { + // 标题 + title: string + // 是否显示 + visible: boolean + // 宽度 + width?: string | number + // 是否可以通过点击 modal 关闭 + closeOnClickModal?: boolean + // 是否可以通过按下 ESC 关闭 + closeOnPressEscape?: boolean + // 是否显示关闭按钮 + showClose?: boolean + // 是否在 Dialog 出现时将 body 滚动锁定 + lockScroll?: boolean + // 是否显示遮罩层 + modal?: boolean + // 自定义类名 + customClass?: string +} diff --git a/web/apps/admin/src/types/config/index.ts b/web/apps/admin/src/types/config/index.ts new file mode 100644 index 0000000..60ac283 --- /dev/null +++ b/web/apps/admin/src/types/config/index.ts @@ -0,0 +1,213 @@ +/** + * 配置类型定义模块 + * + * 提供系统配置相关的类型定义 + * + * ## 主要功能 + * + * - 主题设置类型 + * - 菜单布局类型 + * - 节日配置类型 + * - 系统基础配置类型 + * - 快速入口配置类型 + * - 顶部栏功能配置类型 + * - 环境配置类型 + * - 应用配置类型 + * + * ## 使用场景 + * + * - 系统配置文件类型约束 + * - 配置项类型定义 + * - 配置数据验证 + * + * @module types/config/index + * @author Art Design Pro Team + */ + +import { MenuTypeEnum, SystemThemeEnum } from '@/enums/appEnum' +import { MenuThemeType, SystemThemeTypes } from '@/types/store' + +// 主题设置 +export interface ThemeSetting { + /** 主题名称 */ + name: string + /** 系统主题类型 */ + theme: SystemThemeEnum + /** 主题颜色数组 */ + color: string[] + /** 左侧线条颜色 */ + leftLineColor: string + /** 右侧线条颜色 */ + rightLineColor: string + /** 主题图片 */ + img: string +} + +// 菜单布局 +export interface MenuLayout { + /** 布局名称 */ + name: string + /** 菜单类型值 */ + value: MenuTypeEnum + /** 布局预览图 */ + img: string + /** 布局描述 */ + description?: string +} + +// 节日配置 +export interface FestivalConfig { + /** 节日日期(单日)或开始日期(日期范围) */ + date: string + /** 节日结束日期(可选,用于跨日期节日) */ + endDate?: string + /** 节日名称 */ + name: string + /** 烟花图片 */ + image: string + /** 滚动文本 */ + scrollText: string + /** 是否激活 */ + isActive?: boolean + /** 烟花播放次数(可选,默认为 3 次) */ + count?: number +} + +// 系统基础配置 +export interface SystemBasicConfig { + // 系统名称 + name: string + // 紧凑区域(侧边栏/顶栏)显示的短名称 + shortName?: string + // 系统描述 + description?: string + // 系统logo + logo?: string + // 系统favicon + favicon?: string + // 版权信息 + copyright?: string +} + +// 快速入口基础项 +export interface FastEnterBaseItem { + /** 名称 */ + name: string + /** 是否启用 */ + enabled?: boolean + /** 排序权重 */ + order?: number + /** 路由名称 */ + routeName?: string + /** 外部链接 */ + link?: string +} + +// 快速入口应用项 +export interface FastEnterApplication extends FastEnterBaseItem { + /** 应用描述 */ + description: string + /** 图标代码 */ + icon: string + /** 图标颜色 */ + iconColor: string +} + +// 快速链接项 +export type FastEnterQuickLink = FastEnterBaseItem + +// 快速入口配置 +export interface FastEnterConfig { + /** 应用列表 */ + applications: FastEnterApplication[] + /** 快速链接 */ + quickLinks: FastEnterQuickLink[] + /** 显示条件(屏幕宽度) */ + minWidth?: number +} + +// 系统配置 +export interface SystemConfig { + // 系统基础信息 + systemInfo: SystemBasicConfig + // 系统主题样式 + systemThemeStyles: SystemThemeTypes + // 设置主题列表 + settingThemeList: ThemeSetting[] + // 菜单布局列表 + menuLayoutList: MenuLayout[] + // 主题列表 + themeList: MenuThemeType[] + // 暗色菜单样式 + darkMenuStyles: MenuThemeType[] + // 系统主色调 + systemMainColor: readonly string[] + // 快速入口配置 + fastEnter?: FastEnterConfig + // 顶部栏功能配置 + headerBar?: HeaderBarFeatureConfig +} + +// 环境配置 +export interface EnvConfig { + // 环境名称 + NODE_ENV: string + // 应用版本 + VITE_VERSION: string + // 应用端口 + VITE_PORT: string + // 应用基础路径 + VITE_BASE_URL: string + // API 地址 + VITE_API_URL: string + // 是否开启 Mock + VITE_USE_MOCK?: string + // 是否开启压缩 + VITE_USE_GZIP?: string + // 是否开启 CDN + VITE_USE_CDN?: string +} + +// 应用配置 +export interface AppConfig extends SystemConfig { + // 环境配置 + env: EnvConfig + // 开发模式 + isDev: boolean + // 生产模式 + isProd: boolean + // 测试模式 + isTest: boolean +} + +// 功能配置项基础接口 +export interface FeatureConfigItem { + enabled: boolean + description: string +} + +// 顶部栏功能配置接口 +export interface HeaderBarFeatureConfig { + /** 菜单按钮 */ + menuButton: FeatureConfigItem + /** 刷新按钮 */ + refreshButton: FeatureConfigItem + /** 快速入口 */ + fastEnter: FeatureConfigItem + /** 面包屑导航 */ + breadcrumb: FeatureConfigItem + /** 全局搜索 */ + globalSearch: FeatureConfigItem + /** 全屏功能 */ + fullscreen: FeatureConfigItem + /** 通知功能 */ + notification: FeatureConfigItem + /** 聊天功能 */ + chat: FeatureConfigItem + /** 多语言切换 */ + language: FeatureConfigItem + /** 设置面板 */ + settings: FeatureConfigItem + /** 主题切换 */ + themeToggle: FeatureConfigItem +} diff --git a/web/apps/admin/src/types/directive/directive.d.ts b/web/apps/admin/src/types/directive/directive.d.ts new file mode 100644 index 0000000..eb0154e --- /dev/null +++ b/web/apps/admin/src/types/directive/directive.d.ts @@ -0,0 +1,15 @@ +import type { + AuthDirective, + RolesDirective, + RippleDirective, + HighlightDirective +} from '@/directives' + +declare module 'vue' { + export interface GlobalDirectives { + vAuth: AuthDirective + vRoles: RolesDirective + vRipple: RippleDirective + vHighlight: HighlightDirective + } +} diff --git a/web/apps/admin/src/types/index.ts b/web/apps/admin/src/types/index.ts new file mode 100644 index 0000000..9032fd2 --- /dev/null +++ b/web/apps/admin/src/types/index.ts @@ -0,0 +1,22 @@ +/** + * 类型定义统一导出模块 + * 提供全局类型定义的统一导出入口 + * + * @module types/index + * @author Art Design Pro Team + */ + +/** 通用类型定义(基础类型、工具类型等) */ +export * from './common' + +/** 组件相关类型定义 */ +export * from './component' + +/** 状态管理相关类型定义 */ +export * from './store' + +/** 路由相关类型定义 */ +export * from './router' + +/** 配置相关类型定义 */ +export * from './config' diff --git a/web/apps/admin/src/types/router/index.ts b/web/apps/admin/src/types/router/index.ts new file mode 100644 index 0000000..d9ef012 --- /dev/null +++ b/web/apps/admin/src/types/router/index.ts @@ -0,0 +1,80 @@ +/** + * 路由类型定义模块 + * + * 提供路由相关的类型定义 + * + * ## 主要功能 + * + * - 路由元数据类型(标题、图标、权限等) + * - 应用路由记录类型 + * - 路由配置扩展 + * + * ## 使用场景 + * + * - 路由配置类型约束 + * - 路由元数据定义 + * - 菜单生成 + * - 权限控制 + * + * @module types/router/index + * @author Art Design Pro Team + */ + +import { RouteRecordRaw } from 'vue-router' + +/** + * 路由元数据接口 + * 定义路由的各种配置属性 + */ +export interface RouteMeta extends Record { + /** 路由标题 */ + title: string + /** 路由图标 */ + icon?: string + /** 是否显示徽章 */ + showBadge?: boolean + /** 文本徽章 */ + showTextBadge?: string + /** 是否在菜单中隐藏 */ + isHide?: boolean + /** 是否在标签页中隐藏 */ + isHideTab?: boolean + /** 外部链接 */ + link?: string + /** 是否为iframe */ + isIframe?: boolean + /** 是否缓存 */ + keepAlive?: boolean + /** 操作权限 */ + authList?: Array<{ + title: string + authMark: string + }> + /** 是否为一级菜单 */ + isFirstLevel?: boolean + /** 角色权限 */ + roles?: string[] + /** 是否固定标签页 */ + fixedTab?: boolean + /** 激活菜单路径 */ + activePath?: string + /** 是否为全屏页面 */ + isFullPage?: boolean + /** 是否为权限按钮行 */ + isAuthButton?: boolean + /** 权限标识 */ + authMark?: string + /** 父级路径 */ + parentPath?: string +} + +/** + * 应用路由记录接口 + * 扩展 Vue Router 的路由记录类型 + */ +export interface AppRouteRecord extends Omit { + id?: number + meta: RouteMeta + children?: AppRouteRecord[] + component?: string | (() => Promise) +} diff --git a/web/apps/admin/src/types/store/index.ts b/web/apps/admin/src/types/store/index.ts new file mode 100644 index 0000000..019801e --- /dev/null +++ b/web/apps/admin/src/types/store/index.ts @@ -0,0 +1,157 @@ +/** + * Store 状态类型定义模块 + * + * 提供 Pinia Store 的状态类型定义 + * + * ## 主要功能 + * + * - 系统主题类型 + * - 菜单主题类型 + * - 设置状态类型 + * - 工作标签页类型 + * - 用户状态类型 + * - 菜单状态类型 + * - 根状态类型 + * + * ## 使用场景 + * + * - Store 状态类型约束 + * - 状态数据结构定义 + * - 类型提示和自动补全 + * + * @module types/store/index + * @author Art Design Pro Team + */ + +import { MenuThemeEnum, SystemThemeEnum } from '@/enums/appEnum' +import { LocationQueryRaw } from 'vue-router' + +// 系统主题样式(light | dark) +export interface SystemThemeType { + /** 主题类名 */ + className: string +} + +// 定义包含多个主题的类型 +export type SystemThemeTypes = { + [key in Exclude]: SystemThemeType +} + +// 菜单主题样式 +export interface MenuThemeType { + /** 主题类型 */ + theme: MenuThemeEnum + /** 背景颜色 */ + background: string + /** 系统名称颜色 */ + systemNameColor: string + /** 文本颜色 */ + textColor: string + /** 图标颜色 */ + iconColor: string + /** 背景图片 */ + img?: string +} + +// 设置中心 +export interface SettingState { + /** 主题 */ + theme: string + /** 是否只保持一个子菜单的展开 */ + uniqueOpened: boolean + /** 是否显示菜单按钮 */ + menuButton: boolean + /** 是否显示刷新按钮 */ + showRefreshButton: boolean + /** 是否显示面包屑 */ + showCrumbs: boolean + /** 是否自动关闭 */ + autoClose: boolean + /** 是否显示工作标签页 */ + showWorkTab: boolean + /** 是否显示语言切换 */ + showLanguage: boolean + /** 是否显示进度条 */ + showNprogress: boolean + /** 主题模式 */ + themeModel: string +} + +// 多标签 +export interface WorkTab { + /** 标签标题 */ + title: string + /** 自定义标题 */ + customTitle?: string + /** 路由路径 */ + path: string + /** 路由名称 */ + name: string + /** 是否缓存 */ + keepAlive: boolean + /** 是否固定标签 */ + fixedTab?: boolean + /** 路由参数 */ + params?: object + /** 路由查询参数 */ + query?: LocationQueryRaw + /** 图标 */ + icon?: string + /** 是否激活 */ + isActive?: boolean +} + +// 用户Store状态 +export interface UserState { + /** 用户信息 */ + userInfo: Api.Auth.UserInfo | null + /** 认证令牌 */ + token: string | null + /** 用户角色列表 */ + roles: string[] + /** 用户权限列表 */ + permissions: string[] +} + +// 设置Store状态 +export interface SettingStoreState extends SettingState { + // 额外的设置状态 + /** 菜单是否折叠 */ + collapsed: boolean + /** 设备类型 */ + device: 'desktop' | 'mobile' + /** 当前语言 */ + language: string +} + +// 工作标签页Store状态 +export interface WorkTabState { + /** 标签页列表 */ + tabs: WorkTab[] + /** 当前激活的标签页 */ + activeTab: string + /** 缓存的标签页列表 */ + cachedTabs: string[] +} + +// 菜单Store状态 +export interface MenuState { + /** 菜单列表 */ + menuList: any[] + /** 菜单是否已加载 */ + isLoaded: boolean + /** 菜单是否折叠 */ + collapsed: boolean +} + +// 根Store状态类型 +export interface RootState { + /** 用户状态 */ + user: UserState + /** 设置状态 */ + setting: SettingStoreState + /** 工作标签页状态 */ + workTab: WorkTabState + /** 菜单状态 */ + menu: MenuState +} diff --git a/web/apps/admin/src/utils/constants/index.ts b/web/apps/admin/src/utils/constants/index.ts new file mode 100644 index 0000000..831be29 --- /dev/null +++ b/web/apps/admin/src/utils/constants/index.ts @@ -0,0 +1,8 @@ +/** + * 常量定义相关工具函数统一导出 + * + * @module utils/constants/index + * @author Art Design Pro Team + */ + +export * from './links' diff --git a/web/apps/admin/src/utils/constants/links.ts b/web/apps/admin/src/utils/constants/links.ts new file mode 100644 index 0000000..06d297e --- /dev/null +++ b/web/apps/admin/src/utils/constants/links.ts @@ -0,0 +1,35 @@ +/** + * 网站链接常量配置 + * 集中管理便于维护和更新链接地址 + * + * @module utils/constants/links + * @author Art Design Pro Team + */ +export const WEB_LINKS = { + // Github 主页 + GITHUB_HOME: 'https://github.com/Daymychen/art-design-pro', + + // 项目 Github 主页 + GITHUB: 'https://github.com/Daymychen/art-design-pro', + + // 个人博客 + BLOG: 'https://www.artd.pro', + + // 项目文档 + DOCS: 'https://www.artd.pro/docs/zh/', + + // 精简版本 + LiteVersion: 'https://www.artd.pro/docs/zh/guide/lite-version.html', + + // v2.6.1版本 + OldVersion: 'https://www.artd.pro/v2/', + + // 项目社区 + COMMUNITY: 'https://www.artd.pro/docs/zh/community/communicate.html', + + // 个人 Bilibili 主页 + BILIBILI: 'https://space.bilibili.com/425500936?spm_id_from=333.1007.0.0', + + // 项目介绍 + INTRODUCE: 'https://www.artd.pro/docs/zh/guide/introduce.html' +} diff --git a/web/apps/admin/src/utils/form/index.ts b/web/apps/admin/src/utils/form/index.ts new file mode 100644 index 0000000..ed23a46 --- /dev/null +++ b/web/apps/admin/src/utils/form/index.ts @@ -0,0 +1,12 @@ +/** + * 表单工具函数统一导出 + * + * @module utils/form + * @author Art Design Pro Team + */ + +// 表单验证器 +export * from './validator' + +// 响应式布局 +export * from './responsive' diff --git a/web/apps/admin/src/utils/form/responsive.ts b/web/apps/admin/src/utils/form/responsive.ts new file mode 100644 index 0000000..c11df92 --- /dev/null +++ b/web/apps/admin/src/utils/form/responsive.ts @@ -0,0 +1,122 @@ +/** + * 表单响应式布局工具模块 + * + * 提供表单项在不同屏幕尺寸下的智能布局计算 + * + * ## 主要功能 + * + * - 响应式断点管理(xs/sm/md/lg/xl) + * - 表单列宽自动降级(避免小屏幕压缩) + * - 基于阈值的智能 span 计算 + * - 响应式计算器工厂函数 + * - 可配置的断点规则 + * + * ## 使用场景 + * + * - 表单组件响应式布局 + * - 搜索表单自适应 + * - 移动端表单优化 + * - 多列表单布局 + * + * ## 断点说明(基于 Element Plus Grid 24 栅格系统): + * - xs (手机): < 768px,小于 12 时降级为 24(满宽) + * - sm (平板): ≥ 768px,小于 12 时降级为 12(半宽) + * - md (中等屏幕): ≥ 992px,小于 8 时降级为 8(三分之一宽) + * - lg (大屏幕): ≥ 1200px,直接使用设置的 span + * - xl (超大屏幕): ≥ 1920px,直接使用设置的 span + * + * ## 核心功能 + * + * - calculateResponsiveSpan: 计算响应式列宽 + * - createResponsiveSpanCalculator: 创建 span 计算器(柯里化) + * + * @module utils/form/responsive + * @author Art Design Pro Team + */ + +/** + * 响应式断点类型 + */ +export type ResponsiveBreakpoint = 'xs' | 'sm' | 'md' | 'lg' | 'xl' + +/** + * 断点配置映射 + */ +interface BreakpointConfig { + /** 最小 span 阈值 */ + threshold: number + /** 降级后的 span 值 */ + fallback: number +} + +/** + * 响应式断点配置 + */ +const BREAKPOINT_CONFIG: Record = { + xs: { threshold: 12, fallback: 24 }, // 手机:小于 12 时使用满宽 + sm: { threshold: 12, fallback: 12 }, // 平板:小于 12 时使用半宽 + md: { threshold: 8, fallback: 8 }, // 中等屏幕:小于 8 时使用三分之一宽 + lg: null, // 大屏幕:直接使用设置的 span + xl: null // 超大屏幕:直接使用设置的 span +} + +/** + * 计算响应式列宽 + * + * 根据屏幕尺寸智能降级,避免小屏幕上表单项被压缩过小 + * + * @param itemSpan 表单项自定义的 span 值 + * @param defaultSpan 默认的 span 值 + * @param breakpoint 当前断点 + * @returns 计算后的 span 值 + * + * @example + * ```ts + * // 在 xs 断点下,span 为 6 会降级为 24(满宽) + * calculateResponsiveSpan(6, 6, 'xs') // 24 + * + * // 在 md 断点下,span 为 6 会降级为 8(三分之一宽) + * calculateResponsiveSpan(6, 6, 'md') // 8 + * + * // 在 lg 断点下,直接使用原始 span + * calculateResponsiveSpan(6, 6, 'lg') // 6 + * ``` + */ +export function calculateResponsiveSpan( + itemSpan: number | undefined, + defaultSpan: number, + breakpoint: ResponsiveBreakpoint +): number { + const finalSpan = itemSpan ?? defaultSpan + const config = BREAKPOINT_CONFIG[breakpoint] + + // 如果没有配置(lg/xl),直接返回原始 span + if (!config) { + return finalSpan + } + + // 如果 span 小于阈值,使用降级值 + return finalSpan >= config.threshold ? finalSpan : config.fallback +} + +/** + * 创建响应式 span 计算器 + * + * 返回一个函数,用于计算指定断点下的 span 值 + * + * @param defaultSpan 默认的 span 值 + * @returns span 计算函数 + * + * @example + * ```ts + * const getColSpan = createResponsiveSpanCalculator(6) + * getColSpan(undefined, 'xs') // 24 + * getColSpan(8, 'md') // 8 + * getColSpan(12, 'lg') // 12 + * ``` + */ +export function createResponsiveSpanCalculator(defaultSpan: number) { + return (itemSpan: number | undefined, breakpoint: ResponsiveBreakpoint): number => { + return calculateResponsiveSpan(itemSpan, defaultSpan, breakpoint) + } +} diff --git a/web/apps/admin/src/utils/form/validator.ts b/web/apps/admin/src/utils/form/validator.ts new file mode 100644 index 0000000..3670763 --- /dev/null +++ b/web/apps/admin/src/utils/form/validator.ts @@ -0,0 +1,316 @@ +/** + * 表单验证工具模块 + * + * 提供全面的表单字段验证功能 + * + * ## 主要功能 + * + * - 手机号码验证(中国大陆格式) + * - 固定电话验证(支持区号格式) + * - 用户账号验证(字母开头,支持数字和下划线) + * - 密码强度验证(普通密码、强密码) + * - 密码强度评估(弱、中、强) + * - IPv4 地址验证 + * - 邮箱地址验证(RFC 5322 标准) + * - URL 地址验证 + * - 身份证号码验证(18位,含校验码验证) + * - 银行卡号验证(Luhn 算法) + * - 字符串空格处理 + * + * ## 验证规则 + * + * - 手机号:1开头,第二位3-9,共11位 + * - 账号:字母开头,5-20位,支持字母数字下划线 + * - 普通密码:6-20位,必须包含字母和数字 + * - 强密码:8-20位,必须包含大小写字母、数字和特殊字符 + * - 身份证:18位,含出生日期和校验码验证 + * - 银行卡:13-19位,通过 Luhn 算法验证 + * + * @module utils/validation/formValidator + * @author Art Design Pro Team + */ + +/** + * 密码强度级别枚举 + */ +export enum PasswordStrength { + WEAK = '弱', + MEDIUM = '中', + STRONG = '强' +} + +/** + * 去除字符串首尾空格 + * @param value 待处理的字符串 + * @returns 返回去除首尾空格后的字符串 + */ +export function trimSpaces(value: string): string { + if (typeof value !== 'string') { + return '' + } + return value.trim() +} + +/** + * 验证手机号码(中国大陆) + * @param value 手机号码字符串 + * @returns 返回验证结果,true表示格式正确 + */ +export function validatePhone(value: string): boolean { + if (!value || typeof value !== 'string') { + return false + } + + // 中国大陆手机号码:1开头,第二位为3-9,共11位数字 + const phoneRegex = /^1[3-9]\d{9}$/ + return phoneRegex.test(value.trim()) +} + +/** + * 验证固定电话号码(中国大陆) + * @param value 电话号码字符串 + * @returns 返回验证结果,true表示格式正确 + */ +export function validateTelPhone(value: string): boolean { + if (!value || typeof value !== 'string') { + return false + } + + // 支持格式:区号-号码,如:010-12345678、0755-1234567 + const telRegex = /^0\d{2,3}-?\d{7,8}$/ + return telRegex.test(value.trim().replace(/\s+/g, '')) +} + +/** + * 验证用户账号 + * @param value 账号字符串 + * @returns 返回验证结果,true表示格式正确 + * @description 规则:字母开头,5-20位,支持字母、数字、下划线 + */ +export function validateAccount(value: string): boolean { + if (!value || typeof value !== 'string') { + return false + } + + // 字母开头,5-20位,支持字母、数字、下划线 + const accountRegex = /^[a-zA-Z][a-zA-Z0-9_]{4,19}$/ + return accountRegex.test(value.trim()) +} + +/** + * 验证密码 + * @param value 密码字符串 + * @returns 返回验证结果,true表示格式正确 + * @description 规则:6-20位,必须包含字母和数字 + */ +export function validatePassword(value: string): boolean { + if (!value || typeof value !== 'string') { + return false + } + + const trimmedValue = value.trim() + + // 长度检查 + if (trimmedValue.length < 6 || trimmedValue.length > 20) { + return false + } + + // 必须包含字母和数字 + const hasLetter = /[a-zA-Z]/.test(trimmedValue) + const hasNumber = /\d/.test(trimmedValue) + + return hasLetter && hasNumber +} + +/** + * 验证强密码 + * @param value 密码字符串 + * @returns 返回验证结果,true表示格式正确 + * @description 规则:8-20位,必须包含大写字母、小写字母、数字和特殊字符 + */ +export function validateStrongPassword(value: string): boolean { + if (!value || typeof value !== 'string') { + return false + } + + const trimmedValue = value.trim() + + // 长度检查 + if (trimmedValue.length < 8 || trimmedValue.length > 20) { + return false + } + + // 必须包含:大写字母、小写字母、数字、特殊字符 + const hasUpperCase = /[A-Z]/.test(trimmedValue) + const hasLowerCase = /[a-z]/.test(trimmedValue) + const hasNumber = /\d/.test(trimmedValue) + const hasSpecialChar = /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(trimmedValue) + + return hasUpperCase && hasLowerCase && hasNumber && hasSpecialChar +} + +/** + * 获取密码强度 + * @param value 密码字符串 + * @returns 返回密码强度:弱、中、强 + * @description 弱:纯数字/纯字母/纯特殊字符;中:两种组合;强:三种或以上组合 + */ +export function getPasswordStrength(value: string): PasswordStrength { + if (!value || typeof value !== 'string') { + return PasswordStrength.WEAK + } + + const trimmedValue = value.trim() + + if (trimmedValue.length < 6) { + return PasswordStrength.WEAK + } + + const hasUpperCase = /[A-Z]/.test(trimmedValue) + const hasLowerCase = /[a-z]/.test(trimmedValue) + const hasNumber = /\d/.test(trimmedValue) + const hasSpecialChar = /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(trimmedValue) + + const typeCount = [hasUpperCase, hasLowerCase, hasNumber, hasSpecialChar].filter(Boolean).length + + if (typeCount >= 3) { + return PasswordStrength.STRONG + } else if (typeCount >= 2) { + return PasswordStrength.MEDIUM + } else { + return PasswordStrength.WEAK + } +} + +/** + * 验证IPv4地址 + * @param value IP地址字符串 + * @returns 返回验证结果,true表示格式正确 + */ +export function validateIPv4Address(value: string): boolean { + if (!value || typeof value !== 'string') { + return false + } + + const trimmedValue = value.trim() + const ipRegex = /^((25[0-5]|2[0-4]\d|[01]?\d{1,2})\.){3}(25[0-5]|2[0-4]\d|[01]?\d{1,2})$/ + + if (!ipRegex.test(trimmedValue)) { + return false + } + + // 额外检查每个段是否在有效范围内 + const segments = trimmedValue.split('.') + return segments.every((segment) => { + const num = parseInt(segment, 10) + return num >= 0 && num <= 255 + }) +} + +/** + * 验证邮箱地址 + * @param value 邮箱地址字符串 + * @returns 返回验证结果,true表示格式正确 + */ +export function validateEmail(value: string): boolean { + if (!value || typeof value !== 'string') { + return false + } + + const trimmedValue = value.trim() + + // RFC 5322 标准的简化版邮箱正则 + const emailRegex = + /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/ + + return emailRegex.test(trimmedValue) && trimmedValue.length <= 254 +} + +/** + * 验证URL地址 + * @param value URL字符串 + * @returns 返回验证结果,true表示格式正确 + */ +export function validateURL(value: string): boolean { + if (!value || typeof value !== 'string') { + return false + } + + try { + new URL(value.trim()) + return true + } catch { + return false + } +} + +/** + * 验证身份证号码(中国大陆) + * @param value 身份证号码字符串 + * @returns 返回验证结果,true表示格式正确 + */ +export function validateChineseIDCard(value: string): boolean { + if (!value || typeof value !== 'string') { + return false + } + + const trimmedValue = value.trim() + + // 18位身份证号码正则 + const idCardRegex = + /^[1-9]\d{5}(18|19|20)\d{2}((0[1-9])|(1[0-2]))(([0-2][1-9])|10|20|30|31)\d{3}[0-9Xx]$/ + + if (!idCardRegex.test(trimmedValue)) { + return false + } + + // 验证校验码 + const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2] + const checkCodes = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'] + + let sum = 0 + for (let i = 0; i < 17; i++) { + sum += parseInt(trimmedValue[i]) * weights[i] + } + + const checkCode = checkCodes[sum % 11] + return trimmedValue[17].toUpperCase() === checkCode +} + +/** + * 验证银行卡号 + * @param value 银行卡号字符串 + * @returns 返回验证结果,true表示格式正确 + */ +export function validateBankCard(value: string): boolean { + if (!value || typeof value !== 'string') { + return false + } + + const trimmedValue = value.trim().replace(/\s+/g, '') + + // 银行卡号通常为13-19位数字 + if (!/^\d{13,19}$/.test(trimmedValue)) { + return false + } + + // Luhn算法验证 + let sum = 0 + let shouldDouble = false + + for (let i = trimmedValue.length - 1; i >= 0; i--) { + let digit = parseInt(trimmedValue[i]) + + if (shouldDouble) { + digit *= 2 + if (digit > 9) { + digit = (digit % 10) + 1 + } + } + + sum += digit + shouldDouble = !shouldDouble + } + + return sum % 10 === 0 +} diff --git a/web/apps/admin/src/utils/http/error.ts b/web/apps/admin/src/utils/http/error.ts new file mode 100644 index 0000000..0f2c1ae --- /dev/null +++ b/web/apps/admin/src/utils/http/error.ts @@ -0,0 +1,182 @@ +/** + * HTTP 错误处理模块 + * + * 提供统一的 HTTP 请求错误处理机制 + * + * ## 主要功能 + * + * - 自定义 HttpError 错误类,封装错误信息、状态码、时间戳等 + * - 错误拦截和转换,将 Axios 错误转换为标准的 HttpError + * - 错误消息国际化处理,根据状态码返回对应的多语言错误提示 + * - 错误日志记录,便于问题追踪和调试 + * - 错误和成功消息的统一展示 + * - 类型守卫函数,用于判断错误类型 + * + * ## 使用场景 + * + * - HTTP 请求拦截器中统一处理错误 + * - 业务代码中捕获和处理特定错误 + * - 错误日志收集和上报 + * + * @module utils/http/error + * @author Art Design Pro Team + */ +import { AxiosError } from 'axios' +import { ApiStatus } from './status' +import { $t } from '@/locales' + +// 错误响应接口 +export interface ErrorResponse { + /** 错误状态码 */ + code: number + /** 错误消息 */ + msg: string + /** 错误附加数据 */ + data?: unknown +} + +// 错误日志数据接口 +export interface ErrorLogData { + /** 错误状态码 */ + code: number + /** 错误消息 */ + message: string + /** 错误附加数据 */ + data?: unknown + /** 错误发生时间戳 */ + timestamp: string + /** 请求 URL */ + url?: string + /** 请求方法 */ + method?: string + /** 错误堆栈信息 */ + stack?: string +} + +// 自定义 HttpError 类 +export class HttpError extends Error { + public readonly code: number + public readonly data?: unknown + public readonly timestamp: string + public readonly url?: string + public readonly method?: string + + constructor( + message: string, + code: number, + options?: { + data?: unknown + url?: string + method?: string + } + ) { + super(message) + this.name = 'HttpError' + this.code = code + this.data = options?.data + this.timestamp = new Date().toISOString() + this.url = options?.url + this.method = options?.method + } + + public toLogData(): ErrorLogData { + return { + code: this.code, + message: this.message, + data: this.data, + timestamp: this.timestamp, + url: this.url, + method: this.method, + stack: this.stack + } + } +} + +/** + * 获取错误消息 + * @param status 错误状态码 + * @returns 错误消息 + */ +const getErrorMessage = (status: number): string => { + const errorMap: Record = { + [ApiStatus.unauthorized]: 'httpMsg.unauthorized', + [ApiStatus.forbidden]: 'httpMsg.forbidden', + [ApiStatus.notFound]: 'httpMsg.notFound', + [ApiStatus.methodNotAllowed]: 'httpMsg.methodNotAllowed', + [ApiStatus.requestTimeout]: 'httpMsg.requestTimeout', + [ApiStatus.internalServerError]: 'httpMsg.internalServerError', + [ApiStatus.badGateway]: 'httpMsg.badGateway', + [ApiStatus.serviceUnavailable]: 'httpMsg.serviceUnavailable', + [ApiStatus.gatewayTimeout]: 'httpMsg.gatewayTimeout' + } + + return $t(errorMap[status] || 'httpMsg.internalServerError') +} + +/** + * 处理错误 + * @param error 错误对象 + * @returns 错误对象 + */ +export function handleError(error: AxiosError): never { + // 处理取消的请求 + if (error.code === 'ERR_CANCELED') { + console.warn('Request cancelled:', error.message) + throw new HttpError($t('httpMsg.requestCancelled'), ApiStatus.error) + } + + const statusCode = error.response?.status + const errorMessage = error.response?.data?.msg || error.message + const requestConfig = error.config + + // 处理网络错误 + if (!error.response) { + throw new HttpError($t('httpMsg.networkError'), ApiStatus.error, { + url: requestConfig?.url, + method: requestConfig?.method?.toUpperCase() + }) + } + + // 处理 HTTP 状态码错误 + const message = statusCode + ? getErrorMessage(statusCode) + : errorMessage || $t('httpMsg.requestFailed') + throw new HttpError(message, statusCode || ApiStatus.error, { + data: error.response.data, + url: requestConfig?.url, + method: requestConfig?.method?.toUpperCase() + }) +} + +/** + * 显示错误消息 + * @param error 错误对象 + * @param showMessage 是否显示错误消息 + */ +export function showError(error: HttpError, showMessage: boolean = true): void { + if (showMessage) { + ElMessage.error(error.message) + } + // 记录错误日志 + console.error('[HTTP Error]', error.toLogData()) +} + +/** + * 显示成功消息 + * @param message 成功消息 + * @param showMessage 是否显示消息 + */ +export function showSuccess(message: string, showMessage: boolean = true): void { + if (showMessage) { + ElMessage.success(message) + } +} + +/** + * 判断是否为 HttpError 类型 + * @param error 错误对象 + * @returns 是否为 HttpError 类型 + */ +export const isHttpError = (error: unknown): error is HttpError => { + return error instanceof HttpError +} diff --git a/web/apps/admin/src/utils/http/index.ts b/web/apps/admin/src/utils/http/index.ts new file mode 100755 index 0000000..75d151b --- /dev/null +++ b/web/apps/admin/src/utils/http/index.ts @@ -0,0 +1,217 @@ +/** + * HTTP 请求封装模块 + * 基于 Axios 封装的 HTTP 请求工具,提供统一的请求/响应处理 + * + * ## 主要功能 + * + * - 请求/响应拦截器(自动添加 Token、统一错误处理) + * - 401 未授权自动登出(带防抖机制) + * - 请求失败自动重试(可配置) + * - 统一的成功/错误消息提示 + * - 支持 GET/POST/PUT/DELETE 等常用方法 + * + * @module utils/http + * @author Art Design Pro Team + */ + +import axios, { AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios' +import { useUserStore } from '@/store/modules/user' +import { ApiStatus } from './status' +import { HttpError, handleError, showError, showSuccess } from './error' +import { $t } from '@/locales' +import { BaseResponse } from '@/types' + +/** 请求配置常量 */ +const REQUEST_TIMEOUT = 15000 +const LOGOUT_DELAY = 500 +const MAX_RETRIES = 0 +const RETRY_DELAY = 1000 +const UNAUTHORIZED_DEBOUNCE_TIME = 3000 + +/** 401防抖状态 */ +let isUnauthorizedErrorShown = false +let unauthorizedTimer: NodeJS.Timeout | null = null + +/** 扩展 AxiosRequestConfig */ +interface ExtendedAxiosRequestConfig extends AxiosRequestConfig { + showErrorMessage?: boolean + showSuccessMessage?: boolean +} + +const { VITE_API_URL, VITE_WITH_CREDENTIALS } = import.meta.env + +/** Axios实例 */ +const axiosInstance = axios.create({ + timeout: REQUEST_TIMEOUT, + baseURL: VITE_API_URL, + withCredentials: VITE_WITH_CREDENTIALS === 'true', + validateStatus: (status) => status >= 200 && status < 300, + transformResponse: [ + (data, headers) => { + const contentType = headers['content-type'] + if (contentType?.includes('application/json')) { + try { + return JSON.parse(data) + } catch { + return data + } + } + return data + } + ] +}) + +/** 请求拦截器 */ +axiosInstance.interceptors.request.use( + (request: InternalAxiosRequestConfig) => { + const { accessToken } = useUserStore() + if (accessToken) { + const authorization = accessToken.startsWith('Bearer ') ? accessToken : `Bearer ${accessToken}` + request.headers.set('Authorization', authorization) + } + + if (request.data && !(request.data instanceof FormData) && !request.headers['Content-Type']) { + request.headers.set('Content-Type', 'application/json') + request.data = JSON.stringify(request.data) + } + + return request + }, + (error) => { + showError(createHttpError($t('httpMsg.requestConfigError'), ApiStatus.error)) + return Promise.reject(error) + } +) + +/** 响应拦截器 */ +axiosInstance.interceptors.response.use( + (response: AxiosResponse) => { + const { code, msg } = response.data + if (code === ApiStatus.success) return response + if (code === ApiStatus.unauthorized) handleUnauthorizedError(msg) + throw createHttpError(msg || $t('httpMsg.requestFailed'), code) + }, + (error) => { + if (error.response?.status === ApiStatus.unauthorized) handleUnauthorizedError() + return Promise.reject(handleError(error)) + } +) + +/** 统一创建HttpError */ +function createHttpError(message: string, code: number) { + return new HttpError(message, code) +} + +/** 处理401错误(带防抖) */ +function handleUnauthorizedError(message?: string): never { + const error = createHttpError(message || $t('httpMsg.unauthorized'), ApiStatus.unauthorized) + + if (!isUnauthorizedErrorShown) { + isUnauthorizedErrorShown = true + logOut() + + unauthorizedTimer = setTimeout(resetUnauthorizedError, UNAUTHORIZED_DEBOUNCE_TIME) + + showError(error, true) + throw error + } + + throw error +} + +/** 重置401防抖状态 */ +function resetUnauthorizedError() { + isUnauthorizedErrorShown = false + if (unauthorizedTimer) clearTimeout(unauthorizedTimer) + unauthorizedTimer = null +} + +/** 退出登录函数 */ +function logOut() { + setTimeout(() => { + useUserStore().logOut() + }, LOGOUT_DELAY) +} + +/** 是否需要重试 */ +function shouldRetry(statusCode: number) { + return [ + ApiStatus.requestTimeout, + ApiStatus.internalServerError, + ApiStatus.badGateway, + ApiStatus.serviceUnavailable, + ApiStatus.gatewayTimeout + ].includes(statusCode) +} + +/** 请求重试逻辑 */ +async function retryRequest( + config: ExtendedAxiosRequestConfig, + retries: number = MAX_RETRIES +): Promise { + try { + return await request(config) + } catch (error) { + if (retries > 0 && error instanceof HttpError && shouldRetry(error.code)) { + await delay(RETRY_DELAY) + return retryRequest(config, retries - 1) + } + throw error + } +} + +/** 延迟函数 */ +function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +/** 请求函数 */ +async function request(config: ExtendedAxiosRequestConfig): Promise { + // POST | PUT 参数自动填充 + if ( + ['POST', 'PUT'].includes(config.method?.toUpperCase() || '') && + config.params && + !config.data + ) { + config.data = config.params + config.params = undefined + } + + try { + const res = await axiosInstance.request>(config) + + // 显示成功消息 + if (config.showSuccessMessage && res.data.msg) { + showSuccess(res.data.msg) + } + + return res.data.data as T + } catch (error) { + if (error instanceof HttpError && error.code !== ApiStatus.unauthorized) { + const showMsg = config.showErrorMessage !== false + showError(error, showMsg) + } + return Promise.reject(error) + } +} + +/** API方法集合 */ +const api = { + get(config: ExtendedAxiosRequestConfig) { + return retryRequest({ ...config, method: 'GET' }) + }, + post(config: ExtendedAxiosRequestConfig) { + return retryRequest({ ...config, method: 'POST' }) + }, + put(config: ExtendedAxiosRequestConfig) { + return retryRequest({ ...config, method: 'PUT' }) + }, + del(config: ExtendedAxiosRequestConfig) { + return retryRequest({ ...config, method: 'DELETE' }) + }, + request(config: ExtendedAxiosRequestConfig) { + return retryRequest(config) + } +} + +export default api diff --git a/web/apps/admin/src/utils/http/status.ts b/web/apps/admin/src/utils/http/status.ts new file mode 100755 index 0000000..989bb37 --- /dev/null +++ b/web/apps/admin/src/utils/http/status.ts @@ -0,0 +1,18 @@ +/** + * 接口状态码 + */ +export enum ApiStatus { + success = 200, // 成功 + error = 400, // 错误 + unauthorized = 401, // 未授权 + forbidden = 403, // 禁止访问 + notFound = 404, // 未找到 + methodNotAllowed = 405, // 方法不允许 + requestTimeout = 408, // 请求超时 + internalServerError = 500, // 服务器错误 + notImplemented = 501, // 未实现 + badGateway = 502, // 网关错误 + serviceUnavailable = 503, // 服务不可用 + gatewayTimeout = 504, // 网关超时 + httpVersionNotSupported = 505 // HTTP版本不支持 +} diff --git a/web/apps/admin/src/utils/index.ts b/web/apps/admin/src/utils/index.ts new file mode 100644 index 0000000..f1e1b77 --- /dev/null +++ b/web/apps/admin/src/utils/index.ts @@ -0,0 +1,34 @@ +/** + * Utils 工具函数统一导出 + * 提供向后兼容性和便捷导入 + * + * @module utils/index + * @author Art Design Pro Team + */ + +// UI 相关 +export * from './ui' + +// 路由相关 +export * from './router' + +// 路由导航相关 +export * from './navigation' + +// 系统管理相关 +export * from './sys' + +// 常量定义相关 +export * from './constants' + +// 存储相关 +export * from './storage' + +// HTTP 相关 +export * from './http' + +// 表单相关 +export * from './form' + +// socket 相关 +export * from './socket' diff --git a/web/apps/admin/src/utils/navigation/index.ts b/web/apps/admin/src/utils/navigation/index.ts new file mode 100644 index 0000000..0b84e78 --- /dev/null +++ b/web/apps/admin/src/utils/navigation/index.ts @@ -0,0 +1,10 @@ +/** + * 路由和导航相关工具函数统一导出 + * + * @module utils/navigation/index + * @author Art Design Pro Team + */ + +export * from './jump' +export * from './worktab' +export * from './route' diff --git a/web/apps/admin/src/utils/navigation/jump.ts b/web/apps/admin/src/utils/navigation/jump.ts new file mode 100644 index 0000000..7c63cd9 --- /dev/null +++ b/web/apps/admin/src/utils/navigation/jump.ts @@ -0,0 +1,68 @@ +/** + * 导航跳转工具模块 + * + * 提供统一的页面跳转和导航功能 + * + * ## 主要功能 + * + * - 外部链接打开(新窗口) + * - 菜单项跳转处理(支持内部路由和外部链接) + * - iframe 页面跳转支持 + * - 递归查找并跳转到第一个可见的子菜单 + * - 智能判断跳转目标类型(外部链接/内部路由) + * + * @module utils/navigation/jump + * @author Art Design Pro Team + */ +import { AppRouteRecord } from '@/types/router' +import { router } from '@/router' +import { isNavigableMenuItem } from './route' + +// 打开外部链接 +export const openExternalLink = (link: string) => { + window.open(link, '_blank', 'noopener,noreferrer') +} + +/** + * 菜单跳转 + * @param item 菜单项 + * @param jumpToFirst 是否跳转到第一个子菜单 + * @returns + */ +export const handleMenuJump = (item: AppRouteRecord, jumpToFirst: boolean = false) => { + // 处理外部链接 + const { link, isIframe } = item.meta + if (link && !isIframe) { + return openExternalLink(link) + } + + // 如果不需要跳转到第一个子菜单,或者没有子菜单,直接跳转当前路径 + if (!jumpToFirst || !item.children?.length) { + return router.push(item.path) + } + + // 递归查找第一个可导航的叶子节点菜单 + const findFirstLeafMenu = (items: AppRouteRecord[]): AppRouteRecord | undefined => { + for (const child of items) { + if (isNavigableMenuItem(child)) { + return child.children?.length ? findFirstLeafMenu(child.children) || child : child + } + } + return undefined + } + + const firstChild = findFirstLeafMenu(item.children) + + // 如果子菜单都不可见,则回退到父级页面自身。 + if (!firstChild) { + return router.push(item.path) + } + + // 如果第一个子菜单是外部链接则打开新窗口 + if (firstChild.meta?.link) { + return openExternalLink(firstChild.meta.link) + } + + // 跳转到子菜单路径 + router.push(firstChild.path) +} diff --git a/web/apps/admin/src/utils/navigation/route.ts b/web/apps/admin/src/utils/navigation/route.ts new file mode 100644 index 0000000..518bcb9 --- /dev/null +++ b/web/apps/admin/src/utils/navigation/route.ts @@ -0,0 +1,85 @@ +/** + * 路由工具模块 + * + * 提供路由处理和菜单路径相关的工具函数 + * + * ## 主要功能 + * + * - iframe 路由检测,判断是否为外部嵌入页面 + * - 菜单项有效性验证,过滤隐藏和无效菜单 + * - 路径标准化处理,统一路径格式 + * - 递归查找菜单树中第一个有效路径 + * - 支持多级嵌套菜单的路径解析 + * + * ## 使用场景 + * + * - 系统初始化时获取默认跳转路径 + * - 菜单权限过滤后获取首个可访问页面 + * - 路由重定向逻辑处理 + * - iframe 页面特殊处理 + * + * @module utils/navigation/route + * @author Art Design Pro Team + */ + +import { AppRouteRecord } from '@/types' + +// 检查是否为 iframe 路由 +export function isIframe(url: string): boolean { + return url.startsWith('/outside/iframe/') +} + +/** + * 判断菜单项是否可作为默认导航落点 + * 隐藏的全屏页面虽然不展示在菜单中,但仍然可能是合法首页。 + */ +export const isNavigableMenuItem = (menuItem: AppRouteRecord): boolean => { + if (!menuItem.path || !menuItem.path.trim()) { + return false + } + + if (!menuItem.meta?.isHide) { + return true + } + + return menuItem.meta?.isFullPage === true +} + +/** + * 标准化路径格式 + * @param path 路径 + * @returns 标准化后的路径 + */ +const normalizePath = (path: string): string => { + return path.startsWith('/') ? path : `/${path}` +} + +/** + * 递归获取菜单的第一个有效路径 + * @param menuList 菜单列表 + * @returns 第一个有效路径,如果没有找到则返回空字符串 + */ +export const getFirstMenuPath = (menuList: AppRouteRecord[]): string => { + if (!Array.isArray(menuList) || menuList.length === 0) { + return '' + } + + for (const menuItem of menuList) { + if (!isNavigableMenuItem(menuItem)) { + continue + } + + // 如果有子菜单,优先查找子菜单 + if (menuItem.children?.length) { + const childPath = getFirstMenuPath(menuItem.children) + if (childPath) { + return childPath + } + } + + // 返回当前菜单项的标准化路径 + return normalizePath(menuItem.path!) + } + + return '' +} diff --git a/web/apps/admin/src/utils/navigation/worktab.ts b/web/apps/admin/src/utils/navigation/worktab.ts new file mode 100644 index 0000000..6db6a77 --- /dev/null +++ b/web/apps/admin/src/utils/navigation/worktab.ts @@ -0,0 +1,67 @@ +/** + * 工作标签页管理模块 + * + * 提供工作标签页(Worktab)的自动管理功能 + * + * ## 主要功能 + * + * - 根据路由导航自动创建和更新工作标签页 + * - iframe 页面标签页特殊处理 + * - 标签页信息提取(标题、路径、缓存状态等) + * - 固定标签页支持 + * - 根据系统设置控制标签页显示 + * - 首页标签页特殊处理 + * + * ## 使用场景 + * + * - 路由守卫中自动创建标签页 + * - 页面切换时更新标签页状态 + * - 多标签页导航系统 + * + * @module utils/navigation/worktab + * @author Art Design Pro Team + */ +import { useWorktabStore } from '@/store/modules/worktab' +import { RouteLocationNormalized } from 'vue-router' +import { isIframe } from './route' +import { useSettingStore } from '@/store/modules/setting' +import { IframeRouteManager } from '@/router/core' +import { useCommon } from '@/hooks/core/useCommon' + +/** + * 根据当前路由信息设置工作标签页(worktab) + * @param to 当前路由对象 + */ +export const setWorktab = (to: RouteLocationNormalized): void => { + const worktabStore = useWorktabStore() + const { meta, path, name, params, query } = to + if (!meta.isHideTab) { + // 如果是 iframe 页面,则特殊处理工作标签页 + if (isIframe(path)) { + const iframeRoute = IframeRouteManager.getInstance().findByPath(to.path) + + if (iframeRoute?.meta) { + worktabStore.openTab({ + title: iframeRoute.meta.title, + icon: meta.icon as string, + path, + name: name as string, + keepAlive: meta.keepAlive as boolean, + params, + query + }) + } + } else if (useSettingStore().showWorkTab || path === useCommon().homePath.value) { + worktabStore.openTab({ + title: meta.title as string, + icon: meta.icon as string, + path, + name: name as string, + keepAlive: meta.keepAlive as boolean, + params, + query, + fixedTab: meta.fixedTab as boolean + }) + } + } +} diff --git a/web/apps/admin/src/utils/router.ts b/web/apps/admin/src/utils/router.ts new file mode 100644 index 0000000..8c838ff --- /dev/null +++ b/web/apps/admin/src/utils/router.ts @@ -0,0 +1,61 @@ +/** + * 路由工具函数 + * + * 提供路由相关的工具函数 + * + * @module utils/router + */ +import { RouteLocationNormalized, RouteRecordRaw } from 'vue-router' +import AppConfig from '@/config' +import NProgress from 'nprogress' +import 'nprogress/nprogress.css' +import i18n, { $t } from '@/locales' + +/** 扩展的路由配置类型 */ +export type AppRouteRecordRaw = RouteRecordRaw & { + hidden?: boolean +} + +/** 顶部进度条配置 */ +export const configureNProgress = () => { + NProgress.configure({ + easing: 'ease', + speed: 600, + showSpinner: false, + parent: 'body' + }) +} + +/** + * 设置页面标题,根据路由元信息和系统信息拼接标题 + * @param to 当前路由对象 + */ +export const setPageTitle = (to: RouteLocationNormalized): void => { + const { title } = to.meta + if (title) { + setTimeout(() => { + document.title = `${formatMenuTitle(String(title))} - ${AppConfig.systemInfo.name}` + }, 150) + } +} + +/** + * 格式化菜单标题 + * @param title 菜单标题,可以是 i18n 的 key,也可以是字符串 + * @returns 格式化后的菜单标题 + */ +export const formatMenuTitle = (title: string): string => { + if (title) { + if (title.startsWith('menus.')) { + // 使用 te() 方法检查翻译键值是否存在,避免控制台警告 + if (i18n.global.te(title)) { + return $t(title) + } else { + // 如果翻译不存在,返回键值的最后部分作为fallback + return title.split('.').pop() || title + } + } + return title + } + return '' +} diff --git a/web/apps/admin/src/utils/socket/index.ts b/web/apps/admin/src/utils/socket/index.ts new file mode 100644 index 0000000..ac80da1 --- /dev/null +++ b/web/apps/admin/src/utils/socket/index.ts @@ -0,0 +1,423 @@ +interface WebSocketOptions { + url?: string + messageHandler: (event: MessageEvent) => void + reconnectInterval?: number // 重连间隔(ms) + heartbeatInterval?: number // 心跳检测间隔(ms) + pingInterval?: number // 发送ping间隔(ms) + reconnectTimeout?: number // 重连超时时间(ms) + maxReconnectAttempts?: number // 最大重连次数 + connectionTimeout?: number // 连接建立超时时间(ms) +} + +export default class WebSocketClient { + private static instance: WebSocketClient | null = null + private ws: WebSocket | null = null + private url: string + private messageHandler: (event: MessageEvent) => void + private reconnectInterval: number + private heartbeatInterval: number + private pingInterval: number + private reconnectTimeout: number + private maxReconnectAttempts: number + private connectionTimeout: number + private reconnectAttempts: number = 0 // 当前重连次数 + + // 消息队列 - 缓存连接建立前的消息 + private messageQueue: Array = [] + + // 定时器 + private detectionTimer: NodeJS.Timeout | null = null + private timeoutTimer: NodeJS.Timeout | null = null + private reconnectTimer: NodeJS.Timeout | null = null + private pingTimer: NodeJS.Timeout | null = null + private connectionTimer: NodeJS.Timeout | null = null // 连接超时定时器 + + // 状态标识 + private isConnected: boolean = false + private isConnecting: boolean = false // 是否正在连接中 + private stopReconnect: boolean = false + private isReconnecting: boolean = false + + private constructor(options: WebSocketOptions) { + this.url = options.url || (process.env.VUE_APP_LOGIN_WEBSOCKET as string) + this.messageHandler = options.messageHandler + this.reconnectInterval = options.reconnectInterval ?? 20 * 1000 // 默认20秒 + this.heartbeatInterval = options.heartbeatInterval ?? 5 * 1000 // 默认5秒 + this.pingInterval = options.pingInterval ?? 10 * 1000 // 默认10秒 + this.reconnectTimeout = options.reconnectTimeout ?? 30 * 1000 // 默认30秒 + this.maxReconnectAttempts = options.maxReconnectAttempts ?? 10 // 默认最多重连10次 + this.connectionTimeout = options.connectionTimeout ?? 10 * 1000 // 连接超时10秒 + } + + // 单例模式获取实例 + static getInstance(options: WebSocketOptions): WebSocketClient { + if (!WebSocketClient.instance) { + WebSocketClient.instance = new WebSocketClient(options) + } else { + // 更新消息处理器 + WebSocketClient.instance.messageHandler = options.messageHandler + // 如果提供了新的URL,则更新并重新连接 + if (options.url && WebSocketClient.instance.url !== options.url) { + WebSocketClient.instance.url = options.url + WebSocketClient.instance.reconnectAttempts = 0 + WebSocketClient.instance.init() + } + } + return WebSocketClient.instance + } + + // 初始化连接 + init(): void { + this.connect(true) + } + + private connect(resetReconnectAttempts: boolean = false): void { + // 如果正在连接中,不重复连接 + if (this.isConnecting) { + console.log('正在建立WebSocket连接中...') + return + } + + // 如果已连接,不重复连接 + if (this.ws?.readyState === WebSocket.OPEN) { + console.warn('WebSocket连接已存在') + this.flushMessageQueue() // 确保队列中的消息被发送 + return + } + + try { + this.isConnecting = true + this.stopReconnect = false + if (resetReconnectAttempts) { + this.reconnectAttempts = 0 + this.isReconnecting = false + this.clearTimer('reconnectTimer') + } + this.ws = new WebSocket(this.url) + + // 设置连接超时检测 + this.clearTimer('connectionTimer') + this.connectionTimer = setTimeout(() => { + console.error(`WebSocket连接超时 (${this.connectionTimeout}ms):${this.url}`) + this.handleConnectionTimeout() + }, this.connectionTimeout) + + this.ws.onopen = (event) => this.handleOpen(event) + this.ws.onmessage = (event) => this.handleMessage(event) + this.ws.onclose = (event) => this.handleClose(event) + this.ws.onerror = (event) => this.handleError(event) + } catch (error) { + console.error('WebSocket初始化失败:', error) + this.isConnecting = false + this.reconnect() + } + } + + // 处理连接超时 + private handleConnectionTimeout(): void { + if (this.ws?.readyState !== WebSocket.OPEN) { + console.error('WebSocket连接超时,强制关闭连接') + this.ws?.close(1000, 'Connection timeout') + this.isConnecting = false + this.reconnect() + } + } + + // 关闭连接 + close(force?: boolean): void { + this.clearAllTimers() + this.stopReconnect = true + this.isReconnecting = false + this.isConnecting = false + + if (this.ws) { + // 1000 表示正常关闭 + this.ws.close(force ? 1001 : 1000, force ? 'Force closed' : 'Normal close') + this.ws = null + } + + this.isConnected = false + } + + // 发送消息 - 增加消息队列 + send(data: string | ArrayBufferLike | Blob | ArrayBufferView, immediate: boolean = false): void { + // 如果要求立即发送且未连接,则直接报错 + if (immediate && (!this.ws || this.ws.readyState !== WebSocket.OPEN)) { + console.error('WebSocket未连接,无法立即发送消息') + return + } + + // 如果未连接且不要求立即发送,则加入消息队列 + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + console.log('WebSocket未连接,消息已加入队列等待发送') + this.messageQueue.push(data) + // 如果未在重连中,则尝试重连 + if (!this.isConnecting && !this.stopReconnect) { + this.init() + } + return + } + + try { + this.ws.send(data) + } catch (error) { + console.error('WebSocket发送消息失败:', error) + // 发送失败时将消息加入队列,等待重连后重试 + this.messageQueue.push(data) + this.reconnect() + } + } + + // 发送队列中的消息 + private flushMessageQueue(): void { + if (this.messageQueue.length > 0 && this.ws?.readyState === WebSocket.OPEN) { + console.log(`发送队列中的${this.messageQueue.length}条消息`) + while (this.messageQueue.length > 0) { + const data = this.messageQueue.shift() + if (data) { + try { + this.ws?.send(data) + } catch (error) { + console.error('发送队列消息失败:', error) + // 如果发送失败,将消息放回队列头部 + if (data) this.messageQueue.unshift(data) + break + } + } + } + } + } + + // 处理连接打开 + private handleOpen(event: Event): void { + console.log('WebSocket连接成功', event) + this.clearTimer('connectionTimer') // 清除连接超时定时器 + this.isConnected = true + this.isConnecting = false + this.isReconnecting = false + this.stopReconnect = false + this.reconnectAttempts = 0 // 重置重连次数 + this.startHeartbeat() + this.startPing() + this.flushMessageQueue() // 发送队列中的消息 + } + + // 处理收到的消息 + private handleMessage(event: MessageEvent): void { + console.log('收到WebSocket消息:', event) + this.resetHeartbeat() + this.messageHandler(event) + } + + // 处理连接关闭 + private handleClose(event: CloseEvent): void { + console.log( + `WebSocket断开: 代码=${event.code}, 原因=${event.reason}, 干净关闭=${event.wasClean}` + ) + + // 1000 是正常关闭代码 + const isNormalClose = event.code === 1000 + + this.isConnected = false + this.isConnecting = false + this.clearConnectionTimers() + this.ws = null + + if (!this.stopReconnect && !isNormalClose) { + this.reconnect() + } + } + + // 处理错误 - 增加详细错误信息 + private handleError(event: Event): void { + console.error('WebSocket连接错误:') + console.error('错误事件:', event) + console.error( + '当前连接状态:', + this.ws?.readyState ? this.getReadyStateText(this.ws.readyState) : '未初始化' + ) + + this.isConnected = false + this.isConnecting = false + + // 只有在未停止重连的情况下才尝试重连 + if (!this.stopReconnect) { + this.reconnect() + } + } + + private closeCurrentSocketForReconnect(): void { + this.clearConnectionTimers() + this.isConnected = false + this.isConnecting = false + + if (this.ws) { + this.ws.onopen = null + this.ws.onmessage = null + this.ws.onclose = null + this.ws.onerror = null + + if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) { + this.ws.close(1001, 'Reconnect') + } + + this.ws = null + } + } + + // 转换连接状态为文本描述 + private getReadyStateText(state: number): string { + switch (state) { + case WebSocket.CONNECTING: + return 'CONNECTING (0) - 正在连接' + case WebSocket.OPEN: + return 'OPEN (1) - 已连接' + case WebSocket.CLOSING: + return 'CLOSING (2) - 正在关闭' + case WebSocket.CLOSED: + return 'CLOSED (3) - 已关闭' + default: + return `未知状态 (${state})` + } + } + + // 开始心跳检测 + private startHeartbeat(): void { + this.clearTimer('detectionTimer') + this.clearTimer('timeoutTimer') + + this.detectionTimer = setTimeout(() => { + this.isConnected = this.ws?.readyState === WebSocket.OPEN + + if (!this.isConnected) { + console.warn('WebSocket心跳检测失败,尝试重连') + this.reconnect() + + this.timeoutTimer = setTimeout(() => { + console.warn('WebSocket重连超时') + this.close() + }, this.reconnectTimeout) + } + }, this.heartbeatInterval) + } + + // 重置心跳检测 + private resetHeartbeat(): void { + this.clearTimer('detectionTimer') + this.clearTimer('timeoutTimer') + this.startHeartbeat() + } + + // 开始发送ping消息 + private startPing(): void { + this.clearTimer('pingTimer') + + this.pingTimer = setInterval(() => { + if (this.ws?.readyState !== WebSocket.OPEN) { + console.warn('WebSocket未连接,停止发送ping') + this.clearTimer('pingTimer') + this.reconnect() + return + } + + try { + this.ws.send('ping') + console.log('发送ping消息') + } catch (error) { + console.error('发送ping消息失败:', error) + this.clearTimer('pingTimer') + this.reconnect() + } + }, this.pingInterval) + } + + // 重连 - 增加重连次数限制 + private reconnect(): void { + if (this.stopReconnect || this.isConnecting || this.reconnectInterval <= 0) { + return + } + + // 检查是否超过最大重连次数 + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + console.error(`已达到最大重连次数(${this.maxReconnectAttempts}),停止重连`) + this.close(true) + return + } + + this.reconnectAttempts++ + this.isReconnecting = true + this.closeCurrentSocketForReconnect() + + const delay = this.calculateReconnectDelay() + console.log( + `将在${delay / 1000}秒后尝试重新连接(第${this.reconnectAttempts}/${this.maxReconnectAttempts}次)` + ) + + this.clearTimer('reconnectTimer') + this.reconnectTimer = setTimeout(() => { + console.log(`尝试重新连接WebSocket(第${this.reconnectAttempts}次)`) + this.connect(false) + }, delay) + } + + // 计算重连延迟 - 指数退避策略 + private calculateReconnectDelay(): number { + // 基础延迟 + 随机值,避免多个客户端同时重连 + const jitter = Math.random() * 1000 // 0-1秒的随机延迟 + const baseDelay = Math.min( + this.reconnectInterval * Math.pow(1.5, this.reconnectAttempts - 1), + this.reconnectInterval * 5 + ) + return baseDelay + jitter + } + + // 清除指定定时器 + private clearTimer( + timerName: + | 'detectionTimer' + | 'timeoutTimer' + | 'reconnectTimer' + | 'pingTimer' + | 'connectionTimer' + ): void { + if (this[timerName]) { + clearTimeout(this[timerName] as NodeJS.Timeout) + this[timerName] = null + } + } + + // 清除所有定时器 + private clearAllTimers(): void { + this.clearConnectionTimers() + this.clearTimer('reconnectTimer') + } + + private clearConnectionTimers(): void { + this.clearTimer('detectionTimer') + this.clearTimer('timeoutTimer') + this.clearTimer('pingTimer') + this.clearTimer('connectionTimer') + } + + // 获取当前连接状态 + get isWebSocketConnected(): boolean { + return this.isConnected + } + + // 获取当前连接状态文本 + get connectionStatusText(): string { + if (this.isConnecting) return '正在连接' + if (this.isConnected) return '已连接' + if (this.isReconnecting && this.reconnectAttempts > 0) + return `重连中(${this.reconnectAttempts}/${this.maxReconnectAttempts})` + return '已断开' + } + + // 销毁实例 + static destroyInstance(): void { + if (WebSocketClient.instance) { + WebSocketClient.instance.close() + WebSocketClient.instance = null + } + } +} diff --git a/web/apps/admin/src/utils/storage/index.ts b/web/apps/admin/src/utils/storage/index.ts new file mode 100644 index 0000000..a4366f0 --- /dev/null +++ b/web/apps/admin/src/utils/storage/index.ts @@ -0,0 +1,7 @@ +/** + * 存储相关工具函数统一导出 + */ + +export * from './storage' +export * from './storage-config' +export * from './storage-key-manager' diff --git a/web/apps/admin/src/utils/storage/storage-config.ts b/web/apps/admin/src/utils/storage/storage-config.ts new file mode 100644 index 0000000..c53b489 --- /dev/null +++ b/web/apps/admin/src/utils/storage/storage-config.ts @@ -0,0 +1,125 @@ +/** + * 存储配置管理模块 + * + * 提供统一的本地存储配置和工具方法 + * + * ## 主要功能 + * + * - 版本化存储键管理,支持多版本数据隔离 + * - 存储键名生成和解析(带版本前缀) + * - 版本号提取和验证 + * - 存储键匹配的正则表达式生成 + * - 旧版本存储键兼容处理 + * - 升级和登出延迟配置 + * - 主题存储键配置 + * + * ## 使用场景 + * + * - Pinia Store 持久化存储 + * - 应用版本升级时的数据迁移 + * - 多版本数据清理 + * - 存储键的统一管理和规范 + * + * 存储键格式:sys-v{version}-{storeId} + * 例如:sys-v1.0.0-user, sys-v1.0.0-setting + * + * @module utils/storage/storage-config + * @author Art Design Pro Team + */ +export class StorageConfig { + /** 当前应用版本 */ + static readonly CURRENT_VERSION = __APP_VERSION__ + + /** 存储键前缀 */ + static readonly STORAGE_PREFIX = 'sys-v' + + /** 版本键名 */ + static readonly VERSION_KEY = 'sys-version' + + /** 主题键名(index.html中使用了,如果修改,需要同步修改) */ + static readonly THEME_KEY = 'sys-theme' + + /** 上次登录用户ID键名(用于判断是否为同一用户登录) */ + static readonly LAST_USER_ID_KEY = 'sys-last-user-id' + + /** 响应式布局切换时暂存桌面端菜单类型 */ + static readonly RESPONSIVE_MENU_TYPE_KEY = 'sys-responsive-menu-type' + + /** 跳过升级检查的版本 */ + static readonly SKIP_UPGRADE_VERSION = '1.0.0' + + /** 升级处理延迟时间(毫秒) */ + static readonly UPGRADE_DELAY = 1000 + + /** 登出延迟时间(毫秒) */ + static readonly LOGOUT_DELAY = 1000 + + /** + * 生成版本化的存储键名 + * @param storeId 存储ID + * @param version 版本号,默认使用当前版本 + */ + static generateStorageKey(storeId: string, version: string = this.CURRENT_VERSION): string { + return `${this.STORAGE_PREFIX}${version}-${storeId}` + } + + /** + * 生成旧版本的存储键名(不带分隔符) + * @param version 版本号,默认使用当前版本 + */ + static generateLegacyKey(version: string = this.CURRENT_VERSION): string { + return `${this.STORAGE_PREFIX}${version}` + } + + /** + * 创建存储键匹配的正则表达式 + * @param storeId 存储ID + */ + static createKeyPattern(storeId: string): RegExp { + return new RegExp(`^${this.STORAGE_PREFIX}[^-]+-${storeId}$`) + } + + /** + * 创建当前版本存储键匹配的正则表达式 + */ + static createCurrentVersionPattern(): RegExp { + return new RegExp(`^${this.STORAGE_PREFIX}${this.CURRENT_VERSION}-`) + } + + /** + * 创建任意版本存储键匹配的正则表达式 + */ + static createVersionPattern(): RegExp { + return new RegExp(`^${this.STORAGE_PREFIX}`) + } + + /** + * 检查是否为当前版本的键 + */ + static isCurrentVersionKey(key: string): boolean { + return key.startsWith(`${this.STORAGE_PREFIX}${this.CURRENT_VERSION}`) + } + + /** + * 检查是否为版本化的键 + */ + static isVersionedKey(key: string): boolean { + return key.startsWith(this.STORAGE_PREFIX) + } + + /** + * 从存储键中提取版本号 + */ + static extractVersionFromKey(key: string): string | null { + const match = key.match(new RegExp(`^${this.STORAGE_PREFIX}([^-]+)`)) + return match ? match[1] : null + } + + /** + * 从存储键中提取存储ID + */ + static extractStoreIdFromKey(key: string): string | null { + const match = key.match(new RegExp(`^${this.STORAGE_PREFIX}[^-]+-(.+)$`)) + return match ? match[1] : null + } +} diff --git a/web/apps/admin/src/utils/storage/storage-key-manager.ts b/web/apps/admin/src/utils/storage/storage-key-manager.ts new file mode 100644 index 0000000..ba14f65 --- /dev/null +++ b/web/apps/admin/src/utils/storage/storage-key-manager.ts @@ -0,0 +1,97 @@ +/** + * 存储键名管理器模块 + * + * 提供智能的版本化存储键管理和数据迁移功能 + * + * ## 主要功能 + * + * - 自动生成当前版本的存储键名 + * - 检测当前版本数据是否存在 + * - 查找其他版本的同名存储数据 + * - 自动将旧版本数据迁移到当前版本 + * - 数据迁移日志记录 + * - 迁移失败的错误处理 + * + * ## 使用场景 + * + * - Pinia Store 持久化插件中获取存储键 + * - 应用版本升级时自动迁移用户数据 + * - 避免版本升级导致的数据丢失 + * - 实现平滑的版本过渡 + * + * ## 工作流程 + * + * 1. 优先使用当前版本的存储键 + * 2. 如果当前版本无数据,查找其他版本的同名数据 + * 3. 找到旧版本数据后自动迁移到当前版本 + * 4. 返回当前版本的存储键供使用 + * + * @module utils/storage/storage-key-manager + * @author Art Design Pro Team + */ +import { StorageConfig } from '@/utils/storage' + +/** + * 存储键名管理器 + * 负责处理版本化的存储键名生成和数据迁移 + */ +export class StorageKeyManager { + /** + * 获取当前版本的存储键名 + */ + private getCurrentVersionKey(storeId: string): string { + return StorageConfig.generateStorageKey(storeId) + } + + /** + * 检查当前版本的数据是否存在 + */ + private hasCurrentVersionData(key: string): boolean { + return localStorage.getItem(key) !== null + } + + /** + * 查找其他版本的同名存储键 + */ + private findExistingKey(storeId: string): string | null { + const storageKeys = Object.keys(localStorage) + const pattern = StorageConfig.createKeyPattern(storeId) + + return storageKeys.find((key) => pattern.test(key) && localStorage.getItem(key)) || null + } + + /** + * 将数据从旧版本迁移到当前版本 + */ + private migrateData(fromKey: string, toKey: string): void { + try { + const existingData = localStorage.getItem(fromKey) + if (existingData) { + localStorage.setItem(toKey, existingData) + console.info(`[Storage] 已迁移数据: ${fromKey} → ${toKey}`) + } + } catch (error) { + console.warn(`[Storage] 数据迁移失败: ${fromKey}`, error) + } + } + + /** + * 获取持久化存储的键名(支持自动数据迁移) + */ + getStorageKey(storeId: string): string { + const currentKey = this.getCurrentVersionKey(storeId) + + // 优先使用当前版本的数据 + if (this.hasCurrentVersionData(currentKey)) { + return currentKey + } + + // 查找并迁移其他版本的数据 + const existingKey = this.findExistingKey(storeId) + if (existingKey) { + this.migrateData(existingKey, currentKey) + } + + return currentKey + } +} diff --git a/web/apps/admin/src/utils/storage/storage.ts b/web/apps/admin/src/utils/storage/storage.ts new file mode 100644 index 0000000..67b9e9e --- /dev/null +++ b/web/apps/admin/src/utils/storage/storage.ts @@ -0,0 +1,250 @@ +/** + * 存储兼容性管理模块 + * + * 提供完整的本地存储兼容性检查和数据验证功能 + * + * 主要功能 + * + * - 多版本存储数据检测和验证 + * - 新旧存储格式兼容处理 + * - 存储数据完整性校验 + * - 存储异常自动恢复(清理+登出) + * - 登录状态验证 + * - 存储为空检测 + * - 版本号管理 + * + * ## 使用场景 + * + * - 应用启动时检查存储数据有效性 + * - 路由守卫中验证登录状态 + * - 版本升级时的数据兼容性检查 + * - 存储异常时的自动恢复 + * - 防止因存储数据损坏导致的系统异常 + * + * ## 工作流程 + * + * 1. 优先检查当前版本的存储数据 + * 2. 检查其他版本的存储数据 + * 3. 兼容旧格式的存储数据 + * 4. 验证数据完整性 + * 5. 异常时提示用户并执行登出 + * + * @module utils/storage/storage + * @author Art Design Pro Team + */ +import { router } from '@/router' +import { useUserStore } from '@/store/modules/user' +import { StorageConfig } from '@/utils/storage/storage-config' + +/** + * 存储兼容性管理器 + * 负责处理不同版本间的存储兼容性检查和数据验证 + */ +class StorageCompatibilityManager { + /** + * 获取系统版本号 + */ + getSystemVersion(): string | null { + return localStorage.getItem(StorageConfig.VERSION_KEY) + } + + /** + * 获取系统存储数据(兼容旧格式) + */ + getSystemStorage(): any { + const version = this.getSystemVersion() || StorageConfig.CURRENT_VERSION + const legacyKey = StorageConfig.generateLegacyKey(version) + const data = localStorage.getItem(legacyKey) + return data ? JSON.parse(data) : null + } + + /** + * 检查当前版本是否有存储数据 + */ + private hasCurrentVersionStorage(): boolean { + const storageKeys = Object.keys(localStorage) + const currentVersionPattern = StorageConfig.createCurrentVersionPattern() + + return storageKeys.some( + (key) => currentVersionPattern.test(key) && localStorage.getItem(key) !== null + ) + } + + /** + * 检查是否存在任何版本的存储数据 + */ + private hasAnyVersionStorage(): boolean { + const storageKeys = Object.keys(localStorage) + const versionPattern = StorageConfig.createVersionPattern() + + return storageKeys.some((key) => versionPattern.test(key) && localStorage.getItem(key) !== null) + } + + /** + * 获取旧格式的本地存储数据 + */ + private getLegacyStorageData(): Record { + try { + const systemStorage = this.getSystemStorage() + return systemStorage || {} + } catch (error) { + console.warn('[Storage] 解析旧格式存储数据失败:', error) + return {} + } + } + + /** + * 显示存储错误消息 + */ + private showStorageError(): void { + ElMessage({ + type: 'error', + offset: 40, + duration: 5000, + message: '系统检测到本地数据异常,请重新登录系统恢复使用!' + }) + } + + /** + * 执行系统登出 + */ + private performSystemLogout(): void { + setTimeout(() => { + try { + localStorage.clear() + useUserStore().logOut() + router.push({ name: 'Login' }) + console.info('[Storage] 已执行系统登出') + } catch (error) { + console.error('[Storage] 系统登出失败:', error) + } + }, StorageConfig.LOGOUT_DELAY) + } + + /** + * 处理存储异常 + */ + private handleStorageError(): void { + this.showStorageError() + this.performSystemLogout() + } + + /** + * 验证存储数据完整性 + * @param requireAuth 是否需要验证登录状态(默认 false) + */ + validateStorageData(requireAuth: boolean = false): boolean { + try { + // 优先检查新版本存储结构 + if (this.hasCurrentVersionStorage()) { + // console.debug('[Storage] 发现当前版本存储数据') + return true + } + + // 检查是否有任何版本的存储数据 + if (this.hasAnyVersionStorage()) { + // console.debug('[Storage] 发现其他版本存储数据,可能需要迁移') + return true + } + + // 检查旧版本存储结构 + const legacyData = this.getLegacyStorageData() + if (Object.keys(legacyData).length === 0) { + // 只有在需要验证登录状态时才执行登出操作 + if (requireAuth) { + console.warn('[Storage] 未发现任何存储数据,需要重新登录') + this.performSystemLogout() + return false + } + // 首次访问或访问静态路由,不需要登出 + // console.debug('[Storage] 未发现存储数据,首次访问或访问静态路由') + return true + } + + console.debug('[Storage] 发现旧版本存储数据') + return true + } catch (error) { + console.error('[Storage] 存储数据验证失败:', error) + // 只有在需要验证登录状态时才处理错误 + if (requireAuth) { + this.handleStorageError() + return false + } + return true + } + } + + /** + * 检查存储是否为空 + */ + isStorageEmpty(): boolean { + // 检查新版本存储结构 + if (this.hasCurrentVersionStorage()) { + return false + } + + // 检查是否有任何版本的存储数据 + if (this.hasAnyVersionStorage()) { + return false + } + + // 检查旧版本存储结构 + const legacyData = this.getLegacyStorageData() + return Object.keys(legacyData).length === 0 + } + + /** + * 检查存储兼容性 + * @param requireAuth 是否需要验证登录状态(默认 false) + */ + checkCompatibility(requireAuth: boolean = false): boolean { + try { + const isValid = this.validateStorageData(requireAuth) + const isEmpty = this.isStorageEmpty() + + if (isValid || isEmpty) { + // console.debug('[Storage] 存储兼容性检查通过') + return true + } + + console.warn('[Storage] 存储兼容性检查失败') + return false + } catch (error) { + console.error('[Storage] 兼容性检查异常:', error) + return false + } + } +} + +// 创建存储兼容性管理器实例 +const storageManager = new StorageCompatibilityManager() + +/** + * 获取系统存储数据 + */ +export function getSystemStorage(): any { + return storageManager.getSystemStorage() +} + +/** + * 获取系统版本号 + */ +export function getSysVersion(): string | null { + return storageManager.getSystemVersion() +} + +/** + * 验证本地存储数据 + * @param requireAuth 是否需要验证登录状态(默认 false) + */ +export function validateStorageData(requireAuth: boolean = false): boolean { + return storageManager.validateStorageData(requireAuth) +} + +/** + * 检查存储兼容性 + * @param requireAuth 是否需要验证登录状态(默认 false) + */ +export function checkStorageCompatibility(requireAuth: boolean = false): boolean { + return storageManager.checkCompatibility(requireAuth) +} diff --git a/web/apps/admin/src/utils/sys/console.ts b/web/apps/admin/src/utils/sys/console.ts new file mode 100644 index 0000000..d631087 --- /dev/null +++ b/web/apps/admin/src/utils/sys/console.ts @@ -0,0 +1,13 @@ +// ANSI 转义码生成网站 https://patorjk.com/software/taag/#p=display&f=Big&t=ABB%0A +const asciiArt = ` +\x1b[32m欢迎使用 Art Design Pro! +\x1b[0m +\x1b[36m哇!你居然在用我的项目~ 好用的话别忘了去 GitHub 点个 ★Star 呀,你的支持就是我更新的超强动力!祝使用体验满分💯 +\x1b[0m +\x1b[33mGitHub: https://github.com/Daymychen/art-design-pro +\x1b[0m +\x1b[31m技术支持(QQ群): 1038930070,和开发者一起交流~ 群里有小伙伴实时答疑,遇到问题不用慌! +\x1b[0m +` + +console.log(asciiArt) diff --git a/web/apps/admin/src/utils/sys/error-handle.ts b/web/apps/admin/src/utils/sys/error-handle.ts new file mode 100644 index 0000000..bc966c9 --- /dev/null +++ b/web/apps/admin/src/utils/sys/error-handle.ts @@ -0,0 +1,143 @@ +/** + * 全局错误处理模块 + * + * 提供统一的错误捕获和处理机制 + * + * ## 主要功能 + * + * - Vue 运行时错误捕获(组件错误、生命周期错误等) + * - 全局脚本错误捕获(语法错误、运行时错误等) + * - Promise 未捕获错误处理(unhandledrejection) + * - 静态资源加载错误监控(图片、脚本、样式等) + * - 错误日志记录和上报 + * - 统一的错误处理入口 + * + * ## 使用场景 + * - 应用启动时安装全局错误处理器 + * - 捕获和记录所有类型的错误 + * - 错误上报到监控平台 + * - 提升应用稳定性和可维护性 + * - 问题排查和调试 + * + * ## 错误类型 + * + * - VueError: Vue 组件相关错误 + * - ScriptError: JavaScript 脚本错误 + * - PromiseError: Promise 未捕获的 rejection + * - ResourceError: 静态资源加载失败 + * + * @module utils/sys/error-handle + * @author Art Design Pro Team + */ +import type { App } from 'vue' + +const IGNORABLE_SCRIPT_ERRORS = [ + 'ResizeObserver loop completed with undelivered notifications.', + 'ResizeObserver loop limit exceeded' +] + +function normalizeErrorMessage(message: Event | string): string { + if (typeof message === 'string') { + return message + } + + if ('message' in message && typeof message.message === 'string') { + return message.message + } + + return '' +} + +function isIgnorableScriptError(message: Event | string, source?: string): boolean { + const normalizedMessage = normalizeErrorMessage(message) + + if (!normalizedMessage) { + return false + } + + if (IGNORABLE_SCRIPT_ERRORS.some((item) => normalizedMessage.includes(item))) { + // 浏览器/扩展在布局抖动时常见的 ResizeObserver 噪声,不作为真实异常处理 + return true + } + + // 浏览器扩展注入脚本偶发的跨域 Script error 也没有排查价值 + if (normalizedMessage === 'Script error.' && source === '') { + return true + } + + return false +} + +/** + * Vue 运行时错误处理 + */ +export function vueErrorHandler(err: unknown, instance: any, info: string) { + console.error('[VueError]', err, info, instance) + // 这里可以上报到服务端,比如: + // reportError({ type: 'vue', err, info }) +} + +/** + * 全局脚本错误处理 + */ +export function scriptErrorHandler( + message: Event | string, + source?: string, + lineno?: number, + colno?: number, + error?: Error +): boolean { + if (isIgnorableScriptError(message, source)) { + return true + } + + console.error('[ScriptError]', { message, source, lineno, colno, error }) + // reportError({ type: 'script', message, source, lineno, colno, error }) + return true // 阻止默认控制台报错,可根据需求改 +} + +/** + * Promise 未捕获错误处理 + */ +export function registerPromiseErrorHandler() { + window.addEventListener('unhandledrejection', (event) => { + console.error('[PromiseError]', event.reason) + // reportError({ type: 'promise', reason: event.reason }) + }) +} + +/** + * 资源加载错误处理 (img, script, css...) + */ +export function registerResourceErrorHandler() { + window.addEventListener( + 'error', + (event: Event) => { + const target = event.target as HTMLElement + if ( + target && + (target.tagName === 'IMG' || target.tagName === 'SCRIPT' || target.tagName === 'LINK') + ) { + console.error('[ResourceError]', { + tagName: target.tagName, + src: + (target as HTMLImageElement).src || + (target as HTMLScriptElement).src || + (target as HTMLLinkElement).href + }) + // reportError({ type: 'resource', target }) + } + }, + true // 捕获阶段才能监听到资源错误 + ) +} + +/** + * 安装统一错误处理 + */ +export function setupErrorHandle(app: App) { + app.config.errorHandler = vueErrorHandler + window.onerror = scriptErrorHandler + registerPromiseErrorHandler() + registerResourceErrorHandler() +} diff --git a/web/apps/admin/src/utils/sys/index.ts b/web/apps/admin/src/utils/sys/index.ts new file mode 100644 index 0000000..a2e0729 --- /dev/null +++ b/web/apps/admin/src/utils/sys/index.ts @@ -0,0 +1,6 @@ +/** + * 系统管理相关工具函数统一导出 + */ + +export * from './upgrade' +export { default as mittBus } from './mittBus' diff --git a/web/apps/admin/src/utils/sys/mittBus.ts b/web/apps/admin/src/utils/sys/mittBus.ts new file mode 100644 index 0000000..22f0108 --- /dev/null +++ b/web/apps/admin/src/utils/sys/mittBus.ts @@ -0,0 +1,63 @@ +/** + * 全局事件总线模块 + * + * 基于 mitt 库实现的类型安全的事件总线 + * + * ## 主要功能 + * + * - 跨组件通信(发布/订阅模式) + * - 类型安全的事件定义和调用 + * - 全局事件管理(烟花效果、设置面板、搜索对话框等) + * - 解耦组件间的直接依赖 + * + * ## 使用场景 + * + * - 跨层级组件通信 + * - 全局功能触发(设置、搜索、聊天、锁屏等) + * - 特效触发(烟花效果) + * - 避免 props 层层传递 + * + * ## 用法示例 + * + * ```typescript + * // 订阅事件 + * mittBus.on('openSetting', () => { ... }) + * + * // 发布事件 + * mittBus.emit('openSetting') + * + * // 带参数的事件 + * mittBus.emit('triggerFireworks', 'image-url') + * ``` + * + * ## 已定义的事件 + * + * - triggerFireworks: 触发烟花效果(可选图片URL) + * - openSetting: 打开设置面板 + * - openSearchDialog: 打开搜索对话框 + * - openChat: 打开聊天窗口 + * - openLockScreen: 打开锁屏 + * + * @module utils/sys/mittBus + * @author Art Design Pro Team + */ +import mitt, { type Emitter } from 'mitt' + +// 定义事件类型映射 +type Events = { + // 烟花效果事件 - 可选的图片URL参数 + triggerFireworks: string | undefined + // 打开设置面板事件 - 无参数 + openSetting: void + // 打开搜索对话框事件 - 无参数 + openSearchDialog: void + // 打开聊天窗口事件 - 无参数 + openChat: void + // 打开锁屏事件 - 无参数 + openLockScreen: void +} + +// 创建类型安全的事件总线实例 +const mittBus: Emitter = mitt() + +export default mittBus diff --git a/web/apps/admin/src/utils/sys/upgrade.ts b/web/apps/admin/src/utils/sys/upgrade.ts new file mode 100644 index 0000000..53d3465 --- /dev/null +++ b/web/apps/admin/src/utils/sys/upgrade.ts @@ -0,0 +1,277 @@ +/** + * 系统版本升级管理模块 + * + * 提供完整的应用版本升级检测和处理功能 + * + * ## 主要功能 + * + * - 版本号比较和升级检测 + * - 首次访问识别和处理 + * - 旧版本数据自动清理 + * - 升级日志展示和通知 + * - 强制重新登录控制(根据升级日志配置) + * - 版本号规范化处理 + * - 旧存储结构迁移和清理 + * - 升级流程延迟执行(确保应用完全加载) + * + * ## 使用场景 + * + * - 应用启动时自动检测版本升级 + * - 版本更新后清理旧数据 + * - 向用户展示版本更新内容 + * - 重大更新时要求用户重新登录 + * - 防止旧版本数据污染新版本 + * + * ## 工作流程 + * + * 1. 检查本地存储的版本号 + * 2. 与当前应用版本对比 + * 3. 查找并清理旧版本数据 + * 4. 展示升级通知(包含更新日志) + * 5. 根据配置决定是否强制重新登录 + * 6. 更新本地版本号 + * + * @module utils/sys/upgrade + * @author Art Design Pro Team + */ +import { upgradeLogList } from '@/mock/upgrade/changeLog' +import { ElNotification } from 'element-plus' +import { useUserStore } from '@/store/modules/user' +import { StorageConfig } from '@/utils/storage/storage-config' + +/** + * 版本管理器 + * 负责处理版本比较、升级检测和数据清理 + */ +class VersionManager { + /** + * 规范化版本号字符串,移除前缀 'v' + */ + private normalizeVersion(version: string): string { + return version.replace(/^v/, '') + } + + /** + * 获取存储的版本号 + */ + private getStoredVersion(): string | null { + return localStorage.getItem(StorageConfig.VERSION_KEY) + } + + /** + * 设置版本号到存储 + */ + private setStoredVersion(version: string): void { + localStorage.setItem(StorageConfig.VERSION_KEY, version) + } + + /** + * 检查是否应该跳过升级处理 + */ + private shouldSkipUpgrade(): boolean { + return StorageConfig.CURRENT_VERSION === StorageConfig.SKIP_UPGRADE_VERSION + } + + /** + * 检查是否为首次访问 + */ + private isFirstVisit(storedVersion: string | null): boolean { + return !storedVersion + } + + /** + * 检查版本是否相同 + */ + private isSameVersion(storedVersion: string): boolean { + return storedVersion === StorageConfig.CURRENT_VERSION + } + + /** + * 查找旧的存储结构 + */ + private findLegacyStorage(): { oldSysKey: string | null; oldVersionKeys: string[] } { + const storageKeys = Object.keys(localStorage) + const currentVersionPrefix = StorageConfig.generateStorageKey('').slice(0, -1) // 移除末尾的 '-' + + // 查找旧的单一存储结构 + const oldSysKey = + storageKeys.find( + (key) => + StorageConfig.isVersionedKey(key) && key !== currentVersionPrefix && !key.includes('-') + ) || null + + // 查找旧版本的分离存储键 + const oldVersionKeys = storageKeys.filter( + (key) => + StorageConfig.isVersionedKey(key) && + !StorageConfig.isCurrentVersionKey(key) && + key.includes('-') + ) + + return { oldSysKey, oldVersionKeys } + } + + /** + * 检查是否需要重新登录 + */ + private shouldRequireReLogin(storedVersion: string): boolean { + const normalizedCurrent = this.normalizeVersion(StorageConfig.CURRENT_VERSION) + const normalizedStored = this.normalizeVersion(storedVersion) + + return upgradeLogList.value.some((item) => { + const itemVersion = this.normalizeVersion(item.version) + return ( + item.requireReLogin && itemVersion > normalizedStored && itemVersion <= normalizedCurrent + ) + }) + } + + /** + * 构建升级通知消息 + */ + private buildUpgradeMessage(requireReLogin: boolean): string { + const { title: content } = upgradeLogList.value[0] + + const messageParts = [ + `

`, + `系统已升级到 ${StorageConfig.CURRENT_VERSION} 版本,此次更新带来了以下改进:`, + `

`, + content + ] + + if (requireReLogin) { + messageParts.push( + `

升级完成,请重新登录后继续使用。

` + ) + } + + return messageParts.join('') + } + + /** + * 显示升级通知 + */ + private showUpgradeNotification(message: string): void { + ElNotification({ + title: '系统升级公告', + message, + duration: 0, + type: 'success', + dangerouslyUseHTMLString: true + }) + } + + /** + * 清理旧版本数据 + */ + private cleanupLegacyData(oldSysKey: string | null, oldVersionKeys: string[]): void { + // 清理旧的单一存储结构 + if (oldSysKey) { + localStorage.removeItem(oldSysKey) + console.info(`[Upgrade] 已清理旧存储: ${oldSysKey}`) + } + + // 清理旧版本的分离存储 + oldVersionKeys.forEach((key) => { + localStorage.removeItem(key) + console.info(`[Upgrade] 已清理旧存储: ${key}`) + }) + } + + /** + * 执行升级后的登出操作 + */ + private performLogout(): void { + try { + useUserStore().logOut() + console.info('[Upgrade] 已执行升级后登出') + } catch (error) { + console.error('[Upgrade] 升级后登出失败:', error) + } + } + + /** + * 执行升级流程 + */ + private async executeUpgrade( + storedVersion: string, + legacyStorage: ReturnType + ): Promise { + try { + if (!upgradeLogList.value.length) { + console.warn('[Upgrade] 升级日志列表为空') + return + } + + const requireReLogin = this.shouldRequireReLogin(storedVersion) + const message = this.buildUpgradeMessage(requireReLogin) + + // 显示升级通知 + this.showUpgradeNotification(message) + + // 更新版本号 + this.setStoredVersion(StorageConfig.CURRENT_VERSION) + + // 清理旧数据 + this.cleanupLegacyData(legacyStorage.oldSysKey, legacyStorage.oldVersionKeys) + + // 执行登出(如果需要) + if (requireReLogin) { + this.performLogout() + } + + console.info(`[Upgrade] 升级完成: ${storedVersion} → ${StorageConfig.CURRENT_VERSION}`) + } catch (error) { + console.error('[Upgrade] 系统升级处理失败:', error) + } + } + + /** + * 系统升级处理主流程 + */ + async processUpgrade(): Promise { + // 跳过特定版本 + if (this.shouldSkipUpgrade()) { + console.debug('[Upgrade] 跳过版本升级检查') + return + } + + const storedVersion = this.getStoredVersion() + + // 首次访问处理 + if (this.isFirstVisit(storedVersion)) { + this.setStoredVersion(StorageConfig.CURRENT_VERSION) + // console.info('[Upgrade] 首次访问,已设置当前版本') + return + } + + // 版本相同,无需升级 + if (this.isSameVersion(storedVersion!)) { + // console.debug('[Upgrade] 版本相同,无需升级') + return + } + + // 检查是否有需要升级的旧数据 + const legacyStorage = this.findLegacyStorage() + if (!legacyStorage.oldSysKey && legacyStorage.oldVersionKeys.length === 0) { + this.setStoredVersion(StorageConfig.CURRENT_VERSION) + console.info('[Upgrade] 无旧数据,已更新版本号') + return + } + + // 延迟执行升级流程,确保应用已完全加载 + setTimeout(() => { + this.executeUpgrade(storedVersion!, legacyStorage) + }, StorageConfig.UPGRADE_DELAY) + } +} + +// 创建版本管理器实例 +const versionManager = new VersionManager() + +/** + * 系统升级处理入口函数 + */ +export async function systemUpgrade(): Promise { + await versionManager.processUpgrade() +} diff --git a/web/apps/admin/src/utils/table/tableCache.ts b/web/apps/admin/src/utils/table/tableCache.ts new file mode 100644 index 0000000..045a7ce --- /dev/null +++ b/web/apps/admin/src/utils/table/tableCache.ts @@ -0,0 +1,266 @@ +/** + * 表格缓存管理模块 + * + * 提供高性能的表格数据缓存机制 + * + * ## 主要功能 + * + * - 基于参数的智能缓存键生成(使用 ohash) + * - LRU(最近最少使用)缓存淘汰策略 + * - 缓存过期时间管理 + * - 缓存大小限制和自动清理 + * - 基于标签的缓存分组管理 + * - 多种缓存失效策略(清空所有、清空当前、清空分页等) + * - 缓存访问统计和命中率分析 + * - 缓存大小估算 + * + * ## 使用场景 + * + * - 表格数据的分页缓存 + * - 减少重复的 API 请求 + * - 提升表格切换和返回的响应速度 + * - 搜索条件变化时的智能缓存管理 + * - 数据更新后的缓存失效处理 + * + * ## 缓存策略 + * + * - CLEAR_ALL: 清空所有缓存(适用于全局数据更新) + * - CLEAR_CURRENT: 仅清空当前查询条件的缓存(适用于单条数据更新) + * - CLEAR_PAGINATION: 清空所有分页缓存但保留不同搜索条件(适用于批量操作) + * - KEEP_ALL: 不清除缓存(适用于只读操作) + * + * @module utils/table/tableCache + * @author Art Design Pro Team + */ +import { hash } from 'ohash' + +// 缓存失效策略枚举 +export enum CacheInvalidationStrategy { + /** 清空所有缓存 */ + CLEAR_ALL = 'clear_all', + /** 仅清空当前查询条件的缓存 */ + CLEAR_CURRENT = 'clear_current', + /** 清空所有分页缓存(保留不同搜索条件的缓存) */ + CLEAR_PAGINATION = 'clear_pagination', + /** 不清除缓存 */ + KEEP_ALL = 'keep_all' +} + +// 通用 API 响应接口(兼容不同的后端响应格式) +export interface ApiResponse { + records?: T[] + data?: T[] + total?: number + current?: number + size?: number + [key: string]: unknown +} + +// 缓存存储接口 +export interface CacheItem { + data: T[] + response: ApiResponse + timestamp: number + params: string + // 缓存标签,用于分组管理 + tags: Set + // 访问次数(用于 LRU 算法) + accessCount: number + // 最后访问时间 + lastAccessTime: number +} + +// 增强的缓存管理类 +export class TableCache { + private cache = new Map>() + private cacheTime: number + private maxSize: number + private enableLog: boolean + + constructor(cacheTime = 5 * 60 * 1000, maxSize = 50, enableLog = false) { + // 默认5分钟,最多50条缓存 + this.cacheTime = cacheTime + this.maxSize = maxSize + this.enableLog = enableLog + } + + // 内部日志工具 + private log(message: string, ...args: any[]) { + if (this.enableLog) { + console.log(`[TableCache] ${message}`, ...args) + } + } + + // 生成稳定的缓存键 + private generateKey(params: unknown): string { + return hash(params) + } + + // 🔧 优化:增强类型安全性 + private generateTags(params: Record): Set { + const tags = new Set() + + // 添加搜索条件标签 + const searchKeys = Object.keys(params).filter( + (key) => + !['current', 'size', 'total'].includes(key) && + params[key] !== undefined && + params[key] !== '' && + params[key] !== null + ) + + if (searchKeys.length > 0) { + const searchTag = searchKeys.map((key) => `${key}:${String(params[key])}`).join('|') + tags.add(`search:${searchTag}`) + } else { + tags.add('search:default') + } + + // 添加分页标签 + tags.add(`pagination:${params.size || 10}`) + // 添加通用分页标签,用于清理所有分页缓存 + tags.add('pagination') + + return tags + } + + // 🔧 优化:LRU 缓存清理 + private evictLRU(): void { + if (this.cache.size <= this.maxSize) return + + // 找到最少使用的缓存项 + let lruKey = '' + let minAccessCount = Infinity + let oldestTime = Infinity + + for (const [key, item] of this.cache.entries()) { + if ( + item.accessCount < minAccessCount || + (item.accessCount === minAccessCount && item.lastAccessTime < oldestTime) + ) { + lruKey = key + minAccessCount = item.accessCount + oldestTime = item.lastAccessTime + } + } + + if (lruKey) { + this.cache.delete(lruKey) + this.log(`LRU 清理缓存: ${lruKey}`) + } + } + + // 设置缓存 + set(params: unknown, data: T[], response: ApiResponse): void { + const key = this.generateKey(params) + const tags = this.generateTags(params as Record) + const now = Date.now() + + // 检查是否需要清理 + this.evictLRU() + + this.cache.set(key, { + data, + response, + timestamp: now, + params: key, + tags, + accessCount: 1, + lastAccessTime: now + }) + } + + // 获取缓存 + get(params: unknown): CacheItem | null { + const key = this.generateKey(params) + const item = this.cache.get(key) + + if (!item) return null + + // 检查是否过期 + if (Date.now() - item.timestamp > this.cacheTime) { + this.cache.delete(key) + return null + } + + // 更新访问统计 + item.accessCount++ + item.lastAccessTime = Date.now() + + return item + } + + // 根据标签清除缓存 + clearByTags(tags: string[]): number { + let clearedCount = 0 + + for (const [key, item] of this.cache.entries()) { + // 检查是否包含任意一个标签 + const hasMatchingTag = tags.some((tag) => + Array.from(item.tags).some((itemTag) => itemTag.includes(tag)) + ) + + if (hasMatchingTag) { + this.cache.delete(key) + clearedCount++ + } + } + + return clearedCount + } + + // 清除当前搜索条件的缓存 + clearCurrentSearch(params: unknown): number { + const key = this.generateKey(params) + const deleted = this.cache.delete(key) + return deleted ? 1 : 0 + } + + // 清除分页缓存 + clearPagination(): number { + return this.clearByTags(['pagination']) + } + + // 清空所有缓存 + clear(): void { + this.cache.clear() + } + + // 获取缓存统计信息 + getStats(): { total: number; size: string; hitRate: string } { + const total = this.cache.size + let totalSize = 0 + let totalAccess = 0 + + for (const item of this.cache.values()) { + // 粗略估算大小(JSON字符串长度) + totalSize += JSON.stringify(item.data).length + totalAccess += item.accessCount + } + + // 转换为人类可读的大小 + const sizeInKB = (totalSize / 1024).toFixed(2) + const avgHits = total > 0 ? (totalAccess / total).toFixed(1) : '0' + + return { + total, + size: `${sizeInKB}KB`, + hitRate: `${avgHits} avg hits` + } + } + + // 清理过期缓存 + cleanupExpired(): number { + let cleanedCount = 0 + const now = Date.now() + + for (const [key, item] of this.cache.entries()) { + if (now - item.timestamp > this.cacheTime) { + this.cache.delete(key) + cleanedCount++ + } + } + + return cleanedCount + } +} diff --git a/web/apps/admin/src/utils/table/tableConfig.ts b/web/apps/admin/src/utils/table/tableConfig.ts new file mode 100644 index 0000000..e464c89 --- /dev/null +++ b/web/apps/admin/src/utils/table/tableConfig.ts @@ -0,0 +1,55 @@ +/** + * 表格全局配置模块 + * + * 提供表格与后端接口的字段映射配置 + * + * ## 主要功能 + * + * - 响应数据字段自动识别和映射 + * - 支持多种常见的后端响应格式 + * - 请求参数字段映射配置 + * - 可扩展的字段配置机制 + * + * ## 使用场景 + * + * - 适配不同后端的分页接口格式 + * - 统一前端表格组件的数据处理 + * - 减少重复的数据转换代码 + * - 支持多个后端服务的接口对接 + * + * ## 配置说明 + * + * - recordFields: 列表数据字段名(按优先级顺序查找) + * - totalFields: 总条数字段名 + * - currentFields: 当前页码字段名 + * - sizeFields: 每页大小字段名 + * - paginationKey: 前端发送请求时使用的分页参数名 + * + * ## 扩展方式 + * + * 如果后端使用其他字段名,可以在对应数组中添加新的字段名 + * 例如:recordFields: ['list', 'data', 'records', 'items', 'yourCustomField'] + * + * @module utils/table/tableConfig + * @author Art Design Pro Team + */ +export const tableConfig = { + // 响应数据字段映射配置,系统会从接口返回数据中按顺序查找这些字段 + // 列表数据 + recordFields: ['list', 'data', 'records', 'items', 'result', 'rows'], + // 总条数 + totalFields: ['total', 'count'], + // 当前页码 + currentFields: ['current', 'page', 'pageNum'], + // 每页大小 + sizeFields: ['size', 'pageSize', 'limit'], + + // 请求参数映射配置,前端发送请求时使用的分页参数名 + // useTable 组合式函数传递分页参数的时候 用 current 跟 size + paginationKey: { + // 当前页码 + current: 'current', + // 每页大小 + size: 'size' + } +} diff --git a/web/apps/admin/src/utils/table/tableUtils.ts b/web/apps/admin/src/utils/table/tableUtils.ts new file mode 100644 index 0000000..3ca9db1 --- /dev/null +++ b/web/apps/admin/src/utils/table/tableUtils.ts @@ -0,0 +1,297 @@ +/** + * 表格工具函数模块 + * + * 提供表格数据处理和请求管理的核心工具函数 + * + * ## 主要功能 + * + * - 多格式 API 响应自动适配和标准化 + * - 表格数据提取和转换 + * - 分页信息自动更新和校验 + * - 智能防抖函数(支持取消和立即执行) + * - 统一的错误处理机制 + * - 嵌套数据结构解析 + * + * ## 使用场景 + * + * - useTable 组合式函数的底层工具 + * - 适配各种后端接口响应格式 + * - 表格数据的标准化处理 + * - 请求防抖和性能优化 + * - 错误统一处理和日志记录 + * + * ## 支持的响应格式 + * + * 1. 直接数组: [item1, item2, ...] + * 2. 标准对象: { records: [], total: 100 } + * 3. 嵌套data: { data: { list: [], total: 100 } } + * 4. 多种字段名: list/data/records/items/result/rows + * + * ## 核心功能 + * + * - defaultResponseAdapter: 智能识别和转换响应格式 + * - extractTableData: 提取表格数据数组 + * - updatePaginationFromResponse: 更新分页信息 + * - createSmartDebounce: 创建可控的防抖函数 + * - createErrorHandler: 生成错误处理器 + * + * @module utils/table/tableUtils + * @author Art Design Pro Team + */ + +import type { ApiResponse } from './tableCache' +import { tableConfig } from './tableConfig' + +// 请求参数基础接口,扩展分页参数 +export interface BaseRequestParams extends Api.Common.PaginationParams { + [key: string]: unknown +} + +// 错误处理接口 +export interface TableError { + code: string + message: string + details?: unknown +} + +// 辅助函数:从对象中提取记录数组 +function extractRecords(obj: Record, fields: string[]): T[] { + for (const field of fields) { + if (field in obj && Array.isArray(obj[field])) { + return obj[field] as T[] + } + } + return [] +} + +// 辅助函数:从对象中提取总数 +function extractTotal(obj: Record, records: unknown[], fields: string[]): number { + for (const field of fields) { + if (field in obj && typeof obj[field] === 'number') { + return obj[field] as number + } + } + return records.length +} + +// 辅助函数:提取分页参数 +function extractPagination( + obj: Record, + data?: Record +): Pick, 'current' | 'size'> | undefined { + const result: Partial, 'current' | 'size'>> = {} + const sources = [obj, data ?? {}] + + const currentFields = tableConfig.currentFields + for (const src of sources) { + for (const field of currentFields) { + if (field in src && typeof src[field] === 'number') { + result.current = src[field] as number + break + } + } + if (result.current !== undefined) break + } + + const sizeFields = tableConfig.sizeFields + for (const src of sources) { + for (const field of sizeFields) { + if (field in src && typeof src[field] === 'number') { + result.size = src[field] as number + break + } + } + if (result.size !== undefined) break + } + + if (result.current === undefined && result.size === undefined) return undefined + return result +} + +/** + * 默认响应适配器 - 支持多种常见的API响应格式 + */ +export const defaultResponseAdapter = (response: unknown): ApiResponse => { + // 定义支持的字段 + const recordFields = tableConfig.recordFields + + if (!response) { + return { records: [], total: 0 } + } + + if (Array.isArray(response)) { + return { records: response, total: response.length } + } + + if (typeof response !== 'object') { + console.warn( + '[tableUtils] 无法识别的响应格式,支持的格式包括: 数组、包含' + + recordFields.join('/') + + '字段的对象、嵌套data对象。当前格式:', + response + ) + return { records: [], total: 0 } + } + + const res = response as Record + let records: T[] = [] + let total = 0 + let pagination: Pick, 'current' | 'size'> | undefined + + // 处理标准格式或直接列表 + records = extractRecords(res, recordFields) + total = extractTotal(res, records, tableConfig.totalFields) + pagination = extractPagination(res) + + // 如果没有找到,检查嵌套data + if (records.length === 0 && 'data' in res && typeof res.data === 'object') { + const data = res.data as Record + records = extractRecords(data, ['list', 'records', 'items']) + total = extractTotal(data, records, tableConfig.totalFields) + pagination = extractPagination(res, data) + + if (Array.isArray(res.data)) { + records = res.data as T[] + total = records.length + } + } + + if (!recordFields.some((field) => field in res) && records.length === 0) { + console.warn('[tableUtils] 无法识别的响应格式') + console.warn('支持的字段包括: ' + recordFields.join('、'), response) + console.warn('扩展字段请到 utils/table/tableConfig 文件配置') + } + + const result: ApiResponse = { records, total } + if (pagination) { + Object.assign(result, pagination) + } + return result +} + +/** + * 从标准化的API响应中提取表格数据 + */ +export const extractTableData = (response: ApiResponse): T[] => { + const data = response.records || response.data || [] + return Array.isArray(data) ? data : [] +} + +/** + * 根据API响应更新分页信息 + */ +export const updatePaginationFromResponse = ( + pagination: Api.Common.PaginationParams, + response: ApiResponse +): void => { + pagination.total = response.total ?? pagination.total ?? 0 + + if (response.current !== undefined) { + pagination.current = response.current + } + + const maxPage = Math.max(1, Math.ceil(pagination.total / (pagination.size || 1))) + if (pagination.current > maxPage) { + pagination.current = maxPage + } +} + +/** + * 创建智能防抖函数 - 支持取消和立即执行 + */ +export const createSmartDebounce = Promise>( + fn: T, + delay: number +): T & { cancel: () => void; flush: () => Promise } => { + let timeoutId: NodeJS.Timeout | null = null + let lastArgs: Parameters | null = null + let lastResolve: ((value: any) => void) | null = null + let lastReject: ((reason: any) => void) | null = null + + const debouncedFn = (...args: Parameters): Promise => { + return new Promise((resolve, reject) => { + if (timeoutId) clearTimeout(timeoutId) + lastArgs = args + lastResolve = resolve + lastReject = reject + timeoutId = setTimeout(async () => { + try { + const result = await fn(...args) + resolve(result) + } catch (error) { + reject(error) + } finally { + timeoutId = null + lastArgs = null + lastResolve = null + lastReject = null + } + }, delay) + }) + } + + debouncedFn.cancel = () => { + if (timeoutId) clearTimeout(timeoutId) + timeoutId = null + lastArgs = null + lastResolve = null + lastReject = null + } + + debouncedFn.flush = async () => { + if (timeoutId && lastArgs && lastResolve && lastReject) { + clearTimeout(timeoutId) + timeoutId = null + const args = lastArgs + const resolve = lastResolve + const reject = lastReject + lastArgs = null + lastResolve = null + lastReject = null + try { + const result = await fn(...args) + resolve(result) + return result + } catch (error) { + reject(error) + throw error + } + } + return Promise.resolve() + } + + return debouncedFn as any +} + +/** + * 生成错误处理函数 + */ +export const createErrorHandler = ( + onError?: (error: TableError) => void, + enableLog: boolean = false +) => { + const logger = { + error: (message: string, ...args: any[]) => { + if (enableLog) console.error(`[useTable] ${message}`, ...args) + } + } + + return (err: unknown, context: string): TableError => { + const tableError: TableError = { + code: 'UNKNOWN_ERROR', + message: '未知错误', + details: err + } + + if (err instanceof Error) { + tableError.message = err.message + tableError.code = err.name + } else if (typeof err === 'string') { + tableError.message = err + } + + logger.error(`${context}:`, err) + onError?.(tableError) + return tableError + } +} diff --git a/web/apps/admin/src/utils/ui/animation.ts b/web/apps/admin/src/utils/ui/animation.ts new file mode 100644 index 0000000..5efd02a --- /dev/null +++ b/web/apps/admin/src/utils/ui/animation.ts @@ -0,0 +1,80 @@ +/** + * 主题动画工具模块 + * + * 提供主题切换的视觉动画效果 + * + * ## 主要功能 + * + * - 基于鼠标点击位置的圆形扩散动画 + * - View Transition API 支持(现代浏览器) + * - 降级处理(不支持动画的浏览器) + * - 暗黑主题切换过渡效果 + * - 页面刷新时的主题过渡优化 + * + * ## 使用场景 + * + * - 明暗主题切换 + * - 提升用户体验的视觉反馈 + * - 页面刷新时的平滑过渡 + * + * ## 技术实现 + * + * - 使用 CSS 变量存储点击位置和半径 + * - 利用 View Transition API 实现流畅动画 + * - 通过 CSS class 控制过渡效果 + * - 自动计算最大扩散半径 + * + * @module utils/theme/animation + * @author Art Design Pro Team + */ +import { useCommon } from '@/hooks/core/useCommon' +import { useTheme } from '@/hooks/core/useTheme' +import { SystemThemeEnum } from '@/enums/appEnum' +import { useSettingStore } from '@/store/modules/setting' +const { LIGHT, DARK } = SystemThemeEnum + +/** + * 主题切换动画 + * @param e 鼠标点击事件 + */ +export const themeAnimation = (e: any) => { + const x = e.clientX + const y = e.clientY + // 计算鼠标点击位置距离视窗的最大圆半径 + const endRadius = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y)) + + // 设置CSS变量 + document.documentElement.style.setProperty('--x', x + 'px') + document.documentElement.style.setProperty('--y', y + 'px') + document.documentElement.style.setProperty('--r', endRadius + 'px') + + if (document.startViewTransition) { + document.startViewTransition(() => toggleTheme()) + } else { + toggleTheme() + } +} + +/** + * 切换主题 + */ +const toggleTheme = () => { + useTheme().switchThemeStyles(useSettingStore().systemThemeType === LIGHT ? DARK : LIGHT) + useCommon().refresh() +} + +/** + * 切换主题过渡效果 + * @param enable 是否启用过渡效果 + */ +export const toggleTransition = (enable: boolean) => { + const body = document.body + + if (enable) { + body.classList.add('theme-change') + } else { + setTimeout(() => { + body.classList.remove('theme-change') + }, 300) + } +} diff --git a/web/apps/admin/src/utils/ui/colors.ts b/web/apps/admin/src/utils/ui/colors.ts new file mode 100755 index 0000000..b4f6b77 --- /dev/null +++ b/web/apps/admin/src/utils/ui/colors.ts @@ -0,0 +1,273 @@ +/** + * 颜色处理工具模块 + * + * 提供完整的颜色格式转换和处理功能 + * + * ## 主要功能 + * + * - Hex 与 RGB/RGBA 格式互转 + * - 颜色混合计算 + * - 颜色变浅/变深处理 + * - Element Plus 主题色自动生成 + * - 颜色格式验证 + * - CSS 变量读取 + * - 暗黑模式颜色适配 + * + * ## 使用场景 + * + * - 主题色动态切换 + * - Element Plus 组件主题定制 + * - 颜色渐变生成 + * - 明暗主题颜色计算 + * - 颜色格式标准化 + * + * ## 核心功能 + * + * - hexToRgba: Hex 转 RGBA(支持透明度) + * - hexToRgb: Hex 转 RGB 数组 + * - rgbToHex: RGB 转 Hex + * - colourBlend: 两种颜色混合 + * - getLightColor: 生成变浅的颜色 + * - getDarkColor: 生成变深的颜色 + * - handleElementThemeColor: 处理 Element Plus 主题色 + * - setElementThemeColor: 设置完整的主题色系统 + * + * ## 支持格式 + * + * - Hex: #FFF, #FFFFFF + * - RGB: rgb(255, 255, 255) + * - RGBA: rgba(255, 255, 255, 0.5) + * + * @module utils/ui/colors + * @author Art Design Pro Team + */ +import { useSettingStore } from '@/store/modules/setting' + +/** + * 颜色转换结果接口 + */ +interface RgbaResult { + red: number + green: number + blue: number + rgba: string +} + +/** + * 获取CSS变量值(别名函数) + * @param name CSS变量名 + * @returns CSS变量值 + */ +export function getCssVar(name: string): string { + return getComputedStyle(document.documentElement).getPropertyValue(name) +} + +/** + * 验证hex颜色格式 + * @param hex hex颜色值 + * @returns 是否为有效的hex颜色 + */ +function isValidHexColor(hex: string): boolean { + const cleanHex = hex.trim().replace(/^#/, '') + return /^[0-9A-Fa-f]{3}$|^[0-9A-Fa-f]{6}$/.test(cleanHex) +} + +/** + * 验证RGB颜色值 + * @param r 红色值 + * @param g 绿色值 + * @param b 蓝色值 + * @returns 是否为有效的RGB值 + */ +function isValidRgbValue(r: number, g: number, b: number): boolean { + const isValid = (value: number) => Number.isInteger(value) && value >= 0 && value <= 255 + return isValid(r) && isValid(g) && isValid(b) +} + +/** + * 将hex颜色转换为RGBA + * @param hex hex颜色值 (支持 #FFF 或 #FFFFFF 格式) + * @param opacity 透明度 (0-1) + * @returns 包含RGB值和RGBA字符串的对象 + */ +export function hexToRgba(hex: string, opacity: number): RgbaResult { + if (!isValidHexColor(hex)) { + throw new Error('Invalid hex color format') + } + + // 移除可能存在的 # 前缀并转换为大写 + let cleanHex = hex.trim().replace(/^#/, '').toUpperCase() + + // 如果是缩写形式(如 FFF),转换为完整形式 + if (cleanHex.length === 3) { + cleanHex = cleanHex + .split('') + .map((char) => char.repeat(2)) + .join('') + } + + // 解析 RGB 值 + const [red, green, blue] = cleanHex.match(/\w\w/g)!.map((x) => parseInt(x, 16)) + + // 确保 opacity 在有效范围内 + const validOpacity = Math.max(0, Math.min(1, opacity)) + + // 构建 RGBA 字符串 + const rgba = `rgba(${red}, ${green}, ${blue}, ${validOpacity.toFixed(2)})` + + return { red, green, blue, rgba } +} + +/** + * 将hex颜色转换为RGB数组 + * @param hexColor hex颜色值 + * @returns RGB数组 [r, g, b] + */ +export function hexToRgb(hexColor: string): number[] { + if (!isValidHexColor(hexColor)) { + ElMessage.warning('输入错误的hex颜色值') + throw new Error('Invalid hex color format') + } + + const cleanHex = hexColor.replace(/^#/, '') + let hex = cleanHex + + // 处理缩写形式 + if (hex.length === 3) { + hex = hex + .split('') + .map((char) => char.repeat(2)) + .join('') + } + + const hexPairs = hex.match(/../g) + if (!hexPairs) { + throw new Error('Invalid hex color format') + } + + return hexPairs.map((hexPair) => parseInt(hexPair, 16)) +} + +/** + * 将RGB颜色转换为hex + * @param r 红色值 (0-255) + * @param g 绿色值 (0-255) + * @param b 蓝色值 (0-255) + * @returns hex颜色值 + */ +export function rgbToHex(r: number, g: number, b: number): string { + if (!isValidRgbValue(r, g, b)) { + ElMessage.warning('输入错误的RGB颜色值') + throw new Error('Invalid RGB color values') + } + + const toHex = (value: number) => { + const hex = value.toString(16) + return hex.length === 1 ? `0${hex}` : hex + } + + return `#${toHex(r)}${toHex(g)}${toHex(b)}` +} + +/** + * 颜色混合 + * @param color1 第一个颜色 + * @param color2 第二个颜色 + * @param ratio 混合比例 (0-1) + * @returns 混合后的颜色 + */ +export function colourBlend(color1: string, color2: string, ratio: number): string { + const validRatio = Math.max(0, Math.min(1, Number(ratio))) + + const rgb1 = hexToRgb(color1) + const rgb2 = hexToRgb(color2) + + const blendedRgb = rgb1.map((value1, index) => { + const value2 = rgb2[index] + return Math.round(value1 * (1 - validRatio) + value2 * validRatio) + }) + + return rgbToHex(blendedRgb[0], blendedRgb[1], blendedRgb[2]) +} + +/** + * 获取变浅的颜色 + * @param color 原始颜色 + * @param level 变浅程度 (0-1) + * @param isDark 是否为暗色主题 + * @returns 变浅后的颜色 + */ +export function getLightColor(color: string, level: number, isDark: boolean = false): string { + if (!isValidHexColor(color)) { + ElMessage.warning('输入错误的hex颜色值') + throw new Error('Invalid hex color format') + } + + if (isDark) { + return getDarkColor(color, level) + } + + const rgb = hexToRgb(color) + const lightRgb = rgb.map((value) => Math.floor((255 - value) * level + value)) + + return rgbToHex(lightRgb[0], lightRgb[1], lightRgb[2]) +} + +/** + * 获取变深的颜色 + * @param color 原始颜色 + * @param level 变深程度 (0-1) + * @returns 变深后的颜色 + */ +export function getDarkColor(color: string, level: number): string { + if (!isValidHexColor(color)) { + ElMessage.warning('输入错误的hex颜色值') + throw new Error('Invalid hex color format') + } + + const rgb = hexToRgb(color) + const darkRgb = rgb.map((value) => Math.floor(value * (1 - level))) + + return rgbToHex(darkRgb[0], darkRgb[1], darkRgb[2]) +} + +/** + * 处理 Element Plus 主题颜色 + * @param theme 主题颜色 + * @param isDark 是否为暗色主题 + */ +export function handleElementThemeColor(theme: string, isDark: boolean = false): void { + document.documentElement.style.setProperty('--el-color-primary', theme) + + for (let i = 1; i <= 9; i++) { + document.documentElement.style.setProperty( + `--el-color-primary-light-${i}`, + getLightColor(theme, i / 10, isDark) + ) + } + + for (let i = 1; i <= 9; i++) { + document.documentElement.style.setProperty( + `--el-color-primary-dark-${i}`, + getDarkColor(theme, i / 10) + ) + } +} + +/** + * 设置 Element Plus 主题颜色 + * @param color 主题颜色 + */ +export function setElementThemeColor(color: string): void { + const mixColor = '#ffffff' + const elStyle = document.documentElement.style + + elStyle.setProperty('--el-color-primary', color) + handleElementThemeColor(color, useSettingStore().isDark) + + // 生成更淡一点的颜色 + for (let i = 1; i < 16; i++) { + const itemColor = colourBlend(color, mixColor, i / 16) + elStyle.setProperty(`--el-color-primary-custom-${i}`, itemColor) + } +} diff --git a/web/apps/admin/src/utils/ui/emojo.ts b/web/apps/admin/src/utils/ui/emojo.ts new file mode 100644 index 0000000..cabad7d --- /dev/null +++ b/web/apps/admin/src/utils/ui/emojo.ts @@ -0,0 +1,24 @@ +/** + * 表情 + * 用于在消息提示的时候显示对应的表情 + * + * 用法 + * ElMessage.success(`${EmojiText[200]} 图片上传成功`) + * ElMessage.error(`${EmojiText[400]} 图片上传失败`) + * ElMessage.error(`${EmojiText[500]} 图片上传失败`) + * + * @module utils/ui/emojo + * @author Art Design Pro Team + */ + +// macos 用户 按 shift + 6 可以唤出更多表情…… +const EmojiText: { [key: string]: string } = { + '0': 'O_O', // 空 + '200': '^_^', // 成功 + '400': 'T_T', // 错误请求 + '500': 'X_X' // 服务器内部错误,无法完成请求 +} + +// const EmojiIcon = ['🟢', '🔴', '🟡 ', '🚀', '✨', '💡', '🛠️', '🔥', '🎉', '🌟', '🌈'] + +export default EmojiText diff --git a/web/apps/admin/src/utils/ui/iconify-loader.ts b/web/apps/admin/src/utils/ui/iconify-loader.ts new file mode 100644 index 0000000..035de16 --- /dev/null +++ b/web/apps/admin/src/utils/ui/iconify-loader.ts @@ -0,0 +1,31 @@ +/** + * 离线图标加载器 + * + * 用于在内网环境下支持 Iconify 图标的离线加载。 + * 通过预加载图标集数据,避免运行时从 CDN 获取图标。 + * + * 使用方式: + * 1. 安装所需图标集:pnpm add -D @iconify-json/[icon-set-name] + * 2. 在此文件中导入并注册图标集 + * 3. 在组件中使用: + * + * @module utils/ui/iconify-loader + * @author Art Design Pro Team + */ + +// import { addCollection } from '@iconify/vue' + +// // 导入离线图标数据 + +// // 系统必要图标库 +// import riIcons from '@iconify-json/ri/icons.json' + +// // 演示图标库(可选,生产环境可移除) +// import svgSpinners from '@iconify-json/svg-spinners/icons.json' +// import lineMd from '@iconify-json/line-md/icons.json' + +// // 注册离线图标集 + +// addCollection(riIcons) +// addCollection(svgSpinners) +// addCollection(lineMd) diff --git a/web/apps/admin/src/utils/ui/index.ts b/web/apps/admin/src/utils/ui/index.ts new file mode 100644 index 0000000..9ca1049 --- /dev/null +++ b/web/apps/admin/src/utils/ui/index.ts @@ -0,0 +1,11 @@ +/** + * UI 相关工具函数统一导出 + * + * @module utils/ui/index + * @author Art Design Pro Team + */ + +export * from './colors' +export * from './loading' +export * from './tabs' +export * from './emojo' diff --git a/web/apps/admin/src/utils/ui/loading.ts b/web/apps/admin/src/utils/ui/loading.ts new file mode 100644 index 0000000..6580e02 --- /dev/null +++ b/web/apps/admin/src/utils/ui/loading.ts @@ -0,0 +1,84 @@ +/** + * 全局 Loading 加载管理模块 + * + * 提供统一的全屏加载动画管理 + * + * ## 主要功能 + * + * - 全屏 Loading 显示和隐藏 + * - 自动适配明暗主题背景色 + * - 自定义 SVG 加载动画 + * - 单例模式防止重复创建 + * - 锁定页面交互 + * + * ## 使用场景 + * + * - 页面初始化加载 + * - 大量数据请求 + * - 路由切换过渡 + * - 异步操作等待 + * + * ## 特性 + * + * - 自动检测当前主题并应用对应背景色 + * - 使用自定义 SVG 动画(四点旋转) + * - 单例模式确保同时只有一个 Loading + * - 提供便捷的显示/隐藏方法 + * + * @module utils/ui/loading + * @author Art Design Pro Team + */ +import { fourDotsSpinnerSvg } from '@/assets/svg/loading' + +/** + * 获取当前主题对应的loading背景色 + * @returns 背景色字符串 + */ +const getLoadingBackground = (): string => { + const isDark = document.documentElement.classList.contains('dark') + return isDark ? 'rgba(7, 7, 7, 0.85)' : '#fff' +} + +const DEFAULT_LOADING_CONFIG = { + lock: true, + get background() { + return getLoadingBackground() + }, + svg: fourDotsSpinnerSvg, + svgViewBox: '0 0 40 40', + customClass: 'art-loading-fix' +} as const + +interface LoadingInstance { + close: () => void +} + +let loadingInstance: LoadingInstance | null = null + +export const loadingService = { + /** + * 显示 loading + * @returns 关闭 loading 的函数 + */ + showLoading(): () => void { + if (!loadingInstance) { + // 每次显示时获取最新的配置,确保背景色与当前主题同步 + const config = { + ...DEFAULT_LOADING_CONFIG, + background: getLoadingBackground() + } + loadingInstance = ElLoading.service(config) + } + return () => this.hideLoading() + }, + + /** + * 隐藏 loading + */ + hideLoading(): void { + if (loadingInstance) { + loadingInstance.close() + loadingInstance = null + } + } +} diff --git a/web/apps/admin/src/utils/ui/tabs.ts b/web/apps/admin/src/utils/ui/tabs.ts new file mode 100644 index 0000000..5f53ea5 --- /dev/null +++ b/web/apps/admin/src/utils/ui/tabs.ts @@ -0,0 +1,60 @@ +/** + * 标签页布局配置模块 + * + * 提供不同标签页样式的高度和间距配置 + * + * ## 主要功能 + * + * - 多种标签页样式配置(默认、卡片、谷歌风格) + * - 标签页打开/关闭状态的高度管理 + * - 顶部间距自动计算 + * - 配置获取和默认值处理 + * + * ## 使用场景 + * + * - 工作标签页(Worktab)布局计算 + * - 页面内容区域高度调整 + * - 标签页显示/隐藏时的动画 + * - 响应式布局适配 + * + * ## 配置项说明 + * + * - openTop: 标签页显示时,内容区域距离顶部的距离 + * - closeTop: 标签页隐藏时,内容区域距离顶部的距离 + * - openHeight: 标签页显示时的总高度(包含标签栏) + * - closeHeight: 标签页隐藏时的总高度(仅头部) + * + * ## 支持的样式 + * + * - tab-default: 默认标签页样式 + * - tab-card: 卡片式标签页 + * - tab-google: 谷歌浏览器风格标签页 + * + * @module utils/ui/tabs + * @author Art Design Pro Team + */ +export const TAB_CONFIG = { + 'tab-default': { + openTop: 106, + closeTop: 60, + openHeight: 121, + closeHeight: 75 + }, + 'tab-card': { + openTop: 122, + closeTop: 78, + openHeight: 139, + closeHeight: 95 + }, + 'tab-google': { + openTop: 122, + closeTop: 78, + openHeight: 139, + closeHeight: 95 + } +} + +// 获取当前 tab 样式配置,设置默认值 +export const getTabConfig = (style: string) => { + return TAB_CONFIG[style as keyof typeof TAB_CONFIG] || TAB_CONFIG['tab-card'] // 默认使用 tab-card 配置 +} diff --git a/web/apps/admin/src/views/auth/forget-password/index.vue b/web/apps/admin/src/views/auth/forget-password/index.vue new file mode 100644 index 0000000..147259e --- /dev/null +++ b/web/apps/admin/src/views/auth/forget-password/index.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/web/apps/admin/src/views/auth/login/index.vue b/web/apps/admin/src/views/auth/login/index.vue new file mode 100755 index 0000000..4e8b2a0 --- /dev/null +++ b/web/apps/admin/src/views/auth/login/index.vue @@ -0,0 +1,352 @@ + + + + + + + + + + diff --git a/web/apps/admin/src/views/auth/login/style.css b/web/apps/admin/src/views/auth/login/style.css new file mode 100644 index 0000000..f3c4c68 --- /dev/null +++ b/web/apps/admin/src/views/auth/login/style.css @@ -0,0 +1,104 @@ +@reference '@styles/core/tailwind.css'; + +/* 授权页右侧区域(语枢网关深色 HUD 玻璃面板) */ +.auth-right-wrap { + position: absolute; + inset: 0; + width: 440px; + height: 640px; + margin: auto; + padding: 40px 48px; + overflow: hidden; + background: rgb(7 17 38 / 72%); + backdrop-filter: blur(14px); + border: 1px solid rgb(0 229 255 / 16%); + border-radius: 14px; + box-shadow: + 0 0 40px rgb(0 229 255 / 8%), + inset 0 0 60px rgb(0 229 255 / 3%); + animation: slideInRight 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards; + + .form { + height: 100%; + } + + .title { + font-size: 32px; + font-weight: 600; + letter-spacing: 1px; + color: #f0f7ff; + text-shadow: 0 0 20px rgb(0 229 255 / 45%); + } + + .sub-title { + margin-top: 10px; + font-size: 14px; + letter-spacing: 1px; + color: rgb(0 229 255 / 75%); + font-family: ui-monospace, 'JetBrains Mono', Consolas, monospace; + } + + .custom-height { + height: 40px !important; + } + + /* 安全徽标 */ + .security-badge { + position: absolute; + right: 0; + bottom: 14px; + left: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + font-family: ui-monospace, 'JetBrains Mono', Consolas, monospace; + font-size: 12px; + letter-spacing: 1px; + color: rgb(0 229 255 / 70%); + + .sb-dot { + width: 6px; + height: 6px; + background: #00ff9c; + border-radius: 50%; + box-shadow: 0 0 8px #00ff9c; + animation: sbBlink 2s ease-in-out infinite; + } + } + + @media only screen and (width < 640px) { + width: 100%; + height: auto; + padding: 32px 24px 64px; + } + + @media only screen and (width < 768px) { + animation: none; + } +} + +/* 滑入动画 */ +@keyframes slideInRight { + from { + opacity: 0; + transform: translateX(30px); + } + + to { + opacity: 1; + transform: translateX(0); + } +} + +/* 安全徽标呼吸 */ +@keyframes sbBlink { + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.3; + } +} diff --git a/web/apps/admin/src/views/auth/register/index.vue b/web/apps/admin/src/views/auth/register/index.vue new file mode 100644 index 0000000..9a8570d --- /dev/null +++ b/web/apps/admin/src/views/auth/register/index.vue @@ -0,0 +1,240 @@ + + + + + + diff --git a/web/apps/admin/src/views/dashboard/console/index.vue b/web/apps/admin/src/views/dashboard/console/index.vue new file mode 100755 index 0000000..154c330 --- /dev/null +++ b/web/apps/admin/src/views/dashboard/console/index.vue @@ -0,0 +1,41 @@ + + + + diff --git a/web/apps/admin/src/views/dashboard/console/modules/about-project.vue b/web/apps/admin/src/views/dashboard/console/modules/about-project.vue new file mode 100644 index 0000000..ed946ce --- /dev/null +++ b/web/apps/admin/src/views/dashboard/console/modules/about-project.vue @@ -0,0 +1,44 @@ + + + diff --git a/web/apps/admin/src/views/dashboard/console/modules/active-user.vue b/web/apps/admin/src/views/dashboard/console/modules/active-user.vue new file mode 100755 index 0000000..da740f2 --- /dev/null +++ b/web/apps/admin/src/views/dashboard/console/modules/active-user.vue @@ -0,0 +1,47 @@ + + + diff --git a/web/apps/admin/src/views/dashboard/console/modules/card-list.vue b/web/apps/admin/src/views/dashboard/console/modules/card-list.vue new file mode 100755 index 0000000..5fc76a7 --- /dev/null +++ b/web/apps/admin/src/views/dashboard/console/modules/card-list.vue @@ -0,0 +1,74 @@ + + + diff --git a/web/apps/admin/src/views/dashboard/console/modules/dynamic-stats.vue b/web/apps/admin/src/views/dashboard/console/modules/dynamic-stats.vue new file mode 100755 index 0000000..1876950 --- /dev/null +++ b/web/apps/admin/src/views/dashboard/console/modules/dynamic-stats.vue @@ -0,0 +1,79 @@ + + + diff --git a/web/apps/admin/src/views/dashboard/console/modules/new-user.vue b/web/apps/admin/src/views/dashboard/console/modules/new-user.vue new file mode 100755 index 0000000..9d39522 --- /dev/null +++ b/web/apps/admin/src/views/dashboard/console/modules/new-user.vue @@ -0,0 +1,169 @@ + + + + + diff --git a/web/apps/admin/src/views/dashboard/console/modules/sales-overview.vue b/web/apps/admin/src/views/dashboard/console/modules/sales-overview.vue new file mode 100755 index 0000000..32904b8 --- /dev/null +++ b/web/apps/admin/src/views/dashboard/console/modules/sales-overview.vue @@ -0,0 +1,43 @@ + + + diff --git a/web/apps/admin/src/views/dashboard/console/modules/todo-list.vue b/web/apps/admin/src/views/dashboard/console/modules/todo-list.vue new file mode 100755 index 0000000..ab9a86c --- /dev/null +++ b/web/apps/admin/src/views/dashboard/console/modules/todo-list.vue @@ -0,0 +1,71 @@ + + + diff --git a/web/apps/admin/src/views/exception/403/index.vue b/web/apps/admin/src/views/exception/403/index.vue new file mode 100755 index 0000000..2756c42 --- /dev/null +++ b/web/apps/admin/src/views/exception/403/index.vue @@ -0,0 +1,16 @@ + + + + diff --git a/web/apps/admin/src/views/exception/404/index.vue b/web/apps/admin/src/views/exception/404/index.vue new file mode 100755 index 0000000..6b64f45 --- /dev/null +++ b/web/apps/admin/src/views/exception/404/index.vue @@ -0,0 +1,16 @@ + + + + diff --git a/web/apps/admin/src/views/exception/500/index.vue b/web/apps/admin/src/views/exception/500/index.vue new file mode 100755 index 0000000..1b26377 --- /dev/null +++ b/web/apps/admin/src/views/exception/500/index.vue @@ -0,0 +1,16 @@ + + + + diff --git a/web/apps/admin/src/views/gateway/api-keys/index.vue b/web/apps/admin/src/views/gateway/api-keys/index.vue new file mode 100644 index 0000000..ea20377 --- /dev/null +++ b/web/apps/admin/src/views/gateway/api-keys/index.vue @@ -0,0 +1,176 @@ + + + diff --git a/web/apps/admin/src/views/gateway/applications/index.vue b/web/apps/admin/src/views/gateway/applications/index.vue new file mode 100644 index 0000000..e3de0e0 --- /dev/null +++ b/web/apps/admin/src/views/gateway/applications/index.vue @@ -0,0 +1,4 @@ + + diff --git a/web/apps/admin/src/views/gateway/audit-usage/index.vue b/web/apps/admin/src/views/gateway/audit-usage/index.vue new file mode 100644 index 0000000..3494a1f --- /dev/null +++ b/web/apps/admin/src/views/gateway/audit-usage/index.vue @@ -0,0 +1,95 @@ + + + diff --git a/web/apps/admin/src/views/gateway/content-policies/index.vue b/web/apps/admin/src/views/gateway/content-policies/index.vue new file mode 100644 index 0000000..771e6e7 --- /dev/null +++ b/web/apps/admin/src/views/gateway/content-policies/index.vue @@ -0,0 +1,50 @@ + + diff --git a/web/apps/admin/src/views/gateway/digital-employees/index.vue b/web/apps/admin/src/views/gateway/digital-employees/index.vue new file mode 100644 index 0000000..fe7cc64 --- /dev/null +++ b/web/apps/admin/src/views/gateway/digital-employees/index.vue @@ -0,0 +1,110 @@ + + diff --git a/web/apps/admin/src/views/gateway/governance/index.vue b/web/apps/admin/src/views/gateway/governance/index.vue new file mode 100644 index 0000000..2a93ebe --- /dev/null +++ b/web/apps/admin/src/views/gateway/governance/index.vue @@ -0,0 +1,15 @@ + + diff --git a/web/apps/admin/src/views/gateway/knowledge/index.vue b/web/apps/admin/src/views/gateway/knowledge/index.vue new file mode 100644 index 0000000..0c82d8d --- /dev/null +++ b/web/apps/admin/src/views/gateway/knowledge/index.vue @@ -0,0 +1,5 @@ + + diff --git a/web/apps/admin/src/views/gateway/marketplace/index.vue b/web/apps/admin/src/views/gateway/marketplace/index.vue new file mode 100644 index 0000000..a956d9b --- /dev/null +++ b/web/apps/admin/src/views/gateway/marketplace/index.vue @@ -0,0 +1,79 @@ + + diff --git a/web/apps/admin/src/views/gateway/mcp-servers/index.vue b/web/apps/admin/src/views/gateway/mcp-servers/index.vue new file mode 100644 index 0000000..c311762 --- /dev/null +++ b/web/apps/admin/src/views/gateway/mcp-servers/index.vue @@ -0,0 +1,94 @@ + + diff --git a/web/apps/admin/src/views/gateway/model-prices/index.vue b/web/apps/admin/src/views/gateway/model-prices/index.vue new file mode 100644 index 0000000..15346c1 --- /dev/null +++ b/web/apps/admin/src/views/gateway/model-prices/index.vue @@ -0,0 +1,31 @@ + + diff --git a/web/apps/admin/src/views/gateway/model-routes/index.vue b/web/apps/admin/src/views/gateway/model-routes/index.vue new file mode 100644 index 0000000..8ba77fb --- /dev/null +++ b/web/apps/admin/src/views/gateway/model-routes/index.vue @@ -0,0 +1,95 @@ + + + diff --git a/web/apps/admin/src/views/gateway/notifications/index.vue b/web/apps/admin/src/views/gateway/notifications/index.vue new file mode 100644 index 0000000..1915dc1 --- /dev/null +++ b/web/apps/admin/src/views/gateway/notifications/index.vue @@ -0,0 +1,3 @@ + + diff --git a/web/apps/admin/src/views/gateway/outbox/index.vue b/web/apps/admin/src/views/gateway/outbox/index.vue new file mode 100644 index 0000000..92a461b --- /dev/null +++ b/web/apps/admin/src/views/gateway/outbox/index.vue @@ -0,0 +1,67 @@ + + + diff --git a/web/apps/admin/src/views/gateway/prompts/index.vue b/web/apps/admin/src/views/gateway/prompts/index.vue new file mode 100644 index 0000000..73b90cd --- /dev/null +++ b/web/apps/admin/src/views/gateway/prompts/index.vue @@ -0,0 +1,17 @@ + + diff --git a/web/apps/admin/src/views/gateway/providers/index.vue b/web/apps/admin/src/views/gateway/providers/index.vue new file mode 100644 index 0000000..b560db6 --- /dev/null +++ b/web/apps/admin/src/views/gateway/providers/index.vue @@ -0,0 +1,308 @@ + + + diff --git a/web/apps/admin/src/views/gateway/skills/index.vue b/web/apps/admin/src/views/gateway/skills/index.vue new file mode 100644 index 0000000..1887643 --- /dev/null +++ b/web/apps/admin/src/views/gateway/skills/index.vue @@ -0,0 +1,103 @@ + + diff --git a/web/apps/admin/src/views/gateway/tools/index.vue b/web/apps/admin/src/views/gateway/tools/index.vue new file mode 100644 index 0000000..8343c92 --- /dev/null +++ b/web/apps/admin/src/views/gateway/tools/index.vue @@ -0,0 +1,4 @@ + + diff --git a/web/apps/admin/src/views/index/index.vue b/web/apps/admin/src/views/index/index.vue new file mode 100755 index 0000000..415a436 --- /dev/null +++ b/web/apps/admin/src/views/index/index.vue @@ -0,0 +1,29 @@ + + + + + + diff --git a/web/apps/admin/src/views/index/style.scss b/web/apps/admin/src/views/index/style.scss new file mode 100644 index 0000000..c356974 --- /dev/null +++ b/web/apps/admin/src/views/index/style.scss @@ -0,0 +1,103 @@ +.app-layout { + display: flex; + width: 100%; + min-height: 100vh; + // 语枢网关深色 HUD:藏青底 + 顶部青色辉光 + 极细网格(卡片自带底色,网格在留白区透出) + background: + radial-gradient(1000px 520px at 85% -10%, rgb(0 229 255 / 7%), transparent 55%), + linear-gradient(rgb(0 229 255 / 3%) 1px, transparent 1px), + linear-gradient(90deg, rgb(0 229 255 / 3%) 1px, transparent 1px), + var(--default-bg-color); + background-size: + auto, + 44px 44px, + 44px 44px, + auto; + + #app-sidebar { + flex-shrink: 0; + } + + #app-main { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + height: 100vh; + overflow: auto; + + #app-header { + position: sticky; + top: 0; + z-index: 50; + flex-shrink: 0; + width: 100%; + } + + #app-content { + flex: 1; + + :deep(.layout-content) { + box-sizing: border-box; + width: calc(100% - 40px); + margin: auto; + + // 子页面默认 style + .page-content { + position: relative; + box-sizing: border-box; + padding: 20px; + overflow: hidden; + background: var(--default-box-color); + border-radius: calc(var(--custom-radius) / 2 + 2px) !important; + } + } + } + } +} + +@media only screen and (width <= 1180px) { + .app-layout { + #app-main { + height: 100dvh; + } + } +} + +@media only screen and (width <= 800px) { + .app-layout { + position: relative; + + #app-sidebar { + position: fixed; + top: 0; + left: 0; + z-index: 300; + height: 100vh; + } + + #app-main { + width: 100%; + height: auto; + overflow: visible; + + #app-content { + :deep(.layout-content) { + width: calc(100% - 40px); + } + } + } + } +} + +@media only screen and (width <= 640px) { + .app-layout { + #app-main { + #app-content { + :deep(.layout-content) { + width: calc(100% - 30px); + } + } + } + } +} diff --git a/web/apps/admin/src/views/outside/Iframe.vue b/web/apps/admin/src/views/outside/Iframe.vue new file mode 100644 index 0000000..49cd8c2 --- /dev/null +++ b/web/apps/admin/src/views/outside/Iframe.vue @@ -0,0 +1,42 @@ + + + diff --git a/web/apps/admin/src/views/result/fail/index.vue b/web/apps/admin/src/views/result/fail/index.vue new file mode 100755 index 0000000..8fe2583 --- /dev/null +++ b/web/apps/admin/src/views/result/fail/index.vue @@ -0,0 +1,28 @@ + + + diff --git a/web/apps/admin/src/views/result/success/index.vue b/web/apps/admin/src/views/result/success/index.vue new file mode 100755 index 0000000..ae57aba --- /dev/null +++ b/web/apps/admin/src/views/result/success/index.vue @@ -0,0 +1,21 @@ + + + diff --git a/web/apps/admin/src/views/system/menu/index.vue b/web/apps/admin/src/views/system/menu/index.vue new file mode 100644 index 0000000..b88ea0c --- /dev/null +++ b/web/apps/admin/src/views/system/menu/index.vue @@ -0,0 +1,479 @@ + + + + diff --git a/web/apps/admin/src/views/system/menu/modules/menu-dialog.vue b/web/apps/admin/src/views/system/menu/modules/menu-dialog.vue new file mode 100644 index 0000000..f512301 --- /dev/null +++ b/web/apps/admin/src/views/system/menu/modules/menu-dialog.vue @@ -0,0 +1,384 @@ + + + diff --git a/web/apps/admin/src/views/system/role/index.vue b/web/apps/admin/src/views/system/role/index.vue new file mode 100755 index 0000000..126e8fd --- /dev/null +++ b/web/apps/admin/src/views/system/role/index.vue @@ -0,0 +1,241 @@ + + + + diff --git a/web/apps/admin/src/views/system/role/modules/role-edit-dialog.vue b/web/apps/admin/src/views/system/role/modules/role-edit-dialog.vue new file mode 100644 index 0000000..46ff9b1 --- /dev/null +++ b/web/apps/admin/src/views/system/role/modules/role-edit-dialog.vue @@ -0,0 +1,162 @@ + + + diff --git a/web/apps/admin/src/views/system/role/modules/role-permission-dialog.vue b/web/apps/admin/src/views/system/role/modules/role-permission-dialog.vue new file mode 100644 index 0000000..3691ac8 --- /dev/null +++ b/web/apps/admin/src/views/system/role/modules/role-permission-dialog.vue @@ -0,0 +1,254 @@ + + + diff --git a/web/apps/admin/src/views/system/role/modules/role-search.vue b/web/apps/admin/src/views/system/role/modules/role-search.vue new file mode 100644 index 0000000..ae929e7 --- /dev/null +++ b/web/apps/admin/src/views/system/role/modules/role-search.vue @@ -0,0 +1,125 @@ + + + diff --git a/web/apps/admin/src/views/system/user-center/index.vue b/web/apps/admin/src/views/system/user-center/index.vue new file mode 100755 index 0000000..ab52f00 --- /dev/null +++ b/web/apps/admin/src/views/system/user-center/index.vue @@ -0,0 +1,247 @@ + + + + diff --git a/web/apps/admin/src/views/system/user/index.vue b/web/apps/admin/src/views/system/user/index.vue new file mode 100644 index 0000000..ae0d25b --- /dev/null +++ b/web/apps/admin/src/views/system/user/index.vue @@ -0,0 +1,529 @@ + + + diff --git a/web/apps/admin/src/views/system/user/modules/user-dialog.vue b/web/apps/admin/src/views/system/user/modules/user-dialog.vue new file mode 100644 index 0000000..03cab4f --- /dev/null +++ b/web/apps/admin/src/views/system/user/modules/user-dialog.vue @@ -0,0 +1,143 @@ + + + diff --git a/web/apps/admin/src/views/system/user/modules/user-search.vue b/web/apps/admin/src/views/system/user/modules/user-search.vue new file mode 100644 index 0000000..7282a8e --- /dev/null +++ b/web/apps/admin/src/views/system/user/modules/user-search.vue @@ -0,0 +1,112 @@ + + + diff --git a/web/apps/admin/tsconfig.json b/web/apps/admin/tsconfig.json new file mode 100755 index 0000000..4331962 --- /dev/null +++ b/web/apps/admin/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "node", + "strict": true, + "jsx": "preserve", + "sourceMap": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "lib": ["esnext", "dom"], + "types": ["vite/client", "node", "element-plus/global"], + "skipLibCheck": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"], + "@views/*": ["src/views/*"], + "@imgs/*": ["src/assets/images/*"], + "@icons/*": ["src/assets/icons/*"], + "@utils/*": ["src/utils/*"], + "@stores/*": ["src/store/*"], + "@plugins/*": ["src/plugins/*"], + "@styles/*": ["src/assets/styles/*"] + } + }, + "include": ["src/**/*", "src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], + "exclude": ["node_modules", "dist", "**/*.js"] +} diff --git a/web/apps/admin/vite.config.ts b/web/apps/admin/vite.config.ts new file mode 100755 index 0000000..c2ef072 --- /dev/null +++ b/web/apps/admin/vite.config.ts @@ -0,0 +1,156 @@ +import { defineConfig, loadEnv } from 'vite' +import vue from '@vitejs/plugin-vue' +import path from 'path' +import { fileURLToPath } from 'url' +import vueDevTools from 'vite-plugin-vue-devtools' +import viteCompression from 'vite-plugin-compression' +import Components from 'unplugin-vue-components/vite' +import AutoImport from 'unplugin-auto-import/vite' +import ElementPlus from 'unplugin-element-plus/vite' +import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' +import tailwindcss from '@tailwindcss/vite' +// import { visualizer } from 'rollup-plugin-visualizer' + +export default ({ mode }: { mode: string }) => { + const root = process.cwd() + const env = loadEnv(mode, root) + const { VITE_VERSION, VITE_PORT, VITE_BASE_URL, VITE_API_URL, VITE_API_PROXY_URL } = env + + console.log(`🚀 API_URL = ${VITE_API_URL}`) + console.log(`🚀 VERSION = ${VITE_VERSION}`) + + return defineConfig({ + define: { + __APP_VERSION__: JSON.stringify(VITE_VERSION) + }, + base: VITE_BASE_URL, + server: { + port: Number(VITE_PORT), + proxy: { + '/api': { + target: VITE_API_PROXY_URL, + changeOrigin: true + } + }, + host: true + }, + // 路径别名 + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + '@views': resolvePath('src/views'), + '@imgs': resolvePath('src/assets/images'), + '@icons': resolvePath('src/assets/icons'), + '@utils': resolvePath('src/utils'), + '@stores': resolvePath('src/store'), + '@styles': resolvePath('src/assets/styles') + } + }, + build: { + target: 'es2015', + outDir: 'dist', + chunkSizeWarningLimit: 2000, + minify: 'terser', + terserOptions: { + compress: { + // 生产环境去除 console + drop_console: true, + // 生产环境去除 debugger + drop_debugger: true + } + }, + dynamicImportVarsOptions: { + warnOnError: true, + exclude: [], + include: ['src/views/**/*.vue'] + } + }, + plugins: [ + vue(), + tailwindcss(), + // 自动按需导入 API + AutoImport({ + imports: ['vue', 'vue-router', 'pinia', '@vueuse/core'], + dts: 'src/types/import/auto-imports.d.ts', + resolvers: [ElementPlusResolver()], + eslintrc: { + enabled: true, + filepath: './.auto-import.json', + globalsPropValue: true + } + }), + // 自动按需导入组件 + Components({ + dts: 'src/types/import/components.d.ts', + resolvers: [ElementPlusResolver()] + }), + // 按需定制主题配置 + ElementPlus({ + useSource: true + }), + // 压缩 + viteCompression({ + verbose: false, // 是否在控制台输出压缩结果 + disable: false, // 是否禁用 + algorithm: 'gzip', // 压缩算法 + ext: '.gz', // 压缩后的文件名后缀 + threshold: 10240, // 只有大小大于该值的资源会被处理 10240B = 10KB + deleteOriginFile: false // 压缩后是否删除原文件 + }), + vueDevTools() + // 打包分析 + // visualizer({ + // open: true, + // gzipSize: true, + // brotliSize: true, + // filename: 'dist/stats.html' // 分析图生成的文件名及路径 + // }), + ], + // 依赖预构建:避免运行时重复请求与转换,提升首次加载速度 + optimizeDeps: { + include: [ + 'echarts/core', + 'echarts/charts', + 'echarts/components', + 'echarts/renderers', + 'xlsx', + 'xgplayer', + 'crypto-js', + 'file-saver', + 'vue-img-cutter', + 'element-plus/es', + 'element-plus/es/components/*/style/css', + 'element-plus/es/components/*/style/index' + ] + }, + css: { + preprocessorOptions: { + // sass variable and mixin + scss: { + additionalData: ` + @use "@styles/core/el-light.scss" as *; + @use "@styles/core/mixin.scss" as *; + ` + } + }, + postcss: { + plugins: [ + { + postcssPlugin: 'internal:charset-removal', + AtRule: { + charset: (atRule) => { + if (atRule.name === 'charset') { + atRule.remove() + } + } + } + } + ] + } + } + }) +} + +function resolvePath(paths: string) { + return path.resolve(__dirname, paths) +} diff --git a/web/apps/portal/.env.development b/web/apps/portal/.env.development new file mode 100755 index 0000000..8305a03 --- /dev/null +++ b/web/apps/portal/.env.development @@ -0,0 +1,13 @@ +# 【开发】环境变量 + +# 应用部署基础路径(如部署在子目录 /admin,则设置为 /admin/) +VITE_BASE_URL = /portal/ + +# API 请求基础路径(开发环境设置为 / 使用代理,生产环境设置为完整后端地址) +VITE_API_URL = / + +# 代理目标地址(开发环境通过 Vite 代理转发请求到此地址,解决跨域问题) +VITE_API_PROXY_URL = http://127.0.0.1:8080 + +# Delete console +VITE_DROP_CONSOLE = false diff --git a/web/apps/portal/.env.production b/web/apps/portal/.env.production new file mode 100755 index 0000000..850e903 --- /dev/null +++ b/web/apps/portal/.env.production @@ -0,0 +1,13 @@ +# 【生产】环境变量 + +# 应用部署基础路径(如部署在子目录 /admin,则设置为 /admin/) +VITE_BASE_URL = /portal/ + +# API 地址前缀 +VITE_API_URL = / + +# 生产环境必须使用后端权限和菜单,禁止模板前端模拟权限 +VITE_ACCESS_MODE = backend + +# Delete console +VITE_DROP_CONSOLE = true diff --git a/web/apps/portal/.gitattributes b/web/apps/portal/.gitattributes new file mode 100644 index 0000000..866e8ee --- /dev/null +++ b/web/apps/portal/.gitattributes @@ -0,0 +1,2 @@ +*.html linguist-detectable=false +*.vue linguist-detectable=true diff --git a/web/apps/portal/.gitignore b/web/apps/portal/.gitignore new file mode 100755 index 0000000..e48d2e9 --- /dev/null +++ b/web/apps/portal/.gitignore @@ -0,0 +1,11 @@ +node_modules +.DS_Store +dist +dist-ssr +*.local +.cursorrules + +# Auto-generated files +src/types/import/auto-imports.d.ts +src/types/import/components.d.ts +.auto-import.json diff --git a/web/apps/portal/.husky/commit-msg b/web/apps/portal/.husky/commit-msg new file mode 100644 index 0000000..09d2b14 --- /dev/null +++ b/web/apps/portal/.husky/commit-msg @@ -0,0 +1 @@ +pnpm dlx commitlint --edit $1 \ No newline at end of file diff --git a/web/apps/portal/.husky/pre-commit b/web/apps/portal/.husky/pre-commit new file mode 100644 index 0000000..22c0347 --- /dev/null +++ b/web/apps/portal/.husky/pre-commit @@ -0,0 +1 @@ +pnpm run lint:lint-staged \ No newline at end of file diff --git a/web/apps/portal/.prettierignore b/web/apps/portal/.prettierignore new file mode 100644 index 0000000..9e96efc --- /dev/null +++ b/web/apps/portal/.prettierignore @@ -0,0 +1,3 @@ +/node_modules/* +/dist/* +/src/main.ts \ No newline at end of file diff --git a/web/apps/portal/.prettierrc b/web/apps/portal/.prettierrc new file mode 100644 index 0000000..f3d6ad5 --- /dev/null +++ b/web/apps/portal/.prettierrc @@ -0,0 +1,20 @@ +{ + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "semi": false, + "vueIndentScriptAndStyle": true, + "singleQuote": true, + "quoteProps": "as-needed", + "bracketSpacing": true, + "trailingComma": "none", + "bracketSameLine": false, + "jsxSingleQuote": false, + "arrowParens": "always", + "insertPragma": false, + "requirePragma": false, + "proseWrap": "never", + "htmlWhitespaceSensitivity": "strict", + "endOfLine": "auto", + "rangeStart": 0 +} diff --git a/web/apps/portal/.stylelintignore b/web/apps/portal/.stylelintignore new file mode 100644 index 0000000..476ea45 --- /dev/null +++ b/web/apps/portal/.stylelintignore @@ -0,0 +1,9 @@ +dist +node_modules +public +.husky +.vscode + +src/components/Layout/MenuLeft/index.vue +src/assets +stats.html \ No newline at end of file diff --git a/web/apps/portal/.stylelintrc.cjs b/web/apps/portal/.stylelintrc.cjs new file mode 100644 index 0000000..9dbea0b --- /dev/null +++ b/web/apps/portal/.stylelintrc.cjs @@ -0,0 +1,82 @@ +module.exports = { + // 继承推荐规范配置 + extends: [ + 'stylelint-config-standard', + 'stylelint-config-recommended-scss', + 'stylelint-config-recommended-vue/scss', + 'stylelint-config-html/vue', + 'stylelint-config-recess-order' + ], + // 指定不同文件对应的解析器 + overrides: [ + { + files: ['**/*.{vue,html}'], + customSyntax: 'postcss-html' + }, + { + files: ['**/*.{css,scss}'], + customSyntax: 'postcss-scss' + } + ], + // 自定义规则 + rules: { + 'import-notation': 'string', // 指定导入CSS文件的方式("string"|"url") + 'selector-class-pattern': null, // 选择器类名命名规则 + 'custom-property-pattern': null, // 自定义属性命名规则 + 'keyframes-name-pattern': null, // 动画帧节点样式命名规则 + 'no-descending-specificity': null, // 允许无降序特异性 + 'no-empty-source': null, // 允许空样式 + 'property-no-vendor-prefix': null, // 允许属性前缀 + // 允许 global 、export 、deep伪类 + 'selector-pseudo-class-no-unknown': [ + true, + { + ignorePseudoClasses: ['global', 'export', 'deep'] + } + ], + // 允许未知属性 + 'property-no-unknown': [ + true, + { + ignoreProperties: [] + } + ], + // 允许未知规则 + 'at-rule-no-unknown': [ + true, + { + ignoreAtRules: [ + 'apply', + 'use', + 'mixin', + 'include', + 'extend', + 'each', + 'if', + 'else', + 'for', + 'while', + 'reference' + ] + } + ], + 'scss/at-rule-no-unknown': [ + true, + { + ignoreAtRules: [ + 'apply', + 'use', + 'mixin', + 'include', + 'extend', + 'each', + 'if', + 'else', + 'for', + 'while', + 'reference' + ] + } + ] + } +} diff --git a/web/apps/portal/LICENSE b/web/apps/portal/LICENSE new file mode 100755 index 0000000..cf36c79 --- /dev/null +++ b/web/apps/portal/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 SuperManTT + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/web/apps/portal/commitlint.config.cjs b/web/apps/portal/commitlint.config.cjs new file mode 100644 index 0000000..d2ef1bd --- /dev/null +++ b/web/apps/portal/commitlint.config.cjs @@ -0,0 +1,97 @@ +/** + * commitlint 配置文件 + * 文档 + * https://commitlint.js.org/#/reference-rules + * https://cz-git.qbb.sh/zh/guide/ + */ + +module.exports = { + // 继承的规则 + extends: ['@commitlint/config-conventional'], + // 自定义规则 + rules: { + // 提交类型枚举,git提交type必须是以下类型 + 'type-enum': [ + 2, + 'always', + [ + 'feat', // 新增功能 + 'fix', // 修复缺陷 + 'docs', // 文档变更 + 'style', // 代码格式(不影响功能,例如空格、分号等格式修正) + 'refactor', // 代码重构(不包括 bug 修复、功能新增) + 'perf', // 性能优化 + 'test', // 添加疏漏测试或已有测试改动 + 'build', // 构建流程、外部依赖变更(如升级 npm 包、修改 webpack 配置等) + 'ci', // 修改 CI 配置、脚本 + 'revert', // 回滚 commit + 'chore', // 对构建过程或辅助工具和库的更改(不影响源文件、测试用例) + 'wip' // 对构建过程或辅助工具和库的更改(不影响源文件、测试用例) + ] + ], + 'subject-case': [0] // subject大小写不做校验 + }, + + prompt: { + messages: { + type: '选择你要提交的类型 :', + scope: '选择一个提交范围(可选):', + customScope: '请输入自定义的提交范围 :', + subject: '填写简短精炼的变更描述 :\n', + body: '填写更加详细的变更描述(可选)。使用 "|" 换行 :\n', + breaking: '列举非兼容性重大的变更(可选)。使用 "|" 换行 :\n', + footerPrefixesSelect: '选择关联issue前缀(可选):', + customFooterPrefix: '输入自定义issue前缀 :', + footer: '列举关联issue (可选) 例如: #31, #I3244 :\n', + generatingByAI: '正在通过 AI 生成你的提交简短描述...', + generatedSelectByAI: '选择一个 AI 生成的简短描述:', + confirmCommit: '是否提交或修改commit ?' + }, + // prettier-ignore + types: [ + { value: "feat", name: "feat: 新增功能" }, + { value: "fix", name: "fix: 修复缺陷" }, + { value: "docs", name: "docs: 文档变更" }, + { value: "style", name: "style: 代码格式(不影响功能,例如空格、分号等格式修正)" }, + { value: "refactor", name: "refactor: 代码重构(不包括 bug 修复、功能新增)" }, + { value: "perf", name: "perf: 性能优化" }, + { value: "test", name: "test: 添加疏漏测试或已有测试改动" }, + { value: "build", name: "build: 构建流程、外部依赖变更(如升级 npm 包、修改 vite 配置等)" }, + { value: "ci", name: "ci: 修改 CI 配置、脚本" }, + { value: "revert", name: "revert: 回滚 commit" }, + { value: "chore", name: "chore: 对构建过程或辅助工具和库的更改(不影响源文件、测试用例)" }, + ], + useEmoji: true, + emojiAlign: 'center', + useAI: false, + aiNumber: 1, + themeColorCode: '', + scopes: [], + allowCustomScopes: true, + allowEmptyScopes: true, + customScopesAlign: 'bottom', + customScopesAlias: 'custom', + emptyScopesAlias: 'empty', + upperCaseSubject: false, + markBreakingChangeMode: false, + allowBreakingChanges: ['feat', 'fix'], + breaklineNumber: 100, + breaklineChar: '|', + skipQuestions: ['breaking', 'footerPrefix', 'footer'], // 跳过的步骤 + issuePrefixes: [{ value: 'closed', name: 'closed: ISSUES has been processed' }], + customIssuePrefixAlign: 'top', + emptyIssuePrefixAlias: 'skip', + customIssuePrefixAlias: 'custom', + allowCustomIssuePrefix: true, + allowEmptyIssuePrefix: true, + confirmColorize: true, + maxHeaderLength: Infinity, + maxSubjectLength: Infinity, + minSubjectLength: 0, + scopeOverrides: undefined, + defaultBody: '', + defaultIssues: '', + defaultScope: '', + defaultSubject: '' + } +} diff --git a/web/apps/portal/eslint.config.mjs b/web/apps/portal/eslint.config.mjs new file mode 100644 index 0000000..bb317f6 --- /dev/null +++ b/web/apps/portal/eslint.config.mjs @@ -0,0 +1,83 @@ +// 从 URL 和路径模块中导入必要的功能 +import fs from 'fs' +import path, { dirname } from 'path' +import { fileURLToPath } from 'url' + +// 从 ESLint 插件中导入推荐配置 +import pluginJs from '@eslint/js' +import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended' +import pluginVue from 'eslint-plugin-vue' +import globals from 'globals' +import tseslint from 'typescript-eslint' + +// 使用 import.meta.url 获取当前模块的路径 +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) + +// 读取 .auto-import.json 文件的内容,并将其解析为 JSON 对象 +const autoImportConfig = JSON.parse( + fs.readFileSync(path.resolve(__dirname, '.auto-import.json'), 'utf-8') +) + +export default [ + // 指定文件匹配规则 + { + files: ['**/*.{js,mjs,cjs,ts,vue}'] + }, + // 指定全局变量和环境 + { + languageOptions: { + globals: { + ...globals.browser, + ...globals.node + } + } + }, + // 扩展配置 + pluginJs.configs.recommended, + ...tseslint.configs.recommended, + ...pluginVue.configs['flat/essential'], + // 自定义规则 + { + // 针对所有 JavaScript、TypeScript 和 Vue 文件应用以下配置 + files: ['**/*.{js,mjs,cjs,ts,vue}'], + + languageOptions: { + globals: { + // 合并从 autoImportConfig 中读取的全局变量配置 + ...autoImportConfig.globals, + // TypeScript 全局命名空间 + Api: 'readonly' + } + }, + rules: { + quotes: ['error', 'single'], // 使用单引号 + semi: ['error', 'never'], // 语句末尾不加分号 + 'no-var': 'error', // 要求使用 let 或 const 而不是 var + '@typescript-eslint/no-explicit-any': 'off', // 禁用 any 检查 + 'vue/multi-word-component-names': 'off', // 禁用对 Vue 组件名称的多词要求检查 + 'no-multiple-empty-lines': ['warn', { max: 1 }], // 不允许多个空行 + 'no-unexpected-multiline': 'error' // 禁止空余的多行 + } + }, + // vue 规则 + { + files: ['**/*.vue'], + languageOptions: { + parserOptions: { parser: tseslint.parser } + } + }, + // 忽略文件 + { + ignores: [ + 'node_modules', + 'dist', + 'public', + '.vscode/**', + 'src/assets/**', + 'src/utils/console.ts' + ] + }, + // prettier 配置 + eslintPluginPrettierRecommended +] diff --git a/web/apps/portal/index.html b/web/apps/portal/index.html new file mode 100755 index 0000000..c80f975 --- /dev/null +++ b/web/apps/portal/index.html @@ -0,0 +1,47 @@ + + + + LLMGuardX语枢用户门户 + + + + + + + + + + + +
+ + + diff --git a/web/apps/portal/package.json b/web/apps/portal/package.json new file mode 100755 index 0000000..5308bda --- /dev/null +++ b/web/apps/portal/package.json @@ -0,0 +1,124 @@ +{ + "name": "@aigateway/portal", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { + "node": ">=20.19.0", + "pnpm": ">=8.8.0" + }, + "scripts": { + "dev": "vite --open", + "build": "vue-tsc --noEmit && vite build", + "serve": "vite preview", + "lint": "eslint", + "fix": "eslint --fix", + "lint:prettier": "prettier --write \"**/*.{js,cjs,ts,json,tsx,css,less,scss,vue,html,md}\"", + "lint:stylelint": "stylelint \"**/*.{css,scss,vue}\" --fix", + "lint:lint-staged": "lint-staged", + "prepare": "husky", + "commit": "git-cz", + "clean:dev": "tsx scripts/clean-dev.ts" + }, + "config": { + "commitizen": { + "path": "node_modules/cz-git" + } + }, + "lint-staged": { + "*.{js,ts,mjs,mts,tsx}": [ + "eslint --fix", + "prettier --write" + ], + "*.{cjs,json,jsonc}": [ + "prettier --write" + ], + "*.vue": [ + "eslint --fix", + "stylelint --fix --allow-empty-input", + "prettier --write" + ], + "*.{html,htm}": [ + "prettier --write" + ], + "*.{scss,css,less}": [ + "stylelint --fix --allow-empty-input", + "prettier --write" + ], + "*.{md,mdx}": [ + "prettier --write" + ], + "*.{yaml,yml}": [ + "prettier --write" + ] + }, + "dependencies": { + "@element-plus/icons-vue": "^2.3.2", + "@iconify/vue": "^5.0.0", + "@tailwindcss/vite": "^4.1.14", + "@vue/reactivity": "^3.5.21", + "@vueuse/core": "^13.9.0", + "@wangeditor/editor": "^5.1.23", + "@wangeditor/editor-for-vue": "next", + "axios": "^1.12.2", + "crypto-js": "^4.2.0", + "echarts": "^6.0.0", + "element-plus": "^2.11.2", + "file-saver": "^2.0.5", + "highlight.js": "^11.10.0", + "mitt": "^3.0.1", + "nprogress": "^0.2.0", + "ohash": "^2.0.11", + "pinia": "^3.0.3", + "pinia-plugin-persistedstate": "^4.3.0", + "qrcode.vue": "^3.6.0", + "tailwindcss": "^4.1.14", + "vue": "^3.5.21", + "vue-draggable-plus": "^0.6.0", + "vue-i18n": "^9.14.0", + "vue-router": "^4.5.1", + "xgplayer": "^3.0.20", + "xlsx": "^0.18.5" + }, + "devDependencies": { + "@commitlint/cli": "^19.4.1", + "@commitlint/config-conventional": "^19.4.1", + "@eslint/js": "^9.9.1", + "@types/node": "^24.0.5", + "@typescript-eslint/eslint-plugin": "^8.3.0", + "@typescript-eslint/parser": "^8.3.0", + "@vitejs/plugin-vue": "^6.0.1", + "@vue/compiler-sfc": "^3.0.5", + "commitizen": "^4.3.0", + "cz-git": "^1.11.1", + "eslint": "^9.9.1", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.2.1", + "eslint-plugin-vue": "^9.27.0", + "globals": "^15.9.0", + "husky": "^9.1.5", + "lint-staged": "^15.5.2", + "prettier": "^3.5.3", + "rollup-plugin-visualizer": "^5.12.0", + "sass": "^1.81.0", + "stylelint": "^16.20.0", + "stylelint-config-html": "^1.1.0", + "stylelint-config-recess-order": "^4.6.0", + "stylelint-config-recommended-scss": "^14.1.0", + "stylelint-config-recommended-vue": "^1.5.0", + "stylelint-config-standard": "^36.0.1", + "terser": "^5.36.0", + "tsx": "^4.20.3", + "typescript": "~5.6.3", + "typescript-eslint": "^8.9.0", + "unplugin-auto-import": "^20.2.0", + "unplugin-element-plus": "^0.10.0", + "unplugin-vue-components": "^29.1.0", + "vite": "^7.1.5", + "vite-plugin-compression": "^0.5.1", + "vite-plugin-vue-devtools": "^7.7.6", + "vue-demi": "^0.14.9", + "vue-img-cutter": "^3.0.5", + "vue-tsc": "~2.1.6" + } +} diff --git a/web/apps/portal/public/favicon.ico b/web/apps/portal/public/favicon.ico new file mode 100755 index 0000000000000000000000000000000000000000..df36fcfb72584e00488330b560ebcf34a41c64c2 GIT binary patch literal 4286 zcmds*O-Phc6o&64GDVCEQHxsW(p4>LW*W<827=Unuo8sGpRux(DN@jWP-e29Wl%wj zY84_aq9}^Am9-cWTD5GGEo#+5Fi2wX_P*bo+xO!)p*7B;iKlbFd(U~_d(U?#hLj56 zPhFkj-|A6~Qk#@g^#D^U0XT1cu=c-vu1+SElX9NR;kzAUV(q0|dl0|%h|dI$%VICy zJnu2^L*Te9JrJMGh%-P79CL0}dq92RGU6gI{v2~|)p}sG5x0U*z<8U;Ij*hB9z?ei z@g6Xq-pDoPl=MANPiR7%172VA%r)kevtV-_5H*QJKFmd;8yA$98zCxBZYXTNZ#QFk2(TX0;Y2dt&WitL#$96|gJY=3xX zpCoi|YNzgO3R`f@IiEeSmKrPSf#h#Qd<$%Ej^RIeeYfsxhPMOG`S`Pz8q``=511zm zAm)MX5AV^5xIWPyEu7u>qYs?pn$I4nL9J!=K=SGlKLXpE<5x+2cDTXq?brj?n6sp= zphe9;_JHf40^9~}9i08r{XM$7HB!`{Ys~TK0kx<}ZQng`UPvH*11|q7&l9?@FQz;8 zx!=3<4seY*%=OlbCbcae?5^V_}*K>Uo6ZWV8mTyE^B=DKy7-sdLYkR5Z?paTgK-zyIkKjIcpyO z{+uIt&YSa_$QnN_@t~L014dyK(fOOo+W*MIxbA6Ndgr=Y!f#Tokqv}n<7-9qfHkc3 z=>a|HWqcX8fzQCT=dqVbogRq!-S>H%yA{1w#2Pn;=e>JiEj7Hl;zdt-2f+j2%DeVD zsW0Ab)ZK@0cIW%W7z}H{&~yGhn~D;aiP4=;m-HCo`BEI+Kd6 z={Xwx{TKxD#iCLfl2vQGDitKtN>z|-AdCN|$jTFDg0m3O`WLD4_s#$S literal 0 HcmV?d00001 diff --git a/web/apps/portal/scripts/clean-dev.ts b/web/apps/portal/scripts/clean-dev.ts new file mode 100644 index 0000000..cc0b9bc --- /dev/null +++ b/web/apps/portal/scripts/clean-dev.ts @@ -0,0 +1,838 @@ +// scripts/clean-dev.ts +import fs from 'fs/promises' +import path from 'path' + +// 现代化颜色主题 +const theme = { + // 基础颜色 + reset: '\x1b[0m', + bold: '\x1b[1m', + dim: '\x1b[2m', + + // 前景色 + primary: '\x1b[38;5;75m', // 亮蓝色 + success: '\x1b[38;5;82m', // 亮绿色 + warning: '\x1b[38;5;220m', // 亮黄色 + error: '\x1b[38;5;196m', // 亮红色 + info: '\x1b[38;5;159m', // 青色 + purple: '\x1b[38;5;141m', // 紫色 + orange: '\x1b[38;5;208m', // 橙色 + gray: '\x1b[38;5;245m', // 灰色 + white: '\x1b[38;5;255m', // 白色 + + // 背景色 + bgDark: '\x1b[48;5;235m', // 深灰背景 + bgBlue: '\x1b[48;5;24m', // 蓝色背景 + bgGreen: '\x1b[48;5;22m', // 绿色背景 + bgRed: '\x1b[48;5;52m' // 红色背景 +} + +// 现代化图标集 +const icons = { + rocket: '🚀', + fire: '🔥', + star: '⭐', + gem: '💎', + crown: '👑', + magic: '✨', + warning: '⚠️', + success: '✅', + error: '❌', + info: 'ℹ️', + folder: '📁', + file: '📄', + image: '🖼️', + code: '💻', + data: '📊', + globe: '🌐', + map: '🗺️', + chat: '💬', + bolt: '⚡', + shield: '🛡️', + key: '🔑', + link: '🔗', + clean: '🧹', + trash: '🗑️', + check: '✓', + cross: '✗', + arrow: '→', + loading: '⏳' +} + +// 格式化工具 +const fmt = { + title: (text: string) => `${theme.bold}${theme.primary}${text}${theme.reset}`, + subtitle: (text: string) => `${theme.purple}${text}${theme.reset}`, + success: (text: string) => `${theme.success}${text}${theme.reset}`, + error: (text: string) => `${theme.error}${text}${theme.reset}`, + warning: (text: string) => `${theme.warning}${text}${theme.reset}`, + info: (text: string) => `${theme.info}${text}${theme.reset}`, + highlight: (text: string) => `${theme.bold}${theme.white}${text}${theme.reset}`, + dim: (text: string) => `${theme.dim}${theme.gray}${text}${theme.reset}`, + orange: (text: string) => `${theme.orange}${text}${theme.reset}`, + + // 带背景的文本 + badge: (text: string, bg: string = theme.bgBlue) => + `${bg}${theme.white}${theme.bold} ${text} ${theme.reset}`, + + // 渐变效果模拟 + gradient: (text: string) => { + const colors = ['\x1b[38;5;75m', '\x1b[38;5;81m', '\x1b[38;5;87m', '\x1b[38;5;159m'] + const chars = text.split('') + return chars.map((char, i) => `${colors[i % colors.length]}${char}`).join('') + theme.reset + } +} + +// 创建现代化标题横幅 +function createModernBanner() { + console.log() + console.log( + fmt.gradient(' ╔══════════════════════════════════════════════════════════════════╗') + ) + console.log( + fmt.gradient(' ║ ║') + ) + console.log( + ` ║ ${icons.rocket} ${fmt.title('ART DESIGN PRO')} ${fmt.subtitle('· 代码精简程序')} ${icons.magic} ║` + ) + console.log( + ` ║ ${fmt.dim('为项目移除演示数据,快速切换至开发模式')} ║` + ) + console.log( + fmt.gradient(' ║ ║') + ) + console.log( + fmt.gradient(' ╚══════════════════════════════════════════════════════════════════╝') + ) + console.log() +} + +// 创建分割线 +function createDivider(char = '─', color = theme.primary) { + console.log(`${color}${' ' + char.repeat(66)}${theme.reset}`) +} + +// 创建卡片样式容器 +function createCard(title: string, content: string[]) { + console.log(` ${fmt.badge('', theme.bgBlue)} ${fmt.title(title)}`) + console.log() + content.forEach((line) => { + console.log(` ${line}`) + }) + console.log() +} + +// 进度条动画 +function createProgressBar(current: number, total: number, text: string, width = 40) { + const percentage = Math.round((current / total) * 100) + const filled = Math.round((current / total) * width) + const empty = width - filled + + const filledBar = '█'.repeat(filled) + const emptyBar = '░'.repeat(empty) + + process.stdout.write( + `\r ${fmt.info('进度')} [${theme.success}${filledBar}${theme.gray}${emptyBar}${theme.reset}] ${fmt.highlight(percentage + '%')})}` + ) + + if (current === total) { + console.log() + } +} + +// 统计信息 +const stats = { + deletedFiles: 0, + deletedPaths: 0, + failedPaths: 0, + startTime: Date.now(), + totalFiles: 0 +} + +// 清理目标 +const targets = [ + 'README.md', + 'README.zh-CN.md', + 'CHANGELOG.md', + 'CHANGELOG.zh-CN.md', + 'src/views/change', + 'src/views/safeguard', + 'src/views/article', + 'src/views/examples', + 'src/views/system/nested', + 'src/views/widgets', + 'src/views/template', + 'src/views/dashboard/analysis', + 'src/views/dashboard/ecommerce', + 'src/mock/json', + 'src/mock/temp/articleList.ts', + 'src/mock/temp/commentDetail.ts', + 'src/mock/temp/commentList.ts', + 'src/assets/images/cover', + 'src/assets/images/safeguard', + 'src/assets/images/3d', + 'src/components/core/charts/art-map-chart', + 'src/components/business/comment-widget' +] + +// 递归统计文件数量 +async function countFiles(targetPath: string): Promise { + const fullPath = path.resolve(process.cwd(), targetPath) + + try { + const stat = await fs.stat(fullPath) + + if (stat.isFile()) { + return 1 + } else if (stat.isDirectory()) { + const entries = await fs.readdir(fullPath) + let count = 0 + + for (const entry of entries) { + const entryPath = path.join(targetPath, entry) + count += await countFiles(entryPath) + } + + return count + } + } catch { + return 0 + } + + return 0 +} + +// 统计所有目标的文件数量 +async function countAllFiles(): Promise { + let totalCount = 0 + + for (const target of targets) { + const count = await countFiles(target) + totalCount += count + } + + return totalCount +} + +// 删除文件和目录 +async function remove(targetPath: string, index: number) { + const fullPath = path.resolve(process.cwd(), targetPath) + + createProgressBar(index + 1, targets.length, targetPath) + + try { + const fileCount = await countFiles(targetPath) + await fs.rm(fullPath, { recursive: true, force: true }) + stats.deletedFiles += fileCount + stats.deletedPaths++ + await new Promise((resolve) => setTimeout(resolve, 50)) + } catch (err) { + stats.failedPaths++ + console.log() + console.log(` ${icons.error} ${fmt.error('删除失败')}: ${fmt.highlight(targetPath)}`) + console.log(` ${fmt.dim('错误详情: ' + err)}`) + } +} + +// 清理路由模块 +async function cleanRouteModules() { + const modulesPath = path.resolve(process.cwd(), 'src/router/modules') + + try { + // 删除演示相关的路由模块 + const modulesToRemove = [ + 'template.ts', + 'widgets.ts', + 'examples.ts', + 'article.ts', + 'safeguard.ts', + 'help.ts' + ] + + for (const module of modulesToRemove) { + const modulePath = path.join(modulesPath, module) + try { + await fs.rm(modulePath, { force: true }) + } catch { + // 文件不存在时忽略错误 + } + } + + // 重写 dashboard.ts - 只保留 console + const dashboardContent = `import { AppRouteRecord } from '@/types/router' + +export const dashboardRoutes: AppRouteRecord = { + name: 'Dashboard', + path: '/dashboard', + component: '/index/index', + meta: { + title: 'menus.dashboard.title', + icon: 'ri:pie-chart-line', + roles: ['R_SUPER', 'R_ADMIN'] + }, + children: [ + { + path: 'console', + name: 'Console', + component: '/dashboard/console', + meta: { + title: 'menus.dashboard.console', + keepAlive: false, + fixedTab: true + } + } + ] +} +` + await fs.writeFile(path.join(modulesPath, 'dashboard.ts'), dashboardContent, 'utf-8') + + // 重写 system.ts - 移除 nested 嵌套菜单 + const systemContent = `import { AppRouteRecord } from '@/types/router' + +export const systemRoutes: AppRouteRecord = { + path: '/system', + name: 'System', + component: '/index/index', + meta: { + title: 'menus.system.title', + icon: 'ri:user-3-line', + roles: ['R_SUPER', 'R_ADMIN'] + }, + children: [ + { + path: 'user', + name: 'User', + component: '/system/user', + meta: { + title: 'menus.system.user', + keepAlive: true, + roles: ['R_SUPER', 'R_ADMIN'] + } + }, + { + path: 'role', + name: 'Role', + component: '/system/role', + meta: { + title: 'menus.system.role', + keepAlive: true, + roles: ['R_SUPER'] + } + }, + { + path: 'user-center', + name: 'UserCenter', + component: '/system/user-center', + meta: { + title: 'menus.system.userCenter', + isHide: true, + keepAlive: true, + isHideTab: true + } + }, + { + path: 'menu', + name: 'Menus', + component: '/system/menu', + meta: { + title: 'menus.system.menu', + keepAlive: true, + roles: ['R_SUPER'], + authList: [ + { title: '新增', authMark: 'add' }, + { title: '编辑', authMark: 'edit' }, + { title: '删除', authMark: 'delete' } + ] + } + } + ] +} +` + await fs.writeFile(path.join(modulesPath, 'system.ts'), systemContent, 'utf-8') + + // 重写 index.ts - 只导入保留的模块 + const indexContent = `import { AppRouteRecord } from '@/types/router' +import { dashboardRoutes } from './dashboard' +import { systemRoutes } from './system' +import { resultRoutes } from './result' +import { exceptionRoutes } from './exception' + +/** + * 导出所有模块化路由 + */ +export const routeModules: AppRouteRecord[] = [ + dashboardRoutes, + systemRoutes, + resultRoutes, + exceptionRoutes +] +` + await fs.writeFile(path.join(modulesPath, 'index.ts'), indexContent, 'utf-8') + + console.log(` ${icons.success} ${fmt.success('清理路由模块完成')}`) + } catch (err) { + console.log(` ${icons.error} ${fmt.error('清理路由模块失败')}`) + console.log(` ${fmt.dim('错误详情: ' + err)}`) + } +} + +// 清理路由别名 +async function cleanRoutesAlias() { + const routesAliasPath = path.resolve(process.cwd(), 'src/router/routesAlias.ts') + + try { + const cleanedAlias = `/** + * 公共路由别名 + # 存放系统级公共路由路径,如布局容器、登录页等 + */ +export enum RoutesAlias { + Layout = '/index/index', // 布局容器 + Login = '/auth/login' // 登录页 +} +` + + await fs.writeFile(routesAliasPath, cleanedAlias, 'utf-8') + console.log(` ${icons.success} ${fmt.success('重写路由别名配置完成')}`) + } catch (err) { + console.log(` ${icons.error} ${fmt.error('清理路由别名失败')}`) + console.log(` ${fmt.dim('错误详情: ' + err)}`) + } +} + +// 清理变更日志 +async function cleanChangeLog() { + const changeLogPath = path.resolve(process.cwd(), 'src/mock/upgrade/changeLog.ts') + + try { + const cleanedChangeLog = `import { ref } from 'vue' + +interface UpgradeLog { + version: string // 版本号 + title: string // 更新标题 + date: string // 更新日期 + detail?: string[] // 更新内容 + requireReLogin?: boolean // 是否需要重新登录 + remark?: string // 备注 +} + +export const upgradeLogList = ref([]) +` + + await fs.writeFile(changeLogPath, cleanedChangeLog, 'utf-8') + console.log(` ${icons.success} ${fmt.success('清空变更日志数据完成')}`) + } catch (err) { + console.log(` ${icons.error} ${fmt.error('清理变更日志失败')}`) + console.log(` ${fmt.dim('错误详情: ' + err)}`) + } +} + +// 清理语言文件 +async function cleanLanguageFiles() { + const languageFiles = [ + { path: 'src/locales/langs/zh.json', name: '中文语言文件' }, + { path: 'src/locales/langs/en.json', name: '英文语言文件' } + ] + + for (const { path: langPath, name } of languageFiles) { + try { + const fullPath = path.resolve(process.cwd(), langPath) + const content = await fs.readFile(fullPath, 'utf-8') + const langData = JSON.parse(content) + + const menusToRemove = [ + 'widgets', + 'template', + 'article', + 'examples', + 'safeguard', + 'plan', + 'help' + ] + + if (langData.menus) { + menusToRemove.forEach((menuKey) => { + if (langData.menus[menuKey]) { + delete langData.menus[menuKey] + } + }) + + if (langData.menus.dashboard) { + if (langData.menus.dashboard.analysis) { + delete langData.menus.dashboard.analysis + } + if (langData.menus.dashboard.ecommerce) { + delete langData.menus.dashboard.ecommerce + } + } + + if (langData.menus.system) { + const systemKeysToRemove = [ + 'nested', + 'menu1', + 'menu2', + 'menu21', + 'menu3', + 'menu31', + 'menu32', + 'menu321' + ] + systemKeysToRemove.forEach((key) => { + if (langData.menus.system[key]) { + delete langData.menus.system[key] + } + }) + } + } + + await fs.writeFile(fullPath, JSON.stringify(langData, null, 2), 'utf-8') + console.log(` ${icons.success} ${fmt.success(`清理${name}完成`)}`) + } catch (err) { + console.log(` ${icons.error} ${fmt.error(`清理${name}失败`)}`) + console.log(` ${fmt.dim('错误详情: ' + err)}`) + } + } +} + +// 清理快速入口组件 +async function cleanFastEnterComponent() { + const fastEnterPath = path.resolve(process.cwd(), 'src/config/fastEnter.ts') + + try { + const cleanedFastEnter = `/** + * 快速入口配置 + * 包含:应用列表、快速链接等配置 + */ +import { WEB_LINKS } from '@/utils/constants' +import type { FastEnterConfig } from '@/types/config' + +const fastEnterConfig: FastEnterConfig = { + // 显示条件(屏幕宽度) + minWidth: 1200, + // 应用列表 + applications: [ + { + name: '工作台', + description: '系统概览与数据统计', + icon: 'ri:pie-chart-line', + iconColor: '#377dff', + enabled: true, + order: 1, + routeName: 'Console' + }, + { + name: '官方文档', + description: '使用指南与开发文档', + icon: 'ri:bill-line', + iconColor: '#ffb100', + enabled: true, + order: 2, + link: WEB_LINKS.DOCS + }, + { + name: '技术支持', + description: '技术支持与问题反馈', + icon: 'ri:user-location-line', + iconColor: '#ff6b6b', + enabled: true, + order: 3, + link: WEB_LINKS.COMMUNITY + }, + { + name: '哔哩哔哩', + description: '技术分享与交流', + icon: 'ri:bilibili-line', + iconColor: '#FB7299', + enabled: true, + order: 4, + link: WEB_LINKS.BILIBILI + } + ], + // 快速链接 + quickLinks: [ + { + name: '登录', + enabled: true, + order: 1, + routeName: 'Login' + }, + { + name: '注册', + enabled: true, + order: 2, + routeName: 'Register' + }, + { + name: '忘记密码', + enabled: true, + order: 3, + routeName: 'ForgetPassword' + }, + { + name: '个人中心', + enabled: true, + order: 4, + routeName: 'UserCenter' + } + ] +} + +export default Object.freeze(fastEnterConfig) +` + + await fs.writeFile(fastEnterPath, cleanedFastEnter, 'utf-8') + console.log(` ${icons.success} ${fmt.success('清理快速入口配置完成')}`) + } catch (err) { + console.log(` ${icons.error} ${fmt.error('清理快速入口配置失败')}`) + console.log(` ${fmt.dim('错误详情: ' + err)}`) + } +} + +// 更新菜单接口 +async function updateMenuApi() { + const apiPath = path.resolve(process.cwd(), 'src/api/system-manage.ts') + + try { + const content = await fs.readFile(apiPath, 'utf-8') + const updatedContent = content.replace( + "url: '/api/v3/system/menus'", + "url: '/api/v3/system/menus/simple'" + ) + + await fs.writeFile(apiPath, updatedContent, 'utf-8') + console.log(` ${icons.success} ${fmt.success('更新菜单接口完成')}`) + } catch (err) { + console.log(` ${icons.error} ${fmt.error('更新菜单接口失败')}`) + console.log(` ${fmt.dim('错误详情: ' + err)}`) + } +} + +// 用户确认函数 +async function getUserConfirmation(): Promise { + const { createInterface } = await import('readline') + + return new Promise((resolve) => { + const rl = createInterface({ + input: process.stdin, + output: process.stdout + }) + + console.log( + ` ${fmt.highlight('请输入')} ${fmt.success('yes')} ${fmt.highlight('确认执行清理操作,或按 Enter 取消')}` + ) + console.log() + process.stdout.write(` ${icons.arrow} `) + + rl.question('', (answer: string) => { + rl.close() + resolve(answer.toLowerCase().trim() === 'yes') + }) + }) +} + +// 显示清理警告 +async function showCleanupWarning() { + createCard('安全警告', [ + `${fmt.warning('此操作将永久删除以下演示内容,且无法恢复!')}`, + `${fmt.dim('请仔细阅读清理列表,确认后再继续操作')}` + ]) + + const cleanupItems = [ + { + icon: icons.image, + name: '图片资源', + desc: '演示用的封面图片、3D图片、运维图片等', + color: theme.orange + }, + { + icon: icons.file, + name: '演示页面', + desc: 'widgets、template、article、examples、safeguard等页面', + color: theme.purple + }, + { + icon: icons.code, + name: '路由模块文件', + desc: '删除演示路由模块,只保留核心模块(dashboard、system、result、exception)', + color: theme.primary + }, + { + icon: icons.link, + name: '路由别名', + desc: '重写routesAlias.ts,移除演示路由别名', + color: theme.info + }, + { + icon: icons.data, + name: 'Mock数据', + desc: '演示用的JSON数据、文章列表、评论数据等', + color: theme.success + }, + { + icon: icons.globe, + name: '多语言文件', + desc: '清理中英文语言包中的演示菜单项', + color: theme.warning + }, + { icon: icons.map, name: '地图组件', desc: '移除art-map-chart地图组件', color: theme.error }, + { icon: icons.chat, name: '评论组件', desc: '移除comment-widget评论组件', color: theme.orange }, + { + icon: icons.bolt, + name: '快速入口', + desc: '移除分析页、礼花效果、聊天、更新日志、定价、留言管理等无效项目', + color: theme.purple + } + ] + + console.log(` ${fmt.badge('', theme.bgRed)} ${fmt.title('将要清理的内容')}`) + console.log() + + cleanupItems.forEach((item, index) => { + console.log(` ${item.color}${theme.reset} ${fmt.highlight(`${index + 1}. ${item.name}`)}`) + console.log(` ${fmt.dim(item.desc)}`) + }) + + console.log() + console.log(` ${fmt.badge('', theme.bgGreen)} ${fmt.title('保留的功能模块')}`) + console.log() + + const preservedModules = [ + { name: 'Dashboard', desc: '工作台页面' }, + { name: 'System', desc: '系统管理模块' }, + { name: 'Result', desc: '结果页面' }, + { name: 'Exception', desc: '异常页面' }, + { name: 'Auth', desc: '登录注册功能' }, + { name: 'Core Components', desc: '核心组件库' } + ] + + preservedModules.forEach((module) => { + console.log(` ${icons.check} ${fmt.success(module.name)} ${fmt.dim(`- ${module.desc}`)}`) + }) + + console.log() + createDivider() + console.log() +} + +// 显示统计信息 +async function showStats() { + const duration = Date.now() - stats.startTime + const seconds = (duration / 1000).toFixed(2) + + console.log() + createCard('清理统计', [ + `${fmt.success('成功删除')}: ${fmt.highlight(stats.deletedFiles.toString())} 个文件`, + `${fmt.info('涉及路径')}: ${fmt.highlight(stats.deletedPaths.toString())} 个目录/文件`, + ...(stats.failedPaths > 0 + ? [ + `${icons.error} ${fmt.error('删除失败')}: ${fmt.highlight(stats.failedPaths.toString())} 个路径` + ] + : []), + `${fmt.info('耗时')}: ${fmt.highlight(seconds)} 秒` + ]) +} + +// 创建成功横幅 +function createSuccessBanner() { + console.log() + console.log( + fmt.gradient(' ╔══════════════════════════════════════════════════════════════════╗') + ) + console.log( + fmt.gradient(' ║ ║') + ) + console.log( + ` ║ ${icons.star} ${fmt.success('清理完成!项目已准备就绪')} ${icons.rocket} ║` + ) + console.log( + ` ║ ${fmt.dim('现在可以开始您的开发之旅了!')} ║` + ) + console.log( + fmt.gradient(' ║ ║') + ) + console.log( + fmt.gradient(' ╚══════════════════════════════════════════════════════════════════╝') + ) + console.log() +} + +// 主函数 +async function main() { + // 清屏并显示横幅 + console.clear() + createModernBanner() + + // 显示清理警告 + await showCleanupWarning() + + // 统计文件数量 + console.log(` ${fmt.info('正在统计文件数量...')}`) + stats.totalFiles = await countAllFiles() + + console.log(` ${fmt.info('即将清理')}: ${fmt.highlight(stats.totalFiles.toString())} 个文件`) + console.log(` ${fmt.dim(`涉及 ${targets.length} 个目录/文件路径`)}`) + console.log() + + // 用户确认 + const confirmed = await getUserConfirmation() + + if (!confirmed) { + console.log(` ${fmt.warning('操作已取消,清理中止')}`) + console.log() + return + } + + console.log() + console.log(` ${icons.check} ${fmt.success('确认成功,开始清理...')}`) + console.log() + + // 开始清理过程 + console.log(` ${fmt.badge('步骤 1/6', theme.bgBlue)} ${fmt.title('删除演示文件')}`) + console.log() + for (let i = 0; i < targets.length; i++) { + await remove(targets[i], i) + } + console.log() + + console.log(` ${fmt.badge('步骤 2/6', theme.bgBlue)} ${fmt.title('清理路由模块')}`) + console.log() + await cleanRouteModules() + console.log() + + console.log(` ${fmt.badge('步骤 3/6', theme.bgBlue)} ${fmt.title('重写路由别名')}`) + console.log() + await cleanRoutesAlias() + console.log() + + console.log(` ${fmt.badge('步骤 4/6', theme.bgBlue)} ${fmt.title('清空变更日志')}`) + console.log() + await cleanChangeLog() + console.log() + + console.log(` ${fmt.badge('步骤 5/6', theme.bgBlue)} ${fmt.title('清理语言文件')}`) + console.log() + await cleanLanguageFiles() + console.log() + + console.log(` ${fmt.badge('步骤 6/7', theme.bgBlue)} ${fmt.title('清理快速入口')}`) + console.log() + await cleanFastEnterComponent() + console.log() + + console.log(` ${fmt.badge('步骤 7/7', theme.bgBlue)} ${fmt.title('更新菜单接口')}`) + console.log() + await updateMenuApi() + + // 显示统计信息 + await showStats() + + // 显示成功横幅 + createSuccessBanner() +} + +main().catch((err) => { + console.log() + console.log(` ${icons.error} ${fmt.error('清理脚本执行出错')}`) + console.log(` ${fmt.dim('错误详情: ' + err)}`) + console.log() + process.exit(1) +}) diff --git a/web/apps/portal/src/App.vue b/web/apps/portal/src/App.vue new file mode 100755 index 0000000..d941a48 --- /dev/null +++ b/web/apps/portal/src/App.vue @@ -0,0 +1,41 @@ + + + diff --git a/web/apps/portal/src/api/auth.ts b/web/apps/portal/src/api/auth.ts new file mode 100644 index 0000000..4f9585c --- /dev/null +++ b/web/apps/portal/src/api/auth.ts @@ -0,0 +1,53 @@ +import request from '@/utils/http' + +/** + * 登录 + * @param params 登录参数 + * @returns 登录响应 + */ +export function fetchLogin(params: Api.Auth.LoginParams) { + return request.post({ + url: '/api/v1/portal/login', + params + // showSuccessMessage: true // 显示成功消息 + // showErrorMessage: false // 不显示错误消息 + }) +} + +export function fetchTOTPLogin(params: Api.Auth.TOTPLoginParams) { + return request.post({ + url: '/api/v1/portal/login/totp', + params + }) +} + +export interface SSOProvider { + code: string + display_name: string + kind: 'oidc' | 'saml' +} + +export function fetchSSOProviders() { + return request.get({ url: '/api/v1/portal/sso/providers' }) +} + +export function exchangeSSOCode(code: string) { + return request.post({ + url: '/api/v1/portal/sso/exchange', + params: { code } + }) +} + +/** + * 获取用户信息 + * @returns 用户信息 + */ +export function fetchGetUserInfo() { + return request.get({ + url: '/api/v1/portal/me' + // 自定义请求头 + // headers: { + // 'X-Custom-Header': 'your-custom-value' + // } + }) +} diff --git a/web/apps/portal/src/api/portal.ts b/web/apps/portal/src/api/portal.ts new file mode 100644 index 0000000..2b29795 --- /dev/null +++ b/web/apps/portal/src/api/portal.ts @@ -0,0 +1,33 @@ +import request from '@/utils/http' + +export interface Application { id:string;code:string;name:string;description:string;published_version?:number } +export interface Knowledge { id:string;name:string;description:string;document_count:number;chunk_count:number } +export interface Tool { id:string;code:string;name:string;description:string;input_schema:Record } +export interface PromptVersion { version:number;content:string;variables:Array<{name:string;label?:string;required:boolean}> } +export interface Prompt { id:string;name:string;description:string;category_name:string;tags:string[];favorite:boolean;current?:PromptVersion } +export interface Catalog { applications:Application[];knowledge:Knowledge[];tools:Tool[];prompts:Prompt[] } +export interface Model { provider_code:string;id:string;owned_by:string } +export interface ModelRequest { id:string;provider_code:string;model:string;reason:string;requested_rpm:number;requested_monthly_tokens:number;status:string;decision_note:string;created_at:string } +export interface Stats { days:number;requests:number;failed_requests:number;prompt_tokens:number;completion_tokens:number;total_tokens:number;cost_microunits:number;avg_latency_ms?:number } +export interface AuditEvent { id:string;request_id:string;provider_code?:string;model?:string;protocol:string;status_code?:number;prompt_tokens?:number;completion_tokens?:number;cost_microunits?:number;latency_ms?:number;recorded_at:string } + +export const fetchCatalog=()=>request.get({url:'/api/v1/portal/catalog'}) +export const fetchPrompts=(q='')=>request.get({url:'/api/v1/portal/prompts',params:{q}}) +export const setPromptFavorite=(id:string,value:boolean)=>value?request.post({url:`/api/v1/portal/prompts/${id}/favorite`}):request.del({url:`/api/v1/portal/prompts/${id}/favorite`}) +export const fetchModels=()=>request.get({url:'/api/v1/portal/model-requests/available'}) +export const fetchModelRequests=()=>request.get({url:'/api/v1/portal/model-requests'}) +export const createModelRequest=(params:Pick)=>request.post({url:'/api/v1/portal/model-requests',params}) +export const fetchStats=(days:number)=>request.get({url:'/api/v1/portal/stats',params:{days}}) +export const fetchLogs=(days:number,limit=50)=>request.get<{items:AuditEvent[]}>({url:'/api/v1/portal/logs',params:{days,limit}}) +export const changePassword=(params:{old_password:string;new_password:string})=>request.post({url:'/api/v1/portal/password',params}) + + +// --- 资源市场 --- +export interface MarketItem { type:string;code:string;name:string;description:string;category_id?:string;category_name:string;tags:string[];department_ids:string[];updated_at:string } +export interface MarketplaceCategory { id:string;name:string;description:string;resource_type:string;sort_order:number } +export const fetchMarketplace=(params?:{type?:string;category_id?:string;tag?:string;q?:string})=>request.get({url:'/api/v1/portal/marketplace',params}) +export const fetchMarketplaceCategories=()=>request.get({url:'/api/v1/portal/marketplace/categories'}) +export const fetchMarketplaceInstalled=()=>request.get({url:'/api/v1/portal/marketplace/installed'}) +export const fetchMarketplaceDetail=(type:string,code:string)=>request.get<{item:MarketItem;detail:Record}>({url:`/api/v1/portal/marketplace/${type}/${code}`}) +export const marketplaceInstall=(type:string,code:string)=>request.post<{installed:boolean;created:boolean}>({url:`/api/v1/portal/marketplace/${type}/${code}/install`}) +export const marketplaceUninstall=(type:string,code:string)=>request.del<{installed:boolean}>({url:`/api/v1/portal/marketplace/${type}/${code}/install`}) diff --git a/web/apps/portal/src/api/system-manage.ts b/web/apps/portal/src/api/system-manage.ts new file mode 100644 index 0000000..dd0d598 --- /dev/null +++ b/web/apps/portal/src/api/system-manage.ts @@ -0,0 +1,25 @@ +import request from '@/utils/http' +import { AppRouteRecord } from '@/types/router' + +// 获取用户列表 +export function fetchGetUserList(params: Api.SystemManage.UserSearchParams) { + return request.get({ + url: '/api/user/list', + params + }) +} + +// 获取角色列表 +export function fetchGetRoleList(params: Api.SystemManage.RoleSearchParams) { + return request.get({ + url: '/api/role/list', + params + }) +} + +// 获取菜单列表 +export function fetchGetMenuList() { + return request.get({ + url: '/api/v1/portal/menus' + }) +} diff --git a/web/apps/portal/src/assets/images/avatar/avatar.webp b/web/apps/portal/src/assets/images/avatar/avatar.webp new file mode 100644 index 0000000000000000000000000000000000000000..bea307b7d2e5e1a9d952d834eb57f73605dc55b8 GIT binary patch literal 954 zcmV;r14aB&Nk&Gp0{{S5MM6+kP&go_0{{Rp6abw8Do_AW06w)$ok}MpBB89-ZP?%z ziD>{53Yw2F8^s*TeR(t1;sau?)6x+IJ1ULup4dPqlKRaw%L00&PyRBV@TRirkLMoh znecI$mizfs;B~7~IjdMHs2{A`^5xH2WwlFsv{w~GUrD-c6mAR;p8@&L1l{|oRAzySXHiNX^g1uHD- zk>h_3$=>CvsNexHz6NhKDop!@&$-)@k^G zpxC;k(_I+TKnb=kfcV<3(d@OeA$m8)Ux;2hwn++&N9ggUrswPzKFBA>H1|z9H9&|78oYJ6 zqmYWjF_7HcNYd8We?h}xgx?|h{o>j~Y6Ort0-Tr&f;blhrY6~brlb8`aT?w+$`-AaIO=h`ieN z$~Gg-mXxYMelEHs`4HK%%m9=g4(z;v{W(jv8Yy0+*!MmE7Wb&l>IMYrKWC`)P-M2m zu$RqPHPng}*nD$a!U%{{NAQRJtAxj3Skt$rNr5Sz(*ys48Sm7CQ28#6@u$SQp8jq1 z)0|6ELT|Zgn}T|MFJ^9l$ZpXeQ$e+^QI50?#DWtekW>TGc)Xar_&#%;r9pb{&G`?s zaP-tYMb^D)AT=a2z3RNn!)}O2cKg(?2+hD+pV;B^mgx83f_)BBT#K1H) cmY(20IDjVb)DMN`D2T(p^ZM$jri0Q@7_2mk;8 literal 0 HcmV?d00001 diff --git a/web/apps/portal/src/assets/images/avatar/avatar1.webp b/web/apps/portal/src/assets/images/avatar/avatar1.webp new file mode 100644 index 0000000000000000000000000000000000000000..68e256c297cea6017efb892509f005290f32c95f GIT binary patch literal 2296 zcmVLHNCsINV&i=yrkQTpUuSn>Acb z2FE|`v?5W%`1-J1k%&b^{1D+E*v&*cgPXpOkC1j6c3zl~THPT33XotPrLtyQ6T5WG zK)!TAT}RO#s23>vzA4R_R4Z`A z)!tkNowc|9f8G#J-=@;b?8g;}XfMv3vXpQ%%8H}JEd>Q-E=Is*dOG0dKP#mpzK6Zu z!~hOswhdJQ>~$JV9YE{yxG0G7eyp!4WjSqX4`vFrIzpIQ0C7!XfcamHM$sc`M2fb8 zp?r)l(JQ^6s8qm)T%}1_SBx8Ei&#F=-z9}B6N}fuspI`o)nIez>LrpO#ZCv{{2?o7 zizMB-j`ht%neuFw9`x-5QLn<^-`6#2Q&Jjy+o`+KTIQK8(?v)43qCH3lBIY}J^8V+ z!&1a_htv&I@_M)c0092^2zg+Z2FA6yE){J(rlOXGqB`_f57m^A&4PKhbc!KFs7OB8 zdlaLr7TvuJI%#4#-P7{r$I-Vawb#sYrY1ZHul|y?8{3F>u|DwD9~|=ZB=TKNp#;aH z6p{8M)-)<+c{^=m6B0X{RudGJjVD&&L_)4Q_O-UM8aHDF436jm=6;o62nG$yl1N%V z^US=K*lNl}uT*xK!uV;kLRBU>FcxuN!aJ-6^T-zU<4BjZHoo!!CG`NKe=YJ;2G_dxc7RRuC!*ki8f7LL%n`-+KYn3a!J?P@;tfvk2Nst5@wK3y zRdIUSYB8(CZTDhk8nxBMCfFOiau)hb=Rx7ub2hR3WL{Hyrpsf+J+hGLMDG-~woLz+c}Z@!7eEkAC9e9%w>(!zlJ@E3xS^#||H1lMw>!0H5qz`? zDTk_akMd^!5{RFXFk>aW@yCUb(ne!t0QU(B9Kw(BX97mmD2irsOwo)JKe)-C9>6-W z8>zQiA&goy3?**OV)g6ha!+LWYa$kEDvBX5cFt_J}Y0=#0<)9Gxp~2QlLcB8}FqW>Rt=Zky2M5|a z;Hc#Pb0Ak4j|G(TYytIeThz9Q7q9nW1+bIw%qZ{hkGJdCN^(2I&!>>*jiM;zsNsay zfD%f*gMDH+xlIIpl2^zsEveXG-)Itxrc5f{VG z2+%x%J-=#LxVgp3INtBtb9Dls=&UII?9k3BspvyU-yDWy#ZZ~p{*3H`GxnG%ZA&CL z&*2_n6+ig<_r zr$au4SfLz_%DUXgc@3zY%{O#}L*SV!h@$ z0&J2lMSWRS?_2t+t%IovykIn^Gf0E(4)1S?`Q&xoPeYMB3I7AQVb*GGTG&qx)z35` zm-*_NYHX|F!A=SFfAm+@A_k_kST8zZ^As*W84Ywga!W-8E;52QU#|P1>A>0AG0ZKh z1{j-VQFdICVO6OER;VL$V>bR5N9PP03GmD;8Qdn}J)%-_?J)}HeZciZ5q@(VMY4g( z+?!k6V8t}Nw0vuxbc5?JCw4MK7peL5#PnJHvA}FX`~lUGz3J zY|qT!%$9q#ltNvl4zfC+1!n>208_(%8;BBBv?g=;kmh|_nCPRCC&3y^hj$-@-t&>} z-b^A#l{w#iwS?_Z7@c;6zG?p5>zH=`r^z&UF!0@+o>6G)n+?TjkXzz577X8g2C<5o SVD2lzkuUx<_ZG<9l>h)e2Ygfj literal 0 HcmV?d00001 diff --git a/web/apps/portal/src/assets/images/avatar/avatar10.webp b/web/apps/portal/src/assets/images/avatar/avatar10.webp new file mode 100644 index 0000000000000000000000000000000000000000..a813d4c23ca9d2945fa089f0e4281a7e6e122e42 GIT binary patch literal 1410 zcmV-|1%3KbNk&F`1pok7MM6+kP&goN1pok0BmkWODxd(M06w);rA;R!qoSu4ia_8N ziDxRj@G(#c8w@MiZ2Pm1xtXg&SV}tO*upn_$e3T{3IKsG)2P7zl)<|FlG0beLZ66? zP8MKY$g2W!|C9%Jz7n>$67R(*k0AH;%P<`vmTFO)=V##pXZCZ)gtN(Z%p*SC`lfMv zD>&oYT&*hXU^Rn_m=qyJP9*z79qaCF(f*3BmcvomuMR=u8kHme^Vh+Y#SverH3t|FRmiPRS&TRUoCKe-bsYMg$7@O?MPgGwcid>XA$|n6Z=L) zZ2uB6qou3e=(axuwzL2Vp-2eWFAM$_IkRILs9hSH#S0Z+MXb;wb8oKdBte3;R)bA} z*+BGDjYM<{i3fa8{Zlse2Nb(xOK+^ybEndK`Zlp`kDA%hMHUtv;J0j=R{~n*D$iXB z&!JSJ=<`@=^kw-&i^Bsa0`G0K_b+M}`aMe#zB&dJWAP(c47c>965b2)B(4!Vx4$; zw1H=hX#>fy7P?nk_rl#aH{h1yQ$2BN$smP1u-VbsMnWv11? zLp058?xk1an|Sw(?NjDb;uebjVkWHHiQ7W(f`P#D#fli5R=&I_)-XU?fc6SBhQ?kN zJ$AQ42J^$gWWE%oe-N;M|ApbrPsxz_(cK-~ZAFgxPE2^B`Q75YhbMy^7Pw~YD-ChS zA=>F9_HN9N3FH?)0f>*-9{l#atdGpurvFT^8H)aFa0%PFn~F3`ubg8gY$SI3_!~2` zS_l(^H=T)7S7kzz(;dvwpf17sUZ}3lKcvMGGLfTwlHX5cAC;>SC~wT_CZ)TPLJ|+^ zO1^m4YAnp%qoe6yc=?;Ew9>7%+BplwY*&G+-T%ccSxkn1Qw7Y2OFWn*`da)d=D&$8 zPbb~qkF!m1wX=~@J>R*@ZYZ}fr6gbA|E>MzHvxRQN8k;s6giiV7hzBm=J2h|Fq!!k zd3*yobrI{Cf)}+vu8w>&2vWX5W68fofgrK9qFsRY-2?*b~wc7TUc73C>=31jaEDl4iGgxUWZ zB4rTdKzTY{)gGK&8Am&B2>R;B9wKiG*l5R=A=#t?3mmUkxUGS7kn QTF;h&;#lF?m{x!Q0CCo}*Z=?k literal 0 HcmV?d00001 diff --git a/web/apps/portal/src/assets/images/avatar/avatar2.webp b/web/apps/portal/src/assets/images/avatar/avatar2.webp new file mode 100644 index 0000000000000000000000000000000000000000..6716e3ff9a7c07198562500a8b7b15d86eddd92a GIT binary patch literal 1214 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 SOT){6x=sa6x ziDv-LJda-J@i#;hzW8~^&ycs+x+Z)S_h@HPs{&NWIsvF}Yf|wew7S9Za;U>aQ$~y5id98~wmJMzcHjDBq)*uuBf~SLu z)Gp7IuZx!uI9P=5&_Dqy#yuEZD#{4@MMZH0ciM^|XF*;3r z9;A{NP`bRGi#ZJEeNWg`G5RMK9xvPpo%yTs_LyO5m9BZ6M8d=6DrzKBk#26MucKg% z@Jmlisk&6TglV1uE@+Mi6dU?WTlj^OWWD+VusOAR0QO@7&Is!osAf6}pPg{NW71Lz z;rRW1v6D6$EyDKb`U3Y#iU6JgDBoZ>M~GrxedkT`+jJu#XwKj42Uj3@#eB16DhPMe z?b(<`( zs`~_!4&_XB-I(Go!Ti!(B zzX28-dwQ8RJL`7&KAUMlIpDVwTj)zHMR}xDz-D@3~Cc z7|ZkGAVEdXA(ao{SKLu?NRz`qi;$@P>N24l+deOf>s))>An)p$#2X!_5;0xOako)X zjIOe+FA+F+H}$q69Y(8V+=}dvt+qbRSF%REpd_z<{gNTS|1wDRih&;=->ZRCM90dv%N9SSzr(f;PM-%fXrH3Pfe2f(DQ2NV&X2&-DORYw}T--4;V);q5 z_0DY^&XL%`xt(>cWVu58mGS#f*E!W%9W)`^Ht({sb?}UPP-X{41Jd+OelWn1DB=!? z?MC>k2#5dG-RGm{KTf5t%~`eqSg6fR1{uXI1M|uCGp8g4^^df%E{456Kx`7P(>#EL zmhGFzWA7={)deTE9PtU4F*C@=vOzZKQHFT0AgxVwj1%kfZW*%UC{wbm;GRO6K_yc< zw+EhfXW12v2HeMBD0!Hq=)pPKt|b&p`+N5s*oO##&@~ZAhA7o3S52Pz{bcveBBC5r zCvhxa8Nh;#y@aO<5eJyW?$Hl}R1WvJQx&b&?*|4N4T}!E?@yxeE3b2sV1`a@^R+7J zp27xGD+@x*xmx7d&{M%Yfd#$BB*qzx1LzqTkQa0)>ycf>%P|WAHa3;I)bi6;o@^&8 zlF!9&kvZXgXme;5J9SggsLI9x!bbkq`v&rTj4TS4=%It-3h6O>)z;jL+26!mg@0s7 z{4(NDv3IR6vYgZOFlTl!c!b?aLBP6L#|@u(xpJ83IMLF?7);-IWQly}m|w*of%ad$ zs4&NHOL!Vvik=DMMaQ-=WnunMXR#%% zIGbDlMeFoEBXYga#NI7n8!Wj(pa!rZe>u0q3$8d;_hEE_F!k`bcamCdsV5*a6aolrOAz(+B zd@P)%Sve@b1T5Gzk>0D?uqoxzL9g;Hq#>W|iLtr^A-5BQTpT9ebo8522y|Yi&}N}+ z!nlr^T4DNF?&R$Cvv9rm^Me9(!ik`_w@ulxS$XEX<#KKx{wypTfIc~YJvA0GHEjBa z{tEX`KyB>SnrEk(@oucu?SqXbBaEcn5w^tTc2WGK(%4D)aa56stfLL0kuMEM7Rqi; zo`xQal&o-#zU;IGUcZdFg?D_jdE*(s*c%p)6)*r98xXMy&ILWlQaYSn60FD<1I}dkS@TL!3$rhUX&agD&Hyk%~IJImH>Onl3MQU|Ay%bPf&#ECZX4~J>IhG$r&FUr~Xpd&X)r{G|={i?b_HnA@GVU z)1>)kALc5Yj)mf+#4Bd2-y+^^2?%cJf4F uD!N*}&DvQ+z%4JRZt z**wNaQ)XNFP~Hyc-5LfbIC&@l{_)m6_A6qduzFFQLe7hG z+`Dg7`MP=8PMs#W z>)Zvn?0Q`J3a~$cdz%M6qCYPRal2tH)ADr7pFDSr0hhEecfKJAnD?cGv8&)tngt<% zBEa;{SzSMNkCB(s_+f1EmeKCik^88@dGSs1>7b6hrH2O!%;F^AILl`PB#npS3=mrv ztu!z!I`*4f;;#|N@y$x9puJq@e%MLELvFmQ$7sQp+jYi8zXn z7!yB{(N+rF_;=ECz2YtbVQ{P8&hmx-SM^Px`+Fx?{VVD?*kb%yULK5fou&W;zx@57 ztjN5fd9K(%4A*ppn$C~0uwAicQHGUJqaXtTq-SEO3i*S5UtzkmT-;2S-*yKx^S4)p zu(xtY`uuGFdnEcF#&D-^5r5^8ea?Ikb(enTfiNd*6A`*r_w3nuP4|nOjv4Ar7>Bn* oum=sD9}=@(JYWHzN=bN_eTt*WCui4r!^|MQ?*(KtUpN2&0LE>RZvX%Q literal 0 HcmV?d00001 diff --git a/web/apps/portal/src/assets/images/avatar/avatar7.webp b/web/apps/portal/src/assets/images/avatar/avatar7.webp new file mode 100644 index 0000000000000000000000000000000000000000..e5ef6fea28c4dea1f9e10e78c8183bbb544b75d6 GIT binary patch literal 2712 zcmV;J3TO3FNk&GH3IG6CMM6+kP&goj3IG6bGyt6eDxd(M06w);p-ZPFBO<9-OL*WG zi9i_aoN0I-4^TUVIodrc|E|84pR(K^4Ye}2him)-9_C)Uf2WJ@`4U!&jCB@!jQSWf zkWi1W!e|7=uqM|}&jWCK0bJPHdljLAMtSOBhK2FL9vIJwZ*jR0^qz{%o|;eu@jIID z>1Dho=M;OiIBQ6k90N7)U-LUB4fNbnB zcVd!#l@ge#a^5^>ovyb^=oxMdg}d1U^l`2Fm~d{`GrP+hqwRdlsSp zZ-P@AID|3okzavUmSMCE8gScpMng0Q`5{KtM0ZUi0ncfw=fKTHEdntzAfZJM&W&Uv z67K-k4KmgBg(%$Njk|Jg-*xprnCV_zKfhnobWW_!Q|jolDX+OMrq@H(J)qOVrR-qv zRIBNovB7=pht`u{?#HO!58IuZqG!54`tQ%x7=FyAh}uj_ZRsSbgQ2Z1#W=b?n({iD zm$Y-O(G)Itc^B80u=Sua!8J2(sb2BUsv8#G)g?fpX(OpesA<(_`VsuMZ6dfL41iYJ z?*M;4b?t@DbYfq${(Al({l4n!l089uXnn-u$MA2MX|98@2|Cx#>)`8d($l@QFME%7 z_6}QMOoeB?j@rwb*TH=HoDV(FiLvgaLY2@MYNJ=k3TkS{3csOEw_&C-HxHy;ajX(Z z@#lxXph1y}+iOME!-6i8f&P2>gA0==<@-?sKQ{II&j$D`7MMhkLpi2;N%;HPy+G{o z000uB!pDPiCpwuSQQiLY##czfeQr3R&d!)&9!I35n8R!_q{}a)ZBY|b^-u<_=bdjLM zsH;7oYDXr^TFEo<|H}DS%GT1C0L2i5=EqD1g9i2J^W;u>#;j@z=K^hzFU= zYY2QMrIAKg`G1?4OlL+SauH1y`A{Uz#84Uv44XbR0-O9-ol{Wgj|tx$nKLP-P2du% z-Q0jz6G7SVI+1s!A4*xZp5N|eZYSJHTa=>)Ajvyi?EeB#w59bKeTsO@(I;I(WI77R zTk~JMwUgwE@w2~AD^;ya94Kp&WMaW0WwLS@H`8wZvU0zjb@itK&I(FIx@VOp7hB0H zotLS1e03mJ6E`f%jAQB%^&LSPkvnigLijZK-J)3ej9wE3&g(7ejXm+7C@$Tjez_I- z&tVa7hX+KoAg}8LEcb@&UGwPMMlWWtOM7&0LePzZIRAMRmZmAs1`e%T@SSGa=*OGY zhQzw}Ca-M_vl&mQDqK~wClFH;H49m0Y^Ym{L4&A ztXLwKYl_@y2ljr2kkQYC=V5w0{+F69RiFdTm)%T&!mfzP-kw#`aCmmRG$H1>hu-6$F77UAgTvGa;v!U9uan0`(ci0>}JF8n)U zn-=c1mA3i;uD8W=(^j(nz4X`EN%~rB&EFjc;cV(?)IMy<#JIAZJKCn2H!jd#+FP3d zh|Te*k9)A5x_gw)Y-n@x$ zX==z!&?XI>ePW83uOwKEkDLxe*zeJA4Pq9#;q%y~fS0-v=()A!rQFSY%-}3u_+s~H zW9zimEqBTB=n_B`hS84Ispf(XDkch~Aqqj;#aFJNd-kp*#*uNX>22y$d5{l&6GXjo zj>~j?H5qFF3f<<()y8WFAO-OjT6Bgy#(Rr=E4LU_h_>6k79MC*;=Qq*6sN;;TC+an#IgsR6KrAO z$c@_{eBZ9#Y{_X^v3H=kdKtigpF(Gy(*r=wmktt#QMs28C30ForRlH#j}jMaWuk@E zA_pE51~<1C0n?zwLyZVm9FlRO5^l`sqbf$ri^uN7MVKB^fThgOshG+$Pouyye&KMJ zbG2%FWThe&(<80fo5owGc^_cOzvBH@`F?*~*BqZnd>}Yoo_nqw(`pk)V z*Z@dqLMOC}XJjR@tX1aZzBlMg5_@O^19uBchc*Buw}AJhXJgG{jpTle SlnZDgHxy*uU+B%U0002~4@gr0 literal 0 HcmV?d00001 diff --git a/web/apps/portal/src/assets/images/avatar/avatar8.webp b/web/apps/portal/src/assets/images/avatar/avatar8.webp new file mode 100644 index 0000000000000000000000000000000000000000..b66e48f7b056cc570d368c4e0f5d962216d29971 GIT binary patch literal 3946 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 E044g>D*ylh literal 0 HcmV?d00001 diff --git a/web/apps/portal/src/assets/images/avatar/avatar9.webp b/web/apps/portal/src/assets/images/avatar/avatar9.webp new file mode 100644 index 0000000000000000000000000000000000000000..7974139777dcf5db9848b8f0b97b58708d60d369 GIT binary patch literal 1680 zcmV;B25Cq`s!Rw6r*sJG2T- zxVvRExAV}fjI%u($cwje4h=Qh8M^Vhm$-}w&Q^=5w2y(KN)o9n&3k|tlSOs7*31#L zZd*A}kBRj5Ao@<*ftTKbJz|QgG6yI1)!FB5ojbeD5SIT??x5_85>vF3(iY~_s}B6z zJIL03h+X)vz5D?ncg&2BI7M|1dFC`>e0JBim5qthgnd;jgMITvIsJRf`(5IKpu&FX z-rCs3S~Eqe$321H8YQh+4o=)+a^L}065GaGUCEK0%hpy+*Qx>$m=Rv}gtBWa=77lX z(VYa&S4AY~L9+k={`NoGgSxyFZfl)Pi^vA#KNDL6dX8Q>us5c5t;6L0o&x6W@m%lA zDM)$!t-!khhGc^`eu!>cuJy#x%f0VI5!yaa=td}Xunnnhr8ev;uMHIhcr0J$XyqK9 zuX57K{X^gJE0uk0OB+EWP-c7ns_RMON}bt%QVj{Q$m;#|WErNiIZFFI{kvAmC0JDA z6S~aC#;gaT!rVstFalV1u>i%0BG3(ck`%1_k7)7_k4dLbflBWF-$=y zYXuFm?Pkkw>>-p*ze|ub5=lw7qz?%ivp7%e~o)zuOB^QE*8~1b{~M=$X$;g4OB&<`_pQ(fAcH!qE9%DO$8BLT*BZq0o#qHcMiMyX~<| z-=+m)v%h3{wy>hD`#}La=L=Ej{j!TN*UL@gmGY6yNO_SKe0~V$KMCY{uAJ4ke=00S z#(Ss89O&bj<#0={Y6sz^ccLBU{iacl0AlStaKAP5^H&%N#arv<9wubKsR@^3#Bh3j zH^cK&-QW8*&El;j|62<;pvqeA-&I-=$SY3PHw%JH{W2HBMLgw~VZOlbad0jQB0lI? z&A2OQ_kK9>21w6V#o%fNugN!cbI}(?Mnr>8{3YLGv|JBl>tsulVv@2-!G>Bp(?L%z z_4KMfeT23VcUa&**Qq9gpKCv+thWy@(5FZ;SP-pYt;n#N8hgyGcT~yp0o7wWy%$XW zaP2yYd?+xSzH1i5u=5^{bo z<@blJ^<1?6)cNf7eacQ)dqC94Z}gOf7S(g?b+Z=2PdS8(tvK#JzU@=G-<7lJE^qf| zB`U!>-htj`Lio0dQ+Ic}ohghs09r;1A1!tJ0XI!YVxlj%Az=?yczeG5GPnkT{O zS^iGH_&v0;c>ZPbqB~?md!#IKb(FKUmVRUw=bZ%(nx>OTGVw(Tc|O;5oLA5MX`wQgQMaiXBB zpcHtwlRdTvXZ+CKlb4)N9T#5@t0>l_Y-OHSL)^qXurPW*__rJjBN4{NpNd!kblcd- a+aN;YfSUTyaGIA8bqP%u?tI%K&_Do!2TN1{ literal 0 HcmV?d00001 diff --git a/web/apps/portal/src/assets/images/ceremony/hb.png b/web/apps/portal/src/assets/images/ceremony/hb.png new file mode 100644 index 0000000000000000000000000000000000000000..41033245879f1051f34c33766bb6fdb4fffb38a3 GIT binary patch literal 2275 zcmcImS5#B!8a)ttO(dY8U=WaA1Y@Yu5|SVSLls*nQRyN9At)NUNHK_l2oV%TMHq%6 zW~HUV&dd2T-CeP=QYuma z0LbETXb%7o0!tx40s`K`s*ZSpjlH{z7Y1zkPUy`(6yFK+V`I>pEOS2QpB60u2atRa z*&=|CHo&?cCD<6u+7|PZwe=5v|Ia}Tf6!8(^HbId%`rgn(T)P-2M!tue5VWrG=c1Y zkT+Nd_-qS47W*&AGXD__%+_x6kNBj)76=aZn@?`(2$HZY1yh1oaM~J2Z-T)_>suDMOUNnq>hN9D^-_6J|>Uy(NO0(zje=p;q63A<>}zMlWb#0TvU62S8ac z07zD!Aq9aSF*wG<)f1pZWe1pPJ%2*G6%G6F_GRUjI6r$kBgt=^G5T*;Bzg0ljC=>^ zTA$bQySC>KX({n%kGSrXof++Ks6K%aUFVLzA`*S^mMFw_IJ=zSVXphv=Z`g|v}=@v zkW=o5F-T**U1gO1`|gn_LJ|OoE#T4iUh%`+ku$z3_HZ#yp`3kNGF)E^iVL*YYTQv? z8+LCYGgG%>Y?9BXh&0l}l}5J}sGqsxre+&;o>0IpvWi1UB>&w09{yeJikG(;SxTLS z#Wn6tiR!tOM!#_~RsUW`wJIH9zsm|cIN*~Rvmf1v}8ir>tu{H8@G7<-_?S9f4WqpX>ZAj-RS!{3iHgYJB zUwD;AvY_vo`WkkWk(^XsHys=n)m2qKB41Wbsh5zOEWl?xo1jVXChu6YO<~L&VFjYw zw#cTr@2bd+kbW!cXN?C?T-A;JvN^iHmR^4TNyPNjBe~X$BU#ALLS7$a{CvEOYDRRV zG6%F*{<3bL?KU@yIzv>b<7oQjr)z$E;WZSrh^J8kV~U^F+JusB<=9WDqlDcRbn?lo zDw^VpRVgbFTqxPpSSGWpx|7=Vg`GJH({vk>Wl(p0=&mBKY>r9U9Y&BHDmvuUl><-- zke+~Sm&f6DQ|Cw)7o~azQXhx8IGA?04Egj`Qze(paIRz3;vJsB&Y1brzu-=qc%C*W zd2@Bwt3g;?GotQhY6qXmmG&yXwFUU)Yf$?(kc(W`pdCN5-v1v zToE&Uf$CjP!!rM9eto^UnAX)6u2_d=JoFBbCuX!>Up9};7BjBz&PorOhBeMurH0=f zJhhx@)-9aJV>Kk5tAI5H(RWDg%UMq1Vdil_oj?8Cn($3MxV$#rSQXuVp<QP8WMB>}Vqe`-kf-H`C_x$6V+`uqTu6i7O9 zesm99cek;XW0C5kb#mWzqqrL(Id-*-nH3{LNVOzgqb`tZiz|gS zah?EmIB}!yIU4$WTNq*NILvI~9UMQ^rq$Ss{Djx zCuivMP<2n!v=%s=P&EFXkiv{8Pww@7q^4wA($G!y(@*=7XW2erCPmTxN}3%W@7J@n zaUixi-xr&6qZq&N&WHO&%gqHbB?%`2iTK}Fk8vLLzZD8MFBRvK-fQCu49rUh$Mx+uW!0jUCcU-x}#WRz?c^S|49F*g^)J36r3gg%c2}Kte-E~7qe#2lqfT1 zofWC(7}s;Jxq}l?&*3doa{VHgsRu_M2W_J`=m3<@h2QhDWJ^j!7Kdz;Bt^7g4C!;^ rtK9?{oKY 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

TD-eWE$*|X9h61tDog6 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 ZSO66fIy%JW+RF@^AI^XP00000008fkeAui8$xP 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 literal 0 HcmV?d00001 diff --git a/web/apps/portal/src/assets/images/login/lf_icon2.webp b/web/apps/portal/src/assets/images/login/lf_icon2.webp new file mode 100644 index 0000000000000000000000000000000000000000..5e4f3fd0dd461d4e92ab3222aa9f546e2c4ca2bd GIT binary patch literal 25016 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 Ef{6$HX8-^I literal 0 HcmV?d00001 diff --git a/web/apps/portal/src/assets/images/settings/menu_layouts/horizontal.png b/web/apps/portal/src/assets/images/settings/menu_layouts/horizontal.png new file mode 100644 index 0000000000000000000000000000000000000000..ca779bc78ad5bf45e4b26e4e75e6481295383369 GIT binary patch literal 409 zcmV;K0cQS*P) zvbn(=z`lWa1n(axjo^q~;4t&bS@^%KH`vFc7`p2Q0I=EJ`>gf&x{iCB)gIPy+H$?# zI&Rh@j<_p{ds>?h{E0Z?i2HPk&-@4Ch&w}E!NafkSzG;R2LRxg$z)yzam_;Ieh_DF zDlv-FwX;E-%i6gQ+qsC>eh{Sfe@zd^TDtroDA!A5hii^ozT^kui2GyQ`9Fk7vFhZSNmiXsv;oD2I1{Wo zId{pb(`jB}R@*LWXIIUn~YTl0Pqhc#kP}ccEGly z+kj$coC&s_oYRhWw4>dQcKbJ=%iasfw_w`=03i1PMtw+J%=}$J00000NkvXXu0mjf DJf+bZ literal 0 HcmV?d00001 diff --git a/web/apps/portal/src/assets/images/settings/menu_layouts/mixed.png b/web/apps/portal/src/assets/images/settings/menu_layouts/mixed.png new file mode 100644 index 0000000000000000000000000000000000000000..c82b58038fcbdab260ef4df85cd0bf53fe232ecf GIT binary patch literal 431 zcmV;g0Z{&lP)Yy$(XK&WqF=)bm5 z&;o+?zlB7C)K}fi`)JgA{>Jxlc!KXfgb6|jAx2Z&sveg*c(ShsoP#IVp>AiIo`88@ zr(tz#nx24pU$**en#2z4P8E+W)>TaYs*4?@TZnE@z)$QW={$9Pq+5t?5s4t(vUL8d zk>BD6MzrHvYjymk9q8Cd*CO3GT_5SB%UpMi1j=u9n@Hh+b*4E&dq@{atWMW0ODCOl z4eTM7=ZGc+P_FT6@YfY7)!;zX6{(>H2c9mqfCEieq?Y>qVa~er;Qc|m&m&qoqK^6@ zVBN$3Du2{%NO?+K9&mf!tI@d6TsrBbTaC_bh#7oGv|&;J<@f7bI_ad#=vP1p(n&`P zAWTV(SImLAJYY(!i%iy}>sF+bPPzt{A(q{UW(H7(@yY~TT`{;`%(2Y&+R=+kRYC}n ZKX+7z8kqwQRWJYm002ovPDHLkV1jqY&3ym> literal 0 HcmV?d00001 diff --git a/web/apps/portal/src/assets/images/settings/menu_layouts/vertical.png b/web/apps/portal/src/assets/images/settings/menu_layouts/vertical.png new file mode 100644 index 0000000000000000000000000000000000000000..16e942b0e9f3919eb199134692e32004544b280c GIT binary patch literal 439 zcmV;o0Z9IdP)(# zD2kaKe_n4u2iGeYpx=`+HyKd(2SbJL9}%}U{@8`CedQuyi89i?#7jp@$(zBsZj{qeqluEicf z*6Rj#SRgdoBZzCSqT|}D=pbE-bUD@21=xhdeC5x5a>aJJ;D*%S|aFn{?|8j z&_So{5lo1xtA1;g<9us`n+K#rx>Kg(1@+SDdjvb0&iU3z)8W<#y8VnK9qURScO3WW h%8Q3N0002Cig literal 0 HcmV?d00001 diff --git a/web/apps/portal/src/assets/images/settings/menu_styles/dark.png b/web/apps/portal/src/assets/images/settings/menu_styles/dark.png new file mode 100644 index 0000000000000000000000000000000000000000..e1653b7a657edd6e2e83b8b479bb1b4af4620e15 GIT binary patch literal 292 zcmeAS@N?(olHy`uVBq!ia0vp^cYt^j3p0qNwsRej;t%i%asBk^tGc?@&!0c-?cF|q z{-!dsl@Ta)!PCVtB;(%On+JK17;v}*%3nHAmJs>!0B^+Y`oPQsN=8b26YP2SNGmV+ z(<1Wuz(Ft&-I2P^pJ{r#^(-^z!+)>dS^035-TET1?9HMwt^CXP@rda`@oSCvV)pRD9`Ic+V>RN|cZ9-Q}41PonSK>R4CZA96Tw@1diy fo(~{Er;M1%fByXW@#Dw4 zckfEI`&$F0PIK_}Tq&4}k`{J&n3>JYDATM6ZXdH1D1|rwka( a+00#Z%j6q-R9HLER}7x6elF{r5}E*&a(=A< literal 0 HcmV?d00001 diff --git a/web/apps/portal/src/assets/images/settings/menu_styles/light.png b/web/apps/portal/src/assets/images/settings/menu_styles/light.png new file mode 100644 index 0000000000000000000000000000000000000000..3007b99ea227f5f203ad309d28358d95aa40aad2 GIT binary patch literal 293 zcmeAS@N?(olHy`uVBq!ia0vp^cYt^j3p0qNwsRej5(@AMasBk^>;M1%fByXW@#Dw4 zckhOVhNk`~V+6{c^K@|x$+-9SW+HE^0gvlLR`&CZGg~7o#6IM+GMaerh)K|XF7e~v z*2wU>xm~svV6cO!eAexM#~%n4hWb7dn;9*-?$zE?M-Ku)rRC!eF^lI+S&sX@aO&C)xGbBzUl*1H|~cH`T*7S|L8+xoqd^+pNbuX$T2rdEOtd33n=<}rH(r4Bw?X0 z19j9Zq$jx5yQq&=Rv*u@&uV zZv5wD2VJi=A#|57GsIK59E1=t&(Kn59-)r9I&_O8$G{R>i|A%Y2O}3yS41Z&E$H;i qY~iWsL1-c78CvShBh*n>SoaHFuY$So!<9_{0000$3b*O46wRy83EweL3E-p zTDPTC^?=rG?@;xC)#cYaJXSqGb<2^89-z9fBYo%rs(T&jp$Dk$Kk9+HS#|cPcrJon zbI(i6T+zM{&>wNHId#;{s7qL=Yd{@!)cq1mSEY~blI|(J>{oO}dReaMR_P^Q(MdlO z>i~~A@u&cbFmcTrMB15WsH1KUUF~df!d~+osw?&ewtya>4AqH=XPy0+o=3$lg6Eny zh_o}$P)FSyx+-_rpi9hVN3Lj_1$lsMGW5LKxX|5xOc$eay9h37-XPM>JVPCIbLeVE zkAX{UAEGNAI~aL@@(`VV>$4IoA-;gkJ&1+Cgm8pmlOdPn{^_RrAUFTa z?Nwrlv;qeX95`^`z<~n?4ji~TxhJFJb(>akZP2?200000NkvXXu0mjfdG+rO literal 0 HcmV?d00001 diff --git a/web/apps/portal/src/assets/images/svg/403.svg b/web/apps/portal/src/assets/images/svg/403.svg new file mode 100644 index 0000000..68790ad --- /dev/null +++ b/web/apps/portal/src/assets/images/svg/403.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/apps/portal/src/assets/images/svg/404.svg b/web/apps/portal/src/assets/images/svg/404.svg new file mode 100644 index 0000000..48e1ca3 --- /dev/null +++ b/web/apps/portal/src/assets/images/svg/404.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/apps/portal/src/assets/images/svg/500.svg b/web/apps/portal/src/assets/images/svg/500.svg new file mode 100644 index 0000000..512429f --- /dev/null +++ b/web/apps/portal/src/assets/images/svg/500.svg @@ -0,0 +1,5 @@ + diff --git a/web/apps/portal/src/assets/images/svg/login_icon.svg b/web/apps/portal/src/assets/images/svg/login_icon.svg new file mode 100644 index 0000000..4beb3ab --- /dev/null +++ b/web/apps/portal/src/assets/images/svg/login_icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/apps/portal/src/assets/images/user/avatar.webp b/web/apps/portal/src/assets/images/user/avatar.webp new file mode 100644 index 0000000000000000000000000000000000000000..6d7234b9eb1d4962efb9d01a4703fe4540d3ec34 GIT binary patch literal 2130 zcmV-Y2(9;0Nk&FW2mkB(|iQh=J6I zyxsF})jWZrx1zAfq`;31uOrtG^aslZ++-*oHbA=Ws=r@BH$nEcT>f}v8c<1MaH|2|TsFVAnjLEj8 z)v3~&1Tg$gPbv>1OlgM(i_=4U(n13#54;Dp%@xYc7c42<_;HI{&blw#e zNxIkz>nHOh3a&_eJ&|2SelyaGrVT5H-&xr3qchs0k*P(yrJZP27PwoGyrzP(HQNq) zdnp%e(8+0Kvd=#P@!j@e@d-;H6ynU*1u2;INSHcM)CHM>4FQx&W>lUOt}HM!x%Fka z%X5GL{_uBtT#(I|h-+J>-VdN=0k`87mNLbrC!e0hz)eum&xzb_(qj;)6C_`RprQp+gHMV1x!s=;2Rb%2PsYfg* zh??Ve&~jKa^pwSDNm#|Es3PLmb7sTuZ(x2P^RA`Y&UPQ||EZaGfbNJ@(YlYt9%HYh zZd6LrWC~-IbvBzuEVN?AAJR!ig~P0-MlXvR7Z7bx7?0-n<*4=SW=S5*A~>8h3`T5! z`I{Rw5T_p0VkFU17Z&8dG9Tw3V-0ioX90$ba-jE=Z$T+Ka@XHj^&Ah5&3^#B4xzV6Zt7h5hW%IMfcreWr(GyA?@Q(v8ZjmLibkv4Aj2n8A)< z=`*~B`m=?dsA{+ox>qZp7zsL(cLHfKRXL9|zDz?~SSeGwu!UZyhdt^PsU~45yW9Oj z|7T$>K9wSeG)6;}llOZ5%}(}z0=hks13|B$4L31VdOF*sd@YU?jiH_PWPK9UZtNa*gq3V&9l+*yS!e&SOs`1NHt-i^1q)x3)% z>It^qg|*?RqvUXDdRScQn|t^Lu)KXY5VsWF%#?Kcx(iSN8x!^6ce+4JmX87y6y?wZZ4&b%iwh!QfdXkYoDFzcx@r36&& zo2APee^J><5DkZ2e=>DFvmAxNih)1iwKOrXu;mfGI%PS5k!rJn1B3wHU(p$^yBEHx zq~i|&7X}r0s20A5acrQR9#hvK2Xx=6c*tFM@J9UuD8ohc`RLDsu}deGD`*`c;m8QS zgCUifRD^>%<4S8#^2TMADqw+gjHZ>sulGW1(}gDKwF2hjvQB>n-jtmz#9@~4F*^x@%R}e{Y2&KCz?duu^aa>y%iPm%1zqnPR{G?Y4 z48C*_3ipwVb2oo`j1WM8AYDT&ioo^DN+XA-8Nq=KolVDJ>^(q9Nykz^AO8@jU-TDL8=@Yp# z`0V^~J;sb-xt;21tD-A@Q!b4`@x|E>D83vGJA5%XgyFaLGshP4j^0BP@nJi;8QPq} zZasCOfyHw%T^l_D156Rxq9l?k^_qF>+c(_`j%kBrqB>?FPa!(O&K%uAjw|3vex zg~6iGGvYe?pO(JYfzZ{%9l2^x{GmywWC-Ereh^T;KWV6K)&<>!={pX|l+g~?h}`RK z0g*I#Xa`GBWma{trr5d?fk)++ZmoO}fx6-Yk^Ncr>2Umkn-MFU(O?_Y9peO6vtnRF z$QVFE%8MY|Xohq7+4+t3Y-1=b`+7f0p6}a*tiScRyO=V+;Q3*I3IM{IKduC`j8lKO I28@6J0QOxWo&W#< literal 0 HcmV?d00001 diff --git a/web/apps/portal/src/assets/images/user/bg.webp b/web/apps/portal/src/assets/images/user/bg.webp new file mode 100644 index 0000000000000000000000000000000000000000..762b22dbbe34305886a78b1d103dddcc61d106ec GIT binary patch literal 12352 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 literal 0 HcmV?d00001 diff --git a/web/apps/portal/src/assets/styles/core/app.scss b/web/apps/portal/src/assets/styles/core/app.scss new file mode 100755 index 0000000..c0efeed --- /dev/null +++ b/web/apps/portal/src/assets/styles/core/app.scss @@ -0,0 +1,292 @@ +// 全局样式 +// 顶部进度条颜色 +#nprogress .bar { + z-index: 2400; + background-color: color-mix(in srgb, var(--theme-color) 70%, white); +} + +#nprogress .peg { + box-shadow: + 0 0 10px var(--theme-color), + 0 0 5px var(--theme-color) !important; +} + +#nprogress .spinner-icon { + border-top-color: var(--theme-color) !important; + border-left-color: var(--theme-color) !important; +} + +// 处理移动端组件兼容性 +@media screen and (max-width: 640px) { + * { + cursor: default !important; + } +} + +// 背景滤镜 +*, +::before, +::after { + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; +} + +// 色弱模式 +.color-weak { + filter: invert(80%); + -webkit-filter: invert(80%); +} + +#noop { + display: none; +} + +// 语言切换选中样式 +.langDropDownStyle { + // 选中项背景颜色 + .is-selected { + background-color: var(--art-el-active-color) !important; + } + + // 语言切换按钮菜单样式优化 + .lang-btn-item { + .el-dropdown-menu__item { + padding-left: 13px !important; + padding-right: 6px !important; + margin-bottom: 3px !important; + } + + &:last-child { + .el-dropdown-menu__item { + margin-bottom: 0 !important; + } + } + + .menu-txt { + min-width: 60px; + display: block; + } + + i { + font-size: 10px; + margin-left: 10px; + } + } +} + +// 盒子默认边框 +.page-content { + border: 1px solid var(--art-card-border) !important; +} + +@mixin art-card-base($border-color, $shadow: none, $radius-diff: 4px) { + background: var(--default-box-color); + border: 1px solid #{$border-color} !important; + border-radius: calc(var(--custom-radius) + #{$radius-diff}) !important; + box-shadow: #{$shadow} !important; + + --el-card-border-color: var(--default-border) !important; +} + +.art-card, +.art-card-sm, +.art-card-xs { + border: 1px solid var(--art-card-border); +} + +// 盒子边框 +[data-box-mode='border-mode'] { + .page-content, + .art-table-card { + border: 1px solid var(--art-card-border) !important; + } + + .art-card { + @include art-card-base(var(--art-card-border), none, 4px); + } + + .art-card-sm { + @include art-card-base(var(--art-card-border), none, 0px); + } + + .art-card-xs { + @include art-card-base(var(--art-card-border), none, -4px); + } +} + +// 盒子阴影 +[data-box-mode='shadow-mode'] { + .page-content, + .art-table-card { + box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.04) !important; + border: 1px solid var(--art-gray-200) !important; + } + + .layout-sidebar { + border-right: 1px solid var(--art-card-border) !important; + } + + .art-card { + @include art-card-base( + var(--art-gray-200), + (0 1px 3px 0 rgba(0, 0, 0, 0.03), 0 1px 2px -1px rgba(0, 0, 0, 0.08)), + 4px + ); + } + + .art-card-sm { + @include art-card-base( + var(--art-gray-200), + (0 1px 3px 0 rgba(0, 0, 0, 0.03), 0 1px 2px -1px rgba(0, 0, 0, 0.08)), + 2px + ); + } + + .art-card-xs { + @include art-card-base( + var(--art-gray-200), + (0 1px 2px 0 rgba(0, 0, 0, 0.03), 0 1px 1px -1px rgba(0, 0, 0, 0.08)), + -4px + ); + } +} + +// 元素全屏 +.el-full-screen { + position: fixed; + top: 0; + left: 0; + right: 0; + width: 100vw !important; + height: 100% !important; + z-index: 2300; + margin-top: 0; + padding: 15px; + box-sizing: border-box; + background-color: var(--default-box-color); + display: flex; + flex-direction: column; +} + +// 表格卡片 +.art-table-card { + flex: 1; + display: flex; + flex-direction: column; + margin-top: 12px; + border-radius: calc(var(--custom-radius) / 2 + 2px) !important; + + .el-card__body { + height: 100%; + overflow: hidden; + } +} + +// 容器全高 +.art-full-height { + height: var(--art-full-height); + display: flex; + flex-direction: column; + + @media (max-width: 640px) { + height: auto; + } +} + +// 徽章样式 +.art-badge { + position: absolute; + top: 0; + right: 20px; + bottom: 0; + width: 6px; + height: 6px; + margin: auto; + background: #ff3860; + border-radius: 50%; + animation: breathe 1.5s ease-in-out infinite; + + &.art-badge-horizontal { + right: 0; + } + + &.art-badge-mixed { + right: 0; + } + + &.art-badge-dual { + right: 5px; + top: 5px; + bottom: auto; + } +} + +// 文字徽章样式 +.art-text-badge { + position: absolute; + top: 0; + right: 12px; + bottom: 0; + min-width: 20px; + height: 18px; + line-height: 17px; + padding: 0 5px; + margin: auto; + font-size: 10px; + color: #fff; + text-align: center; + background: #fd4e4e; + border-radius: 4px; +} + +@keyframes breathe { + 0% { + opacity: 0.7; + transform: scale(1); + } + + 50% { + opacity: 1; + transform: scale(1.1); + } + + 100% { + opacity: 0.7; + transform: scale(1); + } +} + +// 修复老机型 loading 定位问题 +.art-loading-fix { + position: fixed !important; + top: 0 !important; + left: 0 !important; + right: 0 !important; + bottom: 0 !important; + width: 100vw !important; + height: 100vh !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; +} + +.art-loading-fix .el-loading-spinner { + position: static !important; + top: auto !important; + left: auto !important; + transform: none !important; +} + +// 去除移动端点击背景色 +@media screen and (max-width: 1180px) { + * { + -webkit-tap-highlight-color: transparent; + } +} diff --git a/web/apps/portal/src/assets/styles/core/dark.scss b/web/apps/portal/src/assets/styles/core/dark.scss new file mode 100755 index 0000000..c52abc3 --- /dev/null +++ b/web/apps/portal/src/assets/styles/core/dark.scss @@ -0,0 +1,93 @@ +/* +* 深色主题 +* 单页面移除深色主题 document.getElementsByTagName("html")[0].removeAttribute('class') +*/ + +$font-color: rgba(#ffffff, 0.85); + +/* 覆盖element-plus默认深色背景色 */ +html.dark { + // element-plus + --el-bg-color: var(--default-box-color); + --el-text-color-regular: #{$font-color}; + + // 富文本编辑器 + // 工具栏背景颜色 + --w-e-toolbar-bg-color: #18191c; + // 输入区域背景颜色 + --w-e-textarea-bg-color: #090909; + // 工具栏文字颜色 + --w-e-toolbar-color: var(--art-gray-600); + // 选中菜单颜色 + --w-e-toolbar-active-bg-color: #25262b; + // 弹窗边框颜色 + --w-e-toolbar-border-color: var(--default-border-dashed); + // 分割线颜色 + --w-e-textarea-border-color: var(--default-border-dashed); + // 链接输入框边框颜色 + --w-e-modal-button-border-color: var(--default-border-dashed); + // 表格头颜色 + --w-e-textarea-slight-bg-color: #090909; + // 按钮背景颜色 + --w-e-modal-button-bg-color: #090909; + // hover toolbar 背景颜色 + --w-e-toolbar-active-color: var(--art-gray-800); +} + +.dark { + .page-content .article-list .item .left .outer > div { + border-right-color: var(--dark-border-color) !important; + } + + // 富文本编辑器 + .editor-wrapper { + *:not(pre code *) { + color: inherit !important; + } + } + // 分隔线 + .w-e-bar-divider { + background-color: var(--art-gray-300) !important; + } + + .w-e-select-list, + .w-e-drop-panel, + .w-e-bar-item-group .w-e-bar-item-menus-container, + .w-e-text-container [data-slate-editor] pre > code { + border: 1px solid var(--default-border) !important; + } + + // 下拉选择框 + .w-e-select-list { + background-color: var(--default-box-color) !important; + } + + /* 下拉选择框 hover 样式调整 */ + .w-e-select-list ul li:hover, + /* 工具栏 hover 按钮背景颜色 */ + .w-e-bar-item button:hover { + background-color: #090909 !important; + } + + /* 代码块 */ + .w-e-text-container [data-slate-editor] pre > code { + background-color: #25262b !important; + text-shadow: none !important; + } + + /* 引用 */ + .w-e-text-container [data-slate-editor] blockquote { + border-left: 4px solid var(--default-border-dashed) !important; + background-color: var(--art-color); + } + + .editor-wrapper { + .w-e-text-container [data-slate-editor] .table-container th:last-of-type { + border-right: 1px solid var(--default-border-dashed) !important; + } + + .w-e-modal { + background-color: var(--art-color); + } + } +} diff --git a/web/apps/portal/src/assets/styles/core/el-dark.scss b/web/apps/portal/src/assets/styles/core/el-dark.scss new file mode 100644 index 0000000..8f81cdf --- /dev/null +++ b/web/apps/portal/src/assets/styles/core/el-dark.scss @@ -0,0 +1,2 @@ +// 导入暗黑主题 +@use 'element-plus/theme-chalk/src/dark/css-vars.scss' as *; diff --git a/web/apps/portal/src/assets/styles/core/el-light.scss b/web/apps/portal/src/assets/styles/core/el-light.scss new file mode 100644 index 0000000..ddf2bc5 --- /dev/null +++ b/web/apps/portal/src/assets/styles/core/el-light.scss @@ -0,0 +1,34 @@ +// https://github.com/element-plus/element-plus/blob/dev/packages/theme-chalk/src/common/var.scss +// 自定义Element 亮色主题 + +@forward 'element-plus/theme-chalk/src/common/var.scss' with ( + $colors: ( + 'white': #ffffff, + 'black': #000000, + 'success': ( + 'base': #13deb9 + ), + 'warning': ( + 'base': #ffae1f + ), + 'danger': ( + 'base': #ff4d4f + ), + 'error': ( + 'base': #fa896b + ) + ), + $button: ( + 'hover-bg-color': var(--el-color-primary-light-9), + 'hover-border-color': var(--el-color-primary), + 'border-color': var(--el-color-primary), + 'text-color': var(--el-color-primary) + ), + $messagebox: ( + 'border-radius': '12px' + ), + $popover: ( + 'padding': '14px', + 'border-radius': '10px' + ) +); diff --git a/web/apps/portal/src/assets/styles/core/el-ui.scss b/web/apps/portal/src/assets/styles/core/el-ui.scss new file mode 100644 index 0000000..44429a1 --- /dev/null +++ b/web/apps/portal/src/assets/styles/core/el-ui.scss @@ -0,0 +1,519 @@ +// 优化 Element Plus 组件库默认样式 + +:root { + // 系统主色 + --main-color: var(--el-color-primary); + --el-color-white: white !important; + --el-color-black: white !important; + // 输入框边框颜色 + // --el-border-color: #E4E4E7 !important; // DCDFE6 + // 按钮粗度 + --el-font-weight-primary: 400 !important; + + --el-component-custom-height: 36px !important; + + --el-component-size: var(--el-component-custom-height) !important; + + // 边框、按钮圆角... + --el-border-radius-base: calc(var(--custom-radius) / 3 + 2px) !important; + + --el-border-radius-small: calc(var(--custom-radius) / 3 + 4px) !important; + --el-messagebox-border-radius: calc(var(--custom-radius) / 3 + 4px) !important; + --el-popover-border-radius: calc(var(--custom-radius) / 3 + 4px) !important; + + .region .el-radio-button__original-radio:checked + .el-radio-button__inner { + color: var(--theme-color); + } +} + +// 优化 el-form-item 标签高度 +.el-form-item__label { + height: var(--el-component-custom-height) !important; + line-height: var(--el-component-custom-height) !important; +} + +// 日期选择器 +.el-date-range-picker { + --el-datepicker-inrange-bg-color: var(--art-gray-200) !important; +} + +// el-card 背景色跟系统背景色保持一致 +html.dark .el-card { + --el-card-bg-color: var(--default-box-color) !important; +} + +// 修改 el-pagination 大小 +.el-pagination--default { + & { + --el-pagination-button-width: 32px !important; + --el-pagination-button-height: var(--el-pagination-button-width) !important; + } + + @media (max-width: 1180px) { + & { + --el-pagination-button-width: 28px !important; + } + } + + .el-select--default .el-select__wrapper { + min-height: var(--el-pagination-button-width) !important; + } + + .el-pagination__jump .el-input { + height: var(--el-pagination-button-width) !important; + } +} + +.el-pager li { + padding: 0 10px !important; + // border: 1px solid red !important; +} + +// 优化菜单折叠展开动画(提升动画流畅度) +.el-menu.el-menu--inline { + transition: max-height 0.26s cubic-bezier(0.4, 0, 0.2, 1) !important; +} + +// 优化菜单 item hover 动画(提升鼠标跟手感) +.el-sub-menu__title, +.el-menu-item { + transition: background-color 0s !important; +} + +// -------------------------------- 修改 el-size=default 组件默认高度 start -------------------------------- +// 修改 el-button 高度 +.el-button--default { + height: var(--el-component-custom-height) !important; +} + +// circle 按钮宽度优化 +.el-button--default.is-circle { + width: var(--el-component-custom-height) !important; +} + +// 修改 el-select 高度 +.el-select--default { + .el-select__wrapper { + min-height: var(--el-component-custom-height) !important; + } +} + +// 修改 el-checkbox-button 高度 +.el-checkbox-button--default .el-checkbox-button__inner, +// 修改 el-radio-button 高度 +.el-radio-button--default .el-radio-button__inner { + padding: 10px 15px !important; +} +// -------------------------------- 修改 el-size=default 组件默认高度 end -------------------------------- + +.el-pagination.is-background .btn-next, +.el-pagination.is-background .btn-prev, +.el-pagination.is-background .el-pager li { + border-radius: 6px; +} + +.el-popover { + min-width: 80px; + border-radius: var(--el-border-radius-small) !important; +} + +.el-dialog { + border-radius: 100px !important; + border-radius: calc(var(--custom-radius) / 1.2 + 2px) !important; + overflow: hidden; +} + +.el-dialog__header { + .el-dialog__title { + font-size: 16px; + } +} + +.el-dialog__body { + padding: 25px 0 !important; + position: relative; // 为了兼容 el-pagination 样式,需要设置 relative,不然会影响 el-pagination 的样式,比如 el-pagination__jump--small 会被影响,导致 el-pagination__jump--small 按钮无法点击,详见 URL_ADDRESS.com/element-plus/element-plus/issues/5684#issuecomment-1176299275; +} + +.el-dialog.el-dialog-border { + .el-dialog__body { + // 上边框 + &::before, + // 下边框 + &::after { + content: ''; + position: absolute; + left: -16px; + width: calc(100% + 32px); + height: 1px; + background-color: var(--art-gray-300); + } + + &::before { + top: 0; + } + + &::after { + bottom: 0; + } + } +} + +// el-message 样式优化 +.el-message { + background-color: var(--default-box-color) !important; + border: 0 !important; + box-shadow: + 0 6px 16px 0 rgba(0, 0, 0, 0.08), + 0 3px 6px -4px rgba(0, 0, 0, 0.12), + 0 9px 28px 8px rgba(0, 0, 0, 0.05) !important; + + p { + font-size: 13px; + } +} + +// 修改 el-dropdown 样式 +.el-dropdown-menu { + padding: 6px !important; + border-radius: 10px !important; + border: none !important; + + .el-dropdown-menu__item { + padding: 6px 16px !important; + border-radius: 6px !important; + + &:hover:not(.is-disabled) { + color: var(--art-gray-900) !important; + background-color: var(--art-el-active-color) !important; + } + + &:focus:not(.is-disabled) { + color: var(--art-gray-900) !important; + background-color: var(--art-gray-200) !important; + } + } +} + +// 隐藏 select、dropdown 的三角 +.el-select__popper, +.el-dropdown__popper { + margin-top: -6px !important; + + .el-popper__arrow { + display: none; + } +} + +.el-dropdown-selfdefine:focus { + outline: none !important; +} + +// 处理移动端组件兼容性 +@media screen and (max-width: 640px) { + .el-message-box, + .el-dialog { + width: calc(100% - 24px) !important; + } + + .el-date-picker.has-sidebar.has-time { + width: calc(100% - 24px); + left: 12px !important; + } + + .el-picker-panel *[slot='sidebar'], + .el-picker-panel__sidebar { + display: none; + } + + .el-picker-panel *[slot='sidebar'] + .el-picker-panel__body, + .el-picker-panel__sidebar + .el-picker-panel__body { + margin-left: 0; + } +} + +// 修改el-button样式 +.el-button { + &.el-button--text { + background-color: transparent !important; + padding: 0 !important; + + span { + margin-left: 0 !important; + } + } +} + +// 修改el-tag样式 +.el-tag { + font-weight: 500; + transition: all 0s !important; + + &.el-tag--default { + height: 26px !important; + } +} + +.el-checkbox-group { + &.el-table-filter__checkbox-group label.el-checkbox { + height: 17px !important; + + .el-checkbox__label { + font-weight: 400 !important; + } + } +} + +.el-radio--default { + // 优化单选按钮大小 + .el-radio__input { + .el-radio__inner { + width: 16px; + height: 16px; + + &::after { + width: 6px; + height: 6px; + } + } + } +} + +.el-checkbox { + .el-checkbox__inner { + border-radius: 2px !important; + } +} + +// 优化复选框样式 +.el-checkbox--default { + .el-checkbox__inner { + width: 16px !important; + height: 16px !important; + border-radius: 4px !important; + + &::before { + content: ''; + height: 4px !important; + top: 5px !important; + background-color: #fff !important; + transform: scale(0.6) !important; + } + } + + .is-checked { + .el-checkbox__inner { + &::after { + width: 3px; + height: 8px; + margin: auto; + border: 2px solid var(--el-checkbox-checked-icon-color); + border-left: 0; + border-top: 0; + transform: translate(-45%, -60%) rotate(45deg) scale(0.86) !important; + transform-origin: center; + } + } + } +} + +.el-notification .el-notification__icon { + font-size: 22px !important; +} + +// 修改 el-message-box 样式 +.el-message-box__headerbtn .el-message-box__close, +.el-dialog__headerbtn .el-dialog__close { + top: 7px; + right: 7px; + width: 30px; + height: 30px; + border-radius: 5px; + transition: all 0.3s; + + &:hover { + background-color: var(--art-hover-color) !important; + color: var(--art-gray-900) !important; + } +} + +.el-message-box { + padding: 25px 20px !important; +} + +.el-message-box__title { + font-weight: 500 !important; +} + +.el-table__column-filter-trigger i { + color: var(--theme-color) !important; + margin: -3px 0 0 2px; +} + +// 去除 el-dropdown 鼠标放上去出现的边框 +.el-tooltip__trigger:focus-visible { + outline: unset; +} + +// ipad 表单右侧按钮优化 +@media screen and (max-width: 1180px) { + .el-table-fixed-column--right { + padding-right: 0 !important; + } +} + +.login-out-dialog { + padding: 30px 20px !important; + border-radius: 10px !important; +} + +// 修改 dialog 动画 +.dialog-fade-enter-active { + .el-dialog:not(.is-draggable) { + animation: dialog-open 0.3s cubic-bezier(0.32, 0.14, 0.15, 0.86); + + // 修复 el-dialog 动画后宽度不自适应问题 + .el-select__selected-item { + display: inline-block; + } + } +} + +.dialog-fade-leave-active { + animation: fade-out 0.2s linear; + + .el-dialog:not(.is-draggable) { + animation: dialog-close 0.5s; + } +} + +@keyframes dialog-open { + 0% { + opacity: 0; + transform: scale(0.2); + } + + 100% { + opacity: 1; + transform: scale(1); + } +} + +@keyframes dialog-close { + 0% { + opacity: 1; + transform: scale(1); + } + + 100% { + opacity: 0; + transform: scale(0.2); + } +} + +// 遮罩层动画 +@keyframes fade-out { + 0% { + opacity: 1; + } + + 100% { + opacity: 0; + } +} + +// 修改 el-select 样式 +.el-select__popper:not(.el-tree-select__popper) { + .el-select-dropdown__list { + padding: 5px !important; + + .el-select-dropdown__item { + height: 34px !important; + line-height: 34px !important; + border-radius: 6px !important; + + &.is-selected { + color: var(--art-gray-900) !important; + font-weight: 400 !important; + background-color: var(--art-el-active-color) !important; + margin-bottom: 4px !important; + } + + &:hover { + background-color: var(--art-hover-color) !important; + } + } + + .el-select-dropdown__item:hover ~ .is-selected, + .el-select-dropdown__item.is-selected:has(~ .el-select-dropdown__item:hover) { + background-color: transparent !important; + } + } +} + +// 修改 el-tree-select 样式 +.el-tree-select__popper { + .el-select-dropdown__list { + padding: 5px !important; + + .el-tree-node { + .el-tree-node__content { + height: 36px !important; + border-radius: 6px !important; + + &:hover { + background-color: var(--art-gray-200) !important; + } + } + } + } +} + +// 实现水波纹在文字下面效果 +.el-button > span { + position: relative; + z-index: 10; +} + +// 优化颜色选择器圆角 +.el-color-picker__color { + border-radius: 2px !important; +} + +// 优化日期时间选择器底部圆角 +.el-picker-panel { + .el-picker-panel__footer { + border-radius: 0 0 var(--el-border-radius-base) var(--el-border-radius-base); + } +} + +// 优化树型菜单样式 +.el-tree-node__content { + border-radius: 4px; + margin-bottom: 4px; + padding: 1px 0; + + &:hover { + background-color: var(--art-hover-color) !important; + } +} + +.dark { + .el-tree--highlight-current .el-tree-node.is-current > .el-tree-node__content { + background-color: var(--art-gray-300) !important; + } +} + +// 隐藏折叠菜单弹窗 hover 出现的边框 +.menu-left-popper:focus-within, +.horizontal-menu-popper:focus-within { + box-shadow: none !important; + outline: none !important; +} + +// 数字输入组件右侧按钮高度跟随自定义组件高度 +.el-input-number--default.is-controls-right { + .el-input-number__decrease, + .el-input-number__increase { + height: calc((var(--el-component-size) / 2)) !important; + } +} diff --git a/web/apps/portal/src/assets/styles/core/md.scss b/web/apps/portal/src/assets/styles/core/md.scss new file mode 100644 index 0000000..b22fdc2 --- /dev/null +++ b/web/apps/portal/src/assets/styles/core/md.scss @@ -0,0 +1,1036 @@ +/* 文章标题设置(h1-h6)*/ +/* ------------------------------------------------ */ +$font-color: #24292e; + +.markdown-body h1, +.markdown-body h2, +.markdown-body h3, +.markdown-body h4, +.markdown-body h5, +.markdown-body h6 { + color: var(--art-gray-800) !important; + margin: 30px 0 10px 0; + font-weight: 600; +} + +.markdown-body h1 { + font-size: 30px; +} + +@media only screen and (max-width: 550px) { + .markdown-body h1 { + font-size: 26px; + } + + .markdown-body h2 { + font-size: 22px; + } + + .markdown-body h3 { + font-size: 18px; + } +} + +/* 块引用 */ +/* ------------------------------------------------ */ +.markdown-body blockquote { + color: rgba(60, 60, 67, 0.7); + font-size: 15px !important; + border-left: 0.18em solid #e7e7e8; + background: #f8f8f8; + padding: 15px 1em; + font-weight: 400 !important; +} + +/* 详情页文章字体颜色 */ +/* ------------------------------------------------ */ +.markdown-body p { + line-height: 28px; + margin-bottom: 10px; +} + +.markdown-body li, +.markdown-body p { + color: var(--art-gray-800) !important; + font-size: 16px !important; +} + +.dark .markdown-body li span { + color: var(--art-gray-800) !important; + background-color: transparent !important; +} + +.dark .markdown-body p span { + color: var(--art-gray-800) !important; + background-color: transparent !important; +} + +.line-numbers-mode { + background-color: var(--art-code-bg); + border-radius: 8px; + position: relative; + padding-left: 32px; + box-sizing: border-box; +} + +.line-numbers-mode pre { + flex: 1; + border-radius: 0 8px 8px 0; + background-color: var(--art-code-bg); +} + +.line-numbers-mode .line-numbers-wrapper { + width: 32px; + height: 100%; + text-align: center; + padding: 16px 0; + box-sizing: border-box; + border-right: 1px solid #000000; + position: absolute; + left: 0; + top: 0; +} + +.line-numbers-mode .line-numbers-wrapper span { + height: 23.6px; + line-height: 23.6px; + display: block; + color: #72747b; + font-size: 13px; + box-sizing: border-box; +} + +.line-numbers-mode .copy-btn { + display: inline-block; + display: flex; + position: absolute; + right: 10px; + top: 10px; + cursor: pointer; + opacity: 0; + background-color: #000; + border-radius: 5px; + text-align: center; + color: rgba(255, 255, 255, 0.6); + transition: opacity 0.3s; +} + +.line-numbers-mode .copy-btn div { + width: 34px; + height: 34px; + line-height: 34px; + cursor: pointer; + text-align: center; + font-size: 20px; +} + +.line-numbers-mode:hover .copy-btn { + opacity: 1; +} + +.line-numbers-mode .copy-btn span { + height: 34px; + line-height: 34px; + font-size: 13px; + padding-left: 10px; + display: none; +} + +.line-numbers-mode .copy-btn .show-copy { + opacity: 1; + display: block; +} + +.line-numbers-mode ::-webkit-scrollbar-track { + background-color: #292b30 !important; +} + +.markdown-body .anchor { + float: left; + line-height: 1; + margin-left: -20px; + padding-right: 4px; +} + +.markdown-body .anchor:focus { + outline: none; +} + +.markdown-body h1 .octicon-link, +.markdown-body h2 .octicon-link, +.markdown-body h3 .octicon-link, +.markdown-body h4 .octicon-link, +.markdown-body h5 .octicon-link, +.markdown-body h6 .octicon-link { + color: #1b1f23; + vertical-align: middle; + visibility: hidden; +} + +.markdown-body h1:hover .anchor, +.markdown-body h2:hover .anchor, +.markdown-body h3:hover .anchor, +.markdown-body h4:hover .anchor, +.markdown-body h5:hover .anchor, +.markdown-body h6:hover .anchor { + text-decoration: none; +} + +.markdown-body h1:hover .anchor .octicon-link, +.markdown-body h2:hover .anchor .octicon-link, +.markdown-body h3:hover .anchor .octicon-link, +.markdown-body h4:hover .anchor .octicon-link, +.markdown-body h5:hover .anchor .octicon-link, +.markdown-body h6:hover .anchor .octicon-link { + visibility: visible; +} + +.markdown-body h1:hover .anchor .octicon-link:before, +.markdown-body h2:hover .anchor .octicon-link:before, +.markdown-body h3:hover .anchor .octicon-link:before, +.markdown-body h4:hover .anchor .octicon-link:before, +.markdown-body h5:hover .anchor .octicon-link:before, +.markdown-body h6:hover .anchor .octicon-link:before { + width: 16px; + height: 16px; + content: ' '; + display: inline-block; +} + +.markdown-body { + -ms-text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; + line-height: 1.5; + color: $font-color; + font-size: 16px; + line-height: 1.5; + word-wrap: break-word; +} + +.markdown-body details { + display: block; +} + +.markdown-body summary { + display: list-item; +} + +.markdown-body a { + background-color: initial; +} + +.markdown-body a:active, +.markdown-body a:hover { + outline-width: 0; +} + +.markdown-body strong { + font-weight: inherit; + font-weight: bolder; +} + +.markdown-body p br { + display: inline; + line-height: 11px; +} + +.markdown-body img { + border-style: none; +} + +.markdown-body hr { + box-sizing: initial; + height: 0; + overflow: visible; +} + +.markdown-body input { + font: inherit; + margin: 0; +} + +.markdown-body input { + overflow: visible; +} + +.markdown-body [type='checkbox'] { + box-sizing: border-box; + padding: 0; +} + +.markdown-body * { + box-sizing: border-box; +} + +.markdown-body input { + font-size: inherit; + line-height: inherit; +} + +.markdown-body a { + color: #0366d6; + text-decoration: none; +} + +.markdown-body a:hover { + text-decoration: underline; +} + +.markdown-body strong { + font-weight: 600; +} + +.markdown-body hr { + height: 0; + margin: 15px 0; + overflow: hidden; + background: transparent; + border: 0; + border-bottom: 1px solid #dfe2e5; +} + +.markdown-body hr:after, +.markdown-body hr:before { + display: table; + content: ''; +} + +.markdown-body hr:after { + clear: both; +} + +.markdown-body table { + border-spacing: 0; + border-collapse: collapse; +} + +.markdown-body td, +.markdown-body th { + padding: 0; +} + +.markdown-body details summary { + cursor: pointer; +} + +.markdown-body kbd { + display: inline-block; + padding: 3px 5px; + font: + 11px SFMono-Regular, + Consolas, + Liberation Mono, + Menlo, + monospace; + line-height: 10px; + color: #444d56; + vertical-align: middle; + background-color: #fafbfc; + border: 1px solid #d1d5da; + border-radius: 3px; + box-shadow: inset 0 -1px 0 #d1d5da; +} + +.markdown-body blockquote { + margin: 0; +} + +.markdown-body ol, +.markdown-body ul { + padding-left: 0; + margin-top: 0; + margin-bottom: 0; +} + +.markdown-body ol ol, +.markdown-body ul ol { + list-style-type: lower-roman; +} + +.markdown-body ol ol ol, +.markdown-body ol ul ol, +.markdown-body ul ol ol, +.markdown-body ul ul ol { + list-style-type: lower-alpha; +} + +.markdown-body dd { + margin-left: 0; +} + +.markdown-body code, +.markdown-body pre, +.markdown-body .line-number { + font-size: 14px !important; + border-radius: 8px; + background-color: #282c34; +} + +.dark { + .markdown-body code, + .markdown-body pre, + .markdown-body .line-number { + background-color: #252525; + } +} + +.markdown-body pre { + margin-top: 0; + margin-bottom: 0; +} + +.markdown-body input::-webkit-inner-spin-button, +.markdown-body input::-webkit-outer-spin-button { + margin: 0; + -webkit-appearance: none; + appearance: none; +} + +.markdown-body :checked + .radio-label { + position: relative; + z-index: 1; + border-color: #0366d6; +} + +.markdown-body .border { + border: 1px solid #e1e4e8 !important; +} + +.markdown-body .border-0 { + border: 0 !important; +} + +.markdown-body .border-bottom { + border-bottom: 1px solid #e1e4e8 !important; +} + +.markdown-body .rounded-1 { + border-radius: 3px !important; +} + +.markdown-body .bg-white { + background-color: #fff !important; +} + +.markdown-body .bg-gray-light { + background-color: #fafbfc !important; +} + +.markdown-body .text-gray-light { + color: #6a737d !important; +} + +.markdown-body .mb-0 { + margin-bottom: 0 !important; +} + +.markdown-body .my-2 { + margin-top: 8px !important; + margin-bottom: 8px !important; +} + +.markdown-body .pl-0 { + padding-left: 0 !important; +} + +.markdown-body .py-0 { + padding-top: 0 !important; + padding-bottom: 0 !important; +} + +.markdown-body .pl-1 { + padding-left: 4px !important; +} + +.markdown-body .pl-2 { + padding-left: 8px !important; +} + +.markdown-body .py-2 { + padding-top: 8px !important; + padding-bottom: 8px !important; +} + +.markdown-body .pl-3, +.markdown-body .px-3 { + padding-left: 16px !important; +} + +.markdown-body .px-3 { + padding-right: 16px !important; +} + +.markdown-body .pl-4 { + padding-left: 24px !important; +} + +.markdown-body .pl-5 { + padding-left: 32px !important; +} + +.markdown-body .pl-6 { + padding-left: 40px !important; +} + +.markdown-body .f6 { + font-size: 12px !important; +} + +.markdown-body .lh-condensed { + line-height: 1.25 !important; +} + +.markdown-body .text-bold { + font-weight: 600 !important; +} + +.markdown-body .pl-c { + color: #6a737d; +} + +.markdown-body .pl-c1, +.markdown-body .pl-s .pl-v { + color: #005cc5; +} + +.markdown-body .pl-e, +.markdown-body .pl-en { + color: #6f42c1; +} + +.markdown-body .pl-s .pl-s1, +.markdown-body .pl-smi { + color: $font-color; +} + +.markdown-body .pl-ent { + color: #22863a; +} + +.markdown-body .pl-k { + color: #d73a49; +} + +.markdown-body .pl-pds, +.markdown-body .pl-s, +.markdown-body .pl-s .pl-pse .pl-s1, +.markdown-body .pl-sr, +.markdown-body .pl-sr .pl-cce, +.markdown-body .pl-sr .pl-sra, +.markdown-body .pl-sr .pl-sre { + color: #032f62; +} + +.markdown-body .pl-smw, +.markdown-body .pl-v { + color: #e36209; +} + +.markdown-body .pl-bu { + color: #b31d28; +} + +.markdown-body .pl-ii { + color: #fafbfc; + background-color: #b31d28; +} + +.markdown-body .pl-c2 { + color: #fafbfc; + background-color: #d73a49; +} + +.markdown-body .pl-c2:before { + content: '^M'; +} + +.markdown-body .pl-sr .pl-cce { + font-weight: 700; + color: #22863a; +} + +.markdown-body .pl-ml { + color: #735c0f; +} + +.markdown-body .pl-mh, +.markdown-body .pl-mh .pl-en, +.markdown-body .pl-ms { + font-weight: 700; + color: #005cc5; +} + +.markdown-body .pl-mi { + font-style: italic; + color: $font-color; +} + +.markdown-body .pl-mb { + font-weight: 700; + color: $font-color; +} + +.markdown-body .pl-md { + color: #b31d28; + background-color: #ffeef0; +} + +.markdown-body .pl-mi1 { + color: #22863a; + background-color: #f0fff4; +} + +.markdown-body .pl-mc { + color: #e36209; + background-color: #ffebda; +} + +.markdown-body .pl-mi2 { + color: #f6f8fa; + background-color: #005cc5; +} + +.markdown-body .pl-mdr { + font-weight: 700; + color: #6f42c1; +} + +.markdown-body .pl-ba { + color: #586069; +} + +.markdown-body .pl-sg { + color: #959da5; +} + +.markdown-body .pl-corl { + text-decoration: underline; + color: #032f62; +} + +.markdown-body .mb-0 { + margin-bottom: 0 !important; +} + +.markdown-body .my-2 { + margin-bottom: 8px !important; +} + +.markdown-body .my-2 { + margin-top: 8px !important; +} + +.markdown-body .pl-0 { + padding-left: 0 !important; +} + +.markdown-body .py-0 { + padding-top: 0 !important; + padding-bottom: 0 !important; +} + +.markdown-body .pl-1 { + padding-left: 4px !important; +} + +.markdown-body .pl-2 { + padding-left: 8px !important; +} + +.markdown-body .py-2 { + padding-top: 8px !important; + padding-bottom: 8px !important; +} + +.markdown-body .pl-3 { + padding-left: 16px !important; +} + +.markdown-body .pl-4 { + padding-left: 24px !important; +} + +.markdown-body .pl-5 { + padding-left: 32px !important; +} + +.markdown-body .pl-6 { + padding-left: 40px !important; +} + +.markdown-body .pl-7 { + padding-left: 48px !important; +} + +.markdown-body .pl-8 { + padding-left: 64px !important; +} + +.markdown-body .pl-9 { + padding-left: 80px !important; +} + +.markdown-body .pl-10 { + padding-left: 96px !important; +} + +.markdown-body .pl-11 { + padding-left: 112px !important; +} + +.markdown-body .pl-12 { + padding-left: 128px !important; +} + +.markdown-body hr { + border-bottom-color: #eee; +} + +.markdown-body kbd { + display: inline-block; + padding: 3px 5px; + font: + 11px SFMono-Regular, + Consolas, + Liberation Mono, + Menlo, + monospace; + line-height: 10px; + color: #444d56; + vertical-align: middle; + background-color: #fafbfc; + border: 1px solid #d1d5da; + border-radius: 3px; + box-shadow: inset 0 -1px 0 #d1d5da; +} + +.markdown-body:after, +.markdown-body:before { + display: table; + content: ''; +} + +.markdown-body:after { + clear: both; +} + +.markdown-body > :first-child { + margin-top: 0 !important; +} + +.markdown-body > :last-child { + margin-bottom: 0 !important; +} + +.markdown-body a:not([href]) { + color: inherit; + text-decoration: none; +} + +.markdown-body blockquote, +.markdown-body details, +.markdown-body dl, +.markdown-body ol, +.markdown-body pre, +.markdown-body table, +.markdown-body ul { + margin-top: 0; + margin-bottom: 16px; +} + +.markdown-body hr { + height: 0.25em; + padding: 0; + margin: 24px 0; + background-color: #e1e4e8; + border: 0; +} + +.markdown-body blockquote > :first-child { + margin-top: 0; +} + +.markdown-body blockquote > :last-child { + margin-bottom: 0; +} + +.markdown-body ol, +.markdown-body ul { + padding-left: 1em; +} + +.markdown-body ol ol, +.markdown-body ol ul, +.markdown-body ul ol, +.markdown-body ul ul { + margin-top: 0; + margin-bottom: 0; +} + +.markdown-body li { + line-height: 28px; + font-size: 14px; + word-wrap: break-all; + list-style: disc; + margin-left: 10px; +} + +.markdown-body li > p { + margin-top: 16px; +} + +.markdown-body li + li { + margin-top: 0.25em; +} + +.markdown-body dl { + padding: 0; +} + +.markdown-body dl dt { + padding: 0; + margin-top: 16px; + font-size: 1em; + font-style: italic; + font-weight: 600; +} + +.markdown-body dl dd { + padding: 0 16px; + margin-bottom: 16px; +} + +.markdown-body table { + display: block; + width: 100%; + overflow: auto; +} + +.markdown-body table th { + font-weight: 600; +} + +.markdown-body table td, +.markdown-body table th { + padding: 6px 13px; + border: 1px solid #dfe2e5; +} + +.markdown-body table tr { + background-color: #fff; + border-top: 1px solid #c6cbd1; +} + +.markdown-body table tr:nth-child(2n) { + background-color: #f6f8fa; +} + +.markdown-body img { + max-width: 100%; + box-sizing: initial; + background-color: #fff; + border: 1px solid #eee; + border: 1px solid var(--art-c-border-2); + cursor: zoom-in; +} + +.markdown-body img[align='right'] { + padding-left: 20px; +} + +.markdown-body img[align='left'] { + padding-right: 20px; +} + +.markdown-body code { + padding: 0.2em 0.4em; + margin: 0; + font-size: 85%; + background-color: rgba(27, 31, 35, 0.05); + border-radius: 3px; +} + +.markdown-body pre { + word-wrap: normal; +} + +.markdown-body pre > code { + padding: 0; + margin: 0; + font-size: 100%; + word-break: normal; + white-space: pre; + background: transparent; + border: 0; +} + +.markdown-body .highlight { + margin-bottom: 16px; +} + +.markdown-body .highlight pre { + margin-bottom: 0; + word-break: normal; +} + +.markdown-body .highlight pre, +.markdown-body pre { + padding: 15px 20px 15px 0; + overflow: auto; + font-size: 92%; + line-height: 1.6; +} + +.markdown-body pre code { + display: inline; + max-width: auto; + padding: 0; + margin: 0; + overflow: visible; + line-height: inherit; + word-wrap: normal; + background-color: initial; + border: 0; +} + +.markdown-body .commit-tease-sha { + display: inline-block; + font-size: 90%; + color: #444d56; +} + +.markdown-body .full-commit .btn-outline:not(:disabled):hover { + color: #005cc5; + border-color: #005cc5; +} + +.markdown-body .blob-wrapper { + overflow-x: auto; + overflow-y: hidden; +} + +.markdown-body .blob-wrapper-embedded { + max-height: 240px; + overflow-y: auto; +} + +.markdown-body .blob-num { + width: 1%; + min-width: 50px; + padding-right: 10px; + padding-left: 10px; + font-size: 12px; + line-height: 20px; + color: rgba(27, 31, 35, 0.3); + text-align: right; + white-space: nowrap; + vertical-align: top; + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.markdown-body .blob-num:hover { + color: rgba(27, 31, 35, 0.6); +} + +.markdown-body .blob-num:before { + content: attr(data-line-number); +} + +.markdown-body .blob-code { + position: relative; + padding-right: 10px; + padding-left: 10px; + line-height: 20px; + vertical-align: top; +} + +.markdown-body .blob-code-inner { + overflow: visible; + font-size: 12px; + color: $font-color; + word-wrap: normal; + white-space: pre; +} + +.markdown-body .pl-token.active, +.markdown-body .pl-token:hover { + cursor: pointer; + background: #ffea7f; +} + +.markdown-body .tab-size[data-tab-size='1'] { + -moz-tab-size: 1; + tab-size: 1; +} + +.markdown-body .tab-size[data-tab-size='2'] { + -moz-tab-size: 2; + tab-size: 2; +} + +.markdown-body .tab-size[data-tab-size='3'] { + -moz-tab-size: 3; + tab-size: 3; +} + +.markdown-body .tab-size[data-tab-size='4'] { + -moz-tab-size: 4; + tab-size: 4; +} + +.markdown-body .tab-size[data-tab-size='5'] { + -moz-tab-size: 5; + tab-size: 5; +} + +.markdown-body .tab-size[data-tab-size='6'] { + -moz-tab-size: 6; + tab-size: 6; +} + +.markdown-body .tab-size[data-tab-size='7'] { + -moz-tab-size: 7; + tab-size: 7; +} + +.markdown-body .tab-size[data-tab-size='8'] { + -moz-tab-size: 8; + tab-size: 8; +} + +.markdown-body .tab-size[data-tab-size='9'] { + -moz-tab-size: 9; + tab-size: 9; +} + +.markdown-body .tab-size[data-tab-size='10'] { + -moz-tab-size: 10; + tab-size: 10; +} + +.markdown-body .tab-size[data-tab-size='11'] { + -moz-tab-size: 11; + tab-size: 11; +} + +.markdown-body .tab-size[data-tab-size='12'] { + -moz-tab-size: 12; + tab-size: 12; +} + +.markdown-body .task-list-item { + list-style-type: none; +} + +.markdown-body .task-list-item + .task-list-item { + margin-top: 3px; +} + +.markdown-body .task-list-item input { + margin: 0 0.2em 0.25em -1.6em; + vertical-align: middle; +} diff --git a/web/apps/portal/src/assets/styles/core/mixin.scss b/web/apps/portal/src/assets/styles/core/mixin.scss new file mode 100755 index 0000000..db36888 --- /dev/null +++ b/web/apps/portal/src/assets/styles/core/mixin.scss @@ -0,0 +1,157 @@ +// sass 混合宏(函数) + +/** +* 溢出省略号 +* @param {Number} 行数 +*/ +@mixin ellipsis($rowCount: 1) { + @if $rowCount <=1 { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } @else { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: $rowCount; + -webkit-box-orient: vertical; + } +} + +/** +* 控制用户能否选中文本 +* @param {String} 类型 +*/ +@mixin userSelect($value: none) { + user-select: $value; + -moz-user-select: $value; + -ms-user-select: $value; + -webkit-user-select: $value; +} + +// 绝对定位居中 +@mixin absoluteCenter() { + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + margin: auto; +} + +/** +* css3动画 +* +*/ +@mixin animation( + $from: ( + width: 0px + ), + $to: ( + width: 100px + ), + $name: mymove, + $animate: mymove 2s 1 linear infinite +) { + -webkit-animation: $animate; + -o-animation: $animate; + animation: $animate; + + @keyframes #{$name} { + from { + @each $key, $value in $from { + #{$key}: #{$value}; + } + } + + to { + @each $key, $value in $to { + #{$key}: #{$value}; + } + } + } + + @-webkit-keyframes #{$name} { + from { + @each $key, $value in $from { + $key: $value; + } + } + + to { + @each $key, $value in $to { + $key: $value; + } + } + } +} + +// 圆形盒子 +@mixin circle($size: 11px, $bg: #fff) { + border-radius: 50%; + width: $size; + height: $size; + line-height: $size; + text-align: center; + background: $bg; +} + +// placeholder +@mixin placeholder($color: #bbb) { + // Firefox + &::-moz-placeholder { + color: $color; + opacity: 1; + } + + // Internet Explorer 10+ + &:-ms-input-placeholder { + color: $color; + } + + // Safari and Chrome + &::-webkit-input-placeholder { + color: $color; + } + + &:placeholder-shown { + text-overflow: ellipsis; + } +} + +//背景透明,文字不透明。兼容IE8 +@mixin betterTransparentize($color, $alpha) { + $c: rgba($color, $alpha); + $ie_c: ie_hex_str($c); + background: rgba($color, 1); + background: $c; + background: transparent \9; + zoom: 1; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr=#{$ie_c}, endColorstr=#{$ie_c}); + -ms-filter: 'progid:DXImageTransform.Microsoft.gradient(startColorstr=#{$ie_c}, endColorstr=#{$ie_c})'; +} + +//添加浏览器前缀 +@mixin browserPrefix($propertyName, $value) { + @each $prefix in -webkit-, -moz-, -ms-, -o-, '' { + #{$prefix}#{$propertyName}: $value; + } +} + +// 边框 +@mixin border($color: red) { + border: 1px solid $color; +} + +// 背景滤镜 +@mixin backdropBlur() { + --tw-backdrop-blur: blur(30px); + -webkit-backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) + var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) + var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) + var(--tw-backdrop-sepia); + backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) + var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) + var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia); +} diff --git a/web/apps/portal/src/assets/styles/core/reset.scss b/web/apps/portal/src/assets/styles/core/reset.scss new file mode 100755 index 0000000..17a3bcf --- /dev/null +++ b/web/apps/portal/src/assets/styles/core/reset.scss @@ -0,0 +1,41 @@ +@charset "UTF-8"; + +/*滚动条*/ +/*滚动条整体部分,必须要设置*/ +::-webkit-scrollbar { + width: 8px !important; + height: 0 !important; +} + +/*滚动条的轨道*/ +::-webkit-scrollbar-track { + background-color: var(--art-gray-200); +} + +/*滚动条的滑块按钮*/ +::-webkit-scrollbar-thumb { + border-radius: 5px; + background-color: #cccccc !important; + transition: all 0.2s; + -webkit-transition: all 0.2s; +} + +::-webkit-scrollbar-thumb:hover { + background-color: #b0abab !important; +} + +/*滚动条的上下两端的按钮*/ +::-webkit-scrollbar-button { + height: 0px; + width: 0; +} + +.dark { + ::-webkit-scrollbar-track { + background-color: var(--default-bg-color); + } + + ::-webkit-scrollbar-thumb { + background-color: var(--art-gray-300) !important; + } +} diff --git a/web/apps/portal/src/assets/styles/core/router-transition.scss b/web/apps/portal/src/assets/styles/core/router-transition.scss new file mode 100644 index 0000000..f47c741 --- /dev/null +++ b/web/apps/portal/src/assets/styles/core/router-transition.scss @@ -0,0 +1,104 @@ +@use 'sass:map'; + +// === 变量区域 === +$transition: ( + // 动画持续时间 + duration: 0.25s, + // 滑动动画的移动距离 + distance: 15px, + // 默认缓动函数 + easing: cubic-bezier(0.25, 0.1, 0.25, 1), + // 淡入淡出专用的缓动函数 + fade-easing: cubic-bezier(0.4, 0, 0.6, 1) +); + +// 抽取配置值函数,提高可复用性 +@function transition-config($key) { + @return map.get($transition, $key); +} + +// 变量简写 +$duration: transition-config('duration'); +$distance: transition-config('distance'); +$easing: transition-config('easing'); +$fade-easing: transition-config('fade-easing'); + +// === 动画类 === + +// 淡入淡出动画 +.fade { + &-enter-active, + &-leave-active { + transition: opacity $duration $fade-easing; + will-change: opacity; + } + + &-enter-from, + &-leave-to { + opacity: 0; + } + + &-enter-to, + &-leave-from { + opacity: 1; + } +} + +// 滑动动画通用样式 +@mixin slide-transition($direction) { + $distance-x: 0; + $distance-y: 0; + + @if $direction == 'left' { + $distance-x: -$distance; + } @else if $direction == 'right' { + $distance-x: $distance; + } @else if $direction == 'top' { + $distance-y: -$distance; + } @else if $direction == 'bottom' { + $distance-y: $distance; + } + + &-enter-active { + transition: + opacity $duration $easing, + transform $duration $easing; + will-change: opacity, transform; + } + + &-leave-active { + transition: + opacity calc($duration * 0.7) $easing, + transform calc($duration * 0.7) $easing; + will-change: opacity, transform; + } + + &-enter-from { + opacity: 0; + transform: translate3d($distance-x, $distance-y, 0); + } + + &-enter-to { + opacity: 1; + transform: translate3d(0, 0, 0); + } + + &-leave-to { + opacity: 0; + transform: translate3d(-$distance-x, -$distance-y, 0); + } +} + +// 滑动动画方向类 +.slide-left { + @include slide-transition('left'); +} +.slide-right { + @include slide-transition('right'); +} +.slide-top { + @include slide-transition('top'); +} +.slide-bottom { + @include slide-transition('bottom'); +} diff --git a/web/apps/portal/src/assets/styles/core/tailwind.css b/web/apps/portal/src/assets/styles/core/tailwind.css new file mode 100644 index 0000000..b810fb7 --- /dev/null +++ b/web/apps/portal/src/assets/styles/core/tailwind.css @@ -0,0 +1,208 @@ +@import 'tailwindcss'; +@custom-variant dark (&:where(.dark, .dark *)); + +/* ==================== Light Mode Variables ==================== */ +:root { + /* Base Colors */ + --art-color: #ffffff; + --theme-color: var(--main-color); + + /* Theme Colors - OKLCH Format (语枢网关:青色主色,去蓝化) */ + --art-primary: #00e5ff; + --art-secondary: #38c0fc; + --art-error: oklch(0.73 0.15 25.3); + --art-info: oklch(0.58 0.03 254.1); + --art-success: oklch(0.78 0.17 166.1); + --art-warning: oklch(0.78 0.14 75.5); + --art-danger: oklch(0.68 0.22 25.3); + + /* Gray Scale - Light Mode */ + --art-gray-100: #f9fafb; + --art-gray-200: #f2f4f5; + --art-gray-300: #e6eaeb; + --art-gray-400: #dbdfe1; + --art-gray-500: #949eb7; + --art-gray-600: #7987a1; + --art-gray-700: #4d5875; + --art-gray-800: #383853; + --art-gray-900: #323251; + + /* Border Colors */ + --art-card-border: rgba(0, 0, 0, 0.08); + + --default-border: #e2e8ee; + --default-border-dashed: #dbdfe9; + + /* Background Colors */ + --default-bg-color: #fafbfc; + --default-box-color: #ffffff; + + /* Hover Color */ + --art-hover-color: #edeff0; + + /* Active Color */ + --art-active-color: #f2f4f5; + + /* Element Component Active Color */ + --art-el-active-color: #f2f4f5; +} + +/* ==================== Dark Mode Variables (语枢网关 中性深色画布 + 青色点缀) ==================== */ +.dark { + /* Base Colors */ + --art-color: #000000; + + /* Gray Scale - Dark Mode (中性暖灰,去蓝调;青色仅作交互点缀) */ + --art-gray-100: #0c0e12; + --art-gray-200: #14171c; + --art-gray-300: #1e2229; + --art-gray-400: #2a2f38; + --art-gray-500: #626a77; + --art-gray-600: #6a7280; + --art-gray-700: #98a0ad; + --art-gray-800: #c9ced6; + --art-gray-900: #e8ebf0; + + /* Border Colors (中性白低透明度,冷蓝感最低) */ + --art-card-border: rgba(255, 255, 255, 0.1); + + --default-border: rgba(255, 255, 255, 0.12); + --default-border-dashed: rgba(255, 255, 255, 0.16); + + /* Background Colors (近黑中性画布 + 中性炭灰面板) */ + --default-bg-color: #0b0d11; + --default-box-color: #15181e; + + /* Hover Color */ + --art-hover-color: #20242b; + + /* Active Color */ + --art-active-color: #1a1e24; + + /* Element Component Active Color */ + --art-el-active-color: #22262d; +} + +/* ==================== Tailwind Theme Configuration ==================== */ +@theme { + /* Box Color (Light: white / Dark: black) */ + --color-box: var(--default-box-color); + + /* System Theme Color */ + --color-theme: var(--theme-color); + + /* Hover Color */ + --color-hover-color: var(--art-hover-color); + + /* Active Color */ + --color-active-color: var(--art-active-color); + + /* Active Color */ + --color-el-active-color: var(--art-active-color); + + /* ElementPlus Theme Colors */ + --color-primary: var(--art-primary); + --color-secondary: var(--art-secondary); + --color-error: var(--art-error); + --color-info: var(--art-info); + --color-success: var(--art-success); + --color-warning: var(--art-warning); + --color-danger: var(--art-danger); + + /* Gray Scale Colors (Auto-adapts to dark mode) */ + --color-g-100: var(--art-gray-100); + --color-g-200: var(--art-gray-200); + --color-g-300: var(--art-gray-300); + --color-g-400: var(--art-gray-400); + --color-g-500: var(--art-gray-500); + --color-g-600: var(--art-gray-600); + --color-g-700: var(--art-gray-700); + --color-g-800: var(--art-gray-800); + --color-g-900: var(--art-gray-900); +} + +/* ==================== Custom Border Radius Utilities ==================== */ +@utility rounded-custom-xs { + border-radius: calc(var(--custom-radius) / 2); +} + +@utility rounded-custom-sm { + border-radius: calc(var(--custom-radius) / 2 + 2px); +} + +/* ==================== Custom Utility Classes ==================== */ +@layer utilities { + /* Flexbox Layout Utilities */ + .flex-c { + @apply flex items-center; + } + + .flex-b { + @apply flex justify-between; + } + + .flex-cc { + @apply flex items-center justify-center; + } + + .flex-cb { + @apply flex items-center justify-between; + } + + /* Transition Utilities */ + .tad-200 { + @apply transition-all duration-200; + } + + .tad-300 { + @apply transition-all duration-300; + } + + /* Border Utilities */ + .border-full-d { + @apply border border-[var(--default-border)]; + } + + .border-b-d { + @apply border-b border-[var(--default-border)]; + } + + .border-t-d { + @apply border-t border-[var(--default-border)]; + } + + .border-l-d { + @apply border-l border-[var(--default-border)]; + } + + .border-r-d { + @apply border-r border-[var(--default-border)]; + } + + /* Cursor Utilities */ + .c-p { + @apply cursor-pointer; + } +} + +/* ==================== Custom Component Classes ==================== */ +@layer components { + /* Art Card Header Component */ + .art-card-header { + @apply flex justify-between pr-6 pb-1; + + .title { + h4 { + @apply text-lg font-medium text-g-900; + } + + p { + @apply mt-1 text-sm text-g-600; + + span { + @apply ml-2 font-medium; + } + } + } + } +} diff --git a/web/apps/portal/src/assets/styles/core/theme-animation.scss b/web/apps/portal/src/assets/styles/core/theme-animation.scss new file mode 100644 index 0000000..377b945 --- /dev/null +++ b/web/apps/portal/src/assets/styles/core/theme-animation.scss @@ -0,0 +1,63 @@ +// 定义基础变量 +$bg-animation-color-light: #000; +$bg-animation-color-dark: #fff; +$bg-animation-duration: 0.5s; + +html { + --bg-animation-color: $bg-animation-color-light; + + &.dark { + --bg-animation-color: $bg-animation-color-dark; + } + + // View transition styles + &::view-transition-old(*) { + animation: none; + } + + &::view-transition-new(*) { + animation: clip $bg-animation-duration ease-in both; + } + + &::view-transition-old(root) { + z-index: 1; + } + + &::view-transition-new(root) { + z-index: 9999; + } + + &.dark { + &::view-transition-old(*) { + animation: clip $bg-animation-duration ease-in reverse both; + } + + &::view-transition-new(*) { + animation: none; + } + + &::view-transition-old(root) { + z-index: 9999; + } + + &::view-transition-new(root) { + z-index: 1; + } + } +} + +// 定义动画 +@keyframes clip { + from { + clip-path: circle(0% at var(--x) var(--y)); + } + + to { + clip-path: circle(var(--r) at var(--x) var(--y)); + } +} + +// body 相关样式 +body { + background-color: var(--bg-animation-color); +} diff --git a/web/apps/portal/src/assets/styles/core/theme-change.scss b/web/apps/portal/src/assets/styles/core/theme-change.scss new file mode 100755 index 0000000..5b640d2 --- /dev/null +++ b/web/apps/portal/src/assets/styles/core/theme-change.scss @@ -0,0 +1,11 @@ +// 主题切换过渡优化,优化除视觉上的不适感 +.theme-change { + * { + transition: 0s !important; + } + + .el-switch__core, + .el-switch__action { + transition: all 0.3s !important; + } +} diff --git a/web/apps/portal/src/assets/styles/custom/one-dark-pro.scss b/web/apps/portal/src/assets/styles/custom/one-dark-pro.scss new file mode 100644 index 0000000..36bdf63 --- /dev/null +++ b/web/apps/portal/src/assets/styles/custom/one-dark-pro.scss @@ -0,0 +1,98 @@ +.hljs { + display: block; + overflow-x: auto; + padding: 0.5em; + + color: #a6accd; +} + +.hljs-string, +.hljs-section, +.hljs-selector-class, +.hljs-template-variable, +.hljs-deletion { + color: #aed07e !important; +} + +.hljs-comment, +.hljs-quote { + color: #6f747d; +} + +.hljs-doctag, +.hljs-keyword, +.hljs-formula { + color: #c792ea; +} + +.hljs-section, +.hljs-name, +.hljs-selector-tag, +.hljs-deletion, +.hljs-subst { + color: #c86068; +} + +.hljs-literal { + color: #56b6c2; +} + +.hljs-string, +.hljs-regexp, +.hljs-addition, +.hljs-attribute, +.hljs-meta-string { + color: #abb2bf; +} + +.hljs-attribute { + color: #c792ea; +} + +.hljs-function { + color: #c792ea; +} + +.hljs-type { + color: #f07178; +} + +.hljs-title { + color: #82aaff !important; +} + +.hljs-built_in, +.hljs-class { + color: #82aaff; +} + +// 括号 +.hljs-params { + color: #a6accd; +} + +.hljs-attr, +.hljs-variable, +.hljs-template-variable, +.hljs-selector-class, +.hljs-selector-attr, +.hljs-selector-pseudo, +.hljs-number { + color: #de7e61; +} + +.hljs-symbol, +.hljs-bullet, +.hljs-link, +.hljs-meta, +.hljs-selector-id { + color: #61aeee; +} + +.hljs-strong { + font-weight: bold; +} + +.hljs-link { + text-decoration: underline; +} diff --git a/web/apps/portal/src/assets/styles/index.scss b/web/apps/portal/src/assets/styles/index.scss new file mode 100644 index 0000000..cdc2ddc --- /dev/null +++ b/web/apps/portal/src/assets/styles/index.scss @@ -0,0 +1,23 @@ +// 重置默认样式 +@use './core/reset.scss'; + +// 应用全局样式 +@use './core/app.scss'; + +// Element Plus 样式优化 +@use './core/el-ui.scss'; + +// Element Plus 暗黑主题 +@use './core/el-dark.scss'; + +// 暗黑主题样式优化 +@use './core/dark.scss'; + +// 路由切换动画 +@use './core/router-transition'; + +// 主题切换过渡优化 +@use './core/theme-change.scss'; + +// 主题切换圆形扩散动画 +@use './core/theme-animation.scss'; diff --git a/web/apps/portal/src/assets/svg/loading.ts b/web/apps/portal/src/assets/svg/loading.ts new file mode 100644 index 0000000..fdfb078 --- /dev/null +++ b/web/apps/portal/src/assets/svg/loading.ts @@ -0,0 +1,32 @@ +// 自定义四点旋转SVG +export const fourDotsSpinnerSvg = ` + + + + + + + + + +` diff --git a/web/apps/portal/src/components/core/banners/art-basic-banner/index.vue b/web/apps/portal/src/components/core/banners/art-basic-banner/index.vue new file mode 100644 index 0000000..65b47e4 --- /dev/null +++ b/web/apps/portal/src/components/core/banners/art-basic-banner/index.vue @@ -0,0 +1,343 @@ + + + + + + diff --git a/web/apps/portal/src/components/core/banners/art-card-banner/index.vue b/web/apps/portal/src/components/core/banners/art-card-banner/index.vue new file mode 100644 index 0000000..8a5f9d4 --- /dev/null +++ b/web/apps/portal/src/components/core/banners/art-card-banner/index.vue @@ -0,0 +1,114 @@ + + + + diff --git a/web/apps/portal/src/components/core/base/art-back-to-top/index.vue b/web/apps/portal/src/components/core/base/art-back-to-top/index.vue new file mode 100644 index 0000000..6f8da61 --- /dev/null +++ b/web/apps/portal/src/components/core/base/art-back-to-top/index.vue @@ -0,0 +1,40 @@ + + + + diff --git a/web/apps/portal/src/components/core/base/art-logo/index.vue b/web/apps/portal/src/components/core/base/art-logo/index.vue new file mode 100644 index 0000000..8bc8309 --- /dev/null +++ b/web/apps/portal/src/components/core/base/art-logo/index.vue @@ -0,0 +1,21 @@ + + + + diff --git a/web/apps/portal/src/components/core/base/art-svg-icon/index.vue b/web/apps/portal/src/components/core/base/art-svg-icon/index.vue new file mode 100644 index 0000000..0bfcd0c --- /dev/null +++ b/web/apps/portal/src/components/core/base/art-svg-icon/index.vue @@ -0,0 +1,24 @@ + + + + diff --git a/web/apps/portal/src/components/core/cards/art-bar-chart-card/index.vue b/web/apps/portal/src/components/core/cards/art-bar-chart-card/index.vue new file mode 100644 index 0000000..6815c2b --- /dev/null +++ b/web/apps/portal/src/components/core/cards/art-bar-chart-card/index.vue @@ -0,0 +1,103 @@ + + + + diff --git a/web/apps/portal/src/components/core/cards/art-data-list-card/index.vue b/web/apps/portal/src/components/core/cards/art-data-list-card/index.vue new file mode 100644 index 0000000..fc43323 --- /dev/null +++ b/web/apps/portal/src/components/core/cards/art-data-list-card/index.vue @@ -0,0 +1,74 @@ + + + + diff --git a/web/apps/portal/src/components/core/cards/art-donut-chart-card/index.vue b/web/apps/portal/src/components/core/cards/art-donut-chart-card/index.vue new file mode 100644 index 0000000..df2dcbb --- /dev/null +++ b/web/apps/portal/src/components/core/cards/art-donut-chart-card/index.vue @@ -0,0 +1,124 @@ + + + + diff --git a/web/apps/portal/src/components/core/cards/art-image-card/index.vue b/web/apps/portal/src/components/core/cards/art-image-card/index.vue new file mode 100644 index 0000000..d27fe00 --- /dev/null +++ b/web/apps/portal/src/components/core/cards/art-image-card/index.vue @@ -0,0 +1,89 @@ + + + + diff --git a/web/apps/portal/src/components/core/cards/art-line-chart-card/index.vue b/web/apps/portal/src/components/core/cards/art-line-chart-card/index.vue new file mode 100644 index 0000000..e58c9b2 --- /dev/null +++ b/web/apps/portal/src/components/core/cards/art-line-chart-card/index.vue @@ -0,0 +1,126 @@ + + + + diff --git a/web/apps/portal/src/components/core/cards/art-progress-card/index.vue b/web/apps/portal/src/components/core/cards/art-progress-card/index.vue new file mode 100644 index 0000000..048a836 --- /dev/null +++ b/web/apps/portal/src/components/core/cards/art-progress-card/index.vue @@ -0,0 +1,86 @@ + + + + diff --git a/web/apps/portal/src/components/core/cards/art-stats-card/index.vue b/web/apps/portal/src/components/core/cards/art-stats-card/index.vue new file mode 100644 index 0000000..8e0341b --- /dev/null +++ b/web/apps/portal/src/components/core/cards/art-stats-card/index.vue @@ -0,0 +1,67 @@ + + + + diff --git a/web/apps/portal/src/components/core/cards/art-timeline-list-card/index.vue b/web/apps/portal/src/components/core/cards/art-timeline-list-card/index.vue new file mode 100644 index 0000000..fbb2c78 --- /dev/null +++ b/web/apps/portal/src/components/core/cards/art-timeline-list-card/index.vue @@ -0,0 +1,69 @@ + + + + diff --git a/web/apps/portal/src/components/core/charts/art-bar-chart/index.vue b/web/apps/portal/src/components/core/charts/art-bar-chart/index.vue new file mode 100644 index 0000000..d677196 --- /dev/null +++ b/web/apps/portal/src/components/core/charts/art-bar-chart/index.vue @@ -0,0 +1,203 @@ + + + + diff --git a/web/apps/portal/src/components/core/charts/art-dual-bar-compare-chart/index.vue b/web/apps/portal/src/components/core/charts/art-dual-bar-compare-chart/index.vue new file mode 100644 index 0000000..32aa60f --- /dev/null +++ b/web/apps/portal/src/components/core/charts/art-dual-bar-compare-chart/index.vue @@ -0,0 +1,195 @@ + + + + diff --git a/web/apps/portal/src/components/core/charts/art-h-bar-chart/index.vue b/web/apps/portal/src/components/core/charts/art-h-bar-chart/index.vue new file mode 100644 index 0000000..2e34759 --- /dev/null +++ b/web/apps/portal/src/components/core/charts/art-h-bar-chart/index.vue @@ -0,0 +1,208 @@ + + + + diff --git a/web/apps/portal/src/components/core/charts/art-k-line-chart/index.vue b/web/apps/portal/src/components/core/charts/art-k-line-chart/index.vue new file mode 100644 index 0000000..0061b51 --- /dev/null +++ b/web/apps/portal/src/components/core/charts/art-k-line-chart/index.vue @@ -0,0 +1,152 @@ + + + + diff --git a/web/apps/portal/src/components/core/charts/art-line-chart/index.vue b/web/apps/portal/src/components/core/charts/art-line-chart/index.vue new file mode 100644 index 0000000..b70c2c3 --- /dev/null +++ b/web/apps/portal/src/components/core/charts/art-line-chart/index.vue @@ -0,0 +1,371 @@ + + + + diff --git a/web/apps/portal/src/components/core/charts/art-radar-chart/index.vue b/web/apps/portal/src/components/core/charts/art-radar-chart/index.vue new file mode 100644 index 0000000..e99fff6 --- /dev/null +++ b/web/apps/portal/src/components/core/charts/art-radar-chart/index.vue @@ -0,0 +1,105 @@ + + + + diff --git a/web/apps/portal/src/components/core/charts/art-ring-chart/index.vue b/web/apps/portal/src/components/core/charts/art-ring-chart/index.vue new file mode 100644 index 0000000..79115f7 --- /dev/null +++ b/web/apps/portal/src/components/core/charts/art-ring-chart/index.vue @@ -0,0 +1,133 @@ + + + + diff --git a/web/apps/portal/src/components/core/charts/art-scatter-chart/index.vue b/web/apps/portal/src/components/core/charts/art-scatter-chart/index.vue new file mode 100644 index 0000000..995b56a --- /dev/null +++ b/web/apps/portal/src/components/core/charts/art-scatter-chart/index.vue @@ -0,0 +1,115 @@ + + + + diff --git a/web/apps/portal/src/components/core/forms/art-button-more/index.vue b/web/apps/portal/src/components/core/forms/art-button-more/index.vue new file mode 100644 index 0000000..858d305 --- /dev/null +++ b/web/apps/portal/src/components/core/forms/art-button-more/index.vue @@ -0,0 +1,71 @@ + + + + diff --git a/web/apps/portal/src/components/core/forms/art-button-table/index.vue b/web/apps/portal/src/components/core/forms/art-button-table/index.vue new file mode 100644 index 0000000..c849901 --- /dev/null +++ b/web/apps/portal/src/components/core/forms/art-button-table/index.vue @@ -0,0 +1,59 @@ + + + + diff --git a/web/apps/portal/src/components/core/forms/art-drag-verify/index.vue b/web/apps/portal/src/components/core/forms/art-drag-verify/index.vue new file mode 100644 index 0000000..5306e04 --- /dev/null +++ b/web/apps/portal/src/components/core/forms/art-drag-verify/index.vue @@ -0,0 +1,430 @@ + + + + + + + + diff --git a/web/apps/portal/src/components/core/forms/art-excel-export/index.vue b/web/apps/portal/src/components/core/forms/art-excel-export/index.vue new file mode 100644 index 0000000..08207c2 --- /dev/null +++ b/web/apps/portal/src/components/core/forms/art-excel-export/index.vue @@ -0,0 +1,389 @@ + + + + + + diff --git a/web/apps/portal/src/components/core/forms/art-excel-import/index.vue b/web/apps/portal/src/components/core/forms/art-excel-import/index.vue new file mode 100644 index 0000000..8aa82fe --- /dev/null +++ b/web/apps/portal/src/components/core/forms/art-excel-import/index.vue @@ -0,0 +1,62 @@ + + + + diff --git a/web/apps/portal/src/components/core/forms/art-form/index.vue b/web/apps/portal/src/components/core/forms/art-form/index.vue new file mode 100644 index 0000000..561e878 --- /dev/null +++ b/web/apps/portal/src/components/core/forms/art-form/index.vue @@ -0,0 +1,507 @@ + + + + + + diff --git a/web/apps/portal/src/components/core/forms/art-search-bar/index.vue b/web/apps/portal/src/components/core/forms/art-search-bar/index.vue new file mode 100644 index 0000000..6786dca --- /dev/null +++ b/web/apps/portal/src/components/core/forms/art-search-bar/index.vue @@ -0,0 +1,581 @@ + + + + + + + + diff --git a/web/apps/portal/src/components/core/forms/art-wang-editor/index.vue b/web/apps/portal/src/components/core/forms/art-wang-editor/index.vue new file mode 100644 index 0000000..3a55691 --- /dev/null +++ b/web/apps/portal/src/components/core/forms/art-wang-editor/index.vue @@ -0,0 +1,257 @@ + + + + + + diff --git a/web/apps/portal/src/components/core/forms/art-wang-editor/style.scss b/web/apps/portal/src/components/core/forms/art-wang-editor/style.scss new file mode 100644 index 0000000..7f22e98 --- /dev/null +++ b/web/apps/portal/src/components/core/forms/art-wang-editor/style.scss @@ -0,0 +1,273 @@ +$box-radius: calc(var(--custom-radius) / 3 + 2px); + +// 全屏容器 z-index 调整 +.w-e-full-screen-container { + z-index: 100 !important; +} + +/* 编辑器容器 */ +.editor-wrapper { + width: 100%; + height: 100%; + border: 1px solid var(--art-gray-300); + border-radius: $box-radius !important; + + .w-e-bar { + border-radius: $box-radius $box-radius 0 0 !important; + } + + .menu-item { + display: flex; + flex-direction: row; + align-items: center; + + i { + margin-right: 5px; + } + } + + /* 工具栏 */ + .editor-toolbar { + border-bottom: 1px solid var(--default-border); + } + + /* 下拉选择框配置 */ + .w-e-select-list { + min-width: 140px; + padding: 5px 10px 10px; + border: none; + border-radius: $box-radius; + } + + /* 下拉选择框元素配置 */ + .w-e-select-list ul li { + margin-top: 5px; + font-size: 15px !important; + border-radius: $box-radius; + } + + /* 下拉选择框 正文文字大小调整 */ + .w-e-select-list ul li:last-of-type { + font-size: 16px !important; + } + + /* 下拉选择框 hover 样式调整 */ + .w-e-select-list ul li:hover { + background-color: var(--art-gray-200); + } + + :root { + /* 激活颜色 */ + --w-e-toolbar-active-bg-color: var(--art-gray-200); + + /* toolbar 图标和文字颜色 */ + --w-e-toolbar-color: #000; + + /* 表格选中时候的边框颜色 */ + --w-e-textarea-selected-border-color: #ddd; + + /* 表格头背景颜色 */ + --w-e-textarea-slight-bg-color: var(--art-gray-200); + } + + /* 工具栏按钮样式 */ + .w-e-bar-item svg { + fill: var(--art-gray-800); + } + + .w-e-bar-item button { + color: var(--art-gray-800); + border-radius: $box-radius; + } + + /* 工具栏 hover 按钮背景颜色 */ + .w-e-bar-item button:hover { + background-color: var(--art-gray-200); + } + + /* 工具栏分割线 */ + .w-e-bar-divider { + height: 20px; + margin-top: 10px; + background-color: #ccc; + } + + /* 工具栏菜单 */ + .w-e-bar-item-group .w-e-bar-item-menus-container { + min-width: 120px; + padding: 10px 0; + border: none; + border-radius: $box-radius; + + .w-e-bar-item { + button { + width: 100%; + margin: 0 5px; + } + } + } + + /* 代码块 */ + .w-e-text-container [data-slate-editor] pre > code { + padding: 0.6rem 1rem; + background-color: var(--art-gray-50); + border-radius: $box-radius; + } + + /* 弹出框 */ + .w-e-drop-panel { + border: 0; + border-radius: $box-radius; + } + + a { + color: #318ef4; + } + + .w-e-text-container { + [data-slate-editor] { + h1, + h2, + h3, + h4, + h5, + h6 { + margin: 0.8em 0 0.4em; + font-weight: 700; + line-height: 1.35; + } + + h1 { + font-size: 2em; + } + + h2 { + font-size: 1.5em; + } + + h3 { + font-size: 1.25em; + } + + h4 { + font-size: 1.125em; + } + + h5 { + font-size: 1em; + } + + h6 { + font-size: 0.875em; + } + + ul, + ol { + padding-left: 1.5em; + margin: 0.8em 0; + } + + ul { + list-style: disc; + } + + ol { + list-style: decimal; + } + + li { + margin: 0.25em 0; + } + + ul ul { + list-style: circle; + } + + ul ul ul { + list-style: square; + } + } + + strong, + b { + font-weight: 700; + } + + i, + em { + font-style: italic; + } + } + + /* 表格样式优化 */ + .w-e-text-container [data-slate-editor] .table-container th { + border-right: none; + } + + .w-e-text-container [data-slate-editor] .table-container th:last-of-type { + border-right: 1px solid #ccc !important; + } + + /* 引用 */ + .w-e-text-container [data-slate-editor] blockquote { + background-color: var(--art-gray-200); + border-left: 4px solid var(--art-gray-300); + } + + /* 输入区域弹出 bar */ + .w-e-hover-bar { + border-radius: $box-radius; + } + + /* 超链接弹窗 */ + .w-e-modal { + border: none; + border-radius: $box-radius; + } + + /* 图片样式调整 */ + .w-e-text-container [data-slate-editor] .w-e-selected-image-container { + overflow: inherit; + + &:hover { + border: 0; + } + + img { + border: 1px solid transparent; + transition: border 0.3s; + + &:hover { + border: 1px solid #318ef4 !important; + } + } + + .w-e-image-dragger { + width: 12px; + height: 12px; + background-color: #318ef4; + border: 2px solid #fff; + border-radius: $box-radius; + } + + .left-top { + top: -6px; + left: -6px; + } + + .right-top { + top: -6px; + right: -6px; + } + + .left-bottom { + bottom: -6px; + left: -6px; + } + + .right-bottom { + right: -6px; + bottom: -6px; + } + } +} diff --git a/web/apps/portal/src/components/core/layouts/art-breadcrumb/index.vue b/web/apps/portal/src/components/core/layouts/art-breadcrumb/index.vue new file mode 100755 index 0000000..4b54859 --- /dev/null +++ b/web/apps/portal/src/components/core/layouts/art-breadcrumb/index.vue @@ -0,0 +1,142 @@ + + + + diff --git a/web/apps/portal/src/components/core/layouts/art-chat-window/index.vue b/web/apps/portal/src/components/core/layouts/art-chat-window/index.vue new file mode 100644 index 0000000..f3d9471 --- /dev/null +++ b/web/apps/portal/src/components/core/layouts/art-chat-window/index.vue @@ -0,0 +1,262 @@ + + + + diff --git a/web/apps/portal/src/components/core/layouts/art-fast-enter/index.vue b/web/apps/portal/src/components/core/layouts/art-fast-enter/index.vue new file mode 100644 index 0000000..fdde222 --- /dev/null +++ b/web/apps/portal/src/components/core/layouts/art-fast-enter/index.vue @@ -0,0 +1,113 @@ + + + + diff --git a/web/apps/portal/src/components/core/layouts/art-fireworks-effect/index.vue b/web/apps/portal/src/components/core/layouts/art-fireworks-effect/index.vue new file mode 100644 index 0000000..be85274 --- /dev/null +++ b/web/apps/portal/src/components/core/layouts/art-fireworks-effect/index.vue @@ -0,0 +1,633 @@ + + + + diff --git a/web/apps/portal/src/components/core/layouts/art-global-component/index.vue b/web/apps/portal/src/components/core/layouts/art-global-component/index.vue new file mode 100644 index 0000000..6908f94 --- /dev/null +++ b/web/apps/portal/src/components/core/layouts/art-global-component/index.vue @@ -0,0 +1,14 @@ + + + + diff --git a/web/apps/portal/src/components/core/layouts/art-global-search/index.vue b/web/apps/portal/src/components/core/layouts/art-global-search/index.vue new file mode 100644 index 0000000..f2c0b88 --- /dev/null +++ b/web/apps/portal/src/components/core/layouts/art-global-search/index.vue @@ -0,0 +1,431 @@ + + + + + + + diff --git a/web/apps/portal/src/components/core/layouts/art-header-bar/index.vue b/web/apps/portal/src/components/core/layouts/art-header-bar/index.vue new file mode 100755 index 0000000..ed8ec22 --- /dev/null +++ b/web/apps/portal/src/components/core/layouts/art-header-bar/index.vue @@ -0,0 +1,501 @@ + + + + + + diff --git a/web/apps/portal/src/components/core/layouts/art-header-bar/widget/ArtUserMenu.vue b/web/apps/portal/src/components/core/layouts/art-header-bar/widget/ArtUserMenu.vue new file mode 100644 index 0000000..c8c5832 --- /dev/null +++ b/web/apps/portal/src/components/core/layouts/art-header-bar/widget/ArtUserMenu.vue @@ -0,0 +1,167 @@ + + + + + + diff --git a/web/apps/portal/src/components/core/layouts/art-menus/art-horizontal-menu/index.vue b/web/apps/portal/src/components/core/layouts/art-menus/art-horizontal-menu/index.vue new file mode 100644 index 0000000..edd1473 --- /dev/null +++ b/web/apps/portal/src/components/core/layouts/art-menus/art-horizontal-menu/index.vue @@ -0,0 +1,110 @@ + + + + + + diff --git a/web/apps/portal/src/components/core/layouts/art-menus/art-horizontal-menu/widget/HorizontalSubmenu.vue b/web/apps/portal/src/components/core/layouts/art-menus/art-horizontal-menu/widget/HorizontalSubmenu.vue new file mode 100644 index 0000000..0f3a817 --- /dev/null +++ b/web/apps/portal/src/components/core/layouts/art-menus/art-horizontal-menu/widget/HorizontalSubmenu.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/web/apps/portal/src/components/core/layouts/art-menus/art-mixed-menu/index.vue b/web/apps/portal/src/components/core/layouts/art-menus/art-mixed-menu/index.vue new file mode 100644 index 0000000..4e98246 --- /dev/null +++ b/web/apps/portal/src/components/core/layouts/art-menus/art-mixed-menu/index.vue @@ -0,0 +1,279 @@ + + + + + + + + diff --git a/web/apps/portal/src/components/core/layouts/art-menus/art-sidebar-menu/index.vue b/web/apps/portal/src/components/core/layouts/art-menus/art-sidebar-menu/index.vue new file mode 100755 index 0000000..16f28c7 --- /dev/null +++ b/web/apps/portal/src/components/core/layouts/art-menus/art-sidebar-menu/index.vue @@ -0,0 +1,354 @@ + + + + + + + + diff --git a/web/apps/portal/src/components/core/layouts/art-menus/art-sidebar-menu/style.scss b/web/apps/portal/src/components/core/layouts/art-menus/art-sidebar-menu/style.scss new file mode 100755 index 0000000..8cccdd9 --- /dev/null +++ b/web/apps/portal/src/components/core/layouts/art-menus/art-sidebar-menu/style.scss @@ -0,0 +1,251 @@ +.layout-sidebar { + display: flex; + height: 100vh; + user-select: none; + scrollbar-width: none; + border-right: 1px solid var(--art-card-border); + + &.no-border { + border-right: none !important; + } + + // 自定义滚动条宽度 + :deep(.el-scrollbar__bar.is-vertical) { + width: 4px; + } + + :deep(.el-scrollbar__thumb) { + right: -2px; + background-color: #ccc; + border-radius: 2px; + } + + .dual-menu-left { + position: relative; + width: 80px; + height: 100%; + border-right: 1px solid var(--art-card-border) !important; + transition: width 0.25s; + + .logo { + margin: auto; + margin-top: 12px; + margin-bottom: 3px; + cursor: pointer; + } + + ul { + li { + > div { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + margin: 8px; + overflow: hidden; + text-align: center; + cursor: pointer; + border-radius: 5px; + + .art-svg-icon { + display: block; + margin: 0 auto; + font-size: 20px; + } + + span { + display: -webkit-box; + width: 100%; + overflow: hidden; + font-size: 12px; + text-overflow: ellipsis; + -webkit-line-clamp: 1; + line-clamp: 1; + -webkit-box-orient: vertical; + } + + &.is-active { + background: var(--el-color-primary-light-9); + + .art-svg-icon, + span { + color: var(--theme-color) !important; + } + } + } + } + } + + .switch-btn { + position: absolute; + right: 0; + bottom: 15px; + left: 0; + margin: auto; + } + } + + .menu-left { + position: relative; + box-sizing: border-box; + height: 100vh; + + @media only screen and (width <= 640px) { + height: 100dvh; + } + + .el-menu { + height: 100%; + } + + &:hover { + .dual-menu-collapse-btn { + opacity: 1 !important; + } + } + + .dual-menu-collapse-btn { + position: absolute; + top: 50%; + right: -11px; + z-index: 10; + width: 11px; + height: 50px; + cursor: pointer; + background-color: var(--default-box-color); + border: 1px solid var(--art-card-border); + border-radius: 0 15px 15px 0; + opacity: 0; + transition: opacity 0.2s; + transform: translateY(-50%); + + &:hover { + .art-svg-icon { + color: var(--art-gray-800) !important; + } + } + + .art-svg-icon { + position: absolute; + top: 0; + bottom: 0; + left: -4px; + margin: auto; + transition: all 0.3s; + } + } + } + + .header { + position: relative; + box-sizing: border-box; + display: flex; + align-items: center; + width: 100%; + height: 60px; + overflow: hidden; + line-height: 60px; + cursor: pointer; + + .logo { + margin-left: 22px; + } + + p { + position: absolute; + top: 0; + bottom: 0; + left: 58px; + box-sizing: border-box; + margin-left: 10px; + font-size: 18px; + + &.is-dual-menu-name { + left: 25px; + margin: auto; + } + } + } + + .el-menu { + box-sizing: border-box; + // 防止菜单内的滚动影响整个页面滚动 + overscroll-behavior: contain; + border-right: 0; + scrollbar-width: none; + -ms-scroll-chaining: contain; + + &::-webkit-scrollbar { + width: 0 !important; + } + } + + .menu-model { + display: none; + } +} + +@media only screen and (width <= 800px) { + .layout-sidebar { + width: 0; + + .header { + height: 50px; + line-height: 50px; + } + + .el-menu { + height: calc(100vh - 60px); + } + + .el-menu--collapse { + width: 0; + } + + // 折叠状态下的header样式 + .menu-left-close .header { + .logo { + display: none; + } + + p { + left: 16px; + font-size: 0; + opacity: 0 !important; + } + } + + .menu-model { + position: fixed; + top: 0; + left: 0; + z-index: -1; + display: block; + width: 100%; + height: 100vh; + background: rgba($color: #000, $alpha: 50%); + transition: opacity 0.2s ease-in-out; + } + } +} + +@media only screen and (width <= 640px) { + .layout-sidebar { + border-right: 0 !important; + } +} + +.dark { + .layout-sidebar { + border-right: 1px solid rgb(255 255 255 / 13%); + + :deep(.el-scrollbar__thumb) { + background-color: #777; + } + + .dual-menu-left { + border-right: 1px solid rgb(255 255 255 / 9%) !important; + } + } +} diff --git a/web/apps/portal/src/components/core/layouts/art-menus/art-sidebar-menu/theme.scss b/web/apps/portal/src/components/core/layouts/art-menus/art-sidebar-menu/theme.scss new file mode 100644 index 0000000..a6329ea --- /dev/null +++ b/web/apps/portal/src/components/core/layouts/art-menus/art-sidebar-menu/theme.scss @@ -0,0 +1,274 @@ +@use '@styles/core/mixin.scss' as *; + +// 菜单样式变量 +$menu-height: 42px; +$menu-icon-size: 20px; +$menu-font-size: 14px; +$hover-bg-color: var(--art-gray-200); +$popup-menu-height: 40px; +$popup-menu-padding: 8px; +$popup-menu-margin: 5px; +$popup-menu-radius: 6px; + +// 通用菜单项样式 +@mixin menu-item-base { + width: calc(100% - 16px); + margin-left: 8px; + border-radius: 6px; + + .menu-icon { + margin-left: -7px; + } +} + +// 通用 hover 样式 +@mixin menu-hover($bg-color) { + .el-sub-menu__title:hover, + .el-menu-item:not(.is-active):hover { + background: $bg-color !important; + } +} + +// 通用选中样式(语枢网关:左侧青色高亮条 + 图标辉光) +@mixin menu-active($color, $bg-color, $icon-color: var(--theme-color)) { + .el-menu-item.is-active { + color: $color !important; + background-color: $bg-color; + + &::before { + position: absolute; + top: 50%; + left: 0; + width: 3px; + height: 60%; + content: ''; + background: var(--theme-color); + border-radius: 0 3px 3px 0; + box-shadow: 0 0 8px var(--theme-color); + transform: translateY(-50%); + } + + .menu-icon { + .art-svg-icon { + color: $icon-color !important; + filter: drop-shadow(0 0 6px var(--theme-color)); + } + } + } +} + +// 弹窗菜单项样式 +@mixin popup-menu-item { + height: $popup-menu-height; + margin-bottom: $popup-menu-margin; + border-radius: $popup-menu-radius; + + .menu-icon { + margin-right: 5px; + } + + &:last-of-type { + margin-bottom: 0; + } +} + +// 主题菜单通用样式(合并 design 和 dark 主题的共同逻辑) +@mixin theme-menu-base { + .el-sub-menu__title, + .el-menu-item { + @include menu-item-base; + } +} + +// 弹窗菜单通用样式 +@mixin popup-menu-base($hover-bg, $active-color, $active-bg) { + .el-menu--popup { + padding: $popup-menu-padding; + + .el-sub-menu__title:hover, + .el-menu-item:hover { + background-color: $hover-bg !important; + border-radius: $popup-menu-radius; + } + + .el-menu-item { + @include popup-menu-item; + + &.is-active { + color: $active-color !important; + background-color: $active-bg !important; + } + } + + .el-sub-menu { + @include popup-menu-item; + + height: $popup-menu-height !important; + + .el-sub-menu__title { + height: $popup-menu-height !important; + border-radius: $popup-menu-radius; + } + } + } +} + +.layout-sidebar { + // ---------------------- Modify default style ---------------------- + + // 菜单折叠样式 + .menu-left-close { + .header { + .logo { + margin: 0 auto; + } + } + } + + // 菜单图标 + .menu-icon { + margin-right: 8px; + font-size: $menu-icon-size; + } + + // 菜单高度 + .el-sub-menu__title, + .el-menu-item { + height: $menu-height !important; + margin-bottom: 4px; + line-height: $menu-height !important; + + span { + font-size: $menu-font-size !important; + + @include ellipsis(); + } + } + + // 右侧箭头 + .el-sub-menu__icon-arrow { + width: 13px !important; + font-size: 13px !important; + } + + // 菜单折叠 + .el-menu--collapse { + .el-sub-menu.is-active { + .el-sub-menu__title { + .menu-icon { + .art-svg-icon { + // 选中菜单图标颜色 + color: var(--theme-color) !important; + } + } + } + } + } + + // ---------------------- Design theme menu ---------------------- + .el-menu-design { + @include theme-menu-base; + @include menu-active(var(--theme-color), var(--el-color-primary-light-9)); + @include menu-hover($hover-bg-color); + + .el-sub-menu__icon-arrow { + color: var(--art-gray-600); + } + } + + // ---------------------- Dark theme menu (navy HUD) ---------------------- + .el-menu-dark { + @include theme-menu-base; + @include menu-active(#fff, #1a2740, #fff); + @include menu-hover(#0d1b33); + + .el-sub-menu__icon-arrow { + color: var(--art-gray-400); + } + } + + // ---------------------- Light theme menu ---------------------- + .el-menu-light { + .el-sub-menu__title, + .el-menu-item { + .menu-icon { + margin-left: 1px; + } + } + + .el-menu-item.is-active { + background-color: var(--el-color-primary-light-9); + + .art-svg-icon { + color: var(--theme-color) !important; + } + + &::before { + position: absolute; + top: 0; + left: 0; + width: 4px; + height: 100%; + content: ''; + background: var(--theme-color); + } + } + + @include menu-hover($hover-bg-color); + + .el-sub-menu__icon-arrow { + color: var(--art-gray-600); + } + } +} + +@media only screen and (width <= 640px) { + .layout-sidebar { + .el-menu-design { + > .el-sub-menu { + margin-left: 0; + } + + .el-sub-menu { + width: 100% !important; + } + } + } +} + +// 菜单折叠 hover 弹窗样式(浅色主题) +.el-menu--vertical, +.el-menu--popup-container { + @include popup-menu-base(var(--art-gray-200), var(--art-gray-900), var(--art-gray-200)); +} + +// 暗黑模式菜单样式 +.dark { + .el-menu--vertical, + .el-menu--popup-container { + @include popup-menu-base(var(--art-gray-200), var(--art-gray-900), #1a2740); + } + + .layout-sidebar { + // 图标颜色、文字颜色 + .menu-icon .art-svg-icon, + .menu-name { + color: var(--art-gray-800) !important; + } + + // 选中的文字颜色跟图标颜色(深色下用半透明青底,替代过亮的 light-9 底色) + .el-menu-item.is-active { + span, + .menu-icon .art-svg-icon { + color: var(--theme-color) !important; + } + + background-color: rgb(0 229 255 / 10%) !important; + } + + // 右侧箭头颜色 + .el-sub-menu__icon-arrow { + color: #fff; + } + } +} diff --git a/web/apps/portal/src/components/core/layouts/art-menus/art-sidebar-menu/widget/SidebarSubmenu.vue b/web/apps/portal/src/components/core/layouts/art-menus/art-sidebar-menu/widget/SidebarSubmenu.vue new file mode 100755 index 0000000..9afa262 --- /dev/null +++ b/web/apps/portal/src/components/core/layouts/art-menus/art-sidebar-menu/widget/SidebarSubmenu.vue @@ -0,0 +1,199 @@ + + + diff --git a/web/apps/portal/src/components/core/layouts/art-notification/index.vue b/web/apps/portal/src/components/core/layouts/art-notification/index.vue new file mode 100755 index 0000000..a58853c --- /dev/null +++ b/web/apps/portal/src/components/core/layouts/art-notification/index.vue @@ -0,0 +1,456 @@ + + + + + + diff --git a/web/apps/portal/src/components/core/layouts/art-page-content/index.vue b/web/apps/portal/src/components/core/layouts/art-page-content/index.vue new file mode 100644 index 0000000..a862df1 --- /dev/null +++ b/web/apps/portal/src/components/core/layouts/art-page-content/index.vue @@ -0,0 +1,136 @@ + + + diff --git a/web/apps/portal/src/components/core/layouts/art-screen-lock/index.vue b/web/apps/portal/src/components/core/layouts/art-screen-lock/index.vue new file mode 100644 index 0000000..5bf5248 --- /dev/null +++ b/web/apps/portal/src/components/core/layouts/art-screen-lock/index.vue @@ -0,0 +1,519 @@ + + + + + + diff --git a/web/apps/portal/src/components/core/layouts/art-settings-panel/composables/useSettingsConfig.ts b/web/apps/portal/src/components/core/layouts/art-settings-panel/composables/useSettingsConfig.ts new file mode 100644 index 0000000..35e8066 --- /dev/null +++ b/web/apps/portal/src/components/core/layouts/art-settings-panel/composables/useSettingsConfig.ts @@ -0,0 +1,248 @@ +import { computed } from 'vue' +import { useI18n } from 'vue-i18n' +import { ContainerWidthEnum } from '@/enums/appEnum' +import AppConfig from '@/config' +import { headerBarConfig } from '@/config/modules/headerBar' + +/** + * 设置项配置选项管理 + */ +export function useSettingsConfig() { + const { t } = useI18n() + + // 标签页风格选项 + const tabStyleOptions = computed(() => [ + { + value: 'tab-default', + label: t('setting.tabStyle.default') + }, + { + value: 'tab-card', + label: t('setting.tabStyle.card') + }, + { + value: 'tab-google', + label: t('setting.tabStyle.google') + } + ]) + + // 页面切换动画选项 + const pageTransitionOptions = computed(() => [ + { + value: '', + label: t('setting.transition.list.none') + }, + { + value: 'fade', + label: t('setting.transition.list.fade') + }, + { + value: 'slide-left', + label: t('setting.transition.list.slideLeft') + }, + { + value: 'slide-bottom', + label: t('setting.transition.list.slideBottom') + }, + { + value: 'slide-top', + label: t('setting.transition.list.slideTop') + } + ]) + + // 圆角大小选项 + const customRadiusOptions = [ + { value: '0', label: '0' }, + { value: '0.25', label: '0.25' }, + { value: '0.5', label: '0.5' }, + { value: '0.75', label: '0.75' }, + { value: '1', label: '1' } + ] + + // 容器宽度选项 + const containerWidthOptions = computed(() => [ + { + value: ContainerWidthEnum.FULL, + label: t('setting.container.list[0]'), + icon: 'icon-park-outline:auto-width' + }, + { + value: ContainerWidthEnum.BOXED, + label: t('setting.container.list[1]'), + icon: 'ix:width' + } + ]) + + // 盒子样式选项 + const boxStyleOptions = computed(() => [ + { + value: 'border-mode', + label: t('setting.box.list[0]'), + type: 'border-mode' as const + }, + { + value: 'shadow-mode', + label: t('setting.box.list[1]'), + type: 'shadow-mode' as const + } + ]) + + // 从配置文件获取的选项 + const configOptions = { + // 主题色彩选项 + mainColors: AppConfig.systemMainColor, + + // 主题风格选项 + themeList: AppConfig.settingThemeList, + + // 菜单布局选项 + menuLayoutList: AppConfig.menuLayoutList + } + + // 基础设置项配置 + const basicSettingsConfig = computed(() => { + // 定义所有基础设置项 + const allSettings = [ + { + key: 'showWorkTab', + label: t('setting.basics.list.multiTab'), + type: 'switch' as const, + handler: 'workTab', + headerBarKey: null // 不依赖headerBar配置 + }, + { + key: 'uniqueOpened', + label: t('setting.basics.list.accordion'), + type: 'switch' as const, + handler: 'uniqueOpened', + headerBarKey: null // 不依赖headerBar配置 + }, + { + key: 'showMenuButton', + label: t('setting.basics.list.collapseSidebar'), + type: 'switch' as const, + handler: 'menuButton', + headerBarKey: 'menuButton' as const + }, + { + key: 'showFastEnter', + label: t('setting.basics.list.fastEnter'), + type: 'switch' as const, + handler: 'fastEnter', + headerBarKey: 'fastEnter' as const + }, + { + key: 'showRefreshButton', + label: t('setting.basics.list.reloadPage'), + type: 'switch' as const, + handler: 'refreshButton', + headerBarKey: 'refreshButton' as const + }, + { + key: 'showCrumbs', + label: t('setting.basics.list.breadcrumb'), + type: 'switch' as const, + handler: 'crumbs', + mobileHide: true, + headerBarKey: 'breadcrumb' as const + }, + { + key: 'showLanguage', + label: t('setting.basics.list.language'), + type: 'switch' as const, + handler: 'language', + headerBarKey: 'language' as const + }, + { + key: 'showNprogress', + label: t('setting.basics.list.progressBar'), + type: 'switch' as const, + handler: 'nprogress', + headerBarKey: null // 不依赖headerBar配置 + }, + { + key: 'colorWeak', + label: t('setting.basics.list.weakMode'), + type: 'switch' as const, + handler: 'colorWeak', + headerBarKey: null // 不依赖headerBar配置 + }, + { + key: 'watermarkVisible', + label: t('setting.basics.list.watermark'), + type: 'switch' as const, + handler: 'watermark', + headerBarKey: null // 不依赖headerBar配置 + }, + { + key: 'menuOpenWidth', + label: t('setting.basics.list.menuWidth'), + type: 'input-number' as const, + handler: 'menuOpenWidth', + min: 180, + max: 320, + step: 10, + style: { width: '120px' }, + controlsPosition: 'right' as const, + headerBarKey: null // 不依赖headerBar配置 + }, + { + key: 'tabStyle', + label: t('setting.basics.list.tabStyle'), + type: 'select' as const, + handler: 'tabStyle', + options: tabStyleOptions.value, + style: { width: '120px' }, + headerBarKey: null // 不依赖headerBar配置 + }, + { + key: 'pageTransition', + label: t('setting.basics.list.pageTransition'), + type: 'select' as const, + handler: 'pageTransition', + options: pageTransitionOptions.value, + style: { width: '120px' }, + headerBarKey: null // 不依赖headerBar配置 + }, + { + key: 'customRadius', + label: t('setting.basics.list.borderRadius'), + type: 'select' as const, + handler: 'customRadius', + options: customRadiusOptions, + style: { width: '120px' }, + headerBarKey: null // 不依赖headerBar配置 + } + ] + + // 根据 headerBarConfig 过滤设置项 + return ( + allSettings + .filter((setting) => { + // 如果设置项不依赖headerBar配置,则始终显示 + if (setting.headerBarKey === null) { + return true + } + + // 如果依赖headerBar配置,检查对应的功能是否启用 + const headerBarFeature = headerBarConfig[setting.headerBarKey] + return headerBarFeature?.enabled !== false + }) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + .map(({ headerBarKey: _headerBarKey, ...setting }) => setting) + ) + }) + + return { + // 选项配置 + tabStyleOptions, + pageTransitionOptions, + customRadiusOptions, + containerWidthOptions, + boxStyleOptions, + configOptions, + + // 设置项配置 + basicSettingsConfig + } +} diff --git a/web/apps/portal/src/components/core/layouts/art-settings-panel/composables/useSettingsHandlers.ts b/web/apps/portal/src/components/core/layouts/art-settings-panel/composables/useSettingsHandlers.ts new file mode 100644 index 0000000..392c690 --- /dev/null +++ b/web/apps/portal/src/components/core/layouts/art-settings-panel/composables/useSettingsHandlers.ts @@ -0,0 +1,167 @@ +import { useSettingStore } from '@/store/modules/setting' +import { storeToRefs } from 'pinia' +import type { ContainerWidthEnum } from '@/enums/appEnum' + +/** + * 设置项通用处理逻辑 + */ +export function useSettingsHandlers() { + const settingStore = useSettingStore() + + // DOM 操作相关 + const domOperations = { + // 设置HTML类名 + setHtmlClass: (className: string, add: boolean) => { + const el = document.getElementsByTagName('html')[0] + if (add) { + el.classList.add(className) + } else { + el.classList.remove(className) + } + }, + + // 设置根元素属性 + setRootAttribute: (attribute: string, value: string) => { + const el = document.documentElement + el.setAttribute(attribute, value) + }, + + // 设置body类名 + setBodyClass: (className: string, add: boolean) => { + const el = document.getElementsByTagName('body')[0] + if (add) { + el.classList.add(className) + } else { + el.classList.remove(className) + } + } + } + + // 通用切换处理器 + const createToggleHandler = (storeMethod: () => void, callback?: () => void) => { + return () => { + storeMethod() + callback?.() + } + } + + // 通用值变更处理器 + const createValueHandler = ( + storeMethod: (value: T) => void, + callback?: (value: T) => void + ) => { + return (value: T) => { + if (value !== undefined && value !== null) { + storeMethod(value) + callback?.(value) + } + } + } + + // 基础设置处理器 + const basicHandlers = { + // 工作台标签页 + workTab: createToggleHandler(() => settingStore.setWorkTab(!settingStore.showWorkTab)), + + // 菜单手风琴 + uniqueOpened: createToggleHandler(() => settingStore.setUniqueOpened()), + + // 显示菜单按钮 + menuButton: createToggleHandler(() => settingStore.setButton()), + + // 显示快速入口 + fastEnter: createToggleHandler(() => settingStore.setFastEnter()), + + // 显示刷新按钮 + refreshButton: createToggleHandler(() => settingStore.setShowRefreshButton()), + + // 显示面包屑 + crumbs: createToggleHandler(() => settingStore.setCrumbs()), + + // 显示语言切换 + language: createToggleHandler(() => settingStore.setLanguage()), + + // 显示进度条 + nprogress: createToggleHandler(() => settingStore.setNprogress()), + + // 色弱模式 + colorWeak: createToggleHandler( + () => settingStore.setColorWeak(), + () => { + domOperations.setHtmlClass('color-weak', settingStore.colorWeak) + } + ), + + // 水印显示 + watermark: createToggleHandler(() => + settingStore.setWatermarkVisible(!settingStore.watermarkVisible) + ), + + // 菜单展开宽度 + menuOpenWidth: createValueHandler((width: number) => + settingStore.setMenuOpenWidth(width) + ), + + // 标签页风格 + tabStyle: createValueHandler((style: string) => settingStore.setTabStyle(style)), + + // 页面切换动画 + pageTransition: createValueHandler((transition: string) => + settingStore.setPageTransition(transition) + ), + + // 圆角大小 + customRadius: createValueHandler((radius: string) => + settingStore.setCustomRadius(radius) + ) + } + + // 盒子样式处理器 + const boxStyleHandlers = { + // 设置盒子模式 + setBoxMode: (type: 'border-mode' | 'shadow-mode') => { + const { boxBorderMode } = storeToRefs(settingStore) + + // 防止重复设置 + if ( + (type === 'shadow-mode' && boxBorderMode.value === false) || + (type === 'border-mode' && boxBorderMode.value === true) + ) { + return + } + + setTimeout(() => { + domOperations.setRootAttribute('data-box-mode', type) + settingStore.setBorderMode() + }, 50) + } + } + + // 颜色设置处理器 + const colorHandlers = { + // 选择主题色 + selectColor: (theme: string) => { + settingStore.setElementTheme(theme) + settingStore.reload() + } + } + + // 容器设置处理器 + const containerHandlers = { + // 设置容器宽度 + setWidth: (type: ContainerWidthEnum) => { + settingStore.setContainerWidth(type) + settingStore.reload() + } + } + + return { + domOperations, + basicHandlers, + boxStyleHandlers, + colorHandlers, + containerHandlers, + createToggleHandler, + createValueHandler + } +} diff --git a/web/apps/portal/src/components/core/layouts/art-settings-panel/composables/useSettingsPanel.ts b/web/apps/portal/src/components/core/layouts/art-settings-panel/composables/useSettingsPanel.ts new file mode 100644 index 0000000..f5e4e56 --- /dev/null +++ b/web/apps/portal/src/components/core/layouts/art-settings-panel/composables/useSettingsPanel.ts @@ -0,0 +1,233 @@ +import { ref, computed, watch } from 'vue' +import { useSettingStore } from '@/store/modules/setting' +import { storeToRefs } from 'pinia' +import { useBreakpoints } from '@vueuse/core' +import AppConfig from '@/config' +import { SystemThemeEnum, MenuTypeEnum } from '@/enums/appEnum' +import { mittBus } from '@/utils/sys' +import { StorageConfig } from '@/utils' +import { useTheme } from '@/hooks/core/useTheme' +import { useCeremony } from '@/hooks/core/useCeremony' +import { useSettingsState } from './useSettingsState' +import { useSettingsHandlers } from './useSettingsHandlers' + +/** + * 设置面板核心逻辑管理 + */ +export function useSettingsPanel() { + const settingStore = useSettingStore() + const { systemThemeType, systemThemeMode, menuType } = storeToRefs(settingStore) + + // Composables + const { openFestival, cleanup } = useCeremony() + const { setSystemTheme, setSystemAutoTheme } = useTheme() + const { initColorWeak } = useSettingsState() + const { domOperations } = useSettingsHandlers() + + // 响应式状态 + const showDrawer = ref(false) + + // 使用 VueUse breakpoints 优化性能 + const breakpoints = useBreakpoints({ tablet: 1000 }) + const isMobile = breakpoints.smaller('tablet') + + // 记录窗口宽度变化前的菜单类型 + const getStoredDesktopMenuType = (): MenuTypeEnum | undefined => { + const storedMenuType = localStorage.getItem(StorageConfig.RESPONSIVE_MENU_TYPE_KEY) + return Object.values(MenuTypeEnum).includes(storedMenuType as MenuTypeEnum) + ? (storedMenuType as MenuTypeEnum) + : undefined + } + + const setStoredDesktopMenuType = (type: MenuTypeEnum) => { + localStorage.setItem(StorageConfig.RESPONSIVE_MENU_TYPE_KEY, type) + } + + const clearStoredDesktopMenuType = () => { + localStorage.removeItem(StorageConfig.RESPONSIVE_MENU_TYPE_KEY) + } + + const storedDesktopMenuType = getStoredDesktopMenuType() + const beforeMenuType = ref(storedDesktopMenuType) + const hasChangedMenu = ref(Boolean(storedDesktopMenuType)) + + // 计算属性 + const systemThemeColor = computed(() => settingStore.systemThemeColor as string) + + // 主题相关处理 + const useThemeHandlers = () => { + // 初始化系统颜色 + const initSystemColor = () => { + if (!AppConfig.systemMainColor.includes(systemThemeColor.value)) { + settingStore.setElementTheme(AppConfig.systemMainColor[0]) + settingStore.reload() + } + } + + // 初始化系统主题 + const initSystemTheme = () => { + if (systemThemeMode.value === SystemThemeEnum.AUTO) { + setSystemAutoTheme() + } else { + setSystemTheme(systemThemeType.value) + } + } + + // 监听系统主题变化 + const listenerSystemTheme = () => { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') + mediaQuery.addEventListener('change', initSystemTheme) + return () => { + mediaQuery.removeEventListener('change', initSystemTheme) + } + } + + return { + initSystemColor, + initSystemTheme, + listenerSystemTheme + } + } + + // 响应式布局处理 + const useResponsiveLayout = () => { + // 使用 watch 监听断点变化,性能更优 + const stopWatch = watch( + isMobile, + (mobile: boolean) => { + if (mobile) { + // 切换到移动端布局 + if (!hasChangedMenu.value) { + beforeMenuType.value = menuType.value + if (menuType.value !== MenuTypeEnum.LEFT) { + setStoredDesktopMenuType(menuType.value) + useSettingsState().switchMenuLayouts(MenuTypeEnum.LEFT) + hasChangedMenu.value = true + } + } + + settingStore.setMenuOpen(false) + } else { + // 恢复桌面端布局 + if (hasChangedMenu.value && beforeMenuType.value) { + if (menuType.value === MenuTypeEnum.LEFT) { + useSettingsState().switchMenuLayouts(beforeMenuType.value) + } + + clearStoredDesktopMenuType() + hasChangedMenu.value = false + } + + settingStore.setMenuOpen(true) + } + }, + { immediate: true } + ) + + return { stopWatch } + } + + // 抽屉控制 + const useDrawerControl = () => { + // 用于存储 setTimeout 的 ID,以便在需要时清除 + let themeChangeTimer: ReturnType | null = null + + // 打开抽屉 + const handleOpen = () => { + // 清除可能存在的旧定时器 + if (themeChangeTimer) { + clearTimeout(themeChangeTimer) + } + // 延迟添加 theme-change class,避免抽屉打开动画受影响 + themeChangeTimer = setTimeout(() => { + domOperations.setBodyClass('theme-change', true) + themeChangeTimer = null + }, 500) + } + + // 关闭抽屉 + const handleClose = () => { + // 清除未执行的定时器,防止关闭后才添加 class + if (themeChangeTimer) { + clearTimeout(themeChangeTimer) + themeChangeTimer = null + } + // 立即移除 theme-change class + domOperations.setBodyClass('theme-change', false) + } + + // 打开设置 + const openSetting = () => { + showDrawer.value = true + } + + // 关闭设置 + const closeDrawer = () => { + showDrawer.value = false + } + + return { + handleOpen, + handleClose, + openSetting, + closeDrawer + } + } + + // Props 变化监听 + const usePropsWatcher = (props: { open?: boolean }) => { + watch( + () => props.open, + (val: boolean | undefined) => { + if (val !== undefined) { + showDrawer.value = val + } + } + ) + } + + // 初始化设置 + const useSettingsInitializer = () => { + const themeHandlers = useThemeHandlers() + const { openSetting } = useDrawerControl() + const { stopWatch } = useResponsiveLayout() + let themeCleanup: (() => void) | null = null + + const initializeSettings = () => { + mittBus.on('openSetting', openSetting) + themeHandlers.initSystemColor() + themeCleanup = themeHandlers.listenerSystemTheme() + initColorWeak() + + // 设置盒子模式 + const boxMode = settingStore.boxBorderMode ? 'border-mode' : 'shadow-mode' + domOperations.setRootAttribute('data-box-mode', boxMode) + + themeHandlers.initSystemTheme() + openFestival() + } + + const cleanupSettings = () => { + stopWatch() + themeCleanup?.() + cleanup() + } + + return { + initializeSettings, + cleanupSettings + } + } + + return { + // 状态 + showDrawer, + + // 方法组合 + useThemeHandlers, + useResponsiveLayout, + useDrawerControl, + usePropsWatcher, + useSettingsInitializer + } +} diff --git a/web/apps/portal/src/components/core/layouts/art-settings-panel/composables/useSettingsState.ts b/web/apps/portal/src/components/core/layouts/art-settings-panel/composables/useSettingsState.ts new file mode 100644 index 0000000..65352d2 --- /dev/null +++ b/web/apps/portal/src/components/core/layouts/art-settings-panel/composables/useSettingsState.ts @@ -0,0 +1,37 @@ +import { useSettingStore } from '@/store/modules/setting' +import { MenuThemeEnum, MenuTypeEnum } from '@/enums/appEnum' + +/** + * 设置状态管理 + */ +export function useSettingsState() { + const settingStore = useSettingStore() + + // 色弱模式初始化 + const initColorWeak = () => { + if (settingStore.colorWeak) { + const el = document.getElementsByTagName('html')[0] + setTimeout(() => { + el.classList.add('color-weak') + }, 100) + } + } + + // 菜单布局切换 + const switchMenuLayouts = (type: MenuTypeEnum) => { + if (type === MenuTypeEnum.LEFT || type === MenuTypeEnum.TOP_LEFT) { + settingStore.setMenuOpen(true) + } + settingStore.switchMenuLayouts(type) + if (type === MenuTypeEnum.DUAL_MENU) { + settingStore.switchMenuStyles(MenuThemeEnum.DESIGN) + settingStore.setMenuOpen(true) + } + } + + return { + // 方法 + initColorWeak, + switchMenuLayouts + } +} diff --git a/web/apps/portal/src/components/core/layouts/art-settings-panel/index.vue b/web/apps/portal/src/components/core/layouts/art-settings-panel/index.vue new file mode 100755 index 0000000..0cbf344 --- /dev/null +++ b/web/apps/portal/src/components/core/layouts/art-settings-panel/index.vue @@ -0,0 +1,72 @@ + + + + + + diff --git a/web/apps/portal/src/components/core/layouts/art-settings-panel/style.scss b/web/apps/portal/src/components/core/layouts/art-settings-panel/style.scss new file mode 100755 index 0000000..e863074 --- /dev/null +++ b/web/apps/portal/src/components/core/layouts/art-settings-panel/style.scss @@ -0,0 +1,92 @@ +@use '@styles/core/mixin.scss' as *; + +// 设置抽屉模态框样式 +.setting-modal { + background: transparent !important; + + .el-drawer { + // 背景滤镜效果 + background: rgba($color: #fff, $alpha: 50%) !important; + box-shadow: 0 0 30px rgb(0 0 0 / 10%) !important; + + @include backdropBlur(); + + .setting-box-wrap { + display: flex; + flex-wrap: wrap; + align-items: center; + width: calc(100% + 15px); + margin-bottom: 10px; + + .setting-item { + box-sizing: border-box; + width: calc(33.333% - 15px); + margin-right: 15px; + text-align: center; + + .box { + position: relative; + box-sizing: border-box; + display: flex; + height: 52px; + overflow: hidden; + cursor: pointer; + border: 2px solid var(--default-border); + border-radius: 8px; + box-shadow: 0 0 8px 0 rgb(0 0 0 / 10%); + transition: box-shadow 0.1s; + + &.mt-16 { + margin-top: 16px; + } + + &.is-active { + border: 2px solid var(--theme-color); + } + + img { + width: 100%; + height: 100%; + } + } + + .name { + margin-top: 6px; + font-size: 14px; + text-align: center; + } + } + } + } + + // 去除滚动条 + .el-drawer__body::-webkit-scrollbar { + width: 0 !important; + } +} + +.dark { + .setting-modal { + .el-drawer { + background: rgba($color: #000, $alpha: 50%) !important; + + .setting-item { + .box { + border: 2px solid transparent; + } + } + } + } +} + +// 去除火狐浏览器滚动条 +:deep(.el-drawer__body) { + scrollbar-width: none; +} + +// 移动端隐藏 +@media screen and (width <= 800px) { + .mobile-hide { + display: none !important; + } +} diff --git a/web/apps/portal/src/components/core/layouts/art-settings-panel/widget/BasicSettings.vue b/web/apps/portal/src/components/core/layouts/art-settings-panel/widget/BasicSettings.vue new file mode 100644 index 0000000..b6dc9d3 --- /dev/null +++ b/web/apps/portal/src/components/core/layouts/art-settings-panel/widget/BasicSettings.vue @@ -0,0 +1,77 @@ + + + diff --git a/web/apps/portal/src/components/core/layouts/art-settings-panel/widget/BoxStyleSettings.vue b/web/apps/portal/src/components/core/layouts/art-settings-panel/widget/BoxStyleSettings.vue new file mode 100644 index 0000000..86c7a9e --- /dev/null +++ b/web/apps/portal/src/components/core/layouts/art-settings-panel/widget/BoxStyleSettings.vue @@ -0,0 +1,38 @@ + + + diff --git a/web/apps/portal/src/components/core/layouts/art-settings-panel/widget/ColorSettings.vue b/web/apps/portal/src/components/core/layouts/art-settings-panel/widget/ColorSettings.vue new file mode 100644 index 0000000..05a4b41 --- /dev/null +++ b/web/apps/portal/src/components/core/layouts/art-settings-panel/widget/ColorSettings.vue @@ -0,0 +1,35 @@ + + + diff --git a/web/apps/portal/src/components/core/layouts/art-settings-panel/widget/ContainerSettings.vue b/web/apps/portal/src/components/core/layouts/art-settings-panel/widget/ContainerSettings.vue new file mode 100644 index 0000000..1f5be72 --- /dev/null +++ b/web/apps/portal/src/components/core/layouts/art-settings-panel/widget/ContainerSettings.vue @@ -0,0 +1,33 @@ + + + diff --git a/web/apps/portal/src/components/core/layouts/art-settings-panel/widget/MenuLayoutSettings.vue b/web/apps/portal/src/components/core/layouts/art-settings-panel/widget/MenuLayoutSettings.vue new file mode 100644 index 0000000..dbcae46 --- /dev/null +++ b/web/apps/portal/src/components/core/layouts/art-settings-panel/widget/MenuLayoutSettings.vue @@ -0,0 +1,31 @@ + + + diff --git a/web/apps/portal/src/components/core/layouts/art-settings-panel/widget/MenuStyleSettings.vue b/web/apps/portal/src/components/core/layouts/art-settings-panel/widget/MenuStyleSettings.vue new file mode 100644 index 0000000..61237eb --- /dev/null +++ b/web/apps/portal/src/components/core/layouts/art-settings-panel/widget/MenuStyleSettings.vue @@ -0,0 +1,44 @@ + + + diff --git a/web/apps/portal/src/components/core/layouts/art-settings-panel/widget/SectionTitle.vue b/web/apps/portal/src/components/core/layouts/art-settings-panel/widget/SectionTitle.vue new file mode 100644 index 0000000..31ef00c --- /dev/null +++ b/web/apps/portal/src/components/core/layouts/art-settings-panel/widget/SectionTitle.vue @@ -0,0 +1,17 @@ + + + diff --git a/web/apps/portal/src/components/core/layouts/art-settings-panel/widget/SettingActions.vue b/web/apps/portal/src/components/core/layouts/art-settings-panel/widget/SettingActions.vue new file mode 100644 index 0000000..7b47d1a --- /dev/null +++ b/web/apps/portal/src/components/core/layouts/art-settings-panel/widget/SettingActions.vue @@ -0,0 +1,235 @@ + + + + diff --git a/web/apps/portal/src/components/core/layouts/art-settings-panel/widget/SettingDrawer.vue b/web/apps/portal/src/components/core/layouts/art-settings-panel/widget/SettingDrawer.vue new file mode 100644 index 0000000..85372be --- /dev/null +++ b/web/apps/portal/src/components/core/layouts/art-settings-panel/widget/SettingDrawer.vue @@ -0,0 +1,51 @@ + + + diff --git a/web/apps/portal/src/components/core/layouts/art-settings-panel/widget/SettingHeader.vue b/web/apps/portal/src/components/core/layouts/art-settings-panel/widget/SettingHeader.vue new file mode 100644 index 0000000..e3ead9e --- /dev/null +++ b/web/apps/portal/src/components/core/layouts/art-settings-panel/widget/SettingHeader.vue @@ -0,0 +1,18 @@ + + + diff --git a/web/apps/portal/src/components/core/layouts/art-settings-panel/widget/SettingItem.vue b/web/apps/portal/src/components/core/layouts/art-settings-panel/widget/SettingItem.vue new file mode 100644 index 0000000..5721027 --- /dev/null +++ b/web/apps/portal/src/components/core/layouts/art-settings-panel/widget/SettingItem.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/web/apps/portal/src/components/core/layouts/art-settings-panel/widget/ThemeSettings.vue b/web/apps/portal/src/components/core/layouts/art-settings-panel/widget/ThemeSettings.vue new file mode 100644 index 0000000..4b46fcd --- /dev/null +++ b/web/apps/portal/src/components/core/layouts/art-settings-panel/widget/ThemeSettings.vue @@ -0,0 +1,28 @@ + + + diff --git a/web/apps/portal/src/components/core/layouts/art-work-tab/index.vue b/web/apps/portal/src/components/core/layouts/art-work-tab/index.vue new file mode 100755 index 0000000..152ff63 --- /dev/null +++ b/web/apps/portal/src/components/core/layouts/art-work-tab/index.vue @@ -0,0 +1,584 @@ + + + + + + diff --git a/web/apps/portal/src/components/core/media/art-cutter-img/index.vue b/web/apps/portal/src/components/core/media/art-cutter-img/index.vue new file mode 100644 index 0000000..191ceed --- /dev/null +++ b/web/apps/portal/src/components/core/media/art-cutter-img/index.vue @@ -0,0 +1,350 @@ + + + + + + diff --git a/web/apps/portal/src/components/core/media/art-video-player/index.vue b/web/apps/portal/src/components/core/media/art-video-player/index.vue new file mode 100644 index 0000000..4f681ea --- /dev/null +++ b/web/apps/portal/src/components/core/media/art-video-player/index.vue @@ -0,0 +1,111 @@ + + + + diff --git a/web/apps/portal/src/components/core/others/art-menu-right/index.vue b/web/apps/portal/src/components/core/others/art-menu-right/index.vue new file mode 100644 index 0000000..1cc92ab --- /dev/null +++ b/web/apps/portal/src/components/core/others/art-menu-right/index.vue @@ -0,0 +1,415 @@ + + + + + + diff --git a/web/apps/portal/src/components/core/others/art-watermark/index.vue b/web/apps/portal/src/components/core/others/art-watermark/index.vue new file mode 100644 index 0000000..1d7f06b --- /dev/null +++ b/web/apps/portal/src/components/core/others/art-watermark/index.vue @@ -0,0 +1,64 @@ + + + + diff --git a/web/apps/portal/src/components/core/tables/art-table-header/index.vue b/web/apps/portal/src/components/core/tables/art-table-header/index.vue new file mode 100644 index 0000000..788c2b7 --- /dev/null +++ b/web/apps/portal/src/components/core/tables/art-table-header/index.vue @@ -0,0 +1,339 @@ + + + + + + diff --git a/web/apps/portal/src/components/core/tables/art-table/index.vue b/web/apps/portal/src/components/core/tables/art-table/index.vue new file mode 100644 index 0000000..be99e70 --- /dev/null +++ b/web/apps/portal/src/components/core/tables/art-table/index.vue @@ -0,0 +1,368 @@ + + + + + + + + + diff --git a/web/apps/portal/src/components/core/tables/art-table/style.scss b/web/apps/portal/src/components/core/tables/art-table/style.scss new file mode 100644 index 0000000..67459e8 --- /dev/null +++ b/web/apps/portal/src/components/core/tables/art-table/style.scss @@ -0,0 +1,99 @@ +.art-table { + position: relative; + height: 100%; + + .el-table { + height: 100%; + margin-top: 10px; + } + + :deep(.el-loading-mask) { + z-index: 100; + background-color: var(--default-box-color) !important; + } + + // Loading 过渡动画 - 消失时淡出 + .loading-fade-leave-active { + transition: opacity 0.3s ease-out; + } + + .loading-fade-leave-to { + opacity: 0; + } + + // 空状态垂直居中 + &.is-empty { + :deep(.el-scrollbar__wrap) { + display: flex; + } + } + + .pagination { + display: flex; + margin-top: 13px; + + :deep(.el-select) { + width: 102px !important; + } + + // 分页对齐方式 + &.left { + justify-content: flex-start; + } + + &.center { + justify-content: center; + } + + &.right { + justify-content: flex-end; + } + + // 自定义分页组件样式 + &.custom-pagination { + :deep(.el-pagination) { + .btn-prev, + .btn-next { + background-color: transparent; + border: 1px solid var(--art-gray-300); + transition: border-color 0.15s; + + &:hover:not(.is-disabled) { + color: var(--theme-color); + border-color: var(--theme-color); + } + } + + li { + box-sizing: border-box; + font-weight: 400 !important; + background-color: transparent; + border: 1px solid var(--art-gray-300); + transition: border-color 0.15s; + + &.is-active { + font-weight: 400; + color: #fff; + background-color: var(--theme-color); + border: 1px solid var(--theme-color); + } + + &:hover:not(.is-disabled) { + border-color: var(--theme-color); + } + } + } + } + } +} + +// 移动端分页 +@media (width <= 640px) { + :deep(.el-pagination) { + display: flex; + flex-wrap: wrap; + gap: 15px 0; + align-items: center; + justify-content: center; + } +} diff --git a/web/apps/portal/src/components/core/text-effect/art-count-to/index.vue b/web/apps/portal/src/components/core/text-effect/art-count-to/index.vue new file mode 100644 index 0000000..7fb104b --- /dev/null +++ b/web/apps/portal/src/components/core/text-effect/art-count-to/index.vue @@ -0,0 +1,310 @@ + + + + diff --git a/web/apps/portal/src/components/core/text-effect/art-festival-text-scroll/index.vue b/web/apps/portal/src/components/core/text-effect/art-festival-text-scroll/index.vue new file mode 100644 index 0000000..770b457 --- /dev/null +++ b/web/apps/portal/src/components/core/text-effect/art-festival-text-scroll/index.vue @@ -0,0 +1,32 @@ + + + + diff --git a/web/apps/portal/src/components/core/text-effect/art-text-scroll/index.vue b/web/apps/portal/src/components/core/text-effect/art-text-scroll/index.vue new file mode 100644 index 0000000..90be30f --- /dev/null +++ b/web/apps/portal/src/components/core/text-effect/art-text-scroll/index.vue @@ -0,0 +1,285 @@ + + + + diff --git a/web/apps/portal/src/components/core/theme/theme-svg/index.vue b/web/apps/portal/src/components/core/theme/theme-svg/index.vue new file mode 100644 index 0000000..0b565a9 --- /dev/null +++ b/web/apps/portal/src/components/core/theme/theme-svg/index.vue @@ -0,0 +1,100 @@ + + + + + + + diff --git a/web/apps/portal/src/components/core/views/exception/ArtException.vue b/web/apps/portal/src/components/core/views/exception/ArtException.vue new file mode 100644 index 0000000..4be59ae --- /dev/null +++ b/web/apps/portal/src/components/core/views/exception/ArtException.vue @@ -0,0 +1,55 @@ + + + diff --git a/web/apps/portal/src/components/core/views/login/AuthTopBar.vue b/web/apps/portal/src/components/core/views/login/AuthTopBar.vue new file mode 100644 index 0000000..e47bba0 --- /dev/null +++ b/web/apps/portal/src/components/core/views/login/AuthTopBar.vue @@ -0,0 +1,171 @@ + + + + + + diff --git a/web/apps/portal/src/components/core/views/login/LoginLeftView.vue b/web/apps/portal/src/components/core/views/login/LoginLeftView.vue new file mode 100644 index 0000000..cbd53ef --- /dev/null +++ b/web/apps/portal/src/components/core/views/login/LoginLeftView.vue @@ -0,0 +1,453 @@ + + + + + + diff --git a/web/apps/portal/src/components/core/views/result/ArtResultPage.vue b/web/apps/portal/src/components/core/views/result/ArtResultPage.vue new file mode 100644 index 0000000..b2eca48 --- /dev/null +++ b/web/apps/portal/src/components/core/views/result/ArtResultPage.vue @@ -0,0 +1,43 @@ + + + diff --git a/web/apps/portal/src/components/core/widget/art-icon-button/index.vue b/web/apps/portal/src/components/core/widget/art-icon-button/index.vue new file mode 100644 index 0000000..760888b --- /dev/null +++ b/web/apps/portal/src/components/core/widget/art-icon-button/index.vue @@ -0,0 +1,23 @@ + + + + diff --git a/web/apps/portal/src/config/assets/images.ts b/web/apps/portal/src/config/assets/images.ts new file mode 100644 index 0000000..f3e89dd --- /dev/null +++ b/web/apps/portal/src/config/assets/images.ts @@ -0,0 +1,61 @@ +/** + * 配置图片资源 + * + * 统一管理设置中心使用的预览图片资源。 + * 包含主题样式、菜单布局、菜单风格的预览图。 + * + * ## 图片分类 + * + * - themeStyles: 系统主题预览图(亮色/暗色/自动) + * - menuLayouts: 菜单布局预览图(左侧/顶部/混合/双栏) + * - menuStyles: 菜单风格预览图(设计/暗色/亮色) + * + * @module config/assets/images + * @author Art Design Pro Team + */ + +import lightTheme from '@imgs/settings/theme_styles/light.png' +import darkTheme from '@imgs/settings/theme_styles/dark.png' +import systemTheme from '@imgs/settings/theme_styles/system.png' +import verticalLayout from '@imgs/settings/menu_layouts/vertical.png' +import horizontalLayout from '@imgs/settings/menu_layouts/horizontal.png' +import mixedLayout from '@imgs/settings/menu_layouts/mixed.png' +import dualColumnLayout from '@imgs/settings/menu_layouts/dual_column.png' +import designStyle from '@imgs/settings/menu_styles/design.png' +import darkStyle from '@imgs/settings/menu_styles/dark.png' +import lightStyle from '@imgs/settings/menu_styles/light.png' + +/** + * 配置中心图片资源对象 + */ +export const configImages = { + /** 系统主题预览图 */ + themeStyles: { + /** 亮色主题 */ + light: lightTheme, + /** 暗色主题 */ + dark: darkTheme, + /** 自动主题(跟随系统) */ + system: systemTheme + }, + /** 菜单布局预览图 */ + menuLayouts: { + /** 左侧菜单 */ + vertical: verticalLayout, + /** 顶部菜单 */ + horizontal: horizontalLayout, + /** 混合菜单 */ + mixed: mixedLayout, + /** 双栏菜单 */ + dualColumn: dualColumnLayout + }, + /** 菜单风格预览图 */ + menuStyles: { + /** 设计风格 */ + design: designStyle, + /** 暗色风格 */ + dark: darkStyle, + /** 亮色风格 */ + light: lightStyle + } +} diff --git a/web/apps/portal/src/config/fastEnter.ts b/web/apps/portal/src/config/fastEnter.ts new file mode 100644 index 0000000..ccade16 --- /dev/null +++ b/web/apps/portal/src/config/fastEnter.ts @@ -0,0 +1,79 @@ +/** + * 快速入口配置 + * 包含:应用列表、快速链接等配置 + */ +import { WEB_LINKS } from '@/utils/constants' +import type { FastEnterConfig } from '@/types/config' + +const fastEnterConfig: FastEnterConfig = { + // 显示条件(屏幕宽度) + minWidth: 1200, + // 应用列表 + applications: [ + { + name: '工作台', + description: '系统概览与数据统计', + icon: 'ri:pie-chart-line', + iconColor: '#377dff', + enabled: true, + order: 1, + routeName: 'Console' + }, + { + name: '官方文档', + description: '使用指南与开发文档', + icon: 'ri:bill-line', + iconColor: '#ffb100', + enabled: true, + order: 2, + link: WEB_LINKS.DOCS + }, + { + name: '技术支持', + description: '技术支持与问题反馈', + icon: 'ri:user-location-line', + iconColor: '#ff6b6b', + enabled: true, + order: 3, + link: WEB_LINKS.COMMUNITY + }, + { + name: '哔哩哔哩', + description: '技术分享与交流', + icon: 'ri:bilibili-line', + iconColor: '#FB7299', + enabled: true, + order: 4, + link: WEB_LINKS.BILIBILI + } + ], + // 快速链接 + quickLinks: [ + { + name: '登录', + enabled: true, + order: 1, + routeName: 'Login' + }, + { + name: '注册', + enabled: true, + order: 2, + routeName: 'Register' + }, + { + name: '忘记密码', + enabled: true, + order: 3, + routeName: 'ForgetPassword' + }, + { + name: '个人中心', + enabled: true, + order: 4, + routeName: 'UserCenter' + } + ] +} + +export default Object.freeze(fastEnterConfig) diff --git a/web/apps/portal/src/config/index.ts b/web/apps/portal/src/config/index.ts new file mode 100644 index 0000000..a39f156 --- /dev/null +++ b/web/apps/portal/src/config/index.ts @@ -0,0 +1,136 @@ +/** + * 系统全局配置 + * + * 这是系统的核心配置文件,集中管理所有全局配置项。 + * 包含系统信息、主题样式、菜单布局、颜色方案等所有可配置项。 + * + * ## 主要功能 + * + * - 系统信息 - 系统名称等基础信息 + * - 主题配置 - 亮色/暗色/自动主题的样式配置 + * - 菜单配置 - 菜单布局、主题、宽度等配置 + * - 颜色方案 - 系统主色和预设颜色列表 + * - 快速入口 - 快速入口应用和链接配置 + * - 顶部栏配置 - 顶部栏功能模块配置 + * + * ## 配置项说明 + * + * - systemInfo: 系统基础信息(名称等) + * - systemThemeStyles: 系统主题样式映射 + * - settingThemeList: 可选的系统主题列表 + * - menuLayoutList: 可选的菜单布局列表 + * - themeList: 菜单主题样式列表 + * - darkMenuStyles: 暗黑模式下的菜单样式 + * - systemMainColor: 预设的系统主色列表 + * - fastEnter: 快速入口配置 + * - headerBar: 顶部栏功能配置 + * + * @module config + * @author Art Design Pro Team + */ + +import { MenuThemeEnum, MenuTypeEnum, SystemThemeEnum } from '@/enums/appEnum' +import { SystemConfig } from '@/types/config' +import { configImages } from './assets/images' +import fastEnterConfig from './modules/fastEnter' +import { headerBarConfig } from './modules/headerBar' + +const appConfig: SystemConfig = { + // 系统信息 + systemInfo: { + name: 'LLMGuardX语枢用户门户', // 系统名称 + shortName: 'LLMGuardX语枢' // 侧边栏/顶栏紧凑显示 + }, + // 系统主题 + systemThemeStyles: { + [SystemThemeEnum.LIGHT]: { className: '' }, + [SystemThemeEnum.DARK]: { className: SystemThemeEnum.DARK } + }, + // 系统主题列表 + settingThemeList: [ + { + name: 'Light', + theme: SystemThemeEnum.LIGHT, + color: ['#fff', '#fff'], + leftLineColor: '#EDEEF0', + rightLineColor: '#EDEEF0', + img: configImages.themeStyles.light + }, + { + name: 'Dark', + theme: SystemThemeEnum.DARK, + color: ['#22252A'], + leftLineColor: '#3F4257', + rightLineColor: '#3F4257', + img: configImages.themeStyles.dark + }, + { + name: 'System', + theme: SystemThemeEnum.AUTO, + color: ['#fff', '#22252A'], + leftLineColor: '#EDEEF0', + rightLineColor: '#3F4257', + img: configImages.themeStyles.system + } + ], + // 菜单布局列表 + menuLayoutList: [ + { name: 'Left', value: MenuTypeEnum.LEFT, img: configImages.menuLayouts.vertical }, + { name: 'Top', value: MenuTypeEnum.TOP, img: configImages.menuLayouts.horizontal }, + { name: 'Mixed', value: MenuTypeEnum.TOP_LEFT, img: configImages.menuLayouts.mixed }, + { name: 'Dual Column', value: MenuTypeEnum.DUAL_MENU, img: configImages.menuLayouts.dualColumn } + ], + // 菜单主题列表 + themeList: [ + { + theme: MenuThemeEnum.DESIGN, + background: '#FFFFFF', + systemNameColor: 'var(--art-gray-800)', + iconColor: '#6B6B6B', + textColor: '#29343D', + img: configImages.menuStyles.design + }, + { + theme: MenuThemeEnum.DARK, + background: '#191A23', + systemNameColor: '#D9DADB', + iconColor: '#BABBBD', + textColor: '#BABBBD', + img: configImages.menuStyles.dark + }, + { + theme: MenuThemeEnum.LIGHT, + background: '#ffffff', + systemNameColor: 'var(--art-gray-800)', + iconColor: '#6B6B6B', + textColor: '#29343D', + img: configImages.menuStyles.light + } + ], + // 暗黑模式菜单样式 + darkMenuStyles: [ + { + theme: MenuThemeEnum.DARK, + background: 'var(--default-box-color)', + systemNameColor: '#DDDDDD', + iconColor: '#BABBBD', + textColor: 'rgba(#FFFFFF, 0.7)' + } + ], + // 系统主色 + systemMainColor: [ + '#00E5FF', + '#2F80FF', + '#1D84FF', + '#60C041', + '#38C0FC', + '#F9901F', + '#FF80C8' + ] as const, + // 快速入口配置 + fastEnter: fastEnterConfig, + // 顶部栏功能配置 + headerBar: headerBarConfig +} + +export default Object.freeze(appConfig) diff --git a/web/apps/portal/src/config/modules/component.ts b/web/apps/portal/src/config/modules/component.ts new file mode 100644 index 0000000..bc709e0 --- /dev/null +++ b/web/apps/portal/src/config/modules/component.ts @@ -0,0 +1,105 @@ +/** + * 全局组件配置 + * + * 统一管理系统级全局组件的注册。 + * 这些组件会在应用启动时全局注册,可在任何地方使用。 + * + * ## 主要功能 + * + * - 组件配置 - 集中管理全局组件的配置信息 + * - 异步加载 - 使用 defineAsyncComponent 实现按需加载 + * - 开关控制 - 支持通过 enabled 字段启用/禁用组件 + * - 配置查询 - 提供工具函数快速查询组件配置 + * + * @module config/component + * @author Art Design Pro Team + */ + +import { defineAsyncComponent } from 'vue' + +/** + * 全局组件配置列表 + */ +export const globalComponentsConfig: GlobalComponentConfig[] = [ + { + name: '设置面板', + key: 'settings-panel', + component: defineAsyncComponent( + () => import('@/components/core/layouts/art-settings-panel/index.vue') + ), + enabled: true + }, + { + name: '全局搜索', + key: 'global-search', + component: defineAsyncComponent( + () => import('@/components/core/layouts/art-global-search/index.vue') + ), + enabled: true + }, + { + name: '锁屏', + key: 'screen-lock', + component: defineAsyncComponent( + () => import('@/components/core/layouts/art-screen-lock/index.vue') + ), + enabled: true + }, + { + name: '聊天窗口', + key: 'chat-window', + component: defineAsyncComponent( + () => import('@/components/core/layouts/art-chat-window/index.vue') + ), + enabled: true + }, + { + name: '礼花效果', + key: 'fireworks-effect', + component: defineAsyncComponent( + () => import('@/components/core/layouts/art-fireworks-effect/index.vue') + ), + enabled: true + }, + { + name: '水印效果', + key: 'watermark', + component: defineAsyncComponent( + () => import('@/components/core/others/art-watermark/index.vue') + ), + enabled: true + } +] + +/** + * 全局组件配置接口 + */ +export interface GlobalComponentConfig { + /** 组件名称 */ + name: string + /** 组件标识 */ + key: string + /** 组件 */ + component: any + /** 是否启用 */ + enabled?: boolean + /** 组件描述 */ + description?: string +} + +/** + * 获取启用的全局组件 + * @returns 已启用的组件配置列表 + */ +export const getEnabledGlobalComponents = () => { + return globalComponentsConfig.filter((config) => config.enabled !== false) +} + +/** + * 根据 key 获取组件配置 + * @param key 组件标识 + * @returns 组件配置对象 + */ +export const getGlobalComponentByKey = (key: string) => { + return globalComponentsConfig.find((config) => config.key === key) +} diff --git a/web/apps/portal/src/config/modules/fastEnter.ts b/web/apps/portal/src/config/modules/fastEnter.ts new file mode 100644 index 0000000..6b9740c --- /dev/null +++ b/web/apps/portal/src/config/modules/fastEnter.ts @@ -0,0 +1,127 @@ +/** + * 快速入口配置 + * 包含:应用列表、快速链接等配置 + */ +import { WEB_LINKS } from '@/utils/constants' +import type { FastEnterConfig } from '@/types/config' + +const fastEnterConfig: FastEnterConfig = { + // 显示条件(屏幕宽度) + minWidth: 1200, + // 应用列表 + applications: [ + { + name: '工作台', + description: '系统概览与数据统计', + icon: 'ri:pie-chart-line', + iconColor: '#377dff', + enabled: true, + order: 1, + routeName: 'Console' + }, + { + name: '分析页', + description: '数据分析与可视化', + icon: 'ri:game-line', + iconColor: '#ff3b30', + enabled: true, + order: 2, + routeName: 'Analysis' + }, + { + name: '礼花效果', + description: '动画特效展示', + icon: 'ri:loader-line', + iconColor: '#7A7FFF', + enabled: true, + order: 3, + routeName: 'Fireworks' + }, + { + name: '聊天', + description: '即时通讯功能', + icon: 'ri:user-line', + iconColor: '#13DEB9', + enabled: true, + order: 4, + routeName: 'Chat' + }, + { + name: '官方文档', + description: '使用指南与开发文档', + icon: 'ri:bill-line', + iconColor: '#ffb100', + enabled: true, + order: 5, + link: WEB_LINKS.DOCS + }, + { + name: '技术支持', + description: '技术支持与问题反馈', + icon: 'ri:user-location-line', + iconColor: '#ff6b6b', + enabled: true, + order: 6, + link: WEB_LINKS.COMMUNITY + }, + { + name: '更新日志', + description: '版本更新与变更记录', + icon: 'ri:gamepad-line', + iconColor: '#38C0FC', + enabled: true, + order: 7, + routeName: 'ChangeLog' + }, + { + name: '哔哩哔哩', + description: '技术分享与交流', + icon: 'ri:bilibili-line', + iconColor: '#FB7299', + enabled: true, + order: 8, + link: WEB_LINKS.BILIBILI + } + ], + // 快速链接 + quickLinks: [ + { + name: '登录', + enabled: true, + order: 1, + routeName: 'Login' + }, + { + name: '注册', + enabled: true, + order: 2, + routeName: 'Register' + }, + { + name: '忘记密码', + enabled: true, + order: 3, + routeName: 'ForgetPassword' + }, + { + name: '定价', + enabled: true, + order: 4, + routeName: 'Pricing' + }, + { + name: '个人中心', + enabled: true, + order: 5, + routeName: 'UserCenter' + }, + { + name: '留言管理', + enabled: true, + order: 6, + routeName: 'ArticleComment' + } + ] +} + +export default Object.freeze(fastEnterConfig) diff --git a/web/apps/portal/src/config/modules/festival.ts b/web/apps/portal/src/config/modules/festival.ts new file mode 100644 index 0000000..39cd790 --- /dev/null +++ b/web/apps/portal/src/config/modules/festival.ts @@ -0,0 +1,51 @@ +/** + * 节日庆祝配置 + * + * 配置系统的节日烟花效果和祝福文本。 + * 支持单日节日和跨日期节日,可自定义烟花播放次数。 + * + * ## 配置说明 + * + * - name: 节日名称 + * - date: 节日开始日期(格式:YYYY-MM-DD) + * - endDate: 节日结束日期(可选,用于跨日期节日) + * - image: 烟花图片(需要预先导入) + * - scrollText: 滚动显示的祝福文本 + * - count: 烟花播放次数(可选,默认为 3 次) + * + * ## 注意事项 + * + * - 图片需要预先导入并在配置中引用 + * - 跨日期节日会在整个日期范围内生效 + * - 每个用户每天只会播放一次烟花效果 + * + * @module config/modules/festival + * @author Art Design Pro Team + */ + +import { FestivalConfig } from '@/types/config' + +// 导入烟花图片(根据需要取消注释) +// import sd from '@imgs/ceremony/sd.png' +// import yd from '@imgs/ceremony/yd.png' + +export const festivalConfigList: FestivalConfig[] = [ + // 跨日期示例 + // { + // name: 'v3.0 Sass 升级至 TailwindCSS', + // date: '2025-11-03', + // endDate: '2025-11-09', + // image: '', + // count: 3, + // scrollText: + // '🚀 系统 v3.0 测试阶段正式开启!测试周期为 11 月 3 日 - 11 月 16 日,通过 TailwindCSS 重构样式体系、统一 Iconify 图标方案,带来更高效现代的开发体验,正式发布敬请期待~' + // } + // 单日示例:圣诞节 + // { + // name: '圣诞节', + // date: '2024-12-25', + // image: sd, + // count: 3 // 可选,不设置则使用默认值 3 次 + // scrollText: 'Merry Christmas!Art Design Pro 祝您圣诞快乐,愿节日的欢乐与祝福如雪花般纷至沓来!', + // } +] diff --git a/web/apps/portal/src/config/modules/headerBar.ts b/web/apps/portal/src/config/modules/headerBar.ts new file mode 100644 index 0000000..a420e82 --- /dev/null +++ b/web/apps/portal/src/config/modules/headerBar.ts @@ -0,0 +1,63 @@ +/** + * 顶部栏功能配置 + * + * 统一管理顶部栏各个功能模块的启用状态。 + * 通过修改此配置文件可以快速启用或禁用顶部栏的功能按钮。 + * + * @module config/headerBar + * @author Art Design Pro Team + */ + +import { HeaderBarFeatureConfig } from '@/types' + +/** + * 顶部栏功能配置对象 + */ +export const headerBarConfig: HeaderBarFeatureConfig = { + menuButton: { + enabled: true, + description: '控制左侧菜单的展开/收起按钮' + }, + refreshButton: { + enabled: true, + description: '页面刷新按钮' + }, + fastEnter: { + enabled: true, + description: '快速入口功能,提供常用应用和链接的快速访问' + }, + breadcrumb: { + enabled: true, + description: '面包屑导航,显示当前页面路径' + }, + globalSearch: { + enabled: true, + description: '全局搜索功能,支持快捷键 Ctrl+K 或 Cmd+K' + }, + fullscreen: { + enabled: true, + description: '全屏切换功能' + }, + notification: { + enabled: true, + description: '通知中心,显示系统通知和消息' + }, + chat: { + enabled: true, + description: '聊天功能,提供实时沟通' + }, + language: { + enabled: true, + description: '多语言切换功能' + }, + settings: { + enabled: true, + description: '系统设置面板' + }, + themeToggle: { + enabled: true, + description: '主题切换功能(明暗主题)' + } +} + +export default headerBarConfig diff --git a/web/apps/portal/src/config/setting.ts b/web/apps/portal/src/config/setting.ts new file mode 100644 index 0000000..20a0a0d --- /dev/null +++ b/web/apps/portal/src/config/setting.ts @@ -0,0 +1,109 @@ +/** + * 系统设置默认值配置 + * + * 统一管理系统设置的所有默认值 + * + * ## 主要功能 + * + * - 菜单相关默认配置 + * - 主题相关默认配置 + * - 界面显示默认配置 + * - 功能开关默认配置 + * - 样式相关默认配置 + * + * ## 注意事项 + * + * 1. 修改此文件的配置项时,需要同步更新以下文件: + * - src/components/core/layouts/art-settings-panel/widget/SettingActions.vue(复制配置和重置配置逻辑) + * - src/store/modules/setting.ts(Store 状态定义) + * 2. 可以通过设置面板的"复制配置"按钮快速生成配置代码 + * 3. 枚举类型的值需要与 src/enums/appEnum.ts 中的定义保持一致 + */ + +import AppConfig from '@/config' +import { SystemThemeEnum, MenuThemeEnum, MenuTypeEnum, ContainerWidthEnum } from '@/enums/appEnum' + +/** + * 系统设置默认值配置 + */ +export const SETTING_DEFAULT_CONFIG = { + /** 菜单类型 */ + menuType: MenuTypeEnum.LEFT, + /** 菜单展开宽度 */ + menuOpenWidth: 230, + /** 菜单是否展开 */ + menuOpen: true, + /** 双菜单是否显示文本 */ + dualMenuShowText: false, + /** 系统主题类型(语枢网关默认深色 HUD,.dark 依赖此项) */ + systemThemeType: SystemThemeEnum.DARK, + /** 系统主题模式 */ + systemThemeMode: SystemThemeEnum.DARK, + /** 菜单风格 */ + menuThemeType: MenuThemeEnum.DESIGN, + /** 系统主题颜色 */ + systemThemeColor: AppConfig.systemMainColor[0], + /** 是否显示菜单按钮 */ + showMenuButton: true, + /** 是否显示快速入口 */ + showFastEnter: true, + /** 是否显示刷新按钮 */ + showRefreshButton: true, + /** 是否显示面包屑 */ + showCrumbs: true, + /** 是否显示工作台标签 */ + showWorkTab: true, + /** 是否显示语言切换 */ + showLanguage: true, + /** 是否显示进度条 */ + showNprogress: false, + /** 是否显示设置引导 */ + showSettingGuide: true, + /** 是否显示节日文本 */ + showFestivalText: false, + /** 是否显示水印 */ + watermarkVisible: false, + /** 是否自动关闭 */ + autoClose: false, + /** 是否唯一展开 */ + uniqueOpened: true, + /** 是否色弱模式 */ + colorWeak: false, + /** 是否刷新 */ + refresh: false, + /** 是否加载节日烟花 */ + holidayFireworksLoaded: false, + /** 边框模式 */ + boxBorderMode: true, + /** 页面过渡效果 */ + pageTransition: 'slide-left', + /** 标签页样式 */ + tabStyle: 'tab-default', + /** 自定义圆角 */ + customRadius: '0.75', + /** 容器宽度 */ + containerWidth: ContainerWidthEnum.FULL, + /** 节日日期 */ + festivalDate: '' +} + +/** + * 获取设置默认值 + * @returns 设置默认值对象 + */ +export function getSettingDefaults() { + return { ...SETTING_DEFAULT_CONFIG } +} + +/** + * 重置为默认设置 + * @param currentSettings 当前设置对象 + */ +export function resetToDefaults(currentSettings: Record) { + const defaults = getSettingDefaults() + Object.keys(defaults).forEach((key) => { + if (key in currentSettings) { + currentSettings[key] = defaults[key as keyof typeof defaults] + } + }) +} diff --git a/web/apps/portal/src/directives/business/highlight.ts b/web/apps/portal/src/directives/business/highlight.ts new file mode 100644 index 0000000..07b6c1d --- /dev/null +++ b/web/apps/portal/src/directives/business/highlight.ts @@ -0,0 +1,250 @@ +/** + * v-highlight 代码高亮指令 + * + * 为代码块提供语法高亮、行号显示和一键复制功能。 + * 基于 highlight.js 实现,支持多种编程语言的语法高亮。 + * + * ## 主要功能 + * + * - 语法高亮 - 使用 highlight.js 自动识别并高亮代码 + * - 行号显示 - 自动为每行代码添加行号 + * - 一键复制 - 提供复制按钮,点击即可复制代码(自动过滤行号) + * - 性能优化 - 批量处理代码块,避免阻塞渲染 + * - 动态监听 - 使用 MutationObserver 监听新增代码块 + * - 防重复处理 - 自动标记已处理的代码块,避免重复处理 + * + * ## 使用示例 + * + * ```vue + * + * ``` + * + * ## 性能优化 + * + * - 批量处理:每次处理 10 个代码块,避免长时间阻塞 + * - 延迟处理:使用 requestAnimationFrame 分批处理 + * - 重试机制:自动重试处理失败的代码块 + * - 智能监听:只在有新代码块时才触发处理 + * + * @module directives/highlight + * @author Art Design Pro Team + */ + +import { App, Directive } from 'vue' +import hljs from 'highlight.js' + +export type HighlightDirective = Directive + +// 高亮代码 +function highlightCode(block: HTMLElement) { + hljs.highlightElement(block) +} + +// 插入行号 +function insertLineNumbers(block: HTMLElement) { + const lines = block.innerHTML.split('\n') + const numberedLines = lines + .map((line, index) => { + return `${index + 1} ${line}` + }) + .join('\n') + block.innerHTML = numberedLines +} + +// 添加复制按钮:调整 DOM 结构,将代码部分包裹在 .code-wrapper 内 +function addCopyButton(block: HTMLElement) { + const copyButton = document.createElement('i') + copyButton.className = 'copy-button' + copyButton.innerHTML = + '' + copyButton.onclick = () => { + // 过滤掉行号,只复制代码内容 + const codeContent = block.innerText.replace(/^\d+\s+/gm, '') + navigator.clipboard.writeText(codeContent).then(() => { + ElMessage.success('复制成功') + }) + } + + const preElement = block.parentElement + if (preElement) { + let codeWrapper: HTMLElement + // 如果代码块还没有被包裹,则创建包裹容器 + if (!block.parentElement.classList.contains('code-wrapper')) { + codeWrapper = document.createElement('div') + codeWrapper.className = 'code-wrapper' + preElement.replaceChild(codeWrapper, block) + codeWrapper.appendChild(block) + } else { + codeWrapper = block.parentElement + } + // 将复制按钮添加到 pre 元素(而非 codeWrapper 内),这样它不会随滚动条滚动 + preElement.appendChild(copyButton) + } +} + +// 检查代码块是否已经被处理过 +function isBlockProcessed(block: HTMLElement): boolean { + return ( + block.hasAttribute('data-highlighted') || + !!block.querySelector('.line-number') || + !!block.parentElement?.querySelector('.copy-button') + ) +} + +// 标记代码块为已处理 +function markBlockAsProcessed(block: HTMLElement) { + block.setAttribute('data-highlighted', 'true') +} + +// 处理单个代码块 +function processBlock(block: HTMLElement) { + if (isBlockProcessed(block)) { + return + } + + try { + highlightCode(block) + insertLineNumbers(block) + addCopyButton(block) + markBlockAsProcessed(block) + } catch (error) { + console.warn('处理代码块时出错:', error) + } +} + +// 查找并处理所有代码块 +function processAllCodeBlocks(el: HTMLElement) { + const blocks = Array.from(el.querySelectorAll('pre code')) + const unprocessedBlocks = blocks.filter((block) => !isBlockProcessed(block)) + + if (unprocessedBlocks.length === 0) { + return + } + + if (unprocessedBlocks.length <= 10) { + // 如果代码块数量少于等于10,直接处理所有代码块 + unprocessedBlocks.forEach((block) => processBlock(block)) + } else { + // 定义每次处理的代码块数 + const batchSize = 10 + let currentIndex = 0 + + const processBatch = () => { + const batch = unprocessedBlocks.slice(currentIndex, currentIndex + batchSize) + + batch.forEach((block) => { + processBlock(block) + }) + + // 更新索引并继续处理下一批 + currentIndex += batchSize + if (currentIndex < unprocessedBlocks.length) { + // 使用 requestAnimationFrame 确保下一帧再处理 + requestAnimationFrame(processBatch) + } + } + + // 开始处理第一批代码块 + processBatch() + } +} + +// 重试处理函数 +function retryProcessing(el: HTMLElement, maxRetries: number = 3, delay: number = 200) { + let retryCount = 0 + + const tryProcess = () => { + processAllCodeBlocks(el) + + // 检查是否还有未处理的代码块 + const remainingBlocks = Array.from(el.querySelectorAll('pre code')).filter( + (block) => !isBlockProcessed(block) + ) + + if (remainingBlocks.length > 0 && retryCount < maxRetries) { + retryCount++ + setTimeout(tryProcess, delay * retryCount) // 递增延迟 + } + } + + tryProcess() +} + +// 代码高亮、插入行号、复制按钮 +const highlightDirective: HighlightDirective = { + mounted(el: HTMLElement) { + // 立即尝试处理一次 + processAllCodeBlocks(el) + + // 延迟处理,确保 v-html 内容已经渲染 + setTimeout(() => { + retryProcessing(el) + }, 100) + + // 使用 MutationObserver 监听 DOM 变化 + const observer = new MutationObserver((mutations) => { + let hasNewCodeBlocks = false + + mutations.forEach((mutation) => { + if (mutation.type === 'childList') { + mutation.addedNodes.forEach((node) => { + if (node.nodeType === Node.ELEMENT_NODE) { + const element = node as HTMLElement + // 检查新添加的节点是否包含代码块 + if (element.tagName === 'PRE' || element.querySelector('pre code')) { + hasNewCodeBlocks = true + } + } + }) + } + }) + + if (hasNewCodeBlocks) { + // 延迟处理新添加的代码块 + setTimeout(() => { + processAllCodeBlocks(el) + }, 50) + } + }) + + // 开始观察 + observer.observe(el, { + childList: true, + subtree: true + }) + + // 将 observer 存储到元素上,以便在 unmounted 时清理 + ;(el as any)._highlightObserver = observer + }, + + updated(el: HTMLElement) { + // 当组件更新时,重新处理代码块 + setTimeout(() => { + processAllCodeBlocks(el) + }, 50) + }, + + unmounted(el: HTMLElement) { + // 清理 MutationObserver + const observer = (el as any)._highlightObserver + if (observer) { + observer.disconnect() + delete (el as any)._highlightObserver + } + } +} + +export function setupHighlightDirective(app: App) { + app.directive('highlight', highlightDirective) +} diff --git a/web/apps/portal/src/directives/business/ripple.ts b/web/apps/portal/src/directives/business/ripple.ts new file mode 100644 index 0000000..e65470e --- /dev/null +++ b/web/apps/portal/src/directives/business/ripple.ts @@ -0,0 +1,116 @@ +/** + * v-ripple 水波纹效果指令 + * + * 为元素添加 Material Design 风格的水波纹点击效果。 + * 点击时从点击位置扩散出圆形水波纹动画,提升交互体验。 + * + * ## 主要功能 + * + * - 水波纹动画 - 点击时从点击位置扩散圆形波纹 + * - 自适应大小 - 根据元素尺寸自动调整波纹大小和动画时长 + * - 智能配色 - 自动识别按钮类型,使用合适的波纹颜色 + * - 自定义颜色 - 支持通过参数自定义波纹颜色 + * - 性能优化 - 使用 requestAnimationFrame 和自动清理机制 + * + * ## 使用示例 + * + * ```vue + * + * ``` + * + * ## 颜色规则 + * + * - 有色按钮(primary、success、warning 等):使用白色半透明波纹 + * - 默认按钮:使用主题色半透明波纹 + * - 自定义:通过 color 参数指定任意颜色 + * + * @module directives/ripple + * @author Art Design Pro Team + */ + +import type { App, Directive, DirectiveBinding } from 'vue' + +export interface RippleOptions { + /** 水波纹颜色 */ + color?: string +} + +export type RippleDirective = Directive + +export const vRipple: RippleDirective = { + mounted(el: HTMLElement, binding: DirectiveBinding) { + // 获取指令的配置参数 + const options: RippleOptions = binding.value || {} + + // 设置元素为相对定位,并隐藏溢出部分 + el.style.position = 'relative' + el.style.overflow = 'hidden' + + // 点击事件处理 + el.addEventListener('mousedown', (e: MouseEvent) => { + const rect = el.getBoundingClientRect() + const left = e.clientX - rect.left + const top = e.clientY - rect.top + + // 创建水波纹元素 + const ripple = document.createElement('div') + const diameter = Math.max(el.clientWidth, el.clientHeight) + const radius = diameter / 2 + + // 根据直径计算动画时间(直径越大,动画时间越长) + const baseTime = 600 // 基础动画时间(毫秒) + const scaleFactor = 0.5 // 缩放因子 + const animationDuration = baseTime + diameter * scaleFactor + + // 设置水波纹的尺寸和位置 + ripple.style.width = ripple.style.height = `${diameter}px` + ripple.style.left = `${left - radius}px` + ripple.style.top = `${top - radius}px` + ripple.style.position = 'absolute' + ripple.style.borderRadius = '50%' + ripple.style.pointerEvents = 'none' + + // 判断是否为有色按钮(Element Plus 按钮类型) + const buttonTypes = ['primary', 'info', 'warning', 'danger', 'success'].map( + (type) => `el-button--${type}` + ) + const isColoredButton = buttonTypes.some((type) => el.classList.contains(type)) + const defaultColor = isColoredButton + ? 'rgba(255, 255, 255, 0.25)' // 有色按钮使用白色水波纹 + : 'var(--el-color-primary-light-7)' // 默认按钮使用主题色水波纹 + + // 设置水波纹颜色、初始状态和过渡效果 + ripple.style.backgroundColor = options.color || defaultColor + ripple.style.transform = 'scale(0)' + ripple.style.transition = `transform ${animationDuration}ms cubic-bezier(0.3, 0, 0.2, 1), opacity ${animationDuration}ms cubic-bezier(0.3, 0, 0.5, 1)` + ripple.style.zIndex = '1' + + // 添加水波纹元素到DOM中 + el.appendChild(ripple) + + // 触发动画 + requestAnimationFrame(() => { + ripple.style.transform = 'scale(2)' + ripple.style.opacity = '0' + }) + + // 动画结束后移除水波纹元素 + setTimeout(() => { + ripple.remove() + }, animationDuration + 500) // 增加500ms缓冲时间 + }) + } +} + +export function setupRippleDirective(app: App) { + app.directive('ripple', vRipple) +} diff --git a/web/apps/portal/src/directives/core/auth.ts b/web/apps/portal/src/directives/core/auth.ts new file mode 100755 index 0000000..3d244c7 --- /dev/null +++ b/web/apps/portal/src/directives/core/auth.ts @@ -0,0 +1,66 @@ +/** + * v-auth 权限指令 + * + * 适用于后端权限控制模式,基于权限标识控制 DOM 元素的显示和隐藏。 + * 如果用户没有对应权限,元素将从 DOM 中移除。 + * + * ## 主要功能 + * + * - 权限验证 - 根据路由 meta 中的权限列表验证用户权限 + * - DOM 控制 - 无权限时自动移除元素,而非隐藏 + * - 响应式更新 - 权限变化时自动更新元素状态 + * + * ## 使用示例 + * + * ```vue + * + * 新增 + * + * + * 编辑 + * + * + * 删除 + * ``` + * + * ## 注意事项 + * + * - 该指令会直接移除 DOM 元素,而不是使用 v-if 隐藏 + * - 权限列表从当前路由的 meta.authList 中获取 + * + * @module directives/auth + * @author Art Design Pro Team + */ + +import { router } from '@/router' +import { App, Directive, DirectiveBinding } from 'vue' + +export type AuthDirective = Directive + +function checkAuthPermission(el: HTMLElement, binding: DirectiveBinding): void { + // 获取当前路由的权限列表 + const authList = (router.currentRoute.value.meta.authList as Array<{ authMark: string }>) || [] + + // 检查是否有对应的权限标识 + const hasPermission = authList.some((item) => item.authMark === binding.value) + + // 如果没有权限,移除元素 + if (!hasPermission) { + removeElement(el) + } +} + +function removeElement(el: HTMLElement): void { + if (el.parentNode) { + el.parentNode.removeChild(el) + } +} + +const authDirective: AuthDirective = { + mounted: checkAuthPermission, + updated: checkAuthPermission +} + +export function setupAuthDirective(app: App): void { + app.directive('auth', authDirective) +} diff --git a/web/apps/portal/src/directives/core/roles.ts b/web/apps/portal/src/directives/core/roles.ts new file mode 100644 index 0000000..f1db35b --- /dev/null +++ b/web/apps/portal/src/directives/core/roles.ts @@ -0,0 +1,87 @@ +/** + * v-roles 角色权限指令 + * + * 基于用户角色控制 DOM 元素的显示和隐藏。 + * 只要用户拥有指定角色中的任意一个,元素就会显示,否则从 DOM 中移除。 + * + * ## 主要功能 + * + * - 角色验证 - 检查用户是否拥有指定角色 + * - 多角色支持 - 支持单个角色或多个角色(满足其一即可) + * - DOM 控制 - 无权限时自动移除元素,而非隐藏 + * - 响应式更新 - 角色变化时自动更新元素状态 + * + * ## 使用示例 + * + * ```vue + * + * ``` + * + * ## 权限逻辑 + * + * - 用户角色从 userStore.getUserInfo.roles 获取 + * - 只要用户拥有指定角色中的任意一个,元素就会显示 + * - 如果用户没有任何角色或不满足条件,元素将被移除 + * + * ## 注意事项 + * + * - 该指令会直接移除 DOM 元素,而不是使用 v-if 隐藏 + * - 适用于基于角色的粗粒度权限控制 + * - 如需基于具体操作的细粒度权限控制,请使用 v-auth 指令 + * + * @module directives/roles + * @author Art Design Pro Team + */ + +import { useUserStore } from '@/store/modules/user' +import { App, Directive, DirectiveBinding } from 'vue' + +export type RolesDirective = Directive + +function checkRolePermission(el: HTMLElement, binding: DirectiveBinding): void { + const userStore = useUserStore() + const userRoles = userStore.getUserInfo.roles + + // 如果用户角色为空或未定义,移除元素 + if (!userRoles?.length) { + removeElement(el) + return + } + + // 确保指令值为数组格式 + const requiredRoles = Array.isArray(binding.value) ? binding.value : [binding.value] + + // 检查用户是否具有所需角色之一 + const hasPermission = requiredRoles.some((role: string) => userRoles.includes(role)) + + // 如果没有权限,安全地移除元素 + if (!hasPermission) { + removeElement(el) + } +} + +function removeElement(el: HTMLElement): void { + if (el.parentNode) { + el.parentNode.removeChild(el) + } +} + +const rolesDirective: RolesDirective = { + mounted: checkRolePermission, + updated: checkRolePermission +} + +export function setupRolesDirective(app: App): void { + app.directive('roles', rolesDirective) +} diff --git a/web/apps/portal/src/directives/index.ts b/web/apps/portal/src/directives/index.ts new file mode 100755 index 0000000..b428a2b --- /dev/null +++ b/web/apps/portal/src/directives/index.ts @@ -0,0 +1,14 @@ +import type { App } from 'vue' +import { setupAuthDirective, type AuthDirective } from './core/auth' +import { setupHighlightDirective, type HighlightDirective } from './business/highlight' +import { setupRippleDirective, type RippleDirective } from './business/ripple' +import { setupRolesDirective, type RolesDirective } from './core/roles' + +export function setupGlobDirectives(app: App) { + setupAuthDirective(app) // 权限指令 + setupRolesDirective(app) // 角色权限指令 + setupHighlightDirective(app) // 高亮指令 + setupRippleDirective(app) // 水波纹指令 +} + +export type { AuthDirective, HighlightDirective, RippleDirective, RolesDirective } diff --git a/web/apps/portal/src/enums/appEnum.ts b/web/apps/portal/src/enums/appEnum.ts new file mode 100755 index 0000000..a39c278 --- /dev/null +++ b/web/apps/portal/src/enums/appEnum.ts @@ -0,0 +1,81 @@ +/** + * 系统级别枚举定义模块 + * + * ## 主要功能 + * + * - 菜单类型枚举(左侧、顶部、混合、双栏) + * - 主题类型枚举(亮色、暗色、自动) + * - 菜单主题枚举(设计、亮色、暗色) + * - 语言类型枚举(中文、英文) + * - 容器宽度枚举(全屏、固定) + * - 菜单宽度枚举(收起宽度) + * + * @module enums/appEnum + * @author Art Design Pro Team + */ + +/** + * 菜单类型 + */ +export enum MenuTypeEnum { + /** 左侧菜单 */ + LEFT = 'left', + /** 顶部菜单 */ + TOP = 'top', + /** 顶部+左侧菜单 */ + TOP_LEFT = 'top-left', + /** 双栏菜单 */ + DUAL_MENU = 'dual-menu' +} + +/** + * 系统主题 + */ +export enum SystemThemeEnum { + /** 暗色主题 */ + DARK = 'dark', + /** 亮色主题 */ + LIGHT = 'light', + /** 自动主题(跟随系统) */ + AUTO = 'auto' +} + +/** + * 菜单主题 + */ +export enum MenuThemeEnum { + /** 暗色主题 */ + DARK = 'dark', + /** 亮色主题 */ + LIGHT = 'light', + /** 设计主题 */ + DESIGN = 'design' +} + +/** + * 菜单宽度 + */ +export enum MenuWidth { + /** 收起宽度 */ + CLOSE = '64px' +} + +/** + * 语言类型 + */ +export enum LanguageEnum { + /** 中文 */ + ZH = 'zh', + /** 英文 */ + EN = 'en' +} + +/** + * 容器宽度 + */ +export enum ContainerWidthEnum { + /** 全屏宽度 */ + FULL = '100%', + /** 固定宽度 */ + BOXED = '1200px' +} diff --git a/web/apps/portal/src/enums/formEnum.ts b/web/apps/portal/src/enums/formEnum.ts new file mode 100644 index 0000000..8e9b3b4 --- /dev/null +++ b/web/apps/portal/src/enums/formEnum.ts @@ -0,0 +1,24 @@ +/** + * 表单相关枚举定义模块 + * + * ## 主要功能 + * + * - 页面模式枚举(新增、编辑) + * - 表格尺寸枚举(默认、紧凑、宽松) + * + * @module enums/formEnum + * @author Art Design Pro Team + */ + +// 页面类型 +export enum PageModeEnum { + Add, // 新增 + Edit // 编辑 +} + +// 表格大小 +export enum TableSizeEnum { + DEFAULT = 'default', + SMALL = 'small', + LARGE = 'large' +} diff --git a/web/apps/portal/src/env.d.ts b/web/apps/portal/src/env.d.ts new file mode 100755 index 0000000..4401f21 --- /dev/null +++ b/web/apps/portal/src/env.d.ts @@ -0,0 +1,34 @@ +/// + +declare module 'nprogress' + +declare module 'crypto-js' + +declare module 'vue-img-cutter' + +declare module 'file-saver' + +declare module 'qrcode.vue' { + export type Level = 'L' | 'M' | 'Q' | 'H' + export type RenderAs = 'canvas' | 'svg' + export type GradientType = 'linear' | 'radial' + export interface ImageSettings { + src: string + height: number + width: number + excavate: boolean + } + export interface QRCodeProps { + value: string + size?: number + level?: Level + background?: string + foreground?: string + renderAs?: RenderAs + } + const QrcodeVue: any + export default QrcodeVue +} + +// 全局变量声明 +declare const __APP_VERSION__: string // 版本号 diff --git a/web/apps/portal/src/hooks/core/useAppMode.ts b/web/apps/portal/src/hooks/core/useAppMode.ts new file mode 100644 index 0000000..c39cd9e --- /dev/null +++ b/web/apps/portal/src/hooks/core/useAppMode.ts @@ -0,0 +1,45 @@ +/** + * useAppMode - 应用模式管理 + * + * 提供应用访问模式的判断和管理功能,支持前端和后端两种权限控制模式。 + * 根据环境变量 VITE_ACCESS_MODE 自动识别当前运行模式。 + * + * ## 主要功能 + * + * 1. 模式识别 - 自动识别前端模式或后端模式 + * 2. 前端模式 - 权限由前端路由配置控制,适合小型项目或演示环境 + * 3. 后端模式 - 权限由后端接口返回的菜单数据控制,适合企业级应用 + * 4. 响应式状态 - 提供响应式的模式判断,方便在组件中使用 + * + * @module useAppMode + * @author Art Design Pro Team + */ + +import { computed } from 'vue' + +export function useAppMode() { + // 获取访问模式配置 + const accessMode = import.meta.env.VITE_ACCESS_MODE + + /** + * 是否为前端控制模式 + * 前端模式:权限由前端路由配置控制 + */ + const isFrontendMode = computed(() => accessMode === 'frontend') + /** + * 是否为后端控制模式 + * 后端模式:权限由后端接口返回的菜单数据控制 + */ + const isBackendMode = computed(() => accessMode === 'backend') + + /** + * 当前应用模式 + */ + const currentMode = computed(() => accessMode) + + return { + isFrontendMode, + isBackendMode, + currentMode + } +} diff --git a/web/apps/portal/src/hooks/core/useAuth.ts b/web/apps/portal/src/hooks/core/useAuth.ts new file mode 100644 index 0000000..283b859 --- /dev/null +++ b/web/apps/portal/src/hooks/core/useAuth.ts @@ -0,0 +1,74 @@ +/** + * useAuth - 权限验证管理 + * + * 提供统一的权限验证功能,支持前端和后端两种权限模式。 + * 用于控制页面按钮、操作等功能的显示和访问权限。 + * + * ## 主要功能 + * + * 1. 权限检查 - 检查用户是否拥有指定的权限标识 + * 2. 双模式支持 - 自动适配前端模式和后端模式的权限验证 + * 3. 前端模式 - 从用户信息中获取按钮权限列表(如 ['add', 'edit', 'delete']) + * 4. 后端模式 - 从路由 meta 配置中获取权限列表(如 [{ authMark: 'add' }]) + * + * ## 使用示例 + * + * ```typescript + * const { hasAuth } = useAuth() + * + * // 检查是否有新增权限 + * if (hasAuth('add')) { + * // 显示新增按钮 + * } + * + * // 在模板中使用 + * 编辑 + * 删除 + * ``` + * + * @module useAuth + * @author Art Design Pro Team + */ + +import { useRoute } from 'vue-router' +import { storeToRefs } from 'pinia' +import { useUserStore } from '@/store/modules/user' +import { useAppMode } from '@/hooks/core/useAppMode' +import type { AppRouteRecord } from '@/types/router' + +type AuthItem = NonNullable[number] + +const userStore = useUserStore() + +export const useAuth = () => { + const route = useRoute() + const { isFrontendMode } = useAppMode() + const { info } = storeToRefs(userStore) + + // 前端按钮权限(例如:['add', 'edit']) + const frontendAuthList = info.value?.buttons ?? [] + + // 后端路由 meta 配置的权限列表(例如:[{ authMark: 'add' }]) + const backendAuthList: AuthItem[] = Array.isArray(route.meta.authList) + ? (route.meta.authList as AuthItem[]) + : [] + + /** + * 检查是否拥有某权限标识(前后端模式通用) + * @param auth 权限标识 + * @returns 是否有权限 + */ + const hasAuth = (auth: string): boolean => { + // 前端模式 + if (isFrontendMode.value) { + return frontendAuthList.includes(auth) + } + + // 后端模式 + return backendAuthList.some((item) => item?.authMark === auth) + } + + return { + hasAuth + } +} diff --git a/web/apps/portal/src/hooks/core/useCeremony.ts b/web/apps/portal/src/hooks/core/useCeremony.ts new file mode 100644 index 0000000..ead2630 --- /dev/null +++ b/web/apps/portal/src/hooks/core/useCeremony.ts @@ -0,0 +1,184 @@ +/** + * useCeremony - 节日庆祝管理 + * + * 提供节日烟花效果和祝福文本展示功能,为系统增添节日氛围。 + * 自动检测当前日期是否为节日,并在首次进入时播放烟花动画和显示祝福语。 + * + * ## 主要功能 + * + * 1. 节日检测 - 自动匹配当前日期与节日配置列表,支持单日和跨日期节日 + * 2. 烟花动画 - 播放节日烟花特效,支持自定义图片和触发次数 + * 3. 祝福文本 - 烟花结束后显示节日祝福文本 + * 4. 状态管理 - 记录烟花播放状态,避免重复播放 + * 5. 清理机制 - 提供清理方法,支持手动停止和重置 + * + * ## 使用示例 + * + * ```typescript + * // 在配置文件中定义节日 + * // 单日节日 + * { + * date: '2024-12-25', + * name: '圣诞节', + * image: christmasImage, + * count: 3 // 可选,不设置则使用默认值 3 次 + * scrollText: 'Merry Christmas!', + * } + * + * // 跨日期节日 + * { + * date: '2025-11-07', + * endDate: '2025-11-10', + * name: 'v3.0 测试阶段', + * image: '', + * count: 5 // 自定义烟花播放次数 + * scrollText: '系统 v3.0 测试阶段正式开启!', + * } + * ``` + * + * @module useCeremony + * @author Art Design Pro Team + */ + +import { useTimeoutFn, useIntervalFn, useDateFormat } from '@vueuse/core' +import { storeToRefs } from 'pinia' +import { computed } from 'vue' +import { useSettingStore } from '@/store/modules/setting' +import { mittBus } from '@/utils/sys' +import { festivalConfigList } from '@/config/modules/festival' + +/** + * 节日庆祝配置常量 + */ +const FESTIVAL_CONFIG = { + /** 初始延迟(毫秒) */ + INITIAL_DELAY: 300, + /** 烟花播放间隔(毫秒) */ + FIREWORK_INTERVAL: 1000, + /** 文本显示延迟(毫秒) */ + TEXT_DELAY: 2000, + /** 默认烟花播放次数 */ + DEFAULT_FIREWORKS_COUNT: 3 +} as const + +/** + * 节日庆祝功能 + * 提供节日烟花效果和祝福文本展示 + */ +export function useCeremony() { + const settingStore = useSettingStore() + const { holidayFireworksLoaded, isShowFireworks } = storeToRefs(settingStore) + + let fireworksInterval: { pause: () => void } | null = null + + /** + * 检查日期是否在节日范围内 + * @param currentDate 当前日期 + * @param festivalDate 节日开始日期 + * @param festivalEndDate 节日结束日期(可选) + */ + const isDateInRange = ( + currentDate: string, + festivalDate: string, + festivalEndDate?: string + ): boolean => { + if (!festivalEndDate) { + // 单日节日 + return currentDate === festivalDate + } + + // 跨日期节日 + const current = new Date(currentDate) + const start = new Date(festivalDate) + const end = new Date(festivalEndDate) + + return current >= start && current <= end + } + + /** + * 获取当前日期对应的节日数据 + */ + const currentFestivalData = computed(() => { + const currentDate = useDateFormat(new Date(), 'YYYY-MM-DD').value + return festivalConfigList.find((item) => isDateInRange(currentDate, item.date, item.endDate)) + }) + + /** + * 更新节日日期到 store + */ + const updateFestivalDate = () => { + settingStore.setFestivalDate(currentFestivalData.value?.date || '') + } + + /** + * 触发烟花效果 + */ + const triggerFirework = () => { + mittBus.emit('triggerFireworks', currentFestivalData.value?.image) + } + + /** + * 完成烟花效果后显示文本 + */ + const showFestivalText = () => { + settingStore.setholidayFireworksLoaded(true) + + useTimeoutFn(() => { + settingStore.setShowFestivalText(true) + updateFestivalDate() + }, FESTIVAL_CONFIG.TEXT_DELAY) + } + + /** + * 启动烟花循环 + */ + const startFireworksLoop = () => { + let playedCount = 0 + // 使用节日配置的播放次数,如果没有则使用默认值 + const count = currentFestivalData.value?.count ?? FESTIVAL_CONFIG.DEFAULT_FIREWORKS_COUNT + + const { pause } = useIntervalFn(() => { + triggerFirework() + playedCount++ + + if (playedCount >= count) { + pause() + showFestivalText() + } + }, FESTIVAL_CONFIG.FIREWORK_INTERVAL) + + fireworksInterval = { pause } + } + + /** + * 开启节日庆祝 + */ + const openFestival = () => { + if (!currentFestivalData.value || !isShowFireworks.value) { + return + } + + const { start } = useTimeoutFn(startFireworksLoop, FESTIVAL_CONFIG.INITIAL_DELAY) + start() + } + + /** + * 清理烟花效果 + */ + const cleanup = () => { + if (fireworksInterval) { + fireworksInterval.pause() + fireworksInterval = null + } + settingStore.setShowFestivalText(false) + updateFestivalDate() + } + + return { + openFestival, + cleanup, + holidayFireworksLoaded, + currentFestivalData, + isShowFireworks + } +} diff --git a/web/apps/portal/src/hooks/core/useChart.ts b/web/apps/portal/src/hooks/core/useChart.ts new file mode 100644 index 0000000..29ba1d1 --- /dev/null +++ b/web/apps/portal/src/hooks/core/useChart.ts @@ -0,0 +1,745 @@ +/** + * useChart - ECharts 图表管理 + * + * 提供完整的 ECharts 图表生命周期管理和配置能力,简化图表开发流程。 + * 自动处理图表初始化、更新、销毁、主题切换、响应式调整等复杂逻辑。 + * + * ## 核心功能 + * + * 1. 图表生命周期管理 - 自动处理初始化、更新、销毁,支持延迟加载和可见性检测 + * 2. 主题自动适配 - 响应系统主题变化,自动更新图表样式和配色 + * 3. 响应式调整 - 监听窗口大小、菜单展开等变化,自动调整图表尺寸 + * 4. 空状态处理 - 优雅的空数据展示,自动显示"暂无数据"提示 + * 5. 样式配置统一 - 提供坐标轴、图例、提示框等统一的样式配置方法 + * 6. 性能优化 - 防抖处理、样式缓存、requestAnimationFrame 优化 + * 7. 高级组件抽象 - useChartComponent 提供更高层次的图表组件封装 + * + * ## 使用示例 + * + * ```typescript + * // 基础用法 + * const { + * chartRef, + * initChart, + * updateChart, + * getAxisLineStyle, + * getTooltipStyle + * } = useChart() + * + * onMounted(() => { + * initChart({ + * xAxis: { type: 'category', data: ['Mon', 'Tue', 'Wed'] }, + * yAxis: { type: 'value' }, + * series: [{ data: [120, 200, 150], type: 'bar' }] + * }) + * }) + * + * // 高级用法 - 组件抽象 + * const chart = useChartComponent({ + * props, + * generateOptions: () => ({ + * // ECharts 配置 + * }), + * checkEmpty: () => data.value.length === 0, + * watchSources: [() => props.data] + * }) + * ``` + * + * @module useChart + * @author Art Design Pro Team + */ + +import { echarts, type EChartsOption } from '@/plugins/echarts' +import { storeToRefs } from 'pinia' +import { useSettingStore } from '@/store/modules/setting' +import { getCssVar } from '@/utils/ui' +import type { BaseChartProps, ChartThemeConfig, UseChartOptions } from '@/types/component/chart' + +// 图表主题配置 +export const useChartOps = (): ChartThemeConfig => ({ + /** */ + chartHeight: '16rem', + /** 字体大小 */ + fontSize: 13, + /** 字体颜色 */ + fontColor: '#999', + /** 主题颜色 */ + themeColor: getCssVar('--el-color-primary-light-1'), + /** 颜色组 */ + colors: [ + getCssVar('--el-color-primary-light-1'), + '#4ABEFF', + '#EDF2FF', + '#14DEBA', + '#FFAF20', + '#FA8A6C', + '#FFAF20' + ] +}) + +// 常量定义 +const RESIZE_DELAYS = [50, 100, 200, 350] as const +const MENU_RESIZE_DELAYS = [50, 100, 200] as const +const RESIZE_DEBOUNCE_DELAY = 100 + +export function useChart(options: UseChartOptions = {}) { + const { initOptions, initDelay = 0, threshold = 0.1, autoTheme = true } = options + + const settingStore = useSettingStore() + const { isDark, menuOpen, menuType } = storeToRefs(settingStore) + + const chartRef = ref() + let chart: echarts.ECharts | null = null + let intersectionObserver: IntersectionObserver | null = null + let pendingOptions: EChartsOption | null = null + let resizeTimeoutId: number | null = null + let resizeFrameId: number | null = null + let isDestroyed = false + let emptyStateDiv: HTMLElement | null = null + + // 清理定时器的统一方法 + const clearTimers = () => { + if (resizeTimeoutId) { + clearTimeout(resizeTimeoutId) + resizeTimeoutId = null + } + if (resizeFrameId) { + cancelAnimationFrame(resizeFrameId) + resizeFrameId = null + } + } + + // 使用 requestAnimationFrame 优化 resize 处理 + const requestAnimationResize = () => { + if (resizeFrameId) { + cancelAnimationFrame(resizeFrameId) + } + resizeFrameId = requestAnimationFrame(() => { + handleResize() + resizeFrameId = null + }) + } + + // 防抖的resize处理(用于窗口resize事件) + const debouncedResize = () => { + if (resizeTimeoutId) { + clearTimeout(resizeTimeoutId) + } + resizeTimeoutId = window.setTimeout(() => { + requestAnimationResize() + resizeTimeoutId = null + }, RESIZE_DEBOUNCE_DELAY) + } + + // 多延迟resize处理 - 统一方法 + const multiDelayResize = (delays: readonly number[]) => { + // 立即调用一次,快速响应 + nextTick(requestAnimationResize) + + // 使用延迟时间,确保图表正确适应变化 + delays.forEach((delay) => { + setTimeout(requestAnimationResize, delay) + }) + } + + // 收缩菜单时,重新计算图表大小(仅在图表存在时监听) + let menuOpenStopHandle: (() => void) | null = null + let menuTypeStopHandle: (() => void) | null = null + + const setupMenuWatchers = () => { + menuOpenStopHandle = watch(menuOpen, () => multiDelayResize(RESIZE_DELAYS)) + menuTypeStopHandle = watch(menuType, () => { + nextTick(requestAnimationResize) + setTimeout(() => multiDelayResize(MENU_RESIZE_DELAYS), 0) + }) + } + + const cleanupMenuWatchers = () => { + menuOpenStopHandle?.() + menuTypeStopHandle?.() + menuOpenStopHandle = null + menuTypeStopHandle = null + } + + // 主题变化时重新设置图表选项 + let themeStopHandle: (() => void) | null = null + + const setupThemeWatcher = () => { + if (autoTheme) { + themeStopHandle = watch(isDark, () => { + // 更新空状态样式 + emptyStateManager.updateStyle() + + if (chart && !isDestroyed) { + // 使用 requestAnimationFrame 优化主题更新 + requestAnimationFrame(() => { + if (chart && !isDestroyed) { + const currentOptions = chart.getOption() + if (currentOptions) { + updateChart(currentOptions as EChartsOption) + } + } + }) + } + }) + } + } + + const cleanupThemeWatcher = () => { + themeStopHandle?.() + themeStopHandle = null + } + + // 样式生成器 - 统一的样式配置 + const createLineStyle = (color: string, width = 1, type?: 'solid' | 'dashed') => ({ + color, + width, + ...(type && { type }) + }) + + // 缓存样式配置以减少重复计算 + const styleCache = { + axisLine: null as any, + splitLine: null as any, + axisLabel: null as any, + lastDarkValue: isDark.value + } + + const clearStyleCache = () => { + styleCache.axisLine = null + styleCache.splitLine = null + styleCache.axisLabel = null + styleCache.lastDarkValue = isDark.value + } + + // 坐标轴线样式 + const getAxisLineStyle = (show: boolean = true) => { + if (styleCache.lastDarkValue !== isDark.value) { + clearStyleCache() + } + if (!styleCache.axisLine) { + styleCache.axisLine = { + show, + lineStyle: createLineStyle(isDark.value ? '#444' : '#EDEDED') + } + } + return styleCache.axisLine + } + + // 分割线样式 + const getSplitLineStyle = (show: boolean = true) => { + if (styleCache.lastDarkValue !== isDark.value) { + clearStyleCache() + } + if (!styleCache.splitLine) { + styleCache.splitLine = { + show, + lineStyle: createLineStyle(isDark.value ? '#444' : '#EDEDED', 1, 'dashed') + } + } + return styleCache.splitLine + } + + // 坐标轴标签样式 + const getAxisLabelStyle = (show: boolean = true) => { + if (styleCache.lastDarkValue !== isDark.value) { + clearStyleCache() + } + if (!styleCache.axisLabel) { + const { fontColor, fontSize } = useChartOps() + styleCache.axisLabel = { + show, + color: fontColor, + fontSize + } + } + return styleCache.axisLabel + } + + // 坐标轴刻度样式(静态配置,无需缓存) + const getAxisTickStyle = () => ({ + show: false + }) + + // 获取动画配置 + const getAnimationConfig = (animationDelay: number = 50, animationDuration: number = 1500) => ({ + animationDelay: (idx: number) => idx * animationDelay + 200, + animationDuration: (idx: number) => animationDuration - idx * 50, + animationEasing: 'quarticOut' as const + }) + + // 获取统一的 tooltip 配置 + const getTooltipStyle = (trigger: 'item' | 'axis' = 'axis', customOptions: any = {}) => ({ + trigger, + backgroundColor: isDark.value ? 'rgba(0, 0, 0, 0.8)' : 'rgba(255, 255, 255, 0.9)', + borderColor: isDark.value ? '#333' : '#ddd', + borderWidth: 1, + textStyle: { + color: isDark.value ? '#fff' : '#333' + }, + ...customOptions + }) + + // 获取统一的图例配置 + const getLegendStyle = ( + position: 'bottom' | 'top' | 'left' | 'right' = 'bottom', + customOptions: any = {} + ) => { + const baseConfig = { + textStyle: { + color: isDark.value ? '#fff' : '#333' + }, + itemWidth: 12, + itemHeight: 12, + itemGap: 20, + ...customOptions + } + + // 根据位置设置不同的配置 + switch (position) { + case 'bottom': + return { + ...baseConfig, + bottom: 0, + left: 'center', + orient: 'horizontal', + icon: 'roundRect' + } + case 'top': + return { + ...baseConfig, + top: 0, + left: 'center', + orient: 'horizontal', + icon: 'roundRect' + } + case 'left': + return { + ...baseConfig, + left: 0, + top: 'center', + orient: 'vertical', + icon: 'roundRect' + } + case 'right': + return { + ...baseConfig, + right: 0, + top: 'center', + orient: 'vertical', + icon: 'roundRect' + } + default: + return baseConfig + } + } + + // 根据图例位置计算 grid 配置 + const getGridWithLegend = ( + showLegend: boolean, + legendPosition: 'bottom' | 'top' | 'left' | 'right' = 'bottom', + baseGrid: any = {} + ) => { + const defaultGrid = { + top: 15, + right: 15, + bottom: 8, + left: 0, + containLabel: true, + ...baseGrid + } + + if (!showLegend) { + return defaultGrid + } + + // 根据图例位置调整 grid + switch (legendPosition) { + case 'bottom': + return { + ...defaultGrid, + bottom: 40 + } + case 'top': + return { + ...defaultGrid, + top: 40 + } + case 'left': + return { + ...defaultGrid, + left: 120 + } + case 'right': + return { + ...defaultGrid, + right: 120 + } + default: + return defaultGrid + } + } + + // 创建IntersectionObserver + const createIntersectionObserver = () => { + if (intersectionObserver || !chartRef.value) return + + intersectionObserver = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting && pendingOptions && !isDestroyed) { + // 使用 requestAnimationFrame 确保在下一帧初始化图表 + requestAnimationFrame(() => { + if (!isDestroyed && pendingOptions) { + try { + // 元素变为可见,初始化图表 + if (!chart) { + chart = echarts.init(entry.target as HTMLElement) + } + + // 触发自定义事件,让组件处理动画逻辑 + const event = new CustomEvent('chartVisible', { + detail: { options: pendingOptions } + }) + entry.target.dispatchEvent(event) + + pendingOptions = null + cleanupIntersectionObserver() + } catch (error) { + console.error('图表初始化失败:', error) + } + } + }) + } + }) + }, + { threshold } + ) + + intersectionObserver.observe(chartRef.value) + } + + // 清理IntersectionObserver + const cleanupIntersectionObserver = () => { + if (intersectionObserver) { + intersectionObserver.disconnect() + intersectionObserver = null + } + } + + // 检查容器是否可见 + const isContainerVisible = (element: HTMLElement): boolean => { + const rect = element.getBoundingClientRect() + return rect.width > 0 && rect.height > 0 && rect.top < window.innerHeight && rect.bottom > 0 + } + + // 图表初始化核心逻辑 + const performChartInit = (options: EChartsOption) => { + if (!chart && chartRef.value && !isDestroyed) { + chart = echarts.init(chartRef.value) + // 图表创建后立即设置监听器 + setupMenuWatchers() + setupThemeWatcher() + } + if (chart && !isDestroyed) { + chart.setOption(options) + pendingOptions = null + } + } + + // 空状态管理器 + const emptyStateManager = { + create: () => { + if (!chartRef.value || emptyStateDiv) return + + emptyStateDiv = document.createElement('div') + emptyStateDiv.style.cssText = ` + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + font-size: 12px; + color: ${isDark.value ? '#555555' : '#B3B2B2'}; + background: transparent; + z-index: 10; + ` + emptyStateDiv.innerHTML = `暂无数据` + + // 确保父容器有相对定位 + if ( + chartRef.value.style.position !== 'relative' && + chartRef.value.style.position !== 'absolute' + ) { + chartRef.value.style.position = 'relative' + } + + chartRef.value.appendChild(emptyStateDiv) + }, + + remove: () => { + if (emptyStateDiv && chartRef.value) { + chartRef.value.removeChild(emptyStateDiv) + emptyStateDiv = null + } + }, + + updateStyle: () => { + if (emptyStateDiv) { + emptyStateDiv.style.color = isDark.value ? '#666' : '#999' + } + } + } + + // 初始化图表 + const initChart = (options: EChartsOption = {}, isEmpty: boolean = false) => { + if (!chartRef.value || isDestroyed) return + + const mergedOptions = { ...initOptions, ...options } + + try { + if (isEmpty) { + // 处理空数据情况 - 显示自定义空状态div + if (chart) { + chart.clear() + } + emptyStateManager.create() + return + } else { + // 有数据时移除空状态div + emptyStateManager.remove() + } + + if (isContainerVisible(chartRef.value)) { + // 容器可见,正常初始化 + if (initDelay > 0) { + setTimeout(() => performChartInit(mergedOptions), initDelay) + } else { + performChartInit(mergedOptions) + } + } else { + // 容器不可见,保存选项并设置监听器 + pendingOptions = mergedOptions + createIntersectionObserver() + } + } catch (error) { + console.error('图表初始化失败:', error) + } + } + + // 更新图表 + const updateChart = (options: EChartsOption) => { + if (isDestroyed) return + + try { + if (!chart) { + // 如果图表不存在,先初始化 + initChart(options) + return + } + chart.setOption(options) + } catch (error) { + console.error('图表更新失败:', error) + } + } + + // 处理窗口大小变化 + const handleResize = () => { + if (chart && !isDestroyed) { + try { + chart.resize() + } catch (error) { + console.error('图表resize失败:', error) + } + } + } + + // 销毁图表 + const destroyChart = () => { + isDestroyed = true + + if (chart) { + try { + chart.dispose() + } catch (error) { + console.error('图表销毁失败:', error) + } finally { + chart = null + } + } + + // 清理所有监听器和资源 + cleanupMenuWatchers() + cleanupThemeWatcher() + emptyStateManager.remove() + cleanupIntersectionObserver() + clearTimers() + clearStyleCache() + pendingOptions = null + } + + // 获取图表实例 + const getChartInstance = () => chart + + // 获取图表是否已初始化 + const isChartInitialized = () => chart !== null + + onMounted(() => { + window.addEventListener('resize', debouncedResize) + }) + + onBeforeUnmount(() => { + window.removeEventListener('resize', debouncedResize) + }) + + onUnmounted(() => { + destroyChart() + }) + + return { + isDark, + chartRef, + initChart, + updateChart, + handleResize, + destroyChart, + getChartInstance, + isChartInitialized, + emptyStateManager, + getAxisLineStyle, + getSplitLineStyle, + getAxisLabelStyle, + getAxisTickStyle, + getAnimationConfig, + getTooltipStyle, + getLegendStyle, + useChartOps, + getGridWithLegend + } +} + +// 高级图表组件抽象 +interface UseChartComponentOptions { + /** Props响应式对象 */ + props: T + /** 图表配置生成函数 */ + generateOptions: () => EChartsOption + /** 空数据检查函数 */ + checkEmpty?: () => boolean + /** 自定义监听的响应式数据 */ + watchSources?: (() => any)[] + /** 自定义可视事件处理 */ + onVisible?: () => void + /** useChart选项 */ + chartOptions?: UseChartOptions +} + +export function useChartComponent(options: UseChartComponentOptions) { + const { + props, + generateOptions, + checkEmpty, + watchSources = [], + onVisible, + chartOptions = {} + } = options + + const chart = useChart(chartOptions) + const { chartRef, initChart, isDark, emptyStateManager } = chart + + // 检查是否为空数据 + const isEmpty = computed(() => { + if (props.isEmpty) return true + if (checkEmpty) return checkEmpty() + return false + }) + + // 更新图表 + const updateChart = () => { + nextTick(() => { + if (isEmpty.value) { + // 处理空数据情况 - 显示自定义空状态div + if (chart.getChartInstance()) { + chart.getChartInstance()?.clear() + } + emptyStateManager.create() + } else { + // 有数据时移除空状态div并初始化图表 + emptyStateManager.remove() + initChart(generateOptions()) + } + }) + } + + // 处理图表进入可视区域时的逻辑 + const handleChartVisible = () => { + if (onVisible) { + onVisible() + } else { + updateChart() + } + } + + // 存储监听器停止函数 + const stopHandles: (() => void)[] = [] + + // 设置数据监听 + const setupWatchers = () => { + // 监听自定义数据源 + if (watchSources.length > 0) { + const stopHandle = watch(watchSources, updateChart, { deep: true }) + stopHandles.push(stopHandle) + } + + // 监听主题变化 + const themeStopHandle = watch(isDark, () => { + emptyStateManager.updateStyle() + updateChart() + }) + stopHandles.push(themeStopHandle) + } + + // 清理所有监听器 + const cleanupWatchers = () => { + stopHandles.forEach((stop) => stop()) + stopHandles.length = 0 + } + + // 设置生命周期 + const setupLifecycle = () => { + onMounted(() => { + updateChart() + + // 监听图表可见事件 + if (chartRef.value) { + chartRef.value.addEventListener('chartVisible', handleChartVisible) + } + }) + + onBeforeUnmount(() => { + // 清理事件监听器 + if (chartRef.value) { + chartRef.value.removeEventListener('chartVisible', handleChartVisible) + } + // 清理所有监听器 + cleanupWatchers() + // 清理空状态div + emptyStateManager.remove() + }) + } + + // 初始化 + setupWatchers() + setupLifecycle() + + return { + ...chart, + isEmpty, + updateChart, + handleChartVisible + } +} diff --git a/web/apps/portal/src/hooks/core/useCommon.ts b/web/apps/portal/src/hooks/core/useCommon.ts new file mode 100644 index 0000000..c936854 --- /dev/null +++ b/web/apps/portal/src/hooks/core/useCommon.ts @@ -0,0 +1,87 @@ +/** + * useCommon - 通用功能集合 + * + * 提供常用的页面操作功能,包括页面刷新、滚动控制、路径获取等。 + * 这些功能在多个页面和组件中都会用到,统一封装便于复用。 + * + * ## 主要功能 + * + * 1. 首页路径 - 获取系统配置的首页路径 + * 2. 页面刷新 - 刷新当前页面内容 + * 3. 滚动控制 - 提供多种滚动到顶部和指定位置的方法 + * 4. 平滑滚动 - 支持平滑滚动动画效果 + * + * @module useCommon + * @author Art Design Pro Team + */ + +import { computed } from 'vue' +import { useMenuStore } from '@/store/modules/menu' +import { useSettingStore } from '@/store/modules/setting' + +export function useCommon() { + const menuStore = useMenuStore() + const settingStore = useSettingStore() + + /** + * 首页路径 + * 从菜单 store 中获取配置的首页路径 + */ + const homePath = computed(() => menuStore.getHomePath()) + + /** + * 刷新当前页面 + * 通过切换 setting store 中的 refresh 状态触发页面重新渲染 + */ + const refresh = () => { + settingStore.reload() + } + + /** + * 滚动到页面顶部 + * 查找主内容区域并将其滚动位置重置为顶部 + */ + const scrollToTop = () => { + const scrollContainer = document.getElementById('app-main') + if (scrollContainer) { + scrollContainer.scrollTop = 0 + } + } + + /** + * 平滑滚动到页面顶部 + * 使用 smooth 行为实现平滑滚动效果 + */ + const smoothScrollToTop = () => { + const scrollContainer = document.getElementById('app-main') + if (scrollContainer) { + scrollContainer.scrollTo({ + top: 0, + behavior: 'smooth' + }) + } + } + + /** + * 滚动到指定位置 + * @param top 目标滚动位置(像素) + * @param smooth 是否使用平滑滚动 + */ + const scrollTo = (top: number, smooth: boolean = false) => { + const scrollContainer = document.getElementById('app-main') + if (scrollContainer) { + scrollContainer.scrollTo({ + top, + behavior: smooth ? 'smooth' : 'auto' + }) + } + } + + return { + homePath, + refresh, + scrollTo, + scrollToTop, + smoothScrollToTop + } +} diff --git a/web/apps/portal/src/hooks/core/useFastEnter.ts b/web/apps/portal/src/hooks/core/useFastEnter.ts new file mode 100644 index 0000000..555eb65 --- /dev/null +++ b/web/apps/portal/src/hooks/core/useFastEnter.ts @@ -0,0 +1,55 @@ +/** + * useFastEnter - 快速入口管理 + * + * 管理顶部栏的快速入口功能,提供应用列表和快速链接的配置和过滤。 + * 支持动态启用/禁用、自定义排序、响应式宽度控制等功能。 + * + * ## 主要功能 + * + * 1. 应用列表管理 - 获取启用的应用列表,自动按排序权重排序 + * 2. 快速链接管理 - 获取启用的快速链接,支持自定义排序 + * 3. 响应式配置 - 所有配置自动响应变化,无需手动更新 + * 4. 宽度控制 - 提供最小显示宽度配置,支持响应式布局 + * + * @module useFastEnter + * @author Art Design Pro Team + */ + +import { computed } from 'vue' +import appConfig from '@/config' +import type { FastEnterApplication, FastEnterQuickLink } from '@/types/config' + +export function useFastEnter() { + // 获取快速入口配置 + const fastEnterConfig = computed(() => appConfig.fastEnter) + + // 获取启用的应用列表(按排序权重排序) + const enabledApplications = computed(() => { + if (!fastEnterConfig.value?.applications) return [] + + return fastEnterConfig.value.applications + .filter((app) => app.enabled !== false) + .sort((a, b) => (a.order || 0) - (b.order || 0)) + }) + + // 获取启用的快速链接(按排序权重排序) + const enabledQuickLinks = computed(() => { + if (!fastEnterConfig.value?.quickLinks) return [] + + return fastEnterConfig.value.quickLinks + .filter((link) => link.enabled !== false) + .sort((a, b) => (a.order || 0) - (b.order || 0)) + }) + + // 获取最小显示宽度 + const minWidth = computed(() => { + return fastEnterConfig.value?.minWidth || 1200 + }) + + return { + fastEnterConfig, + enabledApplications, + enabledQuickLinks, + minWidth + } +} diff --git a/web/apps/portal/src/hooks/core/useHeaderBar.ts b/web/apps/portal/src/hooks/core/useHeaderBar.ts new file mode 100644 index 0000000..be10712 --- /dev/null +++ b/web/apps/portal/src/hooks/core/useHeaderBar.ts @@ -0,0 +1,201 @@ +/** + * useHeaderBar - 顶部栏功能管理 + * + * 统一管理顶部栏各个功能模块的显示状态和配置信息。 + * 提供灵活的功能开关控制,支持动态显示/隐藏顶部栏的各个功能按钮。 + * + * ## 主要功能 + * + * 1. 功能开关控制 - 统一管理菜单按钮、刷新按钮、快速入口等功能的显示状态 + * 2. 配置信息获取 - 获取各个功能模块的详细配置信息 + * 3. 功能列表查询 - 快速获取所有启用或禁用的功能列表 + * 4. 响应式状态 - 所有状态自动响应配置和 store 变化 + * + * @module useHeaderBar + * @author Art Design Pro Team + */ + +import { computed } from 'vue' +import { storeToRefs } from 'pinia' +import { useSettingStore } from '@/store/modules/setting' +import { headerBarConfig } from '@/config/modules/headerBar' +import { HeaderBarFeatureConfig } from '@/types' + +/** + * 顶部栏功能管理 + * @returns 顶部栏功能相关的状态和方法 + */ +export function useHeaderBar() { + const settingStore = useSettingStore() + + // 获取顶部栏配置 + const headerBarConfigRef = computed(() => headerBarConfig) + + // 从store中获取相关状态 + const { showMenuButton, showFastEnter, showRefreshButton, showCrumbs, showLanguage } = + storeToRefs(settingStore) + + /** + * 检查特定功能是否启用 + * @param feature 功能名称 + * @returns 是否启用 + */ + const isFeatureEnabled = (feature: keyof HeaderBarFeatureConfig): boolean => { + return headerBarConfigRef.value[feature]?.enabled ?? false + } + + /** + * 获取功能配置信息 + * @param feature 功能名称 + * @returns 功能配置信息 + */ + const getFeatureConfig = (feature: keyof HeaderBarFeatureConfig) => { + return headerBarConfigRef.value[feature] + } + + // 检查菜单按钮是否显示 + const shouldShowMenuButton = computed(() => { + return isFeatureEnabled('menuButton') && showMenuButton.value + }) + + // 检查刷新按钮是否显示 + const shouldShowRefreshButton = computed(() => { + return isFeatureEnabled('refreshButton') && showRefreshButton.value + }) + + // 检查快速入口是否显示 + const shouldShowFastEnter = computed(() => { + return isFeatureEnabled('fastEnter') && showFastEnter.value + }) + + // 检查面包屑是否显示 + const shouldShowBreadcrumb = computed(() => { + return isFeatureEnabled('breadcrumb') && showCrumbs.value + }) + + // 检查全局搜索是否显示 + const shouldShowGlobalSearch = computed(() => { + return isFeatureEnabled('globalSearch') + }) + + // 检查全屏按钮是否显示 + const shouldShowFullscreen = computed(() => { + return isFeatureEnabled('fullscreen') + }) + + // 检查通知中心是否显示 + const shouldShowNotification = computed(() => { + return isFeatureEnabled('notification') + }) + + // 检查聊天功能是否显示 + const shouldShowChat = computed(() => { + return isFeatureEnabled('chat') + }) + + // 检查语言切换是否显示 + const shouldShowLanguage = computed(() => { + return isFeatureEnabled('language') && showLanguage.value + }) + + // 检查设置面板是否显示 + const shouldShowSettings = computed(() => { + return isFeatureEnabled('settings') + }) + + // 检查主题切换是否显示 + const shouldShowThemeToggle = computed(() => { + return isFeatureEnabled('themeToggle') + }) + + // 获取快速入口的最小宽度 + const fastEnterMinWidth = computed(() => { + const config = getFeatureConfig('fastEnter') + return (config as any)?.minWidth || 1200 + }) + + /** + * 检查功能是否启用(别名) + * @param feature 功能名称 + * @returns 是否启用 + */ + const isFeatureActive = (feature: keyof HeaderBarFeatureConfig): boolean => { + return isFeatureEnabled(feature) + } + + /** + * 获取功能配置(别名) + * @param feature 功能名称 + * @returns 功能配置 + */ + const getFeatureInfo = (feature: keyof HeaderBarFeatureConfig) => { + return getFeatureConfig(feature) + } + + /** + * 获取所有启用的功能列表 + * @returns 启用的功能名称数组 + */ + const getEnabledFeatures = (): (keyof HeaderBarFeatureConfig)[] => { + return Object.keys(headerBarConfigRef.value).filter( + (key) => headerBarConfigRef.value[key as keyof HeaderBarFeatureConfig]?.enabled + ) as (keyof HeaderBarFeatureConfig)[] + } + + /** + * 获取所有禁用的功能列表 + * @returns 禁用的功能名称数组 + */ + const getDisabledFeatures = (): (keyof HeaderBarFeatureConfig)[] => { + return Object.keys(headerBarConfigRef.value).filter( + (key) => !headerBarConfigRef.value[key as keyof HeaderBarFeatureConfig]?.enabled + ) as (keyof HeaderBarFeatureConfig)[] + } + + /** + * 获取所有启用的功能(别名) + * @returns 启用的功能列表 + */ + const getActiveFeatures = () => { + return getEnabledFeatures() + } + + /** + * 获取所有禁用的功能(别名) + * @returns 禁用的功能列表 + */ + const getInactiveFeatures = () => { + return getDisabledFeatures() + } + + return { + // 配置 + headerBarConfig: headerBarConfigRef, + + // 显示状态计算属性 + shouldShowMenuButton, // 是否显示菜单按钮 + shouldShowRefreshButton, // 是否显示刷新按钮 + shouldShowFastEnter, // 是否显示快速入口 + shouldShowBreadcrumb, // 是否显示面包屑 + shouldShowGlobalSearch, // 是否显示全局搜索 + shouldShowFullscreen, // 是否显示全屏按钮 + shouldShowNotification, // 是否显示通知中心 + shouldShowChat, // 是否显示聊天功能 + shouldShowLanguage, // 是否显示语言切换 + shouldShowSettings, // 是否显示设置面板 + shouldShowThemeToggle, // 是否显示主题切换 + + // 配置相关 + fastEnterMinWidth, // 快速入口最小宽度 + + // 方法 + isFeatureEnabled, // 检查功能是否启用 + isFeatureActive, // 检查功能是否启用(别名) + getFeatureConfig, // 获取功能配置 + getFeatureInfo, // 获取功能配置(别名) + getEnabledFeatures, // 获取所有启用的功能 + getDisabledFeatures, // 获取所有禁用的功能 + getActiveFeatures, // 获取所有启用的功能(别名) + getInactiveFeatures // 获取所有禁用的功能(别名) + } +} diff --git a/web/apps/portal/src/hooks/core/useLayoutHeight.ts b/web/apps/portal/src/hooks/core/useLayoutHeight.ts new file mode 100644 index 0000000..4b1171a --- /dev/null +++ b/web/apps/portal/src/hooks/core/useLayoutHeight.ts @@ -0,0 +1,148 @@ +/** + * useLayoutHeight - 页面布局高度管理 + * + * 自动计算和管理页面内容区域的高度,确保内容区域能够正确填充剩余空间。 + * 监听头部元素高度变化,动态调整内容区域高度,避免出现滚动条或布局错乱。 + * + * ## 主要功能 + * + * 1. 动态高度计算 - 根据头部元素高度自动计算内容区域高度 + * 2. 响应式监听 - 自动监听元素尺寸变化并更新高度 + * 3. CSS 变量同步 - 自动更新 CSS 变量,方便全局使用 + * 4. 灵活配置 - 支持自定义间距、CSS 变量名等 + * 5. 自动查找模式 - 提供通过 ID 自动查找元素的便捷方式 + * + * @module useLayoutHeight + * @author Art Design Pro Team + */ + +import { ref, computed, watch, onMounted } from 'vue' +import { useElementSize } from '@vueuse/core' + +/** + * 页面容器高度配置 + */ +interface LayoutHeightOptions { + /** 额外的间距(默认 15px) */ + extraSpacing?: number + /** 是否自动更新 CSS 变量(默认 true) */ + updateCssVar?: boolean + /** CSS 变量名称(默认 '--art-full-height') */ + cssVarName?: string +} + +export function useLayoutHeight(options: LayoutHeightOptions = {}) { + const { extraSpacing = 15, updateCssVar = true, cssVarName = '--art-full-height' } = options + + // 元素引用 + const headerRef = ref() + const contentHeaderRef = ref() + + // 使用 VueUse 自动监听元素尺寸变化 + const { height: headerHeight } = useElementSize(headerRef) + const { height: contentHeaderHeight } = useElementSize(contentHeaderRef) + + // 计算容器最小高度(响应式) + const containerMinHeight = computed(() => { + const totalHeight = headerHeight.value + contentHeaderHeight.value + extraSpacing + return `calc(100vh - ${totalHeight}px)` + }) + + if (updateCssVar) { + watch( + containerMinHeight, + (newHeight) => { + requestAnimationFrame(() => { + document.documentElement.style.setProperty(cssVarName, newHeight) + }) + }, + { immediate: true } + ) + } + + return { + /** 容器最小高度(响应式) */ + containerMinHeight, + /** 头部元素引用 */ + headerRef, + /** 内容头部元素引用 */ + contentHeaderRef, + /** 头部高度(响应式) */ + headerHeight, + /** 内容头部高度(响应式) */ + contentHeaderHeight + } +} + +/** + * 通过 ID 自动查找元素的布局高度管理 + * 适用于无法直接获取元素引用的场景 + * + * @param headerIds 头部元素的 ID 数组 + * @param options 配置选项 + * + * ``` + */ +export function useAutoLayoutHeight( + headerIds: string[] = ['app-header', 'app-content-header'], + options: LayoutHeightOptions = {} +) { + const { extraSpacing = 15, updateCssVar = true, cssVarName = '--art-full-height' } = options + + // 创建元素引用 + const headerRef = ref() + const contentHeaderRef = ref() + + // 使用 VueUse 自动监听元素尺寸变化 + const { height: headerHeight } = useElementSize(headerRef) + const { height: contentHeaderHeight } = useElementSize(contentHeaderRef) + + // 计算容器最小高度(响应式) + const containerMinHeight = computed(() => { + const totalHeight = headerHeight.value + contentHeaderHeight.value + extraSpacing + return `calc(100vh - ${totalHeight}px)` + }) + + if (updateCssVar) { + watch( + containerMinHeight, + (newHeight) => { + requestAnimationFrame(() => { + document.documentElement.style.setProperty(cssVarName, newHeight) + }) + }, + { immediate: true } + ) + } + + // 在 DOM 挂载后查找元素 + onMounted(() => { + if (typeof document !== 'undefined') { + // 使用 nextTick 确保 DOM 完全渲染 + requestAnimationFrame(() => { + const header = document.getElementById(headerIds[0]) + const contentHeader = document.getElementById(headerIds[1]) + + if (header) { + headerRef.value = header + } + if (contentHeader) { + contentHeaderRef.value = contentHeader + } + }) + } + }) + + return { + /** 容器最小高度(响应式) */ + containerMinHeight, + /** 头部元素引用 */ + headerRef, + /** 内容头部元素引用 */ + contentHeaderRef, + /** 头部高度(响应式) */ + headerHeight, + /** 内容头部高度(响应式) */ + contentHeaderHeight + } +} diff --git a/web/apps/portal/src/hooks/core/useTable.ts b/web/apps/portal/src/hooks/core/useTable.ts new file mode 100644 index 0000000..afe9ebe --- /dev/null +++ b/web/apps/portal/src/hooks/core/useTable.ts @@ -0,0 +1,736 @@ +/** + * useTable - 企业级表格数据管理方案 + * + * 功能完整的表格数据管理解决方案,专为后台管理系统设计。 + * 封装了表格开发中的所有常见需求,让你专注于业务逻辑。 + * + * ## 主要功能 + * + * 1. 数据管理 - 自动处理 API 请求、响应转换、加载状态和错误处理 + * 2. 分页控制 - 自动同步分页状态、移动端适配、智能页码边界处理 + * 3. 搜索功能 - 防抖搜索优化、参数管理、一键重置、参数过滤 + * 4. 缓存系统 - 智能请求缓存、多种清理策略、自动过期管理、统计信息 + * 5. 刷新策略 - 提供 5 种刷新方法适配不同业务场景(新增/更新/删除/手动/定时) + * 6. 列配置管理 - 动态显示/隐藏列、列排序、配置持久化、批量操作(可选) + * + * @module useTable + * @author Art Design Pro Team + */ + +import { ref, reactive, computed, onMounted, onUnmounted, nextTick, readonly } from 'vue' +import { useWindowSize } from '@vueuse/core' +import { useTableColumns } from './useTableColumns' +import type { ColumnOption } from '@/types/component' +import { + TableCache, + CacheInvalidationStrategy, + type ApiResponse +} from '../../utils/table/tableCache' +import { + type TableError, + defaultResponseAdapter, + extractTableData, + updatePaginationFromResponse, + createSmartDebounce, + createErrorHandler +} from '../../utils/table/tableUtils' +import { tableConfig } from '../../utils/table/tableConfig' + +// 类型推导工具类型 +type InferApiParams = T extends (params: infer P) => any ? P : never +type InferApiResponse = T extends (params: any) => Promise ? R : never +type InferRecordType = T extends Api.Common.PaginatedResponse ? U : never + +// 优化的配置接口 - 支持自动类型推导 +export interface UseTableConfig< + TApiFn extends (params: any) => Promise = (params: any) => Promise, + TRecord = InferRecordType>, + TParams = InferApiParams, + TResponse = InferApiResponse +> { + // 核心配置 + core: { + /** API 请求函数 */ + apiFn: TApiFn + /** 默认请求参数 */ + apiParams?: Partial + /** 排除 apiParams 中的属性 */ + excludeParams?: string[] + /** 是否立即加载数据 */ + immediate?: boolean + /** 列配置工厂函数 */ + columnsFactory?: () => ColumnOption[] + /** 自定义分页字段映射 */ + paginationKey?: { + /** 当前页码字段名,默认为 'current' */ + current?: string + /** 每页条数字段名,默认为 'size' */ + size?: string + } + } + + // 数据处理 + transform?: { + /** 数据转换函数 */ + dataTransformer?: (data: TRecord[]) => TRecord[] + /** 响应数据适配器 */ + responseAdapter?: (response: TResponse) => ApiResponse + } + + // 性能优化 + performance?: { + /** 是否启用缓存 */ + enableCache?: boolean + /** 缓存时间(毫秒) */ + cacheTime?: number + /** 防抖延迟时间(毫秒) */ + debounceTime?: number + /** 最大缓存条数限制 */ + maxCacheSize?: number + } + + // 生命周期钩子 + hooks?: { + /** 数据加载成功回调(仅网络请求成功时触发) */ + onSuccess?: (data: TRecord[], response: ApiResponse) => void + /** 错误处理回调 */ + onError?: (error: TableError) => void + /** 缓存命中回调(从缓存获取数据时触发) */ + onCacheHit?: (data: TRecord[], response: ApiResponse) => void + /** 加载状态变化回调 */ + onLoading?: (loading: boolean) => void + /** 重置表单回调函数 */ + resetFormCallback?: () => void + } + + // 调试配置 + debug?: { + /** 是否启用日志输出 */ + enableLog?: boolean + /** 日志级别 */ + logLevel?: 'info' | 'warn' | 'error' + } +} + +export function useTable Promise>( + config: UseTableConfig +) { + return useTableImpl(config) +} + +/** + * useTable 的核心实现 - 强大的表格数据管理 Hook + * + * 提供完整的表格解决方案,包括: + * - 数据获取与缓存 + * - 分页控制 + * - 搜索功能 + * - 智能刷新策略 + * - 错误处理 + * - 列配置管理 + */ +function useTableImpl Promise>( + config: UseTableConfig +) { + type TRecord = InferRecordType> + type TParams = InferApiParams + const { + core: { + apiFn, + apiParams = {} as Partial, + excludeParams = [], + immediate = true, + columnsFactory, + paginationKey + }, + transform: { dataTransformer, responseAdapter = defaultResponseAdapter } = {}, + performance: { + enableCache = false, + cacheTime = 5 * 60 * 1000, + debounceTime = 300, + maxCacheSize = 50 + } = {}, + hooks: { onSuccess, onError, onCacheHit, resetFormCallback } = {}, + debug: { enableLog = false } = {} + } = config + + // 分页字段名配置:优先使用传入的配置,否则使用全局配置 + const pageKey = paginationKey?.current || tableConfig.paginationKey.current + const sizeKey = paginationKey?.size || tableConfig.paginationKey.size + + // 响应式触发器,用于手动更新缓存统计信息 + const cacheUpdateTrigger = ref(0) + + // 日志工具函数 + const logger = { + log: (message: string, ...args: unknown[]) => { + if (enableLog) { + console.log(`[useTable] ${message}`, ...args) + } + }, + warn: (message: string, ...args: unknown[]) => { + if (enableLog) { + console.warn(`[useTable] ${message}`, ...args) + } + }, + error: (message: string, ...args: unknown[]) => { + if (enableLog) { + console.error(`[useTable] ${message}`, ...args) + } + } + } + + // 缓存实例 + const cache = enableCache ? new TableCache(cacheTime, maxCacheSize, enableLog) : null + + // 加载状态机 + type LoadingState = 'idle' | 'loading' | 'success' | 'error' + const loadingState = ref('idle') + const loading = computed(() => loadingState.value === 'loading') + + // 错误状态 + const error = ref(null) + + // 表格数据 + const data = ref([]) + + // 请求取消控制器 + let abortController: AbortController | null = null + + // 缓存清理定时器 + let cacheCleanupTimer: NodeJS.Timeout | null = null + + // 搜索参数 + const searchParams = reactive( + Object.assign( + { + [pageKey]: 1, + [sizeKey]: 10 + }, + apiParams || {} + ) as TParams + ) + + // 分页配置 + const pagination = reactive({ + current: ((searchParams as Record)[pageKey] as number) || 1, + size: ((searchParams as Record)[sizeKey] as number) || 10, + total: 0 + }) + + // 移动端分页 (响应式) + const { width } = useWindowSize() + const mobilePagination = computed(() => ({ + ...pagination, + small: width.value < 768 + })) + + // 列配置 + const columnConfig = columnsFactory ? useTableColumns(columnsFactory) : null + const columns = columnConfig?.columns + const columnChecks = columnConfig?.columnChecks + + // 是否有数据 + const hasData = computed(() => data.value.length > 0) + + // 缓存统计信息 + const cacheInfo = computed(() => { + // 依赖触发器,确保缓存变化时重新计算 + void cacheUpdateTrigger.value + if (!cache) return { total: 0, size: '0KB', hitRate: '0 avg hits' } + return cache.getStats() + }) + + // 错误处理函数 + const handleError = createErrorHandler(onError, enableLog) + + // 清理缓存,根据不同的业务场景选择性地清理缓存 + const clearCache = (strategy: CacheInvalidationStrategy, context?: string): void => { + if (!cache) return + + let clearedCount = 0 + + switch (strategy) { + case CacheInvalidationStrategy.CLEAR_ALL: + cache.clear() + logger.log(`清空所有缓存 - ${context || ''}`) + break + + case CacheInvalidationStrategy.CLEAR_CURRENT: + clearedCount = cache.clearCurrentSearch(searchParams) + logger.log(`清空当前搜索缓存 ${clearedCount} 条 - ${context || ''}`) + break + + case CacheInvalidationStrategy.CLEAR_PAGINATION: + clearedCount = cache.clearPagination() + logger.log(`清空分页缓存 ${clearedCount} 条 - ${context || ''}`) + break + + case CacheInvalidationStrategy.KEEP_ALL: + default: + logger.log(`保持缓存不变 - ${context || ''}`) + break + } + // 手动触发缓存状态更新 + cacheUpdateTrigger.value++ + } + + // 获取数据的核心方法 + const fetchData = async ( + params?: Partial, + useCache = enableCache + ): Promise> => { + // 取消上一个请求 + if (abortController) { + abortController.abort() + } + + // 创建新的取消控制器 + const currentController = new AbortController() + abortController = currentController + + // 状态机:进入 loading 状态 + loadingState.value = 'loading' + error.value = null + + try { + let requestParams = Object.assign( + {}, + searchParams, + { + [pageKey]: pagination.current, + [sizeKey]: pagination.size + }, + params || {} + ) as TParams + + // 剔除不需要的参数 + if (excludeParams.length > 0) { + const filteredParams = { ...requestParams } + excludeParams.forEach((key) => { + delete (filteredParams as Record)[key] + }) + requestParams = filteredParams as TParams + } + + // 检查缓存 + if (useCache && cache) { + const cachedItem = cache.get(requestParams) + if (cachedItem) { + data.value = cachedItem.data + updatePaginationFromResponse(pagination, cachedItem.response) + + // 修复:避免重复设置相同的值,防止响应式循环更新 + const paramsRecord = searchParams as Record + if (paramsRecord[pageKey] !== pagination.current) { + paramsRecord[pageKey] = pagination.current + } + if (paramsRecord[sizeKey] !== pagination.size) { + paramsRecord[sizeKey] = pagination.size + } + + // 状态机:缓存命中,进入 success 状态 + loadingState.value = 'success' + + // 缓存命中时触发专门的回调,而不是 onSuccess + if (onCacheHit) { + onCacheHit(cachedItem.data, cachedItem.response) + } + + logger.log(`缓存命中`) + return cachedItem.response + } + } + + const response = await apiFn(requestParams) + + // 检查请求是否被取消 + if (currentController.signal.aborted) { + throw new Error('请求已取消') + } + + // 使用响应适配器转换为标准格式 + const standardResponse = responseAdapter(response) + + // 处理响应数据 + let tableData = extractTableData(standardResponse) + + // 应用数据转换函数 + if (dataTransformer) { + tableData = dataTransformer(tableData) + } + + // 更新状态 + data.value = tableData + updatePaginationFromResponse(pagination, standardResponse) + + // 修复:避免重复设置相同的值,防止响应式循环更新 + const paramsRecord = searchParams as Record + if (paramsRecord[pageKey] !== pagination.current) { + paramsRecord[pageKey] = pagination.current + } + if (paramsRecord[sizeKey] !== pagination.size) { + paramsRecord[sizeKey] = pagination.size + } + + // 缓存数据 + if (useCache && cache) { + cache.set(requestParams, tableData, standardResponse) + // 手动触发缓存状态更新 + cacheUpdateTrigger.value++ + logger.log(`数据已缓存`) + } + + // 状态机:请求成功,进入 success 状态 + loadingState.value = 'success' + + // 成功回调 + if (onSuccess) { + onSuccess(tableData, standardResponse) + } + + return standardResponse + } catch (err) { + if (err instanceof Error && err.message === '请求已取消') { + // 请求被取消,回到 idle 状态 + loadingState.value = 'idle' + return { records: [], total: 0, current: 1, size: 10 } + } + + // 状态机:请求失败,进入 error 状态 + loadingState.value = 'error' + data.value = [] + const tableError = handleError(err, '获取表格数据失败') + throw tableError + } finally { + // 只有当前控制器是活跃的才清空 + if (abortController === currentController) { + abortController = null + } + } + } + + // 获取数据 (保持当前页) + const getData = async (params?: Partial): Promise | void> => { + try { + return await fetchData(params) + } catch { + // 错误已在 fetchData 中处理 + return Promise.resolve() + } + } + + // 分页获取数据 (重置到第一页) - 专门用于搜索场景 + const getDataByPage = async (params?: Partial): Promise | void> => { + pagination.current = 1 + ;(searchParams as Record)[pageKey] = 1 + + // 搜索时清空当前搜索条件的缓存,确保获取最新数据 + clearCache(CacheInvalidationStrategy.CLEAR_CURRENT, '搜索数据') + + try { + return await fetchData(params, false) // 搜索时不使用缓存 + } catch { + // 错误已在 fetchData 中处理 + return Promise.resolve() + } + } + + // 智能防抖搜索函数 + const debouncedGetDataByPage = createSmartDebounce(getDataByPage, debounceTime) + + // 重置搜索参数 + const resetSearchParams = async (): Promise => { + // 取消防抖的搜索 + debouncedGetDataByPage.cancel() + + // 保存分页相关的默认值 + const paramsRecord = searchParams as Record + const defaultPagination = { + [pageKey]: 1, + [sizeKey]: (paramsRecord[sizeKey] as number) || 10 + } + + // 清空所有搜索参数 + Object.keys(searchParams).forEach((key) => { + delete paramsRecord[key] + }) + + // 重新设置默认参数 + Object.assign(searchParams, apiParams || {}, defaultPagination) + + // 重置分页 + pagination.current = 1 + pagination.size = defaultPagination[sizeKey] as number + + // 清空错误状态 + error.value = null + + // 清空缓存 + clearCache(CacheInvalidationStrategy.CLEAR_ALL, '重置搜索') + + // 重新获取数据 + await getData() + + // 执行重置回调 + if (resetFormCallback) { + await nextTick() + resetFormCallback() + } + } + + // 防重复调用的标志 + let isCurrentChanging = false + + // 处理分页大小变化 + const handleSizeChange = async (newSize: number): Promise => { + if (newSize <= 0) return + + debouncedGetDataByPage.cancel() + + const paramsRecord = searchParams as Record + pagination.size = newSize + pagination.current = 1 + paramsRecord[sizeKey] = newSize + paramsRecord[pageKey] = 1 + + clearCache(CacheInvalidationStrategy.CLEAR_CURRENT, '分页大小变化') + + await getData() + } + + // 处理当前页变化 + const handleCurrentChange = async (newCurrent: number): Promise => { + if (newCurrent <= 0) return + + // 修复:防止重复调用 + if (isCurrentChanging) { + return + } + + // 修复:如果当前页没有变化,不需要重新请求 + if (pagination.current === newCurrent) { + logger.log('分页页码未变化,跳过请求') + return + } + + try { + isCurrentChanging = true + + // 修复:只更新必要的状态 + const paramsRecord = searchParams as Record + pagination.current = newCurrent + // 只有当 searchParams 的分页字段与新值不同时才更新 + if (paramsRecord[pageKey] !== newCurrent) { + paramsRecord[pageKey] = newCurrent + } + + await getData() + } finally { + isCurrentChanging = false + } + } + + // 针对不同业务场景的刷新方法 + + // 新增后刷新:回到第一页并清空分页缓存(适用于新增数据后) + const refreshCreate = async (): Promise => { + debouncedGetDataByPage.cancel() + pagination.current = 1 + ;(searchParams as Record)[pageKey] = 1 + clearCache(CacheInvalidationStrategy.CLEAR_PAGINATION, '新增数据') + await getData() + } + + // 更新后刷新:保持当前页,仅清空当前搜索缓存(适用于更新数据后) + const refreshUpdate = async (): Promise => { + clearCache(CacheInvalidationStrategy.CLEAR_CURRENT, '编辑数据') + await getData() + } + + // 删除后刷新:智能处理页码,避免空页面(适用于删除数据后) + const refreshRemove = async (): Promise => { + const { current } = pagination + + // 清除缓存并获取最新数据 + clearCache(CacheInvalidationStrategy.CLEAR_CURRENT, '删除数据') + await getData() + + // 如果当前页为空且不是第一页,回到上一页 + if (data.value.length === 0 && current > 1) { + pagination.current = current - 1 + ;(searchParams as Record)[pageKey] = current - 1 + await getData() + } + } + + // 全量刷新:清空所有缓存,重新获取数据(适用于手动刷新按钮) + const refreshData = async (): Promise => { + debouncedGetDataByPage.cancel() + clearCache(CacheInvalidationStrategy.CLEAR_ALL, '手动刷新') + await getData() + } + + // 轻量刷新:仅清空当前搜索条件的缓存,保持分页状态(适用于定时刷新) + const refreshSoft = async (): Promise => { + clearCache(CacheInvalidationStrategy.CLEAR_CURRENT, '软刷新') + await getData() + } + + // 取消当前请求 + const cancelRequest = (): void => { + if (abortController) { + abortController.abort() + } + debouncedGetDataByPage.cancel() + } + + // 清空数据 + const clearData = (): void => { + data.value = [] + error.value = null + clearCache(CacheInvalidationStrategy.CLEAR_ALL, '清空数据') + } + + // 清理已过期的缓存条目,释放内存空间 + const clearExpiredCache = (): number => { + if (!cache) return 0 + const cleanedCount = cache.cleanupExpired() + if (cleanedCount > 0) { + // 手动触发缓存状态更新 + cacheUpdateTrigger.value++ + } + return cleanedCount + } + + // 设置定期清理过期缓存 + if (enableCache && cache) { + cacheCleanupTimer = setInterval(() => { + const cleanedCount = cache.cleanupExpired() + if (cleanedCount > 0) { + logger.log(`自动清理 ${cleanedCount} 条过期缓存`) + // 手动触发缓存状态更新 + cacheUpdateTrigger.value++ + } + }, cacheTime / 2) // 每半个缓存周期清理一次 + } + + // 挂载时自动加载数据 + if (immediate) { + onMounted(async () => { + await getData() + }) + } + + // 组件卸载时彻底清理 + onUnmounted(() => { + cancelRequest() + if (cache) { + cache.clear() + } + if (cacheCleanupTimer) { + clearInterval(cacheCleanupTimer) + } + }) + + // 优化的返回值结构 + return { + // 数据相关 + /** 表格数据 */ + data, + /** 数据加载状态 */ + loading: readonly(loading), + /** 错误状态 */ + error: readonly(error), + /** 数据是否为空 */ + isEmpty: computed(() => data.value.length === 0), + /** 是否有数据 */ + hasData, + + // 分页相关 + /** 分页状态信息 */ + pagination: readonly(pagination), + /** 移动端分页配置 */ + paginationMobile: mobilePagination, + /** 页面大小变化处理 */ + handleSizeChange, + /** 当前页变化处理 */ + handleCurrentChange, + + // 搜索相关 - 统一前缀 + /** 搜索参数 */ + searchParams, + /** 重置搜索参数 */ + resetSearchParams, + + // 数据操作 - 更明确的操作意图 + /** 加载数据 */ + fetchData: getData, + /** 获取数据 */ + getData: getDataByPage, + /** 获取数据(防抖) */ + getDataDebounced: debouncedGetDataByPage, + /** 清空数据 */ + clearData, + + // 刷新策略 + /** 全量刷新:清空所有缓存,重新获取数据(适用于手动刷新按钮) */ + refreshData, + /** 轻量刷新:仅清空当前搜索条件的缓存,保持分页状态(适用于定时刷新) */ + refreshSoft, + /** 新增后刷新:回到第一页并清空分页缓存(适用于新增数据后) */ + refreshCreate, + /** 更新后刷新:保持当前页,仅清空当前搜索缓存(适用于更新数据后) */ + refreshUpdate, + /** 删除后刷新:智能处理页码,避免空页面(适用于删除数据后) */ + refreshRemove, + + // 缓存控制 + /** 缓存统计信息 */ + cacheInfo, + /** 清除缓存,根据不同的业务场景选择性地清理缓存: */ + clearCache, + // 支持4种清理策略 + // clearCache(CacheInvalidationStrategy.CLEAR_ALL, '手动刷新') // 清空所有缓存 + // clearCache(CacheInvalidationStrategy.CLEAR_CURRENT, '搜索数据') // 只清空当前搜索条件的缓存 + // clearCache(CacheInvalidationStrategy.CLEAR_PAGINATION, '新增数据') // 清空分页相关缓存 + // clearCache(CacheInvalidationStrategy.KEEP_ALL, '保持缓存') // 不清理任何缓存 + /** 清理已过期的缓存条目,释放内存空间 */ + clearExpiredCache, + + // 请求控制 + /** 取消当前请求 */ + cancelRequest, + + // 列配置 (如果提供了 columnsFactory) + ...(columnConfig && { + /** 表格列配置 */ + columns, + /** 列显示控制 */ + columnChecks, + /** 新增列 */ + addColumn: columnConfig.addColumn, + /** 删除列 */ + removeColumn: columnConfig.removeColumn, + /** 切换列显示状态 */ + toggleColumn: columnConfig.toggleColumn, + /** 更新列配置 */ + updateColumn: columnConfig.updateColumn, + /** 批量更新列配置 */ + batchUpdateColumns: columnConfig.batchUpdateColumns, + /** 重新排序列 */ + reorderColumns: columnConfig.reorderColumns, + /** 获取指定列配置 */ + getColumnConfig: columnConfig.getColumnConfig, + /** 获取所有列配置 */ + getAllColumns: columnConfig.getAllColumns, + /** 重置所有列配置到默认状态 */ + resetColumns: columnConfig.resetColumns + }) + } +} + +// 重新导出类型和枚举,方便使用 +export { CacheInvalidationStrategy } from '../../utils/table/tableCache' +export type { ApiResponse, CacheItem } from '../../utils/table/tableCache' +export type { BaseRequestParams, TableError } from '../../utils/table/tableUtils' diff --git a/web/apps/portal/src/hooks/core/useTableColumns.ts b/web/apps/portal/src/hooks/core/useTableColumns.ts new file mode 100644 index 0000000..84b6e13 --- /dev/null +++ b/web/apps/portal/src/hooks/core/useTableColumns.ts @@ -0,0 +1,312 @@ +/** + * useTableColumns - 表格列配置管理 + * + * 提供动态的表格列配置管理能力,支持运行时灵活控制列的显示、隐藏、排序等操作。 + * 通常与 useTable 配合使用,为表格提供完整的列管理功能。 + * + * ## 主要功能 + * + * 1. 列显示控制 - 动态显示/隐藏列,支持批量操作 + * 2. 列排序 - 拖拽或编程方式重新排列列顺序 + * 3. 列配置管理 - 新增、删除、更新列配置 + * 4. 特殊列支持 - 自动处理 selection、expand、index 等特殊列 + * 5. 状态持久化 - 保持列的显示状态,支持重置到初始状态 + * + * ## 使用示例 + * + * ```typescript + * const { columns, columnChecks, toggleColumn, reorderColumns } = useTableColumns(() => [ + * { prop: 'name', label: '姓名', visible: true }, + * { prop: 'email', label: '邮箱', visible: true }, + * { prop: 'status', label: '状态', visible: false } + * ]) + * + * // 切换列显示 + * toggleColumn('email', false) + * + * // 重新排序 + * reorderColumns(0, 2) + * ``` + * + * @module useTableColumns + * @author Art Design Pro Team + */ + +import { ref, computed, watch } from 'vue' +import { $t } from '@/locales' +import type { ColumnOption } from '@/types/component' + +/** + * 特殊列类型 + */ +const SPECIAL_COLUMNS: Record = { + selection: { prop: '__selection__', label: $t('table.column.selection') }, + expand: { prop: '__expand__', label: $t('table.column.expand') }, + index: { prop: '__index__', label: $t('table.column.index') } +} + +/** + * 获取列的唯一标识 + */ +export const getColumnKey = (col: ColumnOption) => + SPECIAL_COLUMNS[col.type as keyof typeof SPECIAL_COLUMNS]?.prop ?? (col.prop as string) + +/** + * 获取列的显示状态 + * 优先使用 visible 字段,如果不存在则使用 checked 字段 + */ +export const getColumnVisibility = (col: ColumnOption): boolean => { + // visible 优先级高于 checked + if (col.visible !== undefined) { + return col.visible + } + // 如果 visible 未定义,使用 checked,默认为 true + return col.checked ?? true +} + +/** + * 获取列的检查状态 + */ +export const getColumnChecks = (columns: ColumnOption[]) => + columns.map((col) => { + const special = col.type && SPECIAL_COLUMNS[col.type] + const visibility = getColumnVisibility(col) + + if (special) { + return { ...col, prop: special.prop, label: special.label, checked: true, visible: true } + } + return { ...col, checked: visibility, visible: visibility } + }) + +/** + * 动态列配置接口 + */ +export interface DynamicColumnConfig { + /** + * 新增列(支持单个或批量) + * @param column 列配置或列配置数组 + * @param index 可选的插入位置,默认末尾(批量时为第一个列的位置) + */ + addColumn: (column: ColumnOption | ColumnOption[], index?: number) => void + /** + * 删除列(支持单个或批量) + * @param prop 列的唯一标识或标识数组 + */ + removeColumn: (prop: string | string[]) => void + /** + * 切换列显示状态(支持单个或批量) + * @param prop 列的唯一标识或标识数组 + * @param visible 可选的显示状态,默认取反 + */ + toggleColumn: (prop: string | string[], visible?: boolean) => void + + /** + * 更新列(支持单个或批量) + * @param prop 列的唯一标识或更新配置数组 + * @param updates 列配置更新(当 prop 为字符串时使用) + */ + updateColumn: ( + prop: string | Array<{ prop: string; updates: Partial> }>, + updates?: Partial> + ) => void + /** + * 批量更新列(兼容旧版本,推荐使用 updateColumn 的数组模式) + * @param updates 列更新配置 + * @deprecated 推荐使用 updateColumn 的数组模式 + */ + batchUpdateColumns: (updates: Array<{ prop: string; updates: Partial> }>) => void + /** + * 重新排序列 + * @param fromIndex 源索引 + * @param toIndex 目标索引 + */ + reorderColumns: (fromIndex: number, toIndex: number) => void + /** + * 获取列配置 + * @param prop 列的唯一标识 + * @returns 列配置 + */ + getColumnConfig: (prop: string) => ColumnOption | undefined + /** + * 获取所有列配置 + * @returns 所有列配置 + */ + getAllColumns: () => ColumnOption[] + /** + * 重置所有列 + */ + resetColumns: () => void +} + +export function useTableColumns( + columnsFactory: () => ColumnOption[] +): { + columns: any + columnChecks: any +} & DynamicColumnConfig { + const dynamicColumns = ref[]>(columnsFactory()) + const columnChecks = ref[]>(getColumnChecks(dynamicColumns.value)) + + // 当 dynamicColumns 变动时,重新生成 columnChecks 且保留已存在的显示状态 + watch( + dynamicColumns, + (newCols) => { + const visibilityMap = new Map( + columnChecks.value.map((c) => [getColumnKey(c), getColumnVisibility(c)]) + ) + const newChecks = getColumnChecks(newCols).map((c) => { + const key = getColumnKey(c) + const visibility = visibilityMap.has(key) ? visibilityMap.get(key) : getColumnVisibility(c) + return { + ...c, + checked: visibility, + visible: visibility + } + }) + columnChecks.value = newChecks + }, + { deep: true } + ) + + // 当前显示列(基于 columnChecks 的 checked 或 visible) + const columns = computed(() => { + const colMap = new Map(dynamicColumns.value.map((c) => [getColumnKey(c), c])) + return columnChecks.value + .filter((c) => getColumnVisibility(c)) + .map((c) => colMap.get(getColumnKey(c))) + .filter(Boolean) as ColumnOption[] + }) + + // 支持 updater 返回新数组或直接在传入数组上 mutate + const setDynamicColumns = (updater: (cols: ColumnOption[]) => void | ColumnOption[]) => { + const copy = [...dynamicColumns.value] + const result = updater(copy) + dynamicColumns.value = Array.isArray(result) ? result : copy + } + + return { + columns, + columnChecks, + + /** + * 新增列(支持单个或批量) + */ + addColumn: (column: ColumnOption | ColumnOption[], index?: number) => + setDynamicColumns((cols) => { + const next = [...cols] + const columnsToAdd = Array.isArray(column) ? column : [column] + const insertIndex = + typeof index === 'number' && index >= 0 && index <= next.length ? index : next.length + + // 批量插入 + next.splice(insertIndex, 0, ...columnsToAdd) + return next + }), + + /** + * 删除列(支持单个或批量) + */ + removeColumn: (prop: string | string[]) => + setDynamicColumns((cols) => { + const propsToRemove = Array.isArray(prop) ? prop : [prop] + return cols.filter((c) => !propsToRemove.includes(getColumnKey(c))) + }), + + /** + * 更新列(支持单个或批量) + */ + updateColumn: ( + prop: string | Array<{ prop: string; updates: Partial> }>, + updates?: Partial> + ) => { + // 批量模式:prop 是数组 + if (Array.isArray(prop)) { + setDynamicColumns((cols) => { + const map = new Map(prop.map((u) => [u.prop, u.updates])) + return cols.map((c) => { + const key = getColumnKey(c) + const upd = map.get(key) + return upd ? { ...c, ...upd } : c + }) + }) + } + // 单个模式:prop 是字符串 + else if (updates) { + setDynamicColumns((cols) => + cols.map((c) => (getColumnKey(c) === prop ? { ...c, ...updates } : c)) + ) + } + }, + + /** + * 切换列显示状态(支持单个或批量) + */ + toggleColumn: (prop: string | string[], visible?: boolean) => { + const propsToToggle = Array.isArray(prop) ? prop : [prop] + const next = [...columnChecks.value] + + propsToToggle.forEach((p) => { + const i = next.findIndex((c) => getColumnKey(c) === p) + if (i > -1) { + const currentVisibility = getColumnVisibility(next[i]) + const newVisibility = visible ?? !currentVisibility + // 同时更新 checked 和 visible 以保持兼容性 + next[i] = { ...next[i], checked: newVisibility, visible: newVisibility } + } + }) + + columnChecks.value = next + }, + + /** + * 重置所有列 + */ + resetColumns: () => { + dynamicColumns.value = columnsFactory() + }, + + /** + * 批量更新列(兼容旧版本) + * @deprecated 推荐使用 updateColumn 的数组模式 + */ + batchUpdateColumns: (updates) => + setDynamicColumns((cols) => { + const map = new Map(updates.map((u) => [u.prop, u.updates])) + return cols.map((c) => { + const key = getColumnKey(c) + const upd = map.get(key) + return upd ? { ...c, ...upd } : c + }) + }), + + /** + * 重新排序列 + */ + reorderColumns: (fromIndex: number, toIndex: number) => + setDynamicColumns((cols) => { + if ( + fromIndex < 0 || + fromIndex >= cols.length || + toIndex < 0 || + toIndex >= cols.length || + fromIndex === toIndex + ) { + return cols + } + const next = [...cols] + const [moved] = next.splice(fromIndex, 1) + next.splice(toIndex, 0, moved) + return next + }), + + /** + * 获取列配置 + */ + getColumnConfig: (prop: string) => dynamicColumns.value.find((c) => getColumnKey(c) === prop), + + /** + * 获取所有列配置 + */ + getAllColumns: () => [...dynamicColumns.value] + } +} diff --git a/web/apps/portal/src/hooks/core/useTableHeight.ts b/web/apps/portal/src/hooks/core/useTableHeight.ts new file mode 100644 index 0000000..8fdf6da --- /dev/null +++ b/web/apps/portal/src/hooks/core/useTableHeight.ts @@ -0,0 +1,105 @@ +/** + * useTableHeight - 表格高度自动计算 + * + * 自动计算表格容器的最佳高度,确保表格在不同布局场景下都能正确显示。 + * 根据表格头部、分页器等元素的高度动态调整容器高度,避免出现滚动条或布局错乱。 + * + * ## 主要功能 + * + * 1. 动态高度计算 - 根据表格头部、分页器高度自动计算容器高度 + * 2. 响应式更新 - 配置变化时自动重新计算高度 + * 3. 灵活配置 - 支持自定义各部分高度和间距 + * 4. 智能适配 - 无额外元素时自动使用 100% 高度 + * + * @module useTableHeight + * @author Art Design Pro Team + */ + +import { computed, type Ref } from 'vue' + +/** + * 表格高度计算器配置接口 + */ +interface TableHeightOptions { + /** 是否显示表格头部 */ + showTableHeader: Ref + /** 分页器高度 */ + paginationHeight: Ref + /** 表格头部高度 */ + tableHeaderHeight: Ref + /** 分页器间距 */ + paginationSpacing: Ref +} + +/** + * 表格高度计算器类 + */ +class TableHeightCalculator { + // 常量配置 + private static readonly DEFAULT_TABLE_HEADER_HEIGHT = 44 + private static readonly TABLE_HEADER_SPACING = 12 + + constructor(private options: TableHeightOptions) {} + + /** + * 计算容器高度 + */ + calculate(): { height: string } { + const offset = this.calculateOffset() + return { + height: offset === 0 ? '100%' : `calc(100% - ${offset}px)` + } + } + + /** + * 计算偏移量 + */ + private calculateOffset(): number { + if (!this.options.showTableHeader.value) { + return this.calculatePaginationOffset() + } + + const headerHeight = this.getHeaderHeight() + const paginationOffset = this.calculatePaginationOffset() + + return headerHeight + paginationOffset + TableHeightCalculator.TABLE_HEADER_SPACING + } + + /** + * 获取表格头部高度 + */ + private getHeaderHeight(): number { + return this.options.tableHeaderHeight.value || TableHeightCalculator.DEFAULT_TABLE_HEADER_HEIGHT + } + + /** + * 计算分页器偏移量 + */ + private calculatePaginationOffset(): number { + const { paginationHeight, paginationSpacing } = this.options + return paginationHeight.value === 0 ? 0 : paginationHeight.value + paginationSpacing.value + } +} + +/** + * 表格高度计算 Hook + * + * 提供表格容器高度的自动计算功能,支持: + * - 表格头部高度 + * - 分页器高度 + * - 动态间距计算 + * + * @param options 配置选项 + * @returns 容器高度计算结果 + */ +export function useTableHeight(options: TableHeightOptions) { + const containerHeight = computed(() => { + const calculator = new TableHeightCalculator(options) + return calculator.calculate() + }) + + return { + /** 容器高度样式对象 */ + containerHeight + } +} diff --git a/web/apps/portal/src/hooks/core/useTheme.ts b/web/apps/portal/src/hooks/core/useTheme.ts new file mode 100644 index 0000000..187c3e0 --- /dev/null +++ b/web/apps/portal/src/hooks/core/useTheme.ts @@ -0,0 +1,174 @@ +/** + * useTheme - 系统主题管理 + * + * 提供完整的主题切换和管理功能,支持亮色、暗色和自动模式。 + * 自动处理主题切换时的过渡效果,确保切换流畅无闪烁。 + * + * ## 主要功能 + * + * 1. 主题切换 - 支持亮色、暗色、自动三种主题模式 + * 2. 自动模式 - 根据系统偏好自动切换主题 + * 3. 颜色适配 - 自动调整主题色的明暗变体(9 个层级) + * 4. 过渡优化 - 切换时临时禁用过渡效果,避免闪烁 + * 5. 状态持久化 - 主题设置自动保存到 store + * + * ## 使用示例 + * + * ```typescript + * const { switchThemeStyles } = useTheme() + * + * // 切换到暗色主题 + * switchThemeStyles(SystemThemeEnum.DARK) + * + * // 切换到亮色主题 + * switchThemeStyles(SystemThemeEnum.LIGHT) + * + * // 切换到自动模式(跟随系统) + * switchThemeStyles(SystemThemeEnum.AUTO) + * ``` + * + * @module useTheme + * @author Art Design Pro Team + */ + +import { useSettingStore } from '@/store/modules/setting' +import { SystemThemeEnum } from '@/enums/appEnum' +import AppConfig from '@/config' +import { SystemThemeTypes } from '@/types/store' +import { getDarkColor, getLightColor, setElementThemeColor } from '@/utils/ui' +import { usePreferredDark } from '@vueuse/core' +import { watch } from 'vue' + +export function useTheme() { + const settingStore = useSettingStore() + + // 禁用过渡效果 + const disableTransitions = () => { + const style = document.createElement('style') + style.setAttribute('id', 'disable-transitions') + style.textContent = '* { transition: none !important; }' + document.head.appendChild(style) + } + + // 启用过渡效果 + const enableTransitions = () => { + const style = document.getElementById('disable-transitions') + if (style) { + style.remove() + } + } + + // 设置系统主题 + const setSystemTheme = (theme: SystemThemeEnum, themeMode?: SystemThemeEnum) => { + // 临时禁用过渡效果 + disableTransitions() + + const el = document.getElementsByTagName('html')[0] + const isDark = theme === SystemThemeEnum.DARK + + if (!themeMode) { + themeMode = theme + } + + const currentTheme = AppConfig.systemThemeStyles[theme as keyof SystemThemeTypes] + + if (currentTheme) { + el.setAttribute('class', currentTheme.className) + } + + // 设置按钮颜色加深或变浅 + const primary = settingStore.systemThemeColor + + for (let i = 1; i <= 9; i++) { + document.documentElement.style.setProperty( + `--el-color-primary-light-${i}`, + isDark ? `${getDarkColor(primary, i / 10)}` : `${getLightColor(primary, i / 10)}` + ) + } + + // 更新store中的主题设置 + settingStore.setGlopTheme(theme, themeMode) + + // 使用 requestAnimationFrame 确保在下一帧恢复过渡效果 + requestAnimationFrame(() => { + requestAnimationFrame(() => { + enableTransitions() + }) + }) + } + + // 使用 VueUse 的 usePreferredDark 检测系统主题偏好 + const prefersDark = usePreferredDark() + + // 自动设置系统主题 + const setSystemAutoTheme = () => { + const theme = prefersDark.value ? SystemThemeEnum.DARK : SystemThemeEnum.LIGHT + setSystemTheme(theme, SystemThemeEnum.AUTO) + } + + // 切换主题 + const switchThemeStyles = (theme: SystemThemeEnum) => { + if (theme === SystemThemeEnum.AUTO) { + setSystemAutoTheme() + } else { + setSystemTheme(theme) + } + } + + return { + setSystemTheme, + setSystemAutoTheme, + switchThemeStyles, + prefersDark + } +} + +/** + * 初始化主题系统 + */ +export function initializeTheme() { + const settingStore = useSettingStore() + const prefersDark = usePreferredDark() + + // 根据系统偏好应用主题 + const applyThemeByMode = () => { + const el = document.getElementsByTagName('html')[0] + let actualTheme = settingStore.systemThemeType + + // 如果是 AUTO 模式,检测系统偏好 + if (settingStore.systemThemeMode === SystemThemeEnum.AUTO) { + actualTheme = prefersDark.value ? SystemThemeEnum.DARK : SystemThemeEnum.LIGHT + // 更新实际应用的主题类型 + settingStore.systemThemeType = actualTheme + } + + // 设置主题 class + const currentTheme = AppConfig.systemThemeStyles[actualTheme as keyof SystemThemeTypes] + if (currentTheme) { + el.setAttribute('class', currentTheme.className) + } + + // 设置主题颜色 + setElementThemeColor(settingStore.systemThemeColor) + + // 设置圆角 + document.documentElement.style.setProperty('--custom-radius', `${settingStore.customRadius}rem`) + } + + // 应用主题 + applyThemeByMode() + + // 如果是 AUTO 模式,监听系统主题变化(使用 VueUse 的响应式特性) + if (settingStore.systemThemeMode === SystemThemeEnum.AUTO) { + watch( + prefersDark, + () => { + // 只有在 AUTO 模式下才响应系统主题变化 + if (settingStore.systemThemeMode === SystemThemeEnum.AUTO) { + applyThemeByMode() + } + }, + { immediate: false } + ) + } +} diff --git a/web/apps/portal/src/hooks/index.ts b/web/apps/portal/src/hooks/index.ts new file mode 100644 index 0000000..472b09c --- /dev/null +++ b/web/apps/portal/src/hooks/index.ts @@ -0,0 +1,32 @@ +// 通用功能集合 +export { useCommon } from './core/useCommon' + +// 应用模式 +export { useAppMode } from './core/useAppMode' + +// 权限控制 +export { useAuth } from './core/useAuth' + +// 表格数据管理方案 +export { useTable } from './core/useTable' + +// 表格列配置管理 +export { useTableColumns } from './core/useTableColumns' + +// 主题相关 +export { useTheme } from './core/useTheme' + +// 礼花+文字滚动 +export { useCeremony } from './core/useCeremony' + +// 顶栏快速入口 +export { useFastEnter } from './core/useFastEnter' + +// 顶栏功能管理 +export { useHeaderBar } from './core/useHeaderBar' + +// 图表相关 +export { useChart, useChartComponent, useChartOps } from './core/useChart' + +// 布局高度 +export { useLayoutHeight, useAutoLayoutHeight } from './core/useLayoutHeight' diff --git a/web/apps/portal/src/locales/index.ts b/web/apps/portal/src/locales/index.ts new file mode 100644 index 0000000..36c2648 --- /dev/null +++ b/web/apps/portal/src/locales/index.ts @@ -0,0 +1,123 @@ +/** + * 国际化配置 + * + * 基于 vue-i18n 实现的多语言国际化解决方案。 + * 支持中文和英文切换,自动从本地存储恢复用户的语言偏好。 + * + * ## 主要功能 + * + * - 多语言支持 - 支持中文(简体)和英文两种语言 + * - 语言切换 - 运行时动态切换语言,无需刷新页面 + * - 持久化存储 - 自动保存和恢复用户的语言偏好 + * - 全局注入 - 在任何组件中都可以使用 $t 函数进行翻译 + * - 类型安全 - 提供 TypeScript 类型支持 + * + * ## 支持的语言 + * + * - zh: 简体中文 + * - en: English + * + * @module locales + * @author Art Design Pro Team + */ + +import { createI18n } from 'vue-i18n' +import type { I18n, I18nOptions } from 'vue-i18n' +import { LanguageEnum } from '@/enums/appEnum' +import { getSystemStorage } from '@/utils/storage' +import { StorageKeyManager } from '@/utils/storage/storage-key-manager' + +// 同步导入语言文件 +import enMessages from './langs/en.json' +import zhMessages from './langs/zh.json' + +/** + * 存储键管理器实例 + */ +const storageKeyManager = new StorageKeyManager() + +/** + * 语言消息对象 + */ +const messages = { + [LanguageEnum.EN]: enMessages, + [LanguageEnum.ZH]: zhMessages +} + +/** + * 语言选项列表 + * 用于语言切换下拉框 + */ +export const languageOptions = [ + { value: LanguageEnum.ZH, label: '简体中文' }, + { value: LanguageEnum.EN, label: 'English' } +] + +/** + * 从存储中获取语言设置 + * @returns 语言设置,如果获取失败则返回默认语言 + */ +const getDefaultLanguage = (): LanguageEnum => { + // 尝试从版本化的存储中获取语言设置 + try { + const storageKey = storageKeyManager.getStorageKey('user') + const userStore = localStorage.getItem(storageKey) + + if (userStore) { + const { language } = JSON.parse(userStore) + if (language && Object.values(LanguageEnum).includes(language)) { + return language + } + } + } catch (error) { + console.warn('[i18n] 从版本化存储获取语言设置失败:', error) + } + + // 尝试从系统存储中获取语言设置 + try { + const sys = getSystemStorage() + if (sys) { + const { user } = JSON.parse(sys) + if (user?.language && Object.values(LanguageEnum).includes(user.language)) { + return user.language + } + } + } catch (error) { + console.warn('[i18n] 从系统存储获取语言设置失败:', error) + } + + // 返回默认语言 + console.debug('[i18n] 使用默认语言:', LanguageEnum.ZH) + return LanguageEnum.ZH +} + +/** + * i18n 配置选项 + */ +const i18nOptions: I18nOptions = { + locale: getDefaultLanguage(), + legacy: false, + globalInjection: true, + fallbackLocale: LanguageEnum.ZH, + messages +} + +/** + * i18n 实例 + */ +const i18n: I18n = createI18n(i18nOptions) + +/** + * 翻译函数类型 + */ +interface Translation { + (key: string): string +} + +/** + * 全局翻译函数 + * 可在任何地方使用,无需导入 useI18n + */ +export const $t = i18n.global.t as Translation + +export default i18n diff --git a/web/apps/portal/src/locales/langs/en.json b/web/apps/portal/src/locales/langs/en.json new file mode 100644 index 0000000..6dc27ea --- /dev/null +++ b/web/apps/portal/src/locales/langs/en.json @@ -0,0 +1,296 @@ +{ + "httpMsg": { + "unauthorized": "Unauthorized access, please login again", + "forbidden": "Access to this resource is forbidden", + "notFound": "The requested resource does not exist", + "methodNotAllowed": "Request method not allowed", + "requestTimeout": "Request timeout, please try again later", + "internalServerError": "Internal server error, please try again later", + "badGateway": "Bad gateway error, please try again later", + "serviceUnavailable": "Service temporarily unavailable, please try again later", + "gatewayTimeout": "Gateway timeout, please try again later", + "requestCancelled": "Request cancelled", + "networkError": "Network connection error, please check your connection", + "requestFailed": "Request failed", + "requestConfigError": "Request configuration error" + }, + "topBar": { + "search": { + "title": "Search" + }, + "user": { + "userCenter": "User center", + "docs": "Document", + "github": "Github", + "lockScreen": "Lock screen", + "logout": "Log out" + }, + "guide": { + "title": "Click here to view", + "theme": "Theme style", + "menu": "Open top menu", + "description": "More configurations" + } + }, + "common": { + "tips": "Prompt", + "cancel": "Cancel", + "confirm": "Confirm", + "logOutTips": "Do you want to log out?" + }, + "search": { + "placeholder": "Search page", + "historyTitle": "Search history", + "switchKeydown": "Navigate", + "selectKeydown": "Select", + "exitKeydown": "Close" + }, + "setting": { + "menuType": { + "title": "Menu Layout", + "list": [ + "Vertical", + "Horizontal", + "Mixed", + "Dual" + ] + }, + "theme": { + "title": "Theme Style", + "list": [ + "Light", + "Dark", + "System" + ] + }, + "menu": { + "title": "Menu Style" + }, + "color": { + "title": "Theme Color" + }, + "box": { + "title": "Box Style", + "list": [ + "Border", + "Shadow" + ] + }, + "container": { + "title": "Container Width", + "list": [ + "Full", + "Boxed" + ] + }, + "basics": { + "title": "Basic Config", + "list": { + "multiTab": "Show work tab", + "accordion": "Sidebar opens accordion", + "collapseSidebar": "Show sidebar button", + "reloadPage": "Show reload page button", + "fastEnter": "Show fast enter", + "breadcrumb": "Show crumb navigation", + "language": "Show multilingual selection", + "progressBar": "Show top progress bar", + "weakMode": "Color Weakness Mode", + "watermark": "Global watermark", + "menuWidth": "Menu width", + "tabStyle": "Tab style", + "pageTransition": "Page animation", + "borderRadius": "Custom radius" + } + }, + "tabStyle": { + "default": "Default", + "card": "Card", + "google": "Chrome" + }, + "transition": { + "list": { + "none": "None", + "fade": "Fade", + "slideLeft": "Slide Left", + "slideBottom": "Slide Bottom", + "slideTop": "Slide Top" + } + }, + "actions": { + "resetConfig": "Reset Config", + "copyConfig": "Copy Config", + "copySuccess": "Configuration copied to clipboard, paste it into src/config/setting.ts file", + "copyFailed": "Copy failed, please try again", + "resetFailed": "Reset failed, please refresh the page and try again" + } + }, + "notice": { + "title": "Notice", + "btnRead": "Mark as read", + "bar": [ + "Notice", + "Message", + "Todo" + ], + "text": [ + "No" + ], + "viewAll": "View all" + }, + "worktab": { + "btn": { + "refresh": "Refresh", + "fixed": "Fixed", + "unfixed": "Unfixed", + "closeLeft": "Close left", + "closeRight": "Close right", + "closeOther": "Close other", + "closeAll": "Close all" + } + }, + "login": { + "leftView": { + "title": "Unified LLM Access Gateway", + "subTitle": "Unified auth · rate limiting · quota · security audit" + }, + "title": "Welcome back", + "subTitle": "Secure access · unified governance", + "tagline": "Encrypted transport · zero-trust access", + "toggleTheme": "Toggle theme", + "roles": { + "super": "Super Admin", + "admin": "Admin", + "user": "User" + }, + "placeholder": { + "username": "Please enter your account", + "password": "Please enter your password" + }, + "rememberPwd": "Remember password", + "securityBadge": "AES-256 encryption · TLS 1.3 · login audit", + "forgetPwd": "Forgot password", + "btnText": "Login", + "noAccount": "No account yet?", + "register": "Register", + "success": { + "title": "Login successful", + "message": "Welcome back" + } + }, + "forgetPassword": { + "title": "Forgot password?", + "subTitle": "Enter your email to reset your password", + "placeholder": "Please enter your email", + "submitBtnText": "Submit", + "backBtnText": "Back" + }, + "register": { + "title": "Create account", + "subTitle": "Welcome to join us, please fill in the following information to complete the registration", + "placeholder": { + "username": "Please enter your account", + "password": "Please enter your password", + "confirmPassword": "Please enter your password again" + }, + "rule": { + "confirmPasswordRequired": "Please enter your password again", + "passwordMismatch": "The two passwords are inconsistent!", + "usernameLength": "The length is 3 to 20 characters", + "passwordLength": "The password length cannot be less than 6 digits", + "agreementRequired": "Please agree to the privacy policy" + }, + "agreeText": "I agree", + "privacyPolicy": "Privacy policy", + "submitBtnText": "Register", + "hasAccount": "Already have an account?", + "toLogin": "To login" + }, + "lockScreen": { + "pwdError": "Password error", + "lock": { + "inputPlaceholder": "Please input lock screen password", + "btnText": "Lock" + }, + "unlock": { + "inputPlaceholder": "Please input unlock password", + "btnText": "Unlock", + "backBtnText": "Back to login" + } + }, + "greeting": { + "dawn": "Good morning!", + "morning": "Good morning!", + "afternoon": "Good afternoon!", + "evening": "Good evening!" + }, + "exceptionPage": { + "403": "Sorry, you do not have permission to access this page", + "404": "Sorry, the page you are trying to access does not exist", + "500": "Sorry, there was an error on the server", + "gohome": "Go Home" + }, + "menus": { + "login": { + "title": "Login" + }, + "register": { + "title": "Register" + }, + "forgetPassword": { + "title": "Forget Password" + }, + "outside": { + "title": "Outside" + }, + "dashboard": { + "title": "Dashboard", + "console": "Console" + }, + "result": { + "title": "Result Page", + "success": "Success", + "fail": "Fail" + }, + "exception": { + "title": "Exception", + "forbidden": "403", + "notFound": "404", + "serverError": "500" + }, + "system": { + "title": "System Settings", + "user": "User Manage", + "role": "Role Manage", + "userCenter": "User Center", + "menu": "Menu Manage" + } + }, + "table": { + "form": { + "reset": "Reset", + "submit": "Submit" + }, + "searchBar": { + "reset": "Reset", + "search": "Search", + "expand": "Expand", + "collapse": "Collapse", + "searchInputPlaceholder": "Please enter", + "searchSelectPlaceholder": "Please select" + }, + "selection": "Select", + "sizeOptions": { + "small": "Compact", + "default": "Default", + "large": "Loose" + }, + "column": { + "selection": "Select", + "expand": "Expand", + "index": "Index" + }, + "zebra": "Zebra", + "border": "Border", + "headerBackground": "Header BG" + } +} \ No newline at end of file diff --git a/web/apps/portal/src/locales/langs/zh.json b/web/apps/portal/src/locales/langs/zh.json new file mode 100644 index 0000000..2c0abd9 --- /dev/null +++ b/web/apps/portal/src/locales/langs/zh.json @@ -0,0 +1,296 @@ +{ + "httpMsg": { + "unauthorized": "未授权访问,请重新登录", + "forbidden": "禁止访问该资源", + "notFound": "请求的资源不存在", + "methodNotAllowed": "请求方法不允许", + "requestTimeout": "请求超时,请稍后重试", + "internalServerError": "服务器内部错误,请稍后重试", + "badGateway": "网关错误,请稍后重试", + "serviceUnavailable": "服务暂时不可用,请稍后重试", + "gatewayTimeout": "网关超时,请稍后重试", + "requestCancelled": "请求已取消", + "networkError": "网络连接异常,请检查网络连接", + "requestFailed": "请求失败", + "requestConfigError": "请求配置错误" + }, + "topBar": { + "search": { + "title": "搜索" + }, + "user": { + "userCenter": "个人中心", + "docs": "使用文档", + "github": "Github", + "lockScreen": "锁定屏幕", + "logout": "退出登录" + }, + "guide": { + "title": "点击这里查看", + "theme": "主题风格", + "menu": "开启顶栏菜单", + "description": "等更多配置" + } + }, + "common": { + "tips": "提示", + "cancel": "取消", + "confirm": "确定", + "logOutTips": "您是否要退出登录?" + }, + "search": { + "placeholder": "搜索页面", + "historyTitle": "搜索历史", + "switchKeydown": "切换", + "selectKeydown": "选择", + "exitKeydown": "关闭" + }, + "setting": { + "menuType": { + "title": "菜单布局", + "list": [ + "垂直", + "水平", + "混合", + "双列" + ] + }, + "theme": { + "title": "主题风格", + "list": [ + "浅色", + "深色", + "系统" + ] + }, + "menu": { + "title": "菜单风格" + }, + "color": { + "title": "系统主题色" + }, + "box": { + "title": "盒子样式", + "list": [ + "边框", + "阴影" + ] + }, + "container": { + "title": "容器宽度", + "list": [ + "铺满", + "定宽" + ] + }, + "basics": { + "title": "基础配置", + "list": { + "multiTab": "开启多标签栏", + "accordion": "侧边栏开启手风琴模式", + "collapseSidebar": "显示折叠侧边栏按钮", + "fastEnter": "显示快速入口", + "reloadPage": "显示重载页面按钮", + "breadcrumb": "显示全局面包屑导航", + "language": "显示多语言选择", + "progressBar": "显示顶部进度条", + "weakMode": "色弱模式", + "watermark": "全局水印", + "menuWidth": "菜单宽度", + "tabStyle": "标签页风格", + "pageTransition": "页面切换动画", + "borderRadius": "自定义圆角" + } + }, + "tabStyle": { + "default": "默认", + "card": "卡片", + "google": "谷歌" + }, + "transition": { + "list": { + "none": "无动画", + "fade": "淡入淡出", + "slideLeft": "左侧滑入", + "slideBottom": "下方滑入", + "slideTop": "上方滑入" + } + }, + "actions": { + "resetConfig": "重置配置", + "copyConfig": "复制配置", + "copySuccess": "配置已复制到剪贴板,可粘贴到 src/config/setting.ts 文件中", + "copyFailed": "复制失败,请重试", + "resetFailed": "重置失败,请刷新页面后重试" + } + }, + "notice": { + "title": "通知", + "btnRead": "标为已读", + "bar": [ + "通知", + "消息", + "代办" + ], + "text": [ + "暂无" + ], + "viewAll": "查看全部" + }, + "worktab": { + "btn": { + "refresh": "刷新", + "fixed": "固定", + "unfixed": "取消固定", + "closeLeft": "关闭左侧", + "closeRight": "关闭右侧", + "closeOther": "关闭其他", + "closeAll": "关闭全部" + } + }, + "login": { + "leftView": { + "title": "大模型统一接入网关", + "subTitle": "统一鉴权 · 限流 · 配额 · 安全审计" + }, + "title": "欢迎回来", + "subTitle": "安全接入 · 统一治理", + "tagline": "数据加密传输 · 零信任接入", + "toggleTheme": "切换主题", + "roles": { + "super": "超级管理员", + "admin": "管理员", + "user": "普通用户" + }, + "placeholder": { + "username": "请输入账号", + "password": "请输入密码" + }, + "rememberPwd": "记住密码", + "securityBadge": "AES-256 加密 · TLS 1.3 · 登录安全审计", + "forgetPwd": "忘记密码", + "btnText": "登录", + "noAccount": "还没有账号?", + "register": "注册", + "success": { + "title": "登录成功", + "message": "欢迎回来" + } + }, + "forgetPassword": { + "title": "忘记密码?", + "subTitle": "输入您的电子邮件来重置您的密码", + "placeholder": "请输入您的电子邮件", + "submitBtnText": "提交", + "backBtnText": "返回" + }, + "register": { + "title": "创建账号", + "subTitle": "欢迎加入我们,请填写以下信息完成注册", + "placeholder": { + "username": "请输入账号", + "password": "请输入密码", + "confirmPassword": "请再次输入密码" + }, + "rule": { + "confirmPasswordRequired": "请再次输入密码", + "passwordMismatch": "两次输入密码不一致!", + "usernameLength": "长度在 3 到 20 个字符", + "passwordLength": "密码长度不能小于6位", + "agreementRequired": "请同意隐私协议" + }, + "agreeText": "我同意", + "privacyPolicy": "《隐私政策》", + "submitBtnText": "注册", + "hasAccount": "已有账号?", + "toLogin": "去登录" + }, + "lockScreen": { + "pwdError": "密码错误", + "lock": { + "inputPlaceholder": "请输入锁屏密码", + "btnText": "锁定" + }, + "unlock": { + "inputPlaceholder": "请输入解锁密码", + "btnText": "解锁", + "backBtnText": "返回登录" + } + }, + "greeting": { + "dawn": "凌晨了!", + "morning": "上午好!", + "afternoon": "下午好!", + "evening": "晚上好!" + }, + "exceptionPage": { + "403": "抱歉,您无权访问该页面", + "404": "抱歉,您访问的页面不存在", + "500": "抱歉,服务器出错了", + "gohome": "返回首页" + }, + "menus": { + "login": { + "title": "登录" + }, + "register": { + "title": "注册" + }, + "forgetPassword": { + "title": "忘记密码" + }, + "outside": { + "title": "内嵌页面" + }, + "dashboard": { + "title": "仪表盘", + "console": "工作台" + }, + "result": { + "title": "结果页面", + "success": "成功页", + "fail": "失败页" + }, + "exception": { + "title": "异常页面", + "forbidden": "403", + "notFound": "404", + "serverError": "500" + }, + "system": { + "title": "系统管理", + "user": "用户管理", + "role": "角色管理", + "userCenter": "个人中心", + "menu": "菜单管理" + } + }, + "table": { + "form": { + "reset": "重置", + "submit": "提交" + }, + "searchBar": { + "reset": "重置", + "search": "查询", + "expand": "展开", + "collapse": "收起", + "searchInputPlaceholder": "请输入", + "searchSelectPlaceholder": "请选择" + }, + "selection": "选择", + "sizeOptions": { + "small": "紧凑", + "default": "默认", + "large": "宽松" + }, + "column": { + "selection": "勾选", + "expand": "展开", + "index": "序号" + }, + "zebra": "斑马纹", + "border": "边框", + "headerBackground": "表头背景" + } +} \ No newline at end of file diff --git a/web/apps/portal/src/main.ts b/web/apps/portal/src/main.ts new file mode 100755 index 0000000..7df948a --- /dev/null +++ b/web/apps/portal/src/main.ts @@ -0,0 +1,25 @@ +import App from './App.vue' +import { createApp } from 'vue' +import { initStore } from './store' // Store +import { initRouter } from './router' // Router +import language from './locales' // 国际化 +import '@styles/core/tailwind.css' // tailwind +import '@styles/index.scss' // 样式 +import '@utils/sys/console.ts' // 控制台输出内容 +import { setupGlobDirectives } from './directives' +import { setupErrorHandle } from './utils/sys/error-handle' + +document.addEventListener( + 'touchstart', + function () {}, + { passive: false } +) + +const app = createApp(App) +initStore(app) +initRouter(app) +setupGlobDirectives(app) +setupErrorHandle(app) + +app.use(language) +app.mount('#app') \ No newline at end of file diff --git a/web/apps/portal/src/mock/temp/formData.ts b/web/apps/portal/src/mock/temp/formData.ts new file mode 100755 index 0000000..8aa7ae5 --- /dev/null +++ b/web/apps/portal/src/mock/temp/formData.ts @@ -0,0 +1,273 @@ +import avatar1 from '@/assets/images/avatar/avatar1.webp' +import avatar2 from '@/assets/images/avatar/avatar2.webp' +import avatar3 from '@/assets/images/avatar/avatar3.webp' +import avatar4 from '@/assets/images/avatar/avatar4.webp' +import avatar5 from '@/assets/images/avatar/avatar5.webp' +import avatar6 from '@/assets/images/avatar/avatar6.webp' +import avatar7 from '@/assets/images/avatar/avatar7.webp' +import avatar8 from '@/assets/images/avatar/avatar8.webp' +import avatar9 from '@/assets/images/avatar/avatar9.webp' +import avatar10 from '@/assets/images/avatar/avatar10.webp' + +export interface User { + id: number + username: string + gender: 1 | 0 + mobile: string + email: string + dep: string + status: string + create_time: string + avatar: string +} + +// 用户列表 +export const ACCOUNT_TABLE_DATA: User[] = [ + { + id: 1, + username: 'alexmorgan', + gender: 1, + mobile: '18670001591', + email: 'alexmorgan@company.com', + dep: '研发部', + status: '1', + create_time: '2020-09-09 10:01:10', + avatar: avatar1 + }, + { + id: 2, + username: 'sophiabaker', + gender: 1, + mobile: '17766664444', + email: 'sophiabaker@company.com', + dep: '电商部', + status: '1', + create_time: '2020-10-10 13:01:12', + avatar: avatar2 + }, + { + id: 3, + username: 'liampark', + gender: 1, + mobile: '18670001597', + email: 'liampark@company.com', + dep: '人事部', + status: '1', + create_time: '2020-11-14 12:01:45', + avatar: avatar3 + }, + { + id: 4, + username: 'oliviagrant', + gender: 0, + mobile: '18670001596', + email: 'oliviagrant@company.com', + dep: '产品部', + status: '1', + create_time: '2020-11-14 09:01:20', + avatar: avatar4 + }, + { + id: 5, + username: 'emmawilson', + gender: 0, + mobile: '18670001595', + email: 'emmawilson@company.com', + dep: '财务部', + status: '1', + create_time: '2020-11-13 11:01:05', + avatar: avatar5 + }, + { + id: 6, + username: 'noahevan', + gender: 1, + mobile: '18670001594', + email: 'noahevan@company.com', + dep: '运营部', + status: '1', + create_time: '2020-10-11 13:10:26', + avatar: avatar6 + }, + { + id: 7, + username: 'avamartin', + gender: 1, + mobile: '18123820191', + email: 'avamartin@company.com', + dep: '客服部', + status: '2', + create_time: '2020-05-14 12:05:10', + avatar: avatar7 + }, + { + id: 8, + username: 'jacoblee', + gender: 1, + mobile: '18670001592', + email: 'jacoblee@company.com', + dep: '总经办', + status: '3', + create_time: '2020-11-12 07:22:25', + avatar: avatar8 + }, + { + id: 9, + username: 'miaclark', + gender: 0, + mobile: '18670001581', + email: 'miaclark@company.com', + dep: '研发部', + status: '4', + create_time: '2020-06-12 05:04:20', + avatar: avatar9 + }, + { + id: 10, + username: 'ethanharris', + gender: 1, + mobile: '13755554444', + email: 'ethanharris@company.com', + dep: '研发部', + status: '1', + create_time: '2020-11-12 16:01:10', + avatar: avatar10 + }, + { + id: 11, + username: 'isabellamoore', + gender: 1, + mobile: '13766660000', + email: 'isabellamoore@company.com', + dep: '研发部', + status: '1', + create_time: '2020-11-14 12:01:20', + avatar: avatar6 + }, + { + id: 12, + username: 'masonwhite', + gender: 1, + mobile: '18670001502', + email: 'masonwhite@company.com', + dep: '研发部', + status: '1', + create_time: '2020-11-14 12:01:20', + avatar: avatar7 + }, + { + id: 13, + username: 'charlottehall', + gender: 1, + mobile: '13006644977', + email: 'charlottehall@company.com', + dep: '研发部', + status: '1', + create_time: '2020-11-14 12:01:20', + avatar: avatar8 + }, + { + id: 14, + username: 'benjaminscott', + gender: 0, + mobile: '13599998888', + email: 'benjaminscott@company.com', + dep: '研发部', + status: '1', + create_time: '2020-11-14 12:01:20', + avatar: avatar9 + }, + { + id: 15, + username: 'ameliaking', + gender: 1, + mobile: '13799998888', + email: 'ameliaking@company.com', + dep: '研发部', + status: '1', + create_time: '2020-11-14 12:01:20', + avatar: avatar10 + } +] + +export interface Role { + roleName: string + roleCode: string + des: string + date: string + enable: boolean +} + +// 角色列表 +export const ROLE_LIST_DATA: Role[] = [ + { + roleName: '超级管理员', + roleCode: 'R_SUPER', + des: '拥有系统全部权限', + date: '2025-05-15 12:30:45', + enable: true + }, + { + roleName: '管理员', + roleCode: 'R_ADMIN', + des: '拥有系统管理权限', + date: '2025-05-15 12:30:45', + enable: true + }, + { + roleName: '普通用户', + roleCode: 'R_USER', + des: '拥有系统普通权限', + date: '2025-05-15 12:30:45', + enable: true + }, + { + roleName: '财务管理员', + roleCode: 'R_FINANCE', + des: '管理财务相关权限', + date: '2025-05-16 09:15:30', + enable: true + }, + { + roleName: '数据分析师', + roleCode: 'R_ANALYST', + des: '拥有数据分析权限', + date: '2025-05-16 11:45:00', + enable: false + }, + { + roleName: '客服专员', + roleCode: 'R_SUPPORT', + des: '处理客户支持请求', + date: '2025-05-17 14:30:22', + enable: true + }, + { + roleName: '营销经理', + roleCode: 'R_MARKETING', + des: '管理营销活动权限', + date: '2025-05-17 15:10:50', + enable: true + }, + { + roleName: '访客用户', + roleCode: 'R_GUEST', + des: '仅限浏览权限', + date: '2025-05-18 08:25:40', + enable: false + }, + { + roleName: '系统维护员', + roleCode: 'R_MAINTAINER', + des: '负责系统维护和更新', + date: '2025-05-18 09:50:12', + enable: true + }, + { + roleName: '项目经理', + roleCode: 'R_PM', + des: '管理项目相关权限', + date: '2025-05-19 13:40:35', + enable: true + } +] diff --git a/web/apps/portal/src/mock/upgrade/changeLog.ts b/web/apps/portal/src/mock/upgrade/changeLog.ts new file mode 100644 index 0000000..dd6b772 --- /dev/null +++ b/web/apps/portal/src/mock/upgrade/changeLog.ts @@ -0,0 +1,12 @@ +import { ref } from 'vue' + +interface UpgradeLog { + version: string // 版本号 + title: string // 更新标题 + date: string // 更新日期 + detail?: string[] // 更新内容 + requireReLogin?: boolean // 是否需要重新登录 + remark?: string // 备注 +} + +export const upgradeLogList = ref([]) diff --git a/web/apps/portal/src/plugins/echarts.ts b/web/apps/portal/src/plugins/echarts.ts new file mode 100644 index 0000000..4f56d89 --- /dev/null +++ b/web/apps/portal/src/plugins/echarts.ts @@ -0,0 +1,76 @@ +/** + * ECharts 插件配置 + * + * 按需导入 ECharts 图表和组件,减小打包体积。 + * 只注册项目中实际使用的图表类型和组件。 + * + * @module plugins/echarts + * @author Art Design Pro Team + */ + +// ECharts 按需导入配置 +import * as echarts from 'echarts/core' + +// 导入图表类型 +import { + BarChart, + LineChart, + PieChart, + ScatterChart, + RadarChart, + MapChart, + CandlestickChart +} from 'echarts/charts' + +// 导入组件 +import { + TitleComponent, + TooltipComponent, + GridComponent, + LegendComponent, + DataZoomComponent, + MarkPointComponent, + MarkLineComponent, + ToolboxComponent, + BrushComponent, + GeoComponent, + VisualMapComponent +} from 'echarts/components' + +// 导入渲染器 +import { CanvasRenderer } from 'echarts/renderers' + +// 注册必要的组件 +echarts.use([ + // 图表类型 + BarChart, + LineChart, + PieChart, + ScatterChart, + RadarChart, + MapChart, + CandlestickChart, + + // 组件 + TitleComponent, + TooltipComponent, + GridComponent, + LegendComponent, + DataZoomComponent, + MarkPointComponent, + MarkLineComponent, + ToolboxComponent, + BrushComponent, + GeoComponent, + VisualMapComponent, + + // 渲染器 + CanvasRenderer +]) + +// 导出 echarts 实例和类型 +export { echarts } +export type { EChartsOption, BarSeriesOption } from 'echarts' + +// 导出常用的图形工具 +export const graphic = echarts.graphic diff --git a/web/apps/portal/src/plugins/index.ts b/web/apps/portal/src/plugins/index.ts new file mode 100644 index 0000000..4536a86 --- /dev/null +++ b/web/apps/portal/src/plugins/index.ts @@ -0,0 +1,6 @@ +/** + * 插件统一导出 + * 集中管理第三方库的封装和配置 + */ + +export * from './echarts' diff --git a/web/apps/portal/src/router/core/ComponentLoader.ts b/web/apps/portal/src/router/core/ComponentLoader.ts new file mode 100644 index 0000000..8af3ce3 --- /dev/null +++ b/web/apps/portal/src/router/core/ComponentLoader.ts @@ -0,0 +1,82 @@ +/** + * 组件加载器 + * + * 负责动态加载 Vue 组件 + * + * @module router/core/ComponentLoader + * @author Art Design Pro Team + */ + +import { h } from 'vue' + +export class ComponentLoader { + private modules: Record Promise> + + constructor() { + // 动态导入 views 目录下所有 .vue 组件 + this.modules = import.meta.glob('../../views/**/*.vue') + } + + /** + * 加载组件 + */ + load(componentPath: string): () => Promise { + if (!componentPath) { + return this.createEmptyComponent() + } + + // 构建可能的路径 + const fullPath = `../../views${componentPath}.vue` + const fullPathWithIndex = `../../views${componentPath}/index.vue` + + // 先尝试直接路径,再尝试添加/index的路径 + const module = this.modules[fullPath] || this.modules[fullPathWithIndex] + + if (!module) { + console.error( + `[ComponentLoader] 未找到组件: ${componentPath},尝试过的路径: ${fullPath} 和 ${fullPathWithIndex}` + ) + return this.createErrorComponent(componentPath) + } + + return module + } + + /** + * 加载布局组件 + */ + loadLayout(): () => Promise { + return () => import('@/views/index/index.vue') + } + + /** + * 加载 iframe 组件 + */ + loadIframe(): () => Promise { + return () => import('@/views/outside/Iframe.vue') + } + + /** + * 创建空组件 + */ + private createEmptyComponent(): () => Promise { + return () => + Promise.resolve({ + render() { + return h('div', {}) + } + }) + } + + /** + * 创建错误提示组件 + */ + private createErrorComponent(componentPath: string): () => Promise { + return () => + Promise.resolve({ + render() { + return h('div', { class: 'route-error' }, `组件未找到: ${componentPath}`) + } + }) + } +} diff --git a/web/apps/portal/src/router/core/IframeRouteManager.ts b/web/apps/portal/src/router/core/IframeRouteManager.ts new file mode 100644 index 0000000..c054ca1 --- /dev/null +++ b/web/apps/portal/src/router/core/IframeRouteManager.ts @@ -0,0 +1,78 @@ +/** + * Iframe 路由管理器 + * + * 负责管理 iframe 类型的路由 + * + * @module router/core/IframeRouteManager + * @author Art Design Pro Team + */ + +import type { AppRouteRecord } from '@/types/router' + +export class IframeRouteManager { + private static instance: IframeRouteManager + private iframeRoutes: AppRouteRecord[] = [] + + private constructor() {} + + static getInstance(): IframeRouteManager { + if (!IframeRouteManager.instance) { + IframeRouteManager.instance = new IframeRouteManager() + } + return IframeRouteManager.instance + } + + /** + * 添加 iframe 路由 + */ + add(route: AppRouteRecord): void { + if (!this.iframeRoutes.find((r) => r.path === route.path)) { + this.iframeRoutes.push(route) + } + } + + /** + * 获取所有 iframe 路由 + */ + getAll(): AppRouteRecord[] { + return this.iframeRoutes + } + + /** + * 根据路径查找 iframe 路由 + */ + findByPath(path: string): AppRouteRecord | undefined { + return this.iframeRoutes.find((route) => route.path === path) + } + + /** + * 清空所有 iframe 路由 + */ + clear(): void { + this.iframeRoutes = [] + } + + /** + * 保存到 sessionStorage + */ + save(): void { + if (this.iframeRoutes.length > 0) { + sessionStorage.setItem('iframeRoutes', JSON.stringify(this.iframeRoutes)) + } + } + + /** + * 从 sessionStorage 加载 + */ + load(): void { + try { + const data = sessionStorage.getItem('iframeRoutes') + if (data) { + this.iframeRoutes = JSON.parse(data) + } + } catch (error) { + console.error('[IframeRouteManager] 加载 iframe 路由失败:', error) + this.iframeRoutes = [] + } + } +} diff --git a/web/apps/portal/src/router/core/MenuProcessor.ts b/web/apps/portal/src/router/core/MenuProcessor.ts new file mode 100644 index 0000000..3f822d5 --- /dev/null +++ b/web/apps/portal/src/router/core/MenuProcessor.ts @@ -0,0 +1,280 @@ +/** + * 菜单处理器 + * + * 负责菜单数据的获取、过滤和处理 + * + * @module router/core/MenuProcessor + * @author Art Design Pro Team + */ + +import type { AppRouteRecord } from '@/types/router' +import { useUserStore } from '@/store/modules/user' +import { useAppMode } from '@/hooks/core/useAppMode' +import { fetchGetMenuList } from '@/api/system-manage' +import { asyncRoutes } from '../routes/asyncRoutes' +import { RoutesAlias } from '../routesAlias' +import { formatMenuTitle } from '@/utils' + +export class MenuProcessor { + /** + * 获取菜单数据 + */ + async getMenuList(): Promise { + const { isFrontendMode } = useAppMode() + + let menuList: AppRouteRecord[] + if (isFrontendMode.value) { + menuList = await this.processFrontendMenu() + } else { + menuList = await this.processBackendMenu() + } + + // 在规范化路径之前,验证原始路径配置 + this.validateMenuPaths(menuList) + + // 规范化路径(将相对路径转换为完整路径) + return this.normalizeMenuPaths(menuList) + } + + /** + * 处理前端控制模式的菜单 + */ + private async processFrontendMenu(): Promise { + const userStore = useUserStore() + const roles = userStore.info?.roles + + let menuList = [...asyncRoutes] + + // 根据角色过滤菜单 + if (roles && roles.length > 0) { + menuList = this.filterMenuByRoles(menuList, roles) + } + + return this.filterEmptyMenus(menuList) + } + + /** + * 处理后端控制模式的菜单 + */ + private async processBackendMenu(): Promise { + const list = await fetchGetMenuList() + return this.filterEmptyMenus(list) + } + + /** + * 根据角色过滤菜单 + */ + private filterMenuByRoles(menu: AppRouteRecord[], roles: string[]): AppRouteRecord[] { + return menu.reduce((acc: AppRouteRecord[], item) => { + const itemRoles = item.meta?.roles + const hasPermission = !itemRoles || itemRoles.some((role) => roles?.includes(role)) + + if (hasPermission) { + const filteredItem = { ...item } + if (filteredItem.children?.length) { + filteredItem.children = this.filterMenuByRoles(filteredItem.children, roles) + } + acc.push(filteredItem) + } + + return acc + }, []) + } + + /** + * 递归过滤空菜单项 + */ + private filterEmptyMenus(menuList: AppRouteRecord[]): AppRouteRecord[] { + return menuList + .map((item) => { + // 如果有子菜单,先递归过滤子菜单 + if (item.children && item.children.length > 0) { + const filteredChildren = this.filterEmptyMenus(item.children) + return { + ...item, + children: filteredChildren + } + } + return item + }) + .filter((item) => { + // 如果定义了 children 属性(即使是空数组),说明这是一个目录菜单,应该保留 + if ('children' in item) { + return true + } + + // 如果有外链或 iframe,保留 + if (item.meta?.isIframe === true || item.meta?.link) { + return true + } + + // 如果有有效的 component,保留 + if (item.component && item.component !== '' && item.component !== RoutesAlias.Layout) { + return true + } + + // 其他情况过滤掉 + return false + }) + } + + /** + * 验证菜单列表是否有效 + */ + validateMenuList(menuList: AppRouteRecord[]): boolean { + return Array.isArray(menuList) && menuList.length > 0 + } + + /** + * 规范化菜单路径 + * 将相对路径转换为完整路径,确保菜单跳转正确 + */ + private normalizeMenuPaths(menuList: AppRouteRecord[], parentPath = ''): AppRouteRecord[] { + return menuList.map((item) => { + // 构建完整路径 + const fullPath = this.buildFullPath(item.path || '', parentPath) + + // 递归处理子菜单 + const children = item.children?.length + ? this.normalizeMenuPaths(item.children, fullPath) + : item.children + + const redirect = item.redirect || this.resolveDefaultRedirect(children) + + return { + ...item, + path: fullPath, + redirect, + children + } + }) + } + + /** + * 为目录型菜单推导默认跳转地址 + */ + private resolveDefaultRedirect(children?: AppRouteRecord[]): string | undefined { + if (!children?.length) { + return undefined + } + + for (const child of children) { + if (this.isNavigableRoute(child)) { + return child.path + } + + const nestedRedirect = this.resolveDefaultRedirect(child.children) + if (nestedRedirect) { + return nestedRedirect + } + } + + return undefined + } + + /** + * 判断子路由是否可以作为默认落点 + */ + private isNavigableRoute(route: AppRouteRecord): boolean { + return Boolean( + route.path && + route.path !== '/' && + !route.meta?.link && + route.meta?.isIframe !== true && + route.component && + route.component !== '' + ) + } + + /** + * 验证菜单路径配置 + * 检测非一级菜单是否错误使用了 / 开头的路径 + */ + /** + * 验证菜单路径配置 + * 检测非一级菜单是否错误使用了 / 开头的路径 + */ + private validateMenuPaths(menuList: AppRouteRecord[], level = 1): void { + menuList.forEach((route) => { + if (!route.children?.length) return + + const parentName = String(route.name || route.path || '未知路由') + + route.children.forEach((child) => { + const childPath = child.path || '' + + // 跳过合法的绝对路径:外部链接和 iframe 路由 + if (this.isValidAbsolutePath(childPath)) return + + // 检测非法的绝对路径 + if (childPath.startsWith('/')) { + this.logPathError(child, childPath, parentName, level) + } + }) + + // 递归检查更深层级的子路由 + this.validateMenuPaths(route.children, level + 1) + }) + } + + /** + * 判断是否为合法的绝对路径 + */ + private isValidAbsolutePath(path: string): boolean { + return ( + path.startsWith('http://') || + path.startsWith('https://') || + path.startsWith('/outside/iframe/') + ) + } + + /** + * 输出路径配置错误日志 + */ + private logPathError( + route: AppRouteRecord, + path: string, + parentName: string, + level: number + ): void { + const routeName = String(route.name || path || '未知路由') + const menuTitle = route.meta?.title || routeName + const suggestedPath = path.split('/').pop() || path.slice(1) + + console.error( + `[路由配置错误] 菜单 "${formatMenuTitle(menuTitle)}" (name: ${routeName}, path: ${path}) 配置错误\n` + + ` 位置: ${parentName} > ${routeName}\n` + + ` 问题: ${level + 1}级菜单的 path 不能以 / 开头\n` + + ` 当前配置: path: '${path}'\n` + + ` 应该改为: path: '${suggestedPath}'` + ) + } + + /** + * 构建完整路径 + */ + private buildFullPath(path: string, parentPath: string): string { + if (!path) return '' + + // 外部链接直接返回 + if (path.startsWith('http://') || path.startsWith('https://')) { + return path + } + + // 如果已经是绝对路径,直接返回 + if (path.startsWith('/')) { + return path + } + + // 拼接父路径和当前路径 + if (parentPath) { + // 移除父路径末尾的斜杠,移除子路径开头的斜杠,然后拼接 + const cleanParent = parentPath.replace(/\/$/, '') + const cleanChild = path.replace(/^\//, '') + return `${cleanParent}/${cleanChild}` + } + + // 没有父路径,添加前导斜杠 + return `/${path}` + } +} diff --git a/web/apps/portal/src/router/core/RoutePermissionValidator.ts b/web/apps/portal/src/router/core/RoutePermissionValidator.ts new file mode 100644 index 0000000..1e607e0 --- /dev/null +++ b/web/apps/portal/src/router/core/RoutePermissionValidator.ts @@ -0,0 +1,161 @@ +/** + * 路由权限验证模块 + * + * 提供路由权限验证和路径检查功能 + * + * ## 主要功能 + * + * - 验证路径是否在用户菜单权限中 + * - 构建菜单路径集合(扁平化处理) + * - 支持动态路由参数匹配 + * - 路径前缀匹配 + * + * ## 使用场景 + * + * - 路由守卫中验证用户权限 + * - 动态路由注册后的权限检查 + * - 防止用户访问无权限的页面 + * + * @module router/core/RoutePermissionValidator + * @author Art Design Pro Team + */ + +import type { AppRouteRecord } from '@/types/router' + +/** + * 路由权限验证器 + */ +export class RoutePermissionValidator { + /** + * 验证路径是否在用户菜单权限中 + * @param targetPath 目标路径 + * @param menuList 菜单列表 + * @returns 是否有权限访问 + */ + static hasPermission(targetPath: string, menuList: AppRouteRecord[]): boolean { + // 根路径始终允许访问 + if (targetPath === '/') { + return true + } + + return this.matchRoute(targetPath, menuList) + } + + /** + * 构建菜单路径集合(扁平化处理) + * @param menuList 菜单列表 + * @param pathSet 路径集合 + * @returns 路径集合 + */ + static buildMenuPathSet( + menuList: AppRouteRecord[], + pathSet: Set = new Set() + ): Set { + if (!Array.isArray(menuList) || menuList.length === 0) { + return pathSet + } + + for (const menuItem of menuList) { + if (!menuItem.path) { + continue + } + + // 标准化路径并添加到集合 + const menuPath = menuItem.path.startsWith('/') ? menuItem.path : `/${menuItem.path}` + pathSet.add(menuPath) + + // 递归处理子菜单 + if (menuItem.children?.length) { + this.buildMenuPathSet(menuItem.children, pathSet) + } + } + + return pathSet + } + + /** + * 检查目标路径是否匹配集合中的某个路径前缀 + * 用于支持动态路由参数匹配,如 /user/123 匹配 /user + * @param targetPath 目标路径 + * @param pathSet 路径集合 + * @returns 是否匹配 + */ + static checkPathPrefix(targetPath: string, pathSet: Set): boolean { + // 遍历路径集合,检查是否有前缀匹配 + for (const menuPath of pathSet) { + if (targetPath.startsWith(`${menuPath}/`)) { + return true + } + } + return false + } + + /** + * 递归匹配路由配置,支持隐藏路由和动态参数路由 + */ + static matchRoute(targetPath: string, routes: AppRouteRecord[]): boolean { + if (!Array.isArray(routes) || routes.length === 0) { + return false + } + + for (const route of routes) { + if (!route.path) { + continue + } + + const routePath = route.path.startsWith('/') ? route.path : `/${route.path}` + + if ( + routePath === targetPath || + this.isDynamicRouteMatch(targetPath, routePath) || + targetPath.startsWith(`${routePath}/`) + ) { + return true + } + + if (route.children?.length && this.matchRoute(targetPath, route.children)) { + return true + } + } + + return false + } + + /** + * 检查目标路径是否匹配动态参数路由,如 /demo/123 匹配 /demo/:id + */ + static isDynamicRouteMatch(targetPath: string, routePath: string): boolean { + if (!routePath.includes(':')) { + return false + } + + const pattern = routePath + .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + .replace(/:([^/]+)/g, '[^/]+') + .replace(/\\\*/g, '.*') + + return new RegExp(`^${pattern}$`).test(targetPath) + } + + /** + * 验证并返回有效的路径 + * 如果目标路径无权限,返回首页路径 + * @param targetPath 目标路径 + * @param menuList 菜单列表 + * @param homePath 首页路径 + * @returns 验证后的路径 + */ + static validatePath( + targetPath: string, + menuList: AppRouteRecord[], + homePath: string = '/' + ): { path: string; hasPermission: boolean } { + const hasPermission = this.hasPermission(targetPath, menuList) + + if (hasPermission) { + return { path: targetPath, hasPermission: true } + } + + return { path: homePath, hasPermission: false } + } +} diff --git a/web/apps/portal/src/router/core/RouteRegistry.ts b/web/apps/portal/src/router/core/RouteRegistry.ts new file mode 100644 index 0000000..e1acb9e --- /dev/null +++ b/web/apps/portal/src/router/core/RouteRegistry.ts @@ -0,0 +1,90 @@ +/** + * 路由注册核心类 + * + * 负责动态路由的注册、验证和管理 + * + * @module router/core/RouteRegistry + * @author Art Design Pro Team + */ + +import type { Router, RouteRecordRaw } from 'vue-router' +import type { AppRouteRecord } from '@/types/router' +import { ComponentLoader } from './ComponentLoader' +import { RouteValidator } from './RouteValidator' +import { RouteTransformer } from './RouteTransformer' + +export class RouteRegistry { + private router: Router + private componentLoader: ComponentLoader + private validator: RouteValidator + private transformer: RouteTransformer + private removeRouteFns: (() => void)[] = [] + private registered = false + + constructor(router: Router) { + this.router = router + this.componentLoader = new ComponentLoader() + this.validator = new RouteValidator() + this.transformer = new RouteTransformer(this.componentLoader) + } + + /** + * 注册动态路由 + */ + register(menuList: AppRouteRecord[]): void { + if (this.registered) { + console.warn('[RouteRegistry] 路由已注册,跳过重复注册') + return + } + + // 验证路由配置 + const validationResult = this.validator.validate(menuList) + if (!validationResult.valid) { + throw new Error(`路由配置验证失败: ${validationResult.errors.join(', ')}`) + } + + // 转换并注册路由 + const removeRouteFns: (() => void)[] = [] + + menuList.forEach((route) => { + if (route.name && !this.router.hasRoute(route.name)) { + const routeConfig = this.transformer.transform(route) + const removeRouteFn = this.router.addRoute(routeConfig as RouteRecordRaw) + removeRouteFns.push(removeRouteFn) + } + }) + + this.removeRouteFns = removeRouteFns + this.registered = true + } + + /** + * 移除所有动态路由 + */ + unregister(): void { + this.removeRouteFns.forEach((fn) => fn()) + this.removeRouteFns = [] + this.registered = false + } + + /** + * 检查是否已注册 + */ + isRegistered(): boolean { + return this.registered + } + + /** + * 获取移除函数列表(用于 store 管理) + */ + getRemoveRouteFns(): (() => void)[] { + return this.removeRouteFns + } + + /** + * 标记为已注册(用于错误处理场景,避免重复请求) + */ + markAsRegistered(): void { + this.registered = true + } +} diff --git a/web/apps/portal/src/router/core/RouteTransformer.ts b/web/apps/portal/src/router/core/RouteTransformer.ts new file mode 100644 index 0000000..0f6900c --- /dev/null +++ b/web/apps/portal/src/router/core/RouteTransformer.ts @@ -0,0 +1,132 @@ +/** + * 路由转换器 + * + * 负责将菜单数据转换为 Vue Router 路由配置 + * + * @module router/core/RouteTransformer + * @author Art Design Pro Team + */ + +import type { RouteRecordRaw } from 'vue-router' +import type { AppRouteRecord } from '@/types/router' +import { ComponentLoader } from './ComponentLoader' +import { IframeRouteManager } from './IframeRouteManager' + +interface ConvertedRoute extends Omit { + id?: number + children?: ConvertedRoute[] + component?: RouteRecordRaw['component'] | (() => Promise) +} + +export class RouteTransformer { + private componentLoader: ComponentLoader + private iframeManager: IframeRouteManager + + constructor(componentLoader: ComponentLoader) { + this.componentLoader = componentLoader + this.iframeManager = IframeRouteManager.getInstance() + } + + /** + * 转换路由配置 + */ + transform(route: AppRouteRecord, depth = 0): ConvertedRoute { + const { component, children, ...routeConfig } = route + + // 基础路由配置 + const converted: ConvertedRoute = { + ...routeConfig, + component: undefined + } + + // 处理不同类型的路由 + if (route.meta.isIframe) { + this.handleIframeRoute(converted, route, depth) + } else if (this.isFirstLevelRoute(route, depth)) { + this.handleFirstLevelRoute(converted, route, component as string) + } else { + this.handleNormalRoute(converted, component as string) + } + + // 递归处理子路由 + if (children?.length) { + converted.children = children.map((child) => this.transform(child, depth + 1)) + } + + return converted + } + + /** + * 判断是否为一级路由(需要 Layout 包裹) + */ + private isFirstLevelRoute(route: AppRouteRecord, depth: number): boolean { + return depth === 0 && (!route.children || route.children.length === 0) + } + + /** + * 处理 iframe 类型路由 + */ + private handleIframeRoute( + targetRoute: ConvertedRoute, + sourceRoute: AppRouteRecord, + depth: number + ): void { + if (depth === 0) { + // 顶级 iframe:用 Layout 包裹 + targetRoute.component = this.componentLoader.loadLayout() + targetRoute.path = this.extractFirstSegment(sourceRoute.path || '') + targetRoute.name = '' + + targetRoute.children = [ + { + ...sourceRoute, + component: this.componentLoader.loadIframe() + } as ConvertedRoute + ] + } else { + // 非顶级(嵌套)iframe:直接使用 Iframe.vue + targetRoute.component = this.componentLoader.loadIframe() + } + + // 记录 iframe 路由 + this.iframeManager.add(sourceRoute) + } + + /** + * 处理一级菜单路由 + */ + private handleFirstLevelRoute( + converted: ConvertedRoute, + route: AppRouteRecord, + component: string | undefined + ): void { + converted.component = this.componentLoader.loadLayout() + converted.path = this.extractFirstSegment(route.path || '') + converted.name = '' + route.meta.isFirstLevel = true + + converted.children = [ + { + ...route, + component: component ? this.componentLoader.load(component) : undefined + } as ConvertedRoute + ] + } + + /** + * 处理普通路由 + */ + private handleNormalRoute(converted: ConvertedRoute, component: string | undefined): void { + if (component) { + converted.component = this.componentLoader.load(component) + } + } + + /** + * 提取路径的第一段 + */ + private extractFirstSegment(path: string): string { + const segments = path.split('/').filter(Boolean) + return segments.length > 0 ? `/${segments[0]}` : '/' + } +} diff --git a/web/apps/portal/src/router/core/RouteValidator.ts b/web/apps/portal/src/router/core/RouteValidator.ts new file mode 100644 index 0000000..f8e58fc --- /dev/null +++ b/web/apps/portal/src/router/core/RouteValidator.ts @@ -0,0 +1,187 @@ +/** + * 路由验证器 + * + * 负责验证路由配置的合法性 + * + * @module router/core/RouteValidator + * @author Art Design Pro Team + */ + +import type { AppRouteRecord } from '@/types/router' +import { RoutesAlias } from '../routesAlias' + +export interface ValidationResult { + valid: boolean + errors: string[] + warnings: string[] +} + +export class RouteValidator { + // 用于记录已经提示过的路由,避免重复提示 + private warnedRoutes = new Set() + + /** + * 验证路由配置 + */ + validate(routes: AppRouteRecord[]): ValidationResult { + const errors: string[] = [] + const warnings: string[] = [] + + // 检测重复路由 + this.checkDuplicates(routes, errors, warnings) + + // 检测组件配置 + this.checkComponents(routes, errors, warnings) + + // 检测嵌套菜单的 /index/index 配置 + this.checkNestedIndexComponent(routes) + + return { + valid: errors.length === 0, + errors, + warnings + } + } + + /** + * 检测重复路由 + */ + private checkDuplicates( + routes: AppRouteRecord[], + errors: string[], + warnings: string[], + parentPath = '' + ): void { + const routeNameMap = new Map() + const componentPathMap = new Map() + + const checkRoutes = (routes: AppRouteRecord[], parentPath = '') => { + routes.forEach((route) => { + const currentPath = route.path || '' + const fullPath = this.resolvePath(parentPath, currentPath) + + // 名称重复检测 + if (route.name) { + const routeName = String(route.name) + if (routeNameMap.has(routeName)) { + warnings.push(`路由名称重复: "${routeName}" (${fullPath})`) + } else { + routeNameMap.set(routeName, fullPath) + } + } + + // 组件路径重复检测 + if (route.component && typeof route.component === 'string') { + const componentPath = route.component + if (componentPath !== RoutesAlias.Layout) { + const componentKey = `${parentPath}:${componentPath}` + if (componentPathMap.has(componentKey)) { + warnings.push(`组件路径重复: "${componentPath}" (${fullPath})`) + } else { + componentPathMap.set(componentKey, fullPath) + } + } + } + + // 递归处理子路由 + if (route.children?.length) { + checkRoutes(route.children, fullPath) + } + }) + } + + checkRoutes(routes, parentPath) + } + + /** + * 检测组件配置 + */ + private checkComponents( + routes: AppRouteRecord[], + errors: string[], + warnings: string[], + parentPath = '' + ): void { + routes.forEach((route) => { + const hasExternalLink = !!route.meta?.link?.trim() + const hasChildren = Array.isArray(route.children) && route.children.length > 0 + const routePath = route.path || '[未定义路径]' + const isIframe = route.meta?.isIframe + + // 如果配置了 component,则无需校验 + if (route.component) { + // 递归检查子路由 + if (route.children?.length) { + const fullPath = this.resolvePath(parentPath, route.path || '') + this.checkComponents(route.children, errors, warnings, fullPath) + } + return + } + + // 一级菜单:必须指定 Layout,除非是外链或 iframe + if (parentPath === '' && !hasExternalLink && !isIframe) { + errors.push(`一级菜单(${routePath}) 缺少 component,必须指向 ${RoutesAlias.Layout}`) + return + } + + // 非一级菜单:如果既不是外链、iframe,也没有子路由,则必须配置 component + if (!hasExternalLink && !isIframe && !hasChildren) { + errors.push(`路由(${routePath}) 缺少 component 配置`) + } + + // 递归检查子路由 + if (route.children?.length) { + const fullPath = this.resolvePath(parentPath, route.path || '') + this.checkComponents(route.children, errors, warnings, fullPath) + } + }) + } + + /** + * 检测嵌套菜单的 Layout 组件配置 + * 只有一级菜单才能使用 Layout,二级及以下菜单不能使用 + */ + private checkNestedIndexComponent(routes: AppRouteRecord[], level = 1): void { + routes.forEach((route) => { + // 检查二级及以下菜单是否错误使用了 Layout + if (level > 1 && route.component === RoutesAlias.Layout) { + this.logLayoutError(route, level) + } + + // 递归检查子路由 + if (route.children?.length) { + this.checkNestedIndexComponent(route.children, level + 1) + } + }) + } + + /** + * 输出 Layout 组件配置错误日志 + */ + private logLayoutError(route: AppRouteRecord, level: number): void { + const routeName = String(route.name || route.path || '未知路由') + const routeKey = `${routeName}_${route.path}` + + // 避免重复提示 + if (this.warnedRoutes.has(routeKey)) return + this.warnedRoutes.add(routeKey) + + const menuTitle = route.meta?.title || routeName + const routePath = route.path || '/' + + console.error( + `[路由配置错误] 菜单 "${menuTitle}" (name: ${routeName}, path: ${routePath}) 配置错误\n` + + ` 问题: ${level}级菜单不能使用 ${RoutesAlias.Layout} 作为 component\n` + + ` 说明: 只有一级菜单才能使用 ${RoutesAlias.Layout},二级及以下菜单应该指向具体的组件路径\n` + + ` 当前配置: component: '${RoutesAlias.Layout}'\n` + + ` 应该改为: component: '/your/component/path' 或留空 ''(如果是目录菜单)` + ) + } + + /** + * 路径解析 + */ + private resolvePath(parent: string, child: string): string { + return [parent.replace(/\/$/, ''), child.replace(/^\//, '')].filter(Boolean).join('/') + } +} diff --git a/web/apps/portal/src/router/core/index.ts b/web/apps/portal/src/router/core/index.ts new file mode 100644 index 0000000..fcfecfc --- /dev/null +++ b/web/apps/portal/src/router/core/index.ts @@ -0,0 +1,14 @@ +/** + * 路由核心模块导出 + * + * @module router/core + * @author Art Design Pro Team + */ + +export { RouteRegistry } from './RouteRegistry' +export { ComponentLoader } from './ComponentLoader' +export { RouteValidator } from './RouteValidator' +export { RouteTransformer } from './RouteTransformer' +export { IframeRouteManager } from './IframeRouteManager' +export { MenuProcessor } from './MenuProcessor' +export { RoutePermissionValidator } from './RoutePermissionValidator' diff --git a/web/apps/portal/src/router/guards/afterEach.ts b/web/apps/portal/src/router/guards/afterEach.ts new file mode 100644 index 0000000..d60572d --- /dev/null +++ b/web/apps/portal/src/router/guards/afterEach.ts @@ -0,0 +1,34 @@ +import { nextTick } from 'vue' +import { useSettingStore } from '@/store/modules/setting' +import { Router } from 'vue-router' +import NProgress from 'nprogress' +import { useCommon } from '@/hooks/core/useCommon' +import { loadingService } from '@/utils/ui' +import { getPendingLoading, resetPendingLoading } from './beforeEach' + +/** 路由全局后置守卫 */ +export function setupAfterEachGuard(router: Router) { + const { scrollToTop } = useCommon() + + router.afterEach(() => { + scrollToTop() + + // 关闭进度条 + const settingStore = useSettingStore() + if (settingStore.showNprogress) { + NProgress.done() + // 确保进度条完全移除,避免残影 + setTimeout(() => { + NProgress.remove() + }, 600) + } + + // 关闭 loading 效果 + if (getPendingLoading()) { + nextTick(() => { + loadingService.hideLoading() + resetPendingLoading() + }) + } + }) +} diff --git a/web/apps/portal/src/router/guards/beforeEach.ts b/web/apps/portal/src/router/guards/beforeEach.ts new file mode 100644 index 0000000..bc97e09 --- /dev/null +++ b/web/apps/portal/src/router/guards/beforeEach.ts @@ -0,0 +1,418 @@ +/** + * 路由全局前置守卫模块 + * + * 提供完整的路由导航守卫功能 + * + * ## 主要功能 + * + * - 登录状态验证和重定向 + * - 动态路由注册和权限控制 + * - 菜单数据获取和处理(前端/后端模式) + * - 用户信息获取和缓存 + * - 页面标题设置 + * - 工作标签页管理 + * - 进度条和加载动画控制 + * - 静态路由识别和处理 + * - 错误处理和异常跳转 + * + * ## 使用场景 + * + * - 路由跳转前的权限验证 + * - 动态菜单加载和路由注册 + * - 用户登录状态管理 + * - 页面访问控制 + * - 路由级别的加载状态管理 + * + * ## 工作流程 + * + * 1. 检查登录状态,未登录跳转到登录页 + * 2. 首次访问时获取用户信息和菜单数据 + * 3. 根据权限动态注册路由 + * 4. 设置页面标题和工作标签页 + * 5. 处理根路径重定向到首页 + * 6. 未匹配路由跳转到 404 页面 + * + * @module router/guards/beforeEach + * @author Art Design Pro Team + */ +import type { Router, RouteLocationNormalized, NavigationGuardNext } from 'vue-router' +import { nextTick } from 'vue' +import NProgress from 'nprogress' +import { useSettingStore } from '@/store/modules/setting' +import { useUserStore } from '@/store/modules/user' +import { useMenuStore } from '@/store/modules/menu' +import { setWorktab } from '@/utils/navigation' +import { setPageTitle } from '@/utils/router' +import { RoutesAlias } from '../routesAlias' +import { staticRoutes } from '../routes/staticRoutes' +import { loadingService } from '@/utils/ui' +import { useCommon } from '@/hooks/core/useCommon' +import { useWorktabStore } from '@/store/modules/worktab' +import { fetchGetUserInfo } from '@/api/auth' +import { ApiStatus } from '@/utils/http/status' +import { isHttpError } from '@/utils/http/error' +import { RouteRegistry, MenuProcessor, IframeRouteManager, RoutePermissionValidator } from '../core' + +// 路由注册器实例 +let routeRegistry: RouteRegistry | null = null + +// 菜单处理器实例 +const menuProcessor = new MenuProcessor() + +// 跟踪是否需要关闭 loading +let pendingLoading = false + +// 路由初始化失败标记,防止死循环 +// 一旦设置为 true,只有刷新页面或重新登录才能重置 +let routeInitFailed = false + +// 路由初始化进行中标记,防止并发请求 +let routeInitInProgress = false + +/** + * 获取 pendingLoading 状态 + */ +export function getPendingLoading(): boolean { + return pendingLoading +} + +/** + * 重置 pendingLoading 状态 + */ +export function resetPendingLoading(): void { + pendingLoading = false +} + +/** + * 获取路由初始化失败状态 + */ +export function getRouteInitFailed(): boolean { + return routeInitFailed +} + +/** + * 重置路由初始化状态(用于重新登录场景) + */ +export function resetRouteInitState(): void { + routeInitFailed = false + routeInitInProgress = false +} + +/** + * 设置路由全局前置守卫 + */ +export function setupBeforeEachGuard(router: Router): void { + // 初始化路由注册器 + routeRegistry = new RouteRegistry(router) + + router.beforeEach( + async ( + to: RouteLocationNormalized, + from: RouteLocationNormalized, + next: NavigationGuardNext + ) => { + try { + await handleRouteGuard(to, from, next, router) + } catch (error) { + console.error('[RouteGuard] 路由守卫处理失败:', error) + closeLoading() + next({ name: 'Exception500' }) + } + } + ) +} + +/** + * 关闭 loading 效果 + */ +function closeLoading(): void { + if (pendingLoading) { + nextTick(() => { + loadingService.hideLoading() + pendingLoading = false + }) + } +} + +/** + * 处理路由守卫逻辑 + */ +async function handleRouteGuard( + to: RouteLocationNormalized, + from: RouteLocationNormalized, + next: NavigationGuardNext, + router: Router +): Promise { + const settingStore = useSettingStore() + const userStore = useUserStore() + + // 启动进度条 + if (settingStore.showNprogress) { + NProgress.start() + } + + // 1. 检查登录状态 + if (!handleLoginStatus(to, userStore, next)) { + return + } + + // 2. 检查路由初始化是否已失败(防止死循环) + if (routeInitFailed) { + // 已经失败过,直接放行到错误页面,不再重试 + if (to.matched.length > 0) { + next() + } else { + // 未匹配到路由,跳转到 500 页面 + next({ name: 'Exception500', replace: true }) + } + return + } + + // 3. 处理动态路由注册 + if (!routeRegistry?.isRegistered() && userStore.isLogin) { + // 防止并发请求(快速连续导航场景) + if (routeInitInProgress) { + // 正在初始化中,等待完成后重新导航 + next(false) + return + } + await handleDynamicRoutes(to, next, router) + return + } + + // 4. 处理根路径重定向 + if (handleRootPathRedirect(to, next)) { + return + } + + // 5. 处理已匹配的路由 + if (to.matched.length > 0) { + setWorktab(to) + setPageTitle(to) + next() + return + } + + // 6. 未匹配到路由,跳转到 404 + next({ name: 'Exception404' }) +} + +/** + * 处理登录状态 + * @returns true 表示可以继续,false 表示已处理跳转 + */ +function handleLoginStatus( + to: RouteLocationNormalized, + userStore: ReturnType, + next: NavigationGuardNext +): boolean { + // 已登录或访问登录页或静态路由,直接放行 + if (userStore.isLogin || to.path === RoutesAlias.Login || isStaticRoute(to.path)) { + return true + } + + // 未登录且访问需要权限的页面,跳转到登录页并携带 redirect 参数 + userStore.logOut() + next({ + name: 'Login', + query: { redirect: to.fullPath } + }) + return false +} + +/** + * 检查路由是否为静态路由 + */ +function isStaticRoute(path: string): boolean { + const checkRoute = (routes: any[], targetPath: string): boolean => { + return routes.some((route) => { + // 404 catch-all 路由不应视为可匿名访问的静态页, + // 否则未登录时手动输入任意地址会直接落到 404,无法跳转登录页。 + if (route.name === 'Exception404') { + return false + } + + // 处理动态路由参数匹配 + const routePath = route.path + const pattern = routePath.replace(/:[^/]+/g, '[^/]+').replace(/\*/g, '.*') + const regex = new RegExp(`^${pattern}$`) + + if (regex.test(targetPath)) { + return true + } + if (route.children && route.children.length > 0) { + return checkRoute(route.children, targetPath) + } + return false + }) + } + + return checkRoute(staticRoutes, path) +} + +/** + * 处理动态路由注册 + */ +async function handleDynamicRoutes( + to: RouteLocationNormalized, + next: NavigationGuardNext, + router: Router +): Promise { + // 标记初始化进行中 + routeInitInProgress = true + + // 显示 loading + pendingLoading = true + loadingService.showLoading() + + try { + // 1. 获取用户信息 + await fetchUserInfo() + + // 2. 获取菜单数据 + const menuList = await menuProcessor.getMenuList() + + // 3. 验证菜单数据 + if (!menuProcessor.validateMenuList(menuList)) { + throw new Error('获取菜单列表失败,请重新登录') + } + + // 4. 注册动态路由 + routeRegistry?.register(menuList) + + // 5. 保存菜单数据到 store + const menuStore = useMenuStore() + menuStore.setMenuList(menuList) + menuStore.addRemoveRouteFns(routeRegistry?.getRemoveRouteFns() || []) + + // 6. 保存 iframe 路由 + IframeRouteManager.getInstance().save() + + // 7. 验证工作标签页 + useWorktabStore().validateWorktabs(router) + + // 8. 静态路由不依赖菜单权限,初始化后直接恢复目标地址。 + if (isStaticRoute(to.path)) { + routeInitInProgress = false + next({ + path: to.path, + query: to.query, + hash: to.hash, + replace: true + }) + return + } + + // 8. 验证目标路径权限 + const { homePath } = useCommon() + const { path: validatedPath, hasPermission } = RoutePermissionValidator.validatePath( + to.path, + menuList, + homePath.value || '/' + ) + + // 初始化成功,重置进行中标记 + routeInitInProgress = false + + // 9. 重新导航到目标路由 + if (!hasPermission) { + // 无权限访问,跳转到首页 + closeLoading() + + // 输出警告信息 + console.warn(`[RouteGuard] 用户无权限访问路径: ${to.path},已跳转到首页`) + + // 直接跳转到首页 + next({ + path: validatedPath, + replace: true + }) + } else { + // 有权限,正常导航 + next({ + path: to.path, + query: to.query, + hash: to.hash, + replace: true + }) + } + } catch (error) { + console.error('[RouteGuard] 动态路由注册失败:', error) + + // 关闭 loading + closeLoading() + + // 401 错误:axios 拦截器已处理退出登录,取消当前导航 + if (isUnauthorizedError(error)) { + // 重置状态,允许重新登录后再次初始化 + routeInitInProgress = false + next(false) + return + } + + // 标记初始化失败,防止死循环 + routeInitFailed = true + routeInitInProgress = false + + // 输出详细错误信息,便于排查 + if (isHttpError(error)) { + console.error(`[RouteGuard] 错误码: ${error.code}, 消息: ${error.message}`) + } + + // 跳转到 500 页面,使用 replace 避免产生历史记录 + next({ name: 'Exception500', replace: true }) + } +} + +/** + * 获取用户信息 + */ +async function fetchUserInfo(): Promise { + const userStore = useUserStore() + const data = await fetchGetUserInfo() + userStore.setUserInfo(data) + // 检查并清理工作台标签页(如果是不同用户登录) + userStore.checkAndClearWorktabs() +} + +/** + * 重置路由相关状态 + */ +export function resetRouterState(delay: number): void { + setTimeout(() => { + routeRegistry?.unregister() + IframeRouteManager.getInstance().clear() + + const menuStore = useMenuStore() + menuStore.removeAllDynamicRoutes() + menuStore.setMenuList([]) + + // 重置路由初始化状态,允许重新登录后再次初始化 + resetRouteInitState() + }, delay) +} + +/** + * 处理根路径重定向到首页 + * @returns true 表示已处理跳转,false 表示无需跳转 + */ +function handleRootPathRedirect(to: RouteLocationNormalized, next: NavigationGuardNext): boolean { + if (to.path !== '/') { + return false + } + + const { homePath } = useCommon() + if (homePath.value && homePath.value !== '/') { + next({ path: homePath.value, replace: true }) + return true + } + + return false +} + +/** + * 判断是否为未授权错误(401) + */ +function isUnauthorizedError(error: unknown): boolean { + return isHttpError(error) && error.code === ApiStatus.unauthorized +} diff --git a/web/apps/portal/src/router/index.ts b/web/apps/portal/src/router/index.ts new file mode 100755 index 0000000..286ae58 --- /dev/null +++ b/web/apps/portal/src/router/index.ts @@ -0,0 +1,23 @@ +import type { App } from 'vue' +import { createRouter, createWebHashHistory } from 'vue-router' +import { staticRoutes } from './routes/staticRoutes' +import { configureNProgress } from '@/utils/router' +import { setupBeforeEachGuard } from './guards/beforeEach' +import { setupAfterEachGuard } from './guards/afterEach' + +// 创建路由实例 +export const router = createRouter({ + history: createWebHashHistory(), + routes: staticRoutes // 静态路由 +}) + +// 初始化路由 +export function initRouter(app: App): void { + configureNProgress() // 顶部进度条 + setupBeforeEachGuard(router) // 路由前置守卫 + setupAfterEachGuard(router) // 路由后置守卫 + app.use(router) +} + +// 主页路径,默认使用菜单第一个有效路径,配置后使用此路径 +export const HOME_PAGE_PATH = '' diff --git a/web/apps/portal/src/router/modules/dashboard.ts b/web/apps/portal/src/router/modules/dashboard.ts new file mode 100644 index 0000000..5f9c3e9 --- /dev/null +++ b/web/apps/portal/src/router/modules/dashboard.ts @@ -0,0 +1,24 @@ +import { AppRouteRecord } from '@/types/router' + +export const dashboardRoutes: AppRouteRecord = { + name: 'Dashboard', + path: '/dashboard', + component: '/index/index', + meta: { + title: 'menus.dashboard.title', + icon: 'ri:pie-chart-line', + roles: ['R_SUPER', 'R_ADMIN'] + }, + children: [ + { + path: 'console', + name: 'Console', + component: '/dashboard/console', + meta: { + title: 'menus.dashboard.console', + keepAlive: false, + fixedTab: true + } + } + ] +} diff --git a/web/apps/portal/src/router/modules/exception.ts b/web/apps/portal/src/router/modules/exception.ts new file mode 100644 index 0000000..07c5604 --- /dev/null +++ b/web/apps/portal/src/router/modules/exception.ts @@ -0,0 +1,46 @@ +import { AppRouteRecord } from '@/types/router' + +export const exceptionRoutes: AppRouteRecord = { + path: '/exception', + name: 'Exception', + component: '/index/index', + meta: { + title: 'menus.exception.title', + icon: 'ri:error-warning-line' + }, + children: [ + { + path: '403', + name: 'Exception403', + component: '/exception/403', + meta: { + title: 'menus.exception.forbidden', + keepAlive: true, + isHideTab: true, + isFullPage: true + } + }, + { + path: '404', + name: 'Exception404', + component: '/exception/404', + meta: { + title: 'menus.exception.notFound', + keepAlive: true, + isHideTab: true, + isFullPage: true + } + }, + { + path: '500', + name: 'Exception500', + component: '/exception/500', + meta: { + title: 'menus.exception.serverError', + keepAlive: true, + isHideTab: true, + isFullPage: true + } + } + ] +} diff --git a/web/apps/portal/src/router/modules/index.ts b/web/apps/portal/src/router/modules/index.ts new file mode 100644 index 0000000..deff162 --- /dev/null +++ b/web/apps/portal/src/router/modules/index.ts @@ -0,0 +1,15 @@ +import { AppRouteRecord } from '@/types/router' +import { dashboardRoutes } from './dashboard' +import { systemRoutes } from './system' +import { resultRoutes } from './result' +import { exceptionRoutes } from './exception' + +/** + * 导出所有模块化路由 + */ +export const routeModules: AppRouteRecord[] = [ + dashboardRoutes, + systemRoutes, + resultRoutes, + exceptionRoutes +] diff --git a/web/apps/portal/src/router/modules/result.ts b/web/apps/portal/src/router/modules/result.ts new file mode 100644 index 0000000..575a2f7 --- /dev/null +++ b/web/apps/portal/src/router/modules/result.ts @@ -0,0 +1,33 @@ +import { AppRouteRecord } from '@/types/router' + +export const resultRoutes: AppRouteRecord = { + path: '/result', + name: 'Result', + component: '/index/index', + meta: { + title: 'menus.result.title', + icon: 'ri:checkbox-circle-line' + }, + children: [ + { + path: 'success', + name: 'ResultSuccess', + component: '/result/success', + meta: { + title: 'menus.result.success', + icon: 'ri:checkbox-circle-line', + keepAlive: true + } + }, + { + path: 'fail', + name: 'ResultFail', + component: '/result/fail', + meta: { + title: 'menus.result.fail', + icon: 'ri:close-circle-line', + keepAlive: true + } + } + ] +} diff --git a/web/apps/portal/src/router/modules/system.ts b/web/apps/portal/src/router/modules/system.ts new file mode 100644 index 0000000..16df585 --- /dev/null +++ b/web/apps/portal/src/router/modules/system.ts @@ -0,0 +1,60 @@ +import { AppRouteRecord } from '@/types/router' + +export const systemRoutes: AppRouteRecord = { + path: '/system', + name: 'System', + component: '/index/index', + meta: { + title: 'menus.system.title', + icon: 'ri:user-3-line', + roles: ['R_SUPER', 'R_ADMIN'] + }, + children: [ + { + path: 'user', + name: 'User', + component: '/system/user', + meta: { + title: 'menus.system.user', + keepAlive: true, + roles: ['R_SUPER', 'R_ADMIN'] + } + }, + { + path: 'role', + name: 'Role', + component: '/system/role', + meta: { + title: 'menus.system.role', + keepAlive: true, + roles: ['R_SUPER'] + } + }, + { + path: 'user-center', + name: 'UserCenter', + component: '/system/user-center', + meta: { + title: 'menus.system.userCenter', + isHide: true, + keepAlive: true, + isHideTab: true + } + }, + { + path: 'menu', + name: 'Menus', + component: '/system/menu', + meta: { + title: 'menus.system.menu', + keepAlive: true, + roles: ['R_SUPER'], + authList: [ + { title: '新增', authMark: 'add' }, + { title: '编辑', authMark: 'edit' }, + { title: '删除', authMark: 'delete' } + ] + } + } + ] +} diff --git a/web/apps/portal/src/router/routes/asyncRoutes.ts b/web/apps/portal/src/router/routes/asyncRoutes.ts new file mode 100644 index 0000000..ccf1201 --- /dev/null +++ b/web/apps/portal/src/router/routes/asyncRoutes.ts @@ -0,0 +1,9 @@ +// 权限文档:https://www.artd.pro/docs/zh/guide/in-depth/permission.html +import { AppRouteRecord } from '@/types/router' +import { routeModules } from '../modules' + +/** + * 动态路由(需要权限才能访问的路由) + * 用于渲染菜单以及根据菜单权限动态加载路由,如果没有权限无法访问 + */ +export const asyncRoutes: AppRouteRecord[] = routeModules diff --git a/web/apps/portal/src/router/routes/staticRoutes.ts b/web/apps/portal/src/router/routes/staticRoutes.ts new file mode 100644 index 0000000..334d0c2 --- /dev/null +++ b/web/apps/portal/src/router/routes/staticRoutes.ts @@ -0,0 +1,72 @@ +import { AppRouteRecordRaw } from '@/utils/router' + +/** + * 静态路由配置(不需要权限就能访问的路由) + * + * 属性说明: + * isHideTab: true 表示不在标签页中显示 + * + * 注意事项: + * 1、path、name 不要和动态路由冲突,否则会导致路由冲突无法访问 + * 2、静态路由不管是否登录都可以访问 + */ +export const staticRoutes: AppRouteRecordRaw[] = [ + // 不需要登录就能访问的路由示例 + // { + // path: '/welcome', + // name: 'WelcomeStatic', + // component: () => import('@views/dashboard/console/index.vue'), + // meta: { title: 'menus.dashboard.title' } + // }, + { + path: '/auth/login', + name: 'Login', + component: () => import('@views/auth/login/index.vue'), + meta: { title: 'menus.login.title', isHideTab: true } + }, + { + path: '/auth/register', + name: 'Register', + component: () => import('@views/auth/register/index.vue'), + meta: { title: 'menus.register.title', isHideTab: true } + }, + { + path: '/auth/forget-password', + name: 'ForgetPassword', + component: () => import('@views/auth/forget-password/index.vue'), + meta: { title: 'menus.forgetPassword.title', isHideTab: true } + }, + { + path: '/403', + name: 'Exception403', + component: () => import('@views/exception/403/index.vue'), + meta: { title: '403', isHideTab: true } + }, + { + path: '/:pathMatch(.*)*', + name: 'Exception404', + component: () => import('@views/exception/404/index.vue'), + meta: { title: '404', isHideTab: true } + }, + { + path: '/500', + name: 'Exception500', + component: () => import('@views/exception/500/index.vue'), + meta: { title: '500', isHideTab: true } + }, + { + path: '/outside', + component: () => import('@views/index/index.vue'), + name: 'Outside', + meta: { title: 'menus.outside.title' }, + children: [ + // iframe 内嵌页面 + { + path: '/outside/iframe/:path', + name: 'Iframe', + component: () => import('@/views/outside/Iframe.vue'), + meta: { title: 'iframe' } + } + ] + } +] diff --git a/web/apps/portal/src/router/routesAlias.ts b/web/apps/portal/src/router/routesAlias.ts new file mode 100644 index 0000000..2af1c68 --- /dev/null +++ b/web/apps/portal/src/router/routesAlias.ts @@ -0,0 +1,8 @@ +/** + * 公共路由别名 + # 存放系统级公共路由路径,如布局容器、登录页等 + */ +export enum RoutesAlias { + Layout = '/index/index', // 布局容器 + Login = '/auth/login' // 登录页 +} diff --git a/web/apps/portal/src/store/index.ts b/web/apps/portal/src/store/index.ts new file mode 100755 index 0000000..b485999 --- /dev/null +++ b/web/apps/portal/src/store/index.ts @@ -0,0 +1,52 @@ +/** + * Pinia Store 配置模块 + * + * 提供全局状态管理的初始化和配置 + * + * ## 主要功能 + * + * - Pinia Store 实例创建 + * - 持久化插件配置(pinia-plugin-persistedstate) + * - 版本化存储键管理 + * - 自动数据迁移(跨版本) + * - LocalStorage 序列化配置 + * - Store 初始化函数 + * + * ## 持久化策略 + * + * - 使用 StorageKeyManager 生成版本化的存储键 + * - 格式:sys-v{version}-{storeId} + * - 自动迁移旧版本数据到当前版本 + * - 使用 localStorage 作为存储介质 + * + * @module store/index + * @author Art Design Pro Team + */ +import type { App } from 'vue' +import { createPinia } from 'pinia' +import { createPersistedState } from 'pinia-plugin-persistedstate' +import { StorageKeyManager } from '@/utils/storage/storage-key-manager' + +export const store = createPinia() + +// 创建存储键管理器实例 +const storageKeyManager = new StorageKeyManager() + +// 配置持久化插件 +store.use( + createPersistedState({ + key: (storeId: string) => storageKeyManager.getStorageKey(storeId), + storage: localStorage, + serializer: { + serialize: JSON.stringify, + deserialize: JSON.parse + } + }) +) + +/** + * 初始化 Store + */ +export function initStore(app: App): void { + app.use(store) +} diff --git a/web/apps/portal/src/store/modules/menu.ts b/web/apps/portal/src/store/modules/menu.ts new file mode 100755 index 0000000..85d13da --- /dev/null +++ b/web/apps/portal/src/store/modules/menu.ts @@ -0,0 +1,109 @@ +/** + * 菜单状态管理模块 + * + * 提供菜单数据和动态路由的状态管理 + * + * ## 主要功能 + * + * - 菜单列表存储和管理 + * - 首页路径配置 + * - 动态路由注册和移除 + * - 路由移除函数管理 + * - 菜单宽度配置 + * + * ## 使用场景 + * + * - 动态菜单加载和渲染 + * - 路由权限控制 + * - 首页路径动态设置 + * - 登出时清理动态路由 + * + * ## 工作流程 + * + * 1. 获取菜单数据(前端/后端模式) + * 2. 设置菜单列表和首页路径 + * 3. 注册动态路由并保存移除函数 + * 4. 登出时调用移除函数清理路由 + * + * @module store/modules/menu + * @author Art Design Pro Team + */ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { AppRouteRecord } from '@/types/router' +import { getFirstMenuPath } from '@/utils' +import { HOME_PAGE_PATH } from '@/router' + +/** + * 菜单状态管理 + * 管理应用的菜单列表、首页路径、菜单宽度和动态路由移除函数 + */ +export const useMenuStore = defineStore('menuStore', () => { + /** 首页路径 */ + const homePath = ref(HOME_PAGE_PATH) + /** 菜单列表 */ + const menuList = ref([]) + /** 菜单宽度 */ + const menuWidth = ref('') + /** 存储路由移除函数的数组 */ + const removeRouteFns = ref<(() => void)[]>([]) + + /** + * 设置菜单列表 + * @param list 菜单路由记录数组 + */ + const setMenuList = (list: AppRouteRecord[]) => { + menuList.value = list + setHomePath(HOME_PAGE_PATH || getFirstMenuPath(list)) + } + + /** + * 获取首页路径 + * @returns 首页路径字符串 + */ + const getHomePath = () => homePath.value + + /** + * 设置主页路径 + * @param path 主页路径 + */ + const setHomePath = (path: string) => { + homePath.value = path + } + + /** + * 添加路由移除函数 + * @param fns 要添加的路由移除函数数组 + */ + const addRemoveRouteFns = (fns: (() => void)[]) => { + removeRouteFns.value.push(...fns) + } + + /** + * 移除所有动态路由 + * 执行所有存储的路由移除函数并清空数组 + */ + const removeAllDynamicRoutes = () => { + removeRouteFns.value.forEach((fn) => fn()) + removeRouteFns.value = [] + } + + /** + * 清空路由移除函数数组 + */ + const clearRemoveRouteFns = () => { + removeRouteFns.value = [] + } + + return { + menuList, + menuWidth, + removeRouteFns, + setMenuList, + getHomePath, + setHomePath, + addRemoveRouteFns, + removeAllDynamicRoutes, + clearRemoveRouteFns + } +}) diff --git a/web/apps/portal/src/store/modules/setting.ts b/web/apps/portal/src/store/modules/setting.ts new file mode 100755 index 0000000..2878259 --- /dev/null +++ b/web/apps/portal/src/store/modules/setting.ts @@ -0,0 +1,450 @@ +/** + * 系统设置状态管理模块 + * + * 提供完整的系统设置状态管理 + * + * ## 主要功能 + * + * - 菜单布局配置(左侧、顶部、混合、双栏) + * - 主题管理(亮色、暗色、自动) + * - 菜单主题样式配置 + * - 界面显示开关(面包屑、标签页、语言切换等) + * - 功能开关(手风琴模式、色弱模式、水印等) + * - 样式配置(边框、圆角、容器宽度、页面过渡) + * - 节日功能配置 + * - Element Plus 主题色动态设置 + * + * ## 使用场景 + * + * - 设置面板配置管理 + * - 主题切换和样式定制 + * - 界面功能开关控制 + * - 用户偏好设置持久化 + * + * ## 持久化 + * + * - 使用 localStorage 存储 + * - 存储键:sys-v{version}-setting + * - 支持跨版本数据迁移 + * + * @module store/modules/setting + * @author Art Design Pro Team + */ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { MenuThemeType } from '@/types/store' +import AppConfig from '@/config' +import { SystemThemeEnum, MenuThemeEnum, MenuTypeEnum, ContainerWidthEnum } from '@/enums/appEnum' +import { setElementThemeColor } from '@/utils/ui' +import { useCeremony } from '@/hooks/core/useCeremony' +import { StorageConfig } from '@/utils' +import { SETTING_DEFAULT_CONFIG } from '@/config/setting' + +/** + * 系统设置状态管理 + * 管理应用的菜单、主题、界面显示等各项设置 + */ +export const useSettingStore = defineStore( + 'settingStore', + () => { + // 菜单相关设置 + /** 菜单类型 */ + const menuType = ref(SETTING_DEFAULT_CONFIG.menuType) + /** 菜单展开宽度 */ + const menuOpenWidth = ref(SETTING_DEFAULT_CONFIG.menuOpenWidth) + /** 菜单是否展开 */ + const menuOpen = ref(SETTING_DEFAULT_CONFIG.menuOpen) + /** 双菜单是否显示文本 */ + const dualMenuShowText = ref(SETTING_DEFAULT_CONFIG.dualMenuShowText) + + // 主题相关设置 + /** 系统主题类型 */ + const systemThemeType = ref(SETTING_DEFAULT_CONFIG.systemThemeType) + /** 系统主题模式 */ + const systemThemeMode = ref(SETTING_DEFAULT_CONFIG.systemThemeMode) + /** 菜单主题类型 */ + const menuThemeType = ref(SETTING_DEFAULT_CONFIG.menuThemeType) + /** 系统主题颜色 */ + const systemThemeColor = ref(SETTING_DEFAULT_CONFIG.systemThemeColor) + + // 界面显示设置 + /** 是否显示菜单按钮 */ + const showMenuButton = ref(SETTING_DEFAULT_CONFIG.showMenuButton) + /** 是否显示快速入口 */ + const showFastEnter = ref(SETTING_DEFAULT_CONFIG.showFastEnter) + /** 是否显示刷新按钮 */ + const showRefreshButton = ref(SETTING_DEFAULT_CONFIG.showRefreshButton) + /** 是否显示面包屑 */ + const showCrumbs = ref(SETTING_DEFAULT_CONFIG.showCrumbs) + /** 是否显示工作台标签 */ + const showWorkTab = ref(SETTING_DEFAULT_CONFIG.showWorkTab) + /** 是否显示语言切换 */ + const showLanguage = ref(SETTING_DEFAULT_CONFIG.showLanguage) + /** 是否显示进度条 */ + const showNprogress = ref(SETTING_DEFAULT_CONFIG.showNprogress) + /** 是否显示设置引导 */ + const showSettingGuide = ref(SETTING_DEFAULT_CONFIG.showSettingGuide) + /** 是否显示节日文本 */ + const showFestivalText = ref(SETTING_DEFAULT_CONFIG.showFestivalText) + /** 是否显示水印 */ + const watermarkVisible = ref(SETTING_DEFAULT_CONFIG.watermarkVisible) + + // 功能设置 + /** 是否自动关闭 */ + const autoClose = ref(SETTING_DEFAULT_CONFIG.autoClose) + /** 是否唯一展开 */ + const uniqueOpened = ref(SETTING_DEFAULT_CONFIG.uniqueOpened) + /** 是否色弱模式 */ + const colorWeak = ref(SETTING_DEFAULT_CONFIG.colorWeak) + /** 是否刷新 */ + const refresh = ref(SETTING_DEFAULT_CONFIG.refresh) + /** 是否加载节日烟花 */ + const holidayFireworksLoaded = ref(SETTING_DEFAULT_CONFIG.holidayFireworksLoaded) + + // 样式设置 + /** 边框模式 */ + const boxBorderMode = ref(SETTING_DEFAULT_CONFIG.boxBorderMode) + /** 页面过渡效果 */ + const pageTransition = ref(SETTING_DEFAULT_CONFIG.pageTransition) + /** 标签页样式 */ + const tabStyle = ref(SETTING_DEFAULT_CONFIG.tabStyle) + /** 自定义圆角 */ + const customRadius = ref(SETTING_DEFAULT_CONFIG.customRadius) + /** 容器宽度 */ + const containerWidth = ref(SETTING_DEFAULT_CONFIG.containerWidth) + + // 节日相关 + /** 节日日期 */ + const festivalDate = ref('') + + /** + * 获取菜单主题 + * 根据当前主题类型和暗色模式返回对应的主题配置 + */ + const getMenuTheme = computed((): MenuThemeType => { + const list = AppConfig.themeList.filter((item) => item.theme === menuThemeType.value) + if (isDark.value) { + return AppConfig.darkMenuStyles[0] + } else { + return list[0] + } + }) + + /** + * 判断是否为暗色模式 + */ + const isDark = computed((): boolean => { + return systemThemeType.value === SystemThemeEnum.DARK + }) + + /** + * 获取菜单展开宽度 + */ + const getMenuOpenWidth = computed((): string => { + return menuOpenWidth.value + 'px' || SETTING_DEFAULT_CONFIG.menuOpenWidth + 'px' + }) + + /** + * 获取自定义圆角 + */ + const getCustomRadius = computed((): string => { + return customRadius.value + 'rem' || SETTING_DEFAULT_CONFIG.customRadius + 'rem' + }) + + /** + * 是否显示烟花 + * 根据当前日期和节日日期判断是否显示烟花效果 + */ + const isShowFireworks = computed((): boolean => { + return festivalDate.value === useCeremony().currentFestivalData.value?.date ? false : true + }) + + /** + * 切换菜单布局 + * @param type 菜单类型 + */ + const switchMenuLayouts = (type: MenuTypeEnum) => { + menuType.value = type + } + + /** + * 设置菜单展开宽度 + * @param width 宽度值 + */ + const setMenuOpenWidth = (width: number) => { + menuOpenWidth.value = width + } + + /** + * 设置全局主题 + * @param theme 主题类型 + * @param themeMode 主题模式 + */ + const setGlopTheme = (theme: SystemThemeEnum, themeMode: SystemThemeEnum) => { + systemThemeType.value = theme + systemThemeMode.value = themeMode + localStorage.setItem(StorageConfig.THEME_KEY, theme) + } + + /** + * 切换菜单样式 + * @param theme 菜单主题 + */ + const switchMenuStyles = (theme: MenuThemeEnum) => { + menuThemeType.value = theme + } + + /** + * 设置Element Plus主题颜色 + * @param theme 主题颜色 + */ + const setElementTheme = (theme: string) => { + systemThemeColor.value = theme + setElementThemeColor(theme) + } + + /** + * 切换边框模式 + */ + const setBorderMode = () => { + boxBorderMode.value = !boxBorderMode.value + } + + /** + * 设置容器宽度 + * @param width 容器宽度枚举值 + */ + const setContainerWidth = (width: ContainerWidthEnum) => { + containerWidth.value = width + } + + /** + * 切换唯一展开模式 + */ + const setUniqueOpened = () => { + uniqueOpened.value = !uniqueOpened.value + } + + /** + * 切换菜单按钮显示 + */ + const setButton = () => { + showMenuButton.value = !showMenuButton.value + } + + /** + * 切换快速入口显示 + */ + const setFastEnter = () => { + showFastEnter.value = !showFastEnter.value + } + + /** + * 切换自动关闭 + */ + const setAutoClose = () => { + autoClose.value = !autoClose.value + } + + /** + * 切换刷新按钮显示 + */ + const setShowRefreshButton = () => { + showRefreshButton.value = !showRefreshButton.value + } + + /** + * 切换面包屑显示 + */ + const setCrumbs = () => { + showCrumbs.value = !showCrumbs.value + } + + /** + * 设置工作台标签显示 + * @param show 是否显示 + */ + const setWorkTab = (show: boolean) => { + showWorkTab.value = show + } + + /** + * 切换语言切换显示 + */ + const setLanguage = () => { + showLanguage.value = !showLanguage.value + } + + /** + * 切换进度条显示 + */ + const setNprogress = () => { + showNprogress.value = !showNprogress.value + } + + /** + * 切换色弱模式 + */ + const setColorWeak = () => { + colorWeak.value = !colorWeak.value + } + + /** + * 隐藏设置引导 + */ + const hideSettingGuide = () => { + showSettingGuide.value = false + } + + /** + * 显示设置引导 + */ + const openSettingGuide = () => { + showSettingGuide.value = true + } + + /** + * 设置页面过渡效果 + * @param transition 过渡效果名称 + */ + const setPageTransition = (transition: string) => { + pageTransition.value = transition + } + + /** + * 设置标签页样式 + * @param style 样式名称 + */ + const setTabStyle = (style: string) => { + tabStyle.value = style + } + + /** + * 设置菜单展开状态 + * @param open 是否展开 + */ + const setMenuOpen = (open: boolean) => { + menuOpen.value = open + } + + /** + * 刷新页面 + */ + const reload = () => { + refresh.value = !refresh.value + } + + /** + * 设置水印显示 + * @param visible 是否显示 + */ + const setWatermarkVisible = (visible: boolean) => { + watermarkVisible.value = visible + } + + /** + * 设置自定义圆角 + * @param radius 圆角值 + */ + const setCustomRadius = (radius: string) => { + customRadius.value = radius + document.documentElement.style.setProperty('--custom-radius', `${radius}rem`) + } + + /** + * 设置节日烟花加载状态 + * @param isLoad 是否已加载 + */ + const setholidayFireworksLoaded = (isLoad: boolean) => { + holidayFireworksLoaded.value = isLoad + } + + /** + * 设置节日文本显示 + * @param show 是否显示 + */ + const setShowFestivalText = (show: boolean) => { + showFestivalText.value = show + } + + const setFestivalDate = (date: string) => { + festivalDate.value = date + } + + const setDualMenuShowText = (show: boolean) => { + dualMenuShowText.value = show + } + + return { + menuType, + menuOpenWidth, + systemThemeType, + systemThemeMode, + menuThemeType, + systemThemeColor, + boxBorderMode, + uniqueOpened, + showMenuButton, + showFastEnter, + showRefreshButton, + showCrumbs, + autoClose, + showWorkTab, + showLanguage, + showNprogress, + colorWeak, + showSettingGuide, + pageTransition, + tabStyle, + menuOpen, + refresh, + watermarkVisible, + customRadius, + holidayFireworksLoaded, + showFestivalText, + festivalDate, + dualMenuShowText, + containerWidth, + getMenuTheme, + isDark, + getMenuOpenWidth, + getCustomRadius, + isShowFireworks, + switchMenuLayouts, + setMenuOpenWidth, + setGlopTheme, + switchMenuStyles, + setElementTheme, + setBorderMode, + setContainerWidth, + setUniqueOpened, + setButton, + setFastEnter, + setAutoClose, + setShowRefreshButton, + setCrumbs, + setWorkTab, + setLanguage, + setNprogress, + setColorWeak, + hideSettingGuide, + openSettingGuide, + setPageTransition, + setTabStyle, + setMenuOpen, + reload, + setWatermarkVisible, + setCustomRadius, + setholidayFireworksLoaded, + setShowFestivalText, + setFestivalDate, + setDualMenuShowText + } + }, + { + persist: { + key: 'setting', + storage: localStorage + } + } +) diff --git a/web/apps/portal/src/store/modules/table.ts b/web/apps/portal/src/store/modules/table.ts new file mode 100644 index 0000000..094c310 --- /dev/null +++ b/web/apps/portal/src/store/modules/table.ts @@ -0,0 +1,97 @@ +/** + * 表格状态管理模块 + * + * 提供表格显示配置的状态管理 + * + * ## 主要功能 + * + * - 表格尺寸配置(紧凑、默认、宽松) + * - 斑马纹显示开关 + * - 边框显示开关 + * - 表头背景显示开关 + * - 全屏模式开关 + * + * ## 使用场景 + * - 表格组件样式配置 + * - 用户表格偏好设置 + * - 表格工具栏功能控制 + * + * ## 持久化 + * + * - 使用 localStorage 存储 + * - 存储键:sys-v{version}-table + * - 用户配置跨页面保持 + * + * @module store/modules/table + * @author Art Design Pro Team + */ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { TableSizeEnum } from '@/enums/formEnum' + +// 表格 +export const useTableStore = defineStore( + 'tableStore', + () => { + // 表格大小 + const tableSize = ref(TableSizeEnum.DEFAULT) + // 斑马纹 + const isZebra = ref(false) + // 边框 + const isBorder = ref(false) + // 表头背景 + const isHeaderBackground = ref(false) + + // 是否全屏 + const isFullScreen = ref(false) + + /** + * 设置表格大小 + * @param size 表格大小枚举值 + */ + const setTableSize = (size: TableSizeEnum) => (tableSize.value = size) + + /** + * 设置斑马纹显示状态 + * @param value 是否显示斑马纹 + */ + const setIsZebra = (value: boolean) => (isZebra.value = value) + + /** + * 设置表格边框显示状态 + * @param value 是否显示边框 + */ + const setIsBorder = (value: boolean) => (isBorder.value = value) + + /** + * 设置表头背景显示状态 + * @param value 是否显示表头背景 + */ + const setIsHeaderBackground = (value: boolean) => (isHeaderBackground.value = value) + + /** + * 设置是否全屏 + * @param value 是否全屏 + */ + const setIsFullScreen = (value: boolean) => (isFullScreen.value = value) + + return { + tableSize, + isZebra, + isBorder, + isHeaderBackground, + setTableSize, + setIsZebra, + setIsBorder, + setIsHeaderBackground, + isFullScreen, + setIsFullScreen + } + }, + { + persist: { + key: 'table', + storage: localStorage + } + } +) diff --git a/web/apps/portal/src/store/modules/user.ts b/web/apps/portal/src/store/modules/user.ts new file mode 100755 index 0000000..08f7684 --- /dev/null +++ b/web/apps/portal/src/store/modules/user.ts @@ -0,0 +1,235 @@ +/** + * 用户状态管理模块 + * + * 提供用户相关的状态管理 + * + * ## 主要功能 + * + * - 用户登录状态管理 + * - 用户信息存储 + * - 访问令牌和刷新令牌管理 + * - 语言设置 + * - 搜索历史记录 + * - 锁屏状态和密码管理 + * - 登出清理逻辑 + * + * ## 使用场景 + * + * - 用户登录和认证 + * - 权限验证 + * - 个人信息展示 + * - 多语言切换 + * - 锁屏功能 + * - 搜索历史管理 + * + * ## 持久化 + * + * - 使用 localStorage 存储 + * - 存储键:sys-v{version}-user + * - 登出时自动清理 + * + * @module store/modules/user + * @author Art Design Pro Team + */ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { LanguageEnum } from '@/enums/appEnum' +import { router } from '@/router' +import { useSettingStore } from './setting' +import { useWorktabStore } from './worktab' +import { AppRouteRecord } from '@/types/router' +import { setPageTitle } from '@/utils/router' +import { resetRouterState } from '@/router/guards/beforeEach' +import { useMenuStore } from './menu' +import { StorageConfig } from '@/utils/storage/storage-config' + +/** + * 用户状态管理 + * 管理用户登录状态、个人信息、语言设置、搜索历史、锁屏状态等 + */ +export const useUserStore = defineStore( + 'userStore', + () => { + // 语言设置 + const language = ref(LanguageEnum.ZH) + // 登录状态 + const isLogin = ref(false) + // 锁屏状态 + const isLock = ref(false) + // 锁屏密码 + const lockPassword = ref('') + // 用户信息 + const info = ref>({}) + // 搜索历史记录 + const searchHistory = ref([]) + // 访问令牌 + const accessToken = ref('') + // 刷新令牌 + const refreshToken = ref('') + + // 计算属性:获取用户信息 + const getUserInfo = computed(() => info.value) + // 计算属性:获取设置状态 + const getSettingState = computed(() => useSettingStore().$state) + // 计算属性:获取工作台状态 + const getWorktabState = computed(() => useWorktabStore().$state) + + /** + * 设置用户信息 + * @param newInfo 新的用户信息 + */ + const setUserInfo = (newInfo: Api.Auth.UserInfo) => { + info.value = newInfo + } + + /** + * 设置登录状态 + * @param status 登录状态 + */ + const setLoginStatus = (status: boolean) => { + isLogin.value = status + } + + /** + * 设置语言 + * @param lang 语言枚举值 + */ + const setLanguage = (lang: LanguageEnum) => { + setPageTitle(router.currentRoute.value) + language.value = lang + } + + /** + * 设置搜索历史 + * @param list 搜索历史列表 + */ + const setSearchHistory = (list: AppRouteRecord[]) => { + searchHistory.value = list + } + + /** + * 设置锁屏状态 + * @param status 锁屏状态 + */ + const setLockStatus = (status: boolean) => { + isLock.value = status + } + + /** + * 设置锁屏密码 + * @param password 锁屏密码 + */ + const setLockPassword = (password: string) => { + lockPassword.value = password + } + + /** + * 设置令牌 + * @param newAccessToken 访问令牌 + * @param newRefreshToken 刷新令牌(可选) + */ + const setToken = (newAccessToken: string, newRefreshToken?: string) => { + accessToken.value = newAccessToken + if (newRefreshToken) { + refreshToken.value = newRefreshToken + } + } + + /** + * 退出登录 + * 清空所有用户相关状态并跳转到登录页 + * 如果是同一账号重新登录,保留工作台标签页 + */ + const logOut = () => { + // 保存当前用户 ID,用于下次登录时判断是否为同一用户 + const currentUserId = info.value.userId + if (currentUserId) { + localStorage.setItem(StorageConfig.LAST_USER_ID_KEY, String(currentUserId)) + } + + // 清空用户信息 + info.value = {} + // 重置登录状态 + isLogin.value = false + // 重置锁屏状态 + isLock.value = false + // 清空锁屏密码 + lockPassword.value = '' + // 清空访问令牌 + accessToken.value = '' + // 清空刷新令牌 + refreshToken.value = '' + // 注意:不清空工作台标签页,等下次登录时根据用户判断 + // 移除iframe路由缓存 + sessionStorage.removeItem('iframeRoutes') + // 清空主页路径 + useMenuStore().setHomePath('') + // 重置路由状态 + resetRouterState(500) + // 跳转到登录页,携带当前路由作为 redirect 参数 + const currentRoute = router.currentRoute.value + const redirect = currentRoute.path !== '/login' ? currentRoute.fullPath : undefined + router.push({ + name: 'Login', + query: redirect ? { redirect } : undefined + }) + } + + /** + * 检查并清理工作台标签页 + * 如果不是同一用户登录,清空工作台标签页 + * 应在登录成功后调用 + */ + const checkAndClearWorktabs = () => { + const lastUserId = localStorage.getItem(StorageConfig.LAST_USER_ID_KEY) + const currentUserId = info.value.userId + + // 无法获取当前用户 ID,跳过检查 + if (!currentUserId) return + + // 首次登录或缓存已清除,保留现有标签页 + if (!lastUserId) { + return + } + + // 不同用户登录,清空工作台标签页 + if (String(currentUserId) !== lastUserId) { + const worktabStore = useWorktabStore() + worktabStore.opened = [] + worktabStore.keepAliveExclude = [] + } + + // 清除临时存储 + localStorage.removeItem(StorageConfig.LAST_USER_ID_KEY) + } + + return { + language, + isLogin, + isLock, + lockPassword, + info, + searchHistory, + accessToken, + refreshToken, + getUserInfo, + getSettingState, + getWorktabState, + setUserInfo, + setLoginStatus, + setLanguage, + setSearchHistory, + setLockStatus, + setLockPassword, + setToken, + logOut, + checkAndClearWorktabs + } + }, + { + persist: { + key: 'user', + storage: localStorage + } + } +) diff --git a/web/apps/portal/src/store/modules/worktab.ts b/web/apps/portal/src/store/modules/worktab.ts new file mode 100755 index 0000000..caa0d90 --- /dev/null +++ b/web/apps/portal/src/store/modules/worktab.ts @@ -0,0 +1,568 @@ +/** + * 工作标签页状态管理模块 + * + * 提供多标签页功能的完整状态管理 + * + * ## 主要功能 + * + * - 标签页打开和关闭 + * - 标签页固定和取消固定 + * - 批量关闭(左侧、右侧、其他、全部) + * - 标签页缓存管理(KeepAlive) + * - 标签页标题自定义 + * - 标签页路由验证 + * - 动态路由参数处理 + * + * ## 使用场景 + * + * - 多标签页导航 + * - 页面缓存控制 + * - 标签页右键菜单 + * - 固定常用页面 + * - 批量关闭标签 + * + * ## 核心特性 + * + * - 智能标签页复用(同路由名称复用) + * - 固定标签页保护(不可关闭) + * - KeepAlive 缓存排除管理 + * - 路由有效性验证 + * - 首页自动保留 + * + * ## 持久化 + * - 使用 localStorage 存储 + * - 存储键:sys-v{version}-worktab + * - 刷新页面保持标签状态 + * + * @module store/modules/worktab + * @author Art Design Pro Team + */ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { router } from '@/router' +import { LocationQueryRaw, Router } from 'vue-router' +import { WorkTab } from '@/types' +import { useCommon } from '@/hooks/core/useCommon' + +interface WorktabState { + current: Partial + opened: WorkTab[] + keepAliveExclude: string[] +} + +/** + * 工作台标签页管理 Store + */ +export const useWorktabStore = defineStore( + 'worktabStore', + () => { + // 状态定义 + const current = ref>({}) + const opened = ref([]) + const keepAliveExclude = ref([]) + + // 计算属性 + const hasOpenedTabs = computed(() => opened.value.length > 0) + const hasMultipleTabs = computed(() => opened.value.length > 1) + const currentTabIndex = computed(() => + current.value.path ? opened.value.findIndex((tab) => tab.path === current.value.path) : -1 + ) + + /** + * 查找标签页索引 + */ + const findTabIndex = (path: string): number => { + return opened.value.findIndex((tab) => tab.path === path) + } + + /** + * 获取标签页 + */ + const getTab = (path: string): WorkTab | undefined => { + return opened.value.find((tab) => tab.path === path) + } + + /** + * 检查标签页是否可关闭 + */ + const isTabClosable = (tab: WorkTab): boolean => { + return !tab.fixedTab + } + + /** + * 安全的路由跳转 + */ + const safeRouterPush = (tab: Partial): void => { + if (!tab.path) { + console.warn('尝试跳转到无效路径的标签页') + return + } + + try { + router.push({ + path: tab.path, + query: tab.query as LocationQueryRaw + }) + } catch (error) { + console.error('路由跳转失败:', error) + } + } + + /** + * 打开或激活一个选项卡 + */ + const openTab = (tab: WorkTab): void => { + if (!tab.path) { + console.warn('尝试打开无效的标签页') + return + } + + // 从 keepAlive 排除列表中移除 + if (tab.name) { + removeKeepAliveExclude(tab.name) + } + + // 先根据路由名称查找(应对动态路由参数导致的多开问题),找不到再根据路径查找 + let existingIndex = -1 + if (tab.name) { + existingIndex = opened.value.findIndex((t) => t.name === tab.name) + } + if (existingIndex === -1) { + existingIndex = findTabIndex(tab.path) + } + + if (existingIndex === -1) { + // 新增标签页 + const insertIndex = tab.fixedTab ? findFixedTabInsertIndex() : opened.value.length + const newTab = { ...tab } + + if (tab.fixedTab) { + opened.value.splice(insertIndex, 0, newTab) + } else { + opened.value.push(newTab) + } + + current.value = newTab + } else { + // 更新现有标签页(当动态路由参数或查询变更时,复用同一标签) + const existingTab = opened.value[existingIndex] + + opened.value[existingIndex] = { + ...existingTab, + path: tab.path, + params: tab.params, + query: tab.query, + title: tab.title || existingTab.title, + fixedTab: tab.fixedTab ?? existingTab.fixedTab, + keepAlive: tab.keepAlive ?? existingTab.keepAlive, + name: tab.name || existingTab.name, + icon: tab.icon || existingTab.icon + } + + current.value = opened.value[existingIndex] + } + } + + /** + * 查找固定标签页的插入位置 + */ + const findFixedTabInsertIndex = (): number => { + let insertIndex = 0 + for (let i = 0; i < opened.value.length; i++) { + if (opened.value[i].fixedTab) { + insertIndex = i + 1 + } else { + break + } + } + return insertIndex + } + + /** + * 关闭指定的选项卡 + */ + const removeTab = (path: string): void => { + const targetTab = getTab(path) + const targetIndex = findTabIndex(path) + + if (targetIndex === -1) { + console.warn(`尝试关闭不存在的标签页: ${path}`) + return + } + + if (targetTab && !isTabClosable(targetTab)) { + console.warn(`尝试关闭固定标签页: ${path}`) + return + } + + // 从标签页列表中移除 + opened.value.splice(targetIndex, 1) + + // 处理缓存排除 + if (targetTab?.name) { + addKeepAliveExclude(targetTab) + } + + const { homePath } = useCommon() + + // 如果关闭后无标签页,跳转首页 + if (!hasOpenedTabs.value) { + if (path !== homePath.value) { + current.value = {} + safeRouterPush({ path: homePath.value }) + } + return + } + + // 如果关闭的是当前激活标签,需要激活其他标签 + if (current.value.path === path) { + const newIndex = targetIndex >= opened.value.length ? opened.value.length - 1 : targetIndex + current.value = opened.value[newIndex] + safeRouterPush(current.value) + } + } + + /** + * 关闭左侧选项卡 + */ + const removeLeft = (path: string): void => { + const targetIndex = findTabIndex(path) + + if (targetIndex === -1) { + console.warn(`尝试关闭左侧标签页,但目标标签页不存在: ${path}`) + return + } + + // 获取左侧可关闭的标签页 + const leftTabs = opened.value.slice(0, targetIndex) + const closableLeftTabs = leftTabs.filter(isTabClosable) + + if (closableLeftTabs.length === 0) { + console.warn('左侧没有可关闭的标签页') + return + } + + // 标记为缓存排除 + markTabsToRemove(closableLeftTabs) + + // 移除左侧可关闭的标签页 + opened.value = opened.value.filter( + (tab, index) => index >= targetIndex || !isTabClosable(tab) + ) + + // 确保当前标签是激活状态 + const targetTab = getTab(path) + if (targetTab) { + current.value = targetTab + } + } + + /** + * 关闭右侧选项卡 + */ + const removeRight = (path: string): void => { + const targetIndex = findTabIndex(path) + + if (targetIndex === -1) { + console.warn(`尝试关闭右侧标签页,但目标标签页不存在: ${path}`) + return + } + + // 获取右侧可关闭的标签页 + const rightTabs = opened.value.slice(targetIndex + 1) + const closableRightTabs = rightTabs.filter(isTabClosable) + + if (closableRightTabs.length === 0) { + console.warn('右侧没有可关闭的标签页') + return + } + + // 标记为缓存排除 + markTabsToRemove(closableRightTabs) + + // 移除右侧可关闭的标签页 + opened.value = opened.value.filter( + (tab, index) => index <= targetIndex || !isTabClosable(tab) + ) + + // 确保当前标签是激活状态 + const targetTab = getTab(path) + if (targetTab) { + current.value = targetTab + } + } + + /** + * 关闭其他选项卡 + */ + const removeOthers = (path: string): void => { + const targetTab = getTab(path) + + if (!targetTab) { + console.warn(`尝试关闭其他标签页,但目标标签页不存在: ${path}`) + return + } + + // 获取其他可关闭的标签页 + const otherTabs = opened.value.filter((tab) => tab.path !== path) + const closableTabs = otherTabs.filter(isTabClosable) + + if (closableTabs.length === 0) { + console.warn('没有其他可关闭的标签页') + return + } + + // 标记为缓存排除 + markTabsToRemove(closableTabs) + + // 只保留当前标签和固定标签 + opened.value = opened.value.filter((tab) => tab.path === path || !isTabClosable(tab)) + + // 确保当前标签是激活状态 + current.value = targetTab + } + + /** + * 关闭所有可关闭的标签页 + */ + const removeAll = (): void => { + const { homePath } = useCommon() + const hasFixedTabs = opened.value.some((tab) => tab.fixedTab) + + // 获取可关闭的标签页 + const closableTabs = opened.value.filter((tab) => { + if (!isTabClosable(tab)) return false + // 如果有固定标签,则所有可关闭的都可以关闭;否则保留首页 + return hasFixedTabs || tab.path !== homePath.value + }) + + if (closableTabs.length === 0) { + console.warn('没有可关闭的标签页') + return + } + + // 标记为缓存排除 + markTabsToRemove(closableTabs) + + // 保留不可关闭的标签页和首页(当没有固定标签时) + opened.value = opened.value.filter((tab) => { + return !isTabClosable(tab) || (!hasFixedTabs && tab.path === homePath.value) + }) + + // 处理激活状态 + if (!hasOpenedTabs.value) { + current.value = {} + safeRouterPush({ path: homePath.value }) + return + } + + // 选择激活的标签页:优先首页,其次第一个可用标签 + const homeTab = opened.value.find((tab) => tab.path === homePath.value) + const targetTab = homeTab || opened.value[0] + + current.value = targetTab + safeRouterPush(targetTab) + } + + /** + * 将指定选项卡添加到 keepAlive 排除列表中 + */ + const addKeepAliveExclude = (tab: WorkTab): void => { + if (!tab.keepAlive || !tab.name) return + + if (!keepAliveExclude.value.includes(tab.name)) { + keepAliveExclude.value.push(tab.name) + } + } + + /** + * 从 keepAlive 排除列表中移除指定组件名称 + */ + const removeKeepAliveExclude = (name: string): void => { + if (!name) return + + keepAliveExclude.value = keepAliveExclude.value.filter((item) => item !== name) + } + + /** + * 将传入的一组选项卡的组件名称标记为排除缓存 + */ + const markTabsToRemove = (tabs: WorkTab[]): void => { + tabs.forEach((tab) => { + if (tab.name) { + addKeepAliveExclude(tab) + } + }) + } + + /** + * 切换指定标签页的固定状态 + */ + const toggleFixedTab = (path: string): void => { + const targetIndex = findTabIndex(path) + + if (targetIndex === -1) { + console.warn(`尝试切换不存在标签页的固定状态: ${path}`) + return + } + + const tab = { ...opened.value[targetIndex] } + tab.fixedTab = !tab.fixedTab + + // 移除原位置 + opened.value.splice(targetIndex, 1) + + if (tab.fixedTab) { + // 固定标签插入到所有固定标签的末尾 + const firstNonFixedIndex = opened.value.findIndex((t) => !t.fixedTab) + const insertIndex = firstNonFixedIndex === -1 ? opened.value.length : firstNonFixedIndex + opened.value.splice(insertIndex, 0, tab) + } else { + // 非固定标签插入到所有固定标签后 + const fixedCount = opened.value.filter((t) => t.fixedTab).length + opened.value.splice(fixedCount, 0, tab) + } + + // 更新当前标签引用 + if (current.value.path === path) { + current.value = tab + } + } + + /** + * 验证工作台标签页的路由有效性 + */ + const validateWorktabs = (routerInstance: Router): void => { + try { + // 动态路由校验:优先使用路由 name 判断有效性;否则用 resolve 匹配参数化路径 + const isTabRouteValid = (tab: Partial): boolean => { + try { + if (tab.name) { + const routes = routerInstance.getRoutes() + if (routes.some((r) => r.name === tab.name)) return true + } + if (tab.path) { + const resolved = routerInstance.resolve({ + path: tab.path, + query: (tab.query as LocationQueryRaw) || undefined + }) + return resolved.matched.length > 0 + } + return false + } catch { + return false + } + } + + // 过滤出有效的标签页 + const validTabs = opened.value.filter((tab) => isTabRouteValid(tab)) + + if (validTabs.length !== opened.value.length) { + console.warn('发现无效的标签页路由,已自动清理') + opened.value = validTabs + } + + // 验证当前激活标签的有效性 + const isCurrentValid = current.value && isTabRouteValid(current.value) + + if (!isCurrentValid && validTabs.length > 0) { + console.warn('当前激活标签无效,已自动切换') + current.value = validTabs[0] + } else if (!isCurrentValid) { + current.value = {} + } + } catch (error) { + console.error('验证工作台标签页失败:', error) + } + } + + /** + * 清空所有状态(用于登出等场景) + */ + const clearAll = (): void => { + current.value = {} + opened.value = [] + keepAliveExclude.value = [] + } + + /** + * 获取状态快照(用于持久化存储) + */ + const getStateSnapshot = (): WorktabState => { + return { + current: { ...current.value }, + opened: [...opened.value], + keepAliveExclude: [...keepAliveExclude.value] + } + } + + /** + * 获取标签页标题 + */ + const getTabTitle = (path: string): WorkTab | undefined => { + const tab = getTab(path) + return tab + } + + /** + * 更新标签页标题 + */ + const updateTabTitle = (path: string, title: string): void => { + const tab = getTab(path) + if (tab) { + tab.customTitle = title + } + } + + /** + * 重置标签页标题 + */ + const resetTabTitle = (path: string): void => { + const tab = getTab(path) + if (tab) { + tab.customTitle = '' + } + } + + return { + // 状态 + current, + opened, + keepAliveExclude, + + // 计算属性 + hasOpenedTabs, + hasMultipleTabs, + currentTabIndex, + + // 方法 + openTab, + removeTab, + removeLeft, + removeRight, + removeOthers, + removeAll, + toggleFixedTab, + validateWorktabs, + clearAll, + getStateSnapshot, + + // 工具方法 + findTabIndex, + getTab, + isTabClosable, + addKeepAliveExclude, + removeKeepAliveExclude, + markTabsToRemove, + getTabTitle, + updateTabTitle, + resetTabTitle + } + }, + { + persist: { + key: 'worktab', + storage: localStorage + } + } +) diff --git a/web/apps/portal/src/types/api/api.d.ts b/web/apps/portal/src/types/api/api.d.ts new file mode 100644 index 0000000..d90fc75 --- /dev/null +++ b/web/apps/portal/src/types/api/api.d.ts @@ -0,0 +1,143 @@ +/** + * API 接口类型定义模块 + * + * 提供所有后端接口的类型定义 + * + * ## 主要功能 + * + * - 通用类型(分页参数、响应结构等) + * - 认证类型(登录、用户信息等) + * - 系统管理类型(用户、角色等) + * - 全局命名空间声明 + * + * ## 使用场景 + * + * - API 请求参数类型约束 + * - API 响应数据类型定义 + * - 接口文档类型同步 + * + * ## 注意事项 + * + * - 在 .vue 文件使用需要在 eslint.config.mjs 中配置 globals: { Api: 'readonly' } + * - 使用全局命名空间,无需导入即可使用 + * + * ## 使用方式 + * + * ```typescript + * const params: Api.Auth.LoginParams = { userName: 'admin', password: '123456' } + * const response: Api.Auth.UserInfo = await fetchUserInfo() + * ``` + * + * @module types/api/api + * @author Art Design Pro Team + */ + +declare namespace Api { + /** 通用类型 */ + namespace Common { + /** 分页参数 */ + interface PaginationParams { + /** 当前页码 */ + current: number + /** 每页条数 */ + size: number + /** 总条数 */ + total: number + } + + /** 通用搜索参数 */ + type CommonSearchParams = Pick + + /** 分页响应基础结构 */ + interface PaginatedResponse { + records: T[] + current: number + size: number + total: number + } + + /** 启用状态 */ + type EnableStatus = '1' | '2' + } + + /** 认证类型 */ + namespace Auth { + /** 登录参数 */ + interface LoginParams { + userName: string + password: string + } + + /** 登录响应 */ + interface LoginResponse { + token?: string + refreshToken?: string + require_totp?: boolean + temp_token?: string + } + + interface TOTPLoginParams { + temp_token: string + code?: string + backup_code?: string + } + + /** 用户信息 */ + interface UserInfo { + buttons: string[] + roles: string[] + userId: string + userName: string + email: string + avatar?: string + } + } + + /** 系统管理类型 */ + namespace SystemManage { + /** 用户列表 */ + type UserList = Api.Common.PaginatedResponse + + /** 用户列表项 */ + interface UserListItem { + id: number + avatar: string + status: string + userName: string + userGender: string + nickName: string + userPhone: string + userEmail: string + userRoles: string[] + createBy: string + createTime: string + updateBy: string + updateTime: string + } + + /** 用户搜索参数 */ + type UserSearchParams = Partial< + Pick & + Api.Common.CommonSearchParams + > + + /** 角色列表 */ + type RoleList = Api.Common.PaginatedResponse + + /** 角色列表项 */ + interface RoleListItem { + roleId: number + roleName: string + roleCode: string + description: string + enabled: boolean + createTime: string + } + + /** 角色搜索参数 */ + type RoleSearchParams = Partial< + Pick & + Api.Common.CommonSearchParams + > + } +} diff --git a/web/apps/portal/src/types/common/index.ts b/web/apps/portal/src/types/common/index.ts new file mode 100644 index 0000000..7e751d1 --- /dev/null +++ b/web/apps/portal/src/types/common/index.ts @@ -0,0 +1,95 @@ +/** + * 通用类型定义模块 + * + * 提供项目中常用的通用类型定义 + * + * ## 主要功能 + * + * - 状态类型(启用/禁用) + * - 性别类型 + * - 排序方向类型 + * - 操作类型(增删改查) + * - 记录类型(键值对) + * - 时间范围类型 + * - 文件信息类型 + * - 坐标和尺寸类型 + * - 响应式断点类型 + * - 主题和语言类型 + * - 环境和弹窗类型 + * + * ## 使用场景 + * + * - 通用数据结构定义 + * - 类型约束和提示 + * - 减少重复类型定义 + * + * @module types/common/index + * @author Art Design Pro Team + */ + +// 导出响应类型 +export * from './response' + +// 状态类型 +export type Status = 0 | 1 // 0: 禁用, 1: 启用 + +// 性别类型 +export type Gender = 'male' | 'female' | 'unknown' + +// 排序方向 +export type SortOrder = 'ascending' | 'descending' + +// 操作类型 +export type ActionType = 'create' | 'update' | 'delete' | 'view' + +// 可选的记录类型 +export type Recordable = Record + +// 键值对类型 +export type KeyValue = { + key: string + value: T + label?: string +} + +// 时间范围类型 +export interface TimeRange { + startTime: string + endTime: string +} + +// 文件类型 +export interface FileInfo { + name: string + url: string + size: number + type: string + lastModified?: number +} + +// 坐标类型 +export interface Position { + x: number + y: number +} + +// 尺寸类型 +export interface Size { + width: number + height: number +} + +// 响应式断点类型 +export type Breakpoint = 'xs' | 'sm' | 'md' | 'lg' | 'xl' + +// 主题类型 +export type ThemeMode = 'light' | 'dark' | 'auto' + +// 语言类型 +export type Language = 'zh-CN' | 'en-US' + +// 环境类型 +export type Environment = 'development' | 'production' | 'test' + +// 弹窗类型 +export type DialogType = 'add' | 'edit' diff --git a/web/apps/portal/src/types/common/response.ts b/web/apps/portal/src/types/common/response.ts new file mode 100644 index 0000000..4a5fdab --- /dev/null +++ b/web/apps/portal/src/types/common/response.ts @@ -0,0 +1,30 @@ +/** + * API 响应类型定义模块 + * + * 提供统一的 API 响应结构类型定义 + * + * ## 主要功能 + * + * - 基础响应结构定义 + * - 泛型支持(适配不同数据类型) + * - 统一的响应格式约束 + * + * ## 使用场景 + * + * - API 请求响应类型约束 + * - 接口数据类型定义 + * - 响应数据解析 + * + * @module types/common/response + * @author Art Design Pro Team + */ + +/** 基础 API 响应结构 */ +export interface BaseResponse { + /** 状态码 */ + code: number + /** 消息 */ + msg: string + /** 数据 */ + data: T +} diff --git a/web/apps/portal/src/types/component/chart.ts b/web/apps/portal/src/types/component/chart.ts new file mode 100644 index 0000000..c3225c9 --- /dev/null +++ b/web/apps/portal/src/types/component/chart.ts @@ -0,0 +1,324 @@ +/** + * 图表组件类型定义模块 + * + * 提供 ECharts 图表组件的完整类型定义 + * + * ## 主要功能 + * + * - 基础图表配置类型 + * - 柱状图类型定义 + * - 折线图类型定义 + * - 饼图/环形图类型定义 + * - 雷达图类型定义 + * - K线图类型定义 + * - 散点图类型定义 + * - 地图图表类型定义 + * - 双向堆叠柱状图类型定义 + * - 图表主题配置类型 + * - 图表事件回调类型 + * + * ## 使用场景 + * + * - 图表组件 Props 类型约束 + * - 图表配置类型定义 + * - 图表数据结构定义 + * - 图表事件处理 + * + * @module types/component/chart + * @author Art Design Pro Team + */ +import type { EChartsOption } from '@/plugins/echarts' + +// 图例位置类型 +export type LegendPosition = 'bottom' | 'top' | 'left' | 'right' + +export type SymbolType = + | 'circle' + | 'rect' + | 'roundRect' + | 'triangle' + | 'diamond' + | 'pin' + | 'arrow' + | 'none' + +// 图表主题配置 +export interface ChartThemeConfig { + /** 图表高度 */ + chartHeight: string + /** 字体大小 */ + fontSize: number + /** 字体颜色 */ + fontColor: string + /** 主题颜色 */ + themeColor: string + /** 颜色组 */ + colors: string[] +} + +// 图表初始化选项 +export interface UseChartOptions { + /** 初始化选项 */ + initOptions?: EChartsOption + /** 延迟初始化时间(ms) */ + initDelay?: number + /** IntersectionObserver阈值 */ + threshold?: number + /** 是否自动响应主题变化 */ + autoTheme?: boolean +} + +// 基础图表 Props 接口 - 统一所有图表的基础属性 +export interface BaseChartProps { + /** 图表高度 */ + height?: string + /** 是否加载中 */ + loading?: boolean + isEmpty?: boolean + /** 颜色配置 */ + colors?: string[] +} + +// 轴线显示控制接口 - 统一轴线相关配置 +export interface AxisDisplayProps { + /** 是否显示坐标轴标签 */ + showAxisLabel?: boolean + /** 是否显示坐标轴线 */ + showAxisLine?: boolean + /** 是否显示分割线 */ + showSplitLine?: boolean +} + +// 交互显示控制接口 - 统一交互相关配置 +export interface InteractionProps { + /** 是否显示提示框 */ + showTooltip?: boolean + /** 是否显示图例 */ + showLegend?: boolean + /** 图例位置 */ + legendPosition?: LegendPosition +} + +// 柱状图数据项接口 +export interface BarDataItem { + /** 系列名称 */ + name: string + /** 数据值 */ + data: number[] + /** 柱状图宽度 */ + barWidth?: string | number + /** 堆叠分组名称 */ + stack?: string +} + +// 柱状图 Props 接口 - 统一柱状图配置 +export interface BarChartProps extends BaseChartProps, AxisDisplayProps, InteractionProps { + /** 图表数据 - 支持单组数据或多组数据 */ + data: number[] | BarDataItem[] + /** X轴标签数据 */ + xAxisData?: string[] + /** 柱状图宽度 */ + barWidth?: string | number + /** 是否堆叠显示 */ + stack?: boolean + /** 圆角 */ + borderRadius?: number | number[] +} + +// 折线图数据项接口 +export interface LineDataItem { + /** 系列名称 */ + name: string + /** 数据值 */ + data: number[] + /** 线条宽度 */ + lineWidth?: number + /** 是否显示区域填充 */ + showAreaColor?: boolean + /** 区域样式配置 */ + areaStyle?: { + /** 渐变开始透明度 */ + startOpacity?: number + /** 渐变结束透明度 */ + endOpacity?: number + /** 自定义 ECharts areaStyle 配置 */ + custom?: any + } + /** 是否平滑曲线 */ + smooth?: boolean + /** 数据点符号 */ + symbol?: SymbolType + /** 数据点大小 */ + symbolSize?: number +} + +// 折线图 Props 接口 - 统一折线图配置 +export interface LineChartProps extends BaseChartProps, AxisDisplayProps, InteractionProps { + /** 图表数据 - 支持单组数据或多组数据 */ + data: number[] | LineDataItem[] + /** X轴标签数据 */ + xAxisData?: string[] + /** 线条宽度 */ + lineWidth?: number + /** 是否显示区域填充 */ + showAreaColor?: boolean + /** 是否平滑曲线 */ + smooth?: boolean + /** 数据点符号 */ + symbol?: SymbolType + /** 数据点大小 */ + symbolSize?: number + /** 多数据动画延迟间隔(毫秒) */ + animationDelay?: number +} + +// 雷达图数据项接口 +export interface RadarDataItem { + /** 系列名称 */ + name: string + /** 数据值 */ + value: number[] +} + +// 雷达图 Props 接口 - 统一雷达图配置 +export interface RadarChartProps extends BaseChartProps, InteractionProps { + /** 雷达图指标配置 */ + indicator?: Array<{ name: string; max: number }> + /** 图表数据 */ + data?: RadarDataItem[] +} + +// 饼图/环形图数据项接口 +export interface PieDataItem { + /** 数据值 */ + value: number + /** 数据名称 */ + name: string +} + +// 环形图 Props 接口 - 统一环形图配置 +export interface RingChartProps extends BaseChartProps, InteractionProps { + /** 图表数据 */ + data: PieDataItem[] + /** 内外半径 */ + radius?: string[] + /** 边框圆角 */ + borderRadius?: number + /** 中心文本 */ + centerText?: string + /** 是否显示标签 */ + showLabel?: boolean +} + +// K线图数据项接口 +export interface KLineDataItem { + /** 时间标签 */ + time: string + /** 开盘价 */ + open: number + /** 收盘价 */ + close: number + /** 最高价 */ + high: number + /** 最低价 */ + low: number +} + +// K线图 Props 接口 - 统一K线图配置 +export interface KLineChartProps extends BaseChartProps { + /** 图表数据 */ + data?: KLineDataItem[] + /** 是否显示数据缩放控件 */ + showDataZoom?: boolean + /** 数据缩放初始开始位置 */ + dataZoomStart?: number + /** 数据缩放初始结束位置 */ + dataZoomEnd?: number +} + +// 散点图数据项接口 +export interface ScatterDataItem { + /** 坐标值 [x, y] */ + value: number[] +} + +// 散点图 Props 接口 - 统一散点图配置 +export interface ScatterChartProps extends BaseChartProps, AxisDisplayProps, InteractionProps { + /** 图表数据 */ + data?: ScatterDataItem[] + /** 散点大小 */ + symbolSize?: number +} + +// 双柱对比图 Props 接口 - 统一双柱对比图配置 +export interface DualBarCompareChartProps extends BaseChartProps { + /** 上方数据 */ + topData: number[] + /** 下方数据 */ + bottomData: number[] + /** X轴标签数据 */ + xAxisData: string[] + /** 上方柱子颜色 */ + topColor?: string + /** 下方柱子颜色 */ + bottomColor?: string + /** 柱状图宽度 */ + barWidth?: number +} + +// 地图图表 Props 接口 - 统一地图图表配置 +export interface MapChartProps extends BaseChartProps { + /** 地图数据 */ + mapData?: any[] + /** 选中区域 */ + selectedRegion?: string + /** 是否显示标签 */ + showLabels?: boolean + /** 是否显示散点 */ + showScatter?: boolean +} + +// 双向堆叠柱状图 Props 接口(人口金字塔样式) +export interface BidirectionalBarChartProps + extends BaseChartProps, + AxisDisplayProps, + InteractionProps { + /** 正向数据(向上显示) */ + positiveData: number[] + /** 负向数据(向下显示) */ + negativeData: number[] + /** X轴标签数据 */ + xAxisData?: string[] + /** 正向数据名称 */ + positiveName?: string + /** 负向数据名称 */ + negativeName?: string + /** 柱状图宽度 */ + barWidth?: string | number + /** Y轴最小值 */ + yAxisMin?: number + /** Y轴最大值 */ + yAxisMax?: number + /** 是否显示数据标签 */ + showDataLabel?: boolean + /** 正向数据圆角配置 */ + positiveBorderRadius?: number | number[] + /** 负向数据圆角配置 */ + negativeBorderRadius?: number | number[] +} + +// 图表配置生成器函数类型 +export type ChartOptionGenerator = () => EChartsOption + +// 图表事件回调类型 +export type ChartEventCallback = (params: any) => void + +// 图表错误信息接口 +export interface ChartError { + /** 错误码 */ + code: string + /** 错误信息 */ + message: string + /** 错误详情 */ + details?: any +} diff --git a/web/apps/portal/src/types/component/index.ts b/web/apps/portal/src/types/component/index.ts new file mode 100644 index 0000000..b9e90c6 --- /dev/null +++ b/web/apps/portal/src/types/component/index.ts @@ -0,0 +1,145 @@ +/** + * 组件类型定义模块 + * + * 提供项目组件的类型定义 + * + * ## 主要功能 + * + * - 搜索组件类型定义 + * - 表格列配置类型 + * - 分页配置类型 + * - 表单规则类型 + * - 对话框配置类型 + * + * ## 使用场景 + * + * - 组件 Props 类型约束 + * - 组件配置类型定义 + * - 组件事件参数类型 + * + * @module types/component/index + * @author Art Design Pro Team + */ + +// 搜索组件类型 +export type SearchComponentType = + | 'input' + | 'select' + | 'radio' + | 'checkbox' + | 'date' + | 'datetime' + | 'daterange' + | 'datetimerange' + | 'month' + | 'monthrange' + | 'year' + | 'yearrange' + | 'week' + | 'time' + | 'timerange' + +// 搜索框值变化参数 +export interface SearchChangeParams { + prop: string + val: unknown +} + +// 表格列配置接口 +export interface ColumnOption { + // 列类型 + type?: 'selection' | 'expand' | 'index' | 'globalIndex' + // 列属性名 + prop?: string + // 列标题 + label?: string + // 列宽度 + width?: string | number + // 最小列宽度 + minWidth?: string | number + // 固定列 + fixed?: boolean | 'left' | 'right' + // 是否可排序 + sortable?: boolean | 'custom' + // 过滤器选项 + filters?: any[] + // 过滤方法 + filterMethod?: (value: any, row: any) => boolean + // 过滤器位置 + filterPlacement?: string + // 是否禁用 + disabled?: boolean + // 是否显示列 + visible?: boolean + // 是否选中显示 + checked?: boolean + // 自定义渲染函数 + formatter?: (row: T) => any + // 插槽相关配置 + // 是否使用插槽渲染内容 + useSlot?: boolean + // 插槽名称(默认为 prop 值) + slotName?: string + // 是否使用表头插槽 + useHeaderSlot?: boolean + // 表头插槽名称(默认为 `${prop}-header`) + headerSlotName?: string + // 其他属性 + [key: string]: any +} + +// 分页配置 +export interface PaginationConfig { + // 当前页 + currentPage: number + // 每页条数 + pageSize: number + // 总条数 + total: number + // 每页显示个数选择器的选项 + pageSizes?: number[] + // 组件布局 + layout?: string + // 是否为小型分页 + small?: boolean +} + +// 表单规则 +export interface FormRule { + // 是否必填 + required?: boolean + // 错误提示信息 + message?: string + // 触发方式 + trigger?: string | string[] + // 最小长度 + min?: number + // 最大长度 + max?: number + // 正则表达式 + pattern?: RegExp + // 自定义验证函数 + validator?: (rule: any, value: any, callback: any) => void +} + +// 对话框配置 +export interface DialogConfig { + // 标题 + title: string + // 是否显示 + visible: boolean + // 宽度 + width?: string | number + // 是否可以通过点击 modal 关闭 + closeOnClickModal?: boolean + // 是否可以通过按下 ESC 关闭 + closeOnPressEscape?: boolean + // 是否显示关闭按钮 + showClose?: boolean + // 是否在 Dialog 出现时将 body 滚动锁定 + lockScroll?: boolean + // 是否显示遮罩层 + modal?: boolean + // 自定义类名 + customClass?: string +} diff --git a/web/apps/portal/src/types/config/index.ts b/web/apps/portal/src/types/config/index.ts new file mode 100644 index 0000000..60ac283 --- /dev/null +++ b/web/apps/portal/src/types/config/index.ts @@ -0,0 +1,213 @@ +/** + * 配置类型定义模块 + * + * 提供系统配置相关的类型定义 + * + * ## 主要功能 + * + * - 主题设置类型 + * - 菜单布局类型 + * - 节日配置类型 + * - 系统基础配置类型 + * - 快速入口配置类型 + * - 顶部栏功能配置类型 + * - 环境配置类型 + * - 应用配置类型 + * + * ## 使用场景 + * + * - 系统配置文件类型约束 + * - 配置项类型定义 + * - 配置数据验证 + * + * @module types/config/index + * @author Art Design Pro Team + */ + +import { MenuTypeEnum, SystemThemeEnum } from '@/enums/appEnum' +import { MenuThemeType, SystemThemeTypes } from '@/types/store' + +// 主题设置 +export interface ThemeSetting { + /** 主题名称 */ + name: string + /** 系统主题类型 */ + theme: SystemThemeEnum + /** 主题颜色数组 */ + color: string[] + /** 左侧线条颜色 */ + leftLineColor: string + /** 右侧线条颜色 */ + rightLineColor: string + /** 主题图片 */ + img: string +} + +// 菜单布局 +export interface MenuLayout { + /** 布局名称 */ + name: string + /** 菜单类型值 */ + value: MenuTypeEnum + /** 布局预览图 */ + img: string + /** 布局描述 */ + description?: string +} + +// 节日配置 +export interface FestivalConfig { + /** 节日日期(单日)或开始日期(日期范围) */ + date: string + /** 节日结束日期(可选,用于跨日期节日) */ + endDate?: string + /** 节日名称 */ + name: string + /** 烟花图片 */ + image: string + /** 滚动文本 */ + scrollText: string + /** 是否激活 */ + isActive?: boolean + /** 烟花播放次数(可选,默认为 3 次) */ + count?: number +} + +// 系统基础配置 +export interface SystemBasicConfig { + // 系统名称 + name: string + // 紧凑区域(侧边栏/顶栏)显示的短名称 + shortName?: string + // 系统描述 + description?: string + // 系统logo + logo?: string + // 系统favicon + favicon?: string + // 版权信息 + copyright?: string +} + +// 快速入口基础项 +export interface FastEnterBaseItem { + /** 名称 */ + name: string + /** 是否启用 */ + enabled?: boolean + /** 排序权重 */ + order?: number + /** 路由名称 */ + routeName?: string + /** 外部链接 */ + link?: string +} + +// 快速入口应用项 +export interface FastEnterApplication extends FastEnterBaseItem { + /** 应用描述 */ + description: string + /** 图标代码 */ + icon: string + /** 图标颜色 */ + iconColor: string +} + +// 快速链接项 +export type FastEnterQuickLink = FastEnterBaseItem + +// 快速入口配置 +export interface FastEnterConfig { + /** 应用列表 */ + applications: FastEnterApplication[] + /** 快速链接 */ + quickLinks: FastEnterQuickLink[] + /** 显示条件(屏幕宽度) */ + minWidth?: number +} + +// 系统配置 +export interface SystemConfig { + // 系统基础信息 + systemInfo: SystemBasicConfig + // 系统主题样式 + systemThemeStyles: SystemThemeTypes + // 设置主题列表 + settingThemeList: ThemeSetting[] + // 菜单布局列表 + menuLayoutList: MenuLayout[] + // 主题列表 + themeList: MenuThemeType[] + // 暗色菜单样式 + darkMenuStyles: MenuThemeType[] + // 系统主色调 + systemMainColor: readonly string[] + // 快速入口配置 + fastEnter?: FastEnterConfig + // 顶部栏功能配置 + headerBar?: HeaderBarFeatureConfig +} + +// 环境配置 +export interface EnvConfig { + // 环境名称 + NODE_ENV: string + // 应用版本 + VITE_VERSION: string + // 应用端口 + VITE_PORT: string + // 应用基础路径 + VITE_BASE_URL: string + // API 地址 + VITE_API_URL: string + // 是否开启 Mock + VITE_USE_MOCK?: string + // 是否开启压缩 + VITE_USE_GZIP?: string + // 是否开启 CDN + VITE_USE_CDN?: string +} + +// 应用配置 +export interface AppConfig extends SystemConfig { + // 环境配置 + env: EnvConfig + // 开发模式 + isDev: boolean + // 生产模式 + isProd: boolean + // 测试模式 + isTest: boolean +} + +// 功能配置项基础接口 +export interface FeatureConfigItem { + enabled: boolean + description: string +} + +// 顶部栏功能配置接口 +export interface HeaderBarFeatureConfig { + /** 菜单按钮 */ + menuButton: FeatureConfigItem + /** 刷新按钮 */ + refreshButton: FeatureConfigItem + /** 快速入口 */ + fastEnter: FeatureConfigItem + /** 面包屑导航 */ + breadcrumb: FeatureConfigItem + /** 全局搜索 */ + globalSearch: FeatureConfigItem + /** 全屏功能 */ + fullscreen: FeatureConfigItem + /** 通知功能 */ + notification: FeatureConfigItem + /** 聊天功能 */ + chat: FeatureConfigItem + /** 多语言切换 */ + language: FeatureConfigItem + /** 设置面板 */ + settings: FeatureConfigItem + /** 主题切换 */ + themeToggle: FeatureConfigItem +} diff --git a/web/apps/portal/src/types/directive/directive.d.ts b/web/apps/portal/src/types/directive/directive.d.ts new file mode 100644 index 0000000..eb0154e --- /dev/null +++ b/web/apps/portal/src/types/directive/directive.d.ts @@ -0,0 +1,15 @@ +import type { + AuthDirective, + RolesDirective, + RippleDirective, + HighlightDirective +} from '@/directives' + +declare module 'vue' { + export interface GlobalDirectives { + vAuth: AuthDirective + vRoles: RolesDirective + vRipple: RippleDirective + vHighlight: HighlightDirective + } +} diff --git a/web/apps/portal/src/types/index.ts b/web/apps/portal/src/types/index.ts new file mode 100644 index 0000000..9032fd2 --- /dev/null +++ b/web/apps/portal/src/types/index.ts @@ -0,0 +1,22 @@ +/** + * 类型定义统一导出模块 + * 提供全局类型定义的统一导出入口 + * + * @module types/index + * @author Art Design Pro Team + */ + +/** 通用类型定义(基础类型、工具类型等) */ +export * from './common' + +/** 组件相关类型定义 */ +export * from './component' + +/** 状态管理相关类型定义 */ +export * from './store' + +/** 路由相关类型定义 */ +export * from './router' + +/** 配置相关类型定义 */ +export * from './config' diff --git a/web/apps/portal/src/types/router/index.ts b/web/apps/portal/src/types/router/index.ts new file mode 100644 index 0000000..d9ef012 --- /dev/null +++ b/web/apps/portal/src/types/router/index.ts @@ -0,0 +1,80 @@ +/** + * 路由类型定义模块 + * + * 提供路由相关的类型定义 + * + * ## 主要功能 + * + * - 路由元数据类型(标题、图标、权限等) + * - 应用路由记录类型 + * - 路由配置扩展 + * + * ## 使用场景 + * + * - 路由配置类型约束 + * - 路由元数据定义 + * - 菜单生成 + * - 权限控制 + * + * @module types/router/index + * @author Art Design Pro Team + */ + +import { RouteRecordRaw } from 'vue-router' + +/** + * 路由元数据接口 + * 定义路由的各种配置属性 + */ +export interface RouteMeta extends Record { + /** 路由标题 */ + title: string + /** 路由图标 */ + icon?: string + /** 是否显示徽章 */ + showBadge?: boolean + /** 文本徽章 */ + showTextBadge?: string + /** 是否在菜单中隐藏 */ + isHide?: boolean + /** 是否在标签页中隐藏 */ + isHideTab?: boolean + /** 外部链接 */ + link?: string + /** 是否为iframe */ + isIframe?: boolean + /** 是否缓存 */ + keepAlive?: boolean + /** 操作权限 */ + authList?: Array<{ + title: string + authMark: string + }> + /** 是否为一级菜单 */ + isFirstLevel?: boolean + /** 角色权限 */ + roles?: string[] + /** 是否固定标签页 */ + fixedTab?: boolean + /** 激活菜单路径 */ + activePath?: string + /** 是否为全屏页面 */ + isFullPage?: boolean + /** 是否为权限按钮行 */ + isAuthButton?: boolean + /** 权限标识 */ + authMark?: string + /** 父级路径 */ + parentPath?: string +} + +/** + * 应用路由记录接口 + * 扩展 Vue Router 的路由记录类型 + */ +export interface AppRouteRecord extends Omit { + id?: number + meta: RouteMeta + children?: AppRouteRecord[] + component?: string | (() => Promise) +} diff --git a/web/apps/portal/src/types/store/index.ts b/web/apps/portal/src/types/store/index.ts new file mode 100644 index 0000000..019801e --- /dev/null +++ b/web/apps/portal/src/types/store/index.ts @@ -0,0 +1,157 @@ +/** + * Store 状态类型定义模块 + * + * 提供 Pinia Store 的状态类型定义 + * + * ## 主要功能 + * + * - 系统主题类型 + * - 菜单主题类型 + * - 设置状态类型 + * - 工作标签页类型 + * - 用户状态类型 + * - 菜单状态类型 + * - 根状态类型 + * + * ## 使用场景 + * + * - Store 状态类型约束 + * - 状态数据结构定义 + * - 类型提示和自动补全 + * + * @module types/store/index + * @author Art Design Pro Team + */ + +import { MenuThemeEnum, SystemThemeEnum } from '@/enums/appEnum' +import { LocationQueryRaw } from 'vue-router' + +// 系统主题样式(light | dark) +export interface SystemThemeType { + /** 主题类名 */ + className: string +} + +// 定义包含多个主题的类型 +export type SystemThemeTypes = { + [key in Exclude]: SystemThemeType +} + +// 菜单主题样式 +export interface MenuThemeType { + /** 主题类型 */ + theme: MenuThemeEnum + /** 背景颜色 */ + background: string + /** 系统名称颜色 */ + systemNameColor: string + /** 文本颜色 */ + textColor: string + /** 图标颜色 */ + iconColor: string + /** 背景图片 */ + img?: string +} + +// 设置中心 +export interface SettingState { + /** 主题 */ + theme: string + /** 是否只保持一个子菜单的展开 */ + uniqueOpened: boolean + /** 是否显示菜单按钮 */ + menuButton: boolean + /** 是否显示刷新按钮 */ + showRefreshButton: boolean + /** 是否显示面包屑 */ + showCrumbs: boolean + /** 是否自动关闭 */ + autoClose: boolean + /** 是否显示工作标签页 */ + showWorkTab: boolean + /** 是否显示语言切换 */ + showLanguage: boolean + /** 是否显示进度条 */ + showNprogress: boolean + /** 主题模式 */ + themeModel: string +} + +// 多标签 +export interface WorkTab { + /** 标签标题 */ + title: string + /** 自定义标题 */ + customTitle?: string + /** 路由路径 */ + path: string + /** 路由名称 */ + name: string + /** 是否缓存 */ + keepAlive: boolean + /** 是否固定标签 */ + fixedTab?: boolean + /** 路由参数 */ + params?: object + /** 路由查询参数 */ + query?: LocationQueryRaw + /** 图标 */ + icon?: string + /** 是否激活 */ + isActive?: boolean +} + +// 用户Store状态 +export interface UserState { + /** 用户信息 */ + userInfo: Api.Auth.UserInfo | null + /** 认证令牌 */ + token: string | null + /** 用户角色列表 */ + roles: string[] + /** 用户权限列表 */ + permissions: string[] +} + +// 设置Store状态 +export interface SettingStoreState extends SettingState { + // 额外的设置状态 + /** 菜单是否折叠 */ + collapsed: boolean + /** 设备类型 */ + device: 'desktop' | 'mobile' + /** 当前语言 */ + language: string +} + +// 工作标签页Store状态 +export interface WorkTabState { + /** 标签页列表 */ + tabs: WorkTab[] + /** 当前激活的标签页 */ + activeTab: string + /** 缓存的标签页列表 */ + cachedTabs: string[] +} + +// 菜单Store状态 +export interface MenuState { + /** 菜单列表 */ + menuList: any[] + /** 菜单是否已加载 */ + isLoaded: boolean + /** 菜单是否折叠 */ + collapsed: boolean +} + +// 根Store状态类型 +export interface RootState { + /** 用户状态 */ + user: UserState + /** 设置状态 */ + setting: SettingStoreState + /** 工作标签页状态 */ + workTab: WorkTabState + /** 菜单状态 */ + menu: MenuState +} diff --git a/web/apps/portal/src/utils/constants/index.ts b/web/apps/portal/src/utils/constants/index.ts new file mode 100644 index 0000000..831be29 --- /dev/null +++ b/web/apps/portal/src/utils/constants/index.ts @@ -0,0 +1,8 @@ +/** + * 常量定义相关工具函数统一导出 + * + * @module utils/constants/index + * @author Art Design Pro Team + */ + +export * from './links' diff --git a/web/apps/portal/src/utils/constants/links.ts b/web/apps/portal/src/utils/constants/links.ts new file mode 100644 index 0000000..06d297e --- /dev/null +++ b/web/apps/portal/src/utils/constants/links.ts @@ -0,0 +1,35 @@ +/** + * 网站链接常量配置 + * 集中管理便于维护和更新链接地址 + * + * @module utils/constants/links + * @author Art Design Pro Team + */ +export const WEB_LINKS = { + // Github 主页 + GITHUB_HOME: 'https://github.com/Daymychen/art-design-pro', + + // 项目 Github 主页 + GITHUB: 'https://github.com/Daymychen/art-design-pro', + + // 个人博客 + BLOG: 'https://www.artd.pro', + + // 项目文档 + DOCS: 'https://www.artd.pro/docs/zh/', + + // 精简版本 + LiteVersion: 'https://www.artd.pro/docs/zh/guide/lite-version.html', + + // v2.6.1版本 + OldVersion: 'https://www.artd.pro/v2/', + + // 项目社区 + COMMUNITY: 'https://www.artd.pro/docs/zh/community/communicate.html', + + // 个人 Bilibili 主页 + BILIBILI: 'https://space.bilibili.com/425500936?spm_id_from=333.1007.0.0', + + // 项目介绍 + INTRODUCE: 'https://www.artd.pro/docs/zh/guide/introduce.html' +} diff --git a/web/apps/portal/src/utils/form/index.ts b/web/apps/portal/src/utils/form/index.ts new file mode 100644 index 0000000..ed23a46 --- /dev/null +++ b/web/apps/portal/src/utils/form/index.ts @@ -0,0 +1,12 @@ +/** + * 表单工具函数统一导出 + * + * @module utils/form + * @author Art Design Pro Team + */ + +// 表单验证器 +export * from './validator' + +// 响应式布局 +export * from './responsive' diff --git a/web/apps/portal/src/utils/form/responsive.ts b/web/apps/portal/src/utils/form/responsive.ts new file mode 100644 index 0000000..c11df92 --- /dev/null +++ b/web/apps/portal/src/utils/form/responsive.ts @@ -0,0 +1,122 @@ +/** + * 表单响应式布局工具模块 + * + * 提供表单项在不同屏幕尺寸下的智能布局计算 + * + * ## 主要功能 + * + * - 响应式断点管理(xs/sm/md/lg/xl) + * - 表单列宽自动降级(避免小屏幕压缩) + * - 基于阈值的智能 span 计算 + * - 响应式计算器工厂函数 + * - 可配置的断点规则 + * + * ## 使用场景 + * + * - 表单组件响应式布局 + * - 搜索表单自适应 + * - 移动端表单优化 + * - 多列表单布局 + * + * ## 断点说明(基于 Element Plus Grid 24 栅格系统): + * - xs (手机): < 768px,小于 12 时降级为 24(满宽) + * - sm (平板): ≥ 768px,小于 12 时降级为 12(半宽) + * - md (中等屏幕): ≥ 992px,小于 8 时降级为 8(三分之一宽) + * - lg (大屏幕): ≥ 1200px,直接使用设置的 span + * - xl (超大屏幕): ≥ 1920px,直接使用设置的 span + * + * ## 核心功能 + * + * - calculateResponsiveSpan: 计算响应式列宽 + * - createResponsiveSpanCalculator: 创建 span 计算器(柯里化) + * + * @module utils/form/responsive + * @author Art Design Pro Team + */ + +/** + * 响应式断点类型 + */ +export type ResponsiveBreakpoint = 'xs' | 'sm' | 'md' | 'lg' | 'xl' + +/** + * 断点配置映射 + */ +interface BreakpointConfig { + /** 最小 span 阈值 */ + threshold: number + /** 降级后的 span 值 */ + fallback: number +} + +/** + * 响应式断点配置 + */ +const BREAKPOINT_CONFIG: Record = { + xs: { threshold: 12, fallback: 24 }, // 手机:小于 12 时使用满宽 + sm: { threshold: 12, fallback: 12 }, // 平板:小于 12 时使用半宽 + md: { threshold: 8, fallback: 8 }, // 中等屏幕:小于 8 时使用三分之一宽 + lg: null, // 大屏幕:直接使用设置的 span + xl: null // 超大屏幕:直接使用设置的 span +} + +/** + * 计算响应式列宽 + * + * 根据屏幕尺寸智能降级,避免小屏幕上表单项被压缩过小 + * + * @param itemSpan 表单项自定义的 span 值 + * @param defaultSpan 默认的 span 值 + * @param breakpoint 当前断点 + * @returns 计算后的 span 值 + * + * @example + * ```ts + * // 在 xs 断点下,span 为 6 会降级为 24(满宽) + * calculateResponsiveSpan(6, 6, 'xs') // 24 + * + * // 在 md 断点下,span 为 6 会降级为 8(三分之一宽) + * calculateResponsiveSpan(6, 6, 'md') // 8 + * + * // 在 lg 断点下,直接使用原始 span + * calculateResponsiveSpan(6, 6, 'lg') // 6 + * ``` + */ +export function calculateResponsiveSpan( + itemSpan: number | undefined, + defaultSpan: number, + breakpoint: ResponsiveBreakpoint +): number { + const finalSpan = itemSpan ?? defaultSpan + const config = BREAKPOINT_CONFIG[breakpoint] + + // 如果没有配置(lg/xl),直接返回原始 span + if (!config) { + return finalSpan + } + + // 如果 span 小于阈值,使用降级值 + return finalSpan >= config.threshold ? finalSpan : config.fallback +} + +/** + * 创建响应式 span 计算器 + * + * 返回一个函数,用于计算指定断点下的 span 值 + * + * @param defaultSpan 默认的 span 值 + * @returns span 计算函数 + * + * @example + * ```ts + * const getColSpan = createResponsiveSpanCalculator(6) + * getColSpan(undefined, 'xs') // 24 + * getColSpan(8, 'md') // 8 + * getColSpan(12, 'lg') // 12 + * ``` + */ +export function createResponsiveSpanCalculator(defaultSpan: number) { + return (itemSpan: number | undefined, breakpoint: ResponsiveBreakpoint): number => { + return calculateResponsiveSpan(itemSpan, defaultSpan, breakpoint) + } +} diff --git a/web/apps/portal/src/utils/form/validator.ts b/web/apps/portal/src/utils/form/validator.ts new file mode 100644 index 0000000..3670763 --- /dev/null +++ b/web/apps/portal/src/utils/form/validator.ts @@ -0,0 +1,316 @@ +/** + * 表单验证工具模块 + * + * 提供全面的表单字段验证功能 + * + * ## 主要功能 + * + * - 手机号码验证(中国大陆格式) + * - 固定电话验证(支持区号格式) + * - 用户账号验证(字母开头,支持数字和下划线) + * - 密码强度验证(普通密码、强密码) + * - 密码强度评估(弱、中、强) + * - IPv4 地址验证 + * - 邮箱地址验证(RFC 5322 标准) + * - URL 地址验证 + * - 身份证号码验证(18位,含校验码验证) + * - 银行卡号验证(Luhn 算法) + * - 字符串空格处理 + * + * ## 验证规则 + * + * - 手机号:1开头,第二位3-9,共11位 + * - 账号:字母开头,5-20位,支持字母数字下划线 + * - 普通密码:6-20位,必须包含字母和数字 + * - 强密码:8-20位,必须包含大小写字母、数字和特殊字符 + * - 身份证:18位,含出生日期和校验码验证 + * - 银行卡:13-19位,通过 Luhn 算法验证 + * + * @module utils/validation/formValidator + * @author Art Design Pro Team + */ + +/** + * 密码强度级别枚举 + */ +export enum PasswordStrength { + WEAK = '弱', + MEDIUM = '中', + STRONG = '强' +} + +/** + * 去除字符串首尾空格 + * @param value 待处理的字符串 + * @returns 返回去除首尾空格后的字符串 + */ +export function trimSpaces(value: string): string { + if (typeof value !== 'string') { + return '' + } + return value.trim() +} + +/** + * 验证手机号码(中国大陆) + * @param value 手机号码字符串 + * @returns 返回验证结果,true表示格式正确 + */ +export function validatePhone(value: string): boolean { + if (!value || typeof value !== 'string') { + return false + } + + // 中国大陆手机号码:1开头,第二位为3-9,共11位数字 + const phoneRegex = /^1[3-9]\d{9}$/ + return phoneRegex.test(value.trim()) +} + +/** + * 验证固定电话号码(中国大陆) + * @param value 电话号码字符串 + * @returns 返回验证结果,true表示格式正确 + */ +export function validateTelPhone(value: string): boolean { + if (!value || typeof value !== 'string') { + return false + } + + // 支持格式:区号-号码,如:010-12345678、0755-1234567 + const telRegex = /^0\d{2,3}-?\d{7,8}$/ + return telRegex.test(value.trim().replace(/\s+/g, '')) +} + +/** + * 验证用户账号 + * @param value 账号字符串 + * @returns 返回验证结果,true表示格式正确 + * @description 规则:字母开头,5-20位,支持字母、数字、下划线 + */ +export function validateAccount(value: string): boolean { + if (!value || typeof value !== 'string') { + return false + } + + // 字母开头,5-20位,支持字母、数字、下划线 + const accountRegex = /^[a-zA-Z][a-zA-Z0-9_]{4,19}$/ + return accountRegex.test(value.trim()) +} + +/** + * 验证密码 + * @param value 密码字符串 + * @returns 返回验证结果,true表示格式正确 + * @description 规则:6-20位,必须包含字母和数字 + */ +export function validatePassword(value: string): boolean { + if (!value || typeof value !== 'string') { + return false + } + + const trimmedValue = value.trim() + + // 长度检查 + if (trimmedValue.length < 6 || trimmedValue.length > 20) { + return false + } + + // 必须包含字母和数字 + const hasLetter = /[a-zA-Z]/.test(trimmedValue) + const hasNumber = /\d/.test(trimmedValue) + + return hasLetter && hasNumber +} + +/** + * 验证强密码 + * @param value 密码字符串 + * @returns 返回验证结果,true表示格式正确 + * @description 规则:8-20位,必须包含大写字母、小写字母、数字和特殊字符 + */ +export function validateStrongPassword(value: string): boolean { + if (!value || typeof value !== 'string') { + return false + } + + const trimmedValue = value.trim() + + // 长度检查 + if (trimmedValue.length < 8 || trimmedValue.length > 20) { + return false + } + + // 必须包含:大写字母、小写字母、数字、特殊字符 + const hasUpperCase = /[A-Z]/.test(trimmedValue) + const hasLowerCase = /[a-z]/.test(trimmedValue) + const hasNumber = /\d/.test(trimmedValue) + const hasSpecialChar = /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(trimmedValue) + + return hasUpperCase && hasLowerCase && hasNumber && hasSpecialChar +} + +/** + * 获取密码强度 + * @param value 密码字符串 + * @returns 返回密码强度:弱、中、强 + * @description 弱:纯数字/纯字母/纯特殊字符;中:两种组合;强:三种或以上组合 + */ +export function getPasswordStrength(value: string): PasswordStrength { + if (!value || typeof value !== 'string') { + return PasswordStrength.WEAK + } + + const trimmedValue = value.trim() + + if (trimmedValue.length < 6) { + return PasswordStrength.WEAK + } + + const hasUpperCase = /[A-Z]/.test(trimmedValue) + const hasLowerCase = /[a-z]/.test(trimmedValue) + const hasNumber = /\d/.test(trimmedValue) + const hasSpecialChar = /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(trimmedValue) + + const typeCount = [hasUpperCase, hasLowerCase, hasNumber, hasSpecialChar].filter(Boolean).length + + if (typeCount >= 3) { + return PasswordStrength.STRONG + } else if (typeCount >= 2) { + return PasswordStrength.MEDIUM + } else { + return PasswordStrength.WEAK + } +} + +/** + * 验证IPv4地址 + * @param value IP地址字符串 + * @returns 返回验证结果,true表示格式正确 + */ +export function validateIPv4Address(value: string): boolean { + if (!value || typeof value !== 'string') { + return false + } + + const trimmedValue = value.trim() + const ipRegex = /^((25[0-5]|2[0-4]\d|[01]?\d{1,2})\.){3}(25[0-5]|2[0-4]\d|[01]?\d{1,2})$/ + + if (!ipRegex.test(trimmedValue)) { + return false + } + + // 额外检查每个段是否在有效范围内 + const segments = trimmedValue.split('.') + return segments.every((segment) => { + const num = parseInt(segment, 10) + return num >= 0 && num <= 255 + }) +} + +/** + * 验证邮箱地址 + * @param value 邮箱地址字符串 + * @returns 返回验证结果,true表示格式正确 + */ +export function validateEmail(value: string): boolean { + if (!value || typeof value !== 'string') { + return false + } + + const trimmedValue = value.trim() + + // RFC 5322 标准的简化版邮箱正则 + const emailRegex = + /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/ + + return emailRegex.test(trimmedValue) && trimmedValue.length <= 254 +} + +/** + * 验证URL地址 + * @param value URL字符串 + * @returns 返回验证结果,true表示格式正确 + */ +export function validateURL(value: string): boolean { + if (!value || typeof value !== 'string') { + return false + } + + try { + new URL(value.trim()) + return true + } catch { + return false + } +} + +/** + * 验证身份证号码(中国大陆) + * @param value 身份证号码字符串 + * @returns 返回验证结果,true表示格式正确 + */ +export function validateChineseIDCard(value: string): boolean { + if (!value || typeof value !== 'string') { + return false + } + + const trimmedValue = value.trim() + + // 18位身份证号码正则 + const idCardRegex = + /^[1-9]\d{5}(18|19|20)\d{2}((0[1-9])|(1[0-2]))(([0-2][1-9])|10|20|30|31)\d{3}[0-9Xx]$/ + + if (!idCardRegex.test(trimmedValue)) { + return false + } + + // 验证校验码 + const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2] + const checkCodes = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'] + + let sum = 0 + for (let i = 0; i < 17; i++) { + sum += parseInt(trimmedValue[i]) * weights[i] + } + + const checkCode = checkCodes[sum % 11] + return trimmedValue[17].toUpperCase() === checkCode +} + +/** + * 验证银行卡号 + * @param value 银行卡号字符串 + * @returns 返回验证结果,true表示格式正确 + */ +export function validateBankCard(value: string): boolean { + if (!value || typeof value !== 'string') { + return false + } + + const trimmedValue = value.trim().replace(/\s+/g, '') + + // 银行卡号通常为13-19位数字 + if (!/^\d{13,19}$/.test(trimmedValue)) { + return false + } + + // Luhn算法验证 + let sum = 0 + let shouldDouble = false + + for (let i = trimmedValue.length - 1; i >= 0; i--) { + let digit = parseInt(trimmedValue[i]) + + if (shouldDouble) { + digit *= 2 + if (digit > 9) { + digit = (digit % 10) + 1 + } + } + + sum += digit + shouldDouble = !shouldDouble + } + + return sum % 10 === 0 +} diff --git a/web/apps/portal/src/utils/http/error.ts b/web/apps/portal/src/utils/http/error.ts new file mode 100644 index 0000000..0f2c1ae --- /dev/null +++ b/web/apps/portal/src/utils/http/error.ts @@ -0,0 +1,182 @@ +/** + * HTTP 错误处理模块 + * + * 提供统一的 HTTP 请求错误处理机制 + * + * ## 主要功能 + * + * - 自定义 HttpError 错误类,封装错误信息、状态码、时间戳等 + * - 错误拦截和转换,将 Axios 错误转换为标准的 HttpError + * - 错误消息国际化处理,根据状态码返回对应的多语言错误提示 + * - 错误日志记录,便于问题追踪和调试 + * - 错误和成功消息的统一展示 + * - 类型守卫函数,用于判断错误类型 + * + * ## 使用场景 + * + * - HTTP 请求拦截器中统一处理错误 + * - 业务代码中捕获和处理特定错误 + * - 错误日志收集和上报 + * + * @module utils/http/error + * @author Art Design Pro Team + */ +import { AxiosError } from 'axios' +import { ApiStatus } from './status' +import { $t } from '@/locales' + +// 错误响应接口 +export interface ErrorResponse { + /** 错误状态码 */ + code: number + /** 错误消息 */ + msg: string + /** 错误附加数据 */ + data?: unknown +} + +// 错误日志数据接口 +export interface ErrorLogData { + /** 错误状态码 */ + code: number + /** 错误消息 */ + message: string + /** 错误附加数据 */ + data?: unknown + /** 错误发生时间戳 */ + timestamp: string + /** 请求 URL */ + url?: string + /** 请求方法 */ + method?: string + /** 错误堆栈信息 */ + stack?: string +} + +// 自定义 HttpError 类 +export class HttpError extends Error { + public readonly code: number + public readonly data?: unknown + public readonly timestamp: string + public readonly url?: string + public readonly method?: string + + constructor( + message: string, + code: number, + options?: { + data?: unknown + url?: string + method?: string + } + ) { + super(message) + this.name = 'HttpError' + this.code = code + this.data = options?.data + this.timestamp = new Date().toISOString() + this.url = options?.url + this.method = options?.method + } + + public toLogData(): ErrorLogData { + return { + code: this.code, + message: this.message, + data: this.data, + timestamp: this.timestamp, + url: this.url, + method: this.method, + stack: this.stack + } + } +} + +/** + * 获取错误消息 + * @param status 错误状态码 + * @returns 错误消息 + */ +const getErrorMessage = (status: number): string => { + const errorMap: Record = { + [ApiStatus.unauthorized]: 'httpMsg.unauthorized', + [ApiStatus.forbidden]: 'httpMsg.forbidden', + [ApiStatus.notFound]: 'httpMsg.notFound', + [ApiStatus.methodNotAllowed]: 'httpMsg.methodNotAllowed', + [ApiStatus.requestTimeout]: 'httpMsg.requestTimeout', + [ApiStatus.internalServerError]: 'httpMsg.internalServerError', + [ApiStatus.badGateway]: 'httpMsg.badGateway', + [ApiStatus.serviceUnavailable]: 'httpMsg.serviceUnavailable', + [ApiStatus.gatewayTimeout]: 'httpMsg.gatewayTimeout' + } + + return $t(errorMap[status] || 'httpMsg.internalServerError') +} + +/** + * 处理错误 + * @param error 错误对象 + * @returns 错误对象 + */ +export function handleError(error: AxiosError): never { + // 处理取消的请求 + if (error.code === 'ERR_CANCELED') { + console.warn('Request cancelled:', error.message) + throw new HttpError($t('httpMsg.requestCancelled'), ApiStatus.error) + } + + const statusCode = error.response?.status + const errorMessage = error.response?.data?.msg || error.message + const requestConfig = error.config + + // 处理网络错误 + if (!error.response) { + throw new HttpError($t('httpMsg.networkError'), ApiStatus.error, { + url: requestConfig?.url, + method: requestConfig?.method?.toUpperCase() + }) + } + + // 处理 HTTP 状态码错误 + const message = statusCode + ? getErrorMessage(statusCode) + : errorMessage || $t('httpMsg.requestFailed') + throw new HttpError(message, statusCode || ApiStatus.error, { + data: error.response.data, + url: requestConfig?.url, + method: requestConfig?.method?.toUpperCase() + }) +} + +/** + * 显示错误消息 + * @param error 错误对象 + * @param showMessage 是否显示错误消息 + */ +export function showError(error: HttpError, showMessage: boolean = true): void { + if (showMessage) { + ElMessage.error(error.message) + } + // 记录错误日志 + console.error('[HTTP Error]', error.toLogData()) +} + +/** + * 显示成功消息 + * @param message 成功消息 + * @param showMessage 是否显示消息 + */ +export function showSuccess(message: string, showMessage: boolean = true): void { + if (showMessage) { + ElMessage.success(message) + } +} + +/** + * 判断是否为 HttpError 类型 + * @param error 错误对象 + * @returns 是否为 HttpError 类型 + */ +export const isHttpError = (error: unknown): error is HttpError => { + return error instanceof HttpError +} diff --git a/web/apps/portal/src/utils/http/index.ts b/web/apps/portal/src/utils/http/index.ts new file mode 100755 index 0000000..75d151b --- /dev/null +++ b/web/apps/portal/src/utils/http/index.ts @@ -0,0 +1,217 @@ +/** + * HTTP 请求封装模块 + * 基于 Axios 封装的 HTTP 请求工具,提供统一的请求/响应处理 + * + * ## 主要功能 + * + * - 请求/响应拦截器(自动添加 Token、统一错误处理) + * - 401 未授权自动登出(带防抖机制) + * - 请求失败自动重试(可配置) + * - 统一的成功/错误消息提示 + * - 支持 GET/POST/PUT/DELETE 等常用方法 + * + * @module utils/http + * @author Art Design Pro Team + */ + +import axios, { AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios' +import { useUserStore } from '@/store/modules/user' +import { ApiStatus } from './status' +import { HttpError, handleError, showError, showSuccess } from './error' +import { $t } from '@/locales' +import { BaseResponse } from '@/types' + +/** 请求配置常量 */ +const REQUEST_TIMEOUT = 15000 +const LOGOUT_DELAY = 500 +const MAX_RETRIES = 0 +const RETRY_DELAY = 1000 +const UNAUTHORIZED_DEBOUNCE_TIME = 3000 + +/** 401防抖状态 */ +let isUnauthorizedErrorShown = false +let unauthorizedTimer: NodeJS.Timeout | null = null + +/** 扩展 AxiosRequestConfig */ +interface ExtendedAxiosRequestConfig extends AxiosRequestConfig { + showErrorMessage?: boolean + showSuccessMessage?: boolean +} + +const { VITE_API_URL, VITE_WITH_CREDENTIALS } = import.meta.env + +/** Axios实例 */ +const axiosInstance = axios.create({ + timeout: REQUEST_TIMEOUT, + baseURL: VITE_API_URL, + withCredentials: VITE_WITH_CREDENTIALS === 'true', + validateStatus: (status) => status >= 200 && status < 300, + transformResponse: [ + (data, headers) => { + const contentType = headers['content-type'] + if (contentType?.includes('application/json')) { + try { + return JSON.parse(data) + } catch { + return data + } + } + return data + } + ] +}) + +/** 请求拦截器 */ +axiosInstance.interceptors.request.use( + (request: InternalAxiosRequestConfig) => { + const { accessToken } = useUserStore() + if (accessToken) { + const authorization = accessToken.startsWith('Bearer ') ? accessToken : `Bearer ${accessToken}` + request.headers.set('Authorization', authorization) + } + + if (request.data && !(request.data instanceof FormData) && !request.headers['Content-Type']) { + request.headers.set('Content-Type', 'application/json') + request.data = JSON.stringify(request.data) + } + + return request + }, + (error) => { + showError(createHttpError($t('httpMsg.requestConfigError'), ApiStatus.error)) + return Promise.reject(error) + } +) + +/** 响应拦截器 */ +axiosInstance.interceptors.response.use( + (response: AxiosResponse) => { + const { code, msg } = response.data + if (code === ApiStatus.success) return response + if (code === ApiStatus.unauthorized) handleUnauthorizedError(msg) + throw createHttpError(msg || $t('httpMsg.requestFailed'), code) + }, + (error) => { + if (error.response?.status === ApiStatus.unauthorized) handleUnauthorizedError() + return Promise.reject(handleError(error)) + } +) + +/** 统一创建HttpError */ +function createHttpError(message: string, code: number) { + return new HttpError(message, code) +} + +/** 处理401错误(带防抖) */ +function handleUnauthorizedError(message?: string): never { + const error = createHttpError(message || $t('httpMsg.unauthorized'), ApiStatus.unauthorized) + + if (!isUnauthorizedErrorShown) { + isUnauthorizedErrorShown = true + logOut() + + unauthorizedTimer = setTimeout(resetUnauthorizedError, UNAUTHORIZED_DEBOUNCE_TIME) + + showError(error, true) + throw error + } + + throw error +} + +/** 重置401防抖状态 */ +function resetUnauthorizedError() { + isUnauthorizedErrorShown = false + if (unauthorizedTimer) clearTimeout(unauthorizedTimer) + unauthorizedTimer = null +} + +/** 退出登录函数 */ +function logOut() { + setTimeout(() => { + useUserStore().logOut() + }, LOGOUT_DELAY) +} + +/** 是否需要重试 */ +function shouldRetry(statusCode: number) { + return [ + ApiStatus.requestTimeout, + ApiStatus.internalServerError, + ApiStatus.badGateway, + ApiStatus.serviceUnavailable, + ApiStatus.gatewayTimeout + ].includes(statusCode) +} + +/** 请求重试逻辑 */ +async function retryRequest( + config: ExtendedAxiosRequestConfig, + retries: number = MAX_RETRIES +): Promise { + try { + return await request(config) + } catch (error) { + if (retries > 0 && error instanceof HttpError && shouldRetry(error.code)) { + await delay(RETRY_DELAY) + return retryRequest(config, retries - 1) + } + throw error + } +} + +/** 延迟函数 */ +function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +/** 请求函数 */ +async function request(config: ExtendedAxiosRequestConfig): Promise { + // POST | PUT 参数自动填充 + if ( + ['POST', 'PUT'].includes(config.method?.toUpperCase() || '') && + config.params && + !config.data + ) { + config.data = config.params + config.params = undefined + } + + try { + const res = await axiosInstance.request>(config) + + // 显示成功消息 + if (config.showSuccessMessage && res.data.msg) { + showSuccess(res.data.msg) + } + + return res.data.data as T + } catch (error) { + if (error instanceof HttpError && error.code !== ApiStatus.unauthorized) { + const showMsg = config.showErrorMessage !== false + showError(error, showMsg) + } + return Promise.reject(error) + } +} + +/** API方法集合 */ +const api = { + get(config: ExtendedAxiosRequestConfig) { + return retryRequest({ ...config, method: 'GET' }) + }, + post(config: ExtendedAxiosRequestConfig) { + return retryRequest({ ...config, method: 'POST' }) + }, + put(config: ExtendedAxiosRequestConfig) { + return retryRequest({ ...config, method: 'PUT' }) + }, + del(config: ExtendedAxiosRequestConfig) { + return retryRequest({ ...config, method: 'DELETE' }) + }, + request(config: ExtendedAxiosRequestConfig) { + return retryRequest(config) + } +} + +export default api diff --git a/web/apps/portal/src/utils/http/status.ts b/web/apps/portal/src/utils/http/status.ts new file mode 100755 index 0000000..989bb37 --- /dev/null +++ b/web/apps/portal/src/utils/http/status.ts @@ -0,0 +1,18 @@ +/** + * 接口状态码 + */ +export enum ApiStatus { + success = 200, // 成功 + error = 400, // 错误 + unauthorized = 401, // 未授权 + forbidden = 403, // 禁止访问 + notFound = 404, // 未找到 + methodNotAllowed = 405, // 方法不允许 + requestTimeout = 408, // 请求超时 + internalServerError = 500, // 服务器错误 + notImplemented = 501, // 未实现 + badGateway = 502, // 网关错误 + serviceUnavailable = 503, // 服务不可用 + gatewayTimeout = 504, // 网关超时 + httpVersionNotSupported = 505 // HTTP版本不支持 +} diff --git a/web/apps/portal/src/utils/index.ts b/web/apps/portal/src/utils/index.ts new file mode 100644 index 0000000..f1e1b77 --- /dev/null +++ b/web/apps/portal/src/utils/index.ts @@ -0,0 +1,34 @@ +/** + * Utils 工具函数统一导出 + * 提供向后兼容性和便捷导入 + * + * @module utils/index + * @author Art Design Pro Team + */ + +// UI 相关 +export * from './ui' + +// 路由相关 +export * from './router' + +// 路由导航相关 +export * from './navigation' + +// 系统管理相关 +export * from './sys' + +// 常量定义相关 +export * from './constants' + +// 存储相关 +export * from './storage' + +// HTTP 相关 +export * from './http' + +// 表单相关 +export * from './form' + +// socket 相关 +export * from './socket' diff --git a/web/apps/portal/src/utils/navigation/index.ts b/web/apps/portal/src/utils/navigation/index.ts new file mode 100644 index 0000000..0b84e78 --- /dev/null +++ b/web/apps/portal/src/utils/navigation/index.ts @@ -0,0 +1,10 @@ +/** + * 路由和导航相关工具函数统一导出 + * + * @module utils/navigation/index + * @author Art Design Pro Team + */ + +export * from './jump' +export * from './worktab' +export * from './route' diff --git a/web/apps/portal/src/utils/navigation/jump.ts b/web/apps/portal/src/utils/navigation/jump.ts new file mode 100644 index 0000000..7c63cd9 --- /dev/null +++ b/web/apps/portal/src/utils/navigation/jump.ts @@ -0,0 +1,68 @@ +/** + * 导航跳转工具模块 + * + * 提供统一的页面跳转和导航功能 + * + * ## 主要功能 + * + * - 外部链接打开(新窗口) + * - 菜单项跳转处理(支持内部路由和外部链接) + * - iframe 页面跳转支持 + * - 递归查找并跳转到第一个可见的子菜单 + * - 智能判断跳转目标类型(外部链接/内部路由) + * + * @module utils/navigation/jump + * @author Art Design Pro Team + */ +import { AppRouteRecord } from '@/types/router' +import { router } from '@/router' +import { isNavigableMenuItem } from './route' + +// 打开外部链接 +export const openExternalLink = (link: string) => { + window.open(link, '_blank', 'noopener,noreferrer') +} + +/** + * 菜单跳转 + * @param item 菜单项 + * @param jumpToFirst 是否跳转到第一个子菜单 + * @returns + */ +export const handleMenuJump = (item: AppRouteRecord, jumpToFirst: boolean = false) => { + // 处理外部链接 + const { link, isIframe } = item.meta + if (link && !isIframe) { + return openExternalLink(link) + } + + // 如果不需要跳转到第一个子菜单,或者没有子菜单,直接跳转当前路径 + if (!jumpToFirst || !item.children?.length) { + return router.push(item.path) + } + + // 递归查找第一个可导航的叶子节点菜单 + const findFirstLeafMenu = (items: AppRouteRecord[]): AppRouteRecord | undefined => { + for (const child of items) { + if (isNavigableMenuItem(child)) { + return child.children?.length ? findFirstLeafMenu(child.children) || child : child + } + } + return undefined + } + + const firstChild = findFirstLeafMenu(item.children) + + // 如果子菜单都不可见,则回退到父级页面自身。 + if (!firstChild) { + return router.push(item.path) + } + + // 如果第一个子菜单是外部链接则打开新窗口 + if (firstChild.meta?.link) { + return openExternalLink(firstChild.meta.link) + } + + // 跳转到子菜单路径 + router.push(firstChild.path) +} diff --git a/web/apps/portal/src/utils/navigation/route.ts b/web/apps/portal/src/utils/navigation/route.ts new file mode 100644 index 0000000..518bcb9 --- /dev/null +++ b/web/apps/portal/src/utils/navigation/route.ts @@ -0,0 +1,85 @@ +/** + * 路由工具模块 + * + * 提供路由处理和菜单路径相关的工具函数 + * + * ## 主要功能 + * + * - iframe 路由检测,判断是否为外部嵌入页面 + * - 菜单项有效性验证,过滤隐藏和无效菜单 + * - 路径标准化处理,统一路径格式 + * - 递归查找菜单树中第一个有效路径 + * - 支持多级嵌套菜单的路径解析 + * + * ## 使用场景 + * + * - 系统初始化时获取默认跳转路径 + * - 菜单权限过滤后获取首个可访问页面 + * - 路由重定向逻辑处理 + * - iframe 页面特殊处理 + * + * @module utils/navigation/route + * @author Art Design Pro Team + */ + +import { AppRouteRecord } from '@/types' + +// 检查是否为 iframe 路由 +export function isIframe(url: string): boolean { + return url.startsWith('/outside/iframe/') +} + +/** + * 判断菜单项是否可作为默认导航落点 + * 隐藏的全屏页面虽然不展示在菜单中,但仍然可能是合法首页。 + */ +export const isNavigableMenuItem = (menuItem: AppRouteRecord): boolean => { + if (!menuItem.path || !menuItem.path.trim()) { + return false + } + + if (!menuItem.meta?.isHide) { + return true + } + + return menuItem.meta?.isFullPage === true +} + +/** + * 标准化路径格式 + * @param path 路径 + * @returns 标准化后的路径 + */ +const normalizePath = (path: string): string => { + return path.startsWith('/') ? path : `/${path}` +} + +/** + * 递归获取菜单的第一个有效路径 + * @param menuList 菜单列表 + * @returns 第一个有效路径,如果没有找到则返回空字符串 + */ +export const getFirstMenuPath = (menuList: AppRouteRecord[]): string => { + if (!Array.isArray(menuList) || menuList.length === 0) { + return '' + } + + for (const menuItem of menuList) { + if (!isNavigableMenuItem(menuItem)) { + continue + } + + // 如果有子菜单,优先查找子菜单 + if (menuItem.children?.length) { + const childPath = getFirstMenuPath(menuItem.children) + if (childPath) { + return childPath + } + } + + // 返回当前菜单项的标准化路径 + return normalizePath(menuItem.path!) + } + + return '' +} diff --git a/web/apps/portal/src/utils/navigation/worktab.ts b/web/apps/portal/src/utils/navigation/worktab.ts new file mode 100644 index 0000000..6db6a77 --- /dev/null +++ b/web/apps/portal/src/utils/navigation/worktab.ts @@ -0,0 +1,67 @@ +/** + * 工作标签页管理模块 + * + * 提供工作标签页(Worktab)的自动管理功能 + * + * ## 主要功能 + * + * - 根据路由导航自动创建和更新工作标签页 + * - iframe 页面标签页特殊处理 + * - 标签页信息提取(标题、路径、缓存状态等) + * - 固定标签页支持 + * - 根据系统设置控制标签页显示 + * - 首页标签页特殊处理 + * + * ## 使用场景 + * + * - 路由守卫中自动创建标签页 + * - 页面切换时更新标签页状态 + * - 多标签页导航系统 + * + * @module utils/navigation/worktab + * @author Art Design Pro Team + */ +import { useWorktabStore } from '@/store/modules/worktab' +import { RouteLocationNormalized } from 'vue-router' +import { isIframe } from './route' +import { useSettingStore } from '@/store/modules/setting' +import { IframeRouteManager } from '@/router/core' +import { useCommon } from '@/hooks/core/useCommon' + +/** + * 根据当前路由信息设置工作标签页(worktab) + * @param to 当前路由对象 + */ +export const setWorktab = (to: RouteLocationNormalized): void => { + const worktabStore = useWorktabStore() + const { meta, path, name, params, query } = to + if (!meta.isHideTab) { + // 如果是 iframe 页面,则特殊处理工作标签页 + if (isIframe(path)) { + const iframeRoute = IframeRouteManager.getInstance().findByPath(to.path) + + if (iframeRoute?.meta) { + worktabStore.openTab({ + title: iframeRoute.meta.title, + icon: meta.icon as string, + path, + name: name as string, + keepAlive: meta.keepAlive as boolean, + params, + query + }) + } + } else if (useSettingStore().showWorkTab || path === useCommon().homePath.value) { + worktabStore.openTab({ + title: meta.title as string, + icon: meta.icon as string, + path, + name: name as string, + keepAlive: meta.keepAlive as boolean, + params, + query, + fixedTab: meta.fixedTab as boolean + }) + } + } +} diff --git a/web/apps/portal/src/utils/router.ts b/web/apps/portal/src/utils/router.ts new file mode 100644 index 0000000..8c838ff --- /dev/null +++ b/web/apps/portal/src/utils/router.ts @@ -0,0 +1,61 @@ +/** + * 路由工具函数 + * + * 提供路由相关的工具函数 + * + * @module utils/router + */ +import { RouteLocationNormalized, RouteRecordRaw } from 'vue-router' +import AppConfig from '@/config' +import NProgress from 'nprogress' +import 'nprogress/nprogress.css' +import i18n, { $t } from '@/locales' + +/** 扩展的路由配置类型 */ +export type AppRouteRecordRaw = RouteRecordRaw & { + hidden?: boolean +} + +/** 顶部进度条配置 */ +export const configureNProgress = () => { + NProgress.configure({ + easing: 'ease', + speed: 600, + showSpinner: false, + parent: 'body' + }) +} + +/** + * 设置页面标题,根据路由元信息和系统信息拼接标题 + * @param to 当前路由对象 + */ +export const setPageTitle = (to: RouteLocationNormalized): void => { + const { title } = to.meta + if (title) { + setTimeout(() => { + document.title = `${formatMenuTitle(String(title))} - ${AppConfig.systemInfo.name}` + }, 150) + } +} + +/** + * 格式化菜单标题 + * @param title 菜单标题,可以是 i18n 的 key,也可以是字符串 + * @returns 格式化后的菜单标题 + */ +export const formatMenuTitle = (title: string): string => { + if (title) { + if (title.startsWith('menus.')) { + // 使用 te() 方法检查翻译键值是否存在,避免控制台警告 + if (i18n.global.te(title)) { + return $t(title) + } else { + // 如果翻译不存在,返回键值的最后部分作为fallback + return title.split('.').pop() || title + } + } + return title + } + return '' +} diff --git a/web/apps/portal/src/utils/socket/index.ts b/web/apps/portal/src/utils/socket/index.ts new file mode 100644 index 0000000..ac80da1 --- /dev/null +++ b/web/apps/portal/src/utils/socket/index.ts @@ -0,0 +1,423 @@ +interface WebSocketOptions { + url?: string + messageHandler: (event: MessageEvent) => void + reconnectInterval?: number // 重连间隔(ms) + heartbeatInterval?: number // 心跳检测间隔(ms) + pingInterval?: number // 发送ping间隔(ms) + reconnectTimeout?: number // 重连超时时间(ms) + maxReconnectAttempts?: number // 最大重连次数 + connectionTimeout?: number // 连接建立超时时间(ms) +} + +export default class WebSocketClient { + private static instance: WebSocketClient | null = null + private ws: WebSocket | null = null + private url: string + private messageHandler: (event: MessageEvent) => void + private reconnectInterval: number + private heartbeatInterval: number + private pingInterval: number + private reconnectTimeout: number + private maxReconnectAttempts: number + private connectionTimeout: number + private reconnectAttempts: number = 0 // 当前重连次数 + + // 消息队列 - 缓存连接建立前的消息 + private messageQueue: Array = [] + + // 定时器 + private detectionTimer: NodeJS.Timeout | null = null + private timeoutTimer: NodeJS.Timeout | null = null + private reconnectTimer: NodeJS.Timeout | null = null + private pingTimer: NodeJS.Timeout | null = null + private connectionTimer: NodeJS.Timeout | null = null // 连接超时定时器 + + // 状态标识 + private isConnected: boolean = false + private isConnecting: boolean = false // 是否正在连接中 + private stopReconnect: boolean = false + private isReconnecting: boolean = false + + private constructor(options: WebSocketOptions) { + this.url = options.url || (process.env.VUE_APP_LOGIN_WEBSOCKET as string) + this.messageHandler = options.messageHandler + this.reconnectInterval = options.reconnectInterval ?? 20 * 1000 // 默认20秒 + this.heartbeatInterval = options.heartbeatInterval ?? 5 * 1000 // 默认5秒 + this.pingInterval = options.pingInterval ?? 10 * 1000 // 默认10秒 + this.reconnectTimeout = options.reconnectTimeout ?? 30 * 1000 // 默认30秒 + this.maxReconnectAttempts = options.maxReconnectAttempts ?? 10 // 默认最多重连10次 + this.connectionTimeout = options.connectionTimeout ?? 10 * 1000 // 连接超时10秒 + } + + // 单例模式获取实例 + static getInstance(options: WebSocketOptions): WebSocketClient { + if (!WebSocketClient.instance) { + WebSocketClient.instance = new WebSocketClient(options) + } else { + // 更新消息处理器 + WebSocketClient.instance.messageHandler = options.messageHandler + // 如果提供了新的URL,则更新并重新连接 + if (options.url && WebSocketClient.instance.url !== options.url) { + WebSocketClient.instance.url = options.url + WebSocketClient.instance.reconnectAttempts = 0 + WebSocketClient.instance.init() + } + } + return WebSocketClient.instance + } + + // 初始化连接 + init(): void { + this.connect(true) + } + + private connect(resetReconnectAttempts: boolean = false): void { + // 如果正在连接中,不重复连接 + if (this.isConnecting) { + console.log('正在建立WebSocket连接中...') + return + } + + // 如果已连接,不重复连接 + if (this.ws?.readyState === WebSocket.OPEN) { + console.warn('WebSocket连接已存在') + this.flushMessageQueue() // 确保队列中的消息被发送 + return + } + + try { + this.isConnecting = true + this.stopReconnect = false + if (resetReconnectAttempts) { + this.reconnectAttempts = 0 + this.isReconnecting = false + this.clearTimer('reconnectTimer') + } + this.ws = new WebSocket(this.url) + + // 设置连接超时检测 + this.clearTimer('connectionTimer') + this.connectionTimer = setTimeout(() => { + console.error(`WebSocket连接超时 (${this.connectionTimeout}ms):${this.url}`) + this.handleConnectionTimeout() + }, this.connectionTimeout) + + this.ws.onopen = (event) => this.handleOpen(event) + this.ws.onmessage = (event) => this.handleMessage(event) + this.ws.onclose = (event) => this.handleClose(event) + this.ws.onerror = (event) => this.handleError(event) + } catch (error) { + console.error('WebSocket初始化失败:', error) + this.isConnecting = false + this.reconnect() + } + } + + // 处理连接超时 + private handleConnectionTimeout(): void { + if (this.ws?.readyState !== WebSocket.OPEN) { + console.error('WebSocket连接超时,强制关闭连接') + this.ws?.close(1000, 'Connection timeout') + this.isConnecting = false + this.reconnect() + } + } + + // 关闭连接 + close(force?: boolean): void { + this.clearAllTimers() + this.stopReconnect = true + this.isReconnecting = false + this.isConnecting = false + + if (this.ws) { + // 1000 表示正常关闭 + this.ws.close(force ? 1001 : 1000, force ? 'Force closed' : 'Normal close') + this.ws = null + } + + this.isConnected = false + } + + // 发送消息 - 增加消息队列 + send(data: string | ArrayBufferLike | Blob | ArrayBufferView, immediate: boolean = false): void { + // 如果要求立即发送且未连接,则直接报错 + if (immediate && (!this.ws || this.ws.readyState !== WebSocket.OPEN)) { + console.error('WebSocket未连接,无法立即发送消息') + return + } + + // 如果未连接且不要求立即发送,则加入消息队列 + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + console.log('WebSocket未连接,消息已加入队列等待发送') + this.messageQueue.push(data) + // 如果未在重连中,则尝试重连 + if (!this.isConnecting && !this.stopReconnect) { + this.init() + } + return + } + + try { + this.ws.send(data) + } catch (error) { + console.error('WebSocket发送消息失败:', error) + // 发送失败时将消息加入队列,等待重连后重试 + this.messageQueue.push(data) + this.reconnect() + } + } + + // 发送队列中的消息 + private flushMessageQueue(): void { + if (this.messageQueue.length > 0 && this.ws?.readyState === WebSocket.OPEN) { + console.log(`发送队列中的${this.messageQueue.length}条消息`) + while (this.messageQueue.length > 0) { + const data = this.messageQueue.shift() + if (data) { + try { + this.ws?.send(data) + } catch (error) { + console.error('发送队列消息失败:', error) + // 如果发送失败,将消息放回队列头部 + if (data) this.messageQueue.unshift(data) + break + } + } + } + } + } + + // 处理连接打开 + private handleOpen(event: Event): void { + console.log('WebSocket连接成功', event) + this.clearTimer('connectionTimer') // 清除连接超时定时器 + this.isConnected = true + this.isConnecting = false + this.isReconnecting = false + this.stopReconnect = false + this.reconnectAttempts = 0 // 重置重连次数 + this.startHeartbeat() + this.startPing() + this.flushMessageQueue() // 发送队列中的消息 + } + + // 处理收到的消息 + private handleMessage(event: MessageEvent): void { + console.log('收到WebSocket消息:', event) + this.resetHeartbeat() + this.messageHandler(event) + } + + // 处理连接关闭 + private handleClose(event: CloseEvent): void { + console.log( + `WebSocket断开: 代码=${event.code}, 原因=${event.reason}, 干净关闭=${event.wasClean}` + ) + + // 1000 是正常关闭代码 + const isNormalClose = event.code === 1000 + + this.isConnected = false + this.isConnecting = false + this.clearConnectionTimers() + this.ws = null + + if (!this.stopReconnect && !isNormalClose) { + this.reconnect() + } + } + + // 处理错误 - 增加详细错误信息 + private handleError(event: Event): void { + console.error('WebSocket连接错误:') + console.error('错误事件:', event) + console.error( + '当前连接状态:', + this.ws?.readyState ? this.getReadyStateText(this.ws.readyState) : '未初始化' + ) + + this.isConnected = false + this.isConnecting = false + + // 只有在未停止重连的情况下才尝试重连 + if (!this.stopReconnect) { + this.reconnect() + } + } + + private closeCurrentSocketForReconnect(): void { + this.clearConnectionTimers() + this.isConnected = false + this.isConnecting = false + + if (this.ws) { + this.ws.onopen = null + this.ws.onmessage = null + this.ws.onclose = null + this.ws.onerror = null + + if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) { + this.ws.close(1001, 'Reconnect') + } + + this.ws = null + } + } + + // 转换连接状态为文本描述 + private getReadyStateText(state: number): string { + switch (state) { + case WebSocket.CONNECTING: + return 'CONNECTING (0) - 正在连接' + case WebSocket.OPEN: + return 'OPEN (1) - 已连接' + case WebSocket.CLOSING: + return 'CLOSING (2) - 正在关闭' + case WebSocket.CLOSED: + return 'CLOSED (3) - 已关闭' + default: + return `未知状态 (${state})` + } + } + + // 开始心跳检测 + private startHeartbeat(): void { + this.clearTimer('detectionTimer') + this.clearTimer('timeoutTimer') + + this.detectionTimer = setTimeout(() => { + this.isConnected = this.ws?.readyState === WebSocket.OPEN + + if (!this.isConnected) { + console.warn('WebSocket心跳检测失败,尝试重连') + this.reconnect() + + this.timeoutTimer = setTimeout(() => { + console.warn('WebSocket重连超时') + this.close() + }, this.reconnectTimeout) + } + }, this.heartbeatInterval) + } + + // 重置心跳检测 + private resetHeartbeat(): void { + this.clearTimer('detectionTimer') + this.clearTimer('timeoutTimer') + this.startHeartbeat() + } + + // 开始发送ping消息 + private startPing(): void { + this.clearTimer('pingTimer') + + this.pingTimer = setInterval(() => { + if (this.ws?.readyState !== WebSocket.OPEN) { + console.warn('WebSocket未连接,停止发送ping') + this.clearTimer('pingTimer') + this.reconnect() + return + } + + try { + this.ws.send('ping') + console.log('发送ping消息') + } catch (error) { + console.error('发送ping消息失败:', error) + this.clearTimer('pingTimer') + this.reconnect() + } + }, this.pingInterval) + } + + // 重连 - 增加重连次数限制 + private reconnect(): void { + if (this.stopReconnect || this.isConnecting || this.reconnectInterval <= 0) { + return + } + + // 检查是否超过最大重连次数 + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + console.error(`已达到最大重连次数(${this.maxReconnectAttempts}),停止重连`) + this.close(true) + return + } + + this.reconnectAttempts++ + this.isReconnecting = true + this.closeCurrentSocketForReconnect() + + const delay = this.calculateReconnectDelay() + console.log( + `将在${delay / 1000}秒后尝试重新连接(第${this.reconnectAttempts}/${this.maxReconnectAttempts}次)` + ) + + this.clearTimer('reconnectTimer') + this.reconnectTimer = setTimeout(() => { + console.log(`尝试重新连接WebSocket(第${this.reconnectAttempts}次)`) + this.connect(false) + }, delay) + } + + // 计算重连延迟 - 指数退避策略 + private calculateReconnectDelay(): number { + // 基础延迟 + 随机值,避免多个客户端同时重连 + const jitter = Math.random() * 1000 // 0-1秒的随机延迟 + const baseDelay = Math.min( + this.reconnectInterval * Math.pow(1.5, this.reconnectAttempts - 1), + this.reconnectInterval * 5 + ) + return baseDelay + jitter + } + + // 清除指定定时器 + private clearTimer( + timerName: + | 'detectionTimer' + | 'timeoutTimer' + | 'reconnectTimer' + | 'pingTimer' + | 'connectionTimer' + ): void { + if (this[timerName]) { + clearTimeout(this[timerName] as NodeJS.Timeout) + this[timerName] = null + } + } + + // 清除所有定时器 + private clearAllTimers(): void { + this.clearConnectionTimers() + this.clearTimer('reconnectTimer') + } + + private clearConnectionTimers(): void { + this.clearTimer('detectionTimer') + this.clearTimer('timeoutTimer') + this.clearTimer('pingTimer') + this.clearTimer('connectionTimer') + } + + // 获取当前连接状态 + get isWebSocketConnected(): boolean { + return this.isConnected + } + + // 获取当前连接状态文本 + get connectionStatusText(): string { + if (this.isConnecting) return '正在连接' + if (this.isConnected) return '已连接' + if (this.isReconnecting && this.reconnectAttempts > 0) + return `重连中(${this.reconnectAttempts}/${this.maxReconnectAttempts})` + return '已断开' + } + + // 销毁实例 + static destroyInstance(): void { + if (WebSocketClient.instance) { + WebSocketClient.instance.close() + WebSocketClient.instance = null + } + } +} diff --git a/web/apps/portal/src/utils/storage/index.ts b/web/apps/portal/src/utils/storage/index.ts new file mode 100644 index 0000000..a4366f0 --- /dev/null +++ b/web/apps/portal/src/utils/storage/index.ts @@ -0,0 +1,7 @@ +/** + * 存储相关工具函数统一导出 + */ + +export * from './storage' +export * from './storage-config' +export * from './storage-key-manager' diff --git a/web/apps/portal/src/utils/storage/storage-config.ts b/web/apps/portal/src/utils/storage/storage-config.ts new file mode 100644 index 0000000..c53b489 --- /dev/null +++ b/web/apps/portal/src/utils/storage/storage-config.ts @@ -0,0 +1,125 @@ +/** + * 存储配置管理模块 + * + * 提供统一的本地存储配置和工具方法 + * + * ## 主要功能 + * + * - 版本化存储键管理,支持多版本数据隔离 + * - 存储键名生成和解析(带版本前缀) + * - 版本号提取和验证 + * - 存储键匹配的正则表达式生成 + * - 旧版本存储键兼容处理 + * - 升级和登出延迟配置 + * - 主题存储键配置 + * + * ## 使用场景 + * + * - Pinia Store 持久化存储 + * - 应用版本升级时的数据迁移 + * - 多版本数据清理 + * - 存储键的统一管理和规范 + * + * 存储键格式:sys-v{version}-{storeId} + * 例如:sys-v1.0.0-user, sys-v1.0.0-setting + * + * @module utils/storage/storage-config + * @author Art Design Pro Team + */ +export class StorageConfig { + /** 当前应用版本 */ + static readonly CURRENT_VERSION = __APP_VERSION__ + + /** 存储键前缀 */ + static readonly STORAGE_PREFIX = 'sys-v' + + /** 版本键名 */ + static readonly VERSION_KEY = 'sys-version' + + /** 主题键名(index.html中使用了,如果修改,需要同步修改) */ + static readonly THEME_KEY = 'sys-theme' + + /** 上次登录用户ID键名(用于判断是否为同一用户登录) */ + static readonly LAST_USER_ID_KEY = 'sys-last-user-id' + + /** 响应式布局切换时暂存桌面端菜单类型 */ + static readonly RESPONSIVE_MENU_TYPE_KEY = 'sys-responsive-menu-type' + + /** 跳过升级检查的版本 */ + static readonly SKIP_UPGRADE_VERSION = '1.0.0' + + /** 升级处理延迟时间(毫秒) */ + static readonly UPGRADE_DELAY = 1000 + + /** 登出延迟时间(毫秒) */ + static readonly LOGOUT_DELAY = 1000 + + /** + * 生成版本化的存储键名 + * @param storeId 存储ID + * @param version 版本号,默认使用当前版本 + */ + static generateStorageKey(storeId: string, version: string = this.CURRENT_VERSION): string { + return `${this.STORAGE_PREFIX}${version}-${storeId}` + } + + /** + * 生成旧版本的存储键名(不带分隔符) + * @param version 版本号,默认使用当前版本 + */ + static generateLegacyKey(version: string = this.CURRENT_VERSION): string { + return `${this.STORAGE_PREFIX}${version}` + } + + /** + * 创建存储键匹配的正则表达式 + * @param storeId 存储ID + */ + static createKeyPattern(storeId: string): RegExp { + return new RegExp(`^${this.STORAGE_PREFIX}[^-]+-${storeId}$`) + } + + /** + * 创建当前版本存储键匹配的正则表达式 + */ + static createCurrentVersionPattern(): RegExp { + return new RegExp(`^${this.STORAGE_PREFIX}${this.CURRENT_VERSION}-`) + } + + /** + * 创建任意版本存储键匹配的正则表达式 + */ + static createVersionPattern(): RegExp { + return new RegExp(`^${this.STORAGE_PREFIX}`) + } + + /** + * 检查是否为当前版本的键 + */ + static isCurrentVersionKey(key: string): boolean { + return key.startsWith(`${this.STORAGE_PREFIX}${this.CURRENT_VERSION}`) + } + + /** + * 检查是否为版本化的键 + */ + static isVersionedKey(key: string): boolean { + return key.startsWith(this.STORAGE_PREFIX) + } + + /** + * 从存储键中提取版本号 + */ + static extractVersionFromKey(key: string): string | null { + const match = key.match(new RegExp(`^${this.STORAGE_PREFIX}([^-]+)`)) + return match ? match[1] : null + } + + /** + * 从存储键中提取存储ID + */ + static extractStoreIdFromKey(key: string): string | null { + const match = key.match(new RegExp(`^${this.STORAGE_PREFIX}[^-]+-(.+)$`)) + return match ? match[1] : null + } +} diff --git a/web/apps/portal/src/utils/storage/storage-key-manager.ts b/web/apps/portal/src/utils/storage/storage-key-manager.ts new file mode 100644 index 0000000..ba14f65 --- /dev/null +++ b/web/apps/portal/src/utils/storage/storage-key-manager.ts @@ -0,0 +1,97 @@ +/** + * 存储键名管理器模块 + * + * 提供智能的版本化存储键管理和数据迁移功能 + * + * ## 主要功能 + * + * - 自动生成当前版本的存储键名 + * - 检测当前版本数据是否存在 + * - 查找其他版本的同名存储数据 + * - 自动将旧版本数据迁移到当前版本 + * - 数据迁移日志记录 + * - 迁移失败的错误处理 + * + * ## 使用场景 + * + * - Pinia Store 持久化插件中获取存储键 + * - 应用版本升级时自动迁移用户数据 + * - 避免版本升级导致的数据丢失 + * - 实现平滑的版本过渡 + * + * ## 工作流程 + * + * 1. 优先使用当前版本的存储键 + * 2. 如果当前版本无数据,查找其他版本的同名数据 + * 3. 找到旧版本数据后自动迁移到当前版本 + * 4. 返回当前版本的存储键供使用 + * + * @module utils/storage/storage-key-manager + * @author Art Design Pro Team + */ +import { StorageConfig } from '@/utils/storage' + +/** + * 存储键名管理器 + * 负责处理版本化的存储键名生成和数据迁移 + */ +export class StorageKeyManager { + /** + * 获取当前版本的存储键名 + */ + private getCurrentVersionKey(storeId: string): string { + return StorageConfig.generateStorageKey(storeId) + } + + /** + * 检查当前版本的数据是否存在 + */ + private hasCurrentVersionData(key: string): boolean { + return localStorage.getItem(key) !== null + } + + /** + * 查找其他版本的同名存储键 + */ + private findExistingKey(storeId: string): string | null { + const storageKeys = Object.keys(localStorage) + const pattern = StorageConfig.createKeyPattern(storeId) + + return storageKeys.find((key) => pattern.test(key) && localStorage.getItem(key)) || null + } + + /** + * 将数据从旧版本迁移到当前版本 + */ + private migrateData(fromKey: string, toKey: string): void { + try { + const existingData = localStorage.getItem(fromKey) + if (existingData) { + localStorage.setItem(toKey, existingData) + console.info(`[Storage] 已迁移数据: ${fromKey} → ${toKey}`) + } + } catch (error) { + console.warn(`[Storage] 数据迁移失败: ${fromKey}`, error) + } + } + + /** + * 获取持久化存储的键名(支持自动数据迁移) + */ + getStorageKey(storeId: string): string { + const currentKey = this.getCurrentVersionKey(storeId) + + // 优先使用当前版本的数据 + if (this.hasCurrentVersionData(currentKey)) { + return currentKey + } + + // 查找并迁移其他版本的数据 + const existingKey = this.findExistingKey(storeId) + if (existingKey) { + this.migrateData(existingKey, currentKey) + } + + return currentKey + } +} diff --git a/web/apps/portal/src/utils/storage/storage.ts b/web/apps/portal/src/utils/storage/storage.ts new file mode 100644 index 0000000..67b9e9e --- /dev/null +++ b/web/apps/portal/src/utils/storage/storage.ts @@ -0,0 +1,250 @@ +/** + * 存储兼容性管理模块 + * + * 提供完整的本地存储兼容性检查和数据验证功能 + * + * 主要功能 + * + * - 多版本存储数据检测和验证 + * - 新旧存储格式兼容处理 + * - 存储数据完整性校验 + * - 存储异常自动恢复(清理+登出) + * - 登录状态验证 + * - 存储为空检测 + * - 版本号管理 + * + * ## 使用场景 + * + * - 应用启动时检查存储数据有效性 + * - 路由守卫中验证登录状态 + * - 版本升级时的数据兼容性检查 + * - 存储异常时的自动恢复 + * - 防止因存储数据损坏导致的系统异常 + * + * ## 工作流程 + * + * 1. 优先检查当前版本的存储数据 + * 2. 检查其他版本的存储数据 + * 3. 兼容旧格式的存储数据 + * 4. 验证数据完整性 + * 5. 异常时提示用户并执行登出 + * + * @module utils/storage/storage + * @author Art Design Pro Team + */ +import { router } from '@/router' +import { useUserStore } from '@/store/modules/user' +import { StorageConfig } from '@/utils/storage/storage-config' + +/** + * 存储兼容性管理器 + * 负责处理不同版本间的存储兼容性检查和数据验证 + */ +class StorageCompatibilityManager { + /** + * 获取系统版本号 + */ + getSystemVersion(): string | null { + return localStorage.getItem(StorageConfig.VERSION_KEY) + } + + /** + * 获取系统存储数据(兼容旧格式) + */ + getSystemStorage(): any { + const version = this.getSystemVersion() || StorageConfig.CURRENT_VERSION + const legacyKey = StorageConfig.generateLegacyKey(version) + const data = localStorage.getItem(legacyKey) + return data ? JSON.parse(data) : null + } + + /** + * 检查当前版本是否有存储数据 + */ + private hasCurrentVersionStorage(): boolean { + const storageKeys = Object.keys(localStorage) + const currentVersionPattern = StorageConfig.createCurrentVersionPattern() + + return storageKeys.some( + (key) => currentVersionPattern.test(key) && localStorage.getItem(key) !== null + ) + } + + /** + * 检查是否存在任何版本的存储数据 + */ + private hasAnyVersionStorage(): boolean { + const storageKeys = Object.keys(localStorage) + const versionPattern = StorageConfig.createVersionPattern() + + return storageKeys.some((key) => versionPattern.test(key) && localStorage.getItem(key) !== null) + } + + /** + * 获取旧格式的本地存储数据 + */ + private getLegacyStorageData(): Record { + try { + const systemStorage = this.getSystemStorage() + return systemStorage || {} + } catch (error) { + console.warn('[Storage] 解析旧格式存储数据失败:', error) + return {} + } + } + + /** + * 显示存储错误消息 + */ + private showStorageError(): void { + ElMessage({ + type: 'error', + offset: 40, + duration: 5000, + message: '系统检测到本地数据异常,请重新登录系统恢复使用!' + }) + } + + /** + * 执行系统登出 + */ + private performSystemLogout(): void { + setTimeout(() => { + try { + localStorage.clear() + useUserStore().logOut() + router.push({ name: 'Login' }) + console.info('[Storage] 已执行系统登出') + } catch (error) { + console.error('[Storage] 系统登出失败:', error) + } + }, StorageConfig.LOGOUT_DELAY) + } + + /** + * 处理存储异常 + */ + private handleStorageError(): void { + this.showStorageError() + this.performSystemLogout() + } + + /** + * 验证存储数据完整性 + * @param requireAuth 是否需要验证登录状态(默认 false) + */ + validateStorageData(requireAuth: boolean = false): boolean { + try { + // 优先检查新版本存储结构 + if (this.hasCurrentVersionStorage()) { + // console.debug('[Storage] 发现当前版本存储数据') + return true + } + + // 检查是否有任何版本的存储数据 + if (this.hasAnyVersionStorage()) { + // console.debug('[Storage] 发现其他版本存储数据,可能需要迁移') + return true + } + + // 检查旧版本存储结构 + const legacyData = this.getLegacyStorageData() + if (Object.keys(legacyData).length === 0) { + // 只有在需要验证登录状态时才执行登出操作 + if (requireAuth) { + console.warn('[Storage] 未发现任何存储数据,需要重新登录') + this.performSystemLogout() + return false + } + // 首次访问或访问静态路由,不需要登出 + // console.debug('[Storage] 未发现存储数据,首次访问或访问静态路由') + return true + } + + console.debug('[Storage] 发现旧版本存储数据') + return true + } catch (error) { + console.error('[Storage] 存储数据验证失败:', error) + // 只有在需要验证登录状态时才处理错误 + if (requireAuth) { + this.handleStorageError() + return false + } + return true + } + } + + /** + * 检查存储是否为空 + */ + isStorageEmpty(): boolean { + // 检查新版本存储结构 + if (this.hasCurrentVersionStorage()) { + return false + } + + // 检查是否有任何版本的存储数据 + if (this.hasAnyVersionStorage()) { + return false + } + + // 检查旧版本存储结构 + const legacyData = this.getLegacyStorageData() + return Object.keys(legacyData).length === 0 + } + + /** + * 检查存储兼容性 + * @param requireAuth 是否需要验证登录状态(默认 false) + */ + checkCompatibility(requireAuth: boolean = false): boolean { + try { + const isValid = this.validateStorageData(requireAuth) + const isEmpty = this.isStorageEmpty() + + if (isValid || isEmpty) { + // console.debug('[Storage] 存储兼容性检查通过') + return true + } + + console.warn('[Storage] 存储兼容性检查失败') + return false + } catch (error) { + console.error('[Storage] 兼容性检查异常:', error) + return false + } + } +} + +// 创建存储兼容性管理器实例 +const storageManager = new StorageCompatibilityManager() + +/** + * 获取系统存储数据 + */ +export function getSystemStorage(): any { + return storageManager.getSystemStorage() +} + +/** + * 获取系统版本号 + */ +export function getSysVersion(): string | null { + return storageManager.getSystemVersion() +} + +/** + * 验证本地存储数据 + * @param requireAuth 是否需要验证登录状态(默认 false) + */ +export function validateStorageData(requireAuth: boolean = false): boolean { + return storageManager.validateStorageData(requireAuth) +} + +/** + * 检查存储兼容性 + * @param requireAuth 是否需要验证登录状态(默认 false) + */ +export function checkStorageCompatibility(requireAuth: boolean = false): boolean { + return storageManager.checkCompatibility(requireAuth) +} diff --git a/web/apps/portal/src/utils/sys/console.ts b/web/apps/portal/src/utils/sys/console.ts new file mode 100644 index 0000000..d631087 --- /dev/null +++ b/web/apps/portal/src/utils/sys/console.ts @@ -0,0 +1,13 @@ +// ANSI 转义码生成网站 https://patorjk.com/software/taag/#p=display&f=Big&t=ABB%0A +const asciiArt = ` +\x1b[32m欢迎使用 Art Design Pro! +\x1b[0m +\x1b[36m哇!你居然在用我的项目~ 好用的话别忘了去 GitHub 点个 ★Star 呀,你的支持就是我更新的超强动力!祝使用体验满分💯 +\x1b[0m +\x1b[33mGitHub: https://github.com/Daymychen/art-design-pro +\x1b[0m +\x1b[31m技术支持(QQ群): 1038930070,和开发者一起交流~ 群里有小伙伴实时答疑,遇到问题不用慌! +\x1b[0m +` + +console.log(asciiArt) diff --git a/web/apps/portal/src/utils/sys/error-handle.ts b/web/apps/portal/src/utils/sys/error-handle.ts new file mode 100644 index 0000000..bc966c9 --- /dev/null +++ b/web/apps/portal/src/utils/sys/error-handle.ts @@ -0,0 +1,143 @@ +/** + * 全局错误处理模块 + * + * 提供统一的错误捕获和处理机制 + * + * ## 主要功能 + * + * - Vue 运行时错误捕获(组件错误、生命周期错误等) + * - 全局脚本错误捕获(语法错误、运行时错误等) + * - Promise 未捕获错误处理(unhandledrejection) + * - 静态资源加载错误监控(图片、脚本、样式等) + * - 错误日志记录和上报 + * - 统一的错误处理入口 + * + * ## 使用场景 + * - 应用启动时安装全局错误处理器 + * - 捕获和记录所有类型的错误 + * - 错误上报到监控平台 + * - 提升应用稳定性和可维护性 + * - 问题排查和调试 + * + * ## 错误类型 + * + * - VueError: Vue 组件相关错误 + * - ScriptError: JavaScript 脚本错误 + * - PromiseError: Promise 未捕获的 rejection + * - ResourceError: 静态资源加载失败 + * + * @module utils/sys/error-handle + * @author Art Design Pro Team + */ +import type { App } from 'vue' + +const IGNORABLE_SCRIPT_ERRORS = [ + 'ResizeObserver loop completed with undelivered notifications.', + 'ResizeObserver loop limit exceeded' +] + +function normalizeErrorMessage(message: Event | string): string { + if (typeof message === 'string') { + return message + } + + if ('message' in message && typeof message.message === 'string') { + return message.message + } + + return '' +} + +function isIgnorableScriptError(message: Event | string, source?: string): boolean { + const normalizedMessage = normalizeErrorMessage(message) + + if (!normalizedMessage) { + return false + } + + if (IGNORABLE_SCRIPT_ERRORS.some((item) => normalizedMessage.includes(item))) { + // 浏览器/扩展在布局抖动时常见的 ResizeObserver 噪声,不作为真实异常处理 + return true + } + + // 浏览器扩展注入脚本偶发的跨域 Script error 也没有排查价值 + if (normalizedMessage === 'Script error.' && source === '') { + return true + } + + return false +} + +/** + * Vue 运行时错误处理 + */ +export function vueErrorHandler(err: unknown, instance: any, info: string) { + console.error('[VueError]', err, info, instance) + // 这里可以上报到服务端,比如: + // reportError({ type: 'vue', err, info }) +} + +/** + * 全局脚本错误处理 + */ +export function scriptErrorHandler( + message: Event | string, + source?: string, + lineno?: number, + colno?: number, + error?: Error +): boolean { + if (isIgnorableScriptError(message, source)) { + return true + } + + console.error('[ScriptError]', { message, source, lineno, colno, error }) + // reportError({ type: 'script', message, source, lineno, colno, error }) + return true // 阻止默认控制台报错,可根据需求改 +} + +/** + * Promise 未捕获错误处理 + */ +export function registerPromiseErrorHandler() { + window.addEventListener('unhandledrejection', (event) => { + console.error('[PromiseError]', event.reason) + // reportError({ type: 'promise', reason: event.reason }) + }) +} + +/** + * 资源加载错误处理 (img, script, css...) + */ +export function registerResourceErrorHandler() { + window.addEventListener( + 'error', + (event: Event) => { + const target = event.target as HTMLElement + if ( + target && + (target.tagName === 'IMG' || target.tagName === 'SCRIPT' || target.tagName === 'LINK') + ) { + console.error('[ResourceError]', { + tagName: target.tagName, + src: + (target as HTMLImageElement).src || + (target as HTMLScriptElement).src || + (target as HTMLLinkElement).href + }) + // reportError({ type: 'resource', target }) + } + }, + true // 捕获阶段才能监听到资源错误 + ) +} + +/** + * 安装统一错误处理 + */ +export function setupErrorHandle(app: App) { + app.config.errorHandler = vueErrorHandler + window.onerror = scriptErrorHandler + registerPromiseErrorHandler() + registerResourceErrorHandler() +} diff --git a/web/apps/portal/src/utils/sys/index.ts b/web/apps/portal/src/utils/sys/index.ts new file mode 100644 index 0000000..a2e0729 --- /dev/null +++ b/web/apps/portal/src/utils/sys/index.ts @@ -0,0 +1,6 @@ +/** + * 系统管理相关工具函数统一导出 + */ + +export * from './upgrade' +export { default as mittBus } from './mittBus' diff --git a/web/apps/portal/src/utils/sys/mittBus.ts b/web/apps/portal/src/utils/sys/mittBus.ts new file mode 100644 index 0000000..22f0108 --- /dev/null +++ b/web/apps/portal/src/utils/sys/mittBus.ts @@ -0,0 +1,63 @@ +/** + * 全局事件总线模块 + * + * 基于 mitt 库实现的类型安全的事件总线 + * + * ## 主要功能 + * + * - 跨组件通信(发布/订阅模式) + * - 类型安全的事件定义和调用 + * - 全局事件管理(烟花效果、设置面板、搜索对话框等) + * - 解耦组件间的直接依赖 + * + * ## 使用场景 + * + * - 跨层级组件通信 + * - 全局功能触发(设置、搜索、聊天、锁屏等) + * - 特效触发(烟花效果) + * - 避免 props 层层传递 + * + * ## 用法示例 + * + * ```typescript + * // 订阅事件 + * mittBus.on('openSetting', () => { ... }) + * + * // 发布事件 + * mittBus.emit('openSetting') + * + * // 带参数的事件 + * mittBus.emit('triggerFireworks', 'image-url') + * ``` + * + * ## 已定义的事件 + * + * - triggerFireworks: 触发烟花效果(可选图片URL) + * - openSetting: 打开设置面板 + * - openSearchDialog: 打开搜索对话框 + * - openChat: 打开聊天窗口 + * - openLockScreen: 打开锁屏 + * + * @module utils/sys/mittBus + * @author Art Design Pro Team + */ +import mitt, { type Emitter } from 'mitt' + +// 定义事件类型映射 +type Events = { + // 烟花效果事件 - 可选的图片URL参数 + triggerFireworks: string | undefined + // 打开设置面板事件 - 无参数 + openSetting: void + // 打开搜索对话框事件 - 无参数 + openSearchDialog: void + // 打开聊天窗口事件 - 无参数 + openChat: void + // 打开锁屏事件 - 无参数 + openLockScreen: void +} + +// 创建类型安全的事件总线实例 +const mittBus: Emitter = mitt() + +export default mittBus diff --git a/web/apps/portal/src/utils/sys/upgrade.ts b/web/apps/portal/src/utils/sys/upgrade.ts new file mode 100644 index 0000000..53d3465 --- /dev/null +++ b/web/apps/portal/src/utils/sys/upgrade.ts @@ -0,0 +1,277 @@ +/** + * 系统版本升级管理模块 + * + * 提供完整的应用版本升级检测和处理功能 + * + * ## 主要功能 + * + * - 版本号比较和升级检测 + * - 首次访问识别和处理 + * - 旧版本数据自动清理 + * - 升级日志展示和通知 + * - 强制重新登录控制(根据升级日志配置) + * - 版本号规范化处理 + * - 旧存储结构迁移和清理 + * - 升级流程延迟执行(确保应用完全加载) + * + * ## 使用场景 + * + * - 应用启动时自动检测版本升级 + * - 版本更新后清理旧数据 + * - 向用户展示版本更新内容 + * - 重大更新时要求用户重新登录 + * - 防止旧版本数据污染新版本 + * + * ## 工作流程 + * + * 1. 检查本地存储的版本号 + * 2. 与当前应用版本对比 + * 3. 查找并清理旧版本数据 + * 4. 展示升级通知(包含更新日志) + * 5. 根据配置决定是否强制重新登录 + * 6. 更新本地版本号 + * + * @module utils/sys/upgrade + * @author Art Design Pro Team + */ +import { upgradeLogList } from '@/mock/upgrade/changeLog' +import { ElNotification } from 'element-plus' +import { useUserStore } from '@/store/modules/user' +import { StorageConfig } from '@/utils/storage/storage-config' + +/** + * 版本管理器 + * 负责处理版本比较、升级检测和数据清理 + */ +class VersionManager { + /** + * 规范化版本号字符串,移除前缀 'v' + */ + private normalizeVersion(version: string): string { + return version.replace(/^v/, '') + } + + /** + * 获取存储的版本号 + */ + private getStoredVersion(): string | null { + return localStorage.getItem(StorageConfig.VERSION_KEY) + } + + /** + * 设置版本号到存储 + */ + private setStoredVersion(version: string): void { + localStorage.setItem(StorageConfig.VERSION_KEY, version) + } + + /** + * 检查是否应该跳过升级处理 + */ + private shouldSkipUpgrade(): boolean { + return StorageConfig.CURRENT_VERSION === StorageConfig.SKIP_UPGRADE_VERSION + } + + /** + * 检查是否为首次访问 + */ + private isFirstVisit(storedVersion: string | null): boolean { + return !storedVersion + } + + /** + * 检查版本是否相同 + */ + private isSameVersion(storedVersion: string): boolean { + return storedVersion === StorageConfig.CURRENT_VERSION + } + + /** + * 查找旧的存储结构 + */ + private findLegacyStorage(): { oldSysKey: string | null; oldVersionKeys: string[] } { + const storageKeys = Object.keys(localStorage) + const currentVersionPrefix = StorageConfig.generateStorageKey('').slice(0, -1) // 移除末尾的 '-' + + // 查找旧的单一存储结构 + const oldSysKey = + storageKeys.find( + (key) => + StorageConfig.isVersionedKey(key) && key !== currentVersionPrefix && !key.includes('-') + ) || null + + // 查找旧版本的分离存储键 + const oldVersionKeys = storageKeys.filter( + (key) => + StorageConfig.isVersionedKey(key) && + !StorageConfig.isCurrentVersionKey(key) && + key.includes('-') + ) + + return { oldSysKey, oldVersionKeys } + } + + /** + * 检查是否需要重新登录 + */ + private shouldRequireReLogin(storedVersion: string): boolean { + const normalizedCurrent = this.normalizeVersion(StorageConfig.CURRENT_VERSION) + const normalizedStored = this.normalizeVersion(storedVersion) + + return upgradeLogList.value.some((item) => { + const itemVersion = this.normalizeVersion(item.version) + return ( + item.requireReLogin && itemVersion > normalizedStored && itemVersion <= normalizedCurrent + ) + }) + } + + /** + * 构建升级通知消息 + */ + private buildUpgradeMessage(requireReLogin: boolean): string { + const { title: content } = upgradeLogList.value[0] + + const messageParts = [ + `

`, + `系统已升级到 ${StorageConfig.CURRENT_VERSION} 版本,此次更新带来了以下改进:`, + `

`, + content + ] + + if (requireReLogin) { + messageParts.push( + `

升级完成,请重新登录后继续使用。

` + ) + } + + return messageParts.join('') + } + + /** + * 显示升级通知 + */ + private showUpgradeNotification(message: string): void { + ElNotification({ + title: '系统升级公告', + message, + duration: 0, + type: 'success', + dangerouslyUseHTMLString: true + }) + } + + /** + * 清理旧版本数据 + */ + private cleanupLegacyData(oldSysKey: string | null, oldVersionKeys: string[]): void { + // 清理旧的单一存储结构 + if (oldSysKey) { + localStorage.removeItem(oldSysKey) + console.info(`[Upgrade] 已清理旧存储: ${oldSysKey}`) + } + + // 清理旧版本的分离存储 + oldVersionKeys.forEach((key) => { + localStorage.removeItem(key) + console.info(`[Upgrade] 已清理旧存储: ${key}`) + }) + } + + /** + * 执行升级后的登出操作 + */ + private performLogout(): void { + try { + useUserStore().logOut() + console.info('[Upgrade] 已执行升级后登出') + } catch (error) { + console.error('[Upgrade] 升级后登出失败:', error) + } + } + + /** + * 执行升级流程 + */ + private async executeUpgrade( + storedVersion: string, + legacyStorage: ReturnType + ): Promise { + try { + if (!upgradeLogList.value.length) { + console.warn('[Upgrade] 升级日志列表为空') + return + } + + const requireReLogin = this.shouldRequireReLogin(storedVersion) + const message = this.buildUpgradeMessage(requireReLogin) + + // 显示升级通知 + this.showUpgradeNotification(message) + + // 更新版本号 + this.setStoredVersion(StorageConfig.CURRENT_VERSION) + + // 清理旧数据 + this.cleanupLegacyData(legacyStorage.oldSysKey, legacyStorage.oldVersionKeys) + + // 执行登出(如果需要) + if (requireReLogin) { + this.performLogout() + } + + console.info(`[Upgrade] 升级完成: ${storedVersion} → ${StorageConfig.CURRENT_VERSION}`) + } catch (error) { + console.error('[Upgrade] 系统升级处理失败:', error) + } + } + + /** + * 系统升级处理主流程 + */ + async processUpgrade(): Promise { + // 跳过特定版本 + if (this.shouldSkipUpgrade()) { + console.debug('[Upgrade] 跳过版本升级检查') + return + } + + const storedVersion = this.getStoredVersion() + + // 首次访问处理 + if (this.isFirstVisit(storedVersion)) { + this.setStoredVersion(StorageConfig.CURRENT_VERSION) + // console.info('[Upgrade] 首次访问,已设置当前版本') + return + } + + // 版本相同,无需升级 + if (this.isSameVersion(storedVersion!)) { + // console.debug('[Upgrade] 版本相同,无需升级') + return + } + + // 检查是否有需要升级的旧数据 + const legacyStorage = this.findLegacyStorage() + if (!legacyStorage.oldSysKey && legacyStorage.oldVersionKeys.length === 0) { + this.setStoredVersion(StorageConfig.CURRENT_VERSION) + console.info('[Upgrade] 无旧数据,已更新版本号') + return + } + + // 延迟执行升级流程,确保应用已完全加载 + setTimeout(() => { + this.executeUpgrade(storedVersion!, legacyStorage) + }, StorageConfig.UPGRADE_DELAY) + } +} + +// 创建版本管理器实例 +const versionManager = new VersionManager() + +/** + * 系统升级处理入口函数 + */ +export async function systemUpgrade(): Promise { + await versionManager.processUpgrade() +} diff --git a/web/apps/portal/src/utils/table/tableCache.ts b/web/apps/portal/src/utils/table/tableCache.ts new file mode 100644 index 0000000..045a7ce --- /dev/null +++ b/web/apps/portal/src/utils/table/tableCache.ts @@ -0,0 +1,266 @@ +/** + * 表格缓存管理模块 + * + * 提供高性能的表格数据缓存机制 + * + * ## 主要功能 + * + * - 基于参数的智能缓存键生成(使用 ohash) + * - LRU(最近最少使用)缓存淘汰策略 + * - 缓存过期时间管理 + * - 缓存大小限制和自动清理 + * - 基于标签的缓存分组管理 + * - 多种缓存失效策略(清空所有、清空当前、清空分页等) + * - 缓存访问统计和命中率分析 + * - 缓存大小估算 + * + * ## 使用场景 + * + * - 表格数据的分页缓存 + * - 减少重复的 API 请求 + * - 提升表格切换和返回的响应速度 + * - 搜索条件变化时的智能缓存管理 + * - 数据更新后的缓存失效处理 + * + * ## 缓存策略 + * + * - CLEAR_ALL: 清空所有缓存(适用于全局数据更新) + * - CLEAR_CURRENT: 仅清空当前查询条件的缓存(适用于单条数据更新) + * - CLEAR_PAGINATION: 清空所有分页缓存但保留不同搜索条件(适用于批量操作) + * - KEEP_ALL: 不清除缓存(适用于只读操作) + * + * @module utils/table/tableCache + * @author Art Design Pro Team + */ +import { hash } from 'ohash' + +// 缓存失效策略枚举 +export enum CacheInvalidationStrategy { + /** 清空所有缓存 */ + CLEAR_ALL = 'clear_all', + /** 仅清空当前查询条件的缓存 */ + CLEAR_CURRENT = 'clear_current', + /** 清空所有分页缓存(保留不同搜索条件的缓存) */ + CLEAR_PAGINATION = 'clear_pagination', + /** 不清除缓存 */ + KEEP_ALL = 'keep_all' +} + +// 通用 API 响应接口(兼容不同的后端响应格式) +export interface ApiResponse { + records?: T[] + data?: T[] + total?: number + current?: number + size?: number + [key: string]: unknown +} + +// 缓存存储接口 +export interface CacheItem { + data: T[] + response: ApiResponse + timestamp: number + params: string + // 缓存标签,用于分组管理 + tags: Set + // 访问次数(用于 LRU 算法) + accessCount: number + // 最后访问时间 + lastAccessTime: number +} + +// 增强的缓存管理类 +export class TableCache { + private cache = new Map>() + private cacheTime: number + private maxSize: number + private enableLog: boolean + + constructor(cacheTime = 5 * 60 * 1000, maxSize = 50, enableLog = false) { + // 默认5分钟,最多50条缓存 + this.cacheTime = cacheTime + this.maxSize = maxSize + this.enableLog = enableLog + } + + // 内部日志工具 + private log(message: string, ...args: any[]) { + if (this.enableLog) { + console.log(`[TableCache] ${message}`, ...args) + } + } + + // 生成稳定的缓存键 + private generateKey(params: unknown): string { + return hash(params) + } + + // 🔧 优化:增强类型安全性 + private generateTags(params: Record): Set { + const tags = new Set() + + // 添加搜索条件标签 + const searchKeys = Object.keys(params).filter( + (key) => + !['current', 'size', 'total'].includes(key) && + params[key] !== undefined && + params[key] !== '' && + params[key] !== null + ) + + if (searchKeys.length > 0) { + const searchTag = searchKeys.map((key) => `${key}:${String(params[key])}`).join('|') + tags.add(`search:${searchTag}`) + } else { + tags.add('search:default') + } + + // 添加分页标签 + tags.add(`pagination:${params.size || 10}`) + // 添加通用分页标签,用于清理所有分页缓存 + tags.add('pagination') + + return tags + } + + // 🔧 优化:LRU 缓存清理 + private evictLRU(): void { + if (this.cache.size <= this.maxSize) return + + // 找到最少使用的缓存项 + let lruKey = '' + let minAccessCount = Infinity + let oldestTime = Infinity + + for (const [key, item] of this.cache.entries()) { + if ( + item.accessCount < minAccessCount || + (item.accessCount === minAccessCount && item.lastAccessTime < oldestTime) + ) { + lruKey = key + minAccessCount = item.accessCount + oldestTime = item.lastAccessTime + } + } + + if (lruKey) { + this.cache.delete(lruKey) + this.log(`LRU 清理缓存: ${lruKey}`) + } + } + + // 设置缓存 + set(params: unknown, data: T[], response: ApiResponse): void { + const key = this.generateKey(params) + const tags = this.generateTags(params as Record) + const now = Date.now() + + // 检查是否需要清理 + this.evictLRU() + + this.cache.set(key, { + data, + response, + timestamp: now, + params: key, + tags, + accessCount: 1, + lastAccessTime: now + }) + } + + // 获取缓存 + get(params: unknown): CacheItem | null { + const key = this.generateKey(params) + const item = this.cache.get(key) + + if (!item) return null + + // 检查是否过期 + if (Date.now() - item.timestamp > this.cacheTime) { + this.cache.delete(key) + return null + } + + // 更新访问统计 + item.accessCount++ + item.lastAccessTime = Date.now() + + return item + } + + // 根据标签清除缓存 + clearByTags(tags: string[]): number { + let clearedCount = 0 + + for (const [key, item] of this.cache.entries()) { + // 检查是否包含任意一个标签 + const hasMatchingTag = tags.some((tag) => + Array.from(item.tags).some((itemTag) => itemTag.includes(tag)) + ) + + if (hasMatchingTag) { + this.cache.delete(key) + clearedCount++ + } + } + + return clearedCount + } + + // 清除当前搜索条件的缓存 + clearCurrentSearch(params: unknown): number { + const key = this.generateKey(params) + const deleted = this.cache.delete(key) + return deleted ? 1 : 0 + } + + // 清除分页缓存 + clearPagination(): number { + return this.clearByTags(['pagination']) + } + + // 清空所有缓存 + clear(): void { + this.cache.clear() + } + + // 获取缓存统计信息 + getStats(): { total: number; size: string; hitRate: string } { + const total = this.cache.size + let totalSize = 0 + let totalAccess = 0 + + for (const item of this.cache.values()) { + // 粗略估算大小(JSON字符串长度) + totalSize += JSON.stringify(item.data).length + totalAccess += item.accessCount + } + + // 转换为人类可读的大小 + const sizeInKB = (totalSize / 1024).toFixed(2) + const avgHits = total > 0 ? (totalAccess / total).toFixed(1) : '0' + + return { + total, + size: `${sizeInKB}KB`, + hitRate: `${avgHits} avg hits` + } + } + + // 清理过期缓存 + cleanupExpired(): number { + let cleanedCount = 0 + const now = Date.now() + + for (const [key, item] of this.cache.entries()) { + if (now - item.timestamp > this.cacheTime) { + this.cache.delete(key) + cleanedCount++ + } + } + + return cleanedCount + } +} diff --git a/web/apps/portal/src/utils/table/tableConfig.ts b/web/apps/portal/src/utils/table/tableConfig.ts new file mode 100644 index 0000000..e464c89 --- /dev/null +++ b/web/apps/portal/src/utils/table/tableConfig.ts @@ -0,0 +1,55 @@ +/** + * 表格全局配置模块 + * + * 提供表格与后端接口的字段映射配置 + * + * ## 主要功能 + * + * - 响应数据字段自动识别和映射 + * - 支持多种常见的后端响应格式 + * - 请求参数字段映射配置 + * - 可扩展的字段配置机制 + * + * ## 使用场景 + * + * - 适配不同后端的分页接口格式 + * - 统一前端表格组件的数据处理 + * - 减少重复的数据转换代码 + * - 支持多个后端服务的接口对接 + * + * ## 配置说明 + * + * - recordFields: 列表数据字段名(按优先级顺序查找) + * - totalFields: 总条数字段名 + * - currentFields: 当前页码字段名 + * - sizeFields: 每页大小字段名 + * - paginationKey: 前端发送请求时使用的分页参数名 + * + * ## 扩展方式 + * + * 如果后端使用其他字段名,可以在对应数组中添加新的字段名 + * 例如:recordFields: ['list', 'data', 'records', 'items', 'yourCustomField'] + * + * @module utils/table/tableConfig + * @author Art Design Pro Team + */ +export const tableConfig = { + // 响应数据字段映射配置,系统会从接口返回数据中按顺序查找这些字段 + // 列表数据 + recordFields: ['list', 'data', 'records', 'items', 'result', 'rows'], + // 总条数 + totalFields: ['total', 'count'], + // 当前页码 + currentFields: ['current', 'page', 'pageNum'], + // 每页大小 + sizeFields: ['size', 'pageSize', 'limit'], + + // 请求参数映射配置,前端发送请求时使用的分页参数名 + // useTable 组合式函数传递分页参数的时候 用 current 跟 size + paginationKey: { + // 当前页码 + current: 'current', + // 每页大小 + size: 'size' + } +} diff --git a/web/apps/portal/src/utils/table/tableUtils.ts b/web/apps/portal/src/utils/table/tableUtils.ts new file mode 100644 index 0000000..3ca9db1 --- /dev/null +++ b/web/apps/portal/src/utils/table/tableUtils.ts @@ -0,0 +1,297 @@ +/** + * 表格工具函数模块 + * + * 提供表格数据处理和请求管理的核心工具函数 + * + * ## 主要功能 + * + * - 多格式 API 响应自动适配和标准化 + * - 表格数据提取和转换 + * - 分页信息自动更新和校验 + * - 智能防抖函数(支持取消和立即执行) + * - 统一的错误处理机制 + * - 嵌套数据结构解析 + * + * ## 使用场景 + * + * - useTable 组合式函数的底层工具 + * - 适配各种后端接口响应格式 + * - 表格数据的标准化处理 + * - 请求防抖和性能优化 + * - 错误统一处理和日志记录 + * + * ## 支持的响应格式 + * + * 1. 直接数组: [item1, item2, ...] + * 2. 标准对象: { records: [], total: 100 } + * 3. 嵌套data: { data: { list: [], total: 100 } } + * 4. 多种字段名: list/data/records/items/result/rows + * + * ## 核心功能 + * + * - defaultResponseAdapter: 智能识别和转换响应格式 + * - extractTableData: 提取表格数据数组 + * - updatePaginationFromResponse: 更新分页信息 + * - createSmartDebounce: 创建可控的防抖函数 + * - createErrorHandler: 生成错误处理器 + * + * @module utils/table/tableUtils + * @author Art Design Pro Team + */ + +import type { ApiResponse } from './tableCache' +import { tableConfig } from './tableConfig' + +// 请求参数基础接口,扩展分页参数 +export interface BaseRequestParams extends Api.Common.PaginationParams { + [key: string]: unknown +} + +// 错误处理接口 +export interface TableError { + code: string + message: string + details?: unknown +} + +// 辅助函数:从对象中提取记录数组 +function extractRecords(obj: Record, fields: string[]): T[] { + for (const field of fields) { + if (field in obj && Array.isArray(obj[field])) { + return obj[field] as T[] + } + } + return [] +} + +// 辅助函数:从对象中提取总数 +function extractTotal(obj: Record, records: unknown[], fields: string[]): number { + for (const field of fields) { + if (field in obj && typeof obj[field] === 'number') { + return obj[field] as number + } + } + return records.length +} + +// 辅助函数:提取分页参数 +function extractPagination( + obj: Record, + data?: Record +): Pick, 'current' | 'size'> | undefined { + const result: Partial, 'current' | 'size'>> = {} + const sources = [obj, data ?? {}] + + const currentFields = tableConfig.currentFields + for (const src of sources) { + for (const field of currentFields) { + if (field in src && typeof src[field] === 'number') { + result.current = src[field] as number + break + } + } + if (result.current !== undefined) break + } + + const sizeFields = tableConfig.sizeFields + for (const src of sources) { + for (const field of sizeFields) { + if (field in src && typeof src[field] === 'number') { + result.size = src[field] as number + break + } + } + if (result.size !== undefined) break + } + + if (result.current === undefined && result.size === undefined) return undefined + return result +} + +/** + * 默认响应适配器 - 支持多种常见的API响应格式 + */ +export const defaultResponseAdapter = (response: unknown): ApiResponse => { + // 定义支持的字段 + const recordFields = tableConfig.recordFields + + if (!response) { + return { records: [], total: 0 } + } + + if (Array.isArray(response)) { + return { records: response, total: response.length } + } + + if (typeof response !== 'object') { + console.warn( + '[tableUtils] 无法识别的响应格式,支持的格式包括: 数组、包含' + + recordFields.join('/') + + '字段的对象、嵌套data对象。当前格式:', + response + ) + return { records: [], total: 0 } + } + + const res = response as Record + let records: T[] = [] + let total = 0 + let pagination: Pick, 'current' | 'size'> | undefined + + // 处理标准格式或直接列表 + records = extractRecords(res, recordFields) + total = extractTotal(res, records, tableConfig.totalFields) + pagination = extractPagination(res) + + // 如果没有找到,检查嵌套data + if (records.length === 0 && 'data' in res && typeof res.data === 'object') { + const data = res.data as Record + records = extractRecords(data, ['list', 'records', 'items']) + total = extractTotal(data, records, tableConfig.totalFields) + pagination = extractPagination(res, data) + + if (Array.isArray(res.data)) { + records = res.data as T[] + total = records.length + } + } + + if (!recordFields.some((field) => field in res) && records.length === 0) { + console.warn('[tableUtils] 无法识别的响应格式') + console.warn('支持的字段包括: ' + recordFields.join('、'), response) + console.warn('扩展字段请到 utils/table/tableConfig 文件配置') + } + + const result: ApiResponse = { records, total } + if (pagination) { + Object.assign(result, pagination) + } + return result +} + +/** + * 从标准化的API响应中提取表格数据 + */ +export const extractTableData = (response: ApiResponse): T[] => { + const data = response.records || response.data || [] + return Array.isArray(data) ? data : [] +} + +/** + * 根据API响应更新分页信息 + */ +export const updatePaginationFromResponse = ( + pagination: Api.Common.PaginationParams, + response: ApiResponse +): void => { + pagination.total = response.total ?? pagination.total ?? 0 + + if (response.current !== undefined) { + pagination.current = response.current + } + + const maxPage = Math.max(1, Math.ceil(pagination.total / (pagination.size || 1))) + if (pagination.current > maxPage) { + pagination.current = maxPage + } +} + +/** + * 创建智能防抖函数 - 支持取消和立即执行 + */ +export const createSmartDebounce = Promise>( + fn: T, + delay: number +): T & { cancel: () => void; flush: () => Promise } => { + let timeoutId: NodeJS.Timeout | null = null + let lastArgs: Parameters | null = null + let lastResolve: ((value: any) => void) | null = null + let lastReject: ((reason: any) => void) | null = null + + const debouncedFn = (...args: Parameters): Promise => { + return new Promise((resolve, reject) => { + if (timeoutId) clearTimeout(timeoutId) + lastArgs = args + lastResolve = resolve + lastReject = reject + timeoutId = setTimeout(async () => { + try { + const result = await fn(...args) + resolve(result) + } catch (error) { + reject(error) + } finally { + timeoutId = null + lastArgs = null + lastResolve = null + lastReject = null + } + }, delay) + }) + } + + debouncedFn.cancel = () => { + if (timeoutId) clearTimeout(timeoutId) + timeoutId = null + lastArgs = null + lastResolve = null + lastReject = null + } + + debouncedFn.flush = async () => { + if (timeoutId && lastArgs && lastResolve && lastReject) { + clearTimeout(timeoutId) + timeoutId = null + const args = lastArgs + const resolve = lastResolve + const reject = lastReject + lastArgs = null + lastResolve = null + lastReject = null + try { + const result = await fn(...args) + resolve(result) + return result + } catch (error) { + reject(error) + throw error + } + } + return Promise.resolve() + } + + return debouncedFn as any +} + +/** + * 生成错误处理函数 + */ +export const createErrorHandler = ( + onError?: (error: TableError) => void, + enableLog: boolean = false +) => { + const logger = { + error: (message: string, ...args: any[]) => { + if (enableLog) console.error(`[useTable] ${message}`, ...args) + } + } + + return (err: unknown, context: string): TableError => { + const tableError: TableError = { + code: 'UNKNOWN_ERROR', + message: '未知错误', + details: err + } + + if (err instanceof Error) { + tableError.message = err.message + tableError.code = err.name + } else if (typeof err === 'string') { + tableError.message = err + } + + logger.error(`${context}:`, err) + onError?.(tableError) + return tableError + } +} diff --git a/web/apps/portal/src/utils/ui/animation.ts b/web/apps/portal/src/utils/ui/animation.ts new file mode 100644 index 0000000..5efd02a --- /dev/null +++ b/web/apps/portal/src/utils/ui/animation.ts @@ -0,0 +1,80 @@ +/** + * 主题动画工具模块 + * + * 提供主题切换的视觉动画效果 + * + * ## 主要功能 + * + * - 基于鼠标点击位置的圆形扩散动画 + * - View Transition API 支持(现代浏览器) + * - 降级处理(不支持动画的浏览器) + * - 暗黑主题切换过渡效果 + * - 页面刷新时的主题过渡优化 + * + * ## 使用场景 + * + * - 明暗主题切换 + * - 提升用户体验的视觉反馈 + * - 页面刷新时的平滑过渡 + * + * ## 技术实现 + * + * - 使用 CSS 变量存储点击位置和半径 + * - 利用 View Transition API 实现流畅动画 + * - 通过 CSS class 控制过渡效果 + * - 自动计算最大扩散半径 + * + * @module utils/theme/animation + * @author Art Design Pro Team + */ +import { useCommon } from '@/hooks/core/useCommon' +import { useTheme } from '@/hooks/core/useTheme' +import { SystemThemeEnum } from '@/enums/appEnum' +import { useSettingStore } from '@/store/modules/setting' +const { LIGHT, DARK } = SystemThemeEnum + +/** + * 主题切换动画 + * @param e 鼠标点击事件 + */ +export const themeAnimation = (e: any) => { + const x = e.clientX + const y = e.clientY + // 计算鼠标点击位置距离视窗的最大圆半径 + const endRadius = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y)) + + // 设置CSS变量 + document.documentElement.style.setProperty('--x', x + 'px') + document.documentElement.style.setProperty('--y', y + 'px') + document.documentElement.style.setProperty('--r', endRadius + 'px') + + if (document.startViewTransition) { + document.startViewTransition(() => toggleTheme()) + } else { + toggleTheme() + } +} + +/** + * 切换主题 + */ +const toggleTheme = () => { + useTheme().switchThemeStyles(useSettingStore().systemThemeType === LIGHT ? DARK : LIGHT) + useCommon().refresh() +} + +/** + * 切换主题过渡效果 + * @param enable 是否启用过渡效果 + */ +export const toggleTransition = (enable: boolean) => { + const body = document.body + + if (enable) { + body.classList.add('theme-change') + } else { + setTimeout(() => { + body.classList.remove('theme-change') + }, 300) + } +} diff --git a/web/apps/portal/src/utils/ui/colors.ts b/web/apps/portal/src/utils/ui/colors.ts new file mode 100755 index 0000000..b4f6b77 --- /dev/null +++ b/web/apps/portal/src/utils/ui/colors.ts @@ -0,0 +1,273 @@ +/** + * 颜色处理工具模块 + * + * 提供完整的颜色格式转换和处理功能 + * + * ## 主要功能 + * + * - Hex 与 RGB/RGBA 格式互转 + * - 颜色混合计算 + * - 颜色变浅/变深处理 + * - Element Plus 主题色自动生成 + * - 颜色格式验证 + * - CSS 变量读取 + * - 暗黑模式颜色适配 + * + * ## 使用场景 + * + * - 主题色动态切换 + * - Element Plus 组件主题定制 + * - 颜色渐变生成 + * - 明暗主题颜色计算 + * - 颜色格式标准化 + * + * ## 核心功能 + * + * - hexToRgba: Hex 转 RGBA(支持透明度) + * - hexToRgb: Hex 转 RGB 数组 + * - rgbToHex: RGB 转 Hex + * - colourBlend: 两种颜色混合 + * - getLightColor: 生成变浅的颜色 + * - getDarkColor: 生成变深的颜色 + * - handleElementThemeColor: 处理 Element Plus 主题色 + * - setElementThemeColor: 设置完整的主题色系统 + * + * ## 支持格式 + * + * - Hex: #FFF, #FFFFFF + * - RGB: rgb(255, 255, 255) + * - RGBA: rgba(255, 255, 255, 0.5) + * + * @module utils/ui/colors + * @author Art Design Pro Team + */ +import { useSettingStore } from '@/store/modules/setting' + +/** + * 颜色转换结果接口 + */ +interface RgbaResult { + red: number + green: number + blue: number + rgba: string +} + +/** + * 获取CSS变量值(别名函数) + * @param name CSS变量名 + * @returns CSS变量值 + */ +export function getCssVar(name: string): string { + return getComputedStyle(document.documentElement).getPropertyValue(name) +} + +/** + * 验证hex颜色格式 + * @param hex hex颜色值 + * @returns 是否为有效的hex颜色 + */ +function isValidHexColor(hex: string): boolean { + const cleanHex = hex.trim().replace(/^#/, '') + return /^[0-9A-Fa-f]{3}$|^[0-9A-Fa-f]{6}$/.test(cleanHex) +} + +/** + * 验证RGB颜色值 + * @param r 红色值 + * @param g 绿色值 + * @param b 蓝色值 + * @returns 是否为有效的RGB值 + */ +function isValidRgbValue(r: number, g: number, b: number): boolean { + const isValid = (value: number) => Number.isInteger(value) && value >= 0 && value <= 255 + return isValid(r) && isValid(g) && isValid(b) +} + +/** + * 将hex颜色转换为RGBA + * @param hex hex颜色值 (支持 #FFF 或 #FFFFFF 格式) + * @param opacity 透明度 (0-1) + * @returns 包含RGB值和RGBA字符串的对象 + */ +export function hexToRgba(hex: string, opacity: number): RgbaResult { + if (!isValidHexColor(hex)) { + throw new Error('Invalid hex color format') + } + + // 移除可能存在的 # 前缀并转换为大写 + let cleanHex = hex.trim().replace(/^#/, '').toUpperCase() + + // 如果是缩写形式(如 FFF),转换为完整形式 + if (cleanHex.length === 3) { + cleanHex = cleanHex + .split('') + .map((char) => char.repeat(2)) + .join('') + } + + // 解析 RGB 值 + const [red, green, blue] = cleanHex.match(/\w\w/g)!.map((x) => parseInt(x, 16)) + + // 确保 opacity 在有效范围内 + const validOpacity = Math.max(0, Math.min(1, opacity)) + + // 构建 RGBA 字符串 + const rgba = `rgba(${red}, ${green}, ${blue}, ${validOpacity.toFixed(2)})` + + return { red, green, blue, rgba } +} + +/** + * 将hex颜色转换为RGB数组 + * @param hexColor hex颜色值 + * @returns RGB数组 [r, g, b] + */ +export function hexToRgb(hexColor: string): number[] { + if (!isValidHexColor(hexColor)) { + ElMessage.warning('输入错误的hex颜色值') + throw new Error('Invalid hex color format') + } + + const cleanHex = hexColor.replace(/^#/, '') + let hex = cleanHex + + // 处理缩写形式 + if (hex.length === 3) { + hex = hex + .split('') + .map((char) => char.repeat(2)) + .join('') + } + + const hexPairs = hex.match(/../g) + if (!hexPairs) { + throw new Error('Invalid hex color format') + } + + return hexPairs.map((hexPair) => parseInt(hexPair, 16)) +} + +/** + * 将RGB颜色转换为hex + * @param r 红色值 (0-255) + * @param g 绿色值 (0-255) + * @param b 蓝色值 (0-255) + * @returns hex颜色值 + */ +export function rgbToHex(r: number, g: number, b: number): string { + if (!isValidRgbValue(r, g, b)) { + ElMessage.warning('输入错误的RGB颜色值') + throw new Error('Invalid RGB color values') + } + + const toHex = (value: number) => { + const hex = value.toString(16) + return hex.length === 1 ? `0${hex}` : hex + } + + return `#${toHex(r)}${toHex(g)}${toHex(b)}` +} + +/** + * 颜色混合 + * @param color1 第一个颜色 + * @param color2 第二个颜色 + * @param ratio 混合比例 (0-1) + * @returns 混合后的颜色 + */ +export function colourBlend(color1: string, color2: string, ratio: number): string { + const validRatio = Math.max(0, Math.min(1, Number(ratio))) + + const rgb1 = hexToRgb(color1) + const rgb2 = hexToRgb(color2) + + const blendedRgb = rgb1.map((value1, index) => { + const value2 = rgb2[index] + return Math.round(value1 * (1 - validRatio) + value2 * validRatio) + }) + + return rgbToHex(blendedRgb[0], blendedRgb[1], blendedRgb[2]) +} + +/** + * 获取变浅的颜色 + * @param color 原始颜色 + * @param level 变浅程度 (0-1) + * @param isDark 是否为暗色主题 + * @returns 变浅后的颜色 + */ +export function getLightColor(color: string, level: number, isDark: boolean = false): string { + if (!isValidHexColor(color)) { + ElMessage.warning('输入错误的hex颜色值') + throw new Error('Invalid hex color format') + } + + if (isDark) { + return getDarkColor(color, level) + } + + const rgb = hexToRgb(color) + const lightRgb = rgb.map((value) => Math.floor((255 - value) * level + value)) + + return rgbToHex(lightRgb[0], lightRgb[1], lightRgb[2]) +} + +/** + * 获取变深的颜色 + * @param color 原始颜色 + * @param level 变深程度 (0-1) + * @returns 变深后的颜色 + */ +export function getDarkColor(color: string, level: number): string { + if (!isValidHexColor(color)) { + ElMessage.warning('输入错误的hex颜色值') + throw new Error('Invalid hex color format') + } + + const rgb = hexToRgb(color) + const darkRgb = rgb.map((value) => Math.floor(value * (1 - level))) + + return rgbToHex(darkRgb[0], darkRgb[1], darkRgb[2]) +} + +/** + * 处理 Element Plus 主题颜色 + * @param theme 主题颜色 + * @param isDark 是否为暗色主题 + */ +export function handleElementThemeColor(theme: string, isDark: boolean = false): void { + document.documentElement.style.setProperty('--el-color-primary', theme) + + for (let i = 1; i <= 9; i++) { + document.documentElement.style.setProperty( + `--el-color-primary-light-${i}`, + getLightColor(theme, i / 10, isDark) + ) + } + + for (let i = 1; i <= 9; i++) { + document.documentElement.style.setProperty( + `--el-color-primary-dark-${i}`, + getDarkColor(theme, i / 10) + ) + } +} + +/** + * 设置 Element Plus 主题颜色 + * @param color 主题颜色 + */ +export function setElementThemeColor(color: string): void { + const mixColor = '#ffffff' + const elStyle = document.documentElement.style + + elStyle.setProperty('--el-color-primary', color) + handleElementThemeColor(color, useSettingStore().isDark) + + // 生成更淡一点的颜色 + for (let i = 1; i < 16; i++) { + const itemColor = colourBlend(color, mixColor, i / 16) + elStyle.setProperty(`--el-color-primary-custom-${i}`, itemColor) + } +} diff --git a/web/apps/portal/src/utils/ui/emojo.ts b/web/apps/portal/src/utils/ui/emojo.ts new file mode 100644 index 0000000..cabad7d --- /dev/null +++ b/web/apps/portal/src/utils/ui/emojo.ts @@ -0,0 +1,24 @@ +/** + * 表情 + * 用于在消息提示的时候显示对应的表情 + * + * 用法 + * ElMessage.success(`${EmojiText[200]} 图片上传成功`) + * ElMessage.error(`${EmojiText[400]} 图片上传失败`) + * ElMessage.error(`${EmojiText[500]} 图片上传失败`) + * + * @module utils/ui/emojo + * @author Art Design Pro Team + */ + +// macos 用户 按 shift + 6 可以唤出更多表情…… +const EmojiText: { [key: string]: string } = { + '0': 'O_O', // 空 + '200': '^_^', // 成功 + '400': 'T_T', // 错误请求 + '500': 'X_X' // 服务器内部错误,无法完成请求 +} + +// const EmojiIcon = ['🟢', '🔴', '🟡 ', '🚀', '✨', '💡', '🛠️', '🔥', '🎉', '🌟', '🌈'] + +export default EmojiText diff --git a/web/apps/portal/src/utils/ui/iconify-loader.ts b/web/apps/portal/src/utils/ui/iconify-loader.ts new file mode 100644 index 0000000..035de16 --- /dev/null +++ b/web/apps/portal/src/utils/ui/iconify-loader.ts @@ -0,0 +1,31 @@ +/** + * 离线图标加载器 + * + * 用于在内网环境下支持 Iconify 图标的离线加载。 + * 通过预加载图标集数据,避免运行时从 CDN 获取图标。 + * + * 使用方式: + * 1. 安装所需图标集:pnpm add -D @iconify-json/[icon-set-name] + * 2. 在此文件中导入并注册图标集 + * 3. 在组件中使用: + * + * @module utils/ui/iconify-loader + * @author Art Design Pro Team + */ + +// import { addCollection } from '@iconify/vue' + +// // 导入离线图标数据 + +// // 系统必要图标库 +// import riIcons from '@iconify-json/ri/icons.json' + +// // 演示图标库(可选,生产环境可移除) +// import svgSpinners from '@iconify-json/svg-spinners/icons.json' +// import lineMd from '@iconify-json/line-md/icons.json' + +// // 注册离线图标集 + +// addCollection(riIcons) +// addCollection(svgSpinners) +// addCollection(lineMd) diff --git a/web/apps/portal/src/utils/ui/index.ts b/web/apps/portal/src/utils/ui/index.ts new file mode 100644 index 0000000..9ca1049 --- /dev/null +++ b/web/apps/portal/src/utils/ui/index.ts @@ -0,0 +1,11 @@ +/** + * UI 相关工具函数统一导出 + * + * @module utils/ui/index + * @author Art Design Pro Team + */ + +export * from './colors' +export * from './loading' +export * from './tabs' +export * from './emojo' diff --git a/web/apps/portal/src/utils/ui/loading.ts b/web/apps/portal/src/utils/ui/loading.ts new file mode 100644 index 0000000..6580e02 --- /dev/null +++ b/web/apps/portal/src/utils/ui/loading.ts @@ -0,0 +1,84 @@ +/** + * 全局 Loading 加载管理模块 + * + * 提供统一的全屏加载动画管理 + * + * ## 主要功能 + * + * - 全屏 Loading 显示和隐藏 + * - 自动适配明暗主题背景色 + * - 自定义 SVG 加载动画 + * - 单例模式防止重复创建 + * - 锁定页面交互 + * + * ## 使用场景 + * + * - 页面初始化加载 + * - 大量数据请求 + * - 路由切换过渡 + * - 异步操作等待 + * + * ## 特性 + * + * - 自动检测当前主题并应用对应背景色 + * - 使用自定义 SVG 动画(四点旋转) + * - 单例模式确保同时只有一个 Loading + * - 提供便捷的显示/隐藏方法 + * + * @module utils/ui/loading + * @author Art Design Pro Team + */ +import { fourDotsSpinnerSvg } from '@/assets/svg/loading' + +/** + * 获取当前主题对应的loading背景色 + * @returns 背景色字符串 + */ +const getLoadingBackground = (): string => { + const isDark = document.documentElement.classList.contains('dark') + return isDark ? 'rgba(7, 7, 7, 0.85)' : '#fff' +} + +const DEFAULT_LOADING_CONFIG = { + lock: true, + get background() { + return getLoadingBackground() + }, + svg: fourDotsSpinnerSvg, + svgViewBox: '0 0 40 40', + customClass: 'art-loading-fix' +} as const + +interface LoadingInstance { + close: () => void +} + +let loadingInstance: LoadingInstance | null = null + +export const loadingService = { + /** + * 显示 loading + * @returns 关闭 loading 的函数 + */ + showLoading(): () => void { + if (!loadingInstance) { + // 每次显示时获取最新的配置,确保背景色与当前主题同步 + const config = { + ...DEFAULT_LOADING_CONFIG, + background: getLoadingBackground() + } + loadingInstance = ElLoading.service(config) + } + return () => this.hideLoading() + }, + + /** + * 隐藏 loading + */ + hideLoading(): void { + if (loadingInstance) { + loadingInstance.close() + loadingInstance = null + } + } +} diff --git a/web/apps/portal/src/utils/ui/tabs.ts b/web/apps/portal/src/utils/ui/tabs.ts new file mode 100644 index 0000000..5f53ea5 --- /dev/null +++ b/web/apps/portal/src/utils/ui/tabs.ts @@ -0,0 +1,60 @@ +/** + * 标签页布局配置模块 + * + * 提供不同标签页样式的高度和间距配置 + * + * ## 主要功能 + * + * - 多种标签页样式配置(默认、卡片、谷歌风格) + * - 标签页打开/关闭状态的高度管理 + * - 顶部间距自动计算 + * - 配置获取和默认值处理 + * + * ## 使用场景 + * + * - 工作标签页(Worktab)布局计算 + * - 页面内容区域高度调整 + * - 标签页显示/隐藏时的动画 + * - 响应式布局适配 + * + * ## 配置项说明 + * + * - openTop: 标签页显示时,内容区域距离顶部的距离 + * - closeTop: 标签页隐藏时,内容区域距离顶部的距离 + * - openHeight: 标签页显示时的总高度(包含标签栏) + * - closeHeight: 标签页隐藏时的总高度(仅头部) + * + * ## 支持的样式 + * + * - tab-default: 默认标签页样式 + * - tab-card: 卡片式标签页 + * - tab-google: 谷歌浏览器风格标签页 + * + * @module utils/ui/tabs + * @author Art Design Pro Team + */ +export const TAB_CONFIG = { + 'tab-default': { + openTop: 106, + closeTop: 60, + openHeight: 121, + closeHeight: 75 + }, + 'tab-card': { + openTop: 122, + closeTop: 78, + openHeight: 139, + closeHeight: 95 + }, + 'tab-google': { + openTop: 122, + closeTop: 78, + openHeight: 139, + closeHeight: 95 + } +} + +// 获取当前 tab 样式配置,设置默认值 +export const getTabConfig = (style: string) => { + return TAB_CONFIG[style as keyof typeof TAB_CONFIG] || TAB_CONFIG['tab-card'] // 默认使用 tab-card 配置 +} diff --git a/web/apps/portal/src/views/auth/forget-password/index.vue b/web/apps/portal/src/views/auth/forget-password/index.vue new file mode 100644 index 0000000..147259e --- /dev/null +++ b/web/apps/portal/src/views/auth/forget-password/index.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/web/apps/portal/src/views/auth/login/index.vue b/web/apps/portal/src/views/auth/login/index.vue new file mode 100755 index 0000000..c933723 --- /dev/null +++ b/web/apps/portal/src/views/auth/login/index.vue @@ -0,0 +1,385 @@ + + + + + + + + + + diff --git a/web/apps/portal/src/views/auth/login/style.css b/web/apps/portal/src/views/auth/login/style.css new file mode 100644 index 0000000..f3c4c68 --- /dev/null +++ b/web/apps/portal/src/views/auth/login/style.css @@ -0,0 +1,104 @@ +@reference '@styles/core/tailwind.css'; + +/* 授权页右侧区域(语枢网关深色 HUD 玻璃面板) */ +.auth-right-wrap { + position: absolute; + inset: 0; + width: 440px; + height: 640px; + margin: auto; + padding: 40px 48px; + overflow: hidden; + background: rgb(7 17 38 / 72%); + backdrop-filter: blur(14px); + border: 1px solid rgb(0 229 255 / 16%); + border-radius: 14px; + box-shadow: + 0 0 40px rgb(0 229 255 / 8%), + inset 0 0 60px rgb(0 229 255 / 3%); + animation: slideInRight 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards; + + .form { + height: 100%; + } + + .title { + font-size: 32px; + font-weight: 600; + letter-spacing: 1px; + color: #f0f7ff; + text-shadow: 0 0 20px rgb(0 229 255 / 45%); + } + + .sub-title { + margin-top: 10px; + font-size: 14px; + letter-spacing: 1px; + color: rgb(0 229 255 / 75%); + font-family: ui-monospace, 'JetBrains Mono', Consolas, monospace; + } + + .custom-height { + height: 40px !important; + } + + /* 安全徽标 */ + .security-badge { + position: absolute; + right: 0; + bottom: 14px; + left: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + font-family: ui-monospace, 'JetBrains Mono', Consolas, monospace; + font-size: 12px; + letter-spacing: 1px; + color: rgb(0 229 255 / 70%); + + .sb-dot { + width: 6px; + height: 6px; + background: #00ff9c; + border-radius: 50%; + box-shadow: 0 0 8px #00ff9c; + animation: sbBlink 2s ease-in-out infinite; + } + } + + @media only screen and (width < 640px) { + width: 100%; + height: auto; + padding: 32px 24px 64px; + } + + @media only screen and (width < 768px) { + animation: none; + } +} + +/* 滑入动画 */ +@keyframes slideInRight { + from { + opacity: 0; + transform: translateX(30px); + } + + to { + opacity: 1; + transform: translateX(0); + } +} + +/* 安全徽标呼吸 */ +@keyframes sbBlink { + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.3; + } +} diff --git a/web/apps/portal/src/views/auth/register/index.vue b/web/apps/portal/src/views/auth/register/index.vue new file mode 100644 index 0000000..9a8570d --- /dev/null +++ b/web/apps/portal/src/views/auth/register/index.vue @@ -0,0 +1,240 @@ + + + + + + diff --git a/web/apps/portal/src/views/dashboard/console/index.vue b/web/apps/portal/src/views/dashboard/console/index.vue new file mode 100755 index 0000000..154c330 --- /dev/null +++ b/web/apps/portal/src/views/dashboard/console/index.vue @@ -0,0 +1,41 @@ + + + + diff --git a/web/apps/portal/src/views/dashboard/console/modules/about-project.vue b/web/apps/portal/src/views/dashboard/console/modules/about-project.vue new file mode 100644 index 0000000..ed946ce --- /dev/null +++ b/web/apps/portal/src/views/dashboard/console/modules/about-project.vue @@ -0,0 +1,44 @@ + + + diff --git a/web/apps/portal/src/views/dashboard/console/modules/active-user.vue b/web/apps/portal/src/views/dashboard/console/modules/active-user.vue new file mode 100755 index 0000000..da740f2 --- /dev/null +++ b/web/apps/portal/src/views/dashboard/console/modules/active-user.vue @@ -0,0 +1,47 @@ + + + diff --git a/web/apps/portal/src/views/dashboard/console/modules/card-list.vue b/web/apps/portal/src/views/dashboard/console/modules/card-list.vue new file mode 100755 index 0000000..5fc76a7 --- /dev/null +++ b/web/apps/portal/src/views/dashboard/console/modules/card-list.vue @@ -0,0 +1,74 @@ + + + diff --git a/web/apps/portal/src/views/dashboard/console/modules/dynamic-stats.vue b/web/apps/portal/src/views/dashboard/console/modules/dynamic-stats.vue new file mode 100755 index 0000000..1876950 --- /dev/null +++ b/web/apps/portal/src/views/dashboard/console/modules/dynamic-stats.vue @@ -0,0 +1,79 @@ + + + diff --git a/web/apps/portal/src/views/dashboard/console/modules/new-user.vue b/web/apps/portal/src/views/dashboard/console/modules/new-user.vue new file mode 100755 index 0000000..9d39522 --- /dev/null +++ b/web/apps/portal/src/views/dashboard/console/modules/new-user.vue @@ -0,0 +1,169 @@ + + + + + diff --git a/web/apps/portal/src/views/dashboard/console/modules/sales-overview.vue b/web/apps/portal/src/views/dashboard/console/modules/sales-overview.vue new file mode 100755 index 0000000..32904b8 --- /dev/null +++ b/web/apps/portal/src/views/dashboard/console/modules/sales-overview.vue @@ -0,0 +1,43 @@ + + + diff --git a/web/apps/portal/src/views/dashboard/console/modules/todo-list.vue b/web/apps/portal/src/views/dashboard/console/modules/todo-list.vue new file mode 100755 index 0000000..ab9a86c --- /dev/null +++ b/web/apps/portal/src/views/dashboard/console/modules/todo-list.vue @@ -0,0 +1,71 @@ + + + diff --git a/web/apps/portal/src/views/exception/403/index.vue b/web/apps/portal/src/views/exception/403/index.vue new file mode 100755 index 0000000..2756c42 --- /dev/null +++ b/web/apps/portal/src/views/exception/403/index.vue @@ -0,0 +1,16 @@ + + + + diff --git a/web/apps/portal/src/views/exception/404/index.vue b/web/apps/portal/src/views/exception/404/index.vue new file mode 100755 index 0000000..6b64f45 --- /dev/null +++ b/web/apps/portal/src/views/exception/404/index.vue @@ -0,0 +1,16 @@ + + + + diff --git a/web/apps/portal/src/views/exception/500/index.vue b/web/apps/portal/src/views/exception/500/index.vue new file mode 100755 index 0000000..1b26377 --- /dev/null +++ b/web/apps/portal/src/views/exception/500/index.vue @@ -0,0 +1,16 @@ + + + + diff --git a/web/apps/portal/src/views/index/index.vue b/web/apps/portal/src/views/index/index.vue new file mode 100755 index 0000000..415a436 --- /dev/null +++ b/web/apps/portal/src/views/index/index.vue @@ -0,0 +1,29 @@ + + + + + + diff --git a/web/apps/portal/src/views/index/style.scss b/web/apps/portal/src/views/index/style.scss new file mode 100644 index 0000000..c356974 --- /dev/null +++ b/web/apps/portal/src/views/index/style.scss @@ -0,0 +1,103 @@ +.app-layout { + display: flex; + width: 100%; + min-height: 100vh; + // 语枢网关深色 HUD:藏青底 + 顶部青色辉光 + 极细网格(卡片自带底色,网格在留白区透出) + background: + radial-gradient(1000px 520px at 85% -10%, rgb(0 229 255 / 7%), transparent 55%), + linear-gradient(rgb(0 229 255 / 3%) 1px, transparent 1px), + linear-gradient(90deg, rgb(0 229 255 / 3%) 1px, transparent 1px), + var(--default-bg-color); + background-size: + auto, + 44px 44px, + 44px 44px, + auto; + + #app-sidebar { + flex-shrink: 0; + } + + #app-main { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + height: 100vh; + overflow: auto; + + #app-header { + position: sticky; + top: 0; + z-index: 50; + flex-shrink: 0; + width: 100%; + } + + #app-content { + flex: 1; + + :deep(.layout-content) { + box-sizing: border-box; + width: calc(100% - 40px); + margin: auto; + + // 子页面默认 style + .page-content { + position: relative; + box-sizing: border-box; + padding: 20px; + overflow: hidden; + background: var(--default-box-color); + border-radius: calc(var(--custom-radius) / 2 + 2px) !important; + } + } + } + } +} + +@media only screen and (width <= 1180px) { + .app-layout { + #app-main { + height: 100dvh; + } + } +} + +@media only screen and (width <= 800px) { + .app-layout { + position: relative; + + #app-sidebar { + position: fixed; + top: 0; + left: 0; + z-index: 300; + height: 100vh; + } + + #app-main { + width: 100%; + height: auto; + overflow: visible; + + #app-content { + :deep(.layout-content) { + width: calc(100% - 40px); + } + } + } + } +} + +@media only screen and (width <= 640px) { + .app-layout { + #app-main { + #app-content { + :deep(.layout-content) { + width: calc(100% - 30px); + } + } + } + } +} diff --git a/web/apps/portal/src/views/outside/Iframe.vue b/web/apps/portal/src/views/outside/Iframe.vue new file mode 100644 index 0000000..49cd8c2 --- /dev/null +++ b/web/apps/portal/src/views/outside/Iframe.vue @@ -0,0 +1,42 @@ + + + diff --git a/web/apps/portal/src/views/portal/access/index.vue b/web/apps/portal/src/views/portal/access/index.vue new file mode 100644 index 0000000..01b8d27 --- /dev/null +++ b/web/apps/portal/src/views/portal/access/index.vue @@ -0,0 +1,10 @@ + + diff --git a/web/apps/portal/src/views/portal/catalog/index.vue b/web/apps/portal/src/views/portal/catalog/index.vue new file mode 100644 index 0000000..429fe91 --- /dev/null +++ b/web/apps/portal/src/views/portal/catalog/index.vue @@ -0,0 +1,22 @@ + + + diff --git a/web/apps/portal/src/views/portal/marketplace/index.vue b/web/apps/portal/src/views/portal/marketplace/index.vue new file mode 100644 index 0000000..0580130 --- /dev/null +++ b/web/apps/portal/src/views/portal/marketplace/index.vue @@ -0,0 +1,127 @@ + + diff --git a/web/apps/portal/src/views/portal/prompts/index.vue b/web/apps/portal/src/views/portal/prompts/index.vue new file mode 100644 index 0000000..0a23f30 --- /dev/null +++ b/web/apps/portal/src/views/portal/prompts/index.vue @@ -0,0 +1,13 @@ + + + diff --git a/web/apps/portal/src/views/portal/usage/index.vue b/web/apps/portal/src/views/portal/usage/index.vue new file mode 100644 index 0000000..171e64c --- /dev/null +++ b/web/apps/portal/src/views/portal/usage/index.vue @@ -0,0 +1,11 @@ + + + diff --git a/web/apps/portal/src/views/result/fail/index.vue b/web/apps/portal/src/views/result/fail/index.vue new file mode 100755 index 0000000..8fe2583 --- /dev/null +++ b/web/apps/portal/src/views/result/fail/index.vue @@ -0,0 +1,28 @@ + + + diff --git a/web/apps/portal/src/views/result/success/index.vue b/web/apps/portal/src/views/result/success/index.vue new file mode 100755 index 0000000..ae57aba --- /dev/null +++ b/web/apps/portal/src/views/result/success/index.vue @@ -0,0 +1,21 @@ + + + diff --git a/web/apps/portal/src/views/system/menu/index.vue b/web/apps/portal/src/views/system/menu/index.vue new file mode 100644 index 0000000..b88ea0c --- /dev/null +++ b/web/apps/portal/src/views/system/menu/index.vue @@ -0,0 +1,479 @@ + + + + diff --git a/web/apps/portal/src/views/system/menu/modules/menu-dialog.vue b/web/apps/portal/src/views/system/menu/modules/menu-dialog.vue new file mode 100644 index 0000000..f512301 --- /dev/null +++ b/web/apps/portal/src/views/system/menu/modules/menu-dialog.vue @@ -0,0 +1,384 @@ + + + diff --git a/web/apps/portal/src/views/system/role/index.vue b/web/apps/portal/src/views/system/role/index.vue new file mode 100755 index 0000000..126e8fd --- /dev/null +++ b/web/apps/portal/src/views/system/role/index.vue @@ -0,0 +1,241 @@ + + + + diff --git a/web/apps/portal/src/views/system/role/modules/role-edit-dialog.vue b/web/apps/portal/src/views/system/role/modules/role-edit-dialog.vue new file mode 100644 index 0000000..46ff9b1 --- /dev/null +++ b/web/apps/portal/src/views/system/role/modules/role-edit-dialog.vue @@ -0,0 +1,162 @@ + + + diff --git a/web/apps/portal/src/views/system/role/modules/role-permission-dialog.vue b/web/apps/portal/src/views/system/role/modules/role-permission-dialog.vue new file mode 100644 index 0000000..3691ac8 --- /dev/null +++ b/web/apps/portal/src/views/system/role/modules/role-permission-dialog.vue @@ -0,0 +1,254 @@ + + + diff --git a/web/apps/portal/src/views/system/role/modules/role-search.vue b/web/apps/portal/src/views/system/role/modules/role-search.vue new file mode 100644 index 0000000..ae929e7 --- /dev/null +++ b/web/apps/portal/src/views/system/role/modules/role-search.vue @@ -0,0 +1,125 @@ + + + diff --git a/web/apps/portal/src/views/system/user-center/index.vue b/web/apps/portal/src/views/system/user-center/index.vue new file mode 100755 index 0000000..ab52f00 --- /dev/null +++ b/web/apps/portal/src/views/system/user-center/index.vue @@ -0,0 +1,247 @@ + + + + diff --git a/web/apps/portal/src/views/system/user/index.vue b/web/apps/portal/src/views/system/user/index.vue new file mode 100644 index 0000000..ca07681 --- /dev/null +++ b/web/apps/portal/src/views/system/user/index.vue @@ -0,0 +1,261 @@ + + + + + + + + diff --git a/web/apps/portal/src/views/system/user/modules/user-dialog.vue b/web/apps/portal/src/views/system/user/modules/user-dialog.vue new file mode 100644 index 0000000..03cab4f --- /dev/null +++ b/web/apps/portal/src/views/system/user/modules/user-dialog.vue @@ -0,0 +1,143 @@ + + + diff --git a/web/apps/portal/src/views/system/user/modules/user-search.vue b/web/apps/portal/src/views/system/user/modules/user-search.vue new file mode 100644 index 0000000..7282a8e --- /dev/null +++ b/web/apps/portal/src/views/system/user/modules/user-search.vue @@ -0,0 +1,112 @@ + + + diff --git a/web/apps/portal/tsconfig.json b/web/apps/portal/tsconfig.json new file mode 100755 index 0000000..4331962 --- /dev/null +++ b/web/apps/portal/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "node", + "strict": true, + "jsx": "preserve", + "sourceMap": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "lib": ["esnext", "dom"], + "types": ["vite/client", "node", "element-plus/global"], + "skipLibCheck": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"], + "@views/*": ["src/views/*"], + "@imgs/*": ["src/assets/images/*"], + "@icons/*": ["src/assets/icons/*"], + "@utils/*": ["src/utils/*"], + "@stores/*": ["src/store/*"], + "@plugins/*": ["src/plugins/*"], + "@styles/*": ["src/assets/styles/*"] + } + }, + "include": ["src/**/*", "src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], + "exclude": ["node_modules", "dist", "**/*.js"] +} diff --git a/web/apps/portal/vite.config.ts b/web/apps/portal/vite.config.ts new file mode 100755 index 0000000..c2ef072 --- /dev/null +++ b/web/apps/portal/vite.config.ts @@ -0,0 +1,156 @@ +import { defineConfig, loadEnv } from 'vite' +import vue from '@vitejs/plugin-vue' +import path from 'path' +import { fileURLToPath } from 'url' +import vueDevTools from 'vite-plugin-vue-devtools' +import viteCompression from 'vite-plugin-compression' +import Components from 'unplugin-vue-components/vite' +import AutoImport from 'unplugin-auto-import/vite' +import ElementPlus from 'unplugin-element-plus/vite' +import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' +import tailwindcss from '@tailwindcss/vite' +// import { visualizer } from 'rollup-plugin-visualizer' + +export default ({ mode }: { mode: string }) => { + const root = process.cwd() + const env = loadEnv(mode, root) + const { VITE_VERSION, VITE_PORT, VITE_BASE_URL, VITE_API_URL, VITE_API_PROXY_URL } = env + + console.log(`🚀 API_URL = ${VITE_API_URL}`) + console.log(`🚀 VERSION = ${VITE_VERSION}`) + + return defineConfig({ + define: { + __APP_VERSION__: JSON.stringify(VITE_VERSION) + }, + base: VITE_BASE_URL, + server: { + port: Number(VITE_PORT), + proxy: { + '/api': { + target: VITE_API_PROXY_URL, + changeOrigin: true + } + }, + host: true + }, + // 路径别名 + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + '@views': resolvePath('src/views'), + '@imgs': resolvePath('src/assets/images'), + '@icons': resolvePath('src/assets/icons'), + '@utils': resolvePath('src/utils'), + '@stores': resolvePath('src/store'), + '@styles': resolvePath('src/assets/styles') + } + }, + build: { + target: 'es2015', + outDir: 'dist', + chunkSizeWarningLimit: 2000, + minify: 'terser', + terserOptions: { + compress: { + // 生产环境去除 console + drop_console: true, + // 生产环境去除 debugger + drop_debugger: true + } + }, + dynamicImportVarsOptions: { + warnOnError: true, + exclude: [], + include: ['src/views/**/*.vue'] + } + }, + plugins: [ + vue(), + tailwindcss(), + // 自动按需导入 API + AutoImport({ + imports: ['vue', 'vue-router', 'pinia', '@vueuse/core'], + dts: 'src/types/import/auto-imports.d.ts', + resolvers: [ElementPlusResolver()], + eslintrc: { + enabled: true, + filepath: './.auto-import.json', + globalsPropValue: true + } + }), + // 自动按需导入组件 + Components({ + dts: 'src/types/import/components.d.ts', + resolvers: [ElementPlusResolver()] + }), + // 按需定制主题配置 + ElementPlus({ + useSource: true + }), + // 压缩 + viteCompression({ + verbose: false, // 是否在控制台输出压缩结果 + disable: false, // 是否禁用 + algorithm: 'gzip', // 压缩算法 + ext: '.gz', // 压缩后的文件名后缀 + threshold: 10240, // 只有大小大于该值的资源会被处理 10240B = 10KB + deleteOriginFile: false // 压缩后是否删除原文件 + }), + vueDevTools() + // 打包分析 + // visualizer({ + // open: true, + // gzipSize: true, + // brotliSize: true, + // filename: 'dist/stats.html' // 分析图生成的文件名及路径 + // }), + ], + // 依赖预构建:避免运行时重复请求与转换,提升首次加载速度 + optimizeDeps: { + include: [ + 'echarts/core', + 'echarts/charts', + 'echarts/components', + 'echarts/renderers', + 'xlsx', + 'xgplayer', + 'crypto-js', + 'file-saver', + 'vue-img-cutter', + 'element-plus/es', + 'element-plus/es/components/*/style/css', + 'element-plus/es/components/*/style/index' + ] + }, + css: { + preprocessorOptions: { + // sass variable and mixin + scss: { + additionalData: ` + @use "@styles/core/el-light.scss" as *; + @use "@styles/core/mixin.scss" as *; + ` + } + }, + postcss: { + plugins: [ + { + postcssPlugin: 'internal:charset-removal', + AtRule: { + charset: (atRule) => { + if (atRule.name === 'charset') { + atRule.remove() + } + } + } + } + ] + } + } + }) +} + +function resolvePath(paths: string) { + return path.resolve(__dirname, paths) +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..8bc3141 --- /dev/null +++ b/web/package.json @@ -0,0 +1,25 @@ +{ + "name": "@aigateway/web", + "private": true, + "packageManager": "pnpm@10.15.1", + "pnpm": { + "overrides": { + "vue": "3.5.22", + "@vue/reactivity": "3.5.22", + "@vue/compiler-sfc": "3.5.22", + "axios": "1.12.2", + "element-plus": "2.11.4", + "vite": "7.1.7", + "@vitejs/plugin-vue": "6.0.1", + "vue-tsc": "2.1.10", + "typescript": "5.6.3", + "@types/node": "24.8.1" + } + }, + "scripts": { + "dev:admin": "corepack pnpm --filter @aigateway/admin dev", + "dev:portal": "corepack pnpm --filter @aigateway/portal dev", + "build": "corepack pnpm -r --if-present build", + "lint": "corepack pnpm -r --if-present lint" + } +} diff --git a/web/packages/api-client/package.json b/web/packages/api-client/package.json new file mode 100644 index 0000000..ce74f92 --- /dev/null +++ b/web/packages/api-client/package.json @@ -0,0 +1,8 @@ +{ + "name": "@aigateway/api-client", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": "./src/index.ts" +} + diff --git a/web/packages/api-client/src/index.ts b/web/packages/api-client/src/index.ts new file mode 100644 index 0000000..d019f6c --- /dev/null +++ b/web/packages/api-client/src/index.ts @@ -0,0 +1,41 @@ +export interface HealthResponse { + status: 'ok' + version: string +} + +export interface ReadinessComponent { + status: 'ok' | 'unavailable' | 'not_configured' + required: boolean + latency_ms: number + error?: string +} + +export interface ReadinessResponse { + ready: boolean + status: 'ok' | 'degraded' | 'unavailable' + components: Record +} + +export class GatewaySystemClient { + constructor(private readonly baseURL = '') {} + + health(signal?: AbortSignal): Promise { + return this.get('/healthz', signal) + } + + readiness(signal?: AbortSignal): Promise { + return this.get('/readyz', signal) + } + + private async get(path: string, signal?: AbortSignal): Promise { + const response = await fetch(`${this.baseURL}${path}`, { + headers: { Accept: 'application/json' }, + signal + }) + if (!response.ok) { + throw new Error(`gateway request failed: ${response.status}`) + } + return (await response.json()) as T + } +} + diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml new file mode 100644 index 0000000..aad4661 --- /dev/null +++ b/web/pnpm-lock.yaml @@ -0,0 +1,8172 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + vue: 3.5.22 + '@vue/reactivity': 3.5.22 + '@vue/compiler-sfc': 3.5.22 + axios: 1.12.2 + element-plus: 2.11.4 + vite: 7.1.7 + '@vitejs/plugin-vue': 6.0.1 + vue-tsc: 2.1.10 + typescript: 5.6.3 + '@types/node': 24.8.1 + +importers: + + .: {} + + apps/admin: + dependencies: + '@element-plus/icons-vue': + specifier: ^2.3.2 + version: 2.3.2(vue@3.5.22(typescript@5.6.3)) + '@iconify/vue': + specifier: ^5.0.0 + version: 5.0.1(vue@3.5.22(typescript@5.6.3)) + '@tailwindcss/vite': + specifier: ^4.1.14 + version: 4.3.3(vite@7.1.7(@types/node@24.8.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.12)(yaml@2.9.0)) + '@vue/reactivity': + specifier: 3.5.22 + version: 3.5.22 + '@vueuse/core': + specifier: ^13.9.0 + version: 13.9.0(vue@3.5.22(typescript@5.6.3)) + '@wangeditor/editor': + specifier: ^5.1.23 + version: 5.1.23 + '@wangeditor/editor-for-vue': + specifier: next + version: 5.1.12(@wangeditor/editor@5.1.23)(vue@3.5.22(typescript@5.6.3)) + axios: + specifier: 1.12.2 + version: 1.12.2 + crypto-js: + specifier: ^4.2.0 + version: 4.2.0 + echarts: + specifier: ^6.0.0 + version: 6.1.0 + element-plus: + specifier: 2.11.4 + version: 2.11.4(vue@3.5.22(typescript@5.6.3)) + file-saver: + specifier: ^2.0.5 + version: 2.0.5 + highlight.js: + specifier: ^11.10.0 + version: 11.11.1 + mitt: + specifier: ^3.0.1 + version: 3.0.1 + nprogress: + specifier: ^0.2.0 + version: 0.2.0 + ohash: + specifier: ^2.0.11 + version: 2.0.11 + pinia: + specifier: ^3.0.3 + version: 3.0.4(typescript@5.6.3)(vue@3.5.22(typescript@5.6.3)) + pinia-plugin-persistedstate: + specifier: ^4.3.0 + version: 4.7.1(pinia@3.0.4(typescript@5.6.3)(vue@3.5.22(typescript@5.6.3))) + qrcode.vue: + specifier: ^3.6.0 + version: 3.10.0(vue@3.5.22(typescript@5.6.3)) + tailwindcss: + specifier: ^4.1.14 + version: 4.3.3 + vue: + specifier: 3.5.22 + version: 3.5.22(typescript@5.6.3) + vue-draggable-plus: + specifier: ^0.6.0 + version: 0.6.1(@types/sortablejs@1.15.9) + vue-i18n: + specifier: ^9.14.0 + version: 9.14.5(vue@3.5.22(typescript@5.6.3)) + vue-router: + specifier: ^4.5.1 + version: 4.6.4(vue@3.5.22(typescript@5.6.3)) + xgplayer: + specifier: ^3.0.20 + version: 3.0.26(core-js@3.50.0) + xlsx: + specifier: ^0.18.5 + version: 0.18.5 + devDependencies: + '@commitlint/cli': + specifier: ^19.4.1 + version: 19.8.1(@types/node@24.8.1)(typescript@5.6.3) + '@commitlint/config-conventional': + specifier: ^19.4.1 + version: 19.8.1 + '@eslint/js': + specifier: ^9.9.1 + version: 9.39.5 + '@types/node': + specifier: 24.8.1 + version: 24.8.1 + '@typescript-eslint/eslint-plugin': + specifier: ^8.3.0 + version: 8.66.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.6.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.6.3) + '@typescript-eslint/parser': + specifier: ^8.3.0 + version: 8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.6.3) + '@vitejs/plugin-vue': + specifier: 6.0.1 + version: 6.0.1(vite@7.1.7(@types/node@24.8.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.12)(yaml@2.9.0))(vue@3.5.22(typescript@5.6.3)) + '@vue/compiler-sfc': + specifier: 3.5.22 + version: 3.5.22 + commitizen: + specifier: ^4.3.0 + version: 4.3.2(@types/node@24.8.1)(typescript@5.6.3) + cz-git: + specifier: ^1.11.1 + version: 1.13.1 + eslint: + specifier: ^9.9.1 + version: 9.39.5(jiti@2.7.0) + eslint-config-prettier: + specifier: ^9.1.0 + version: 9.1.2(eslint@9.39.5(jiti@2.7.0)) + eslint-plugin-prettier: + specifier: ^5.2.1 + version: 5.5.6(eslint-config-prettier@9.1.2(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0))(prettier@3.9.6) + eslint-plugin-vue: + specifier: ^9.27.0 + version: 9.33.0(eslint@9.39.5(jiti@2.7.0)) + globals: + specifier: ^15.9.0 + version: 15.15.0 + husky: + specifier: ^9.1.5 + version: 9.1.7 + lint-staged: + specifier: ^15.5.2 + version: 15.5.2 + prettier: + specifier: ^3.5.3 + version: 3.9.6 + rollup-plugin-visualizer: + specifier: ^5.12.0 + version: 5.14.0(rollup@4.62.4) + sass: + specifier: ^1.81.0 + version: 1.102.0 + stylelint: + specifier: ^16.20.0 + version: 16.26.1(typescript@5.6.3) + stylelint-config-html: + specifier: ^1.1.0 + version: 1.1.0(postcss-html@1.8.1)(stylelint@16.26.1(typescript@5.6.3)) + stylelint-config-recess-order: + specifier: ^4.6.0 + version: 4.6.0(stylelint@16.26.1(typescript@5.6.3)) + stylelint-config-recommended-scss: + specifier: ^14.1.0 + version: 14.1.0(postcss@8.5.26)(stylelint@16.26.1(typescript@5.6.3)) + stylelint-config-recommended-vue: + specifier: ^1.5.0 + version: 1.6.1(postcss-html@1.8.1)(stylelint@16.26.1(typescript@5.6.3)) + stylelint-config-standard: + specifier: ^36.0.1 + version: 36.0.1(stylelint@16.26.1(typescript@5.6.3)) + terser: + specifier: ^5.36.0 + version: 5.49.2 + tsx: + specifier: ^4.20.3 + version: 4.23.12 + typescript: + specifier: 5.6.3 + version: 5.6.3 + typescript-eslint: + specifier: ^8.9.0 + version: 8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.6.3) + unplugin-auto-import: + specifier: ^20.2.0 + version: 20.3.0(@vueuse/core@13.9.0(vue@3.5.22(typescript@5.6.3))) + unplugin-element-plus: + specifier: ^0.10.0 + version: 0.10.0 + unplugin-vue-components: + specifier: ^29.1.0 + version: 29.2.0(@babel/parser@7.29.8)(vue@3.5.22(typescript@5.6.3)) + vite: + specifier: 7.1.7 + version: 7.1.7(@types/node@24.8.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.12)(yaml@2.9.0) + vite-plugin-compression: + specifier: ^0.5.1 + version: 0.5.1(vite@7.1.7(@types/node@24.8.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.12)(yaml@2.9.0)) + vite-plugin-vue-devtools: + specifier: ^7.7.6 + version: 7.7.10(rollup@4.62.4)(vite@7.1.7(@types/node@24.8.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.12)(yaml@2.9.0))(vue@3.5.22(typescript@5.6.3)) + vue-demi: + specifier: ^0.14.9 + version: 0.14.10(vue@3.5.22(typescript@5.6.3)) + vue-img-cutter: + specifier: ^3.0.5 + version: 3.1.1(typescript@5.6.3) + vue-tsc: + specifier: 2.1.10 + version: 2.1.10(typescript@5.6.3) + + apps/portal: + dependencies: + '@element-plus/icons-vue': + specifier: ^2.3.2 + version: 2.3.2(vue@3.5.22(typescript@5.6.3)) + '@iconify/vue': + specifier: ^5.0.0 + version: 5.0.1(vue@3.5.22(typescript@5.6.3)) + '@tailwindcss/vite': + specifier: ^4.1.14 + version: 4.3.3(vite@7.1.7(@types/node@24.8.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.12)(yaml@2.9.0)) + '@vue/reactivity': + specifier: 3.5.22 + version: 3.5.22 + '@vueuse/core': + specifier: ^13.9.0 + version: 13.9.0(vue@3.5.22(typescript@5.6.3)) + '@wangeditor/editor': + specifier: ^5.1.23 + version: 5.1.23 + '@wangeditor/editor-for-vue': + specifier: next + version: 5.1.12(@wangeditor/editor@5.1.23)(vue@3.5.22(typescript@5.6.3)) + axios: + specifier: 1.12.2 + version: 1.12.2 + crypto-js: + specifier: ^4.2.0 + version: 4.2.0 + echarts: + specifier: ^6.0.0 + version: 6.1.0 + element-plus: + specifier: 2.11.4 + version: 2.11.4(vue@3.5.22(typescript@5.6.3)) + file-saver: + specifier: ^2.0.5 + version: 2.0.5 + highlight.js: + specifier: ^11.10.0 + version: 11.11.1 + mitt: + specifier: ^3.0.1 + version: 3.0.1 + nprogress: + specifier: ^0.2.0 + version: 0.2.0 + ohash: + specifier: ^2.0.11 + version: 2.0.11 + pinia: + specifier: ^3.0.3 + version: 3.0.4(typescript@5.6.3)(vue@3.5.22(typescript@5.6.3)) + pinia-plugin-persistedstate: + specifier: ^4.3.0 + version: 4.7.1(pinia@3.0.4(typescript@5.6.3)(vue@3.5.22(typescript@5.6.3))) + qrcode.vue: + specifier: ^3.6.0 + version: 3.10.0(vue@3.5.22(typescript@5.6.3)) + tailwindcss: + specifier: ^4.1.14 + version: 4.3.3 + vue: + specifier: 3.5.22 + version: 3.5.22(typescript@5.6.3) + vue-draggable-plus: + specifier: ^0.6.0 + version: 0.6.1(@types/sortablejs@1.15.9) + vue-i18n: + specifier: ^9.14.0 + version: 9.14.5(vue@3.5.22(typescript@5.6.3)) + vue-router: + specifier: ^4.5.1 + version: 4.6.4(vue@3.5.22(typescript@5.6.3)) + xgplayer: + specifier: ^3.0.20 + version: 3.0.26(core-js@3.50.0) + xlsx: + specifier: ^0.18.5 + version: 0.18.5 + devDependencies: + '@commitlint/cli': + specifier: ^19.4.1 + version: 19.8.1(@types/node@24.8.1)(typescript@5.6.3) + '@commitlint/config-conventional': + specifier: ^19.4.1 + version: 19.8.1 + '@eslint/js': + specifier: ^9.9.1 + version: 9.39.5 + '@types/node': + specifier: 24.8.1 + version: 24.8.1 + '@typescript-eslint/eslint-plugin': + specifier: ^8.3.0 + version: 8.66.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.6.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.6.3) + '@typescript-eslint/parser': + specifier: ^8.3.0 + version: 8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.6.3) + '@vitejs/plugin-vue': + specifier: 6.0.1 + version: 6.0.1(vite@7.1.7(@types/node@24.8.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.12)(yaml@2.9.0))(vue@3.5.22(typescript@5.6.3)) + '@vue/compiler-sfc': + specifier: 3.5.22 + version: 3.5.22 + commitizen: + specifier: ^4.3.0 + version: 4.3.2(@types/node@24.8.1)(typescript@5.6.3) + cz-git: + specifier: ^1.11.1 + version: 1.13.1 + eslint: + specifier: ^9.9.1 + version: 9.39.5(jiti@2.7.0) + eslint-config-prettier: + specifier: ^9.1.0 + version: 9.1.2(eslint@9.39.5(jiti@2.7.0)) + eslint-plugin-prettier: + specifier: ^5.2.1 + version: 5.5.6(eslint-config-prettier@9.1.2(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0))(prettier@3.9.6) + eslint-plugin-vue: + specifier: ^9.27.0 + version: 9.33.0(eslint@9.39.5(jiti@2.7.0)) + globals: + specifier: ^15.9.0 + version: 15.15.0 + husky: + specifier: ^9.1.5 + version: 9.1.7 + lint-staged: + specifier: ^15.5.2 + version: 15.5.2 + prettier: + specifier: ^3.5.3 + version: 3.9.6 + rollup-plugin-visualizer: + specifier: ^5.12.0 + version: 5.14.0(rollup@4.62.4) + sass: + specifier: ^1.81.0 + version: 1.102.0 + stylelint: + specifier: ^16.20.0 + version: 16.26.1(typescript@5.6.3) + stylelint-config-html: + specifier: ^1.1.0 + version: 1.1.0(postcss-html@1.8.1)(stylelint@16.26.1(typescript@5.6.3)) + stylelint-config-recess-order: + specifier: ^4.6.0 + version: 4.6.0(stylelint@16.26.1(typescript@5.6.3)) + stylelint-config-recommended-scss: + specifier: ^14.1.0 + version: 14.1.0(postcss@8.5.26)(stylelint@16.26.1(typescript@5.6.3)) + stylelint-config-recommended-vue: + specifier: ^1.5.0 + version: 1.6.1(postcss-html@1.8.1)(stylelint@16.26.1(typescript@5.6.3)) + stylelint-config-standard: + specifier: ^36.0.1 + version: 36.0.1(stylelint@16.26.1(typescript@5.6.3)) + terser: + specifier: ^5.36.0 + version: 5.49.2 + tsx: + specifier: ^4.20.3 + version: 4.23.12 + typescript: + specifier: 5.6.3 + version: 5.6.3 + typescript-eslint: + specifier: ^8.9.0 + version: 8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.6.3) + unplugin-auto-import: + specifier: ^20.2.0 + version: 20.3.0(@vueuse/core@13.9.0(vue@3.5.22(typescript@5.6.3))) + unplugin-element-plus: + specifier: ^0.10.0 + version: 0.10.0 + unplugin-vue-components: + specifier: ^29.1.0 + version: 29.2.0(@babel/parser@7.29.8)(vue@3.5.22(typescript@5.6.3)) + vite: + specifier: 7.1.7 + version: 7.1.7(@types/node@24.8.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.12)(yaml@2.9.0) + vite-plugin-compression: + specifier: ^0.5.1 + version: 0.5.1(vite@7.1.7(@types/node@24.8.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.12)(yaml@2.9.0)) + vite-plugin-vue-devtools: + specifier: ^7.7.6 + version: 7.7.10(rollup@4.62.4)(vite@7.1.7(@types/node@24.8.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.12)(yaml@2.9.0))(vue@3.5.22(typescript@5.6.3)) + vue-demi: + specifier: ^0.14.9 + version: 0.14.10(vue@3.5.22(typescript@5.6.3)) + vue-img-cutter: + specifier: ^3.0.5 + version: 3.1.1(typescript@5.6.3) + vue-tsc: + specifier: 2.1.10 + version: 2.1.10(typescript@5.6.3) + + packages/api-client: {} + +packages: + + '@antfu/utils@0.7.10': + resolution: {integrity: sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.29.7': + resolution: {integrity: sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-create-class-features-plugin@7.29.7': + resolution: {integrity: sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.29.7': + resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.29.7': + resolution: {integrity: sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-replace-supers@7.29.7': + resolution: {integrity: sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + resolution: {integrity: sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-proposal-decorators@7.29.7': + resolution: {integrity: sha512-EtU0Hi3GvrTqD56xKmZvV/uCXK2ZbwVNPNLAquVItcAZpUhkXwWlo3Fmj0c2LxgSf2I8IDULeAepwNP1OefLXg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-decorators@7.29.7': + resolution: {integrity: sha512-9MTTLbF39X6sqM92JPEsoI7++26hjZvzkxKZy64aMhWLH2mPkJ/Q3AV4QLmls3R14FpSpkOwQQfUh962JGQxxg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-attributes@7.29.7': + resolution: {integrity: sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-meta@7.10.4': + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-jsx@7.29.7': + resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.29.7': + resolution: {integrity: sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typescript@7.29.7': + resolution: {integrity: sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@cacheable/memory@2.2.0': + resolution: {integrity: sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==} + + '@cacheable/utils@2.5.0': + resolution: {integrity: sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==} + + '@commitlint/cli@19.8.1': + resolution: {integrity: sha512-LXUdNIkspyxrlV6VDHWBmCZRtkEVRpBKxi2Gtw3J54cGWhLCTouVD/Q6ZSaSvd2YaDObWK8mDjrz3TIKtaQMAA==} + engines: {node: '>=v18'} + hasBin: true + + '@commitlint/config-conventional@19.8.1': + resolution: {integrity: sha512-/AZHJL6F6B/G959CsMAzrPKKZjeEiAVifRyEwXxcT6qtqbPwGw+iQxmNS+Bu+i09OCtdNRW6pNpBvgPrtMr9EQ==} + engines: {node: '>=v18'} + + '@commitlint/config-validator@19.8.1': + resolution: {integrity: sha512-0jvJ4u+eqGPBIzzSdqKNX1rvdbSU1lPNYlfQQRIFnBgLy26BtC0cFnr7c/AyuzExMxWsMOte6MkTi9I3SQ3iGQ==} + engines: {node: '>=v18'} + + '@commitlint/config-validator@21.2.0': + resolution: {integrity: sha512-t7AzNHAKeIdo/3NRGwzpufKHsKkPHmFs/56N2Fnsh0/r0rGtnQzTxk6vnFgjaGr4hdSQKNB50/KAhR9Yk4LJKA==} + engines: {node: '>=22.12.0'} + + '@commitlint/ensure@19.8.1': + resolution: {integrity: sha512-mXDnlJdvDzSObafjYrOSvZBwkD01cqB4gbnnFuVyNpGUM5ijwU/r/6uqUmBXAAOKRfyEjpkGVZxaDsCVnHAgyw==} + engines: {node: '>=v18'} + + '@commitlint/execute-rule@19.8.1': + resolution: {integrity: sha512-YfJyIqIKWI64Mgvn/sE7FXvVMQER/Cd+s3hZke6cI1xgNT/f6ZAz5heND0QtffH+KbcqAwXDEE1/5niYayYaQA==} + engines: {node: '>=v18'} + + '@commitlint/execute-rule@21.0.1': + resolution: {integrity: sha512-RifH+FmImozKBE6mozhF4K3r2RRKP7SMi/Q/zLCmExtp5e05lhHOUYqGBlFBAGNHaZxU/WYw1XuugYK9jQzqnA==} + engines: {node: '>=22.12.0'} + + '@commitlint/format@19.8.1': + resolution: {integrity: sha512-kSJj34Rp10ItP+Eh9oCItiuN/HwGQMXBnIRk69jdOwEW9llW9FlyqcWYbHPSGofmjsqeoxa38UaEA5tsbm2JWw==} + engines: {node: '>=v18'} + + '@commitlint/is-ignored@19.8.1': + resolution: {integrity: sha512-AceOhEhekBUQ5dzrVhDDsbMaY5LqtN8s1mqSnT2Kz1ERvVZkNihrs3Sfk1Je/rxRNbXYFzKZSHaPsEJJDJV8dg==} + engines: {node: '>=v18'} + + '@commitlint/lint@19.8.1': + resolution: {integrity: sha512-52PFbsl+1EvMuokZXLRlOsdcLHf10isTPlWwoY1FQIidTsTvjKXVXYb7AvtpWkDzRO2ZsqIgPK7bI98x8LRUEw==} + engines: {node: '>=v18'} + + '@commitlint/load@19.8.1': + resolution: {integrity: sha512-9V99EKG3u7z+FEoe4ikgq7YGRCSukAcvmKQuTtUyiYPnOd9a2/H9Ak1J9nJA1HChRQp9OA/sIKPugGS+FK/k1A==} + engines: {node: '>=v18'} + + '@commitlint/load@21.2.0': + resolution: {integrity: sha512-RjlzWQqruRwIenJEfZtq7kG97co97nKoHpflE5YnF61tDLXxHPrdWImgzw6VL6MlFyaOcVlk74eBV8ZQmc3oIA==} + engines: {node: '>=22.12.0'} + + '@commitlint/message@19.8.1': + resolution: {integrity: sha512-+PMLQvjRXiU+Ae0Wc+p99EoGEutzSXFVwQfa3jRNUZLNW5odZAyseb92OSBTKCu+9gGZiJASt76Cj3dLTtcTdg==} + engines: {node: '>=v18'} + + '@commitlint/parse@19.8.1': + resolution: {integrity: sha512-mmAHYcMBmAgJDKWdkjIGq50X4yB0pSGpxyOODwYmoexxxiUCy5JJT99t1+PEMK7KtsCtzuWYIAXYAiKR+k+/Jw==} + engines: {node: '>=v18'} + + '@commitlint/read@19.8.1': + resolution: {integrity: sha512-03Jbjb1MqluaVXKHKRuGhcKWtSgh3Jizqy2lJCRbRrnWpcM06MYm8th59Xcns8EqBYvo0Xqb+2DoZFlga97uXQ==} + engines: {node: '>=v18'} + + '@commitlint/resolve-extends@19.8.1': + resolution: {integrity: sha512-GM0mAhFk49I+T/5UCYns5ayGStkTt4XFFrjjf0L4S26xoMTSkdCf9ZRO8en1kuopC4isDFuEm7ZOm/WRVeElVg==} + engines: {node: '>=v18'} + + '@commitlint/resolve-extends@21.2.0': + resolution: {integrity: sha512-4O/1j51+79Wth9s/MGxt/5gs0XYLDgNlYpltQfhAvLE0itusLKs9zruxbiNg1oOkmkb9L9L4USYGjEj7n87NxA==} + engines: {node: '>=22.12.0'} + + '@commitlint/rules@19.8.1': + resolution: {integrity: sha512-Hnlhd9DyvGiGwjfjfToMi1dsnw1EXKGJNLTcsuGORHz6SS9swRgkBsou33MQ2n51/boIDrbsg4tIBbRpEWK2kw==} + engines: {node: '>=v18'} + + '@commitlint/to-lines@19.8.1': + resolution: {integrity: sha512-98Mm5inzbWTKuZQr2aW4SReY6WUukdWXuZhrqf1QdKPZBCCsXuG87c+iP0bwtD6DBnmVVQjgp4whoHRVixyPBg==} + engines: {node: '>=v18'} + + '@commitlint/top-level@19.8.1': + resolution: {integrity: sha512-Ph8IN1IOHPSDhURCSXBz44+CIu+60duFwRsg6HqaISFHQHbmBtxVw4ZrFNIYUzEP7WwrNPxa2/5qJ//NK1FGcw==} + engines: {node: '>=v18'} + + '@commitlint/types@19.8.1': + resolution: {integrity: sha512-/yCrWGCoA1SVKOks25EGadP9Pnj0oAIHGpl2wH2M2Y46dPM2ueb8wyCVOD7O3WCTkaJ0IkKvzhl1JY7+uCT2Dw==} + engines: {node: '>=v18'} + + '@commitlint/types@21.2.0': + resolution: {integrity: sha512-7zVFCDB2reMvJH5dmbKnOQPjZEvjdJTH8jc0U/PIPU1r3/+vf5pD1HlfitV2MWsWXrvu7u39iY1lyLUPOaN0Gw==} + engines: {node: '>=22.12.0'} + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-syntax-patches-for-csstree@1.1.7': + resolution: {integrity: sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@csstools/media-query-list-parser@4.0.3': + resolution: {integrity: sha512-HAYH7d3TLRHDOUQK4mZKf9k9Ph/m8Akstg66ywKR4SFAigjs3yBiUeZtFxywiTm5moZMAp/5W/ZuFnNXXYLuuQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/selector-specificity@5.0.0': + resolution: {integrity: sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==} + engines: {node: '>=18'} + peerDependencies: + postcss-selector-parser: ^7.0.0 + + '@ctrl/tinycolor@3.6.1': + resolution: {integrity: sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==} + engines: {node: '>=10'} + + '@dual-bundle/import-meta-resolve@4.2.1': + resolution: {integrity: sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==} + + '@element-plus/icons-vue@2.3.2': + resolution: {integrity: sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==} + peerDependencies: + vue: 3.5.22 + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.6': + resolution: {integrity: sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.5': + resolution: {integrity: sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/vue@5.0.1': + resolution: {integrity: sha512-aumwwooJlFJ5H5qYWB6ZTAyM0C8hpfcSVLB9/a3qnH1GGvIJ+FEbpEs4s/HfErYe/M5qZeLjwmESR5fFm3lXEw==} + peerDependencies: + vue: 3.5.22 + + '@inquirer/external-editor@1.0.3': + resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': 24.8.1 + peerDependenciesMeta: + '@types/node': + optional: true + + '@intlify/core-base@11.4.8': + resolution: {integrity: sha512-A+Q7SKm5oEcy1E/cghqd7n/St4XjTqLhiiyDuieNcMrJcrHlkY5n0jp7Q9dD3txvVHzvsmBVV5M9wD5/s1zfzw==} + engines: {node: '>= 22'} + + '@intlify/core-base@9.14.5': + resolution: {integrity: sha512-5ah5FqZG4pOoHjkvs8mjtv+gPKYU0zCISaYNjBNNqYiaITxW8ZtVih3GS/oTOqN8d9/mDLyrjD46GBApNxmlsA==} + engines: {node: '>= 16'} + + '@intlify/devtools-types@11.4.8': + resolution: {integrity: sha512-MGpID+rlfzGUbNcnC20bm5NMSBHPrvx0atLTfv9dftn3kjXw1hGKDcIcwrO99tSrZEc2i+hczRL7ks8qXsHPkQ==} + engines: {node: '>= 22'} + + '@intlify/message-compiler@11.4.8': + resolution: {integrity: sha512-vbzk17dYwduYiv52EK61+FDCyhfVg1uPUtPmiD/d45W99uJIcXywrweOBcHv7n9/iEqmXiMGT52bgJbZDQqK3w==} + engines: {node: '>= 22'} + + '@intlify/message-compiler@9.14.5': + resolution: {integrity: sha512-IHzgEu61/YIpQV5Pc3aRWScDcnFKWvQA9kigcINcCBXN8mbW+vk9SK+lDxA6STzKQsVJxUPg9ACC52pKKo3SVQ==} + engines: {node: '>= 16'} + + '@intlify/shared@11.4.8': + resolution: {integrity: sha512-XbRgrv+XEuvDr7UCY55oibVrh+o4u+A0VB6nSL0F5Z8LcZxE/8j573LYG6bCrOigIcHdGpSNI7Rh5UpC5/B/eg==} + engines: {node: '>= 22'} + + '@intlify/shared@9.14.5': + resolution: {integrity: sha512-9gB+E53BYuAEMhbCAxVgG38EZrk59sxBtv3jSizNL2hEWlgjBjAw1AwpLHtNaeda12pe6W20OGEa0TwuMSRbyQ==} + engines: {node: '>= 16'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@keyv/bigmap@1.3.1': + resolution: {integrity: sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==} + engines: {node: '>= 18'} + peerDependencies: + keyv: ^5.6.0 + + '@keyv/serialize@1.1.1': + resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@parcel/watcher-android-arm64@2.6.0': + resolution: {integrity: sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.6.0': + resolution: {integrity: sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.6.0': + resolution: {integrity: sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.6.0': + resolution: {integrity: sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.6.0': + resolution: {integrity: sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm-musl@2.6.0': + resolution: {integrity: sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm64-glibc@2.6.0': + resolution: {integrity: sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-arm64-musl@2.6.0': + resolution: {integrity: sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-x64-glibc@2.6.0': + resolution: {integrity: sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-linux-x64-musl@2.6.0': + resolution: {integrity: sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-win32-arm64@2.6.0': + resolution: {integrity: sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-x64@2.6.0': + resolution: {integrity: sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.6.0': + resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==} + engines: {node: '>= 10.0.0'} + + '@pkgr/core@0.3.6': + resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} + engines: {node: ^14.18.0 || >=16.0.0} + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@rolldown/pluginutils@1.0.0-beta.29': + resolution: {integrity: sha512-NIJgOsMjbxAXvoGq/X0gD7VPMQ8j9g0BiDaNjVNVjvl+iKXxL3Jre0v31RmBYeLEmkbj2s02v8vFTbUXi5XS2Q==} + + '@rollup/pluginutils@5.4.0': + resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/rollup-android-arm-eabi@4.62.4': + resolution: {integrity: sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.4': + resolution: {integrity: sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.4': + resolution: {integrity: sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.4': + resolution: {integrity: sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.4': + resolution: {integrity: sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.4': + resolution: {integrity: sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.4': + resolution: {integrity: sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.62.4': + resolution: {integrity: sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.62.4': + resolution: {integrity: sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.62.4': + resolution: {integrity: sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.62.4': + resolution: {integrity: sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.62.4': + resolution: {integrity: sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.62.4': + resolution: {integrity: sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.62.4': + resolution: {integrity: sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.62.4': + resolution: {integrity: sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.62.4': + resolution: {integrity: sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.62.4': + resolution: {integrity: sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.62.4': + resolution: {integrity: sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.62.4': + resolution: {integrity: sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.62.4': + resolution: {integrity: sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.4': + resolution: {integrity: sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.4': + resolution: {integrity: sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.4': + resolution: {integrity: sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.4': + resolution: {integrity: sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.4': + resolution: {integrity: sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==} + cpu: [x64] + os: [win32] + + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + + '@simple-libs/stream-utils@2.0.0': + resolution: {integrity: sha512-fCTuZK4QBa+39Oz9l4OGfJfz+GpwCp3AqO7Zch3to99xHPgstVsRFpeQ8LNd2o1Gv8raL2mCFwiaHh7bFSp5DQ==} + engines: {node: '>=22'} + + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + + '@sxzz/popperjs-es@2.11.8': + resolution: {integrity: sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==} + + '@tailwindcss/node@4.3.3': + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} + + '@tailwindcss/oxide-android-arm64@4.3.3': + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.3': + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.3': + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.3.3': + resolution: {integrity: sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==} + peerDependencies: + vite: 7.1.7 + + '@transloadit/prettier-bytes@0.0.7': + resolution: {integrity: sha512-VeJbUb0wEKbcwaSlj5n+LscBl9IPgLPkHVGBkh00cztv6X4L/TJXK58LzFuBKX7/GAfiGhIwH67YTLTlzvIzBA==} + + '@types/conventional-commits-parser@5.0.2': + resolution: {integrity: sha512-BgT2szDXnVypgpNxOK8aL5SGjUdaQbC++WZNjF1Qge3Og2+zhHj+RWhmehLhYyvQwqAmvezruVfOf8+3m74W+g==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/event-emitter@0.3.5': + resolution: {integrity: sha512-zx2/Gg0Eg7gwEiOIIh5w9TrhKKTeQh7CPCOPNc0el4pLSwzebA8SmnHwZs2dWlLONvyulykSwGSQxQHLhjGLvQ==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/lodash-es@4.17.12': + resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} + + '@types/lodash@4.17.25': + resolution: {integrity: sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==} + + '@types/node@24.8.1': + resolution: {integrity: sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q==} + + '@types/sortablejs@1.15.9': + resolution: {integrity: sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==} + + '@types/web-bluetooth@0.0.16': + resolution: {integrity: sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==} + + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + + '@typescript-eslint/eslint-plugin@8.66.0': + resolution: {integrity: sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.66.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: 5.6.3 + + '@typescript-eslint/parser@8.66.0': + resolution: {integrity: sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: 5.6.3 + + '@typescript-eslint/project-service@8.66.0': + resolution: {integrity: sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: 5.6.3 + + '@typescript-eslint/scope-manager@8.66.0': + resolution: {integrity: sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.66.0': + resolution: {integrity: sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: 5.6.3 + + '@typescript-eslint/type-utils@8.66.0': + resolution: {integrity: sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: 5.6.3 + + '@typescript-eslint/types@8.66.0': + resolution: {integrity: sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.66.0': + resolution: {integrity: sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: 5.6.3 + + '@typescript-eslint/utils@8.66.0': + resolution: {integrity: sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: 5.6.3 + + '@typescript-eslint/visitor-keys@8.66.0': + resolution: {integrity: sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@uppy/companion-client@2.2.2': + resolution: {integrity: sha512-5mTp2iq97/mYSisMaBtFRry6PTgZA6SIL7LePteOV5x0/DxKfrZW3DEiQERJmYpHzy7k8johpm2gHnEKto56Og==} + + '@uppy/core@2.3.4': + resolution: {integrity: sha512-iWAqppC8FD8mMVqewavCz+TNaet6HPXitmGXpGGREGrakZ4FeuWytVdrelydzTdXx6vVKkOmI2FLztGg73sENQ==} + + '@uppy/store-default@2.1.1': + resolution: {integrity: sha512-xnpTxvot2SeAwGwbvmJ899ASk5tYXhmZzD/aCFsXePh/v8rNvR2pKlcQUH7cF/y4baUGq3FHO/daKCok/mpKqQ==} + + '@uppy/utils@4.1.3': + resolution: {integrity: sha512-nTuMvwWYobnJcytDO3t+D6IkVq/Qs4Xv3vyoEZ+Iaf8gegZP+rEyoaFT2CK5XLRMienPyqRqNbIfRuFaOWSIFw==} + + '@uppy/xhr-upload@2.1.3': + resolution: {integrity: sha512-YWOQ6myBVPs+mhNjfdWsQyMRWUlrDLMoaG7nvf/G6Y3GKZf8AyjFDjvvJ49XWQ+DaZOftGkHmF1uh/DBeGivJQ==} + peerDependencies: + '@uppy/core': ^2.3.3 + + '@vitejs/plugin-vue@6.0.1': + resolution: {integrity: sha512-+MaE752hU0wfPFJEUAIxqw18+20euHHdxVtMvbFcOEpjEyfqXH/5DCoTHiVJ0J29EhTJdoTkjEv5YBKU9dnoTw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: 7.1.7 + vue: 3.5.22 + + '@volar/language-core@2.4.28': + resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} + + '@volar/source-map@2.4.28': + resolution: {integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==} + + '@volar/typescript@2.4.28': + resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} + + '@vue/babel-helper-vue-transform-on@1.5.0': + resolution: {integrity: sha512-0dAYkerNhhHutHZ34JtTl2czVQHUNWv6xEbkdF5W+Yrv5pCWsqjeORdOgbtW2I9gWlt+wBmVn+ttqN9ZxR5tzA==} + + '@vue/babel-plugin-jsx@1.5.0': + resolution: {integrity: sha512-mneBhw1oOqCd2247O0Yw/mRwC9jIGACAJUlawkmMBiNmL4dGA2eMzuNZVNqOUfYTa6vqmND4CtOPzmEEEqLKFw==} + peerDependencies: + '@babel/core': ^7.0.0-0 + peerDependenciesMeta: + '@babel/core': + optional: true + + '@vue/babel-plugin-resolve-type@1.5.0': + resolution: {integrity: sha512-Wm/60o+53JwJODm4Knz47dxJnLDJ9FnKnGZJbUUf8nQRAtt6P+undLUAVU3Ha33LxOJe6IPoifRQ6F/0RrU31w==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@vue/compiler-core@3.5.22': + resolution: {integrity: sha512-jQ0pFPmZwTEiRNSb+i9Ow/I/cHv2tXYqsnHKKyCQ08irI2kdF5qmYedmF8si8mA7zepUFmJ2hqzS8CQmNOWOkQ==} + + '@vue/compiler-core@3.5.41': + resolution: {integrity: sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==} + + '@vue/compiler-dom@3.5.22': + resolution: {integrity: sha512-W8RknzUM1BLkypvdz10OVsGxnMAuSIZs9Wdx1vzA3mL5fNMN15rhrSCLiTm6blWeACwUwizzPVqGJgOGBEN/hA==} + + '@vue/compiler-dom@3.5.41': + resolution: {integrity: sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==} + + '@vue/compiler-sfc@3.5.22': + resolution: {integrity: sha512-tbTR1zKGce4Lj+JLzFXDq36K4vcSZbJ1RBu8FxcDv1IGRz//Dh2EBqksyGVypz3kXpshIfWKGOCcqpSbyGWRJQ==} + + '@vue/compiler-ssr@3.5.22': + resolution: {integrity: sha512-GdgyLvg4R+7T8Nk2Mlighx7XGxq/fJf9jaVofc3IL0EPesTE86cP/8DD1lT3h1JeZr2ySBvyqKQJgbS54IX1Ww==} + + '@vue/compiler-vue2@2.7.16': + resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==} + + '@vue/devtools-api@6.6.4': + resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} + + '@vue/devtools-api@7.7.10': + resolution: {integrity: sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==} + + '@vue/devtools-core@7.7.10': + resolution: {integrity: sha512-rpsAY7HuxYUz6G7R0zBGoOgYsn/r3iU0LixLOGNJ3o5if6tkPDGIGV+6CIJbDNbeueP2HsGDCOQ5ni6Wr36xEQ==} + peerDependencies: + vue: 3.5.22 + + '@vue/devtools-kit@7.7.10': + resolution: {integrity: sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==} + + '@vue/devtools-shared@7.7.10': + resolution: {integrity: sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==} + + '@vue/language-core@2.1.10': + resolution: {integrity: sha512-DAI289d0K3AB5TUG3xDp9OuQ71CnrujQwJrQnfuZDwo6eGNf0UoRlPuaVNO+Zrn65PC3j0oB2i7mNmVPggeGeQ==} + peerDependencies: + typescript: 5.6.3 + peerDependenciesMeta: + typescript: + optional: true + + '@vue/reactivity@3.5.22': + resolution: {integrity: sha512-f2Wux4v/Z2pqc9+4SmgZC1p73Z53fyD90NFWXiX9AKVnVBEvLFOWCEgJD3GdGnlxPZt01PSlfmLqbLYzY/Fw4A==} + + '@vue/runtime-core@3.5.22': + resolution: {integrity: sha512-EHo4W/eiYeAzRTN5PCextDUZ0dMs9I8mQ2Fy+OkzvRPUYQEyK9yAjbasrMCXbLNhF7P0OUyivLjIy0yc6VrLJQ==} + + '@vue/runtime-dom@3.5.22': + resolution: {integrity: sha512-Av60jsryAkI023PlN7LsqrfPvwfxOd2yAwtReCjeuugTJTkgrksYJJstg1e12qle0NarkfhfFu1ox2D+cQotww==} + + '@vue/server-renderer@3.5.22': + resolution: {integrity: sha512-gXjo+ao0oHYTSswF+a3KRHZ1WszxIqO7u6XwNHqcqb9JfyIL/pbWrrh/xLv7jeDqla9u+LK7yfZKHih1e1RKAQ==} + peerDependencies: + vue: 3.5.22 + + '@vue/shared@3.5.22': + resolution: {integrity: sha512-F4yc6palwq3TT0u+FYf0Ns4Tfl9GRFURDN2gWG7L1ecIaS/4fCIuFOjMTnCyjsu/OK6vaDKLCrGAa+KvvH+h4w==} + + '@vue/shared@3.5.41': + resolution: {integrity: sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==} + + '@vueuse/core@13.9.0': + resolution: {integrity: sha512-ts3regBQyURfCE2BcytLqzm8+MmLlo5Ln/KLoxDVcsZ2gzIwVNnQpQOL/UKV8alUqjSZOlpFZcRNsLRqj+OzyA==} + peerDependencies: + vue: 3.5.22 + + '@vueuse/core@9.13.0': + resolution: {integrity: sha512-pujnclbeHWxxPRqXWmdkKV5OX4Wk4YeK7wusHqRwU0Q7EFusHoqNA/aPhB6KCh9hEqJkLAJo7bb0Lh9b+OIVzw==} + + '@vueuse/metadata@13.9.0': + resolution: {integrity: sha512-1AFRvuiGphfF7yWixZa0KwjYH8ulyjDCC0aFgrGRz8+P4kvDFSdXLVfTk5xAN9wEuD1J6z4/myMoYbnHoX07zg==} + + '@vueuse/metadata@9.13.0': + resolution: {integrity: sha512-gdU7TKNAUVlXXLbaF+ZCfte8BjRJQWPCa2J55+7/h+yDtzw3vOoGQDRXzI6pyKyo6bXFT5/QoPE4hAknExjRLQ==} + + '@vueuse/shared@13.9.0': + resolution: {integrity: sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g==} + peerDependencies: + vue: 3.5.22 + + '@vueuse/shared@9.13.0': + resolution: {integrity: sha512-UrnhU+Cnufu4S6JLCPZnkWh0WwZGUp72ktOF2DFptMlOs3TOdVv8xJN53zhHGARmVOsz5KqOls09+J1NR6sBKw==} + + '@wangeditor/basic-modules@1.1.7': + resolution: {integrity: sha512-cY9CPkLJaqF05STqfpZKWG4LpxTMeGSIIF1fHvfm/mz+JXatCagjdkbxdikOuKYlxDdeqvOeBmsUBItufDLXZg==} + peerDependencies: + '@wangeditor/core': 1.x + dom7: ^3.0.0 + lodash.throttle: ^4.1.1 + nanoid: ^3.2.0 + slate: ^0.72.0 + snabbdom: ^3.1.0 + + '@wangeditor/code-highlight@1.0.3': + resolution: {integrity: sha512-iazHwO14XpCuIWJNTQTikqUhGKyqj+dUNWJ9288Oym9M2xMVHvnsOmDU2sgUDWVy+pOLojReMPgXCsvvNlOOhw==} + peerDependencies: + '@wangeditor/core': 1.x + dom7: ^3.0.0 + slate: ^0.72.0 + snabbdom: ^3.1.0 + + '@wangeditor/core@1.1.19': + resolution: {integrity: sha512-KevkB47+7GhVszyYF2pKGKtCSj/YzmClsD03C3zTt+9SR2XWT5T0e3yQqg8baZpcMvkjs1D8Dv4fk8ok/UaS2Q==} + peerDependencies: + '@uppy/core': ^2.1.1 + '@uppy/xhr-upload': ^2.0.3 + dom7: ^3.0.0 + is-hotkey: ^0.2.0 + lodash.camelcase: ^4.3.0 + lodash.clonedeep: ^4.5.0 + lodash.debounce: ^4.0.8 + lodash.foreach: ^4.5.0 + lodash.isequal: ^4.5.0 + lodash.throttle: ^4.1.1 + lodash.toarray: ^4.4.0 + nanoid: ^3.2.0 + slate: ^0.72.0 + snabbdom: ^3.1.0 + + '@wangeditor/editor-for-vue@5.1.12': + resolution: {integrity: sha512-0Ds3D8I+xnpNWezAeO7HmPRgTfUxHLMd9JKcIw+QzvSmhC5xUHbpCcLU+KLmeBKTR/zffnS5GQo6qi3GhTMJWQ==} + peerDependencies: + '@wangeditor/editor': '>=5.1.0' + vue: 3.5.22 + + '@wangeditor/editor@5.1.23': + resolution: {integrity: sha512-0RxfeVTuK1tktUaPROnCoFfaHVJpRAIE2zdS0mpP+vq1axVQpLjM8+fCvKzqYIkH0Pg+C+44hJpe3VVroSkEuQ==} + + '@wangeditor/list-module@1.0.5': + resolution: {integrity: sha512-uDuYTP6DVhcYf7mF1pTlmNn5jOb4QtcVhYwSSAkyg09zqxI1qBqsfUnveeDeDqIuptSJhkh81cyxi+MF8sEPOQ==} + peerDependencies: + '@wangeditor/core': 1.x + dom7: ^3.0.0 + slate: ^0.72.0 + snabbdom: ^3.1.0 + + '@wangeditor/table-module@1.1.4': + resolution: {integrity: sha512-5saanU9xuEocxaemGdNi9t8MCDSucnykEC6jtuiT72kt+/Hhh4nERYx1J20OPsTCCdVr7hIyQenFD1iSRkIQ6w==} + peerDependencies: + '@wangeditor/core': 1.x + dom7: ^3.0.0 + lodash.isequal: ^4.5.0 + lodash.throttle: ^4.1.1 + nanoid: ^3.2.0 + slate: ^0.72.0 + snabbdom: ^3.1.0 + + '@wangeditor/upload-image-module@1.0.2': + resolution: {integrity: sha512-z81lk/v71OwPDYeQDxj6cVr81aDP90aFuywb8nPD6eQeECtOymrqRODjpO6VGvCVxVck8nUxBHtbxKtjgcwyiA==} + peerDependencies: + '@uppy/core': ^2.0.3 + '@uppy/xhr-upload': ^2.0.3 + '@wangeditor/basic-modules': 1.x + '@wangeditor/core': 1.x + dom7: ^3.0.0 + lodash.foreach: ^4.5.0 + slate: ^0.72.0 + snabbdom: ^3.1.0 + + '@wangeditor/video-module@1.1.4': + resolution: {integrity: sha512-ZdodDPqKQrgx3IwWu4ZiQmXI8EXZ3hm2/fM6E3t5dB8tCaIGWQZhmqd6P5knfkRAd3z2+YRSRbxOGfoRSp/rLg==} + peerDependencies: + '@uppy/core': ^2.1.4 + '@uppy/xhr-upload': ^2.0.7 + '@wangeditor/core': 1.x + dom7: ^3.0.0 + nanoid: ^3.2.0 + slate: ^0.72.0 + snabbdom: ^3.1.0 + + JSONStream@1.3.5: + resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==} + hasBin: true + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + adler-32@1.3.1: + resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==} + engines: {node: '>=0.8'} + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + alien-signals@0.2.2: + resolution: {integrity: sha512-cZIRkbERILsBOXTQmMrxc9hgpxglstn69zm+F1ARf4aPAzdAFYd6sBq87ErO0Fj3DV94tglcyHG5kQz9nDC/8A==} + + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@3.2.1: + resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} + engines: {node: '>=4'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + argue-cli@3.1.0: + resolution: {integrity: sha512-DhBpBfXL4SS2uC0N922MMajKR3CdrTG0u2or1PNYgXMsrSzViJrbtvT0nCLlLGUI0plam/ZZCs7aAauHtW9thw==} + engines: {node: '>=22'} + + array-ify@1.0.0: + resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + astral-regex@2.0.0: + resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} + engines: {node: '>=8'} + + async-validator@4.2.5: + resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + at-least-node@1.0.0: + resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} + engines: {node: '>= 4.0.0'} + + axios@1.12.2: + resolution: {integrity: sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@2.0.0: + resolution: {integrity: sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + baseline-browser-mapping@2.11.13: + resolution: {integrity: sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + birpc@2.9.0: + resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + brace-expansion@1.1.18: + resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} + + brace-expansion@2.1.4: + resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==} + + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.8: + resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + + cacheable@2.5.0: + resolution: {integrity: sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==} + + cachedir@2.4.0: + resolution: {integrity: sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ==} + engines: {node: '>=6'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001809: + resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==} + + cfb@1.2.2: + resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==} + engines: {node: '>=0.8'} + + chalk@2.4.2: + resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} + engines: {node: '>=4'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + chardet@2.2.0: + resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + cli-cursor@3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + cli-truncate@4.0.0: + resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==} + engines: {node: '>=18'} + + cli-width@3.0.0: + resolution: {integrity: sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==} + engines: {node: '>= 10'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + + codepage@1.15.0: + resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==} + engines: {node: '>=0.8'} + + color-convert@1.9.3: + resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.3: + resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + colord@2.9.3: + resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + commitizen@4.3.2: + resolution: {integrity: sha512-1Zs37z9JPvAcuTSSricZZwBhOPVNNxJouuY4yDEt+eD70EoxT2TU9kViG8CuB/PmVg2G4XsAGQiK4YCst97aDQ==} + engines: {node: '>= 18'} + hasBin: true + + compare-func@2.0.0: + resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} + + compute-scroll-into-view@1.0.20: + resolution: {integrity: sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + + conventional-changelog-angular@7.0.0: + resolution: {integrity: sha512-ROjNchA9LgfNMTTFSIWPzebCwOGFdgkEq45EnvvrmSLvCtAw0HSmrCs7/ty+wAeYUZyNay0YMUNYFTRL72PkBQ==} + engines: {node: '>=16'} + + conventional-changelog-conventionalcommits@7.0.2: + resolution: {integrity: sha512-NKXYmMR/Hr1DevQegFB4MwfM5Vv0m4UIxKZTTYuD98lpTknaZlSRrDOG4X7wIXpGkfsYxZTghUN+Qq+T0YQI7w==} + engines: {node: '>=16'} + + conventional-commit-types@3.0.0: + resolution: {integrity: sha512-SmmCYnOniSsAa9GqWOeLqc179lfr5TRu5b4QFDkbsrJ5TZjPJx85wtOr3zn+1dbeNiXDKGPbZ72IKbPhLXh/Lg==} + + conventional-commits-parser@5.0.0: + resolution: {integrity: sha512-ZPMl0ZJbw74iS9LuX9YIAiW8pfM5p3yh2o/NbXHbkFuZzY5jvdi5jFycEOkmBW5H5I7nA+D6f3UcsCLP2vvSEA==} + engines: {node: '>=16'} + hasBin: true + + conventional-commits-parser@7.1.2: + resolution: {integrity: sha512-O+x4N2yH+ijvqWlIyTHsXTAP+algNWgGbjY2duCe8w2vUMvUB95cLRslCPfTMQyLAKlet3bhZTdu6ozn4M+QJQ==} + engines: {node: '>=22'} + hasBin: true + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + copy-anything@4.0.5: + resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} + engines: {node: '>=18'} + + core-js@3.50.0: + resolution: {integrity: sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==} + + cosmiconfig-typescript-loader@6.3.0: + resolution: {integrity: sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA==} + engines: {node: '>=v18'} + peerDependencies: + '@types/node': 24.8.1 + cosmiconfig: '>=9' + typescript: 5.6.3 + + cosmiconfig@9.0.2: + resolution: {integrity: sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==} + engines: {node: '>=14'} + peerDependencies: + typescript: 5.6.3 + peerDependenciesMeta: + typescript: + optional: true + + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + crypto-js@4.2.0: + resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + deprecated: Active development of CryptoJS has been discontinued. This library is no longer maintained. + + css-functions-list@3.3.3: + resolution: {integrity: sha512-8HFEBPKhOpJPEPu70wJJetjKta86Gw9+CCyCnB3sui2qQfOvRyqBy4IKLKKAwdMpWb2lHXWk9Wb4Z6AmaUT1Pg==} + engines: {node: '>=12'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + cz-conventional-changelog@3.3.0: + resolution: {integrity: sha512-U466fIzU5U22eES5lTNiNbZ+d8dfcHcssH4o7QsdWaCcRs/feIPCxKYSWkYBNs5mny7MvEfwpTLWjvbm94hecw==} + engines: {node: '>= 10'} + + cz-git@1.13.1: + resolution: {integrity: sha512-xeMA5ci+gkBY6uKmqc6nzhG/Q4pmK1DKbVVUgByOTBgregoiYE0f3otEF0ADelyEWH4vNsO1W2o7QZYEe7Nwlw==} + engines: {node: '>=v12.20.0'} + + d@1.0.2: + resolution: {integrity: sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==} + engines: {node: '>=0.12'} + + danmu.js@1.2.1: + resolution: {integrity: sha512-evDEImUBo94c846fC92K//Dzll8jXnZ3zKmYlQHwMzmvw6IW2IyjWL3Ew2SqEAzuqauFnDkwJEgZauu3uW/p1Q==} + + dargs@8.1.0: + resolution: {integrity: sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw==} + engines: {node: '>=12'} + + dayjs@1.11.21: + resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} + + de-indent@1.0.2: + resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + dedent@0.7.0: + resolution: {integrity: sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + + define-lazy-prop@2.0.0: + resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} + engines: {node: '>=8'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + delegate@3.2.0: + resolution: {integrity: sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==} + + detect-file@1.0.0: + resolution: {integrity: sha512-DtCOLG98P007x7wiiOmfI0fi3eIKyWiLTGJ2MDnVi/E04lWGbf+JzrRHMm0rgIIZJGtHpKpbVgLWHrv8xXpc3Q==} + engines: {node: '>=0.10.0'} + + detect-indent@6.1.0: + resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} + engines: {node: '>=8'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + dom7@3.0.0: + resolution: {integrity: sha512-oNlcUdHsC4zb7Msx7JN3K0Nro1dzJ48knvBOnDPKJ2GV9wl1i5vydJZUSyOfrkKFDZEud/jBsTk92S/VGSAe/g==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dot-prop@5.3.0: + resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} + engines: {node: '>=8'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + echarts@6.1.0: + resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==} + + electron-to-chromium@1.5.403: + resolution: {integrity: sha512-MQsYmdaLzvaCX5j+ZZBr5Fm6uCCnPQcRtlvmvRlWqrXy+BH2O4ffXIAScF+JQznQWB9brWp4lSD9Z4yNmaf2BA==} + + element-plus@2.11.4: + resolution: {integrity: sha512-sLq+Ypd0cIVilv8wGGMEGvzRVBBsRpJjnAS5PsI/1JU1COZXqzH3N1UYMUc/HCdvdjf6dfrBy80Sj7KcACsT7w==} + peerDependencies: + vue: 3.5.22 + + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + enhanced-resolve@5.24.5: + resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==} + engines: {node: '>=10.13.0'} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + error-stack-parser-es@0.1.5: + resolution: {integrity: sha512-xHku1X40RO+fO8yJ8Wh2f2rZWVjqyhb1zgq1yZ8aZRQkv6OOKhKWRUaht3eSCUbAOBaKIgM+ykwFLE+QUxgGeg==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-toolkit@1.50.0: + resolution: {integrity: sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==} + + es5-ext@0.10.64: + resolution: {integrity: sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==} + engines: {node: '>=0.10'} + + es6-iterator@2.0.3: + resolution: {integrity: sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==} + + es6-symbol@3.1.4: + resolution: {integrity: sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==} + engines: {node: '>=0.12'} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + eslint-config-prettier@9.1.2: + resolution: {integrity: sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-plugin-prettier@5.5.6: + resolution: {integrity: sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + '@types/eslint': '>=8.0.0' + eslint: '>=8.0.0' + eslint-config-prettier: '>= 7.0.0 <10.0.0 || >=10.1.0' + prettier: '>=3.0.0' + peerDependenciesMeta: + '@types/eslint': + optional: true + eslint-config-prettier: + optional: true + + eslint-plugin-vue@9.33.0: + resolution: {integrity: sha512-174lJKuNsuDIlLpjeXc5E2Tss8P44uIimAfGD0b90k0NoirJqpG7stLuU9Vp/9ioTOrQdWVREc4mRd1BD+CvGw==} + engines: {node: ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.2.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 + + eslint-scope@7.2.2: + resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.5: + resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + esniff@2.0.1: + resolution: {integrity: sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==} + engines: {node: '>=0.10'} + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + espree@9.6.1: + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + event-emitter@0.3.5: + resolution: {integrity: sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==} + + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + execa@8.0.1: + resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} + engines: {node: '>=16.17'} + + execa@9.6.1: + resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} + engines: {node: ^18.19.0 || >=20.5.0} + + expand-tilde@2.0.2: + resolution: {integrity: sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==} + engines: {node: '>=0.10.0'} + + exsolve@1.1.1: + resolution: {integrity: sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==} + + ext@1.7.0: + resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-diff@1.3.0: + resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-uri@3.1.5: + resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} + + fastest-levenshtein@1.0.16: + resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} + engines: {node: '>= 4.9.1'} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + figures@3.2.0: + resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} + engines: {node: '>=8'} + + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + + file-entry-cache@11.1.5: + resolution: {integrity: sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==} + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + file-saver@2.0.5: + resolution: {integrity: sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-node-modules@2.1.3: + resolution: {integrity: sha512-UC2I2+nx1ZuOBclWVNdcnbDR5dlrOdVb7xNjmT/lHE+LsgztWks3dG7boJ37yTS/venXw84B/mAW9uHVoC5QRg==} + + find-root@1.1.0: + resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + find-up@7.0.0: + resolution: {integrity: sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==} + engines: {node: '>=18'} + + findup-sync@4.0.0: + resolution: {integrity: sha512-6jvvn/12IC4quLBL1KNokxC7wWTvYncaVUYSoxWw7YykPLuRrnv4qdHcSOywOI5RpkOVGeQRtWM8/q+G6W6qfQ==} + engines: {node: '>= 8'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flat-cache@6.1.23: + resolution: {integrity: sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==} + + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + frac@1.1.2: + resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==} + engines: {node: '>=0.8'} + + fs-extra@10.1.0: + resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} + engines: {node: '>=12'} + + fs-extra@11.4.0: + resolution: {integrity: sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==} + engines: {node: '>=14.14'} + + fs-extra@9.1.0: + resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} + engines: {node: '>=10'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@8.0.1: + resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} + engines: {node: '>=16'} + + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + + git-raw-commits@4.0.0: + resolution: {integrity: sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==} + engines: {node: '>=16'} + deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead. + hasBin: true + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + global-directory@4.0.1: + resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==} + engines: {node: '>=18'} + + global-directory@5.0.0: + resolution: {integrity: sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w==} + engines: {node: '>=20'} + + global-modules@1.0.0: + resolution: {integrity: sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==} + engines: {node: '>=0.10.0'} + + global-modules@2.0.0: + resolution: {integrity: sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==} + engines: {node: '>=6'} + + global-prefix@1.0.2: + resolution: {integrity: sha512-5lsx1NUDHtSjfg0eHlmYvZKv8/nVqX4ckFbM+FrGcQ+04KWcWFo9P5MxPZYSzUvyzmdTbI7Eix8Q4IbELDqzKg==} + engines: {node: '>=0.10.0'} + + global-prefix@3.0.0: + resolution: {integrity: sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==} + engines: {node: '>=6'} + + globals@13.24.0: + resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} + engines: {node: '>=8'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@15.15.0: + resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} + engines: {node: '>=18'} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + globjoin@0.1.4: + resolution: {integrity: sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hashery@1.5.1: + resolution: {integrity: sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==} + engines: {node: '>=20'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + highlight.js@11.11.1: + resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} + engines: {node: '>=12.0.0'} + + homedir-polyfill@1.0.3: + resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==} + engines: {node: '>=0.10.0'} + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + + hookified@1.15.1: + resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} + + hookified@2.2.0: + resolution: {integrity: sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==} + + html-tags@3.3.1: + resolution: {integrity: sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==} + engines: {node: '>=8'} + + html-void-elements@2.0.1: + resolution: {integrity: sha512-0quDb7s97CfemeJAnW9wC0hw78MtW7NU3hqtCD75g2vFlDLt36llsYD7uB7SUzojLMP24N5IatXf7ylGXiGG9A==} + + htmlparser2@8.0.2: + resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + + human-signals@5.0.0: + resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} + engines: {node: '>=16.17.0'} + + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true + + i18next@20.6.1: + resolution: {integrity: sha512-yCMYTMEJ9ihCwEQQ3phLo7I/Pwycf8uAx+sRHwwk5U9Aui/IZYgQRyMqXafQOw5QQ7DM1Z+WyEXWIqSuJHhG2A==} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} + engines: {node: '>= 4'} + + immer@9.0.21: + resolution: {integrity: sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==} + + immutable@5.1.9: + resolution: {integrity: sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + ini@4.1.1: + resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + ini@6.0.0: + resolution: {integrity: sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + inquirer@8.2.7: + resolution: {integrity: sha512-UjOaSel/iddGZJ5xP/Eixh6dY1XghiBw4XK13rCCIJcJfyhhoul/7KhLLUGtebEj6GDYM6Vnx/mVsjx2L/mFIA==} + engines: {node: '>=12.0.0'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-fullwidth-code-point@4.0.0: + resolution: {integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==} + engines: {node: '>=12'} + + is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} + engines: {node: '>=18'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-hotkey@0.2.0: + resolution: {integrity: sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-interactive@1.0.0: + resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} + engines: {node: '>=8'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-obj@2.0.0: + resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} + engines: {node: '>=8'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + + is-stream@3.0.0: + resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + + is-text-path@2.0.0: + resolution: {integrity: sha512-+oDTluR6WEjdXEJMnC2z6A4FRwFoYuvShVVEGsS7ewc0UTi2QtAKMDJuL4BDEVt+5T7MjFo12RP8ghOM75oKJw==} + engines: {node: '>=8'} + + is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + + is-url@1.2.4: + resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==} + + is-utf8@0.2.1: + resolution: {integrity: sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q==} + + is-what@5.5.0: + resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} + engines: {node: '>=18'} + + is-windows@1.0.2: + resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} + engines: {node: '>=0.10.0'} + + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + js-yaml@4.3.1: + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + + jsonparse@1.3.1: + resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==} + engines: {'0': node >= 0.2.0} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + keyv@5.6.0: + resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==} + + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + + known-css-properties@0.37.0: + resolution: {integrity: sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==} + + kolorist@1.8.0: + resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + lint-staged@15.5.2: + resolution: {integrity: sha512-YUSOLq9VeRNAo/CTaVmhGDKG+LBtA8KF1X4K5+ykMSwWST1vDxJRB2kv2COgLb1fvpCo+A/y9A0G0znNVmdx4w==} + engines: {node: '>=18.12.0'} + hasBin: true + + listr2@8.3.3: + resolution: {integrity: sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ==} + engines: {node: '>=18.0.0'} + + local-pkg@1.2.1: + resolution: {integrity: sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==} + engines: {node: '>=14'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + locate-path@7.2.0: + resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + + lodash-unified@1.0.3: + resolution: {integrity: sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==} + peerDependencies: + '@types/lodash-es': '*' + lodash: '*' + lodash-es: '*' + + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + + lodash.clonedeep@4.5.0: + resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==} + + lodash.debounce@4.0.8: + resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + + lodash.foreach@4.5.0: + resolution: {integrity: sha512-aEXTF4d+m05rVOAUG3z4vZZ4xVexLKZGF0lIxuHZ1Hplpk/3B6Z1+/ICICYRLm7c41Z2xiejbkCkJoTlypoXhQ==} + + lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.kebabcase@4.1.1: + resolution: {integrity: sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==} + + lodash.map@4.6.0: + resolution: {integrity: sha512-worNHGKLDetmcEYDvh2stPCrrQRkP20E4l0iIS7F8EvzMqBBi7ltvFN5m1HvTf1P7Jk1txKhvFcmYsCr8O2F1Q==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash.mergewith@4.6.2: + resolution: {integrity: sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==} + + lodash.snakecase@4.1.1: + resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==} + + lodash.startcase@4.4.0: + resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} + + lodash.throttle@4.1.1: + resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==} + + lodash.toarray@4.4.0: + resolution: {integrity: sha512-QyffEA3i5dma5q2490+SgCvDN0pXLmRGSyAANuVi0HQ01Pkfr9fuoKQW8wm1wGBnJITs/mS7wQvS6VshUEBFCw==} + + lodash.truncate@4.4.2: + resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} + + lodash.uniq@4.5.0: + resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} + + lodash.upperfirst@4.3.1: + resolution: {integrity: sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + + log-update@6.1.0: + resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} + engines: {node: '>=18'} + + longest@2.0.1: + resolution: {integrity: sha512-Ajzxb8CM6WAnFjgiloPsI3bF+WCxcvhdIG3KNA2KN962+tdBsHcuQ4k4qX/EcS/2CRkcc0iAkR956Nib6aXU/Q==} + engines: {node: '>=0.10.0'} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mathml-tag-names@2.1.3: + resolution: {integrity: sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + mdn-data@2.31.0: + resolution: {integrity: sha512-+q2nMMt5Rd+PFSC6Nypqn0nU/po6kUPNIHEKYo3xxdl7ASK+Uam3zHOkSBVyw+Wjvb+dkWXfNJqIM0RWJio/MQ==} + + memoize-one@6.0.0: + resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} + + meow@12.1.1: + resolution: {integrity: sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==} + engines: {node: '>=16.10'} + + meow@13.2.0: + resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} + engines: {node: '>=18'} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + merge@2.1.1: + resolution: {integrity: sha512-jz+Cfrg9GWOZbQAnDQ4hlVnQky+341Yk5ru8bZSe6sIDTCIg8n9i/u7hSQGSVOF3C7lH6mGtqjkiT9G4wFLL0w==} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-match@1.0.2: + resolution: {integrity: sha512-VXp/ugGDVh3eCLOBCiHZMYWQaTNUHv2IJrut+yXA6+JbLPXHglHwfS/5A5L0ll+jkCY7fIzRJcH6OIunF+c6Cg==} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + mimic-fn@4.0.0: + resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} + engines: {node: '>=12'} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + mute-stream@0.0.8: + resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} + + namespace-emitter@2.0.1: + resolution: {integrity: sha512-N/sMKHniSDJBjfrkbS/tpkPj4RAbvW3mr8UAzvlMHyun93XEm83IAvhWtJVHo+RHn/oO8Job5YN4b+wRjSVp5g==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + nanoid@5.1.16: + resolution: {integrity: sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==} + engines: {node: ^18 || >=20} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + next-tick@1.1.0: + resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==} + + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + + node-releases@2.0.53: + resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==} + engines: {node: '>=18'} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + normalize-wheel-es@1.2.0: + resolution: {integrity: sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==} + + npm-run-path@5.3.0: + resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + + nprogress@0.2.0: + resolution: {integrity: sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + onetime@6.0.0: + resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} + engines: {node: '>=12'} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + open@10.2.0: + resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} + engines: {node: '>=18'} + + open@8.4.2: + resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} + engines: {node: '>=12'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + ora@5.4.1: + resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} + engines: {node: '>=10'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-limit@4.0.0: + resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-locate@6.0.0: + resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + + parse-passwd@1.0.0: + resolution: {integrity: sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==} + engines: {node: '>=0.10.0'} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-exists@5.0.0: + resolution: {integrity: sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pidtree@0.6.1: + resolution: {integrity: sha512-e0F9AOF1JMrCfBsyJOwU9lNvQ0WtXTq0j/4jk0BQ5JSI9VAybPXmDpPRw/2FQ3e5d3ZFN1mLh7jW99m/jjaptw==} + engines: {node: '>=0.10'} + hasBin: true + + pinia-plugin-persistedstate@4.7.1: + resolution: {integrity: sha512-WHOqh2esDlR3eAaknPbqXrkkj0D24h8shrDPqysgCFR6ghqP/fpFfJmMPJp0gETHsvrh9YNNg6dQfo2OEtDnIQ==} + peerDependencies: + '@nuxt/kit': '>=3.0.0' + '@pinia/nuxt': '>=0.10.0' + pinia: '>=3.0.0' + peerDependenciesMeta: + '@nuxt/kit': + optional: true + '@pinia/nuxt': + optional: true + pinia: + optional: true + + pinia@3.0.4: + resolution: {integrity: sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==} + peerDependencies: + typescript: 5.6.3 + vue: 3.5.22 + peerDependenciesMeta: + typescript: + optional: true + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + pkg-types@2.3.1: + resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + + postcss-html@1.8.1: + resolution: {integrity: sha512-OLF6P7qctfAWayOhLpcVnTGqVeJzu2W3WpIYelfz2+JV5oGxfkcEvweN9U4XpeqE0P98dcD9ssusGwlF0TK0uQ==} + engines: {node: ^12 || >=14} + + postcss-media-query-parser@0.2.3: + resolution: {integrity: sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==} + + postcss-resolve-nested-selector@0.1.6: + resolution: {integrity: sha512-0sglIs9Wmkzbr8lQwEyIzlDOOC9bGmfVKcJTaxv3vMmd3uo4o4DerC3En0bnmgceeql9BfC8hRkp7cg0fjdVqw==} + + postcss-safe-parser@6.0.0: + resolution: {integrity: sha512-FARHN8pwH+WiS2OPCxJI8FuRJpTVnn6ZNFiqAM2aeW2LwTHWWmWgIyKC6cUo0L8aeKiF/14MNvnpls6R2PBeMQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.3.3 + + postcss-safe-parser@7.0.1: + resolution: {integrity: sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==} + engines: {node: '>=18.0'} + peerDependencies: + postcss: ^8.4.31 + + postcss-scss@4.0.9: + resolution: {integrity: sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.4.29 + + postcss-selector-parser@6.1.4: + resolution: {integrity: sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==} + engines: {node: '>=4'} + + postcss-selector-parser@7.1.5: + resolution: {integrity: sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw==} + engines: {node: '>=4'} + + postcss-sorting@8.0.2: + resolution: {integrity: sha512-M9dkSrmU00t/jK7rF6BZSZauA5MAaBW4i5EnJXspMwt4iqTh/L9j6fgMnbElEOfyRyfLfVbIHj/R52zHzAPe1Q==} + peerDependencies: + postcss: ^8.4.20 + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + + preact@10.29.8: + resolution: {integrity: sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==} + peerDependencies: + preact-render-to-string: '>=5' + peerDependenciesMeta: + preact-render-to-string: + optional: true + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier-linter-helpers@1.0.1: + resolution: {integrity: sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==} + engines: {node: '>=6.0.0'} + + prettier@3.9.6: + resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} + engines: {node: '>=14'} + hasBin: true + + pretty-ms@9.3.0: + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + engines: {node: '>=18'} + + prismjs@1.30.0: + resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} + engines: {node: '>=6'} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qified@0.10.1: + resolution: {integrity: sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==} + engines: {node: '>=20'} + + qrcode.vue@3.10.0: + resolution: {integrity: sha512-1bjeBds9hRKMszZuBuYQZor9HdJYWtb0S44HG1JofZ9uicpJpdF+TtDvqo3+2ZlH0k80WVIkmiKB4hq0/N6/rA==} + peerDependencies: + vue: 3.5.22 + + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + readdirp@5.1.1: + resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} + engines: {node: '>= 20.19.0'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-dir@1.0.1: + resolution: {integrity: sha512-R7uiTjECzvOsWSfdM0QKFNBVFcK27aHOUwdvK53BcW8zqnGdYp0Fbj82cy54+2A4P2tFM22J5kRfe1R+lM/1yg==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + restore-cursor@3.1.0: + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rollup-plugin-visualizer@5.14.0: + resolution: {integrity: sha512-VlDXneTDaKsHIw8yzJAFWtrzguoJ/LnQ+lMpoVfYJ3jJF4Ihe5oYLAqLklIK/35lgUY+1yEzCkHyZ1j4A5w5fA==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + rolldown: 1.x + rollup: 2.x || 3.x || 4.x + peerDependenciesMeta: + rolldown: + optional: true + rollup: + optional: true + + rollup@4.62.4: + resolution: {integrity: sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + + run-async@2.4.1: + resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==} + engines: {node: '>=0.12.0'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sass@1.102.0: + resolution: {integrity: sha512-NSOyTnaQF7rTAEOtI2fwb386vL+akyiQLBZu8Na7hXCb+umJy0GAqlcMIaqACZ6Z1VgTBS4K9PG6B3IdjHGJsw==} + engines: {node: '>=20.19.0'} + hasBin: true + + scroll-into-view-if-needed@2.2.31: + resolution: {integrity: sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==} + + scule@1.3.0: + resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + slate-history@0.66.0: + resolution: {integrity: sha512-6MWpxGQZiMvSINlCbMW43E2YBSVMCMCIwQfBzGssjWw4kb0qfvj0pIdblWNRQZD0hR6WHP+dHHgGSeVdMWzfng==} + peerDependencies: + slate: '>=0.65.3' + + slate@0.72.8: + resolution: {integrity: sha512-/nJwTswQgnRurpK+bGJFH1oM7naD5qDmHd89JyiKNT2oOKD8marW0QSBtuFnwEbL5aGCS8AmrhXQgNOsn4osAw==} + + slice-ansi@4.0.0: + resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} + engines: {node: '>=10'} + + slice-ansi@5.0.0: + resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==} + engines: {node: '>=12'} + + slice-ansi@7.1.2: + resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} + engines: {node: '>=18'} + + snabbdom@3.6.4: + resolution: {integrity: sha512-VmxEfuw1/Y/eFj5VtMhYnukExpYiPkNzoo3+N3qwAOUDMl8wXgbli5ebR+j0knE3lZ/0eYskLxNcX64uy10N9w==} + engines: {node: '>=12.17.0'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + speakingurl@14.0.1: + resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} + engines: {node: '>=0.10.0'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + ssf@0.11.2: + resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==} + engines: {node: '>=0.8'} + + ssr-window@3.0.0: + resolution: {integrity: sha512-q+8UfWDg9Itrg0yWK7oe5p/XRCJpJF9OBtXfOPgSJl+u3Xd5KI328RUEvUqSMVM9CiQUEf1QdBzJMkYGErj9QA==} + + string-argv@0.3.2: + resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} + engines: {node: '>=0.6.19'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-bom@4.0.0: + resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} + engines: {node: '>=8'} + + strip-final-newline@3.0.0: + resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} + engines: {node: '>=12'} + + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + stylelint-config-html@1.1.0: + resolution: {integrity: sha512-IZv4IVESjKLumUGi+HWeb7skgO6/g4VMuAYrJdlqQFndgbj6WJAXPhaysvBiXefX79upBdQVumgYcdd17gCpjQ==} + engines: {node: ^12 || >=14} + peerDependencies: + postcss-html: ^1.0.0 + stylelint: '>=14.0.0' + + stylelint-config-recess-order@4.6.0: + resolution: {integrity: sha512-V76fhv3YtcNXh/hyAuAdSzi5FmcrG54Mp2AThJ3D/PTMTSYzUPd7GIhP6z9mTqnRhmkk6YTfcu/JWB8h+Yrcaw==} + peerDependencies: + stylelint: '>=15' + + stylelint-config-recommended-scss@14.1.0: + resolution: {integrity: sha512-bhaMhh1u5dQqSsf6ri2GVWWQW5iUjBYgcHkh7SgDDn92ijoItC/cfO/W+fpXshgTQWhwFkP1rVcewcv4jaftRg==} + engines: {node: '>=18.12.0'} + peerDependencies: + postcss: ^8.3.3 + stylelint: ^16.6.1 + peerDependenciesMeta: + postcss: + optional: true + + stylelint-config-recommended-vue@1.6.1: + resolution: {integrity: sha512-lLW7hTIMBiTfjenGuDq2kyHA6fBWd/+Df7MO4/AWOxiFeXP9clbpKgg27kHfwA3H7UNMGC7aeP3mNlZB5LMmEQ==} + engines: {node: ^12 || >=14} + peerDependencies: + postcss-html: ^1.0.0 + stylelint: '>=14.0.0' + + stylelint-config-recommended@14.0.1: + resolution: {integrity: sha512-bLvc1WOz/14aPImu/cufKAZYfXs/A/owZfSMZ4N+16WGXLoX5lOir53M6odBxvhgmgdxCVnNySJmZKx73T93cg==} + engines: {node: '>=18.12.0'} + peerDependencies: + stylelint: ^16.1.0 + + stylelint-config-recommended@18.0.0: + resolution: {integrity: sha512-mxgT2XY6YZ3HWWe3Di8umG6aBmWmHTblTgu/f10rqFXnyWxjKWwNdjSWkgkwCtxIKnqjSJzvFmPT5yabVIRxZg==} + engines: {node: '>=20.19.0'} + peerDependencies: + stylelint: ^17.0.0 + + stylelint-config-standard@36.0.1: + resolution: {integrity: sha512-8aX8mTzJ6cuO8mmD5yon61CWuIM4UD8Q5aBcWKGSf6kg+EC3uhB+iOywpTK4ca6ZL7B49en8yanOFtUW0qNzyw==} + engines: {node: '>=18.12.0'} + peerDependencies: + stylelint: ^16.1.0 + + stylelint-order@6.0.4: + resolution: {integrity: sha512-0UuKo4+s1hgQ/uAxlYU4h0o0HS4NiQDud0NAUNI0aa8FJdmYHA5ZZTFHiV5FpmE3071e9pZx5j0QpVJW5zOCUA==} + peerDependencies: + stylelint: ^14.0.0 || ^15.0.0 || ^16.0.1 + + stylelint-scss@6.14.0: + resolution: {integrity: sha512-ZKmHMZolxeuYsnB+PCYrTpFce0/QWX9i9gh0hPXzp73WjuIMqUpzdQaBCrKoLWh6XtCFSaNDErkMPqdjy1/8aA==} + engines: {node: '>=18.12.0'} + peerDependencies: + stylelint: ^16.8.2 + + stylelint@16.26.1: + resolution: {integrity: sha512-v20V59/crfc8sVTAtge0mdafI3AdnzQ2KsWe6v523L4OA1bJO02S7MO2oyXDCS6iWb9ckIPnqAFVItqSBQr7jw==} + engines: {node: '>=18.12.0'} + hasBin: true + + superjson@2.2.6: + resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} + engines: {node: '>=16'} + + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-hyperlinks@3.2.0: + resolution: {integrity: sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==} + engines: {node: '>=14.18'} + + svg-tags@1.0.0: + resolution: {integrity: sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==} + + synckit@0.11.13: + resolution: {integrity: sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==} + engines: {node: ^14.18.0 || >=16.0.0} + + table@6.9.0: + resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} + engines: {node: '>=10.0.0'} + + tailwindcss@4.3.3: + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + terser@5.49.2: + resolution: {integrity: sha512-rGbJiKeQ4WDe3EXlDAIaQcwftVfv2Q8o1awFNfvXolJYKkb1AuZY1RTOmqx4LJXZENbWZA7eIsYGHuEzHsi1nQ==} + engines: {node: '>=10'} + hasBin: true + + text-extensions@2.4.0: + resolution: {integrity: sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g==} + engines: {node: '>=8'} + + through@2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + + tiny-warning@1.0.3: + resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: 5.6.3 + + tslib@2.3.0: + resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.23.12: + resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + type@2.7.3: + resolution: {integrity: sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==} + + typescript-eslint@8.66.0: + resolution: {integrity: sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: 5.6.3 + + typescript@5.6.3: + resolution: {integrity: sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + undici-types@7.14.0: + resolution: {integrity: sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==} + + unicorn-magic@0.1.0: + resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} + engines: {node: '>=18'} + + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + + unimport@5.7.0: + resolution: {integrity: sha512-njnL6sp8lEA8QQbZrt+52p/g4X0rw3bnGGmUcJnt1jeG8+iiqO779aGz0PirCtydAIVcuTBRlJ52F0u46z309Q==} + engines: {node: '>=18.12.0'} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + unplugin-auto-import@20.3.0: + resolution: {integrity: sha512-RcSEQiVv7g0mLMMXibYVKk8mpteKxvyffGuDKqZZiFr7Oq3PB1HwgHdK5O7H4AzbhzHoVKG0NnMnsk/1HIVYzQ==} + engines: {node: '>=14'} + peerDependencies: + '@nuxt/kit': ^4.0.0 + '@vueuse/core': '*' + peerDependenciesMeta: + '@nuxt/kit': + optional: true + '@vueuse/core': + optional: true + + unplugin-element-plus@0.10.0: + resolution: {integrity: sha512-oRSW0x6U58xBOWKy8TcoVZNA8ElIpfp3TUJRLQI6ey/E9PpjHl9/deeTAZNt8D57Li4OA4pCJtM6p2cb4Ff4ZA==} + engines: {node: '>=18.12.0'} + + unplugin-utils@0.2.5: + resolution: {integrity: sha512-gwXJnPRewT4rT7sBi/IvxKTjsms7jX7QIDLOClApuZwR49SXbrB1z2NLUZ+vDHyqCj/n58OzRRqaW+B8OZi8vg==} + engines: {node: '>=18.12.0'} + + unplugin-utils@0.3.2: + resolution: {integrity: sha512-xVToRh2CTmLk2HnEG7ac4rl1MJTT3RFkpS8B++/SnB0kXvuaavD+n3m/vrzyWQOdJNSZQACnbz01pnppbwV5BA==} + engines: {node: '>=20.19.0'} + + unplugin-vue-components@29.2.0: + resolution: {integrity: sha512-QxBeBdmEflgtJRgMQMc/z/JVV5lcwXN5nOy5ehX6CKDGylIu6Qn4Goy8X95S0qOxF7EdI+uNhdBd4v5i0bvzCw==} + engines: {node: '>=14'} + peerDependencies: + '@babel/parser': ^7.15.8 + '@nuxt/kit': ^3.2.2 || ^4.0.0 + vue: 3.5.22 + peerDependenciesMeta: + '@babel/parser': + optional: true + '@nuxt/kit': + optional: true + + unplugin@2.3.11: + resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} + engines: {node: '>=18.12.0'} + + update-browserslist-db@1.3.0: + resolution: {integrity: sha512-x/M6q3w4Ybp91CNaS4S69UnliqR3BzRpOT6LWbksjth0S/+jhfaPJsWjt/TewpT8j9eLIojUf5jr29WextHroA==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vite-hot-client@2.2.0: + resolution: {integrity: sha512-76Zs9zrHbH7M7wqeyooGQKdX+yg0pQ0xuQ1PbFp4z5a0Lzn2e5IPFoCswnmqZ4GiwqB4Jo3WcDAMO9jARTJl8w==} + peerDependencies: + vite: 7.1.7 + + vite-plugin-compression@0.5.1: + resolution: {integrity: sha512-5QJKBDc+gNYVqL/skgFAP81Yuzo9R+EAf19d+EtsMF/i8kFUpNi3J/H01QD3Oo8zBQn+NzoCIFkpPLynoOzaJg==} + peerDependencies: + vite: 7.1.7 + + vite-plugin-inspect@0.8.9: + resolution: {integrity: sha512-22/8qn+LYonzibb1VeFZmISdVao5kC22jmEKm24vfFE8siEn47EpVcCLYMv6iKOYMJfjSvSJfueOwcFCkUnV3A==} + engines: {node: '>=14'} + peerDependencies: + '@nuxt/kit': '*' + vite: 7.1.7 + peerDependenciesMeta: + '@nuxt/kit': + optional: true + + vite-plugin-vue-devtools@7.7.10: + resolution: {integrity: sha512-aHkUvDKxUdmSnuolEc8DkOrSjMpKO4Bb/BG01x2pBv6xTv487No9Tr7BMAsH2XWwMd+LtlUeCamhEZyEu9vxKQ==} + engines: {node: '>=v14.21.3'} + peerDependencies: + vite: 7.1.7 + + vite-plugin-vue-inspector@5.4.0: + resolution: {integrity: sha512-Iq/024CydcE46FZqWPU4t4lw4uYOdLnFSO1RNxJVt2qY9zxIjmnkBqhHnYaReWM82kmNnaXs7OkfgRrV2GEjyw==} + peerDependencies: + vite: 7.1.7 + + vite@7.1.7: + resolution: {integrity: sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': 24.8.1 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + + vue-demi@0.14.10: + resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} + engines: {node: '>=12'} + hasBin: true + peerDependencies: + '@vue/composition-api': ^1.0.0-rc.1 + vue: 3.5.22 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + + vue-draggable-plus@0.6.1: + resolution: {integrity: sha512-FbtQ/fuoixiOfTZzG3yoPl4JAo9HJXRHmBQZFB9x2NYCh6pq0TomHf7g5MUmpaDYv+LU2n6BPq2YN9sBO+FbIg==} + peerDependencies: + '@types/sortablejs': ^1.15.0 + '@vue/composition-api': '*' + peerDependenciesMeta: + '@vue/composition-api': + optional: true + + vue-eslint-parser@9.4.3: + resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==} + engines: {node: ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: '>=6.0.0' + + vue-i18n@11.4.8: + resolution: {integrity: sha512-0ULeHP6Z9CGvAm67S77ZEp41cfGXIREGL8qfhos2BMgcQQewtQcDKuojt6jjasAD/S8GwfTp2ySPmDSpwvrCMQ==} + engines: {node: '>= 22'} + peerDependencies: + vue: 3.5.22 + + vue-i18n@9.14.5: + resolution: {integrity: sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g==} + engines: {node: '>= 16'} + deprecated: v9 and v10 no longer supported. please migrate to v11. about maintenance status, see https://vue-i18n.intlify.dev/guide/maintenance.html + peerDependencies: + vue: 3.5.22 + + vue-img-cutter@3.1.1: + resolution: {integrity: sha512-9n0jHJGS7YpakbUNg+VZdUudzRLfNTkI+vo1CnoSp0JlskXOSa5rRPiPS8nO1YSkDnBsiRwJeUM+TOaJy95HWw==} + engines: {node: '>=22.22.2'} + + vue-router@4.6.4: + resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==} + peerDependencies: + vue: 3.5.22 + + vue-tsc@2.1.10: + resolution: {integrity: sha512-RBNSfaaRHcN5uqVqJSZh++Gy/YUzryuv9u1aFWhsammDJXNtUiJMNoJ747lZcQ68wUQFx6E73y4FY3D8E7FGMA==} + hasBin: true + peerDependencies: + typescript: 5.6.3 + + vue@3.5.22: + resolution: {integrity: sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==} + peerDependencies: + typescript: 5.6.3 + peerDependenciesMeta: + typescript: + optional: true + + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + + which@1.3.1: + resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} + hasBin: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wildcard@1.1.2: + resolution: {integrity: sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng==} + + wmf@1.0.2: + resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==} + engines: {node: '>=0.8'} + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + word@0.3.0: + resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==} + engines: {node: '>=0.8'} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + write-file-atomic@5.0.1: + resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + wsl-utils@0.1.0: + resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} + engines: {node: '>=18'} + + xgplayer-subtitles@3.0.26: + resolution: {integrity: sha512-HiHlw3E8emEQCv0xFYHdmNUFgyzyWGBnSNmyI/fkuuvRBb+4IbsCfxg6PF3r1TNCfzOuNzA/OaMbtXBSH8n/Bw==} + peerDependencies: + core-js: '>=3.12.1' + + xgplayer@3.0.26: + resolution: {integrity: sha512-F/urLGIrxCa1DI+wtkPyT0TcRS6Wejbo+OZKOTlSVvg0+SGyN3TfSXddNcu6LL876ZTWzlEwhS4C5nKSUV5FZw==} + peerDependencies: + core-js: '>=3.12.1' + + xlsx@0.18.5: + resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==} + engines: {node: '>=0.8'} + hasBin: true + + xml-name-validator@4.0.0: + resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} + engines: {node: '>=12'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + + yoctocolors@2.2.0: + resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==} + engines: {node: '>=18'} + + zrender@6.1.0: + resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==} + +snapshots: + + '@antfu/utils@0.7.10': {} + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.29.7': + dependencies: + '@babel/types': 7.29.8 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.8 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/traverse': 7.29.8 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-member-expression-to-functions@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.29.7': + dependencies: + '@babel/types': 7.29.8 + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/plugin-proposal-decorators@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-syntax-decorators': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-syntax-decorators@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-import-attributes@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/runtime@7.29.7': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@babel/traverse@7.29.8': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@cacheable/memory@2.2.0': + dependencies: + '@cacheable/utils': 2.5.0 + '@keyv/bigmap': 1.3.1(keyv@5.6.0) + hookified: 1.15.1 + keyv: 5.6.0 + + '@cacheable/utils@2.5.0': + dependencies: + hashery: 1.5.1 + keyv: 5.6.0 + + '@commitlint/cli@19.8.1(@types/node@24.8.1)(typescript@5.6.3)': + dependencies: + '@commitlint/format': 19.8.1 + '@commitlint/lint': 19.8.1 + '@commitlint/load': 19.8.1(@types/node@24.8.1)(typescript@5.6.3) + '@commitlint/read': 19.8.1 + '@commitlint/types': 19.8.1 + tinyexec: 1.3.0 + yargs: 17.7.3 + transitivePeerDependencies: + - '@types/node' + - typescript + + '@commitlint/config-conventional@19.8.1': + dependencies: + '@commitlint/types': 19.8.1 + conventional-changelog-conventionalcommits: 7.0.2 + + '@commitlint/config-validator@19.8.1': + dependencies: + '@commitlint/types': 19.8.1 + ajv: 8.20.0 + + '@commitlint/config-validator@21.2.0': + dependencies: + '@commitlint/types': 21.2.0 + ajv: 8.20.0 + optional: true + + '@commitlint/ensure@19.8.1': + dependencies: + '@commitlint/types': 19.8.1 + lodash.camelcase: 4.3.0 + lodash.kebabcase: 4.1.1 + lodash.snakecase: 4.1.1 + lodash.startcase: 4.4.0 + lodash.upperfirst: 4.3.1 + + '@commitlint/execute-rule@19.8.1': {} + + '@commitlint/execute-rule@21.0.1': + optional: true + + '@commitlint/format@19.8.1': + dependencies: + '@commitlint/types': 19.8.1 + chalk: 5.6.2 + + '@commitlint/is-ignored@19.8.1': + dependencies: + '@commitlint/types': 19.8.1 + semver: 7.8.5 + + '@commitlint/lint@19.8.1': + dependencies: + '@commitlint/is-ignored': 19.8.1 + '@commitlint/parse': 19.8.1 + '@commitlint/rules': 19.8.1 + '@commitlint/types': 19.8.1 + + '@commitlint/load@19.8.1(@types/node@24.8.1)(typescript@5.6.3)': + dependencies: + '@commitlint/config-validator': 19.8.1 + '@commitlint/execute-rule': 19.8.1 + '@commitlint/resolve-extends': 19.8.1 + '@commitlint/types': 19.8.1 + chalk: 5.6.2 + cosmiconfig: 9.0.2(typescript@5.6.3) + cosmiconfig-typescript-loader: 6.3.0(@types/node@24.8.1)(cosmiconfig@9.0.2(typescript@5.6.3))(typescript@5.6.3) + lodash.isplainobject: 4.0.6 + lodash.merge: 4.6.2 + lodash.uniq: 4.5.0 + transitivePeerDependencies: + - '@types/node' + - typescript + + '@commitlint/load@21.2.0(@types/node@24.8.1)(typescript@5.6.3)': + dependencies: + '@commitlint/config-validator': 21.2.0 + '@commitlint/execute-rule': 21.0.1 + '@commitlint/resolve-extends': 21.2.0 + '@commitlint/types': 21.2.0 + cosmiconfig: 9.0.2(typescript@5.6.3) + cosmiconfig-typescript-loader: 6.3.0(@types/node@24.8.1)(cosmiconfig@9.0.2(typescript@5.6.3))(typescript@5.6.3) + es-toolkit: 1.50.0 + is-plain-obj: 4.1.0 + picocolors: 1.1.1 + transitivePeerDependencies: + - '@types/node' + - typescript + optional: true + + '@commitlint/message@19.8.1': {} + + '@commitlint/parse@19.8.1': + dependencies: + '@commitlint/types': 19.8.1 + conventional-changelog-angular: 7.0.0 + conventional-commits-parser: 5.0.0 + + '@commitlint/read@19.8.1': + dependencies: + '@commitlint/top-level': 19.8.1 + '@commitlint/types': 19.8.1 + git-raw-commits: 4.0.0 + minimist: 1.2.8 + tinyexec: 1.3.0 + + '@commitlint/resolve-extends@19.8.1': + dependencies: + '@commitlint/config-validator': 19.8.1 + '@commitlint/types': 19.8.1 + global-directory: 4.0.1 + import-meta-resolve: 4.2.0 + lodash.mergewith: 4.6.2 + resolve-from: 5.0.0 + + '@commitlint/resolve-extends@21.2.0': + dependencies: + '@commitlint/config-validator': 21.2.0 + '@commitlint/types': 21.2.0 + es-toolkit: 1.50.0 + global-directory: 5.0.0 + resolve-from: 5.0.0 + optional: true + + '@commitlint/rules@19.8.1': + dependencies: + '@commitlint/ensure': 19.8.1 + '@commitlint/message': 19.8.1 + '@commitlint/to-lines': 19.8.1 + '@commitlint/types': 19.8.1 + + '@commitlint/to-lines@19.8.1': {} + + '@commitlint/top-level@19.8.1': + dependencies: + find-up: 7.0.0 + + '@commitlint/types@19.8.1': + dependencies: + '@types/conventional-commits-parser': 5.0.2 + chalk: 5.6.2 + + '@commitlint/types@21.2.0': + dependencies: + conventional-commits-parser: 7.1.2 + picocolors: 1.1.1 + optional: true + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-syntax-patches-for-csstree@1.1.7(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@3.0.4': {} + + '@csstools/media-query-list-parser@4.0.3(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/selector-specificity@5.0.0(postcss-selector-parser@7.1.5)': + dependencies: + postcss-selector-parser: 7.1.5 + + '@ctrl/tinycolor@3.6.1': {} + + '@dual-bundle/import-meta-resolve@4.2.1': {} + + '@element-plus/icons-vue@2.3.2(vue@3.5.22(typescript@5.6.3))': + dependencies: + vue: 3.5.22(typescript@5.6.3) + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@eslint-community/eslint-utils@4.10.1(eslint@9.39.5(jiti@2.7.0))': + dependencies: + eslint: 9.39.5(jiti@2.7.0) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.6': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.3.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.5': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + + '@floating-ui/utils@0.2.12': {} + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@iconify/types@2.0.0': {} + + '@iconify/vue@5.0.1(vue@3.5.22(typescript@5.6.3))': + dependencies: + '@iconify/types': 2.0.0 + vue: 3.5.22(typescript@5.6.3) + + '@inquirer/external-editor@1.0.3(@types/node@24.8.1)': + dependencies: + chardet: 2.2.0 + iconv-lite: 0.7.3 + optionalDependencies: + '@types/node': 24.8.1 + + '@intlify/core-base@11.4.8': + dependencies: + '@intlify/devtools-types': 11.4.8 + '@intlify/message-compiler': 11.4.8 + '@intlify/shared': 11.4.8 + + '@intlify/core-base@9.14.5': + dependencies: + '@intlify/message-compiler': 9.14.5 + '@intlify/shared': 9.14.5 + + '@intlify/devtools-types@11.4.8': + dependencies: + '@intlify/core-base': 11.4.8 + '@intlify/shared': 11.4.8 + + '@intlify/message-compiler@11.4.8': + dependencies: + '@intlify/shared': 11.4.8 + source-map-js: 1.2.1 + + '@intlify/message-compiler@9.14.5': + dependencies: + '@intlify/shared': 9.14.5 + source-map-js: 1.2.1 + + '@intlify/shared@11.4.8': {} + + '@intlify/shared@9.14.5': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@keyv/bigmap@1.3.1(keyv@5.6.0)': + dependencies: + hashery: 1.5.1 + hookified: 1.15.1 + keyv: 5.6.0 + + '@keyv/serialize@1.1.1': {} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@parcel/watcher-android-arm64@2.6.0': + optional: true + + '@parcel/watcher-darwin-arm64@2.6.0': + optional: true + + '@parcel/watcher-darwin-x64@2.6.0': + optional: true + + '@parcel/watcher-freebsd-x64@2.6.0': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.6.0': + optional: true + + '@parcel/watcher-linux-arm-musl@2.6.0': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.6.0': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.6.0': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.6.0': + optional: true + + '@parcel/watcher-linux-x64-musl@2.6.0': + optional: true + + '@parcel/watcher-win32-arm64@2.6.0': + optional: true + + '@parcel/watcher-win32-x64@2.6.0': + optional: true + + '@parcel/watcher@2.6.0': + dependencies: + detect-libc: 2.1.2 + is-glob: 4.0.3 + node-addon-api: 7.1.1 + picomatch: 4.0.5 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.6.0 + '@parcel/watcher-darwin-arm64': 2.6.0 + '@parcel/watcher-darwin-x64': 2.6.0 + '@parcel/watcher-freebsd-x64': 2.6.0 + '@parcel/watcher-linux-arm-glibc': 2.6.0 + '@parcel/watcher-linux-arm-musl': 2.6.0 + '@parcel/watcher-linux-arm64-glibc': 2.6.0 + '@parcel/watcher-linux-arm64-musl': 2.6.0 + '@parcel/watcher-linux-x64-glibc': 2.6.0 + '@parcel/watcher-linux-x64-musl': 2.6.0 + '@parcel/watcher-win32-arm64': 2.6.0 + '@parcel/watcher-win32-x64': 2.6.0 + optional: true + + '@pkgr/core@0.3.6': {} + + '@polka/url@1.0.0-next.29': {} + + '@rolldown/pluginutils@1.0.0-beta.29': {} + + '@rollup/pluginutils@5.4.0(rollup@4.62.4)': + dependencies: + '@types/estree': 1.0.9 + estree-walker: 2.0.2 + picomatch: 4.0.5 + optionalDependencies: + rollup: 4.62.4 + + '@rollup/rollup-android-arm-eabi@4.62.4': + optional: true + + '@rollup/rollup-android-arm64@4.62.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.4': + optional: true + + '@rollup/rollup-darwin-x64@4.62.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.4': + optional: true + + '@sec-ant/readable-stream@0.4.1': {} + + '@simple-libs/stream-utils@2.0.0': + optional: true + + '@sindresorhus/merge-streams@4.0.0': {} + + '@sxzz/popperjs-es@2.11.8': {} + + '@tailwindcss/node@4.3.3': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.24.5 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.3 + + '@tailwindcss/oxide-android-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide@4.3.3': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-x64': 4.3.3 + '@tailwindcss/oxide-freebsd-x64': 4.3.3 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 + + '@tailwindcss/vite@4.3.3(vite@7.1.7(@types/node@24.8.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.12)(yaml@2.9.0))': + dependencies: + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 + tailwindcss: 4.3.3 + vite: 7.1.7(@types/node@24.8.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.12)(yaml@2.9.0) + + '@transloadit/prettier-bytes@0.0.7': {} + + '@types/conventional-commits-parser@5.0.2': + dependencies: + '@types/node': 24.8.1 + + '@types/estree@1.0.9': {} + + '@types/event-emitter@0.3.5': {} + + '@types/json-schema@7.0.15': {} + + '@types/lodash-es@4.17.12': + dependencies: + '@types/lodash': 4.17.25 + + '@types/lodash@4.17.25': {} + + '@types/node@24.8.1': + dependencies: + undici-types: 7.14.0 + + '@types/sortablejs@1.15.9': {} + + '@types/web-bluetooth@0.0.16': {} + + '@types/web-bluetooth@0.0.21': {} + + '@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.6.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.6.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.6.3) + '@typescript-eslint/scope-manager': 8.66.0 + '@typescript-eslint/type-utils': 8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.6.3) + '@typescript-eslint/utils': 8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.6.3) + '@typescript-eslint/visitor-keys': 8.66.0 + eslint: 9.39.5(jiti@2.7.0) + ignore: 7.0.6 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.6.3) + typescript: 5.6.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.6.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.66.0 + '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/typescript-estree': 8.66.0(typescript@5.6.3) + '@typescript-eslint/visitor-keys': 8.66.0 + debug: 4.4.3 + eslint: 9.39.5(jiti@2.7.0) + typescript: 5.6.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.66.0(typescript@5.6.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.66.0(typescript@5.6.3) + '@typescript-eslint/types': 8.66.0 + debug: 4.4.3 + typescript: 5.6.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.66.0': + dependencies: + '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/visitor-keys': 8.66.0 + + '@typescript-eslint/tsconfig-utils@8.66.0(typescript@5.6.3)': + dependencies: + typescript: 5.6.3 + + '@typescript-eslint/type-utils@8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.6.3)': + dependencies: + '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/typescript-estree': 8.66.0(typescript@5.6.3) + '@typescript-eslint/utils': 8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.6.3) + debug: 4.4.3 + eslint: 9.39.5(jiti@2.7.0) + ts-api-utils: 2.5.0(typescript@5.6.3) + typescript: 5.6.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.66.0': {} + + '@typescript-eslint/typescript-estree@8.66.0(typescript@5.6.3)': + dependencies: + '@typescript-eslint/project-service': 8.66.0(typescript@5.6.3) + '@typescript-eslint/tsconfig-utils': 8.66.0(typescript@5.6.3) + '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/visitor-keys': 8.66.0 + debug: 4.4.3 + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.6.3) + typescript: 5.6.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.6.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.66.0 + '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/typescript-estree': 8.66.0(typescript@5.6.3) + eslint: 9.39.5(jiti@2.7.0) + typescript: 5.6.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.66.0': + dependencies: + '@typescript-eslint/types': 8.66.0 + eslint-visitor-keys: 5.0.1 + + '@uppy/companion-client@2.2.2': + dependencies: + '@uppy/utils': 4.1.3 + namespace-emitter: 2.0.1 + + '@uppy/core@2.3.4': + dependencies: + '@transloadit/prettier-bytes': 0.0.7 + '@uppy/store-default': 2.1.1 + '@uppy/utils': 4.1.3 + lodash.throttle: 4.1.1 + mime-match: 1.0.2 + namespace-emitter: 2.0.1 + nanoid: 3.3.18 + preact: 10.29.8 + transitivePeerDependencies: + - preact-render-to-string + + '@uppy/store-default@2.1.1': {} + + '@uppy/utils@4.1.3': + dependencies: + lodash.throttle: 4.1.1 + + '@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4)': + dependencies: + '@uppy/companion-client': 2.2.2 + '@uppy/core': 2.3.4 + '@uppy/utils': 4.1.3 + nanoid: 3.3.18 + + '@vitejs/plugin-vue@6.0.1(vite@7.1.7(@types/node@24.8.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.12)(yaml@2.9.0))(vue@3.5.22(typescript@5.6.3))': + dependencies: + '@rolldown/pluginutils': 1.0.0-beta.29 + vite: 7.1.7(@types/node@24.8.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.12)(yaml@2.9.0) + vue: 3.5.22(typescript@5.6.3) + + '@volar/language-core@2.4.28': + dependencies: + '@volar/source-map': 2.4.28 + + '@volar/source-map@2.4.28': {} + + '@volar/typescript@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + path-browserify: 1.0.1 + vscode-uri: 3.1.0 + + '@vue/babel-helper-vue-transform-on@1.5.0': {} + + '@vue/babel-plugin-jsx@1.5.0(@babel/core@7.29.7)': + dependencies: + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + '@vue/babel-helper-vue-transform-on': 1.5.0 + '@vue/babel-plugin-resolve-type': 1.5.0(@babel/core@7.29.7) + '@vue/shared': 3.5.41 + optionalDependencies: + '@babel/core': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@vue/babel-plugin-resolve-type@1.5.0(@babel/core@7.29.7)': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/parser': 7.29.8 + '@vue/compiler-sfc': 3.5.22 + transitivePeerDependencies: + - supports-color + + '@vue/compiler-core@3.5.22': + dependencies: + '@babel/parser': 7.29.8 + '@vue/shared': 3.5.22 + entities: 4.5.0 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-core@3.5.41': + dependencies: + '@babel/parser': 7.29.8 + '@vue/shared': 3.5.41 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.22': + dependencies: + '@vue/compiler-core': 3.5.22 + '@vue/shared': 3.5.22 + + '@vue/compiler-dom@3.5.41': + dependencies: + '@vue/compiler-core': 3.5.41 + '@vue/shared': 3.5.41 + + '@vue/compiler-sfc@3.5.22': + dependencies: + '@babel/parser': 7.29.8 + '@vue/compiler-core': 3.5.22 + '@vue/compiler-dom': 3.5.22 + '@vue/compiler-ssr': 3.5.22 + '@vue/shared': 3.5.22 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.26 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.22': + dependencies: + '@vue/compiler-dom': 3.5.22 + '@vue/shared': 3.5.22 + + '@vue/compiler-vue2@2.7.16': + dependencies: + de-indent: 1.0.2 + he: 1.2.0 + + '@vue/devtools-api@6.6.4': {} + + '@vue/devtools-api@7.7.10': + dependencies: + '@vue/devtools-kit': 7.7.10 + + '@vue/devtools-core@7.7.10(vite@7.1.7(@types/node@24.8.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.12)(yaml@2.9.0))(vue@3.5.22(typescript@5.6.3))': + dependencies: + '@vue/devtools-kit': 7.7.10 + '@vue/devtools-shared': 7.7.10 + mitt: 3.0.1 + nanoid: 5.1.16 + pathe: 2.0.3 + vite-hot-client: 2.2.0(vite@7.1.7(@types/node@24.8.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.12)(yaml@2.9.0)) + vue: 3.5.22(typescript@5.6.3) + transitivePeerDependencies: + - vite + + '@vue/devtools-kit@7.7.10': + dependencies: + '@vue/devtools-shared': 7.7.10 + birpc: 2.9.0 + hookable: 5.5.3 + mitt: 3.0.1 + perfect-debounce: 1.0.0 + speakingurl: 14.0.1 + superjson: 2.2.6 + + '@vue/devtools-shared@7.7.10': + dependencies: + rfdc: 1.4.1 + + '@vue/language-core@2.1.10(typescript@5.6.3)': + dependencies: + '@volar/language-core': 2.4.28 + '@vue/compiler-dom': 3.5.41 + '@vue/compiler-vue2': 2.7.16 + '@vue/shared': 3.5.41 + alien-signals: 0.2.2 + minimatch: 9.0.9 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + optionalDependencies: + typescript: 5.6.3 + + '@vue/reactivity@3.5.22': + dependencies: + '@vue/shared': 3.5.22 + + '@vue/runtime-core@3.5.22': + dependencies: + '@vue/reactivity': 3.5.22 + '@vue/shared': 3.5.22 + + '@vue/runtime-dom@3.5.22': + dependencies: + '@vue/reactivity': 3.5.22 + '@vue/runtime-core': 3.5.22 + '@vue/shared': 3.5.22 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.22(vue@3.5.22(typescript@5.6.3))': + dependencies: + '@vue/compiler-ssr': 3.5.22 + '@vue/shared': 3.5.22 + vue: 3.5.22(typescript@5.6.3) + + '@vue/shared@3.5.22': {} + + '@vue/shared@3.5.41': {} + + '@vueuse/core@13.9.0(vue@3.5.22(typescript@5.6.3))': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 13.9.0 + '@vueuse/shared': 13.9.0(vue@3.5.22(typescript@5.6.3)) + vue: 3.5.22(typescript@5.6.3) + + '@vueuse/core@9.13.0(vue@3.5.22(typescript@5.6.3))': + dependencies: + '@types/web-bluetooth': 0.0.16 + '@vueuse/metadata': 9.13.0 + '@vueuse/shared': 9.13.0(vue@3.5.22(typescript@5.6.3)) + vue-demi: 0.14.10(vue@3.5.22(typescript@5.6.3)) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + + '@vueuse/metadata@13.9.0': {} + + '@vueuse/metadata@9.13.0': {} + + '@vueuse/shared@13.9.0(vue@3.5.22(typescript@5.6.3))': + dependencies: + vue: 3.5.22(typescript@5.6.3) + + '@vueuse/shared@9.13.0(vue@3.5.22(typescript@5.6.3))': + dependencies: + vue-demi: 0.14.10(vue@3.5.22(typescript@5.6.3)) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + + '@wangeditor/basic-modules@1.1.7(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.18)(slate@0.72.8)(snabbdom@3.6.4))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.18)(slate@0.72.8)(snabbdom@3.6.4)': + dependencies: + '@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.18)(slate@0.72.8)(snabbdom@3.6.4) + dom7: 3.0.0 + is-url: 1.2.4 + lodash.throttle: 4.1.1 + nanoid: 3.3.18 + slate: 0.72.8 + snabbdom: 3.6.4 + + '@wangeditor/code-highlight@1.0.3(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.18)(slate@0.72.8)(snabbdom@3.6.4))(dom7@3.0.0)(slate@0.72.8)(snabbdom@3.6.4)': + dependencies: + '@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.18)(slate@0.72.8)(snabbdom@3.6.4) + dom7: 3.0.0 + prismjs: 1.30.0 + slate: 0.72.8 + snabbdom: 3.6.4 + + '@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.18)(slate@0.72.8)(snabbdom@3.6.4)': + dependencies: + '@types/event-emitter': 0.3.5 + '@uppy/core': 2.3.4 + '@uppy/xhr-upload': 2.1.3(@uppy/core@2.3.4) + dom7: 3.0.0 + event-emitter: 0.3.5 + html-void-elements: 2.0.1 + i18next: 20.6.1 + is-hotkey: 0.2.0 + lodash.camelcase: 4.3.0 + lodash.clonedeep: 4.5.0 + lodash.debounce: 4.0.8 + lodash.foreach: 4.5.0 + lodash.isequal: 4.5.0 + lodash.throttle: 4.1.1 + lodash.toarray: 4.4.0 + nanoid: 3.3.18 + scroll-into-view-if-needed: 2.2.31 + slate: 0.72.8 + slate-history: 0.66.0(slate@0.72.8) + snabbdom: 3.6.4 + + '@wangeditor/editor-for-vue@5.1.12(@wangeditor/editor@5.1.23)(vue@3.5.22(typescript@5.6.3))': + dependencies: + '@wangeditor/editor': 5.1.23 + vue: 3.5.22(typescript@5.6.3) + + '@wangeditor/editor@5.1.23': + dependencies: + '@uppy/core': 2.3.4 + '@uppy/xhr-upload': 2.1.3(@uppy/core@2.3.4) + '@wangeditor/basic-modules': 1.1.7(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.18)(slate@0.72.8)(snabbdom@3.6.4))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.18)(slate@0.72.8)(snabbdom@3.6.4) + '@wangeditor/code-highlight': 1.0.3(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.18)(slate@0.72.8)(snabbdom@3.6.4))(dom7@3.0.0)(slate@0.72.8)(snabbdom@3.6.4) + '@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.18)(slate@0.72.8)(snabbdom@3.6.4) + '@wangeditor/list-module': 1.0.5(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.18)(slate@0.72.8)(snabbdom@3.6.4))(dom7@3.0.0)(slate@0.72.8)(snabbdom@3.6.4) + '@wangeditor/table-module': 1.1.4(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.18)(slate@0.72.8)(snabbdom@3.6.4))(dom7@3.0.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(nanoid@3.3.18)(slate@0.72.8)(snabbdom@3.6.4) + '@wangeditor/upload-image-module': 1.0.2(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(@wangeditor/basic-modules@1.1.7(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.18)(slate@0.72.8)(snabbdom@3.6.4))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.18)(slate@0.72.8)(snabbdom@3.6.4))(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.18)(slate@0.72.8)(snabbdom@3.6.4))(dom7@3.0.0)(lodash.foreach@4.5.0)(slate@0.72.8)(snabbdom@3.6.4) + '@wangeditor/video-module': 1.1.4(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.18)(slate@0.72.8)(snabbdom@3.6.4))(dom7@3.0.0)(nanoid@3.3.18)(slate@0.72.8)(snabbdom@3.6.4) + dom7: 3.0.0 + is-hotkey: 0.2.0 + lodash.camelcase: 4.3.0 + lodash.clonedeep: 4.5.0 + lodash.debounce: 4.0.8 + lodash.foreach: 4.5.0 + lodash.isequal: 4.5.0 + lodash.throttle: 4.1.1 + lodash.toarray: 4.4.0 + nanoid: 3.3.18 + slate: 0.72.8 + snabbdom: 3.6.4 + transitivePeerDependencies: + - preact-render-to-string + + '@wangeditor/list-module@1.0.5(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.18)(slate@0.72.8)(snabbdom@3.6.4))(dom7@3.0.0)(slate@0.72.8)(snabbdom@3.6.4)': + dependencies: + '@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.18)(slate@0.72.8)(snabbdom@3.6.4) + dom7: 3.0.0 + slate: 0.72.8 + snabbdom: 3.6.4 + + '@wangeditor/table-module@1.1.4(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.18)(slate@0.72.8)(snabbdom@3.6.4))(dom7@3.0.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(nanoid@3.3.18)(slate@0.72.8)(snabbdom@3.6.4)': + dependencies: + '@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.18)(slate@0.72.8)(snabbdom@3.6.4) + dom7: 3.0.0 + lodash.isequal: 4.5.0 + lodash.throttle: 4.1.1 + nanoid: 3.3.18 + slate: 0.72.8 + snabbdom: 3.6.4 + + '@wangeditor/upload-image-module@1.0.2(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(@wangeditor/basic-modules@1.1.7(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.18)(slate@0.72.8)(snabbdom@3.6.4))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.18)(slate@0.72.8)(snabbdom@3.6.4))(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.18)(slate@0.72.8)(snabbdom@3.6.4))(dom7@3.0.0)(lodash.foreach@4.5.0)(slate@0.72.8)(snabbdom@3.6.4)': + dependencies: + '@uppy/core': 2.3.4 + '@uppy/xhr-upload': 2.1.3(@uppy/core@2.3.4) + '@wangeditor/basic-modules': 1.1.7(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.18)(slate@0.72.8)(snabbdom@3.6.4))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.18)(slate@0.72.8)(snabbdom@3.6.4) + '@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.18)(slate@0.72.8)(snabbdom@3.6.4) + dom7: 3.0.0 + lodash.foreach: 4.5.0 + slate: 0.72.8 + snabbdom: 3.6.4 + + '@wangeditor/video-module@1.1.4(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.18)(slate@0.72.8)(snabbdom@3.6.4))(dom7@3.0.0)(nanoid@3.3.18)(slate@0.72.8)(snabbdom@3.6.4)': + dependencies: + '@uppy/core': 2.3.4 + '@uppy/xhr-upload': 2.1.3(@uppy/core@2.3.4) + '@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.18)(slate@0.72.8)(snabbdom@3.6.4) + dom7: 3.0.0 + nanoid: 3.3.18 + slate: 0.72.8 + snabbdom: 3.6.4 + + JSONStream@1.3.5: + dependencies: + jsonparse: 1.3.1 + through: 2.3.8 + + acorn-jsx@5.3.2(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + + acorn@8.18.0: {} + + adler-32@1.3.1: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.5 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + alien-signals@0.2.2: {} + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@3.2.1: + dependencies: + color-convert: 1.9.3 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + argparse@2.0.1: {} + + argue-cli@3.1.0: + optional: true + + array-ify@1.0.0: {} + + array-union@2.1.0: {} + + astral-regex@2.0.0: {} + + async-validator@4.2.5: {} + + asynckit@0.4.0: {} + + at-least-node@1.0.0: {} + + axios@1.12.2: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.6 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + + balanced-match@1.0.2: {} + + balanced-match@2.0.0: {} + + balanced-match@4.0.4: {} + + base64-js@1.5.1: {} + + baseline-browser-mapping@2.11.13: {} + + binary-extensions@2.3.0: {} + + birpc@2.9.0: {} + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + boolbase@1.0.0: {} + + brace-expansion@1.1.18: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.1.4: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.8: + dependencies: + baseline-browser-mapping: 2.11.13 + caniuse-lite: 1.0.30001809 + electron-to-chromium: 1.5.403 + node-releases: 2.0.53 + update-browserslist-db: 1.3.0(browserslist@4.28.8) + + buffer-from@1.1.2: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + + cacheable@2.5.0: + dependencies: + '@cacheable/memory': 2.2.0 + '@cacheable/utils': 2.5.0 + hookified: 1.15.1 + keyv: 5.6.0 + qified: 0.10.1 + + cachedir@2.4.0: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001809: {} + + cfb@1.2.2: + dependencies: + adler-32: 1.3.1 + crc-32: 1.2.2 + + chalk@2.4.2: + dependencies: + ansi-styles: 3.2.1 + escape-string-regexp: 1.0.5 + supports-color: 5.5.0 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.6.2: {} + + chardet@2.2.0: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + chokidar@5.0.0: + dependencies: + readdirp: 5.1.1 + + cli-cursor@3.1.0: + dependencies: + restore-cursor: 3.1.0 + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-spinners@2.9.2: {} + + cli-truncate@4.0.0: + dependencies: + slice-ansi: 5.0.0 + string-width: 7.2.0 + + cli-width@3.0.0: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clone@1.0.4: {} + + codepage@1.15.0: {} + + color-convert@1.9.3: + dependencies: + color-name: 1.1.3 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.3: {} + + color-name@1.1.4: {} + + colord@2.9.3: {} + + colorette@2.0.20: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@13.1.0: {} + + commander@2.20.3: {} + + commitizen@4.3.2(@types/node@24.8.1)(typescript@5.6.3): + dependencies: + cachedir: 2.4.0 + cz-conventional-changelog: 3.3.0(@types/node@24.8.1)(typescript@5.6.3) + dedent: 0.7.0 + detect-indent: 6.1.0 + find-node-modules: 2.1.3 + find-root: 1.1.0 + fs-extra: 9.1.0 + glob: 7.2.3 + inquirer: 8.2.7(@types/node@24.8.1) + is-utf8: 0.2.1 + lodash: 4.18.1 + minimist: 1.2.8 + strip-bom: 4.0.0 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - '@types/node' + - typescript + + compare-func@2.0.0: + dependencies: + array-ify: 1.0.0 + dot-prop: 5.3.0 + + compute-scroll-into-view@1.0.20: {} + + concat-map@0.0.1: {} + + confbox@0.1.8: {} + + confbox@0.2.4: {} + + conventional-changelog-angular@7.0.0: + dependencies: + compare-func: 2.0.0 + + conventional-changelog-conventionalcommits@7.0.2: + dependencies: + compare-func: 2.0.0 + + conventional-commit-types@3.0.0: {} + + conventional-commits-parser@5.0.0: + dependencies: + JSONStream: 1.3.5 + is-text-path: 2.0.0 + meow: 12.1.1 + split2: 4.2.0 + + conventional-commits-parser@7.1.2: + dependencies: + '@simple-libs/stream-utils': 2.0.0 + argue-cli: 3.1.0 + optional: true + + convert-source-map@2.0.0: {} + + copy-anything@4.0.5: + dependencies: + is-what: 5.5.0 + + core-js@3.50.0: {} + + cosmiconfig-typescript-loader@6.3.0(@types/node@24.8.1)(cosmiconfig@9.0.2(typescript@5.6.3))(typescript@5.6.3): + dependencies: + '@types/node': 24.8.1 + cosmiconfig: 9.0.2(typescript@5.6.3) + jiti: 2.6.1 + typescript: 5.6.3 + + cosmiconfig@9.0.2(typescript@5.6.3): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.3.1 + parse-json: 5.2.0 + optionalDependencies: + typescript: 5.6.3 + + crc-32@1.2.2: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + crypto-js@4.2.0: {} + + css-functions-list@3.3.3: {} + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + cz-conventional-changelog@3.3.0(@types/node@24.8.1)(typescript@5.6.3): + dependencies: + chalk: 2.4.2 + commitizen: 4.3.2(@types/node@24.8.1)(typescript@5.6.3) + conventional-commit-types: 3.0.0 + lodash.map: 4.6.0 + longest: 2.0.1 + word-wrap: 1.2.5 + optionalDependencies: + '@commitlint/load': 21.2.0(@types/node@24.8.1)(typescript@5.6.3) + transitivePeerDependencies: + - '@types/node' + - typescript + + cz-git@1.13.1: {} + + d@1.0.2: + dependencies: + es5-ext: 0.10.64 + type: 2.7.3 + + danmu.js@1.2.1: + dependencies: + event-emitter: 0.3.5 + + dargs@8.1.0: {} + + dayjs@1.11.21: {} + + de-indent@1.0.2: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + dedent@0.7.0: {} + + deep-is@0.1.4: {} + + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + + defaults@1.0.4: + dependencies: + clone: 1.0.4 + + define-lazy-prop@2.0.0: {} + + define-lazy-prop@3.0.0: {} + + defu@6.1.7: {} + + delayed-stream@1.0.0: {} + + delegate@3.2.0: {} + + detect-file@1.0.0: {} + + detect-indent@6.1.0: {} + + detect-libc@2.1.2: {} + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + dom7@3.0.0: + dependencies: + ssr-window: 3.0.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dot-prop@5.3.0: + dependencies: + is-obj: 2.0.0 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + echarts@6.1.0: + dependencies: + tslib: 2.3.0 + zrender: 6.1.0 + + electron-to-chromium@1.5.403: {} + + element-plus@2.11.4(vue@3.5.22(typescript@5.6.3)): + dependencies: + '@ctrl/tinycolor': 3.6.1 + '@element-plus/icons-vue': 2.3.2(vue@3.5.22(typescript@5.6.3)) + '@floating-ui/dom': 1.8.0 + '@popperjs/core': '@sxzz/popperjs-es@2.11.8' + '@types/lodash': 4.17.25 + '@types/lodash-es': 4.17.12 + '@vueuse/core': 9.13.0(vue@3.5.22(typescript@5.6.3)) + async-validator: 4.2.5 + dayjs: 1.11.21 + escape-html: 1.0.3 + lodash: 4.18.1 + lodash-es: 4.18.1 + lodash-unified: 1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.18.1)(lodash@4.18.1) + memoize-one: 6.0.0 + normalize-wheel-es: 1.2.0 + vue: 3.5.22(typescript@5.6.3) + transitivePeerDependencies: + - '@vue/composition-api' + + emoji-regex@10.6.0: {} + + emoji-regex@8.0.0: {} + + enhanced-resolve@5.24.5: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + entities@4.5.0: {} + + entities@7.0.1: {} + + env-paths@2.2.1: {} + + environment@1.1.0: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + error-stack-parser-es@0.1.5: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@1.7.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + es-toolkit@1.50.0: + optional: true + + es5-ext@0.10.64: + dependencies: + es6-iterator: 2.0.3 + es6-symbol: 3.1.4 + esniff: 2.0.1 + next-tick: 1.1.0 + + es6-iterator@2.0.3: + dependencies: + d: 1.0.2 + es5-ext: 0.10.64 + es6-symbol: 3.1.4 + + es6-symbol@3.1.4: + dependencies: + d: 1.0.2 + ext: 1.7.0 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@1.0.5: {} + + escape-string-regexp@4.0.0: {} + + escape-string-regexp@5.0.0: {} + + eslint-config-prettier@9.1.2(eslint@9.39.5(jiti@2.7.0)): + dependencies: + eslint: 9.39.5(jiti@2.7.0) + + eslint-plugin-prettier@5.5.6(eslint-config-prettier@9.1.2(eslint@9.39.5(jiti@2.7.0)))(eslint@9.39.5(jiti@2.7.0))(prettier@3.9.6): + dependencies: + eslint: 9.39.5(jiti@2.7.0) + prettier: 3.9.6 + prettier-linter-helpers: 1.0.1 + synckit: 0.11.13 + optionalDependencies: + eslint-config-prettier: 9.1.2(eslint@9.39.5(jiti@2.7.0)) + + eslint-plugin-vue@9.33.0(eslint@9.39.5(jiti@2.7.0)): + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)) + eslint: 9.39.5(jiti@2.7.0) + globals: 13.24.0 + natural-compare: 1.4.0 + nth-check: 2.1.1 + postcss-selector-parser: 6.1.4 + semver: 7.8.5 + vue-eslint-parser: 9.4.3(eslint@9.39.5(jiti@2.7.0)) + xml-name-validator: 4.0.0 + transitivePeerDependencies: + - supports-color + + eslint-scope@7.2.2: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.5(jiti@2.7.0): + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(jiti@2.7.0)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.6 + '@eslint/js': 9.39.5 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.7.0 + transitivePeerDependencies: + - supports-color + + esniff@2.0.1: + dependencies: + d: 1.0.2 + es5-ext: 0.10.64 + event-emitter: 0.3.5 + type: 2.7.3 + + espree@10.4.0: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + eslint-visitor-keys: 4.2.1 + + espree@9.6.1: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + eslint-visitor-keys: 3.4.3 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + event-emitter@0.3.5: + dependencies: + d: 1.0.2 + es5-ext: 0.10.64 + + eventemitter3@4.0.7: {} + + eventemitter3@5.0.4: {} + + execa@8.0.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 8.0.1 + human-signals: 5.0.0 + is-stream: 3.0.0 + merge-stream: 2.0.0 + npm-run-path: 5.3.0 + onetime: 6.0.0 + signal-exit: 4.1.0 + strip-final-newline: 3.0.0 + + execa@9.6.1: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + cross-spawn: 7.0.6 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.0 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + yoctocolors: 2.2.0 + + expand-tilde@2.0.2: + dependencies: + homedir-polyfill: 1.0.3 + + exsolve@1.1.1: {} + + ext@1.7.0: + dependencies: + type: 2.7.3 + + fast-deep-equal@3.1.3: {} + + fast-diff@1.3.0: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-uri@3.1.5: {} + + fastest-levenshtein@1.0.16: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + figures@3.2.0: + dependencies: + escape-string-regexp: 1.0.5 + + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + + file-entry-cache@11.1.5: + dependencies: + flat-cache: 6.1.23 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + file-saver@2.0.5: {} + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-node-modules@2.1.3: + dependencies: + findup-sync: 4.0.0 + merge: 2.1.1 + + find-root@1.1.0: {} + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + find-up@7.0.0: + dependencies: + locate-path: 7.2.0 + path-exists: 5.0.0 + unicorn-magic: 0.1.0 + + findup-sync@4.0.0: + dependencies: + detect-file: 1.0.0 + is-glob: 4.0.3 + micromatch: 4.0.8 + resolve-dir: 1.0.1 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.4 + keyv: 4.5.4 + + flat-cache@6.1.23: + dependencies: + cacheable: 2.5.0 + flatted: 3.4.4 + hookified: 1.15.1 + + flatted@3.4.4: {} + + follow-redirects@1.16.0: {} + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + frac@1.1.2: {} + + fs-extra@10.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs-extra@11.4.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs-extra@9.1.0: + dependencies: + at-least-node: 1.0.0 + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-east-asian-width@1.6.0: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-stream@8.0.1: {} + + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + + git-raw-commits@4.0.0: + dependencies: + dargs: 8.1.0 + meow: 12.1.1 + split2: 4.2.0 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + global-directory@4.0.1: + dependencies: + ini: 4.1.1 + + global-directory@5.0.0: + dependencies: + ini: 6.0.0 + optional: true + + global-modules@1.0.0: + dependencies: + global-prefix: 1.0.2 + is-windows: 1.0.2 + resolve-dir: 1.0.1 + + global-modules@2.0.0: + dependencies: + global-prefix: 3.0.0 + + global-prefix@1.0.2: + dependencies: + expand-tilde: 2.0.2 + homedir-polyfill: 1.0.3 + ini: 1.3.8 + is-windows: 1.0.2 + which: 1.3.1 + + global-prefix@3.0.0: + dependencies: + ini: 1.3.8 + kind-of: 6.0.3 + which: 1.3.1 + + globals@13.24.0: + dependencies: + type-fest: 0.20.2 + + globals@14.0.0: {} + + globals@15.15.0: {} + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + + globjoin@0.1.4: {} + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + has-flag@3.0.0: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hashery@1.5.1: + dependencies: + hookified: 1.15.1 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + he@1.2.0: {} + + highlight.js@11.11.1: {} + + homedir-polyfill@1.0.3: + dependencies: + parse-passwd: 1.0.0 + + hookable@5.5.3: {} + + hookified@1.15.1: {} + + hookified@2.2.0: {} + + html-tags@3.3.1: {} + + html-void-elements@2.0.1: {} + + htmlparser2@8.0.2: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 4.5.0 + + human-signals@5.0.0: {} + + human-signals@8.0.1: {} + + husky@9.1.7: {} + + i18next@20.6.1: + dependencies: + '@babel/runtime': 7.29.7 + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + ignore@5.3.2: {} + + ignore@7.0.6: {} + + immer@9.0.21: {} + + immutable@5.1.9: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-meta-resolve@4.2.0: {} + + imurmurhash@0.1.4: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + ini@1.3.8: {} + + ini@4.1.1: {} + + ini@6.0.0: + optional: true + + inquirer@8.2.7(@types/node@24.8.1): + dependencies: + '@inquirer/external-editor': 1.0.3(@types/node@24.8.1) + ansi-escapes: 4.3.2 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-width: 3.0.0 + figures: 3.2.0 + lodash: 4.18.1 + mute-stream: 0.0.8 + ora: 5.4.1 + run-async: 2.4.1 + rxjs: 7.8.2 + string-width: 4.2.3 + strip-ansi: 6.0.1 + through: 2.3.8 + wrap-ansi: 6.2.0 + transitivePeerDependencies: + - '@types/node' + + is-arrayish@0.2.1: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-docker@2.2.1: {} + + is-docker@3.0.0: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-fullwidth-code-point@4.0.0: {} + + is-fullwidth-code-point@5.1.0: + dependencies: + get-east-asian-width: 1.6.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-hotkey@0.2.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-interactive@1.0.0: {} + + is-number@7.0.0: {} + + is-obj@2.0.0: {} + + is-plain-obj@4.1.0: {} + + is-plain-object@5.0.0: {} + + is-stream@3.0.0: {} + + is-stream@4.0.1: {} + + is-text-path@2.0.0: + dependencies: + text-extensions: 2.4.0 + + is-unicode-supported@0.1.0: {} + + is-unicode-supported@2.1.0: {} + + is-url@1.2.4: {} + + is-utf8@0.2.1: {} + + is-what@5.5.0: {} + + is-windows@1.0.2: {} + + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + + isexe@2.0.0: {} + + jiti@2.6.1: {} + + jiti@2.7.0: {} + + js-tokens@4.0.0: {} + + js-tokens@9.0.1: {} + + js-yaml@4.3.1: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + jsonparse@1.3.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + keyv@5.6.0: + dependencies: + '@keyv/serialize': 1.1.1 + + kind-of@6.0.3: {} + + known-css-properties@0.37.0: {} + + kolorist@1.8.0: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + lint-staged@15.5.2: + dependencies: + chalk: 5.6.2 + commander: 13.1.0 + debug: 4.4.3 + execa: 8.0.1 + lilconfig: 3.1.3 + listr2: 8.3.3 + micromatch: 4.0.8 + pidtree: 0.6.1 + string-argv: 0.3.2 + yaml: 2.9.0 + transitivePeerDependencies: + - supports-color + + listr2@8.3.3: + dependencies: + cli-truncate: 4.0.0 + colorette: 2.0.20 + eventemitter3: 5.0.4 + log-update: 6.1.0 + rfdc: 1.4.1 + wrap-ansi: 9.0.2 + + local-pkg@1.2.1: + dependencies: + mlly: 1.8.2 + pkg-types: 2.3.1 + quansync: 0.2.11 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + locate-path@7.2.0: + dependencies: + p-locate: 6.0.0 + + lodash-es@4.18.1: {} + + lodash-unified@1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.18.1)(lodash@4.18.1): + dependencies: + '@types/lodash-es': 4.17.12 + lodash: 4.18.1 + lodash-es: 4.18.1 + + lodash.camelcase@4.3.0: {} + + lodash.clonedeep@4.5.0: {} + + lodash.debounce@4.0.8: {} + + lodash.foreach@4.5.0: {} + + lodash.isequal@4.5.0: {} + + lodash.isplainobject@4.0.6: {} + + lodash.kebabcase@4.1.1: {} + + lodash.map@4.6.0: {} + + lodash.merge@4.6.2: {} + + lodash.mergewith@4.6.2: {} + + lodash.snakecase@4.1.1: {} + + lodash.startcase@4.4.0: {} + + lodash.throttle@4.1.1: {} + + lodash.toarray@4.4.0: {} + + lodash.truncate@4.4.2: {} + + lodash.uniq@4.5.0: {} + + lodash.upperfirst@4.3.1: {} + + lodash@4.18.1: {} + + log-symbols@4.1.0: + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + + log-update@6.1.0: + dependencies: + ansi-escapes: 7.3.0 + cli-cursor: 5.0.0 + slice-ansi: 7.1.2 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 + + longest@2.0.1: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + math-intrinsics@1.1.0: {} + + mathml-tag-names@2.1.3: {} + + mdn-data@2.27.1: {} + + mdn-data@2.31.0: {} + + memoize-one@6.0.0: {} + + meow@12.1.1: {} + + meow@13.2.0: {} + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + merge@2.1.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.52.0: {} + + mime-match@1.0.2: + dependencies: + wildcard: 1.1.2 + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mimic-fn@2.1.0: {} + + mimic-fn@4.0.0: {} + + mimic-function@5.0.1: {} + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.18 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.4 + + minimist@1.2.8: {} + + mitt@3.0.1: {} + + mlly@1.8.2: + dependencies: + acorn: 8.18.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + + mrmime@2.0.1: {} + + ms@2.1.3: {} + + muggle-string@0.4.1: {} + + mute-stream@0.0.8: {} + + namespace-emitter@2.0.1: {} + + nanoid@3.3.18: {} + + nanoid@5.1.16: {} + + natural-compare@1.4.0: {} + + next-tick@1.1.0: {} + + node-addon-api@7.1.1: + optional: true + + node-releases@2.0.53: {} + + normalize-path@3.0.0: {} + + normalize-wheel-es@1.2.0: {} + + npm-run-path@5.3.0: + dependencies: + path-key: 4.0.0 + + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + + nprogress@0.2.0: {} + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + ohash@2.0.11: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + onetime@6.0.0: + dependencies: + mimic-fn: 4.0.0 + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + open@10.2.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-inside-container: 1.0.0 + wsl-utils: 0.1.0 + + open@8.4.2: + dependencies: + define-lazy-prop: 2.0.0 + is-docker: 2.2.1 + is-wsl: 2.2.0 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + ora@5.4.1: + dependencies: + bl: 4.1.0 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-spinners: 2.9.2 + is-interactive: 1.0.0 + is-unicode-supported: 0.1.0 + log-symbols: 4.1.0 + strip-ansi: 6.0.1 + wcwidth: 1.0.1 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-limit@4.0.0: + dependencies: + yocto-queue: 1.2.2 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-locate@6.0.0: + dependencies: + p-limit: 4.0.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.7 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parse-ms@4.0.0: {} + + parse-passwd@1.0.0: {} + + path-browserify@1.0.1: {} + + path-exists@4.0.0: {} + + path-exists@5.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-key@4.0.0: {} + + path-type@4.0.0: {} + + pathe@2.0.3: {} + + perfect-debounce@1.0.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.5: {} + + pidtree@0.6.1: {} + + pinia-plugin-persistedstate@4.7.1(pinia@3.0.4(typescript@5.6.3)(vue@3.5.22(typescript@5.6.3))): + dependencies: + defu: 6.1.7 + optionalDependencies: + pinia: 3.0.4(typescript@5.6.3)(vue@3.5.22(typescript@5.6.3)) + + pinia@3.0.4(typescript@5.6.3)(vue@3.5.22(typescript@5.6.3)): + dependencies: + '@vue/devtools-api': 7.7.10 + vue: 3.5.22(typescript@5.6.3) + optionalDependencies: + typescript: 5.6.3 + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + pkg-types@2.3.1: + dependencies: + confbox: 0.2.4 + exsolve: 1.1.1 + pathe: 2.0.3 + + postcss-html@1.8.1: + dependencies: + htmlparser2: 8.0.2 + js-tokens: 9.0.1 + postcss: 8.5.26 + postcss-safe-parser: 6.0.0(postcss@8.5.26) + + postcss-media-query-parser@0.2.3: {} + + postcss-resolve-nested-selector@0.1.6: {} + + postcss-safe-parser@6.0.0(postcss@8.5.26): + dependencies: + postcss: 8.5.26 + + postcss-safe-parser@7.0.1(postcss@8.5.26): + dependencies: + postcss: 8.5.26 + + postcss-scss@4.0.9(postcss@8.5.26): + dependencies: + postcss: 8.5.26 + + postcss-selector-parser@6.1.4: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-selector-parser@7.1.5: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-sorting@8.0.2(postcss@8.5.26): + dependencies: + postcss: 8.5.26 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + preact@10.29.8: {} + + prelude-ls@1.2.1: {} + + prettier-linter-helpers@1.0.1: + dependencies: + fast-diff: 1.3.0 + + prettier@3.9.6: {} + + pretty-ms@9.3.0: + dependencies: + parse-ms: 4.0.0 + + prismjs@1.30.0: {} + + proxy-from-env@1.1.0: {} + + punycode@2.3.1: {} + + qified@0.10.1: + dependencies: + hookified: 2.2.0 + + qrcode.vue@3.10.0(vue@3.5.22(typescript@5.6.3)): + dependencies: + vue: 3.5.22(typescript@5.6.3) + + quansync@0.2.11: {} + + queue-microtask@1.2.3: {} + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + + readdirp@5.1.1: {} + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + resolve-dir@1.0.1: + dependencies: + expand-tilde: 2.0.2 + global-modules: 1.0.0 + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + restore-cursor@3.1.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + reusify@1.1.0: {} + + rfdc@1.4.1: {} + + rollup-plugin-visualizer@5.14.0(rollup@4.62.4): + dependencies: + open: 8.4.2 + picomatch: 4.0.5 + source-map: 0.7.6 + yargs: 17.7.3 + optionalDependencies: + rollup: 4.62.4 + + rollup@4.62.4: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.62.4 + '@rollup/rollup-android-arm64': 4.62.4 + '@rollup/rollup-darwin-arm64': 4.62.4 + '@rollup/rollup-darwin-x64': 4.62.4 + '@rollup/rollup-freebsd-arm64': 4.62.4 + '@rollup/rollup-freebsd-x64': 4.62.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.4 + '@rollup/rollup-linux-arm-musleabihf': 4.62.4 + '@rollup/rollup-linux-arm64-gnu': 4.62.4 + '@rollup/rollup-linux-arm64-musl': 4.62.4 + '@rollup/rollup-linux-loong64-gnu': 4.62.4 + '@rollup/rollup-linux-loong64-musl': 4.62.4 + '@rollup/rollup-linux-ppc64-gnu': 4.62.4 + '@rollup/rollup-linux-ppc64-musl': 4.62.4 + '@rollup/rollup-linux-riscv64-gnu': 4.62.4 + '@rollup/rollup-linux-riscv64-musl': 4.62.4 + '@rollup/rollup-linux-s390x-gnu': 4.62.4 + '@rollup/rollup-linux-x64-gnu': 4.62.4 + '@rollup/rollup-linux-x64-musl': 4.62.4 + '@rollup/rollup-openbsd-x64': 4.62.4 + '@rollup/rollup-openharmony-arm64': 4.62.4 + '@rollup/rollup-win32-arm64-msvc': 4.62.4 + '@rollup/rollup-win32-ia32-msvc': 4.62.4 + '@rollup/rollup-win32-x64-gnu': 4.62.4 + '@rollup/rollup-win32-x64-msvc': 4.62.4 + fsevents: 2.3.3 + + run-applescript@7.1.0: {} + + run-async@2.4.1: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + sass@1.102.0: + dependencies: + chokidar: 5.0.0 + immutable: 5.1.9 + source-map-js: 1.2.1 + optionalDependencies: + '@parcel/watcher': 2.6.0 + + scroll-into-view-if-needed@2.2.31: + dependencies: + compute-scroll-into-view: 1.0.20 + + scule@1.3.0: {} + + semver@6.3.1: {} + + semver@7.8.5: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + + slash@3.0.0: {} + + slate-history@0.66.0(slate@0.72.8): + dependencies: + is-plain-object: 5.0.0 + slate: 0.72.8 + + slate@0.72.8: + dependencies: + immer: 9.0.21 + is-plain-object: 5.0.0 + tiny-warning: 1.0.3 + + slice-ansi@4.0.0: + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + + slice-ansi@5.0.0: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 4.0.0 + + slice-ansi@7.1.2: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 + + snabbdom@3.6.4: {} + + source-map-js@1.2.1: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + source-map@0.7.6: {} + + speakingurl@14.0.1: {} + + split2@4.2.0: {} + + ssf@0.11.2: + dependencies: + frac: 1.1.2 + + ssr-window@3.0.0: {} + + string-argv@0.3.2: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-bom@4.0.0: {} + + strip-final-newline@3.0.0: {} + + strip-final-newline@4.0.0: {} + + strip-json-comments@3.1.1: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + stylelint-config-html@1.1.0(postcss-html@1.8.1)(stylelint@16.26.1(typescript@5.6.3)): + dependencies: + postcss-html: 1.8.1 + stylelint: 16.26.1(typescript@5.6.3) + + stylelint-config-recess-order@4.6.0(stylelint@16.26.1(typescript@5.6.3)): + dependencies: + stylelint: 16.26.1(typescript@5.6.3) + stylelint-order: 6.0.4(stylelint@16.26.1(typescript@5.6.3)) + + stylelint-config-recommended-scss@14.1.0(postcss@8.5.26)(stylelint@16.26.1(typescript@5.6.3)): + dependencies: + postcss-scss: 4.0.9(postcss@8.5.26) + stylelint: 16.26.1(typescript@5.6.3) + stylelint-config-recommended: 14.0.1(stylelint@16.26.1(typescript@5.6.3)) + stylelint-scss: 6.14.0(stylelint@16.26.1(typescript@5.6.3)) + optionalDependencies: + postcss: 8.5.26 + + stylelint-config-recommended-vue@1.6.1(postcss-html@1.8.1)(stylelint@16.26.1(typescript@5.6.3)): + dependencies: + postcss-html: 1.8.1 + semver: 7.8.5 + stylelint: 16.26.1(typescript@5.6.3) + stylelint-config-html: 1.1.0(postcss-html@1.8.1)(stylelint@16.26.1(typescript@5.6.3)) + stylelint-config-recommended: 18.0.0(stylelint@16.26.1(typescript@5.6.3)) + + stylelint-config-recommended@14.0.1(stylelint@16.26.1(typescript@5.6.3)): + dependencies: + stylelint: 16.26.1(typescript@5.6.3) + + stylelint-config-recommended@18.0.0(stylelint@16.26.1(typescript@5.6.3)): + dependencies: + stylelint: 16.26.1(typescript@5.6.3) + + stylelint-config-standard@36.0.1(stylelint@16.26.1(typescript@5.6.3)): + dependencies: + stylelint: 16.26.1(typescript@5.6.3) + stylelint-config-recommended: 14.0.1(stylelint@16.26.1(typescript@5.6.3)) + + stylelint-order@6.0.4(stylelint@16.26.1(typescript@5.6.3)): + dependencies: + postcss: 8.5.26 + postcss-sorting: 8.0.2(postcss@8.5.26) + stylelint: 16.26.1(typescript@5.6.3) + + stylelint-scss@6.14.0(stylelint@16.26.1(typescript@5.6.3)): + dependencies: + css-tree: 3.2.1 + is-plain-object: 5.0.0 + known-css-properties: 0.37.0 + mdn-data: 2.31.0 + postcss-media-query-parser: 0.2.3 + postcss-resolve-nested-selector: 0.1.6 + postcss-selector-parser: 7.1.5 + postcss-value-parser: 4.2.0 + stylelint: 16.26.1(typescript@5.6.3) + + stylelint@16.26.1(typescript@5.6.3): + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-syntax-patches-for-csstree': 1.1.7(css-tree@3.2.1) + '@csstools/css-tokenizer': 3.0.4 + '@csstools/media-query-list-parser': 4.0.3(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.5) + '@dual-bundle/import-meta-resolve': 4.2.1 + balanced-match: 2.0.0 + colord: 2.9.3 + cosmiconfig: 9.0.2(typescript@5.6.3) + css-functions-list: 3.3.3 + css-tree: 3.2.1 + debug: 4.4.3 + fast-glob: 3.3.3 + fastest-levenshtein: 1.0.16 + file-entry-cache: 11.1.5 + global-modules: 2.0.0 + globby: 11.1.0 + globjoin: 0.1.4 + html-tags: 3.3.1 + ignore: 7.0.6 + imurmurhash: 0.1.4 + is-plain-object: 5.0.0 + known-css-properties: 0.37.0 + mathml-tag-names: 2.1.3 + meow: 13.2.0 + micromatch: 4.0.8 + normalize-path: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.26 + postcss-resolve-nested-selector: 0.1.6 + postcss-safe-parser: 7.0.1(postcss@8.5.26) + postcss-selector-parser: 7.1.5 + postcss-value-parser: 4.2.0 + resolve-from: 5.0.0 + string-width: 4.2.3 + supports-hyperlinks: 3.2.0 + svg-tags: 1.0.0 + table: 6.9.0 + write-file-atomic: 5.0.1 + transitivePeerDependencies: + - supports-color + - typescript + + superjson@2.2.6: + dependencies: + copy-anything: 4.0.5 + + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-hyperlinks@3.2.0: + dependencies: + has-flag: 4.0.0 + supports-color: 7.2.0 + + svg-tags@1.0.0: {} + + synckit@0.11.13: + dependencies: + '@pkgr/core': 0.3.6 + + table@6.9.0: + dependencies: + ajv: 8.20.0 + lodash.truncate: 4.4.2 + slice-ansi: 4.0.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + tailwindcss@4.3.3: {} + + tapable@2.3.3: {} + + terser@5.49.2: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.18.0 + commander: 2.20.3 + source-map-support: 0.5.21 + + text-extensions@2.4.0: {} + + through@2.3.8: {} + + tiny-warning@1.0.3: {} + + tinyexec@1.3.0: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + totalist@3.0.1: {} + + ts-api-utils@2.5.0(typescript@5.6.3): + dependencies: + typescript: 5.6.3 + + tslib@2.3.0: {} + + tslib@2.8.1: {} + + tsx@4.23.12: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@0.20.2: {} + + type-fest@0.21.3: {} + + type@2.7.3: {} + + typescript-eslint@8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.6.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.66.0(@typescript-eslint/parser@8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.6.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.6.3) + '@typescript-eslint/parser': 8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.6.3) + '@typescript-eslint/typescript-estree': 8.66.0(typescript@5.6.3) + '@typescript-eslint/utils': 8.66.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.6.3) + eslint: 9.39.5(jiti@2.7.0) + typescript: 5.6.3 + transitivePeerDependencies: + - supports-color + + typescript@5.6.3: {} + + ufo@1.6.4: {} + + undici-types@7.14.0: {} + + unicorn-magic@0.1.0: {} + + unicorn-magic@0.3.0: {} + + unimport@5.7.0: + dependencies: + acorn: 8.18.0 + escape-string-regexp: 5.0.0 + estree-walker: 3.0.3 + local-pkg: 1.2.1 + magic-string: 0.30.21 + mlly: 1.8.2 + pathe: 2.0.3 + picomatch: 4.0.5 + pkg-types: 2.3.1 + scule: 1.3.0 + strip-literal: 3.1.0 + tinyglobby: 0.2.17 + unplugin: 2.3.11 + unplugin-utils: 0.3.2 + + universalify@2.0.1: {} + + unplugin-auto-import@20.3.0(@vueuse/core@13.9.0(vue@3.5.22(typescript@5.6.3))): + dependencies: + local-pkg: 1.2.1 + magic-string: 0.30.21 + picomatch: 4.0.5 + unimport: 5.7.0 + unplugin: 2.3.11 + unplugin-utils: 0.3.2 + optionalDependencies: + '@vueuse/core': 13.9.0(vue@3.5.22(typescript@5.6.3)) + + unplugin-element-plus@0.10.0: + dependencies: + es-module-lexer: 1.7.0 + magic-string: 0.30.21 + unplugin: 2.3.11 + unplugin-utils: 0.2.5 + + unplugin-utils@0.2.5: + dependencies: + pathe: 2.0.3 + picomatch: 4.0.5 + + unplugin-utils@0.3.2: + dependencies: + pathe: 2.0.3 + picomatch: 4.0.5 + + unplugin-vue-components@29.2.0(@babel/parser@7.29.8)(vue@3.5.22(typescript@5.6.3)): + dependencies: + chokidar: 3.6.0 + debug: 4.4.3 + local-pkg: 1.2.1 + magic-string: 0.30.21 + mlly: 1.8.2 + tinyglobby: 0.2.17 + unplugin: 2.3.11 + unplugin-utils: 0.3.2 + vue: 3.5.22(typescript@5.6.3) + optionalDependencies: + '@babel/parser': 7.29.8 + transitivePeerDependencies: + - supports-color + + unplugin@2.3.11: + dependencies: + '@jridgewell/remapping': 2.3.5 + acorn: 8.18.0 + picomatch: 4.0.5 + webpack-virtual-modules: 0.6.2 + + update-browserslist-db@1.3.0(browserslist@4.28.8): + dependencies: + browserslist: 4.28.8 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + util-deprecate@1.0.2: {} + + vite-hot-client@2.2.0(vite@7.1.7(@types/node@24.8.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.12)(yaml@2.9.0)): + dependencies: + vite: 7.1.7(@types/node@24.8.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.12)(yaml@2.9.0) + + vite-plugin-compression@0.5.1(vite@7.1.7(@types/node@24.8.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.12)(yaml@2.9.0)): + dependencies: + chalk: 4.1.2 + debug: 4.4.3 + fs-extra: 10.1.0 + vite: 7.1.7(@types/node@24.8.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.12)(yaml@2.9.0) + transitivePeerDependencies: + - supports-color + + vite-plugin-inspect@0.8.9(rollup@4.62.4)(vite@7.1.7(@types/node@24.8.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.12)(yaml@2.9.0)): + dependencies: + '@antfu/utils': 0.7.10 + '@rollup/pluginutils': 5.4.0(rollup@4.62.4) + debug: 4.4.3 + error-stack-parser-es: 0.1.5 + fs-extra: 11.4.0 + open: 10.2.0 + perfect-debounce: 1.0.0 + picocolors: 1.1.1 + sirv: 3.0.2 + vite: 7.1.7(@types/node@24.8.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.12)(yaml@2.9.0) + transitivePeerDependencies: + - rollup + - supports-color + + vite-plugin-vue-devtools@7.7.10(rollup@4.62.4)(vite@7.1.7(@types/node@24.8.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.12)(yaml@2.9.0))(vue@3.5.22(typescript@5.6.3)): + dependencies: + '@vue/devtools-core': 7.7.10(vite@7.1.7(@types/node@24.8.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.12)(yaml@2.9.0))(vue@3.5.22(typescript@5.6.3)) + '@vue/devtools-kit': 7.7.10 + '@vue/devtools-shared': 7.7.10 + execa: 9.6.1 + sirv: 3.0.2 + vite: 7.1.7(@types/node@24.8.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.12)(yaml@2.9.0) + vite-plugin-inspect: 0.8.9(rollup@4.62.4)(vite@7.1.7(@types/node@24.8.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.12)(yaml@2.9.0)) + vite-plugin-vue-inspector: 5.4.0(vite@7.1.7(@types/node@24.8.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.12)(yaml@2.9.0)) + transitivePeerDependencies: + - '@nuxt/kit' + - rollup + - supports-color + - vue + + vite-plugin-vue-inspector@5.4.0(vite@7.1.7(@types/node@24.8.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.12)(yaml@2.9.0)): + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-proposal-decorators': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-import-attributes': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.7) + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7) + '@vue/babel-plugin-jsx': 1.5.0(@babel/core@7.29.7) + '@vue/compiler-dom': 3.5.41 + kolorist: 1.8.0 + magic-string: 0.30.21 + vite: 7.1.7(@types/node@24.8.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.12)(yaml@2.9.0) + transitivePeerDependencies: + - supports-color + + vite@7.1.7(@types/node@24.8.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.102.0)(terser@5.49.2)(tsx@4.23.12)(yaml@2.9.0): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.26 + rollup: 4.62.4 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.8.1 + fsevents: 2.3.3 + jiti: 2.7.0 + lightningcss: 1.32.0 + sass: 1.102.0 + terser: 5.49.2 + tsx: 4.23.12 + yaml: 2.9.0 + + vscode-uri@3.1.0: {} + + vue-demi@0.14.10(vue@3.5.22(typescript@5.6.3)): + dependencies: + vue: 3.5.22(typescript@5.6.3) + + vue-draggable-plus@0.6.1(@types/sortablejs@1.15.9): + dependencies: + '@types/sortablejs': 1.15.9 + + vue-eslint-parser@9.4.3(eslint@9.39.5(jiti@2.7.0)): + dependencies: + debug: 4.4.3 + eslint: 9.39.5(jiti@2.7.0) + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + esquery: 1.7.0 + lodash: 4.18.1 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + + vue-i18n@11.4.8(vue@3.5.22(typescript@5.6.3)): + dependencies: + '@intlify/core-base': 11.4.8 + '@intlify/devtools-types': 11.4.8 + '@intlify/shared': 11.4.8 + '@vue/devtools-api': 6.6.4 + vue: 3.5.22(typescript@5.6.3) + + vue-i18n@9.14.5(vue@3.5.22(typescript@5.6.3)): + dependencies: + '@intlify/core-base': 9.14.5 + '@intlify/shared': 9.14.5 + '@vue/devtools-api': 6.6.4 + vue: 3.5.22(typescript@5.6.3) + + vue-img-cutter@3.1.1(typescript@5.6.3): + dependencies: + core-js: 3.50.0 + vue: 3.5.22(typescript@5.6.3) + vue-i18n: 11.4.8(vue@3.5.22(typescript@5.6.3)) + transitivePeerDependencies: + - typescript + + vue-router@4.6.4(vue@3.5.22(typescript@5.6.3)): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.22(typescript@5.6.3) + + vue-tsc@2.1.10(typescript@5.6.3): + dependencies: + '@volar/typescript': 2.4.28 + '@vue/language-core': 2.1.10(typescript@5.6.3) + semver: 7.8.5 + typescript: 5.6.3 + + vue@3.5.22(typescript@5.6.3): + dependencies: + '@vue/compiler-dom': 3.5.22 + '@vue/compiler-sfc': 3.5.22 + '@vue/runtime-dom': 3.5.22 + '@vue/server-renderer': 3.5.22(vue@3.5.22(typescript@5.6.3)) + '@vue/shared': 3.5.22 + optionalDependencies: + typescript: 5.6.3 + + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + + webpack-virtual-modules@0.6.2: {} + + which@1.3.1: + dependencies: + isexe: 2.0.0 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wildcard@1.1.2: {} + + wmf@1.0.2: {} + + word-wrap@1.2.5: {} + + word@0.3.0: {} + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + wrappy@1.0.2: {} + + write-file-atomic@5.0.1: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 4.1.0 + + wsl-utils@0.1.0: + dependencies: + is-wsl: 3.1.1 + + xgplayer-subtitles@3.0.26(core-js@3.50.0): + dependencies: + core-js: 3.50.0 + eventemitter3: 4.0.7 + + xgplayer@3.0.26(core-js@3.50.0): + dependencies: + core-js: 3.50.0 + danmu.js: 1.2.1 + delegate: 3.2.0 + eventemitter3: 4.0.7 + xgplayer-subtitles: 3.0.26(core-js@3.50.0) + + xlsx@0.18.5: + dependencies: + adler-32: 1.3.1 + cfb: 1.2.2 + codepage: 1.15.0 + crc-32: 1.2.2 + ssf: 0.11.2 + wmf: 1.0.2 + word: 0.3.0 + + xml-name-validator@4.0.0: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yaml@2.9.0: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yocto-queue@0.1.0: {} + + yocto-queue@1.2.2: {} + + yoctocolors@2.2.0: {} + + zrender@6.1.0: + dependencies: + tslib: 2.3.0 diff --git a/web/pnpm-workspace.yaml b/web/pnpm-workspace.yaml new file mode 100644 index 0000000..cc61e60 --- /dev/null +++ b/web/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +packages: + - apps/* + - packages/* +

yjkcOw&(>Y8mz^;5!^gv~u``#^d4Gx|bp z2-7XGTS)^qhf7!w*}02Gj=X#OF@kb6Jt3zqO+zHUi|*y6rT)zw2fWpG5HATLaEHBA?3FMMUbKN! z#PTx@LRbQWSrGV>{xDg;Y&mz%O`b0->jK)iZ$3px^C{9_{ufhP&J*iK#sD|4mEY65 zo_)#pd^kuy=pjs7EPZ($mXiA4=KVU~KK%qdoT$y{+sF>raM|L3$68%+)I;OfUJTDx z1U>Y4LD0a6u&j$I!_&)rdJZMen2S(UBFCq5k=S`7fSS^^=A=x(J*#i zKz~~J*K*g#S=`a;a@2hrk*YK539RJvg8R}@o;E{PoRRvhvd?og-ce(w(-^A772(B( zwL2heplT-AQ4b4Hp0}RR@qK_+3oDXt)Qd>>d`CW+gt~#ZDk|uueQ--tz-xBiqRf(p zSffC>me&9Q_wx4x z#)Qrvl1BIk!nP|C;SW46WI9zBNIC596gbh(Um)?0RRV@0Qr)AD2Mixv&BN8B^nL0xEYRQ+MWL)?U}O`I{8z}8Q? zqG61P(MtfE8g1vOCeR!PQzyS$q+)y+-wzSkduvQVFUmM|rHAQycjo0CzZDu)rXQXWDzDqA%HPG7qYby8MxfS z>2kznxxt&D$SV6{@UgaOVu)ace0YO7ty}dTMy^_LW@vip+jR!lQOLYfcUR}c^Y@!Jnct35&-L@u9|L%xY>y6!J@{c3l!bY^F&>hg%@dSqG{+5` zE4-aaWE0hb+H_tHF}e*kiqSi+VPyDuDOBh%0Q9PS8x*jW{fsR42v9aN&nPoT-pD;Mch2^&mj&z{}% z?ao99Xsn)nJGGiN&%)>Y*vrk5Qd)>~eENEgD#>kYk?|f?-Dc2*)GlQs5fWw2%`c@1 zquNsRdGT*p7sf+aGhl!xV{-_s@?%#S8;Q64tRn2_{uzADxkbqN!l&w_!_$_;R zdnsX>lMrYouxu71`|g>Q3b*oO4VgPmk9>qyZ|=MVra^f>#y@Cjo3Le`o&ytIK5lE8sW9IS|@f;Q>AtCT=tq~<&h4j>sRb|(%9~?-avmzJm z(1;a^3(S5?{Nf<=?U3i=pJL|ad65!sGoKsxu4*RAB1&0F-Mi^-asgy-m6dR=Z{s3z zavmTR&xu&hAESu(?TAP1rOXTC!xu7C0j{|8YD=rQ4Ws+FS?yX}djleb%}G-Eu3=yv z)mED!6H2>+u;Zxto<8<9Xt45@(?|y8300?P{epb5UOjZ0LTRvgAN0Gwa^aTvL}hcM z)9UMLmZ(pjz3255xlAiZd9kwXYEhk{s~v3B#SbqriecXENeu4c8U6V)#W*Wz7YpRb zLVC&9vT1wI*EHG3Ct7ZF#!Rq;MAkc;sIb$ya+2Ek5XwAq3L;suewr*%>z-P4&q}Ax zsEye_4g<-Mn9J*lN*ADRlVX_0btd~eEGu;uXcR_)`tC!^^j(VFwKJ-BR~mm~ZhU>= zTNr;_cxu*T!7VxB&C=LX(l&Z7ZKcou4OcsR9#@%L%ez_E-QhOB!gpU@uEl@cTj}=6 zb4utCT-AslKou@|scXX_;#vr13|;2cdxC|i810%0mPcpmHEW+^gai|EgUzEXEtq-2 zG@*;-2-P_$0p0UO1rm9p^wUi_;7Y?p7k{1K!t4@hxiGazC{z9&y8B&~{Soo0+aYe{ z$lVfa8?;-5&uRUxp?d4ZgPt(ISdo%bOB#jOF+-ujYKA}jn`5q`)J-P4USjFJZw%MhK;@VL4a^|*giXyGFs{+74$5gu-fsXz^`$=Lh literal 0 HcmV?d00001 diff --git a/web/apps/portal/src/assets/images/ceremony/yd.png b/web/apps/portal/src/assets/images/ceremony/yd.png new file mode 100644 index 0000000000000000000000000000000000000000..426912d581d699afa00e95ad5e9c54d9cdda03f7 GIT binary patch literal 4629 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 literal 0 HcmV?d00001 diff --git a/web/apps/portal/src/assets/images/draw/draw1.png b/web/apps/portal/src/assets/images/draw/draw1.png new file mode 100644 index 0000000000000000000000000000000000000000..da5d87a3528ce62da611614d3cf3ed57c8b0288f GIT binary patch literal 11315 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 Q{0ji6DrrHR6s@EF2ReyiTmS$7 literal 0 HcmV?d00001 diff --git a/web/apps/portal/src/assets/images/favicon.ico b/web/apps/portal/src/assets/images/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..21e7063503b8c6eaaad646f6592ff0372aa0cf88 GIT binary patch literal 4286 zcmeH}S!`5Q7{^ZmX^4%bgOEf`9AQ5&B8oFA?$zA!I!YZ;>n%y{{bj?D_FL0{^85fX7IpmzXuNGUKjz zsqSE+ROd>RXb=$jMCCKi_Y1J^o>{Yy4P-D{>;kHF0vQD^a=K zC3Trya<9cDQT3sGR+kj8y2POE06$o;DK@zOg=nei?L=vA2-lZUFs&RMqsnuD(}d42 zC&ek}97tT+4$(7N5IMFPBF8tw#E+Zd$;mAcJDUl7LoPs{8RUMH&jND53Ubg2Fvx&3 zWPivHz!u&0FStoJG<7Wz)l+9O7X^JU$ z^a_tMg&IAGwT7g;Mx37u(Njt9lem{e#Ws*J{(y0Z0aS zw!$z(J!)PRE_$j;`cjxGw+4AslM zEK$F>zTAjvqQX5iqpl#k1=X6A&}fWG-DXb7yXt)^_Hx+qNEpe_5=Zj1*HFC5NMdfw zMy^Ufs?`?eTlFr~VxFhilES7;RErUy71fTqifoj0)y(sqj?YBDty;#KSL$v6<4$WH z^Dcy)-;IS1ElPxoHU7AF7lCtY2y9?8`()N);2Ps1N*xZ z)nx{;i)oE@7Ovp^J2px|=1(Q>*zd`HbNs@N6sB_{`5Idt#N0OW#N&LKa~pb+Y#r8F zvc9D{WKuSbH}&0$bwxdQAiERQjWvGF3{sDIjO%f36?$ysK~o8lf{rib0Y;{EFAv+| ze0azEaNSHE4o-Vl`Qh^)5#D1U@oGko=f@)Um&TvUxkPh~Y*}F=AK5bPZ~BhuMelvK zN8U2|wLS7SM@_89dp zp?9U$sP{HhN0JSc-Gh5Tw$yLE&iV4wdEX9Yxk%3*f?OgD=DjC|?A2np;7wt8y5>~o z+%xNCNWyD>jjuKRmaIA2V>Iu`#d@RO>IZSTcIifvVXz-vipMW=;uwLsvvChcSK9^{((VC;)X`OeeF~>8$-=uz~hV>V} z$oqF{%SJBf$XJPC+2mz?j^H&M7Mjh@L@TqBZ!u|e78*oron)eR$8w?G+@N@;{3-9W z=9HXc+@TNq&1bpC`SRWp{B~`*`=w6u$N4>rqZf5wa}_z0Vx122W)OrfOI575C@tPo zsLfqyz&Dx*+sI^yO~${Ibw|A?W3Sd5{;tZR-~QEKzn)Fu2X|{rO_W&)>Q(%IVN~$s zV*K=2tN7i1OPmilbwXfzuiBU93?>>2Ntfqz3g2uwDygx@y3@SV-*i6{5C#fYi379y zIa+Qa(Q@-U`1>4@>d|{U_G>T3EocET!ZtIj#TL_WvhI*idky0* z`^-`FVwHS)^4Ra1wLr|n;CtP+lJ^>Y@)cxvW95rh zk=t?!*T&>YS4~l+?r6WEchP6PFcH|5IvIF6m-icX6KLN`;*Iu9vE9;%{drdk+J@PI zyj^#FA1)dX?wLV(_At>|NTMCZOT`XLi4uF->ptu6@qWj8*0*pR$6EIinAkuP-9^jA zK1VtqELu6{Kb$hrxBuC|d7et$_#WwUtQNbipNqY=W4zC~1%QypzE9_w{ruMR2>gE{ z0P`G2=ihz`-ltmJiy9>)b(Abgi;$(Lw6!c*yH+IWX(Gu;l@%RH1tO-xQ8^X9gf#HY I1HxPX0yQi+Qvd(} literal 0 HcmV?d00001 diff --git a/web/apps/portal/src/assets/images/lock/bg_dark.webp b/web/apps/portal/src/assets/images/lock/bg_dark.webp new file mode 100644 index 0000000000000000000000000000000000000000..1c334354f0145e7ae1d91c8d51022fb8067d0b83 GIT binary patch literal 70592 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